#!/usr/bin/env python3 """Quiesce mas-mx in place, capture the evidence files, and rehearse locally. Does not change Deployment replicas, roll the image, delete the namespace, or read the TEM relay files. Offsite publication stays a separate grant. """ from __future__ import annotations import argparse import json import os import signal import subprocess from datetime import UTC, datetime from pathlib import Path import mas_evidence_archive as archive STATE = Path("/var/lib/mas-evidence-backup") def kubectl(*args): return json.loads( subprocess.check_output(["k3s", "kubectl", *args, "-o", "json"], text=True) ) def volume(): claim = kubectl("-n", "mas", "get", "pvc", "mas-evidence") name = claim["spec"]["volumeName"] body = kubectl("get", "pv", name) local = body["spec"].get("local") or {} host = body["spec"].get("hostPath") or {} path = local.get("path") or host.get("path") if not path or not Path(path).is_dir(): raise archive.Refused("evidence_volume_path_required") return name, Path(path) def deployments(): found = {} for name in ("mas-mx", "mas-statute"): body = kubectl("-n", "mas", "get", "deploy", name) image = body["spec"]["template"]["spec"]["containers"][0]["image"] found[name] = {"replicas": body["spec"]["replicas"], "image": image} return found def container_init_pid(): pods = kubectl("-n", "mas", "get", "pods", "-l", "app.kubernetes.io/name=mas-mx") items = [item for item in pods["items"] if item["status"]["phase"] == "Running"] if len(items) != 1: raise archive.Refused("mas_mx_pod_required") statuses = items[0]["status"]["containerStatuses"] if len(statuses) != 1 or not statuses[0].get("containerID", "").startswith("containerd://"): raise archive.Refused("mas_mx_container_required") container = statuses[0]["containerID"].removeprefix("containerd://") info = json.loads( subprocess.check_output(["k3s", "crictl", "inspect", container], text=True) ) return int(info["info"]["pid"]) def namespace_pids(init_pid): host = os.readlink("/proc/self/ns/pid") target = os.readlink(f"/proc/{init_pid}/ns/pid") if host == target: raise archive.Refused("refusing_host_pid_namespace") found = [] for entry in os.listdir("/proc"): if not entry.isdigit(): continue try: if os.readlink(f"/proc/{entry}/ns/pid") == target: found.append(int(entry)) except OSError: continue if init_pid not in found: raise archive.Refused("container_process_missing") return found def quiesced_copy(destination): before = deployments() volume_name, volume_path = volume() if {name: item["replicas"] for name, item in before.items()} != {"mas-mx": 1, "mas-statute": 1}: raise archive.Refused("facility_replica_drift") if before["mas-mx"]["image"] != before["mas-statute"]["image"]: raise archive.Refused("facility_image_drift") pids = namespace_pids(container_init_pid()) stopped = [] try: for pid in pids: os.kill(pid, signal.SIGSTOP) stopped.append(pid) receipt = archive.capture( volume_path, destination, captured_at=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), image=before["mas-mx"]["image"], writers_quiesced=True, replicas={"mas-mx": 1, "mas-statute": 1}, ) finally: for pid in stopped: try: os.kill(pid, signal.SIGCONT) except OSError: pass after = deployments() if after != before: raise archive.Refused("facility_changed_during_capture") receipt["volume"] = volume_name receipt["facility_scaled"] = False receipt["relay_read"] = False archive.checkpoint(destination / "receipt.json", receipt) return receipt def main(): parser = argparse.ArgumentParser() parser.add_argument("--execute", action="store_true") parser.add_argument("--recipient") args = parser.parse_args() if not args.execute: print(json.dumps({"status": "preview", "offsite": False, "facility_scaled": False})) return if os.geteuid() != 0: raise archive.Refused("root_required") if not isinstance(args.recipient, str) or not args.recipient.startswith("age1"): raise archive.Refused("age_recipient_required") STATE.mkdir(mode=0o700, exist_ok=True) stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") destination = STATE / stamp receipt = quiesced_copy(destination) sealed = archive.seal(destination, destination, args.recipient) for name in archive.FILES: path = destination / name if path.exists(): path.unlink() public = { "status": "sealed", "application": "mas", "captured_at": receipt["captured_at"], "image": receipt["image"], "volume": receipt["volume"], "files": { name: {"present": meta.get("present"), "bytes": meta.get("bytes")} for name, meta in receipt["files"].items() }, "ciphertext_sha256": sealed["ciphertext_sha256"], "ciphertext_bytes": sealed["ciphertext_bytes"], "mail_sent": False, "production_restored": False, "facility_scaled": False, "relay_read": False, "offsite_published": False, "age_identity_on_node": False, } archive.checkpoint(STATE / "latest-public.json", public) print(json.dumps(public)) if __name__ == "__main__": main()