#!/usr/bin/env python3 """felhom-release-copy — copy the released images and agent packages off DooPlex to ep0, nightly (D33, R-1227). Runs on DooPlex as root from felhom-release-copy.timer. Runbook: documentation/runbooks/total-loss-of-dooplex.md („Restore the release copy"). Pinned by test_release_copy.py. WHAT. The newest KEEP release tags (X.Y.Z only) of each image in REPOS, and the newest AGENT_KEEP agent versions (the binary and its config bundle). After a DooPlex loss these are what every box update, reinstall and new install pulls. HOW, and why skopeo, not `docker save`. Each image tag is copied with `skopeo copy --all --preserve-digests` into skopeo's `dir:` layout: the registry's own manifest bytes and compressed layer blobs, unchanged. Pushed back into any registry, the image has THE SAME DIGEST as the release (a `docker save` tar is re-compressed on the next push, so the digest — what the „a version is pushed once" rule and the hub's spoiled-tag list are about — would differ). It also needs no Docker daemon on DooPlex. CHECKS, before anything leaves: the sha256 of the copied top manifest equals the registry's digest; every blob and child manifest hashes to its own name; every blob a manifest names is present; each agent file's sha256 equals the registry's record. The staged tree must be ≤ CAP_MB (the cap — the sender enforces it; ep0 has no quota). Then `rsync --delete` to ep0 (the limited user `felhom-relcopy`, rrsync-confined to one directory), and a second `rsync --checksum --dry-run` must report NO difference. Only then is the success signal written (CLAUDE.md „presence is not success"). Versions beyond KEEP / AGENT_KEEP are deleted locally, and so on ep0 by `--delete`. """ import base64 import hashlib import json import os import re import shutil import subprocess import sys import tempfile import time import urllib.request CONF = os.environ.get("FELHOM_RC_CONF", "/etc/felhom-release-copy") STATE = os.environ.get("FELHOM_RC_STATE", "/var/lib/felhom-release-copy") TEXTFILE_DIR = os.environ.get("FELHOM_RC_TEXTFILE_DIR", "/var/lib/node_exporter/textfile_collector") REGISTRY = "gitea.dooplex.hu" OWNER = "admin" SPOILED = {"felhom-controller": {"0.315.0", "0.316.3"}} # withdrawn builds (R-1085) — never offered, never copied AGENT_FILES = ("felhom-agent", "felhom-config-bundle.json") HEX64 = re.compile(r"^[0-9a-f]{64}$") RELEASE = re.compile(r"^\d+\.\d+\.\d+$") class Refused(Exception): pass def log(msg): print("felhom-release-copy: " + msg, flush=True) def load_env(path): env = {} for line in open(path, encoding="utf-8"): line = line.strip() if line and not line.startswith("#") and "=" in line: k, v = line.split("=", 1) env[k.strip()] = v.strip().strip("'\"") return env def newest(versions, n, spoiled=()): rel = [v for v in versions if RELEASE.match(v) and v not in spoiled] rel.sort(key=lambda v: tuple(int(x) for x in v.split("."))) return rel[-n:] if n > 0 else [] def sha256_file(path): h = hashlib.sha256() with open(path, "rb") as f: for chunk in iter(lambda: f.read(1 << 20), b""): h.update(chunk) return h.hexdigest() def verify_image_dir(d, want_digest): """Refuses unless the dir: copy in d is exactly the image want_digest names (see the module docstring).""" top = os.path.join(d, "manifest.json") if not os.path.isfile(top): raise Refused(f"{d}: no manifest.json") got = "sha256:" + sha256_file(top) if got != want_digest: raise Refused(f"{d}: manifest digest {got} != registry {want_digest}") names = set(os.listdir(d)) manifests = [top] for n in sorted(names): p = os.path.join(d, n) if HEX64.match(n): if sha256_file(p) != n: raise Refused(f"{d}: blob {n} does not hash to its name") elif n.endswith(".manifest.json") and HEX64.match(n[: -len(".manifest.json")]): if sha256_file(p) != n[: -len(".manifest.json")]: raise Refused(f"{d}: child manifest {n} does not hash to its name") manifests.append(p) for m in manifests: doc = json.load(open(m, encoding="utf-8")) for ref in doc.get("manifests", []): hx = ref["digest"].split(":", 1)[1] if hx + ".manifest.json" not in names: raise Refused(f"{d}: child manifest {hx[:12]} named by the index is missing") for ref in ([doc["config"]] if "config" in doc else []) + doc.get("layers", []): hx = ref["digest"].split(":", 1)[1] if hx not in names: raise Refused(f"{d}: blob {hx[:12]} named by a manifest is missing") def tree_bytes(root): total = 0 for dp, _, fs in os.walk(root): for f in fs: total += os.lstat(os.path.join(dp, f)).st_size return total def prune(root, keep): """Removes every directory under root whose name is not in keep; returns the removed names.""" gone = [] if os.path.isdir(root): for n in sorted(os.listdir(root)): if n not in keep: shutil.rmtree(os.path.join(root, n)) gone.append(n) return gone class Registry: def __init__(self, user, token): self.auth = "Basic " + base64.b64encode(f"{user}:{token}".encode()).decode() def get(self, url, raw=False): r = urllib.request.urlopen(urllib.request.Request(url, headers={"Authorization": self.auth}), timeout=120) return r.read() if raw else json.load(r) def agent_versions(self): out, page = [], 1 while True: batch = self.get(f"https://{REGISTRY}/api/v1/packages/{OWNER}?type=generic&q=felhom-agent&limit=50&page={page}") if not batch: return out out += [p["version"] for p in batch if p.get("name") == "felhom-agent"] page += 1 def agent_files(self, ver): return {f["name"]: f["sha256"] for f in self.get( f"https://{REGISTRY}/api/v1/packages/{OWNER}/generic/felhom-agent/{ver}/files")} def agent_download(self, ver, name, dest): with open(dest, "wb") as f: f.write(self.get(f"https://{REGISTRY}/api/packages/{OWNER}/generic/felhom-agent/{ver}/{name}", raw=True)) def run(cmd, **kw): return subprocess.run(cmd, check=True, text=True, capture_output=True, **kw) def skopeo(authfile, *args): return run(["skopeo", *args[:1], "--authfile", authfile, *args[1:]]).stdout def copy_images(reg_authfile, stage, repos, keep): images = os.path.join(stage, "images") os.makedirs(images, exist_ok=True) copied = [] for repo in repos: tags = json.loads(skopeo(reg_authfile, "list-tags", f"docker://{REGISTRY}/{OWNER}/{repo}"))["Tags"] want = newest(tags, keep, SPOILED.get(repo, ())) if not want: raise Refused(f"{repo}: no release tag") rdir = os.path.join(images, repo) os.makedirs(rdir, exist_ok=True) for tag in want: ref = f"docker://{REGISTRY}/{OWNER}/{repo}:{tag}" digest = "sha256:" + hashlib.sha256( skopeo(reg_authfile, "inspect", "--raw", ref).encode()).hexdigest() d = os.path.join(rdir, tag) if os.path.isfile(os.path.join(d, "DIGEST")) and open(os.path.join(d, "DIGEST")).read().strip() == digest: verify_image_dir(d, digest) # already here — re-checked every night, not trusted continue tmp = d + ".tmp" shutil.rmtree(tmp, ignore_errors=True) shutil.rmtree(d, ignore_errors=True) skopeo(reg_authfile, "copy", "--all", "--preserve-digests", "--quiet", ref, f"dir:{tmp}") verify_image_dir(tmp, digest) with open(os.path.join(tmp, "DIGEST"), "w") as f: f.write(digest + "\n") os.rename(tmp, d) copied.append(f"{repo}:{tag}") gone = prune(rdir, set(want)) log(f"{repo}: keeping {', '.join(want)}" + (f"; removed {', '.join(gone)}" if gone else "")) prune(images, set(repos)) return copied def copy_agents(reg, stage, keep): adir = os.path.join(stage, "agent") os.makedirs(adir, exist_ok=True) want = newest(reg.agent_versions(), keep) if not want: raise Refused("felhom-agent: no release version") for ver in want: d = os.path.join(adir, ver) os.makedirs(d, exist_ok=True) recorded = reg.agent_files(ver) for name in AGENT_FILES: if name not in recorded: raise Refused(f"felhom-agent {ver}: the registry has no {name}") p = os.path.join(d, name) if not (os.path.isfile(p) and sha256_file(p) == recorded[name]): reg.agent_download(ver, name, p + ".tmp") if sha256_file(p + ".tmp") != recorded[name]: os.remove(p + ".tmp") raise Refused(f"felhom-agent {ver}/{name}: the download does not match the registry's sha256") os.rename(p + ".tmp", p) with open(os.path.join(d, name + ".sha256"), "w") as f: f.write(f"{recorded[name]} {name}\n") gone = prune(adir, set(want)) log(f"felhom-agent: keeping {', '.join(want)}" + (f"; removed {', '.join(gone)}" if gone else "")) def write_sums(stage): lines = [] for dp, _, fs in os.walk(stage): for f in fs: if f == "SHA256SUMS": continue p = os.path.join(dp, f) lines.append(f"{sha256_file(p)} {os.path.relpath(p, stage)}") with open(os.path.join(stage, "SHA256SUMS"), "w") as f: f.write("\n".join(sorted(lines, key=lambda l: l[66:])) + "\n") def rsync_to(env, stage, dry_check=False): ssh = (f"ssh -i {CONF}/id_ed25519 -o UserKnownHostsFile={CONF}/known_hosts -o StrictHostKeyChecking=yes " f"-o BatchMode=yes -o ConnectTimeout=30") cmd = ["rsync", "-a", "--delete", "-e", ssh] if dry_check: cmd += ["--checksum", "--dry-run", "--itemize-changes"] return run(cmd + [stage.rstrip("/") + "/", env["REMOTE"] + ":./"]).stdout def signal(nbytes): tmp = os.path.join(TEXTFILE_DIR, f"felhom_release_copy.prom.{os.getpid()}") with open(tmp, "w") as f: f.write("# HELP felhom_release_copy_last_success_timestamp_seconds Last verified copy of the releases to ep0 (D33).\n" "# TYPE felhom_release_copy_last_success_timestamp_seconds gauge\n" f"felhom_release_copy_last_success_timestamp_seconds {int(time.time())}\n" f"felhom_release_copy_last_success_bytes {nbytes}\n") os.chmod(tmp, 0o644) os.rename(tmp, os.path.join(TEXTFILE_DIR, "felhom_release_copy.prom")) def main(): env = load_env(os.path.join(CONF, "env")) keep, akeep, cap = int(env.get("KEEP", "3")), int(env.get("AGENT_KEEP", "5")), int(env["CAP_MB"]) repos = env["REPOS"].split() token = open(os.path.join(CONF, "registry-token"), encoding="utf-8").read().strip() user = env.get("REGISTRY_USER", OWNER) stage = os.path.join(STATE, "stage") os.makedirs(stage, exist_ok=True) os.chmod(STATE, 0o700) with tempfile.TemporaryDirectory(dir=STATE) as td: authfile = os.path.join(td, "auth.json") fd = os.open(authfile, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) with os.fdopen(fd, "w") as f: json.dump({"auths": {REGISTRY: {"auth": base64.b64encode(f"{user}:{token}".encode()).decode()}}}, f) start = time.time() copied = copy_images(authfile, stage, repos, keep) copy_agents(Registry(user, token), stage, akeep) for n in os.listdir(stage): if n not in ("images", "agent", "SHA256SUMS", "COPIED_AT"): p = os.path.join(stage, n) shutil.rmtree(p) if os.path.isdir(p) else os.remove(p) with open(os.path.join(stage, "COPIED_AT"), "w") as f: f.write(time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + "\n") write_sums(stage) nbytes = tree_bytes(stage) if nbytes > cap * 1024 * 1024: raise Refused(f"the copy is {nbytes // (1024 * 1024)} MB, above the cap {cap} MB — nothing sent") log(f"staged and verified: {nbytes // (1024 * 1024)} MB, new this run: {', '.join(copied) or 'none'}") rsync_to(env, stage) diff = rsync_to(env, stage, dry_check=True).strip() if diff: raise Refused("ep0's copy differs from the staged one after the push: " + diff.splitlines()[0]) log(f"pushed to ep0 and compared by checksum: no difference ({int(time.time() - start)} s)") signal(nbytes) log("success signal written") if __name__ == "__main__": try: main() except Refused as e: print(f"felhom-release-copy: FAILED: {e}", file=sys.stderr) sys.exit(1) except subprocess.CalledProcessError as e: print(f"felhom-release-copy: FAILED: {e.cmd[0]} {e.cmd[1] if len(e.cmd) > 1 else ''} rc={e.returncode}: " f"{(e.stderr or '').strip()[:300]}", file=sys.stderr) sys.exit(1)