From 015c496bda066521a47ddca8bd22ed0e564497be Mon Sep 17 00:00:00 2001 From: mptyl Date: Sun, 12 Jul 2026 02:29:53 +0200 Subject: [PATCH] fix(vector): harden backup restore parity gates --- .superpowers/sdd/pgvector-task-4-report.md | 21 +++ README.md | 7 +- deploy/vector/reconcile-roles.sh | 1 + .../tests/l0/test_vector_adapter_parity.py | 122 +++++++++++++++--- harness/tht/adapters/vector/thoth_http.py | 17 ++- scripts/local-vector-smoke.sh | 78 ++++++++++- scripts/test-vector-backup-restore-safety.sh | 72 +++++++++++ scripts/vector-backup.sh | 10 +- scripts/vector-restore.sh | 8 +- 9 files changed, 300 insertions(+), 36 deletions(-) create mode 100755 scripts/test-vector-backup-restore-safety.sh diff --git a/.superpowers/sdd/pgvector-task-4-report.md b/.superpowers/sdd/pgvector-task-4-report.md index 85c529be..1da90815 100644 --- a/.superpowers/sdd/pgvector-task-4-report.md +++ b/.superpowers/sdd/pgvector-task-4-report.md @@ -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. diff --git a/README.md b/README.md index b6e8b231..9aac8311 100644 --- a/README.md +++ b/README.md @@ -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 \ diff --git a/deploy/vector/reconcile-roles.sh b/deploy/vector/reconcile-roles.sh index ef3486d3..b2f09719 100755 --- a/deploy/vector/reconcile-roles.sh +++ b/deploy/vector/reconcile-roles.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 diff --git a/harness/tests/l0/test_vector_adapter_parity.py b/harness/tests/l0/test_vector_adapter_parity.py index d25fa785..deab75a7 100644 --- a/harness/tests/l0/test_vector_adapter_parity.py +++ b/harness/tests/l0/test_vector_adapter_parity.py @@ -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] diff --git a/harness/tht/adapters/vector/thoth_http.py b/harness/tht/adapters/vector/thoth_http.py index eabdd99b..3efeef85 100644 --- a/harness/tht/adapters/vector/thoth_http.py +++ b/harness/tht/adapters/vector/thoth_http.py @@ -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: diff --git a/scripts/local-vector-smoke.sh b/scripts/local-vector-smoke.sh index d852a6d8..7dab3d69 100755 --- a/scripts/local-vector-smoke.sh +++ b/scripts/local-vector-smoke.sh @@ -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." diff --git a/scripts/test-vector-backup-restore-safety.sh b/scripts/test-vector-backup-restore-safety.sh new file mode 100755 index 00000000..c553244e --- /dev/null +++ b/scripts/test-vector-backup-restore-safety.sh @@ -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." diff --git a/scripts/vector-backup.sh b/scripts/vector-backup.sh index 1262ab57..030880fd 100755 --- a/scripts/vector-backup.sh +++ b/scripts/vector-backup.sh @@ -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" diff --git a/scripts/vector-restore.sh b/scripts/vector-restore.sh index 58fa6ef0..0e910544 100755 --- a/scripts/vector-restore.sh +++ b/scripts/vector-restore.sh @@ -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"