From 8a935b4625c369f138d1a2e19852ad80b42365b7 Mon Sep 17 00:00:00 2001 From: twentyOne2x Date: Tue, 7 Jul 2026 14:22:03 +0200 Subject: [PATCH] Add portable restore canary capsule (#55) --- .github/workflows/ci.yml | 2 + .github/workflows/gcp-readiness.yml | 16 +++ docs/gcp-infra-hardening-20260707.md | 19 ++++ docs/gcp-kb-cloudsql-restore-runbook.md | 25 +++++ ops/check_gcp_infra_readiness.py | 31 +++-- ops/redact_sqlite_postgres_restore_canary.py | 101 +++++++++++++++++ tests/test_gcp_infra_readiness_checker.py | 55 +++++++++ tests/test_gcp_readiness_workflow.py | 3 + ..._sqlite_postgres_restore_canary_capsule.py | 106 ++++++++++++++++++ 9 files changed, 348 insertions(+), 10 deletions(-) create mode 100644 ops/redact_sqlite_postgres_restore_canary.py create mode 100644 tests/test_sqlite_postgres_restore_canary_capsule.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e04dffe..d9d0f32 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/gcp-readiness.yml b/.github/workflows/gcp-readiness.yml index 5e4eb3b..7802df9 100644 --- a/.github/workflows/gcp-readiness.yml +++ b/.github/workflows/gcp-readiness.yml @@ -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: | diff --git a/docs/gcp-infra-hardening-20260707.md b/docs/gcp-infra-hardening-20260707.md index 353eabb..a6673be 100644 --- a/docs/gcp-infra-hardening-20260707.md +++ b/docs/gcp-infra-hardening-20260707.md @@ -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-.json \ + --output outputs/gcp-infra-hardening-20260707/proofs/sqlite-postgres-restore-canary-capsule-.json + +CAPSULE_B64="$(base64 < outputs/gcp-infra-hardening-20260707/proofs/sqlite-postgres-restore-canary-capsule-.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 diff --git a/docs/gcp-kb-cloudsql-restore-runbook.md b/docs/gcp-kb-cloudsql-restore-runbook.md index e3b87c4..11be44c 100644 --- a/docs/gcp-kb-cloudsql-restore-runbook.md +++ b/docs/gcp-kb-cloudsql-restore-runbook.md @@ -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-.json \ + --output outputs/gcp-infra-hardening-20260707/proofs/sqlite-postgres-restore-canary-capsule-.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-.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: diff --git a/ops/check_gcp_infra_readiness.py b/ops/check_gcp_infra_readiness.py index 4c94d07..729703e 100755 --- a/ops/check_gcp_infra_readiness.py +++ b/ops/check_gcp_infra_readiness.py @@ -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", ) diff --git a/ops/redact_sqlite_postgres_restore_canary.py b/ops/redact_sqlite_postgres_restore_canary.py new file mode 100644 index 0000000..5e77726 --- /dev/null +++ b/ops/redact_sqlite_postgres_restore_canary.py @@ -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()) diff --git a/tests/test_gcp_infra_readiness_checker.py b/tests/test_gcp_infra_readiness_checker.py index f5ab25a..ca96e36 100644 --- a/tests/test_gcp_infra_readiness_checker.py +++ b/tests/test_gcp_infra_readiness_checker.py @@ -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 diff --git a/tests/test_gcp_readiness_workflow.py b/tests/test_gcp_readiness_workflow.py index 30c9c33..03e2a99 100644 --- a/tests/test_gcp_readiness_workflow.py +++ b/tests/test_gcp_readiness_workflow.py @@ -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 diff --git a/tests/test_sqlite_postgres_restore_canary_capsule.py b/tests/test_sqlite_postgres_restore_canary_capsule.py new file mode 100644 index 0000000..bc5322d --- /dev/null +++ b/tests/test_sqlite_postgres_restore_canary_capsule.py @@ -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"]