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/apply_gcp_runtime_baseline.py \
ops/check_gcp_service_communications.py \ ops/check_gcp_service_communications.py \
ops/plan_gcp_iam_split.py \ ops/plan_gcp_iam_split.py \
ops/redact_sqlite_postgres_restore_canary.py \
ops/sqlite_to_postgres_dump.py \ ops/sqlite_to_postgres_dump.py \
ops/verify_gcp_cloudsql_restore_readback.py \ ops/verify_gcp_cloudsql_restore_readback.py \
telegram/approvals.py \ telegram/approvals.py \
@ -67,6 +68,7 @@ jobs:
tests/test_gcp_readiness_workflow.py \ tests/test_gcp_readiness_workflow.py \
tests/test_phase1b_end_to_end.py \ tests/test_phase1b_end_to_end.py \
tests/test_sqlite_to_postgres_dump.py \ tests/test_sqlite_to_postgres_dump.py \
tests/test_sqlite_postgres_restore_canary_capsule.py \
tests/test_eval_parse.py \ tests/test_eval_parse.py \
tests/test_contributor.py \ tests/test_contributor.py \
tests/test_search.py tests/test_search.py

View file

@ -7,6 +7,10 @@ on:
description: GCP service account to impersonate for the read-only readiness probe description: GCP service account to impersonate for the read-only readiness probe
required: false required: false
default: sa-teleo-readiness@teleo-501523.iam.gserviceaccount.com 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: permissions:
contents: read contents: read
@ -20,6 +24,7 @@ env:
PROJECT_ID: teleo-501523 PROJECT_ID: teleo-501523
WORKLOAD_IDENTITY_PROVIDER: projects/785938879453/locations/global/workloadIdentityPools/github-actions/providers/living-ip-github 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' }} 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 READINESS_ARTIFACT_DIR: gcp-readiness-artifacts
jobs: jobs:
@ -38,6 +43,17 @@ jobs:
- uses: google-github-actions/setup-gcloud@v2 - 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 - name: Capture gcloud identity
shell: bash shell: bash
run: | run: |

View file

@ -82,6 +82,25 @@ gh workflow run gcp-readiness.yml \
-f service_account=sa-teleo-readiness@teleo-501523.iam.gserviceaccount.com -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: For a local/manual Cloud Build proof:
```bash ```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 preflight that should pass before the same generated import is applied through
the approved Cloud SQL connector/VPC path. 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 ## Cloud SQL Restore Drill Runner
Prepare the exact GCS import and Cloud SQL import operation without mutating GCP: 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_DATABASE = "teleo_kb"
CLOUDSQL_STANDBY_PASSWORD_SECRET = "gcp-teleo-pgvector-standby-postgres-password" 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")) 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 @dataclass
@ -576,36 +577,46 @@ def check_kb_source_restore_access() -> Check:
def check_local_sqlite_postgres_restore_canary() -> 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: if not candidates:
return Check( return Check(
"local_sqlite_postgres_restore_canary", "local_sqlite_postgres_restore_canary",
"blocked", "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", required_tier="T2_local_restore_canary",
current_tier="T1_foundation", current_tier="T1_foundation",
) )
valid_proofs = [] valid_proofs = []
invalid_proofs = [] invalid_proofs = []
for candidate in candidates: for candidate, evidence_kind in candidates:
try: try:
proof = json.loads(candidate.read_text()) proof = json.loads(candidate.read_text())
except json.JSONDecodeError: except (FileNotFoundError, json.JSONDecodeError):
invalid_proofs.append(candidate) invalid_proofs.append(candidate)
continue 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: if not valid_proofs:
return Check( return Check(
"local_sqlite_postgres_restore_canary", "local_sqlite_postgres_restore_canary",
"fail", "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", required_tier="T2_local_restore_canary",
current_tier="T1_foundation", 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 [] 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") source_tables = proof.get("source_table_count")
target_tables = proof.get("target_table_count") target_tables = proof.get("target_table_count")
source_rows = proof.get("source_total_rows") source_rows = proof.get("source_total_rows")
@ -613,21 +624,21 @@ def check_local_sqlite_postgres_restore_canary() -> Check:
if ( if (
proof.get("status") == "pass" proof.get("status") == "pass"
and proof.get("source_integrity_check") == "ok" and proof.get("source_integrity_check") == "ok"
and not mismatches and not mismatched_table_count
and source_tables == target_tables and source_tables == target_tables
and source_rows == target_rows and source_rows == target_rows
): ):
return Check( return Check(
"local_sqlite_postgres_restore_canary", "local_sqlite_postgres_restore_canary",
"pass", "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", required_tier="T2_local_restore_canary",
current_tier="T2_local_restore_canary", current_tier="T2_local_restore_canary",
) )
return Check( return Check(
"local_sqlite_postgres_restore_canary", "local_sqlite_postgres_restore_canary",
"fail", "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", required_tier="T2_local_restore_canary",
current_tier="T1_foundation", 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 result.status == "pass"
assert "sqlite-postgres-restore-canary-valid.json" in result.detail 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() workflow = Path(".github/workflows/gcp-readiness.yml").read_text()
assert "service_account:" in workflow assert "service_account:" in workflow
assert "restore_canary_capsule_b64:" in workflow
assert "READINESS_SERVICE_ACCOUNT:" 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-teleo-readiness@teleo-501523.iam.gserviceaccount.com" in workflow
assert "sa-artifact-builder@teleo-501523.iam.gserviceaccount.com" not in workflow assert "sa-artifact-builder@teleo-501523.iam.gserviceaccount.com" not in workflow
assert "READINESS_ARTIFACT_DIR: gcp-readiness-artifacts" 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"]