303 lines
13 KiB
Python
303 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""Bind a postflight VPS manifest to a captured snapshot and report exact deltas."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import stat
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
try:
|
|
from .capture_vps_canonical_postgres_snapshot import (
|
|
SOURCE_SNAPSHOT_RECEIPT_SCHEMA,
|
|
build_provenance_binding,
|
|
)
|
|
from .private_receipt_io import print_private_receipt_summary, write_private_json
|
|
from .verify_postgres_parity_manifest import (
|
|
REVIEWED_MANIFEST_SQL_SHA256,
|
|
SINGLETON_KINDS,
|
|
canonical_hash,
|
|
file_sha256,
|
|
load_manifest,
|
|
)
|
|
except ImportError: # pragma: no cover - direct script execution
|
|
from capture_vps_canonical_postgres_snapshot import SOURCE_SNAPSHOT_RECEIPT_SCHEMA, build_provenance_binding
|
|
from private_receipt_io import print_private_receipt_summary, write_private_json
|
|
from verify_postgres_parity_manifest import (
|
|
REVIEWED_MANIFEST_SQL_SHA256,
|
|
SINGLETON_KINDS,
|
|
canonical_hash,
|
|
file_sha256,
|
|
load_manifest,
|
|
)
|
|
|
|
DELTA_SCHEMA = "livingip.vpsCanonicalSnapshotDelta.v1"
|
|
SHA256_RE = re.compile(r"[0-9a-f]{64}\Z")
|
|
|
|
|
|
def service_healthy(value: Any) -> bool:
|
|
if not isinstance(value, dict):
|
|
return False
|
|
try:
|
|
main_pid = int(value.get("MainPID") or 0)
|
|
restarts = int(value.get("NRestarts") or 0)
|
|
except (TypeError, ValueError):
|
|
return False
|
|
return (
|
|
value.get("ActiveState") == "active" and value.get("SubState") == "running" and main_pid > 0 and restarts >= 0
|
|
)
|
|
|
|
|
|
def regular_file(path: Path, label: str) -> Path:
|
|
try:
|
|
mode = path.lstat().st_mode
|
|
except OSError as exc:
|
|
raise ValueError(f"{label} is unavailable: {exc}") from exc
|
|
if not stat.S_ISREG(mode) or path.is_symlink():
|
|
raise ValueError(f"{label} must be a regular non-symlink file")
|
|
return path.resolve()
|
|
|
|
|
|
def load_json(path: Path, label: str) -> dict[str, Any]:
|
|
path = regular_file(path, label)
|
|
try:
|
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
except json.JSONDecodeError as exc:
|
|
raise ValueError(f"{label} is invalid JSON") from exc
|
|
if not isinstance(payload, dict):
|
|
raise ValueError(f"{label} must be a JSON object")
|
|
return payload
|
|
|
|
|
|
def parse_timestamp(value: Any, label: str) -> datetime:
|
|
text = str(value or "")
|
|
if text.endswith("Z"):
|
|
text = text[:-1] + "+00:00"
|
|
try:
|
|
parsed = datetime.fromisoformat(text)
|
|
except ValueError as exc:
|
|
raise ValueError(f"{label} is not an ISO-8601 timestamp") from exc
|
|
if parsed.tzinfo is None:
|
|
raise ValueError(f"{label} must include a timezone")
|
|
return parsed.astimezone(UTC)
|
|
|
|
|
|
def validate_capture(
|
|
receipt_path: Path,
|
|
manifest_path: Path,
|
|
*,
|
|
expected_authorization_ref: str,
|
|
label: str,
|
|
) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
receipt = load_json(receipt_path, f"{label} receipt")
|
|
manifest_path = regular_file(manifest_path, f"{label} manifest")
|
|
manifest = load_manifest(manifest_path)
|
|
snapshot = receipt.get("snapshot") or {}
|
|
source = receipt.get("source") or {}
|
|
dump = receipt.get("dump") or {}
|
|
manifest_receipt = receipt.get("manifest") or {}
|
|
source_context = receipt.get("source_context") or {}
|
|
source_service = receipt.get("source_service") or {}
|
|
binding = receipt.get("provenance_binding") or {}
|
|
expected_binding = build_provenance_binding(
|
|
run_id=str(receipt.get("run_id") or ""),
|
|
authorization_ref=str(receipt.get("capture_authorization_ref") or ""),
|
|
source=source,
|
|
snapshot=snapshot,
|
|
dump_sha256=str(dump.get("sha256") or ""),
|
|
manifest_sql_sha256=str(manifest_receipt.get("manifest_sql_sha256") or ""),
|
|
source_manifest_sha256=file_sha256(manifest_path),
|
|
source_context_sha256=str(source_context.get("sha256") or ""),
|
|
)
|
|
total_rows = sum(int(row["row_count"]) for row in manifest["tables"].values())
|
|
checks = {
|
|
"schema": receipt.get("schema") == SOURCE_SNAPSHOT_RECEIPT_SCHEMA,
|
|
"receipt_version": receipt.get("receipt_version") == 2,
|
|
"status": receipt.get("status") == "pass",
|
|
"authorization": receipt.get("capture_authorization_ref") == expected_authorization_ref,
|
|
"snapshot_exported": receipt.get("snapshot_exported") is True,
|
|
"service_unchanged": receipt.get("service_unchanged") is True,
|
|
"source_service_healthy": source_service.get("unchanged") is True
|
|
and service_healthy(source_service.get("before"))
|
|
and source_service.get("before") == source_service.get("after"),
|
|
"source_not_mutated": receipt.get("production_db_mutated") is False,
|
|
"telegram_not_sent": receipt.get("telegram_send_attempted") is False,
|
|
"manifest_hash": manifest_receipt.get("sha256") == file_sha256(manifest_path),
|
|
"manifest_table_count": manifest_receipt.get("table_count") == len(manifest["tables"]),
|
|
"manifest_total_rows": manifest_receipt.get("total_rows") == total_rows,
|
|
"manifest_read_only": manifest["singleton"]["identity"].get("transaction_read_only") == "on",
|
|
"manifest_database": manifest["singleton"]["identity"].get("database") == source.get("database"),
|
|
"binding_payload": binding.get("payload") == expected_binding["payload"],
|
|
"binding_sha256": binding.get("sha256") == expected_binding["sha256"],
|
|
"binding_algorithm": binding.get("algorithm") == "sha256",
|
|
"dump_sha256": bool(SHA256_RE.fullmatch(str(dump.get("sha256") or ""))),
|
|
"manifest_sql_sha256": bool(SHA256_RE.fullmatch(str(manifest_receipt.get("manifest_sql_sha256") or ""))),
|
|
"manifest_sql_reviewed": manifest_receipt.get("manifest_sql_sha256") == REVIEWED_MANIFEST_SQL_SHA256,
|
|
"source_context_sha256": bool(SHA256_RE.fullmatch(str(source_context.get("sha256") or ""))),
|
|
"snapshot_identity": all(
|
|
bool(snapshot.get(field))
|
|
for field in ("captured_at_utc", "exported_snapshot_id", "txid_snapshot", "wal_lsn", "system_identifier")
|
|
),
|
|
}
|
|
failed = sorted(name for name, passed in checks.items() if not passed)
|
|
if failed:
|
|
raise ValueError(f"{label} capture failed validation: " + ", ".join(failed))
|
|
return receipt, manifest
|
|
|
|
|
|
def manifest_fingerprint(manifest: dict[str, Any]) -> dict[str, Any]:
|
|
tables = {
|
|
name: {"row_count": int(row["row_count"]), "rowset_md5": row.get("rowset_md5")}
|
|
for name, row in sorted(manifest["tables"].items())
|
|
}
|
|
structures = {
|
|
kind: canonical_hash(manifest["singleton"][kind].get("items", []))
|
|
for kind in sorted(SINGLETON_KINDS - {"identity"})
|
|
}
|
|
return {
|
|
"table_count": len(tables),
|
|
"total_rows": sum(row["row_count"] for row in tables.values()),
|
|
"table_fingerprint_sha256": canonical_hash(tables),
|
|
"structure_fingerprint_sha256": canonical_hash(structures),
|
|
"tables": tables,
|
|
"structures": structures,
|
|
}
|
|
|
|
|
|
def verify_delta(
|
|
*,
|
|
baseline_receipt_path: Path,
|
|
baseline_manifest_path: Path,
|
|
baseline_authorization_ref: str,
|
|
current_receipt_path: Path,
|
|
current_manifest_path: Path,
|
|
current_authorization_ref: str,
|
|
) -> dict[str, Any]:
|
|
baseline_receipt, baseline_manifest = validate_capture(
|
|
baseline_receipt_path,
|
|
baseline_manifest_path,
|
|
expected_authorization_ref=baseline_authorization_ref,
|
|
label="baseline",
|
|
)
|
|
current_receipt, current_manifest = validate_capture(
|
|
current_receipt_path,
|
|
current_manifest_path,
|
|
expected_authorization_ref=current_authorization_ref,
|
|
label="current",
|
|
)
|
|
baseline_snapshot = baseline_receipt["snapshot"]
|
|
current_snapshot = current_receipt["snapshot"]
|
|
baseline_time = parse_timestamp(baseline_snapshot["captured_at_utc"], "baseline snapshot time")
|
|
current_time = parse_timestamp(current_snapshot["captured_at_utc"], "current snapshot time")
|
|
if current_time <= baseline_time:
|
|
raise ValueError("current source capture must be newer than the restored baseline capture")
|
|
if current_receipt.get("source") != baseline_receipt.get("source"):
|
|
raise ValueError("current source identity does not match the restored baseline source")
|
|
if current_snapshot.get("system_identifier") != baseline_snapshot.get("system_identifier"):
|
|
raise ValueError("current source PostgreSQL system identifier changed")
|
|
baseline_manifest_sql_sha256 = str((baseline_receipt.get("manifest") or {}).get("manifest_sql_sha256") or "")
|
|
current_manifest_sql_sha256 = str((current_receipt.get("manifest") or {}).get("manifest_sql_sha256") or "")
|
|
if baseline_manifest_sql_sha256 != current_manifest_sql_sha256:
|
|
raise ValueError("baseline and current captures used different manifest SQL algorithms")
|
|
|
|
baseline = manifest_fingerprint(baseline_manifest)
|
|
current = manifest_fingerprint(current_manifest)
|
|
baseline_tables = baseline.pop("tables")
|
|
current_tables = current.pop("tables")
|
|
baseline_structures = baseline.pop("structures")
|
|
current_structures = current.pop("structures")
|
|
added_tables = sorted(set(current_tables) - set(baseline_tables))
|
|
removed_tables = sorted(set(baseline_tables) - set(current_tables))
|
|
changed_tables = [
|
|
{"table": table, "baseline": baseline_tables[table], "current": current_tables[table]}
|
|
for table in sorted(set(baseline_tables) & set(current_tables))
|
|
if baseline_tables[table] != current_tables[table]
|
|
]
|
|
changed_structures = [
|
|
{
|
|
"kind": kind,
|
|
"baseline_sha256": baseline_structures.get(kind),
|
|
"current_sha256": current_structures.get(kind),
|
|
}
|
|
for kind in sorted(set(baseline_structures) | set(current_structures))
|
|
if baseline_structures.get(kind) != current_structures.get(kind)
|
|
]
|
|
delta_detected = bool(added_tables or removed_tables or changed_tables or changed_structures)
|
|
return {
|
|
"artifact": "vps_canonical_snapshot_postflight_delta",
|
|
"schema": DELTA_SCHEMA,
|
|
"generated_at_utc": datetime.now(UTC).isoformat(),
|
|
"status": "pass",
|
|
"baseline": {
|
|
"run_id": baseline_receipt["run_id"],
|
|
"capture_authorization_ref": baseline_authorization_ref,
|
|
"captured_at_utc": baseline_snapshot["captured_at_utc"],
|
|
"capture_receipt_sha256": file_sha256(baseline_receipt_path),
|
|
"manifest_sha256": file_sha256(baseline_manifest_path),
|
|
"manifest_sql_sha256": baseline_manifest_sql_sha256,
|
|
**baseline,
|
|
},
|
|
"current": {
|
|
"run_id": current_receipt["run_id"],
|
|
"capture_authorization_ref": current_authorization_ref,
|
|
"captured_at_utc": current_snapshot["captured_at_utc"],
|
|
"capture_receipt_sha256": file_sha256(current_receipt_path),
|
|
"manifest_sha256": file_sha256(current_manifest_path),
|
|
"manifest_sql_sha256": current_manifest_sql_sha256,
|
|
**current,
|
|
},
|
|
"delta": {
|
|
"delta_detected": delta_detected,
|
|
"added_tables": added_tables,
|
|
"removed_tables": removed_tables,
|
|
"changed_tables": changed_tables,
|
|
"changed_structures": changed_structures,
|
|
"total_row_delta": current["total_rows"] - baseline["total_rows"],
|
|
},
|
|
"strongest_claim_allowed": (
|
|
"restored snapshot still matches the newest postflight VPS capture"
|
|
if not delta_detected
|
|
else "restored snapshot parity is bounded to its capture; post-snapshot VPS deltas are explicit"
|
|
),
|
|
}
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--baseline-receipt", required=True, type=Path)
|
|
parser.add_argument("--baseline-manifest", required=True, type=Path)
|
|
parser.add_argument("--baseline-authorization-ref", required=True)
|
|
parser.add_argument("--current-receipt", required=True, type=Path)
|
|
parser.add_argument("--current-manifest", required=True, type=Path)
|
|
parser.add_argument("--current-authorization-ref", required=True)
|
|
parser.add_argument("--output", required=True, type=Path)
|
|
args = parser.parse_args()
|
|
try:
|
|
payload = verify_delta(
|
|
baseline_receipt_path=args.baseline_receipt,
|
|
baseline_manifest_path=args.baseline_manifest,
|
|
baseline_authorization_ref=args.baseline_authorization_ref,
|
|
current_receipt_path=args.current_receipt,
|
|
current_manifest_path=args.current_manifest,
|
|
current_authorization_ref=args.current_authorization_ref,
|
|
)
|
|
except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError) as exc:
|
|
payload = {
|
|
"artifact": "vps_canonical_snapshot_postflight_delta",
|
|
"schema": DELTA_SCHEMA,
|
|
"generated_at_utc": datetime.now(UTC).isoformat(),
|
|
"status": "fail",
|
|
"error": str(exc),
|
|
"strongest_claim_allowed": "post-snapshot VPS delta is not proven",
|
|
}
|
|
write_private_json(args.output, payload)
|
|
print_private_receipt_summary(args.output, payload)
|
|
return 0 if payload.get("status") == "pass" else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|