Add portable restore canary capsule (#55)
Some checks are pending
CI / lint-and-test (push) Waiting to run

This commit is contained in:
twentyOne2x 2026-07-07 14:22:03 +02:00 committed by GitHub
parent fc451ec94f
commit 8a935b4625
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 348 additions and 10 deletions

View file

@ -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

View file

@ -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: |

View file

@ -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

View file

@ -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:

View file

@ -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",
)

View 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())

View file

@ -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

View file

@ -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

View 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"]