fix(vector): harden backup restore parity gates

This commit is contained in:
2026-07-12 02:29:53 +02:00
parent e4db2ea5e1
commit 015c496bda
9 changed files with 300 additions and 36 deletions
@@ -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 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 uses `pg_restore --clean --if-exists`; operators should prefer a new database/volume and validate
migration status, health, and known retrieval before endpoint cutover. 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.
+4 -3
View File
@@ -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` 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. 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 Restore always names both the currently active source and a target on a physically distinct
PostgreSQL cluster identity plus database OID, so host aliases cannot bypass the active-database PostgreSQL cluster. The script compares PostgreSQL system identity, so host aliases or a different
guard. It refuses a non-empty target unless `--force-nonempty` is explicit: 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 ```sh
./scripts/vector-restore.sh \ ./scripts/vector-restore.sh \
+1
View File
@@ -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('CREATE SCHEMA IF NOT EXISTS vectors AUTHORIZATION %I', :'migrator_user') \gexec
SELECT format('ALTER SCHEMA vectors OWNER TO %I', :'migrator_user') \gexec SELECT format('ALTER SCHEMA vectors OWNER TO %I', :'migrator_user') \gexec
REVOKE ALL ON SCHEMA vectors FROM PUBLIC; 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; CREATE EXTENSION IF NOT EXISTS vector WITH SCHEMA vectors;
SQL SQL
+101 -21
View File
@@ -6,8 +6,9 @@ from testcontainers.postgres import PostgresContainer
from tht.adapters.vector.pgvector import PgVectorStore from tht.adapters.vector.pgvector import PgVectorStore
from tht.adapters.vector.thoth_http import ThothHttpVectorStore 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.ports.vector import VectorRecord, VectorStoreError, VectorWriteRecord
from tht.vectorstore.rest_client import VectorRestClient
def _write(record_id, kind, embedding, content_hash): 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): def __init__(self):
self.rows = {} self.rows = {}
self.calls = []
def list_tables(self): def post(self, url, json, headers, **kwargs):
return [{"table_name": "memory", "vector_dimensions": 2}] 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): def similarity(row):
left, right = row["embedding"], embedding left, right = row["embedding"], embedding
return sum(a * b for a, b in zip(left, right)) / ( return sum(a * b for a, b in zip(left, right)) / (
@@ -64,10 +85,11 @@ class FixtureHttpClient:
for (table, _), row in self.rows.items() for (table, _), row in self.rows.items()
if table == table_name and (not kinds or row["kind"] in kinds) if table == table_name and (not kinds or row["kind"] in kinds)
] ]
return sorted( payload = sorted(
rows, rows,
key=lambda row: (-row["similarity"], row["metadata"]["record_key"]), key=lambda row: (-row["similarity"], row["metadata"]["record_key"]),
)[:limit] )[: json["limit_count"]]
return Response(payload)
@pytest.fixture @pytest.fixture
@@ -100,10 +122,13 @@ def direct_store():
@pytest.fixture @pytest.fixture
def http_store(): def http_store(monkeypatch):
client = FixtureHttpClient() 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 = ThothHttpVectorStore(client, client, expected_dimension=2)
store.upsert("memory", FIXTURE) store.upsert("memory", FIXTURE)
store.transport = transport
return store return store
@@ -146,3 +171,58 @@ def test_dimension_error_parity(request, store_fixture):
store.search(["memory"], [1.0], limit=1) store.search(["memory"], [1.0], limit=1)
with pytest.raises(VectorStoreError, match="Embedding dimension"): with pytest.raises(VectorStoreError, match="Embedding dimension"):
store.upsert("memory", [_write("bad", "memory", [1.0], "bad")]) 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]
+13 -4
View File
@@ -10,7 +10,7 @@ from tht.ports.vector import (
VectorWriteUnavailable, VectorWriteUnavailable,
require_positive_limit, 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.vectorstore.store import hit_from_metadata
from tht.adapters.vector.pgvector import ( from tht.adapters.vector.pgvector import (
_collection, _collection,
@@ -102,7 +102,10 @@ class ThothHttpVectorStore:
hits: list[VectorHit] = [] hits: list[VectorHit] = []
for collection in collections: for collection in collections:
_collection("vectors", collection) _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( hits.extend(
hit_from_metadata(row.get("similarity", 0.0), row.get("metadata")) hit_from_metadata(row.get("similarity", 0.0), row.get("metadata"))
for row in rows for row in rows
@@ -120,7 +123,10 @@ class ThothHttpVectorStore:
def existing_hashes(self, collection: str, kinds: list[str]) -> dict[str, str]: def existing_hashes(self, collection: str, kinds: list[str]) -> dict[str, str]:
_collection("vectors", collection) _collection("vectors", collection)
_validate_collection_kinds(collection, kinds) _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: def upsert(self, collection: str, records: list[VectorWriteRecord]) -> int:
writer = self._require_writer() writer = self._require_writer()
@@ -133,7 +139,10 @@ class ThothHttpVectorStore:
): ):
raise VectorStoreError("Embedding dimension does not match configured dimension") raise VectorStoreError("Embedding dimension does not match configured dimension")
rows = [self._row(record) for record in records] 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 @staticmethod
def _row(write_record: VectorWriteRecord) -> dict: def _row(write_record: VectorWriteRecord) -> dict:
+76 -2
View File
@@ -277,7 +277,13 @@ if [ "$mode" = "--backup-restore" ]; then
docker exec -e PGPASSWORD="$new_bootstrap_password" "$restore_container" psql -X \ docker exec -e PGPASSWORD="$new_bootstrap_password" "$restore_container" psql -X \
-U "$THT_VECTOR_BOOTSTRAP_USER" -d thoth -v ON_ERROR_STOP=1 --command \ -U "$THT_VECTOR_BOOTSTRAP_USER" -d thoth -v ON_ERROR_STOP=1 --command \
"CREATE SCHEMA vectors; CREATE EXTENSION vector WITH SCHEMA vectors; "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" \ docker run --rm --network "$network" \
--mount "type=bind,source=$(pwd),target=/repo,readonly" \ --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'\''"' \ "UPDATE vectors.memory SET content_hash = '\''mutated-after-backup'\'' WHERE record_key = '\''$1'\''"' \
sh "$marker" >/dev/null 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" \ docker run --rm --network "$network" \
--mount "type=bind,source=$(pwd),target=/repo,readonly" \ --mount "type=bind,source=$(pwd),target=/repo,readonly" \
--mount "type=bind,source=$secret_dir,target=/scratch" "$image" \ --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 \ --target-user "$THT_VECTOR_BOOTSTRAP_USER" --target-password-file /scratch/bootstrap \
--input /scratch/vector.dump --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 \ restored=$(docker exec -e PGPASSWORD="$new_bootstrap_password" "$restore_container" psql \
-XAt -U "$THT_VECTOR_BOOTSTRAP_USER" -d thoth --command \ -XAt -U "$THT_VECTOR_BOOTSTRAP_USER" -d thoth --command \
"SELECT content_hash <> 'mutated-after-backup' FROM vectors.memory WHERE record_key = '$marker'") "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 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)'") WHERE n.nspname='vectors' AND a.attname='embedding' AND format_type(a.atttypid,a.atttypmod)='vectors.vector(768)'")
test "$dimensions" = t 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 fi
echo "Local pgvector runtime/bootstrap rotation, least-privilege roles, and persistence passed." echo "Local pgvector runtime/bootstrap rotation, least-privilege roles, and persistence passed."
+72
View File
@@ -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."
+7 -3
View File
@@ -25,12 +25,16 @@ done
[ -n "$password_file" ] && [ -n "$output" ] || usage [ -n "$password_file" ] && [ -n "$output" ] || usage
validate_secret_file "$password_file" "backup password file" validate_secret_file "$password_file" "backup password file"
[ ! -e "$output" ] || { echo "refusing to overwrite existing backup: $output" >&2; exit 2; } [ ! -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") password=$(read_secret_file "$password_file" "backup password file")
umask 077 umask 077
passfile=$(mktemp "${TMPDIR:-/tmp}/thoth-vector-pgpass.XXXXXX") 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 trap cleanup EXIT HUP INT TERM
escaped=$(printf '%s' "$password" | sed 's/\\/\\\\/g; s/:/\\:/g') escaped=$(printf '%s' "$password" | sed 's/\\/\\\\/g; s/:/\\:/g')
printf '%s:%s:%s:%s:%s\n' "$host" "$port" "$database" "$user" "$escaped" >"$passfile" 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" \ --host="$host" --port="$port" --username="$user" --dbname="$database" \
--format=custom --compress=9 \ --format=custom --compress=9 \
--table=vectors.schema_records --table=vectors.evidence --table=vectors.memory \ --table=vectors.schema_records --table=vectors.evidence --table=vectors.memory \
--table=public.tht_vector_migrations --file="$output.partial" --table=public.tht_vector_migrations --file="$temporary_output"
mv "$output.partial" "$output" mv "$temporary_output" "$output"
echo "Vector backup written: $output" echo "Vector backup written: $output"
+5 -3
View File
@@ -53,13 +53,14 @@ make_passfile "$active_host" "$active_port" "$active_database" "$active_user" \
make_passfile "$target_host" "$target_port" "$target_database" "$target_user" \ make_passfile "$target_host" "$target_port" "$target_database" "$target_user" \
"$target_password_file" "$target_pass" "$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" \ active_identity=$(PGPASSFILE=$active_pass psql -XAt --host="$active_host" --port="$active_port" \
--username="$active_user" --dbname="$active_database" --command="$identity_sql") --username="$active_user" --dbname="$active_database" --command="$identity_sql")
target_identity=$(PGPASSFILE=$target_pass psql -XAt --host="$target_host" --port="$target_port" \ target_identity=$(PGPASSFILE=$target_pass psql -XAt --host="$target_host" --port="$target_port" \
--username="$target_user" --dbname="$target_database" --command="$identity_sql") --username="$target_user" --dbname="$target_database" --command="$identity_sql")
[ "$active_identity" != "$target_identity" ] || { [ "$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" \ 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 exit 2
fi 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" \ --host="$target_host" --port="$target_port" --username="$target_user" \
--dbname="$target_database" "$input" --dbname="$target_database" "$input"
echo "Vector restore completed into explicit target $target_host:$target_port/$target_database" echo "Vector restore completed into explicit target $target_host:$target_port/$target_database"