fix(vector): harden backup restore parity gates
This commit is contained in:
@@ -62,3 +62,24 @@ comparison and create/restore the selected objects. This is intentionally an adm
|
||||
recovery operation, not a runtime reader/writer action. `--force-nonempty` is explicit but still
|
||||
uses `pg_restore --clean --if-exists`; operators should prefer a new database/volume and validate
|
||||
migration status, health, and known retrieval before endpoint cutover.
|
||||
|
||||
## Post-review hardening
|
||||
|
||||
All five final review findings were addressed in a follow-up commit:
|
||||
|
||||
- Restore now requires a physically separate PostgreSQL cluster and refuses any equal
|
||||
`system_identifier`, independent of database OID or hostname.
|
||||
- `pg_restore` combines `--single-transaction` with `--exit-on-error`. The live drill creates an
|
||||
existing vector sentinel, deliberately fails late during a forced restore, and proves the
|
||||
original sentinel row/hash remains unchanged before performing the successful restore.
|
||||
- Backup uses a mode-0600 `mktemp` in the output directory, atomically renames it, and cleans only
|
||||
that owned path. A fake-command test pins symlink-clobber resistance and preserves an adversarial
|
||||
legacy `.partial` symlink and its target.
|
||||
- HTTP parity now traverses the real `VectorRestClient` transport boundary. It asserts RPC URL/key
|
||||
and kinds payloads, legacy 404 fallback, response conversion, malformed metadata tolerance, and
|
||||
canonical `VectorRestError` to `VectorStoreError` mapping.
|
||||
- The restored target runs role/secret reconciliation and a real `PgVectorStore` with separate
|
||||
reader/writer logins. Health, known-record search, writer upsert, hash probe, schema/table/column/
|
||||
sequence authority, and 768-dimensional compatibility are therefore verified through the
|
||||
production adapter. Reconciliation now restores group-role schema `USAGE`, which table-selected
|
||||
archives cannot carry.
|
||||
|
||||
@@ -67,9 +67,10 @@ The dump contains the three allowlisted `vectors` tables, their data and ACLs, p
|
||||
provision/reconcile the approved role names on the target first, and install the `vector`
|
||||
extension in its `vectors` schema. The target must otherwise contain no vector tables or ledger.
|
||||
|
||||
Restore always names both the currently active source and a distinct target. The script compares
|
||||
PostgreSQL cluster identity plus database OID, so host aliases cannot bypass the active-database
|
||||
guard. It refuses a non-empty target unless `--force-nonempty` is explicit:
|
||||
Restore always names both the currently active source and a target on a physically distinct
|
||||
PostgreSQL cluster. The script compares PostgreSQL system identity, so host aliases or a different
|
||||
database in the active cluster cannot bypass the guard. It refuses a non-empty target unless
|
||||
`--force-nonempty` is explicit, and the clean restore is one transaction:
|
||||
|
||||
```sh
|
||||
./scripts/vector-restore.sh \
|
||||
|
||||
@@ -48,5 +48,6 @@ SELECT format('ALTER DATABASE %I OWNER TO %I', current_database(), :'migrator_us
|
||||
SELECT format('CREATE SCHEMA IF NOT EXISTS vectors AUTHORIZATION %I', :'migrator_user') \gexec
|
||||
SELECT format('ALTER SCHEMA vectors OWNER TO %I', :'migrator_user') \gexec
|
||||
REVOKE ALL ON SCHEMA vectors FROM PUBLIC;
|
||||
GRANT USAGE ON SCHEMA vectors TO vector_reader, vector_writer;
|
||||
CREATE EXTENSION IF NOT EXISTS vector WITH SCHEMA vectors;
|
||||
SQL
|
||||
|
||||
@@ -6,8 +6,9 @@ from testcontainers.postgres import PostgresContainer
|
||||
|
||||
from tht.adapters.vector.pgvector import PgVectorStore
|
||||
from tht.adapters.vector.thoth_http import ThothHttpVectorStore
|
||||
from tht.config import DatabaseConfig
|
||||
from tht.config import DatabaseConfig, RestConfig
|
||||
from tht.ports.vector import VectorRecord, VectorStoreError, VectorWriteRecord
|
||||
from tht.vectorstore.rest_client import VectorRestClient
|
||||
|
||||
|
||||
def _write(record_id, kind, embedding, content_hash):
|
||||
@@ -32,26 +33,46 @@ FIXTURE = [
|
||||
]
|
||||
|
||||
|
||||
class FixtureHttpClient:
|
||||
class Response:
|
||||
def __init__(self, payload=None, status=200):
|
||||
self.status_code = status
|
||||
self.payload = payload
|
||||
self.text = "" if payload is None else "json"
|
||||
|
||||
@property
|
||||
def ok(self):
|
||||
return self.status_code < 400
|
||||
|
||||
def json(self):
|
||||
return self.payload
|
||||
|
||||
|
||||
class FixtureHttpTransport:
|
||||
def __init__(self):
|
||||
self.rows = {}
|
||||
self.calls = []
|
||||
|
||||
def list_tables(self):
|
||||
return [{"table_name": "memory", "vector_dimensions": 2}]
|
||||
def post(self, url, json, headers, **kwargs):
|
||||
assert headers == {"X-API-Key": "parity-key"}
|
||||
self.calls.append((url.rsplit("/", 1)[-1], json))
|
||||
function = self.calls[-1][0]
|
||||
if function == "list_tables":
|
||||
return Response([{"table_name": "memory", "vector_dimensions": 2}])
|
||||
if function == "upsert_vector_records":
|
||||
for row in json["rows"]:
|
||||
self.rows[(json["table_name"], row["record_key"])] = row
|
||||
return Response({"upserted": len(json["rows"])})
|
||||
if function == "existing_vector_hashes":
|
||||
return Response([
|
||||
{"record_key": row["record_key"], "content_hash": row["content_hash"]}
|
||||
for (table, _), row in self.rows.items()
|
||||
if table == json["table_name"] and row["kind"] in json["kinds"]
|
||||
])
|
||||
assert function == "search_similar"
|
||||
table_name = json["table_name"]
|
||||
embedding = json["query_embedding"]
|
||||
kinds = json.get("kinds")
|
||||
|
||||
def upsert_records(self, table_name, rows):
|
||||
for row in rows:
|
||||
self.rows[(table_name, row["record_key"])] = row
|
||||
return len(rows)
|
||||
|
||||
def existing_hashes(self, table_name, kinds):
|
||||
return {
|
||||
row["record_key"]: row["content_hash"]
|
||||
for (table, _), row in self.rows.items()
|
||||
if table == table_name and row["kind"] in kinds
|
||||
}
|
||||
|
||||
def search_similar(self, table_name, embedding, limit, kinds=None):
|
||||
def similarity(row):
|
||||
left, right = row["embedding"], embedding
|
||||
return sum(a * b for a, b in zip(left, right)) / (
|
||||
@@ -64,10 +85,11 @@ class FixtureHttpClient:
|
||||
for (table, _), row in self.rows.items()
|
||||
if table == table_name and (not kinds or row["kind"] in kinds)
|
||||
]
|
||||
return sorted(
|
||||
payload = sorted(
|
||||
rows,
|
||||
key=lambda row: (-row["similarity"], row["metadata"]["record_key"]),
|
||||
)[:limit]
|
||||
)[: json["limit_count"]]
|
||||
return Response(payload)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -100,10 +122,13 @@ def direct_store():
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def http_store():
|
||||
client = FixtureHttpClient()
|
||||
def http_store(monkeypatch):
|
||||
transport = FixtureHttpTransport()
|
||||
monkeypatch.setattr("tht.vectorstore.rest_client.requests.post", transport.post)
|
||||
client = VectorRestClient(RestConfig(base_url="https://vectors.test", api_key="parity-key"))
|
||||
store = ThothHttpVectorStore(client, client, expected_dimension=2)
|
||||
store.upsert("memory", FIXTURE)
|
||||
store.transport = transport
|
||||
return store
|
||||
|
||||
|
||||
@@ -146,3 +171,58 @@ def test_dimension_error_parity(request, store_fixture):
|
||||
store.search(["memory"], [1.0], limit=1)
|
||||
with pytest.raises(VectorStoreError, match="Embedding dimension"):
|
||||
store.upsert("memory", [_write("bad", "memory", [1.0], "bad")])
|
||||
|
||||
|
||||
def test_http_parity_exercises_rpc_kinds_payload(http_store):
|
||||
http_store.search(["memory"], [1.0, 0.0], limit=2, kinds=["memory"])
|
||||
search_calls = [payload for function, payload in http_store.transport.calls if function == "search_similar"]
|
||||
assert search_calls[-1] == {
|
||||
"query_embedding": [1.0, 0.0],
|
||||
"limit_count": 2,
|
||||
"table_name": "memory",
|
||||
"kinds": ["memory"],
|
||||
}
|
||||
|
||||
|
||||
def test_http_adapter_maps_transport_error(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"tht.vectorstore.rest_client.requests.post",
|
||||
lambda *args, **kwargs: Response({"message": "server broke"}, status=500),
|
||||
)
|
||||
client = VectorRestClient(RestConfig(base_url="https://vectors.test", api_key="parity-key"))
|
||||
store = ThothHttpVectorStore(client, client, expected_dimension=2)
|
||||
with pytest.raises(VectorStoreError, match="HTTP 500"):
|
||||
store.search(["memory"], [1.0, 0.0], limit=1, kinds=["memory"])
|
||||
|
||||
|
||||
def test_http_adapter_tolerates_malformed_metadata(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"tht.vectorstore.rest_client.requests.post",
|
||||
lambda *args, **kwargs: Response([{"similarity": 0.5, "metadata": None}]),
|
||||
)
|
||||
client = VectorRestClient(RestConfig(base_url="https://vectors.test", api_key="parity-key"))
|
||||
hit = ThothHttpVectorStore(client, None, expected_dimension=2).search(
|
||||
["memory"], [1.0, 0.0], limit=1
|
||||
)[0]
|
||||
assert (hit.id, hit.kind, hit.metadata) == ("", "", {})
|
||||
|
||||
|
||||
def test_http_adapter_legacy_fallback_preserves_kind_semantics(monkeypatch):
|
||||
calls = []
|
||||
|
||||
def post(url, json, **kwargs):
|
||||
calls.append(json)
|
||||
if "kinds" in json:
|
||||
return Response({"message": "function not found"}, status=404)
|
||||
return Response([
|
||||
{"similarity": 1.0, "metadata": {"record_key": "wrong", "kind": "solved_question"}},
|
||||
{"similarity": 0.9, "metadata": {"record_key": "right", "kind": "memory"}},
|
||||
])
|
||||
|
||||
monkeypatch.setattr("tht.vectorstore.rest_client.requests.post", post)
|
||||
client = VectorRestClient(RestConfig(base_url="https://vectors.test", api_key="parity-key"))
|
||||
hits = ThothHttpVectorStore(client, None, expected_dimension=2).search(
|
||||
["memory"], [1.0, 0.0], limit=2, kinds=["memory"]
|
||||
)
|
||||
assert [hit.id for hit in hits] == ["right"]
|
||||
assert "kinds" in calls[0] and "kinds" not in calls[1]
|
||||
|
||||
@@ -10,7 +10,7 @@ from tht.ports.vector import (
|
||||
VectorWriteUnavailable,
|
||||
require_positive_limit,
|
||||
)
|
||||
from tht.vectorstore.rest_client import VectorRestClient
|
||||
from tht.vectorstore.rest_client import VectorRestClient, VectorRestError
|
||||
from tht.vectorstore.store import hit_from_metadata
|
||||
from tht.adapters.vector.pgvector import (
|
||||
_collection,
|
||||
@@ -102,7 +102,10 @@ class ThothHttpVectorStore:
|
||||
hits: list[VectorHit] = []
|
||||
for collection in collections:
|
||||
_collection("vectors", collection)
|
||||
rows = self._reader.search_similar(collection, embedding, limit, kinds=kinds)
|
||||
try:
|
||||
rows = self._reader.search_similar(collection, embedding, limit, kinds=kinds)
|
||||
except VectorRestError as exc:
|
||||
raise VectorStoreError(str(exc)) from exc
|
||||
hits.extend(
|
||||
hit_from_metadata(row.get("similarity", 0.0), row.get("metadata"))
|
||||
for row in rows
|
||||
@@ -120,7 +123,10 @@ class ThothHttpVectorStore:
|
||||
def existing_hashes(self, collection: str, kinds: list[str]) -> dict[str, str]:
|
||||
_collection("vectors", collection)
|
||||
_validate_collection_kinds(collection, kinds)
|
||||
return self._require_writer().existing_hashes(collection, kinds)
|
||||
try:
|
||||
return self._require_writer().existing_hashes(collection, kinds)
|
||||
except VectorRestError as exc:
|
||||
raise VectorStoreError(str(exc)) from exc
|
||||
|
||||
def upsert(self, collection: str, records: list[VectorWriteRecord]) -> int:
|
||||
writer = self._require_writer()
|
||||
@@ -133,7 +139,10 @@ class ThothHttpVectorStore:
|
||||
):
|
||||
raise VectorStoreError("Embedding dimension does not match configured dimension")
|
||||
rows = [self._row(record) for record in records]
|
||||
return writer.upsert_records(collection, rows)
|
||||
try:
|
||||
return writer.upsert_records(collection, rows)
|
||||
except VectorRestError as exc:
|
||||
raise VectorStoreError(str(exc)) from exc
|
||||
|
||||
@staticmethod
|
||||
def _row(write_record: VectorWriteRecord) -> dict:
|
||||
|
||||
@@ -277,7 +277,13 @@ if [ "$mode" = "--backup-restore" ]; then
|
||||
docker exec -e PGPASSWORD="$new_bootstrap_password" "$restore_container" psql -X \
|
||||
-U "$THT_VECTOR_BOOTSTRAP_USER" -d thoth -v ON_ERROR_STOP=1 --command \
|
||||
"CREATE SCHEMA vectors; CREATE EXTENSION vector WITH SCHEMA vectors;
|
||||
CREATE ROLE vector_reader NOLOGIN; CREATE ROLE vector_writer NOLOGIN;" >/dev/null
|
||||
CREATE TABLE vectors.memory (
|
||||
id bigserial PRIMARY KEY, record_key text UNIQUE NOT NULL, kind text NOT NULL,
|
||||
content_hash text NOT NULL, metadata jsonb NOT NULL,
|
||||
embedding vectors.vector(768) NOT NULL, indexed_at timestamptz NOT NULL DEFAULT now());
|
||||
INSERT INTO vectors.memory (record_key, kind, content_hash, metadata, embedding)
|
||||
VALUES ('restore-sentinel', 'memory', 'sentinel-original', '{}',
|
||||
('[' || '1,' || repeat('0,', 766) || '0]')::vectors.vector);" >/dev/null
|
||||
|
||||
docker run --rm --network "$network" \
|
||||
--mount "type=bind,source=$(pwd),target=/repo,readonly" \
|
||||
@@ -292,6 +298,27 @@ if [ "$mode" = "--backup-restore" ]; then
|
||||
"UPDATE vectors.memory SET content_hash = '\''mutated-after-backup'\'' WHERE record_key = '\''$1'\''"' \
|
||||
sh "$marker" >/dev/null
|
||||
|
||||
if docker run --rm --network "$network" \
|
||||
--mount "type=bind,source=$(pwd),target=/repo,readonly" \
|
||||
--mount "type=bind,source=$secret_dir,target=/scratch" "$image" \
|
||||
/repo/scripts/vector-restore.sh \
|
||||
--active-host vector-db --active-database thoth --active-user "$THT_VECTOR_BOOTSTRAP_USER" \
|
||||
--active-password-file /scratch/bootstrap \
|
||||
--target-host vector-db-restore --target-database thoth \
|
||||
--target-user "$THT_VECTOR_BOOTSTRAP_USER" --target-password-file /scratch/bootstrap \
|
||||
--input /scratch/vector.dump --force-nonempty >/dev/null 2>&1; then
|
||||
echo "forced restore unexpectedly succeeded without archived ACL roles" >&2
|
||||
exit 1
|
||||
fi
|
||||
sentinel=$(docker exec -e PGPASSWORD="$new_bootstrap_password" "$restore_container" psql \
|
||||
-XAt -U "$THT_VECTOR_BOOTSTRAP_USER" -d thoth --command \
|
||||
"SELECT content_hash FROM vectors.memory WHERE record_key='restore-sentinel'")
|
||||
test "$sentinel" = sentinel-original
|
||||
docker exec -e PGPASSWORD="$new_bootstrap_password" "$restore_container" psql -X \
|
||||
-U "$THT_VECTOR_BOOTSTRAP_USER" -d thoth -v ON_ERROR_STOP=1 --command \
|
||||
"DROP TABLE vectors.memory; CREATE ROLE vector_reader NOLOGIN; CREATE ROLE vector_writer NOLOGIN;" \
|
||||
>/dev/null
|
||||
|
||||
docker run --rm --network "$network" \
|
||||
--mount "type=bind,source=$(pwd),target=/repo,readonly" \
|
||||
--mount "type=bind,source=$secret_dir,target=/scratch" "$image" \
|
||||
@@ -302,6 +329,53 @@ if [ "$mode" = "--backup-restore" ]; then
|
||||
--target-user "$THT_VECTOR_BOOTSTRAP_USER" --target-password-file /scratch/bootstrap \
|
||||
--input /scratch/vector.dump
|
||||
|
||||
docker run --rm --network "$network" \
|
||||
--mount "type=bind,source=$(pwd)/deploy/vector/reconcile-roles.sh,target=/opt/thoth/reconcile-roles.sh,readonly" \
|
||||
--mount "type=bind,source=$(pwd)/deploy/vector/secret-policy.sh,target=/opt/thoth/secret-policy.sh,readonly" \
|
||||
--mount "type=bind,source=$secret_dir/bootstrap,target=/run/secrets/vector_bootstrap_password,readonly" \
|
||||
--mount "type=bind,source=$secret_dir/migrator,target=/run/secrets/vector_migrator_password,readonly" \
|
||||
--mount "type=bind,source=$secret_dir/reader,target=/run/secrets/vector_reader_password,readonly" \
|
||||
--mount "type=bind,source=$secret_dir/writer,target=/run/secrets/vector_writer_password,readonly" \
|
||||
-e PGHOST=vector-db-restore -e PGDATABASE=thoth \
|
||||
-e PGUSER="$THT_VECTOR_BOOTSTRAP_USER" \
|
||||
-e THT_VECTOR_MIGRATOR_USER=thoth_vector_migrator \
|
||||
-e THT_VECTOR_READER_USER=thoth_vector_reader \
|
||||
-e THT_VECTOR_WRITER_USER=thoth_vector_writer \
|
||||
--entrypoint /opt/thoth/reconcile-roles.sh "$image" >/dev/null
|
||||
|
||||
docker run --rm -i --network "$network" \
|
||||
-e THT_VECTOR_READER_PASSWORD="$THT_VECTOR_READER_PASSWORD" \
|
||||
-e THT_VECTOR_WRITER_PASSWORD="$THT_VECTOR_WRITER_PASSWORD" \
|
||||
--entrypoint /opt/venv/bin/python thothii-core:local - "$marker" <<'PY'
|
||||
import hashlib
|
||||
import os
|
||||
import sys
|
||||
|
||||
from tht.adapters.vector.pgvector import PgVectorStore
|
||||
from tht.config import DatabaseConfig
|
||||
from tht.ports.vector import VectorWriteRecord
|
||||
from tht.vectorstore.records import VectorRecord
|
||||
|
||||
def config(role):
|
||||
return DatabaseConfig(
|
||||
host="vector-db-restore", port=5432, database="thoth", schema="vectors",
|
||||
user=f"thoth_vector_{role}", password=os.environ[f"THT_VECTOR_{role.upper()}_PASSWORD"],
|
||||
)
|
||||
|
||||
store = PgVectorStore(config("reader"), config("writer"), expected_dimension=768)
|
||||
assert store.health().ok, store.health()
|
||||
embedding = [1.0] + [0.0] * 767
|
||||
marker = sys.argv[1]
|
||||
assert store.search(["memory"], embedding, limit=1, kinds=["memory"])[0].id == marker
|
||||
write_id = marker + "-restore-write"
|
||||
record = VectorRecord(
|
||||
id=write_id, kind="memory", ref=write_id, title="restore writer",
|
||||
content=write_id, metadata={},
|
||||
)
|
||||
store.upsert("memory", [VectorWriteRecord(record, embedding, hashlib.sha256(write_id.encode()).hexdigest())])
|
||||
assert store.existing_hashes("memory", ["memory"])[write_id]
|
||||
PY
|
||||
|
||||
restored=$(docker exec -e PGPASSWORD="$new_bootstrap_password" "$restore_container" psql \
|
||||
-XAt -U "$THT_VECTOR_BOOTSTRAP_USER" -d thoth --command \
|
||||
"SELECT content_hash <> 'mutated-after-backup' FROM vectors.memory WHERE record_key = '$marker'")
|
||||
@@ -316,7 +390,7 @@ if [ "$mode" = "--backup-restore" ]; then
|
||||
JOIN pg_namespace n ON n.oid=c.relnamespace
|
||||
WHERE n.nspname='vectors' AND a.attname='embedding' AND format_type(a.atttypid,a.atttypmod)='vectors.vector(768)'")
|
||||
test "$dimensions" = t
|
||||
echo "Disposable-volume backup, mutation, restore, ledger, health, and retrieval parity passed."
|
||||
echo "Transactional rollback and disposable-volume restore adapter parity passed."
|
||||
fi
|
||||
|
||||
echo "Local pgvector runtime/bootstrap rotation, least-privilege roles, and persistence passed."
|
||||
|
||||
Executable
+72
@@ -0,0 +1,72 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp"' EXIT HUP INT TERM
|
||||
fakebin="$tmp/bin"
|
||||
mkdir "$fakebin"
|
||||
printf '%s' secret >"$tmp/password"
|
||||
|
||||
cat >"$fakebin/pg_dump" <<'SH'
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
for arg in "$@"; do case "$arg" in --file=*) output=${arg#--file=} ;; esac; done
|
||||
printf 'custom dump' >"$output"
|
||||
SH
|
||||
chmod 0755 "$fakebin/pg_dump"
|
||||
|
||||
victim="$tmp/victim"
|
||||
output="$tmp/vector.dump"
|
||||
printf 'sentinel' >"$victim"
|
||||
ln -s "$victim" "$output.partial"
|
||||
PATH="$fakebin:$PATH" ./scripts/vector-backup.sh --host source --database thoth --user admin \
|
||||
--password-file "$tmp/password" --output "$output" >/dev/null
|
||||
test "$(cat "$victim")" = sentinel
|
||||
test "$(cat "$output")" = 'custom dump'
|
||||
test -L "$output.partial"
|
||||
|
||||
cat >"$fakebin/psql" <<'SH'
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
case "$*" in
|
||||
*pg_control_system*)
|
||||
echo same-cluster ;;
|
||||
*) echo 0 ;;
|
||||
esac
|
||||
SH
|
||||
cat >"$fakebin/pg_restore" <<'SH'
|
||||
#!/bin/sh
|
||||
printf '%s\n' "$*" >"$RESTORE_LOG"
|
||||
SH
|
||||
chmod 0755 "$fakebin/psql" "$fakebin/pg_restore"
|
||||
printf 'archive' >"$tmp/input"
|
||||
if PATH="$fakebin:$PATH" RESTORE_LOG="$tmp/restore.log" ./scripts/vector-restore.sh \
|
||||
--active-host source --active-database active --active-user admin \
|
||||
--active-password-file "$tmp/password" --target-host target --target-database restore \
|
||||
--target-user admin --target-password-file "$tmp/password" --input "$tmp/input" \
|
||||
>"$tmp/out" 2>"$tmp/err"; then
|
||||
echo "restore accepted a target on the active PostgreSQL cluster" >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -q 'same PostgreSQL cluster' "$tmp/err"
|
||||
test ! -e "$tmp/restore.log"
|
||||
|
||||
cat >"$fakebin/psql" <<'SH'
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
case "$*" in
|
||||
*pg_control_system*)
|
||||
case "$*" in *--host=source*) echo same-cluster ;; *) echo other-cluster ;; esac ;;
|
||||
*) echo 0 ;;
|
||||
esac
|
||||
SH
|
||||
chmod 0755 "$fakebin/psql"
|
||||
PATH="$fakebin:$PATH" RESTORE_LOG="$tmp/restore.log" ./scripts/vector-restore.sh \
|
||||
--active-host source --active-database active --active-user admin \
|
||||
--active-password-file "$tmp/password" --target-host target --target-database restore \
|
||||
--target-user admin --target-password-file "$tmp/password" --input "$tmp/input" >/dev/null
|
||||
grep -q -- '--single-transaction' "$tmp/restore.log"
|
||||
grep -q -- '--exit-on-error' "$tmp/restore.log"
|
||||
|
||||
echo "vector backup/restore filesystem, identity, and transaction contracts passed."
|
||||
@@ -25,12 +25,16 @@ done
|
||||
[ -n "$password_file" ] && [ -n "$output" ] || usage
|
||||
validate_secret_file "$password_file" "backup password file"
|
||||
[ ! -e "$output" ] || { echo "refusing to overwrite existing backup: $output" >&2; exit 2; }
|
||||
output_dir=$(dirname "$output")
|
||||
output_name=$(basename "$output")
|
||||
[ -d "$output_dir" ] || { echo "backup destination directory does not exist" >&2; exit 2; }
|
||||
|
||||
password=$(read_secret_file "$password_file" "backup password file")
|
||||
|
||||
umask 077
|
||||
passfile=$(mktemp "${TMPDIR:-/tmp}/thoth-vector-pgpass.XXXXXX")
|
||||
cleanup() { rm -f "$passfile" "$output.partial"; }
|
||||
temporary_output=$(mktemp "$output_dir/.${output_name}.tmp.XXXXXX")
|
||||
cleanup() { rm -f "$passfile" "$temporary_output"; }
|
||||
trap cleanup EXIT HUP INT TERM
|
||||
escaped=$(printf '%s' "$password" | sed 's/\\/\\\\/g; s/:/\\:/g')
|
||||
printf '%s:%s:%s:%s:%s\n' "$host" "$port" "$database" "$user" "$escaped" >"$passfile"
|
||||
@@ -40,6 +44,6 @@ PGPASSFILE=$passfile pg_dump \
|
||||
--host="$host" --port="$port" --username="$user" --dbname="$database" \
|
||||
--format=custom --compress=9 \
|
||||
--table=vectors.schema_records --table=vectors.evidence --table=vectors.memory \
|
||||
--table=public.tht_vector_migrations --file="$output.partial"
|
||||
mv "$output.partial" "$output"
|
||||
--table=public.tht_vector_migrations --file="$temporary_output"
|
||||
mv "$temporary_output" "$output"
|
||||
echo "Vector backup written: $output"
|
||||
|
||||
@@ -53,13 +53,14 @@ make_passfile "$active_host" "$active_port" "$active_database" "$active_user" \
|
||||
make_passfile "$target_host" "$target_port" "$target_database" "$target_user" \
|
||||
"$target_password_file" "$target_pass"
|
||||
|
||||
identity_sql="SELECT system_identifier::text || ':' || d.oid::text FROM pg_control_system(), pg_database d WHERE d.datname = current_database()"
|
||||
identity_sql="SELECT system_identifier::text FROM pg_control_system()"
|
||||
active_identity=$(PGPASSFILE=$active_pass psql -XAt --host="$active_host" --port="$active_port" \
|
||||
--username="$active_user" --dbname="$active_database" --command="$identity_sql")
|
||||
target_identity=$(PGPASSFILE=$target_pass psql -XAt --host="$target_host" --port="$target_port" \
|
||||
--username="$target_user" --dbname="$target_database" --command="$identity_sql")
|
||||
[ "$active_identity" != "$target_identity" ] || {
|
||||
echo "refusing restore: active source and target are the same database" >&2; exit 2;
|
||||
echo "refusing restore: active source and target are on the same PostgreSQL cluster" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
object_count=$(PGPASSFILE=$target_pass psql -XAt --host="$target_host" --port="$target_port" \
|
||||
@@ -72,7 +73,8 @@ if [ "$object_count" != 0 ] && [ "$force" != 1 ]; then
|
||||
exit 2
|
||||
fi
|
||||
|
||||
PGPASSFILE=$target_pass pg_restore --exit-on-error --clean --if-exists --no-owner \
|
||||
PGPASSFILE=$target_pass pg_restore --exit-on-error --single-transaction \
|
||||
--clean --if-exists --no-owner \
|
||||
--host="$target_host" --port="$target_port" --username="$target_user" \
|
||||
--dbname="$target_database" "$input"
|
||||
echo "Vector restore completed into explicit target $target_host:$target_port/$target_database"
|
||||
|
||||
Reference in New Issue
Block a user