139 lines
4.8 KiB
Bash
Executable file
139 lines
4.8 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
# Restores a Teleo SQLite backup into a disposable local Postgres container and
|
|
# compares source/target table row counts. It does not touch GCP or production.
|
|
|
|
SQLITE_BACKUP="${SQLITE_BACKUP:-}"
|
|
POSTGRES_IMAGE="${POSTGRES_IMAGE:-postgres:16-alpine}"
|
|
OUTPUT_ROOT="${OUTPUT_ROOT:-./outputs/gcp-infra-hardening-20260707/private-restore-canaries}"
|
|
PROOF_ROOT="${PROOF_ROOT:-./outputs/gcp-infra-hardening-20260707/proofs}"
|
|
TS="${TS:-$(date -u +%Y%m%dT%H%M%SZ)}"
|
|
SCHEMA="${SCHEMA:-teleo_restore}"
|
|
CONTAINER_NAME="${CONTAINER_NAME:-teleo-pg-restore-canary-${TS}}"
|
|
|
|
if [[ -z "$SQLITE_BACKUP" ]]; then
|
|
echo "SQLITE_BACKUP is required, for example SQLITE_BACKUP=outputs/.../teleo-pipeline-sqlite-...db.gz" >&2
|
|
exit 2
|
|
fi
|
|
|
|
if [[ ! -f "$SQLITE_BACKUP" ]]; then
|
|
echo "SQLite backup not found: $SQLITE_BACKUP" >&2
|
|
exit 2
|
|
fi
|
|
|
|
mkdir -p "$OUTPUT_ROOT" "$PROOF_ROOT"
|
|
chmod 700 "$OUTPUT_ROOT"
|
|
|
|
WORK_DIR="$OUTPUT_ROOT/sqlite-postgres-restore-canary-${TS}"
|
|
mkdir -p "$WORK_DIR"
|
|
chmod 700 "$WORK_DIR"
|
|
|
|
IMPORT_SQL="$WORK_DIR/teleo-pipeline-postgres-import.sql"
|
|
SOURCE_SUMMARY="$WORK_DIR/source-summary.json"
|
|
TARGET_COUNTS_SQL="$WORK_DIR/target-counts.sql"
|
|
TARGET_COUNTS_CSV="$WORK_DIR/target-counts.csv"
|
|
IMPORT_LOG="$WORK_DIR/postgres-import.log"
|
|
PROOF_JSON="$PROOF_ROOT/sqlite-postgres-restore-canary-${TS}.json"
|
|
|
|
cleanup() {
|
|
if [[ "${KEEP_CONTAINER:-0}" != "1" ]]; then
|
|
docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
|
|
fi
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
python3 ops/sqlite_to_postgres_dump.py \
|
|
--sqlite-backup "$SQLITE_BACKUP" \
|
|
--output-sql "$IMPORT_SQL" \
|
|
--summary-json "$SOURCE_SUMMARY" \
|
|
--schema "$SCHEMA" \
|
|
--target-counts-sql "$TARGET_COUNTS_SQL"
|
|
|
|
docker run --rm -d \
|
|
--name "$CONTAINER_NAME" \
|
|
-e POSTGRES_PASSWORD=postgres \
|
|
-e POSTGRES_DB=teleo_restore_canary \
|
|
"$POSTGRES_IMAGE" >/dev/null
|
|
|
|
for _ in $(seq 1 60); do
|
|
if docker exec "$CONTAINER_NAME" pg_isready -U postgres -d teleo_restore_canary >/dev/null 2>&1; then
|
|
break
|
|
fi
|
|
sleep 1
|
|
done
|
|
|
|
docker exec "$CONTAINER_NAME" pg_isready -U postgres -d teleo_restore_canary >/dev/null
|
|
|
|
docker exec -i "$CONTAINER_NAME" \
|
|
psql -v ON_ERROR_STOP=1 -U postgres -d teleo_restore_canary \
|
|
< "$IMPORT_SQL" > "$IMPORT_LOG"
|
|
|
|
docker exec -i "$CONTAINER_NAME" \
|
|
psql -v ON_ERROR_STOP=1 -U postgres -d teleo_restore_canary \
|
|
< "$TARGET_COUNTS_SQL" > "$TARGET_COUNTS_CSV"
|
|
|
|
python3 - "$SOURCE_SUMMARY" "$TARGET_COUNTS_CSV" "$PROOF_JSON" "$SQLITE_BACKUP" "$IMPORT_SQL" "$POSTGRES_IMAGE" <<'PY'
|
|
import csv
|
|
import hashlib
|
|
import json
|
|
import sys
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
|
|
source_summary_path = Path(sys.argv[1])
|
|
target_counts_path = Path(sys.argv[2])
|
|
proof_path = Path(sys.argv[3])
|
|
sqlite_backup = Path(sys.argv[4])
|
|
import_sql = Path(sys.argv[5])
|
|
postgres_image = sys.argv[6]
|
|
|
|
source_summary = json.loads(source_summary_path.read_text())
|
|
source_counts = {table["name"]: int(table["row_count"]) for table in source_summary["tables"]}
|
|
with target_counts_path.open(newline="") as handle:
|
|
target_counts = {row["table_name"]: int(row["row_count"]) for row in csv.DictReader(handle)}
|
|
|
|
all_tables = sorted(set(source_counts) | set(target_counts))
|
|
mismatches = [
|
|
{
|
|
"table": table,
|
|
"source_rows": source_counts.get(table),
|
|
"target_rows": target_counts.get(table),
|
|
}
|
|
for table in all_tables
|
|
if source_counts.get(table) != target_counts.get(table)
|
|
]
|
|
|
|
def sha256(path: Path) -> str:
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as handle:
|
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
proof = {
|
|
"artifact": "sqlite_postgres_restore_canary",
|
|
"generated_at_utc": datetime.now(UTC).isoformat(),
|
|
"status": "pass" if not mismatches and source_summary["source_integrity_check"] == "ok" else "fail",
|
|
"source_sqlite_backup": str(sqlite_backup),
|
|
"source_sqlite_backup_sha256": sha256(sqlite_backup),
|
|
"postgres_image": postgres_image,
|
|
"source_integrity_check": source_summary["source_integrity_check"],
|
|
"postgres_schema": source_summary["schema"],
|
|
"source_table_count": len(source_counts),
|
|
"target_table_count": len(target_counts),
|
|
"source_total_rows": sum(source_counts.values()),
|
|
"target_total_rows": sum(target_counts.values()),
|
|
"mismatched_tables": mismatches,
|
|
"conversion_notes": source_summary["conversion_notes"],
|
|
"conversion_stats": source_summary["conversion_stats"],
|
|
"private_artifacts": {
|
|
"source_summary_json": str(source_summary_path),
|
|
"postgres_import_sql": str(import_sql),
|
|
"target_counts_csv": str(target_counts_path),
|
|
},
|
|
}
|
|
proof_path.write_text(json.dumps(proof, indent=2, sort_keys=True) + "\n")
|
|
print(json.dumps(proof, indent=2, sort_keys=True))
|
|
sys.exit(0 if proof["status"] == "pass" else 1)
|
|
PY
|