Files
felhom.eu/documentation/audits/catalog-sweep-2026-10-24/tools/box_walk.py
T
admin 67053335d1
gates / gates (push) Successful in 13m17s
Catalog sweep 2026-10-24: all 65 catalog apps on a fresh box (52 work, 10 with a problem, 0 broken); register 208 -> 217 (R-1269..R-1277 opened; notes on R-739, R-1267); STATUS + D46; map B1 re-checked; evidence
Every catalog app installed, used, backed up, removed keeping data, reinstalled, restored and removed with data on a
fresh box from the public ISO (demo-hp VM 9360, Tester4), endpoint-level. Worst: a reinstall's capture rewrites the
kept unit so its restore breaks the app (R-1269, P2), the restore still reports success (R-1270, P2), and „use my kept
data" loaded another box's future-dated database (R-1271, P2). Drill catalog fixes for the morning list (n8n usage
data; OpenGist 256M, proven by a cgroup probe). VM 9360 and LXC 9361 destroyed; hub host Tester4-aa9466 left for the
operator. REPORT-catalog-sweep-2026-10-24.md.
2026-10-25 00:13:05 +02:00

919 lines
48 KiB
Python

#!/usr/bin/env python3
"""box_walk.py — the BOX venue's client (moved into the catalog 2026-09-30 from the audits' walk.py, so the monthly
re-test `retest-floating.py` has a stable home). ONE app's walk on scratch guest 9202, through the product's own
endpoints. Configuration by environment: SC (a 0600 scratch dir holding `.ctlpw`, the 9202 dashboard password —
never committed), EV (where evidence goes), GUEST/BASE/DOMAIN as before.
EVIDENCE, NOT PRODUCT. It presses exactly the buttons a person presses:
POST /api/stacks/<n>/deploy · POST /api/sync · POST /api/stacks/rescan
POST /api/stacks/<n>/update · POST /api/stacks/<n>/remove
and reads GET /api/stacks/<n>. No controller code exists for it.
The walk, per `09` §6.4 and the update-night brief §4:
1 deploy from the DRILL catalog at the LIVE pin
2 seed through the app's OWN front door (R-156: never a volume, never SQL)
3 read the seed back <- control C1; a fixture that cannot prove itself proves nothing
4 „Mentés most"
5 commit the real one-step bump to the DRILL repo, sync, rescan, read the badge in BOTH languages
6 press the guarded Update, record every phase with timestamps
7 read the seed back through the front door
8 the four version observables side by side
9 write the verdict record in `09`'s JSON shape
`inconclusive` is a first-class verdict and is NEVER collapsed into `failed`.
"""
import argparse, json, os, re, subprocess, sys, time
from datetime import datetime, timezone
SC = os.environ.get('SC', os.path.expanduser('~/.felhom-retest'))
EV = os.environ.get('EV', os.path.join(SC, 'evidence'))
DRILL = "/mnt/5_hdd/felhom.eu/drill/app-catalog-drill"
# THE TARGETS (R-892, 2026-10-06): one row per box the walk may drive. `hp` is the box's NAME for the guards
# (upgrade_fixtures_box.BOX_ADMIN_SEED_GUESTS keys on (hp, guest)); `ssh` is how its Proxmox host is reached, with an
# optional `jump` (never an edit of DooPlex's ~/.ssh/config); `guest` the customer LXC; `base` and `domain` its dashboard.
# TARGET=9202 (default) scratch guest 9202 on demo-hp
# TARGET=9201 demo-hp's hub-enabled guest (the mail proof) — household-shaped, never seeded through an admin
# TARGET=tester-1 the Tester 1 box: VM 341 on the HP box (`09` §3 decision 158), its node `felhom` at
# 192.168.0.154, guest 9201 (DHCP — read at start) for felhom.enkicsifelhom.hu (identity matched
# 2026-10-06: the agent's report `host.node=felhom` = the VM's certificate; the guest answers
# that domain, the other one 404 — `felhom.eu/documentation/audits/readback-2026-10-07/`)
# GUEST=<vmid> still selects a demo-hp row (the old switch), unless TARGET is set.
TARGETS = {
# 9202 takes its address by DHCP (.114 until 2026-10-19, then .121), so its row names none: it is read from the
# guest at start (resolve_base below); BASE= still wins.
"9202": {"hp": "demo-hp", "ssh": "demo-hp", "jump": None, "guest": "9202",
"base": None, "domain": "enkisfelhom.hu"},
"9201": {"hp": "demo-hp", "ssh": "demo-hp", "jump": None, "guest": "9201",
"base": "https://192.168.0.155", "domain": "enkisfelhom.hu"},
# catalog sweep 2026-10-24: the fresh Tester4 box (nested VM 9360 on demo-hp, host 192.168.0.138, DHCP) — SWEEP COPY
"sweep": {"hp": "sweep", "ssh": "root@192.168.0.138", "jump": None, "guest": "9201",
"base": None, "domain": "tester4.test"},
"tester-1": {"hp": "tester-1", "ssh": "root@192.168.0.154", "jump": "demo-hp", "guest": "9201",
"base": None, "domain": "enkicsifelhom.hu"}, # DHCP: .101 until 2026-10-23, then .148 — read
}
TARGET = os.environ.get("TARGET") or os.environ.get("GUEST", "9202")
if TARGET not in TARGETS:
raise SystemExit(f"box_walk: unknown TARGET {TARGET!r} — one of {sorted(TARGETS)}")
_T = TARGETS[TARGET]
GUEST = _T["guest"]
def ssh_args():
"""The ssh argv prefix that reaches the target's Proxmox host (a jump when the row names one)."""
a = ["ssh", "-o", "ConnectTimeout=20", "-o", "StrictHostKeyChecking=accept-new"]
if _T["jump"]:
a += ["-J", _T["jump"]]
return a + [_T["ssh"]]
LOG = []
def resolve_base():
"""A DHCP guest's dashboard address, read from the guest itself over the host's pct (no guessing)."""
r = subprocess.run(ssh_args() + ["pct", "exec", _T["guest"], "--", "ip", "-4", "-o",
"addr", "show", "eth0"], capture_output=True, text=True, timeout=60)
m = re.search(r"inet (\d+\.\d+\.\d+\.\d+)/", r.stdout)
if not m:
raise SystemExit("box_walk: could not read guest %s's address (%s) — set BASE=https://<ip>"
% (_T["guest"], (r.stderr or r.stdout).strip()[-120:]))
return "https://" + m.group(1)
BASE = os.environ.get("BASE") or _T["base"] or resolve_base()
DOMAIN = os.environ.get("DOMAIN") or _T["domain"]
HOSTHDR = f"Host: felhom.{DOMAIN}"
HP = _T["hp"]
def say(*a):
line = " ".join(str(x) for x in a)
ts = datetime.now().strftime("%H:%M:%S")
print(f"{ts} {line}", flush=True)
LOG.append(f"{ts} {line}")
def sh(args, timeout=300, inp=None):
try:
return subprocess.run(args, capture_output=True, text=True, timeout=timeout, input=inp)
except (subprocess.TimeoutExpired, OSError) as e:
return subprocess.CompletedProcess(args, 124, "", f"{e}")
def guest(script, timeout=600):
"""Run a bash script inside guest 9202. Piped as a file — never as an argument (quoting)."""
# ONE TEMP FILE PER CALL (night 2026-09-23): the shared /tmp/w<guest>.sh swapped scripts under
# two concurrent walks (memory: guest-helper-shares-one-tmp-file).
import secrets as _s
t = f"/tmp/w{GUEST}-{os.getpid()}-{_s.token_hex(4)}.sh"
r = sh(ssh_args() + [
f"export LC_ALL=C; cat > {t}; pct push {GUEST} {t} {t} >/dev/null 2>&1; "
f"pct exec {GUEST} -- bash {t}; pct exec {GUEST} -- rm -f {t}; rm -f {t}"],
timeout=timeout, inp=script)
return r.stdout or ""
def login():
pw = open(f"{SC}/.ctlpw").read().strip()
sh(["curl", "-sk", "-D", f"{SC}/hdr{os.getpid()}.txt", "-o", "/dev/null", "-H", HOSTHDR,
"-X", "POST", "--data-urlencode", f"password={pw}", f"{BASE}/login"])
h = open(f"{SC}/hdr{os.getpid()}.txt").read()
m = re.search(r"felhom_session=[A-Za-z0-9._-]+", h, re.I)
if not m:
sys.exit("login failed: no session cookie")
open(f"{SC}/sess{os.getpid()}.txt", "w").write(m.group(0))
r = sh(["curl", "-sk", "-L", "-H", HOSTHDR, "-H", f"Cookie: {m.group(0)}", f"{BASE}/"])
c = re.search(r'<meta name="csrf-token" content="([^"]+)"', r.stdout or "")
if not c:
sys.exit("login failed: no csrf token")
open(f"{SC}/csrf{os.getpid()}.txt", "w").write(c.group(1))
def ctl(method, path, data=None, raw=False, tries=2):
"""One controller API call. Re-logs in once on a 302/401 — the controller's session store is
in memory, so any controller restart during the night invalidates it silently."""
for attempt in range(tries):
sess = open(f"{SC}/sess{os.getpid()}.txt").read().strip()
csrf = open(f"{SC}/csrf{os.getpid()}.txt").read().strip()
args = ["curl", "-sk", "-H", HOSTHDR, "-H", f"Cookie: {sess}", "-w", "\n%{http_code}"]
if method != "GET":
args += ["-H", f"X-CSRF-Token: {csrf}", "-H", "Content-Type: application/json",
"-X", method]
if data is not None:
args += ["--data", json.dumps(data)]
args.append(f"{BASE}{path}")
r = sh(args)
body, _, code = (r.stdout or "").rpartition("\n")
if code.strip() in ("302", "401") and attempt + 1 < tries:
login()
continue
if raw:
return code.strip(), body
try:
return code.strip(), json.loads(body)
except Exception:
return code.strip(), {"_raw": body[:600]}
return code.strip(), {"_raw": body[:600]}
def page(path):
sess = open(f"{SC}/sess{os.getpid()}.txt").read().strip()
r = sh(["curl", "-sk", "-H", HOSTHDR, "-H", f"Cookie: {sess}", f"{BASE}{path}"])
return r.stdout or ""
GATE = {} # sub -> the felhom_gate cookie the household's browser would hold (setup gate, v0.280.0)
def _loc(out):
m = re.search(r"(?im)^location:\s*(\S+)", out or "")
return m.group(1) if m else ""
def gate_cookie(sub):
"""Pass the setup gate (`09` decision 46) the way the HOUSEHOLD does: the app host redirects to the
dashboard's /__gate/start, which (with the dashboard session) redirects back to the app host's
/__felhom_gate/cb, which sets `felhom_gate`. Never printed."""
import urllib.parse
sess = open(f"{SC}/sess{os.getpid()}.txt").read().strip()
r = sh(["curl", "-sk", "-D", "-", "-o", "/dev/null", "-H", "Accept: text/html", "-H", f"Host: {sub}.{DOMAIN}", f"{BASE}/"])
loc = _loc(r.stdout)
st = re.match(r"HTTP/\S+ (\d+)", r.stdout or "")
if not loc and (not st or st.group(1) in ("404", "502", "503", "504")):
# the app is not routed at this moment (a restart, an update's stop): NOT the same as "not gated" — a cached ""
# here sent every later call past nothing and read the gate's 401 as the app's (2026-10-02, grim-step)
say(f" gate: {sub} not routed right now ({st.group(1) if st else 'no answer'}) — not cached, asked again next call")
return ""
if "/__family/start" in loc: # v0.287.0: the FAMILY gate — the household's dashboard session vouches, as for
return family_cookie(sub, loc, sess) # the setup gate (decisions 63/64)
if "/__gate/start" not in loc:
GATE[sub] = ""
return "" # not gated (open, or no gate for this app)
u = urllib.parse.urlsplit(loc)
r = sh(["curl", "-sk", "-D", "-", "-o", "/dev/null", "-H", "Accept: text/html", "-H", f"Host: {u.hostname}", "-H", f"Cookie: {sess}",
f"{BASE}{u.path}?{u.query}"])
loc = _loc(r.stdout)
u = urllib.parse.urlsplit(loc)
if "/__felhom_gate/cb" not in u.path:
say(f" gate: the dashboard did not hand back a callback for {sub} ({loc[:80]})")
return ""
r = sh(["curl", "-sk", "-D", "-", "-o", "/dev/null", "-H", "Accept: text/html", "-H", f"Host: {u.hostname}", f"{BASE}{u.path}?{u.query}"])
m = re.search(r"(?im)^set-cookie:\s*(felhom_gate=[^;\r\n]+)", r.stdout or "")
GATE[sub] = m.group(1) if m else ""
say(f" gate: {sub} is gated — passed as the household (cookie {'set' if GATE[sub] else 'NOT set'})")
return GATE[sub]
def family_cookie(sub, loc, sess):
"""Pass the FAMILY gate (controller >= 0.287.0) as the household: /__family/start on the dashboard host with the
dashboard session → the app host's /__felhom_gate/fcb → `felhom_famgate`. Never printed."""
import urllib.parse
u = urllib.parse.urlsplit(loc)
r = sh(["curl", "-sk", "-D", "-", "-o", "/dev/null", "-H", "Accept: text/html", "-H", f"Host: {u.hostname}", "-H", f"Cookie: {sess}",
f"{BASE}{u.path}?{u.query}"])
u = urllib.parse.urlsplit(_loc(r.stdout))
if "/__felhom_gate/fcb" not in u.path:
say(f" family gate: the dashboard did not hand back a callback for {sub}")
GATE[sub] = ""
return ""
r = sh(["curl", "-sk", "-D", "-", "-o", "/dev/null", "-H", "Accept: text/html", "-H", f"Host: {u.hostname}", f"{BASE}{u.path}?{u.query}"])
m = re.search(r"(?im)^set-cookie:\s*(felhom_famgate=[^;\r\n]+)", r.stdout or "")
GATE[sub] = m.group(1) if m else ""
say(f" family gate: {sub} is family-gated — passed as the household (cookie {'set' if GATE[sub] else 'NOT set'})")
return GATE[sub]
def app_curl(sub, path, *extra, method=None, data=None, timeout=45, _retry=True):
"""A call to the APP's own front door on 9202 — the household's route, not ours. Carries the setup
gate's cookie when the app is gated, merged into a fixture's own Cookie header (never a second one)."""
raw = list(extra)
gc = GATE[sub] if sub in GATE else gate_cookie(sub)
ext = list(raw)
if gc:
merged = False
for i, a in enumerate(ext):
if isinstance(a, str) and a.lower().startswith("cookie:") and i > 0 and ext[i - 1] == "-H":
ext[i] = a + "; " + gc
merged = True
if not merged:
ext = ["-H", f"Cookie: {gc}"] + ext
args = ["curl", "-sSk", "--max-time", str(timeout), "-H", f"Host: {sub}.{DOMAIN}",
"-w", "\n%{http_code} %{redirect_url}"]
if method:
args += ["-X", method]
if data is not None:
args += ["--data-binary", "@-"]
args += ext + [f"{BASE}{path}"]
r = sh(args, timeout=timeout + 30, inp=data)
body, _, tail = (r.stdout or "").rpartition("\n")
code, _, redir = tail.strip().partition(" ")
if _retry and ("/__gate/start" in redir or "/__family/start" in redir):
GATE.pop(sub, None) # the gate cookie expired or was never taken — log in as the household again
return app_curl(sub, path, *raw, method=method, data=data, timeout=timeout, _retry=False)
return r.returncode, code.strip(), body
def stack(name):
_, d = ctl("GET", f"/api/stacks/{name}")
return (d.get("data") or {}) if isinstance(d, dict) else {}
def wait_app(sub, path="/", want=("200", "302", "303", "401", "403"), tries=60, delay=5):
"""Settling says the container runs; this says the APP answers. Not the same thing."""
last = None
for _ in range(tries):
rc, code, _ = app_curl(sub, path, timeout=15)
last = (rc, code)
if rc == 0 and code in want:
return True
time.sleep(delay)
say(f" app never answered on {sub}{path} (last rc={last[0]} code={last[1]})")
return False
# ------------------------------------------------------------------ the walk
DRIVE_ROOT = os.environ.get("DRIVE_ROOT", "/mnt/felhom-drives/scratch_hdd") # SWEEP COPY: env override
DRIVE = DRIVE_ROOT + "/userdata"
# What THIS run generated for a deploy, per app. Deploy secrets are ENCRYPTED AT REST in
# `app.yaml` (`ENC:…`), which is right and which means a fixture cannot read an app's admin
# password back off the box — the household sees it once. So the value the harness itself
# generated is kept here for the life of the run, and nowhere else.
GENERATED = {}
# The apps THIS run installed (deploy() adds one on its 202). The test-box admin seed refuses an app not in it: an app
# the walk merely reused may be a household's (upgrade_fixtures_box.box_admin_seed_allowed, R-890).
DEPLOYED_THIS_RUN = set()
def deploy_values(name, sub, given=None):
"""Fill EVERY required deploy field the way the wizard would, by asking the box what this app
asks for — `GET /api/stacks/<n>/deploy-fields` — instead of assuming DOMAIN+SUBDOMAIN.
Measured 2026-09-21: three apps in one batch refused at the deploy with a correct 400 because
a required field was absent — `HDD_PATH` (navidrome, audiobookshelf) and an admin password
(grafana). The refusals happen BEFORE anything is created (`deploy.go:324`), which is the only
reason this was safe to discover by running it (live-probes rule).
A `path` field must name a directory that ALREADY EXISTS (`deploy.go:330`), so one is made on
the scratch drive first — the same act the drive browser performs for a household.
"""
code, d = ctl("GET", f"/api/stacks/{name}/deploy-fields")
fields = (((d.get("data") or {}).get("metadata") or {}).get("deploy_fields")) or []
values = {"DOMAIN": DOMAIN, "SUBDOMAIN": sub}
values.update(given or {}) # a caller's own value wins, and no drive folder is made for it (2026-10-16: a passed
made = [] # HDD_PATH still got DRIVE/<app> created, an empty folder nobody asked for)
for f in fields:
ev, ty = f.get("env_var"), f.get("type")
if ev in values:
continue
# `type: password` is MANDATORY whatever `required` says — `deploy.go:305-312` refuses
# when the caller sends none, deliberately ("the user needs to know their password"),
# while `.felhom.yml` declares `required: false` and the API serves that verbatim. A
# caller that trusts the contract gets a 400. Measured tonight on grafana; filed.
if not f.get("required") and ty != "password":
continue # the controller generates the optional secrets itself
if ty == "path" and ev == "HDD_PATH":
# R-839 (controller): the deploy API refuses an HDD_PATH that is a folder INSIDE a registered drive —
# the app's data place is the drive itself. Measured 2026-10-24: DRIVE/<app> came back 409.
values[ev] = DRIVE_ROOT
elif ty == "path":
p = f"{DRIVE}/{name}"
values[ev] = p
made.append(p)
elif ty in ("secret", "password"):
import secrets as _s
values[ev] = "Drill-" + _s.token_hex(12)
GENERATED.setdefault(name, {})[ev] = values[ev]
elif f.get("default"):
values[ev] = f["default"]
else:
values[ev] = f"drill-{name}"
if made:
guest("mkdir -p " + " ".join(made) + "; ls -ld " + " ".join(made))
say(f" [1] made the drive paths this app requires: {made}")
extra = [k for k in values if k not in ("DOMAIN", "SUBDOMAIN")]
if extra:
say(f" [1] required fields filled beyond DOMAIN/SUBDOMAIN: {extra}")
return values
def deploy(name, sub, extra_values=None):
st = stack(name)
if st.get("deployed"):
say(f" [1] {name} already deployed — reusing")
return True
values = deploy_values(name, sub, extra_values)
body = {"values": values}
if os.environ.get("KEPT"): # decision 36: the household's answer when the drive holds old data ("fresh" moves it aside, deletes nothing)
body["kept_data"] = os.environ["KEPT"]
code, d = ctl("POST", f"/api/stacks/{name}/deploy", body)
say(f" [1] deploy -> {code} {str(d)[:120]}")
if code != "202":
return False
DEPLOYED_THIS_RUN.add(name)
# WAIT FOR `deployed`, NOT FOR `running`. Measured 2026-09-21 on tandoor: docker reported the
# container `healthy` while the controller's own state read `unhealthy` — a gate on `running`
# alone therefore times out on an app that is up. The state is RECORDED rather than required;
# the real gate is the fixture's own `wait_app`, which asks whether the APP answers.
seen = None
for _ in range(90):
time.sleep(5)
st = stack(name)
seen = st.get("state")
# `deployed` alone is NOT enough and `state` alone is NOT right. Measured 2026-09-21:
# tandoor reads `unhealthy` while serving (R-618), so gating on "running" hangs; and romm
# read `deployed=True, state=degraded, pinned_images=None` twenty seconds in, i.e. the
# deploy had not finished writing app.yaml. The PIN is the deploy's own completion mark
# (`runComposeDeploy` writes it), so that is what to wait for.
pins = (st.get("app_config") or {}).get("pinned_images")
if st.get("deployed") and pins and seen in ("running", "unhealthy", "degraded"):
say(f" [1] deployed, controller state={seen}, "
f"pinned={(st.get('app_config') or {}).get('pinned_images')}")
if seen != "running":
say(f" [1] NOTE: the controller's own state is {seen!r}, not 'running' — recorded, "
f"not treated as a failure; the fixture's front-door wait is the real gate")
return True
say(f" [1] never became deployed (last controller state={seen!r})")
return False
def backup_now(name):
"""R-648 (2026-09-23): NO whole-box „Mentés most" from a drill, ever.
`POST /api/backup/run` is the only backup endpoint and it is WHOLE-BOX: on 9201 it stopped and
restarted 9 of 10 standing apps twice, and on 9202 it broke a deploy in flight (R-634). The product
has NO per-app backup endpoint (router.go: /backup/run, /backup/tier2 only); the per-app backup
exists only inside the guarded update, whose `backing-up` phase calls RunAppBackupNow for the one
app. So this presses nothing: the update takes the throwaway app's own backup, and says so in its
phase list. A seed written "after the backup" is therefore written before the update's own backup
— the undo's last-second copy is still the one that must bring it back."""
say(f" [4] backup press SKIPPED for {name} (R-648: whole-box only; the update's backing-up phase backs up {name} alone)")
return None
def drill_bump(app, frm, to, service_hint=None):
"""Serialised across concurrent walks: one git working tree, one lock."""
import fcntl
with open(f"{SC}/drill.lock", "w") as lk:
fcntl.flock(lk, fcntl.LOCK_EX)
sh(["git", "-C", DRILL, "pull", "-q", "--rebase", "origin", "main"], timeout=120)
return _drill_bump(app, frm, to, service_hint)
def _drill_bump(app, frm, to, service_hint=None):
"""Commit the edge to the DRILL repo. catalog_since set by hand (the drill repo has no gates).
`frm`/`to` may be comma-separated lists of the SAME length: an app whose own version lives in
two images (adventurelog's backend and frontend) moves both in one edge, while its engine
sidecar stays where it is — `09` §3b Q3's rule is per SERVICE, and an app-half edge must move
every service that carries the app's own version and no others.
"""
comp = f"{DRILL}/templates/{app}/docker-compose.yml"
fy = f"{DRILL}/templates/{app}/.felhom.yml"
s = open(comp).read()
froms = [x.strip() for x in frm.split(",") if x.strip()]
tos = [x.strip() for x in to.split(",") if x.strip()]
if len(froms) != len(tos):
say(f" [5] from/to lists differ in length: {froms} vs {tos}")
return None
for f1, t1 in zip(froms, tos):
if f"image: {f1}" not in s:
say(f" [5] FROM ref not found in compose: {f1}")
return None
s = s.replace(f"image: {f1}", f"image: {t1}")
open(comp, "w").write(s)
f = open(fy).read()
today = datetime.now().strftime("%Y-%m-%d")
f = re.sub(r'^catalog_since:.*$', f'catalog_since: "{today}"', f, count=1, flags=re.M)
open(fy, "w").write(f)
sh(["git", "-C", DRILL, "add", "-A"])
sh(["git", "-C", DRILL, "commit", "-q", "-m", f"DRILL {app}: {frm} -> {to}"])
r = sh(["git", "-C", DRILL, "push", "-q", "origin", "main"], timeout=120)
h = sh(["git", "-C", DRILL, "rev-parse", "--short=12", "HEAD"]).stdout.strip()
say(f" [5] drill commit {h}: {app} {frm} -> {to} (push rc={r.returncode})")
refresh_box_mirror()
return h
BOX_VOL = "/var/lib/docker/volumes/felhom-controller-data/_data" # the controller's data volume, guest path
BOX_CVOL = "/opt/docker/felhom-controller" # the same, inside the controller container
MIRROR_URL = f"file://{BOX_CVOL}/drill-catalog.git"
def refresh_box_mirror():
"""A box pointed at the drill through its LOCAL MIRROR (R-992: no catalog credential on the box — the mirror method of
felhom.eu audits/burndown-3-2026-10-17/tools/repoint.py) sees a drill commit only after the mirror is loaded again.
Without this a drill commit reaches Gitea and never the box, and a walk then presses Update on nothing. A box on any
other catalog URL is left alone (it fetches the drill itself)."""
conf = guest(f"grep -E '^\\s+repo_url:' {BOX_VOL}/controller.yaml")
if MIRROR_URL not in conf:
return False
import secrets as _s
b = f"/tmp/drill-walk-{os.getpid()}-{_s.token_hex(4)}.bundle"
sh(["git", "-C", DRILL, "bundle", "create", b, "main"])
sh(["scp", "-q", "-o", "StrictHostKeyChecking=accept-new"] + (["-J", _T["jump"]] if _T["jump"] else [])
+ [b, f"{_T['ssh']}:{b}"], timeout=120)
sh(ssh_args() + [f"pct push {GUEST} {b} {BOX_VOL}/{os.path.basename(b)} && rm -f {b}"], timeout=120)
out = guest(f"""docker exec felhom-controller sh -c 'git -C {BOX_CVOL}/drill-catalog.git fetch -q {BOX_CVOL}/{os.path.basename(b)} +main:main && rm -f {BOX_CVOL}/{os.path.basename(b)}; git -C {BOX_CVOL}/drill-catalog.git log -1 --format="%h" main'""")
os.remove(b)
say(f" [5] the box's drill mirror now at {out.strip()}")
return True
def sync_rescan(expect_app=None, expect_ref=None, tries=12, delay=5):
"""Sync, rescan, and — when told what to expect — WAIT FOR THE BADGE TO CATCH UP.
R-607: `POST /api/sync` answers "nincs valtozas" while the catalog HAS moved, and
`catalog_images` stays stale until a separate rescan. Tonight showed the rescan alone is not
enough either: mealie's badge read "Naprakesz" seconds after its bump was pushed, and the
Update that followed moved nothing and still reported "Frissitve". So when the caller knows
which reference should appear, this polls for it and SAYS HOW LONG IT TOOK — which is the
NUMBER R-607 asks for and has never had.
"""
t0 = time.time()
ctl("POST", "/api/sync")
time.sleep(2)
ctl("POST", "/api/stacks/rescan")
time.sleep(2)
if not expect_app or not expect_ref:
return None
for i in range(tries):
cat = stack(expect_app).get("catalog_images") or {}
if expect_ref in cat.values():
waited = round(time.time() - t0, 1)
if i:
say(f" [sync] the badge needed {waited}s and {i+1} sync+rescan rounds to catch up "
f"to {expect_ref} — R-607's window, measured")
return waited
time.sleep(delay)
ctl("POST", "/api/sync")
time.sleep(1)
ctl("POST", "/api/stacks/rescan")
say(f" [sync] the badge NEVER caught up to {expect_ref} in {round(time.time()-t0,1)}s — "
f"catalog_images = {stack(expect_app).get('catalog_images')}")
return None
def badges(name):
out = {}
for lang, suffix in (("hu", ""), ("en", "?lang=en")):
h = page(f"/apps/{name}{suffix}")
m = re.findall(r'<span class="tag tag-[^"]*"[^>]*title="([^"]*)"[^>]*>([^<]*)<', h)
out[lang] = [{"title": a.strip(), "text": b.strip()} for a, b in m][:3]
return out
def press_update(name, poll=1.0, cap_s=1800):
code, d = ctl("POST", f"/api/stacks/{name}/update")
say(f" [6] Update -> {code} {str(d)[:220]}")
if code not in ("202", "200"):
return {"accepted": False, "http": code, "refusal": d, "phases": [], "duration_s": 0}
phases, seen, t0 = [], None, time.time()
while time.time() - t0 < cap_s:
st = stack(name)
ph = st.get("update_phase")
if ph != seen:
seen = ph
rec = {"t": round(time.time() - t0, 1), "phase": ph,
"label": st.get("update_phase_label"), "updating": st.get("updating"),
"error": st.get("update_error"), "hold": st.get("hold_reason")}
phases.append(rec)
say(f" +{rec['t']:>6.1f}s phase={ph} label={rec['label']} "
f"err={rec['error']} hold={rec['hold']}")
if not st.get("updating") and ph in ("done", "failed", "undone", None) and time.time() - t0 > 3:
break
time.sleep(poll)
st = stack(name)
return {"accepted": True, "http": code, "phases": phases,
"duration_s": round(time.time() - t0, 1),
"final_phase": st.get("update_phase"), "update_error": st.get("update_error"),
"hold_reason": st.get("hold_reason"), "state": st.get("state")}
def observables(name):
st = stack(name)
ac = st.get("app_config") or {}
live = guest(f"""
grep -E '^\\s+image:' /opt/docker/stacks/{name}/docker-compose.yml 2>/dev/null | sed 's/^ *//'
echo '---inspect---'
for c in $(docker ps -a --filter label=com.docker.compose.project={name} --format '{{{{.Names}}}}'); do
echo -n "$c "; docker inspect "$c" --format '{{{{.Config.Image}}}} running={{{{.State.Running}}}} restarts={{{{.RestartCount}}}}'
done
""")
a, _, b = live.partition("---inspect---")
return {
"pinned_images": ac.get("pinned_images"),
"installed_images": {k: (v.get("ref") if isinstance(v, dict) else v)
for k, v in (ac.get("installed_images") or {}).items()},
"catalog_images": st.get("catalog_images"),
"live_compose_image_lines": [x for x in a.strip().splitlines() if x.strip()],
"docker_inspect": [x for x in b.strip().splitlines() if x.strip()],
}
def app_logs(name, lines=400):
"""The app's own container log, DECODED. The endpoint answers a JSON envelope whose `logs` is
one string with escaped newlines — a scan over the envelope sees a single enormous line and
finds nothing, which reads exactly like "the app printed no migration line" and is not. R-96
rule 3 in a new place: an absent line is not evidence when the instrument cannot see lines."""
code, d = ctl("GET", f"/api/stacks/{name}/logs?lines={lines}")
if isinstance(d, dict):
data = d.get("data")
if isinstance(data, dict) and isinstance(data.get("logs"), str):
return data["logs"]
if isinstance(d.get("_raw"), str):
return d["_raw"]
return str(d)
def write_verdict(rec, appdir):
os.makedirs(appdir, exist_ok=True)
p = os.path.join(appdir, "verdict.json")
json.dump(rec, open(p, "w"), indent=2, ensure_ascii=False)
say(f" [9] verdict {rec['verdict']} -> {p}")
def remove(name):
"""Remove through the PRODUCT, never `docker rm` (live-probes rule). The remove endpoint
refuses a running stack — `409 still running` — so the stop is part of the act, not a tidy-up."""
c1, d1 = ctl("POST", f"/api/stacks/{name}/stop")
say(f" [X] stop -> {c1} {str(d1)[:100]}")
for _ in range(24):
time.sleep(5)
if stack(name).get("state") != "running":
break
code, d = ctl("POST", f"/api/stacks/{name}/remove",
{"remove_hdd_data": True, "remove_backups": True})
say(f" [X] remove (with drive data) -> {code} {str(d)[:160]}")
if code == "409":
# R-442's fail-closed guard: when the storage subsystem cannot RESOLVE the app's drive
# path, the removal is REFUSED and the app is kept rather than half-deleted. On guest 9202
# `/api/disks` answers `agent not configured`, so every app deployed with an HDD_PATH hits
# this. The household's other choice — remove the app, KEEP the data — is accepted, and the
# harness takes it, then tidies its own directory by name at teardown.
say(" [X] refused because the drive path cannot be resolved (R-442, fail-closed and right)"
" — removing the app and KEEPING the drive data instead")
code, d = ctl("POST", f"/api/stacks/{name}/remove",
{"remove_hdd_data": False, "remove_backups": True})
say(f" [X] remove (keeping drive data) -> {code} {str(d)[:160]}")
time.sleep(5)
st = stack(name)
# The stack folder itself stays: it is the catalog's template copy (every app has one). An install leaves its record,
# app.yaml — that is the leftover to look for (2026-10-24: the folder alone read as a leftover after a clean remove).
left = guest(f"ls -d /opt/docker/stacks/{name}/app.yaml 2>/dev/null; "
f"docker ps -a --filter label=com.docker.compose.project={name} --format '{{{{.Names}}}}'")
say(f" [X] after remove: deployed={st.get('deployed')} leftovers={left.strip()!r}")
return code
def app_env(name, key):
"""Read one deploy value the CUSTOMER was given (e.g. the generated admin password) from the
app's own `app.yaml`. This is not seeding — it is how the household logs in; the controller
shows them the same value. Data still goes in through the app's own front door."""
out = guest(f"grep -E '^\\s*{key}:' /opt/docker/stacks/{name}/app.yaml 2>/dev/null | head -1")
if ":" in out:
return out.split(":", 1)[1].strip().strip('"').strip("'")
return ""
def snapshots(name):
"""The restorable copies the backups page offers for this app."""
code, d = ctl("GET", f"/api/backup/snapshots?stack={name}")
data = d.get("data") if isinstance(d, dict) else None
if isinstance(data, dict):
for k in ("snapshots", "items", "restore_points"):
if isinstance(data.get(k), list):
return data[k]
return data if isinstance(data, list) else []
def restore(name, snapshot_id=None, wait_s=1200):
"""The household's own way out: the „Visszaállítás a mentésből" button on the backups page.
A FORM post, not an API call — `POST /backup/restore` with `_csrf`, `stack_name`,
`snapshot_id` — because that is the button the sentence tells them to press.
"""
snaps = snapshots(name)
if snapshot_id is None:
if not snaps:
say(f" [R] no restorable copy offered for {name}")
return {"ok": False, "why": "no snapshot offered", "snapshots": snaps}
first = snaps[0]
snapshot_id = first.get("id") or first.get("snapshot_id") or first.get("short_id")
say(f" [R] restoring {name} from snapshot {snapshot_id!r} (of {len(snaps)} offered)")
sess = open(f"{SC}/sess{os.getpid()}.txt").read().strip()
csrf = open(f"{SC}/csrf{os.getpid()}.txt").read().strip()
r = sh(["curl", "-sk", "-D", "-", "-o", "/dev/null", "-H", HOSTHDR, "-H", f"Cookie: {sess}",
"-X", "POST",
"--data-urlencode", f"_csrf={csrf}",
"--data-urlencode", f"stack_name={name}",
"--data-urlencode", f"snapshot_id={snapshot_id}",
f"{BASE}/backup/restore"], timeout=180)
head = (r.stdout or "").split("\n")[0].strip()
loc = [l for l in (r.stdout or "").split("\n") if l.lower().startswith("location:")]
say(f" [R] POST /backup/restore -> {head} {loc[:1]}")
t0 = time.time()
last = None
while time.time() - t0 < wait_s:
code, d = ctl("GET", "/api/backup/restore-status")
dd = d.get("data") or {}
cur = (dd.get("running"), dd.get("phase") or dd.get("state"), dd.get("message"))
if cur != last:
say(f" +{round(time.time()-t0,1):>6.1f}s restore {cur}")
last = cur
if not dd.get("running", False) and time.time() - t0 > 5:
break
time.sleep(2)
st = stack(name)
say(f" [R] after restore: state={st.get('state')} hold={st.get('hold_reason')!r} "
f"phase={st.get('update_phase')}")
return {"ok": True, "snapshot_id": snapshot_id, "snapshots": snaps,
"http": head, "location": loc[:1], "seconds": round(time.time() - t0, 1),
"state_after": st.get("state"), "hold_after": st.get("hold_reason"),
"observables_after": observables(name)}
# ------------------------------------------------------------------ the security-fix walk (D39 = B)
SECURITY_WALK_VERSION = 1
SOAK_DEFAULT_S = 600
def soak(name, seconds, interval=10):
"""The box's memory soak (`09` §3 decision 255): every `interval` s for `seconds`, per container of the app, the
app's own memory (cgroup `anon`, decision 22 — the cgroup peak counts the file cache) against its limit, the
cgroup peak, the OOM-kill counter and the restart count. One guest call; the guest prints one JSON line a sample."""
script = f"""
set -u
ids=$(docker ps -a --filter label=com.docker.compose.project={name} --format '{{{{.ID}}}}')
end=$(( $(date +%s) + {int(seconds)} ))
while :; do
for id in $ids; do
full=$(docker inspect -f '{{{{.Id}}}}' $id); nm=$(docker inspect -f '{{{{.Name}}}}' $id | tr -d /)
rc=$(docker inspect -f '{{{{.RestartCount}}}}' $id); run=$(docker inspect -f '{{{{.State.Running}}}}' $id)
start=$(docker inspect -f '{{{{.State.StartedAt}}}}' $id); oomk=$(docker inspect -f '{{{{.State.OOMKilled}}}}' $id)
cg=""; for c in /sys/fs/cgroup/system.slice/docker-$full.scope /sys/fs/cgroup/docker/$full; do [ -d "$c" ] && cg=$c; done
anon=0; mx=max; pk=0; ok=0
if [ -n "$cg" ]; then
anon=$(awk '$1=="anon"{{print $2}}' $cg/memory.stat); mx=$(cat $cg/memory.max)
pk=$(cat $cg/memory.peak 2>/dev/null || echo 0); ok=$(awk '$1=="oom_kill"{{print $2}}' $cg/memory.events)
fi
printf '{{"t":%s,"c":"%s","anon":%s,"max":"%s","peak":%s,"oom_kill":%s,"restarts":%s,"running":"%s","started":"%s","oomkilled":"%s","cg":"%s"}}\\n' \\
"$(date +%s)" "$nm" "${{anon:-0}}" "$mx" "${{pk:-0}}" "${{ok:-0}}" "$rc" "$run" "$start" "$oomk" "$cg"
done
[ "$(date +%s)" -ge "$end" ] && break
sleep {int(interval)}
done
"""
out = guest(script, timeout=int(seconds) + 300)
samples = []
for line in out.splitlines():
try:
samples.append(json.loads(line))
except ValueError:
continue
per = {}
for s in samples:
c = per.setdefault(s["c"], {"samples": 0, "anon_peak": 0, "cg_peak": 0, "limit": None, "oom_kill": [],
"restarts": [], "running": [], "started": set(), "oomkilled": False, "cgroup": s["cg"]})
c["samples"] += 1
c["anon_peak"] = max(c["anon_peak"], int(s["anon"]))
c["cg_peak"] = max(c["cg_peak"], int(s["peak"]))
c["limit"] = int(s["max"]) if str(s["max"]).isdigit() else None
c["oom_kill"].append(int(s["oom_kill"]))
c["restarts"].append(int(s["restarts"]))
c["running"].append(s["running"] == "true")
c["started"].add(s["started"])
c["oomkilled"] = c["oomkilled"] or s["oomkilled"] == "true"
res = {"soak_s": int(seconds), "interval_s": interval, "samples": len(samples), "containers": {}}
kills = restarts = 0
peaks = []
for nm, c in per.items():
k = (max(c["oom_kill"]) - min(c["oom_kill"])) if c["oom_kill"] else 0
r = (max(c["restarts"]) - min(c["restarts"])) + (len(c["started"]) - 1)
kills += k + (1 if c["oomkilled"] else 0)
restarts += r
pct = round(100.0 * c["anon_peak"] / c["limit"], 1) if c["limit"] else None
cgp = round(100.0 * c["cg_peak"] / c["limit"], 1) if c["limit"] else None
if pct is not None:
peaks.append(pct)
res["containers"][nm] = {"samples": c["samples"], "anon_peak_bytes": c["anon_peak"], "limit_bytes": c["limit"],
"anon_peak_pct": pct, "cgroup_peak_pct": cgp, "oom_kill_delta": k,
"oom_killed_flag": c["oomkilled"], "restarts_delta": r,
"running_all_samples": all(c["running"]), "cgroup": c["cgroup"]}
res["oom_kills"], res["restarts"] = kills, restarts
res["peak_pct"] = max(peaks) if peaks else None
return res
def setup_gate_probe(sub):
"""How the setup gate (decision 46) answers this app right now, as the household's browser and as a script: status and
redirect target only (the cookie is never printed)."""
out = {}
for label, hdr in (("browser", ["-H", "Accept: text/html"]), ("script", [])):
r = sh(["curl", "-sk", "-D", "-", "-o", "/dev/null", "-H", f"Host: {sub}.{DOMAIN}"] + hdr + [f"{BASE}/identity"])
st = re.match(r"HTTP/\S+ (\d+)", r.stdout or "")
loc = _loc(r.stdout)
out[label] = {"status": st.group(1) if st else None, "redirect": re.sub(r"\?.*", "?…", loc) if loc else None}
return out
def security_walk(app, frm, to, evdir, soak_s=SOAK_DEFAULT_S, values=None):
"""`box_walk.py --security <app> --from <svc=ref,…> --to <svc=ref,…> --ev <dir> [--soak N] [--value K=V …]` — the BOX venue of a
security-fix step (`09` §3 decision 255, D39 = B), on scratch guest 9202 pointed at the DRILL catalog, through the
product's own endpoints only:
1. the drill's template for <app> must stand at FROM (the caller puts it there; this walk never guesses);
2. <app> is installed fresh; the four version observables must read FROM;
3. seed + read-back when a box fixture has the app, else `seed: "none"` — written, never hidden;
4. a DRILL-only commit moves the compose FROM -> TO; sync + rescan until the box's catalog_images carry TO;
5. the product's guarded Update, every phase with its time;
6. after: final phase `done`, no undo, no hold, the app running, the four observables equal TO, the seed read back;
7. the memory soak (default 600 s): a kill or a restart = failed;
8. <evdir>/box-verdict-<app>.json: `proven` only if all of that holds; `inconclusive` stays `inconclusive`.
The app is removed through the product at the end unless KEEP=1."""
import upgrade_fixtures_box as fixtures
import upgrade_fixtures_box28 as _f28
global LOG
LOG = []
FX = dict(_f28.FIXTURES28)
FX.update(fixtures.FIXTURES)
os.makedirs(evdir, exist_ok=True)
rec = {"app": app, "walk": "security", "box_walk_version": SECURITY_WALK_VERSION, "verdict": "inconclusive",
"venue": f"box {GUEST} (drill catalog), the guarded Update", "from": frm, "to": to}
def finish(verdict, why):
rec["verdict"], rec["why"] = verdict, why
rec["log"] = LOG
write_verdict(rec, evdir)
os.replace(os.path.join(evdir, "verdict.json"), os.path.join(evdir, f"box-verdict-{app}.json"))
say(f"RESULT {verdict} — {why}")
if not os.environ.get("KEEP") and stack(app).get("deployed"):
remove(app)
return 0 if verdict == "proven" else 1
login()
conf = guest("grep -A3 '^git:' /var/lib/docker/volumes/felhom-controller-data/_data/controller.yaml")
if "app-catalog-drill" not in conf and MIRROR_URL not in conf:
return finish("inconclusive", f"guest {GUEST} is not on the drill catalog (nor its local mirror) — nothing done")
sh(["git", "-C", DRILL, "pull", "-q", "--rebase", "origin", "main"], timeout=120)
drill_imgs = images_of(open(f"{DRILL}/templates/{app}/docker-compose.yml").read())
if drill_imgs != frm:
return finish("inconclusive", f"the drill's {app} is at {drill_imgs}, not at FROM {frm} — put it there first")
fx = FX.get(app)
sub = getattr(fx, "sub", app) if fx else app
if stack(app).get("deployed"):
say(f" [1] removing the existing {app} first (scratch box)")
remove(app)
sync_rescan()
if not deploy(app, sub, values):
return finish("inconclusive", "the install at FROM did not complete — nothing was tested")
wait_app(sub, "/identity" if app == "plex" else "/", tries=60)
rec["observables_before"] = observables(app)
if rec["observables_before"]["pinned_images"] != frm:
return finish("inconclusive", f"installed at {rec['observables_before']['pinned_images']}, not at FROM")
rec["setup_gate_before"] = setup_gate_probe(sub)
say(f" [2] setup gate as installed: {rec['setup_gate_before']}")
tok = None
if fx is None or fx.__class__.__name__ == "NoRoute":
rec["seed"] = "none"
rec["seed_why"] = getattr(fx, "tried", "no box fixture for this app")
say(f" [3] seed: none — {rec['seed_why']}")
else:
tok = fx.seed(w_self(), sub, say)
if tok is None or not fx.verify(w_self(), sub, tok, say):
return finish("inconclusive", "C1: the fixture could not seed and read back before the update")
rec["seed"] = "read_back"
froms = ",".join(frm[s] for s in sorted(frm) if frm[s] != to.get(s))
tos = ",".join(to[s] for s in sorted(frm) if frm[s] != to.get(s))
rec["drill_commit"] = drill_bump(app, froms, tos)
if not rec["drill_commit"]:
return finish("inconclusive", "the drill could not be moved FROM -> TO")
moved = [s for s in sorted(to) if frm.get(s) != to[s]]
rec["sync_waited_s"] = sync_rescan(app, to[moved[0]], tries=40, delay=10)
if rec["sync_waited_s"] is None:
return finish("inconclusive", f"the box never read TO {to[moved[0]]} from the drill catalog — nothing pressed")
rec["badge_before"] = badges(app)
rec["measured_at"] = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
rec["update"] = press_update(app, poll=1.0, cap_s=1800)
time.sleep(15)
st = stack(app)
rec["observables_after"] = obs = observables(app)
rec["badge_after"] = badges(app)
rec["state_after"], rec["hold_after"] = st.get("state"), st.get("hold_reason")
ii = (st.get("app_config") or {}).get("installed_images") or {}
rec["to_digests"] = {s: (ii.get(s) or {}).get("digest") if isinstance(ii.get(s), dict) else None for s in to}
u = rec["update"]
if not u.get("accepted"):
return finish("failed", f"the Update was refused: {u.get('http')} {str(u.get('refusal'))[:200]}")
if u.get("final_phase") != "done" or u.get("update_error") or rec["hold_after"]:
return finish("failed", f"the update ended {u.get('final_phase')!r} (error {u.get('update_error')!r}, hold {rec['hold_after']!r})")
live = sorted({l.split("image:", 1)[1].strip() for l in obs.get("live_compose_image_lines") or [] if "image:" in l})
four = {"pinned": obs.get("pinned_images"), "installed": obs.get("installed_images"), "catalog": obs.get("catalog_images"),
"live_compose": live}
rec["four_observables"] = four
running = {l.split()[1] for l in obs.get("docker_inspect") or [] if "running=true" in l}
bad = {k: v for k, v in four.items() if k != "live_compose" and v != to}
if bad or set(live) != set(to.values()) or not set(to.values()) <= running:
return finish("failed", f"the version observables are not TO: {bad or four['live_compose']}, running {sorted(running)}")
if rec["state_after"] != "running":
return finish("failed", f"the app is {rec['state_after']!r} after the update")
if tok is not None:
rec["seed_read_after"] = fx.verify(w_self(), sub, tok, say)
if not rec["seed_read_after"]:
return finish("failed", "the seed did not read back after the update")
if any(not d for d in rec["to_digests"].values()):
return finish("inconclusive", f"the box recorded no digest for a TO service: {rec['to_digests']}")
rec["setup_gate_after"] = setup_gate_probe(sub)
say(f" [7] memory soak {soak_s} s …")
rec["memory"] = m = soak(app, soak_s)
say(f" [7] soak: peak {m.get('peak_pct')}% of the limit (anon), kills {m.get('oom_kills')}, restarts {m.get('restarts')}")
if m.get("samples", 0) == 0 or m.get("peak_pct") is None:
return finish("inconclusive", "the soak read no sample — not a pass")
if m["oom_kills"] or m["restarts"] or not all(c["running_all_samples"] for c in m["containers"].values()):
return finish("failed", f"the soak saw {m['oom_kills']} OOM kill(s) and {m['restarts']} restart(s)")
return finish("proven", "the security-fix step ran on the box through the guarded Update; healthy, TO on all four "
"observables, no undo, no hold, a clean soak" + ("" if tok is None else ", seed read back"))
def images_of(compose_text):
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import ladder
return ladder.images_in(compose_text)
def w_self():
return sys.modules[__name__]
def _refs(spec):
"""`svc=ref,svc2=ref2` -> {svc: ref}."""
out = {}
for part in spec.split(","):
k, _, v = part.strip().partition("=")
if k and v:
out[k] = v
return out
def main(argv):
ap = argparse.ArgumentParser(prog="box_walk.py")
ap.add_argument("--security", metavar="APP", required=True,
help="the security-fix walk (09 decision 255) for this app")
ap.add_argument("--from", dest="frm", required=True, help="svc=ref,… the drill must stand at")
ap.add_argument("--to", required=True, help="svc=ref,… the step moves to")
ap.add_argument("--ev", required=True, help="evidence directory")
ap.add_argument("--soak", type=int, default=SOAK_DEFAULT_S)
ap.add_argument("--value", action="append", default=[], metavar="KEY=VAL",
help="a deploy field the wizard would get from the household (e.g. HDD_PATH=<the drive>)")
a = ap.parse_args(argv)
frm, to = _refs(a.frm), _refs(a.to)
if set(frm) != set(to):
print("box_walk: --from and --to must name the same services")
return 2
vals = dict(v.split("=", 1) for v in a.value if "=" in v)
return security_walk(a.security, frm, to, a.ev, a.soak, vals or None)
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))