#!/usr/bin/env python3 """Rehearse the accepted-cohort date patch on isolated recovery data only. The SQL transaction always rolls back. File changes affect a disposable copy; the recovered pair and production are never changed. This is not a live executor. """ from __future__ import annotations import argparse import hashlib import json import os import re import shutil import subprocess import tempfile import uuid from pathlib import Path CHECKS = r''' import copy, hashlib, json, django from pathlib import Path django.setup() from django.db import connection, transaction from coulomb_social.apps.spaces.models import Space from coulomb_social.apps.spaces.date_backfill import prepare_index_date_patch from coulomb_social.apps.spaces.bubble_transfer import source_timestamp assert connection.vendor == "postgresql" plan = json.loads(Path("/review/plan.json").read_bytes()) selection = plan["selection"] root = Path("/rehearsal-content") index = root / "spaces" / selection["space_slug"] / "pages/index.md" original = index.read_bytes() patched = prepare_index_date_patch(plan, original) def files(): return {str(p.relative_to(root)): hashlib.sha256(p.read_bytes()).hexdigest() for p in root.rglob("*") if p.is_file()} def database(): result = {} with connection.cursor() as cursor: cursor.execute("SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY 1") tables = [r[0] for r in cursor.fetchall()] for table in tables: cursor.execute("SELECT * FROM " + connection.ops.quote_name(table)) columns = [c[0] for c in cursor.description] result[table] = [dict(zip(columns, row)) for row in cursor.fetchall()] return result def digest(rows): return {table: sorted(json.dumps(row, sort_keys=True, default=str) for row in entries) for table, entries in rows.items()} before_files = files() with transaction.atomic(): target = Space.objects.select_for_update().get( pk=selection["space_id"], tenant_id=selection["tenant_id"], slug=selection["space_slug"]) prior = source_timestamp(plan["observed_sql_created_at"]) desired = source_timestamp(plan["source_created_at"]) assert target.created_at == prior pages = root / "spaces" / selection["space_slug"] / "pages" observed = {p["slug"]: p["observed_sha256"] for p in plan["pages"]} assert len(observed) == len(plan["pages"]) observed["index"] = plan["index_sha256"] assert {p.stem: hashlib.sha256(p.read_bytes()).hexdigest() for p in pages.glob("*.md")} == observed before = database() expected = copy.deepcopy(before) selected = [r for r in expected["spaces_space"] if r["id"] == target.pk] assert len(selected) == 1 selected[0]["created_at"] = desired try: assert Space.objects.filter(pk=target.pk, created_at=prior).update(created_at=desired) == 1 index.write_bytes(patched) after_files = dict(before_files) after_files[str(index.relative_to(root))] = hashlib.sha256(patched).hexdigest() assert digest(database()) == digest(expected) assert files() == after_files assert Space.objects.get(pk=target.pk).created_at == desired # Replaying the old SQL precondition must refuse, rather than rewrite. if prior != desired: assert Space.objects.filter(pk=target.pk, created_at=prior).update( created_at=desired) == 0 assert Space.objects.filter(pk=target.pk, created_at=desired).update(created_at=prior) == 1 index.write_bytes(original) assert digest(database()) == digest(before) and files() == before_files # Prove rollback after the file has been staged and SQL has changed. Space.objects.filter(pk=target.pk).update(created_at=desired) index.write_bytes(patched) finally: index.write_bytes(original) transaction.set_rollback(True) assert digest(database()) == digest(before) and files() == before_files print(json.dumps({ "status": "recovered_date_patch_rehearsed", "business_tables": len(before) - int("django_migrations" in before), "all_table_rows_compared": True, "sql_changes": int(prior != desired), "content_files": len(before_files), "index_changed": patched != original, "only_selected_date_and_index_changed": True, "reverse_restores_all_rows_and_files": True, "sql_rollback_and_file_cleanup_verified": True, "replay_precondition_refused": prior != desired, "other_page_dates_and_bytes_preserved": len(observed) - 1, "grants_and_local_pages_unchanged": True, })) ''' def run(arguments): return subprocess.run(arguments, capture_output=True, timeout=180, check=True).stdout def hashes(root): entries = list(root.rglob("*")) if any(p.is_symlink() for p in entries): raise ValueError("Recovery content must not contain symlinks") return {str(p.relative_to(root)): hashlib.sha256(p.read_bytes()).hexdigest() for p in entries if p.is_file()} def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--restore-receipt", type=Path, required=True) parser.add_argument("--plan", type=Path, required=True) parser.add_argument("--candidate-image", required=True) parser.add_argument("--output", type=Path, required=True) args = parser.parse_args() if args.output.exists() or args.output.is_symlink(): raise ValueError("Fresh output required") state = json.loads(args.restore_receipt.read_bytes()) if (state["status"] != "restored_verified" or not re.fullmatch(r"csoc-recovery-\d{14}", state["database_container"]) or state["network"] != state["database_container"] + "-net"): raise ValueError("Isolated recovery receipt required") network = json.loads(run(["docker", "network", "inspect", state["network"]]))[0] db = json.loads(run(["docker", "inspect", state["database_container"]]))[0] if (network["Internal"] is not True or not db["State"]["Running"] or set(db["NetworkSettings"]["Networks"]) != {state["network"]} or any(db["NetworkSettings"]["Ports"].values()) or db["Image"] != state["postgres_image_id"]): raise ValueError("Recovery isolation mismatch") image = run(["docker", "image", "inspect", args.candidate_image, "--format", "{{.Id}}"]).decode().strip() if not re.fullmatch(r"sha256:[a-f0-9]{64}", image): raise ValueError("Locally retained image required") root = Path(state["restored_directory"]) / "content" if root.is_symlink() or not root.resolve().is_relative_to(Path("/tmp")): raise ValueError("Temporary recovered content required") before = hashes(root) module = Path(__file__).resolve().parents[1] / "coulomb_social/apps/spaces/date_backfill.py" module_bytes, plan_bytes = module.read_bytes(), args.plan.read_bytes() with tempfile.TemporaryDirectory(prefix="csoc-date-rehearsal-") as temporary: work = Path(temporary) shutil.copytree(root, work / "content") (work / "plan.json").write_bytes(plan_bytes) (work / "date_backfill.py").write_bytes(module_bytes) name = "csoc-date-check-" + uuid.uuid4().hex command = [ "docker", "run", "--rm", "--name", name, "--network", state["network"], "--read-only", "--tmpfs", "/tmp", "--cap-drop=ALL", "--security-opt=no-new-privileges", "--user", f"{os.getuid()}:{os.getgid()}", "--entrypoint", "/app/.venv/bin/python", "-e", "DJANGO_SETTINGS_MODULE=coulomb_social.settings.base", "-e", "DATABASE_URL=postgres://postgres@recovery-db/coulomb_social_restore", "-e", "CONTENT_ROOT=/rehearsal-content", "-e", "FORGEJO_BASE_URL=", "-e", "PYTHONDONTWRITEBYTECODE=1", "-v", str(work / "content") + ":/rehearsal-content", "-v", str(work / "plan.json") + ":/review/plan.json:ro", "-v", str(work / "date_backfill.py") + ":/app/coulomb_social/apps/spaces/date_backfill.py:ro", image, "-c", CHECKS, ] try: result = subprocess.run(command, capture_output=True, timeout=180) finally: # Killing the Docker client alone could leave the transaction open. # Remove this exact disposable client container, never the database. subprocess.run(["docker", "rm", "-f", name], capture_output=True, timeout=30, check=False) if result.returncode: fd, diagnostic = tempfile.mkstemp(prefix="csoc-date-private-diagnostics-", dir="/tmp") with os.fdopen(fd, "wb") as stream: stream.write(result.stdout + result.stderr) print(json.dumps({"private_diagnostics": diagnostic})) raise ValueError("Rehearsal refused; inspect private diagnostics locally") rows = [json.loads(line) for line in result.stdout.splitlines() if line.startswith(b"{")] if len(rows) != 1 or hashes(root) != before or hashes(work / "content") != before: raise ValueError("Recovery preservation check failed") proof = { "schema": "coulomb.source-date-backfill-rehearsal.v1", **rows[0], "candidate_image_id": image, "date_patch_module_sha256": hashlib.sha256(module_bytes).hexdigest(), "rehearsal_runner_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), "private_plan_sha256": hashlib.sha256(plan_bytes).hexdigest(), "network_internal": True, "published_ports": 0, "original_recovered_files_unchanged": True, "production_access": False, "production_writes": False, "sql_commit": False, "source_overlay_used": True, "live_executor_prepared": False, } fd = os.open(args.output, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) with os.fdopen(fd, "w") as stream: json.dump(proof, stream, indent=2) stream.write("\n") print(json.dumps(proof)) if __name__ == "__main__": try: main() except (ValueError, OSError, KeyError, subprocess.SubprocessError): raise SystemExit("Date rehearsal refused; inspect private inputs/diagnostics locally.")