The facility stayed at one replica and the relay credential was not read. Offsite publication still waits for a prefix-scoped publisher grant. Assistant: grok Assistant-Session: 01a198fa-fd27-7e41-a0a2-57355c3e4efd
166 lines
5.6 KiB
Python
166 lines
5.6 KiB
Python
#!/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()
|