Add portable restore canary capsule (#55)
Some checks are pending
CI / lint-and-test (push) Waiting to run
Some checks are pending
CI / lint-and-test (push) Waiting to run
This commit is contained in:
parent
fc451ec94f
commit
8a935b4625
9 changed files with 348 additions and 10 deletions
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
|
@ -46,6 +46,7 @@ jobs:
|
|||
ops/apply_gcp_runtime_baseline.py \
|
||||
ops/check_gcp_service_communications.py \
|
||||
ops/plan_gcp_iam_split.py \
|
||||
ops/redact_sqlite_postgres_restore_canary.py \
|
||||
ops/sqlite_to_postgres_dump.py \
|
||||
ops/verify_gcp_cloudsql_restore_readback.py \
|
||||
telegram/approvals.py \
|
||||
|
|
@ -67,6 +68,7 @@ jobs:
|
|||
tests/test_gcp_readiness_workflow.py \
|
||||
tests/test_phase1b_end_to_end.py \
|
||||
tests/test_sqlite_to_postgres_dump.py \
|
||||
tests/test_sqlite_postgres_restore_canary_capsule.py \
|
||||
tests/test_eval_parse.py \
|
||||
tests/test_contributor.py \
|
||||
tests/test_search.py
|
||||
|
|
|
|||
16
.github/workflows/gcp-readiness.yml
vendored
16
.github/workflows/gcp-readiness.yml
vendored
|
|
@ -7,6 +7,10 @@ on:
|
|||
description: GCP service account to impersonate for the read-only readiness probe
|
||||
required: false
|
||||
default: sa-teleo-readiness@teleo-501523.iam.gserviceaccount.com
|
||||
restore_canary_capsule_b64:
|
||||
description: Optional base64-encoded non-secret SQLite-to-Postgres restore canary capsule
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
|
@ -20,6 +24,7 @@ env:
|
|||
PROJECT_ID: teleo-501523
|
||||
WORKLOAD_IDENTITY_PROVIDER: projects/785938879453/locations/global/workloadIdentityPools/github-actions/providers/living-ip-github
|
||||
READINESS_SERVICE_ACCOUNT: ${{ inputs.service_account || 'sa-teleo-readiness@teleo-501523.iam.gserviceaccount.com' }}
|
||||
RESTORE_CANARY_CAPSULE_B64: ${{ inputs.restore_canary_capsule_b64 || '' }}
|
||||
READINESS_ARTIFACT_DIR: gcp-readiness-artifacts
|
||||
|
||||
jobs:
|
||||
|
|
@ -38,6 +43,17 @@ jobs:
|
|||
|
||||
- uses: google-github-actions/setup-gcloud@v2
|
||||
|
||||
- name: Install optional restore canary capsule
|
||||
if: ${{ env.RESTORE_CANARY_CAPSULE_B64 != '' }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p "${READINESS_ARTIFACT_DIR}"
|
||||
capsule_path="${READINESS_ARTIFACT_DIR}/restore-canary-capsule.json"
|
||||
printf '%s' "${RESTORE_CANARY_CAPSULE_B64}" | base64 --decode > "${capsule_path}"
|
||||
python3 -m json.tool "${capsule_path}" >/dev/null
|
||||
echo "TELEO_GCP_RESTORE_CANARY_CAPSULE=${capsule_path}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Capture gcloud identity
|
||||
shell: bash
|
||||
run: |
|
||||
|
|
|
|||
|
|
@ -82,6 +82,25 @@ gh workflow run gcp-readiness.yml \
|
|||
-f service_account=sa-teleo-readiness@teleo-501523.iam.gserviceaccount.com
|
||||
```
|
||||
|
||||
If you also want GitHub readiness to include a local SQLite-to-Postgres restore
|
||||
canary proof without uploading private backup paths or generated SQL, pass a
|
||||
redacted capsule:
|
||||
|
||||
```bash
|
||||
python3 ops/redact_sqlite_postgres_restore_canary.py \
|
||||
--proof outputs/gcp-infra-hardening-20260707/proofs/sqlite-postgres-restore-canary-<timestamp>.json \
|
||||
--output outputs/gcp-infra-hardening-20260707/proofs/sqlite-postgres-restore-canary-capsule-<timestamp>.json
|
||||
|
||||
CAPSULE_B64="$(base64 < outputs/gcp-infra-hardening-20260707/proofs/sqlite-postgres-restore-canary-capsule-<timestamp>.json | tr -d '\n')"
|
||||
gh workflow run gcp-readiness.yml \
|
||||
--repo living-ip/teleo-infrastructure \
|
||||
--ref main \
|
||||
-f restore_canary_capsule_b64="${CAPSULE_B64}"
|
||||
```
|
||||
|
||||
This only upgrades the local restore-preflight row. It is not GCP DB redundancy
|
||||
until the Cloud SQL import and target-count readback also pass.
|
||||
|
||||
For a local/manual Cloud Build proof:
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -66,6 +66,31 @@ This is a local restore/parity proof, not GCP redundancy by itself. It is the
|
|||
preflight that should pass before the same generated import is applied through
|
||||
the approved Cloud SQL connector/VPC path.
|
||||
|
||||
To pass this local preflight into a clean GitHub readiness run without uploading
|
||||
private backup paths, generated SQL, or target-count CSVs, create a redacted
|
||||
capsule from the proof:
|
||||
|
||||
```bash
|
||||
python3 ops/redact_sqlite_postgres_restore_canary.py \
|
||||
--proof outputs/gcp-infra-hardening-20260707/proofs/sqlite-postgres-restore-canary-<timestamp>.json \
|
||||
--output outputs/gcp-infra-hardening-20260707/proofs/sqlite-postgres-restore-canary-capsule-<timestamp>.json
|
||||
```
|
||||
|
||||
The capsule keeps only non-secret evidence: proof hash, backup hash, source and
|
||||
target table/row counts, conversion notes/stats, and the redacted-field list.
|
||||
It does not prove that Cloud SQL imported the data; it only proves the local
|
||||
SQLite-to-Postgres parity preflight.
|
||||
|
||||
To include the capsule in GitHub readiness:
|
||||
|
||||
```bash
|
||||
CAPSULE_B64="$(base64 < outputs/gcp-infra-hardening-20260707/proofs/sqlite-postgres-restore-canary-capsule-<timestamp>.json | tr -d '\n')"
|
||||
gh workflow run gcp-readiness.yml \
|
||||
--repo living-ip/teleo-infrastructure \
|
||||
--ref main \
|
||||
-f restore_canary_capsule_b64="${CAPSULE_B64}"
|
||||
```
|
||||
|
||||
## Cloud SQL Restore Drill Runner
|
||||
|
||||
Prepare the exact GCS import and Cloud SQL import operation without mutating GCP:
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ CLOUDSQL_STANDBY_INSTANCE = "teleo-pgvector-standby"
|
|||
CLOUDSQL_STANDBY_DATABASE = "teleo_kb"
|
||||
CLOUDSQL_STANDBY_PASSWORD_SECRET = "gcp-teleo-pgvector-standby-postgres-password"
|
||||
PROOF_ROOT = Path(os.environ.get("TELEO_GCP_PROOF_ROOT", "outputs/gcp-infra-hardening-20260707/proofs"))
|
||||
RESTORE_CANARY_CAPSULE = os.environ.get("TELEO_GCP_RESTORE_CANARY_CAPSULE")
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -576,36 +577,46 @@ def check_kb_source_restore_access() -> Check:
|
|||
|
||||
|
||||
def check_local_sqlite_postgres_restore_canary() -> Check:
|
||||
candidates = sorted(PROOF_ROOT.glob("sqlite-postgres-restore-canary-*.json"))
|
||||
candidates = [
|
||||
(candidate, "full_proof")
|
||||
for candidate in sorted(PROOF_ROOT.glob("sqlite-postgres-restore-canary-*.json"))
|
||||
if not candidate.name.startswith("sqlite-postgres-restore-canary-capsule-")
|
||||
]
|
||||
capsule_candidates = sorted(PROOF_ROOT.glob("sqlite-postgres-restore-canary-capsule-*.json"))
|
||||
if RESTORE_CANARY_CAPSULE:
|
||||
capsule_candidates.append(Path(RESTORE_CANARY_CAPSULE))
|
||||
candidates.extend((candidate, "capsule") for candidate in capsule_candidates)
|
||||
if not candidates:
|
||||
return Check(
|
||||
"local_sqlite_postgres_restore_canary",
|
||||
"blocked",
|
||||
f"no sqlite-postgres restore canary proof found under {PROOF_ROOT}",
|
||||
f"no sqlite-postgres restore canary proof or capsule found under {PROOF_ROOT}",
|
||||
required_tier="T2_local_restore_canary",
|
||||
current_tier="T1_foundation",
|
||||
)
|
||||
|
||||
valid_proofs = []
|
||||
invalid_proofs = []
|
||||
for candidate in candidates:
|
||||
for candidate, evidence_kind in candidates:
|
||||
try:
|
||||
proof = json.loads(candidate.read_text())
|
||||
except json.JSONDecodeError:
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
invalid_proofs.append(candidate)
|
||||
continue
|
||||
valid_proofs.append((str(proof.get("generated_at_utc") or ""), candidate, proof))
|
||||
generated_at = proof.get("generated_at_utc") or proof.get("source_proof_generated_at_utc") or ""
|
||||
valid_proofs.append((str(generated_at), candidate, proof, evidence_kind))
|
||||
if not valid_proofs:
|
||||
return Check(
|
||||
"local_sqlite_postgres_restore_canary",
|
||||
"fail",
|
||||
f"no valid sqlite-postgres restore canary proof found under {PROOF_ROOT}; invalid={len(invalid_proofs)}",
|
||||
f"no valid sqlite-postgres restore canary proof or capsule found under {PROOF_ROOT}; invalid={len(invalid_proofs)}",
|
||||
required_tier="T2_local_restore_canary",
|
||||
current_tier="T1_foundation",
|
||||
)
|
||||
|
||||
_, latest, proof = max(valid_proofs, key=lambda item: (item[0], item[1].stat().st_mtime))
|
||||
_, latest, proof, evidence_kind = max(valid_proofs, key=lambda item: (item[0], item[1].stat().st_mtime))
|
||||
mismatches = proof.get("mismatched_tables") or []
|
||||
mismatched_table_count = len(mismatches) if isinstance(mismatches, list) else proof.get("mismatched_table_count")
|
||||
source_tables = proof.get("source_table_count")
|
||||
target_tables = proof.get("target_table_count")
|
||||
source_rows = proof.get("source_total_rows")
|
||||
|
|
@ -613,21 +624,21 @@ def check_local_sqlite_postgres_restore_canary() -> Check:
|
|||
if (
|
||||
proof.get("status") == "pass"
|
||||
and proof.get("source_integrity_check") == "ok"
|
||||
and not mismatches
|
||||
and not mismatched_table_count
|
||||
and source_tables == target_tables
|
||||
and source_rows == target_rows
|
||||
):
|
||||
return Check(
|
||||
"local_sqlite_postgres_restore_canary",
|
||||
"pass",
|
||||
f"latest={latest} tables={source_tables} rows={source_rows} postgres_image={proof.get('postgres_image')}",
|
||||
f"latest={latest} evidence={evidence_kind} tables={source_tables} rows={source_rows} postgres_image={proof.get('postgres_image')}",
|
||||
required_tier="T2_local_restore_canary",
|
||||
current_tier="T2_local_restore_canary",
|
||||
)
|
||||
return Check(
|
||||
"local_sqlite_postgres_restore_canary",
|
||||
"fail",
|
||||
f"latest={latest} status={proof.get('status')} mismatches={len(mismatches)} source_tables={source_tables} target_tables={target_tables} source_rows={source_rows} target_rows={target_rows}",
|
||||
f"latest={latest} evidence={evidence_kind} status={proof.get('status')} mismatches={mismatched_table_count} source_tables={source_tables} target_tables={target_tables} source_rows={source_rows} target_rows={target_rows}",
|
||||
required_tier="T2_local_restore_canary",
|
||||
current_tier="T1_foundation",
|
||||
)
|
||||
|
|
|
|||
101
ops/redact_sqlite_postgres_restore_canary.py
Normal file
101
ops/redact_sqlite_postgres_restore_canary.py
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Create a non-secret capsule from a SQLite-to-Postgres restore proof."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
PRIVATE_FIELDS = ("source_sqlite_backup", "private_artifacts")
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
def validate_restore_proof(proof: dict[str, Any]) -> list[str]:
|
||||
problems = []
|
||||
mismatches = proof.get("mismatched_tables") or []
|
||||
if proof.get("artifact") != "sqlite_postgres_restore_canary":
|
||||
problems.append(f"artifact={proof.get('artifact')}")
|
||||
if proof.get("status") != "pass":
|
||||
problems.append(f"status={proof.get('status')}")
|
||||
if proof.get("source_integrity_check") != "ok":
|
||||
problems.append(f"source_integrity_check={proof.get('source_integrity_check')}")
|
||||
if mismatches:
|
||||
problems.append(f"mismatched_tables={len(mismatches)}")
|
||||
if proof.get("source_table_count") != proof.get("target_table_count"):
|
||||
problems.append(
|
||||
f"table_count source={proof.get('source_table_count')} target={proof.get('target_table_count')}"
|
||||
)
|
||||
if proof.get("source_total_rows") != proof.get("target_total_rows"):
|
||||
problems.append(f"total_rows source={proof.get('source_total_rows')} target={proof.get('target_total_rows')}")
|
||||
if not proof.get("generated_at_utc"):
|
||||
problems.append("missing_generated_at_utc")
|
||||
return problems
|
||||
|
||||
|
||||
def build_capsule(proof_path: Path) -> dict[str, Any]:
|
||||
proof = json.loads(proof_path.read_text())
|
||||
problems = validate_restore_proof(proof)
|
||||
capsule = {
|
||||
"artifact": "sqlite_postgres_restore_canary_capsule",
|
||||
"capsule_generated_at_utc": datetime.now(UTC).isoformat(),
|
||||
"source_proof_generated_at_utc": proof.get("generated_at_utc"),
|
||||
"source_proof_sha256": sha256(proof_path),
|
||||
"status": "pass" if not problems else "fail",
|
||||
"source_integrity_check": proof.get("source_integrity_check"),
|
||||
"source_sqlite_backup_sha256": proof.get("source_sqlite_backup_sha256"),
|
||||
"postgres_image": proof.get("postgres_image"),
|
||||
"postgres_schema": proof.get("postgres_schema"),
|
||||
"source_table_count": proof.get("source_table_count"),
|
||||
"target_table_count": proof.get("target_table_count"),
|
||||
"source_total_rows": proof.get("source_total_rows"),
|
||||
"target_total_rows": proof.get("target_total_rows"),
|
||||
"mismatched_table_count": len(proof.get("mismatched_tables") or []),
|
||||
"conversion_notes": proof.get("conversion_notes", {}),
|
||||
"conversion_stats": proof.get("conversion_stats", {}),
|
||||
"redacted_fields": list(PRIVATE_FIELDS),
|
||||
"problems": problems,
|
||||
"not_proven_by_this_artifact": [
|
||||
"private backup file availability",
|
||||
"private generated SQL contents",
|
||||
"GCP Cloud SQL import execution",
|
||||
"GCP Cloud SQL target-count readback",
|
||||
],
|
||||
}
|
||||
return capsule
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--proof", required=True, type=Path, help="Full sqlite-postgres restore canary proof JSON")
|
||||
parser.add_argument("--output", required=True, type=Path, help="Redacted capsule JSON output path")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
capsule = build_capsule(args.proof)
|
||||
rendered = json.dumps(capsule, indent=2, sort_keys=True) + "\n"
|
||||
for private_field in PRIVATE_FIELDS:
|
||||
if private_field in rendered and private_field not in json.dumps(capsule.get("redacted_fields", [])):
|
||||
print(f"private field leaked into capsule: {private_field}", file=sys.stderr)
|
||||
return 1
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(rendered)
|
||||
print(rendered, end="")
|
||||
return 0 if capsule["status"] == "pass" else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -117,3 +117,58 @@ def test_restore_canary_check_ignores_invalid_json_when_valid_proof_exists(tmp_p
|
|||
|
||||
assert result.status == "pass"
|
||||
assert "sqlite-postgres-restore-canary-valid.json" in result.detail
|
||||
|
||||
|
||||
def test_restore_canary_check_accepts_redacted_capsule(tmp_path, monkeypatch) -> None:
|
||||
capsule = tmp_path / "restore-canary-capsule.json"
|
||||
capsule.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"artifact": "sqlite_postgres_restore_canary_capsule",
|
||||
"source_proof_generated_at_utc": "2026-07-07T12:00:00+00:00",
|
||||
"status": "pass",
|
||||
"source_integrity_check": "ok",
|
||||
"source_table_count": 41,
|
||||
"target_table_count": 41,
|
||||
"source_total_rows": 805745,
|
||||
"target_total_rows": 805745,
|
||||
"mismatched_table_count": 0,
|
||||
"postgres_image": "postgres:16-alpine",
|
||||
}
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(checker, "PROOF_ROOT", tmp_path / "empty")
|
||||
monkeypatch.setattr(checker, "RESTORE_CANARY_CAPSULE", str(capsule))
|
||||
|
||||
result = checker.check_local_sqlite_postgres_restore_canary()
|
||||
|
||||
assert result.status == "pass"
|
||||
assert "evidence=capsule" in result.detail
|
||||
assert "rows=805745" in result.detail
|
||||
|
||||
|
||||
def test_restore_canary_check_labels_capsule_files_under_proof_root(tmp_path, monkeypatch) -> None:
|
||||
capsule = tmp_path / "sqlite-postgres-restore-canary-capsule-20260707.json"
|
||||
capsule.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"artifact": "sqlite_postgres_restore_canary_capsule",
|
||||
"source_proof_generated_at_utc": "2026-07-07T12:00:00+00:00",
|
||||
"status": "pass",
|
||||
"source_integrity_check": "ok",
|
||||
"source_table_count": 41,
|
||||
"target_table_count": 41,
|
||||
"source_total_rows": 805745,
|
||||
"target_total_rows": 805745,
|
||||
"mismatched_table_count": 0,
|
||||
"postgres_image": "postgres:16-alpine",
|
||||
}
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(checker, "PROOF_ROOT", tmp_path)
|
||||
monkeypatch.setattr(checker, "RESTORE_CANARY_CAPSULE", None)
|
||||
|
||||
result = checker.check_local_sqlite_postgres_restore_canary()
|
||||
|
||||
assert result.status == "pass"
|
||||
assert "evidence=capsule" in result.detail
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@ def test_gcp_readiness_workflow_uses_uploadable_artifact_dir() -> None:
|
|||
workflow = Path(".github/workflows/gcp-readiness.yml").read_text()
|
||||
|
||||
assert "service_account:" in workflow
|
||||
assert "restore_canary_capsule_b64:" in workflow
|
||||
assert "READINESS_SERVICE_ACCOUNT:" in workflow
|
||||
assert "RESTORE_CANARY_CAPSULE_B64:" in workflow
|
||||
assert "TELEO_GCP_RESTORE_CANARY_CAPSULE=" in workflow
|
||||
assert "sa-teleo-readiness@teleo-501523.iam.gserviceaccount.com" in workflow
|
||||
assert "sa-artifact-builder@teleo-501523.iam.gserviceaccount.com" not in workflow
|
||||
assert "READINESS_ARTIFACT_DIR: gcp-readiness-artifacts" in workflow
|
||||
|
|
|
|||
106
tests/test_sqlite_postgres_restore_canary_capsule.py
Normal file
106
tests/test_sqlite_postgres_restore_canary_capsule.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_redact_restore_canary_capsule_removes_private_paths(tmp_path: Path) -> None:
|
||||
proof = tmp_path / "sqlite-postgres-restore-canary.json"
|
||||
output = tmp_path / "restore-capsule.json"
|
||||
proof.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"artifact": "sqlite_postgres_restore_canary",
|
||||
"generated_at_utc": "2026-07-07T12:00:00+00:00",
|
||||
"status": "pass",
|
||||
"source_sqlite_backup": "/private/path/teleo.db.gz",
|
||||
"source_sqlite_backup_sha256": "abc123",
|
||||
"source_integrity_check": "ok",
|
||||
"postgres_image": "postgres:16-alpine",
|
||||
"postgres_schema": "teleo_restore",
|
||||
"source_table_count": 2,
|
||||
"target_table_count": 2,
|
||||
"source_total_rows": 4,
|
||||
"target_total_rows": 4,
|
||||
"mismatched_tables": [],
|
||||
"conversion_notes": {"mode": "shadow_restore"},
|
||||
"conversion_stats": {"blob_values_hex_encoded": 0},
|
||||
"private_artifacts": {
|
||||
"source_summary_json": "/private/path/source-summary.json",
|
||||
"postgres_import_sql": "/private/path/import.sql",
|
||||
"target_counts_csv": "/private/path/counts.csv",
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
completed = subprocess.run(
|
||||
[
|
||||
"python3",
|
||||
"ops/redact_sqlite_postgres_restore_canary.py",
|
||||
"--proof",
|
||||
str(proof),
|
||||
"--output",
|
||||
str(output),
|
||||
],
|
||||
cwd=REPO_ROOT,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
capsule = json.loads(output.read_text())
|
||||
rendered = output.read_text()
|
||||
assert capsule["artifact"] == "sqlite_postgres_restore_canary_capsule"
|
||||
assert capsule["status"] == "pass"
|
||||
assert capsule["source_table_count"] == 2
|
||||
assert capsule["target_total_rows"] == 4
|
||||
assert capsule["mismatched_table_count"] == 0
|
||||
assert capsule["source_sqlite_backup_sha256"] == "abc123"
|
||||
assert "source_sqlite_backup" not in capsule
|
||||
assert "private_artifacts" not in capsule
|
||||
assert "/private/path" not in rendered
|
||||
|
||||
|
||||
def test_redact_restore_canary_capsule_fails_for_mismatched_rows(tmp_path: Path) -> None:
|
||||
proof = tmp_path / "sqlite-postgres-restore-canary.json"
|
||||
output = tmp_path / "restore-capsule.json"
|
||||
proof.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"artifact": "sqlite_postgres_restore_canary",
|
||||
"generated_at_utc": "2026-07-07T12:00:00+00:00",
|
||||
"status": "pass",
|
||||
"source_integrity_check": "ok",
|
||||
"source_table_count": 1,
|
||||
"target_table_count": 1,
|
||||
"source_total_rows": 4,
|
||||
"target_total_rows": 3,
|
||||
"mismatched_tables": [{"table": "claims", "source_rows": 4, "target_rows": 3}],
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
completed = subprocess.run(
|
||||
[
|
||||
"python3",
|
||||
"ops/redact_sqlite_postgres_restore_canary.py",
|
||||
"--proof",
|
||||
str(proof),
|
||||
"--output",
|
||||
str(output),
|
||||
],
|
||||
cwd=REPO_ROOT,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert completed.returncode == 1
|
||||
capsule = json.loads(output.read_text())
|
||||
assert capsule["status"] == "fail"
|
||||
assert "total_rows source=4 target=3" in capsule["problems"]
|
||||
Loading…
Reference in a new issue