Files
felhom.eu/documentation/audits/new-apps-2026-10-01/tools/newapp_box.py
T

320 lines
23 KiB
Python

#!/usr/bin/env python3
"""newapp_box.py <phase> <app> <sub> — a NEW app's checklist measurements on scratch guest 9202 (drill catalog), through the
product's own endpoints. Evidence under $EV/box/<app>/.
install — the memory sampler (every named container, from its birth) and a STRANGER's poll of the app (no dashboard
session, no gate cookie, through traefik, once a second) start BEFORE the deploy press; then deploy, the
seed through the app's own route and its read-back (C1). Seed tokens + this run's deploy secrets go to the
0600 file in SC (boxstep.py reads them). [checklist 3.5, 4.3, 5.1 (box; swap ON here — the bench is the
swap-0 venue), 9.2]
checks — the app-specific checks (CHECKS[app]) + the generic ones: an unknown page (1.8), the negative control
(docker pause → the controller's state, 4.4), the server process (1.5).
remove — stop + remove through the product: first REMOVE_HDD=0 then reinstall-free: whatever is left is listed (2.6).
Secrets are never printed.
"""
import json, os, re, secrets, sys, time
import walk as w
sys.path.insert(0, "/mnt/5_hdd/felhom.eu/git/app-catalog-felhom.eu/scripts")
import upgrade_fixtures_box as fixtures, upgrade_fixtures_box28 as _f28
FX = dict(_f28.FIXTURES28); FX.update(fixtures.FIXTURES)
phase, app, sub = sys.argv[1:4]
EVD = f"{w.EV}/box/{app}"; os.makedirs(EVD, exist_ok=True)
TOK = w.SC + "/seed-tokens-box.json"
log = open(f"{EVD}/{phase}.txt", "a", buffering=1)
HOST = f"{sub}.{w.DOMAIN}"
def say(*a):
w.say(*a); log.write(" ".join(map(str, a)) + "\n")
def containers():
return [l for l in w.guest(f"docker ps -a --filter label=com.docker.compose.project={app} --format '{{{{.Names}}}}'").split() if l]
# ---- app-specific checks -----------------------------------------------------------------------------------------
def checks_radicale(t):
user, pw = t["user"], t["pw"]
base = ["-sk", "--max-time", "10", "-H", f"Host: {HOST}"]
def c(*a):
r = w.sh(["curl"] + base + ["-o", "/dev/null", "-w", "%{http_code} %{redirect_url}"] + list(a))
return (r.stdout or "").strip()
say("0.7/3.9 a phone calendar client's discovery, through traefik over https (DAVx5 / iOS shape):")
say(" PROPFIND /.well-known/caldav (no auth) ->", c("-X", "PROPFIND", f"{w.BASE}/.well-known/caldav"))
say(" PROPFIND /.well-known/carddav (no auth) ->", c("-X", "PROPFIND", f"{w.BASE}/.well-known/carddav"))
say(" PROPFIND / Depth:0, right login ->", c("-u", f"{user}:{pw}", "-X", "PROPFIND", "-H", "Depth: 0", f"{w.BASE}/"))
body = w.sh(["curl"] + base + ["-u", f"{user}:{pw}", "-X", "PROPFIND", "-H", "Depth: 0", "-H", "Content-Type: application/xml",
"--data", '<propfind xmlns="DAV:"><prop><current-user-principal/></prop></propfind>', f"{w.BASE}/"]).stdout or ""
m = re.search(r"<(?:\w+:)?href>([^<]+)</(?:\w+:)?href>\s*</(?:\w+:)?current-user-principal>", body)
say(" current-user-principal ->", m.group(1) if m else body[:160])
say(" PROPFIND principal Depth:1 (the calendar list), right login ->", c("-u", f"{user}:{pw}", "-X", "PROPFIND", "-H", "Depth: 1", f"{w.BASE}/{user}/"))
say(" the same with a WRONG password ->", c("-u", f"{user}:{pw}x", "-X", "PROPFIND", "-H", "Depth: 1", f"{w.BASE}/{user}/"))
say(" MKCOL an address book + PUT a contact + GET it back (CardDAV):")
ab = "kontakt-" + secrets.token_hex(3)
mk = ('<mkcol xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:carddav"><set><prop><resourcetype><collection/>'
'<C:addressbook/></resourcetype></prop></set></mkcol>')
say(" MKCOL ->", c("-u", f"{user}:{pw}", "-X", "MKCOL", "-H", "Content-Type: application/xml", "--data", mk, f"{w.BASE}/{user}/{ab}/"))
vcf = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:c1@felhom\r\nFN:Teszt Elek\r\nN:Elek;Teszt;;;\r\nEND:VCARD\r\n"
say(" PUT vcf ->", c("-u", f"{user}:{pw}", "-X", "PUT", "-H", "Content-Type: text/vcard", "--data-binary", vcf, f"{w.BASE}/{user}/{ab}/c1.vcf"))
g = w.sh(["curl"] + base + ["-u", f"{user}:{pw}", f"{w.BASE}/{user}/{ab}/c1.vcf"]).stdout or ""
say(" GET vcf contains FN:Teszt Elek ->", "FN:Teszt Elek" in g)
say("3.6 lock-out: 15 wrong passwords for the user's name as a stranger, then the right one:")
t0 = time.time(); codes = []
for _ in range(15):
codes.append(c("-u", f"{user}:wrong{secrets.token_hex(2)}", "-X", "PROPFIND", "-H", "Depth: 0", f"{w.BASE}/{user}/").split()[0])
dt = time.time() - t0
say(f" 15 wrong -> {sorted(set(codes))} in {dt:.1f} s ({dt/15:.2f} s each — Radicale's own delay)")
say(" right password at once ->", c("-u", f"{user}:{pw}", "-X", "PROPFIND", "-H", "Depth: 0", f"{w.BASE}/{user}/"))
say(" wrong password for ANOTHER name (admin) ->", c("-u", "admin:admin", "-X", "PROPFIND", "-H", "Depth: 0", f"{w.BASE}/admin/"))
say("3.4 sign-up: Radicale has no sign-up route; a stranger's MKCOL on a new principal ->",
c("-X", "MKCOL", f"{w.BASE}/stranger{secrets.token_hex(2)}/"))
say("2.2/2.7 where the data is, and its size:", " ".join(w.guest(
f"for v in $(docker volume ls -q | grep -i {app}); do echo $v=$(du -sh $(docker volume inspect -f '{{{{.Mountpoint}}}}' $v) | cut -f1); done").split()))
say(" the login file inside (mode, owner — never its content):", w.guest(f"docker exec {app} ls -l /var/lib/radicale/users").strip())
def checks_karakeep(t):
base = ["-sk", "--max-time", "15", "-H", f"Host: {HOST}"]
def c(*a):
r = w.sh(["curl"] + base + ["-o", "/dev/null", "-w", "%{http_code}"] + list(a))
return (r.stdout or "").strip()
def trpc_stranger(path, payload):
return c("-H", "Content-Type: application/json", "-X", "POST", "--data", json.dumps({"0": {"json": payload}}), f"{w.BASE}{path}")
st = w.stack(app)
say("before the press: gate closed? a stranger's GET / ->", c(f"{w.BASE}/"), "| controller:", st.get("setup_gate") or st.get("gate"))
pw = "Str-" + secrets.token_hex(8)
say("3.3 BEFORE the household's press, a stranger's users.create ->",
trpc_stranger("/api/trpc/users.create?batch=1", {"name": "x", "email": f"s{secrets.token_hex(2)}@x.invalid", "password": pw, "confirmPassword": pw}))
code, d = w.ctl("POST", f"/apps/{app}/setup-gate/open", {})
say("the household presses „Kész, beállítottam\" ->", code, str(d)[:160])
for i in range(36):
time.sleep(5)
env = w.guest(f"docker inspect {app} --format '{{{{range .Config.Env}}}}{{{{println .}}}}{{{{end}}}}' | grep -E '^DISABLE_SIGNUPS='").strip()
if env.endswith("true"):
break
say("after_setup: the app's own switch now", env, f"(after {5*(i+1)} s)")
w.wait_app(sub, "/api/health", want=("200",), tries=60)
say("3.4 a STRANGER after the setup (no session, no gate cookie, through traefik):")
for label, path in (("users.create", "/api/trpc/users.create?batch=1"), ("batched whoami,users.create", "/api/trpc/users.whoami,users.create?batch=1"),
("upper-case path", "/API/TRPC/USERS.CREATE?batch=1"), ("double slash", "//api/trpc/users.create?batch=1")):
say(f" {label} ->", trpc_stranger(path, {"name": "x", "email": f"s{secrets.token_hex(2)}@x.invalid", "password": pw, "confirmPassword": pw}))
say(" GET /signup ->", c(f"{w.BASE}/signup"), "| GET /SignUp ->", c(f"{w.BASE}/SignUp"))
n = w.guest(f"docker exec {app} sh -c 'ls /data'").split()
say(" the app's own switch, asked straight (inside the container, past traefik):", w.guest(
f"""docker exec {app} sh -c "wget -q -O - --header 'Content-Type: application/json' --post-data '{{\\"0\\":{{\\"json\\":{{\\"name\\":\\"x\\",\\"email\\":\\"in@x.invalid\\",\\"password\\":\\"{pw}\\",\\"confirmPassword\\":\\"{pw}\\"}}}}}}' 'http://127.0.0.1:3000/api/trpc/users.create?batch=1' 2>&1 | head -c 200" """).strip()[:220])
say("3.6 lock-out: 12 wrong apiKeys.exchange for the household's email as a stranger, then the right one:")
codes = [trpc_stranger("/api/trpc/apiKeys.exchange?batch=1", {"keyName": "x", "email": t["email"], "password": "wrong" + secrets.token_hex(2)}) for _ in range(12)]
say(" 12 wrong ->", sorted(set(codes)), "| right at once ->", trpc_stranger("/api/trpc/apiKeys.exchange?batch=1", {"keyName": "y", "email": t["email"], "password": t["pw"]}))
key = fixtures.Karakeep()._key(w, sub, t)
rc, code, out = w.app_curl(sub, "/api/v1/bookmarks", "-H", f"Authorization: Bearer {key}", "-H", "Content-Type: application/json",
data=json.dumps({"type": "link", "url": "https://example.com/"}), method="POST") # felhom.eu resolves to a LAN address here
bid = (json.loads(out) if code in ("200", "201") else {}).get("id")
say("0.5/5.2 a LINK bookmark (the crawler + Chrome): POST ->", code)
for _ in range(24):
time.sleep(5)
rc, c2, o2 = w.app_curl(sub, f"/api/v1/bookmarks/{bid}", "-H", f"Authorization: Bearer {key}")
try:
cont = json.loads(o2).get("content") or {}
except Exception:
cont = {}
if cont.get("crawledAt") or cont.get("title"):
break
say(" crawled:", {k: (str(v)[:60] if v else v) for k, v in cont.items() if k in ("title", "crawledAt", "favicon", "imageUrl")},
"| taggingStatus:", (json.loads(o2) if o2.startswith("{") else {}).get("taggingStatus"))
say("0.4 AI is off: OPENAI_API_KEY in the container is", "EMPTY" if w.guest(f"docker inspect {app} --format '{{{{range .Config.Env}}}}{{{{println .}}}}{{{{end}}}}' | grep -c '^OPENAI_API_KEY=$'").strip() == "1" else "SET (!)",
"| the app's log on inference:", " ".join(w.guest(f"docker logs {app} 2>&1 | grep -i -E 'inference|openai|tagging' | tail -3").split())[:300])
say("2.2/2.7 data and size:", " ".join(w.guest(
f"for v in $(docker volume ls -q | grep -i {app}); do echo $v=$(du -sh $(docker volume inspect -f '{{{{.Mountpoint}}}}' $v) | cut -f1); done").split()))
def checks_dawarich(t):
import tempfile
pw = (w.GENERATED.get(app) or (t or {}).get("__generated") or {}).get("ADMIN_PASSWORD", "")
def web_login(email, password):
jar = tempfile.mktemp(prefix="dw-jar-")
o = f"https://{HOST}"
hdr = ["-H", f"Origin: {o}", "-H", f"Referer: {o}/users/sign_in", "-c", jar, "-b", jar]
rc, code, page = w.app_curl(sub, "/users/sign_in", *hdr)
m = re.search(r'name="authenticity_token" value="([^"]+)"', page or "")
rc, code, body = w.app_curl(sub, "/users/sign_in", *hdr, "--data-urlencode", f"authenticity_token={m.group(1) if m else ''}",
"--data-urlencode", f"user[email]={email}", "--data-urlencode", f"user[password]={password}", method="POST")
os.path.exists(jar) and os.unlink(jar)
locked = "locked" in (body or "").lower()
return code + (" (locked text)" if locked else "")
say("3.2/3.9 the browser form through traefik over https (Origin + CSRF), as the household:")
say(" default demo@dawarich.app / safepassword ->", web_login("demo@dawarich.app", "safepassword"))
say(" the box's generated password ->", web_login("demo@dawarich.app", pw), "(303 = signed in)")
say(" a wrong password ->", web_login("demo@dawarich.app", "wrong-" + secrets.token_hex(3)))
base = ["-sk", "--max-time", "15", "-H", f"Host: {HOST}"]
def c(*a):
return (w.sh(["curl"] + base + ["-o", "/dev/null", "-w", "%{http_code}"] + list(a)).stdout or "").strip()
say("3.4 sign-up as a stranger: GET /users/sign_up ->", c(f"{w.BASE}/users/sign_up"), "| POST /users ->",
c("-X", "POST", "--data-urlencode", "user[email]=s@x.invalid", "--data-urlencode", "user[password]=Str-12345678abc",
"--data-urlencode", "user[password_confirmation]=Str-12345678abc", f"{w.BASE}/users"),
"| users in the app:", w.guest("docker exec dawarich bin/rails runner 'puts User.count' 2>/dev/null | tail -1").strip())
say("3.6 lock-out: 11 wrong passwords for the public name as a stranger, then the household's right one:")
codes = [web_login("demo@dawarich.app", "wrong-" + secrets.token_hex(3)) for _ in range(11)]
say(" 11 wrong ->", codes[-3:], "| right password now ->", web_login("demo@dawarich.app", pw),
"| locked_at:", w.guest("docker exec dawarich bin/rails runner 'puts User.order(:id).first.locked_at.inspect' 2>/dev/null | tail -1").strip())
k = fixtures.Dawarich._key(w)
rc, code, _ = w.app_curl(sub, f"/api/v1/points?api_key={k}&start_at=2026-09-01T00:00:00Z&end_at=2026-09-02T00:00:00Z")
say(" the phone app's API key while the account is locked ->", code)
w.guest("docker exec dawarich bin/rails runner 'User.order(:id).first.unlock_access!' 2>/dev/null")
say(" (unlocked again with the app's own CLI for the rest of the walk) right password ->", web_login("demo@dawarich.app", pw))
say("0.4 outbound connections held by the app at rest:", " ".join(w.guest(
"docker exec dawarich sh -c \"cat /proc/net/tcp /proc/net/tcp6 2>/dev/null\" | awk 'NR>1 && $4==\"01\"{print $3}' | sort -u | head -8").split()) or "none")
say("2.2/2.7 data and size:", " ".join(w.guest(
f"for v in $(docker volume ls -q | grep -i {app}); do echo $v=$(du -sh $(docker volume inspect -f '{{{{.Mountpoint}}}}' $v) | cut -f1); done").split()))
def checks_grimmory(t):
base = ["-sk", "--max-time", "15", "-H", f"Host: {HOST}"]
def c(*a):
return (w.sh(["curl"] + base + ["-o", "/dev/null", "-w", "%{http_code}"] + list(a)).stdout or "").strip()
seen = None
for i in range(40): # the gate opens by itself when the probe reads done (no household press)
seen = c(f"{w.BASE}/")
if seen == "200":
break
time.sleep(10)
say(f"3.3 the gate opened BY ITS PROBE: a stranger's GET / -> {seen} after ~{10*i} s (no press)")
say(" controller log:", " ".join(w.guest(f"docker logs --since 30m felhom-controller 2>&1 | grep -i 'setup gate' | grep -i {app} | tail -2 | cut -c1-200").split())[:400])
say("3.4 a stranger's second setup ->", c("-H", "Content-Type: application/json", "-X", "POST", "--data",
json.dumps({"username": "x", "password": "Str-12345678abc", "email": "x@x.invalid", "name": "X"}), f"{w.BASE}/api/v1/setup"),
"| GET /api/v1/books with no token ->", c(f"{w.BASE}/api/v1/books"))
code, tok = fixtures.Grimmory()._token(w, sub, t["user"], t["pw"])
auth = ["-H", f"Authorization: Bearer {tok}"]
say("0.7 OPDS for e-readers: switched on in the settings (as the household does) ->",
w.app_curl(sub, "/api/v1/settings", *auth, "-H", "Content-Type: application/json", data=json.dumps([{"name": "OPDS_SERVER_ENABLED", "value": True}]), method="PUT")[1],
"| an OPDS user ->", w.app_curl(sub, "/api/v2/opds-users", *auth, "-H", "Content-Type: application/json", data=json.dumps({"username": "olvaso", "password": "Op-" + t["pw"][-10:]}), method="POST")[1])
say(" the OPDS feed through traefik over https with basic auth: right ->", c("-u", "olvaso:Op-" + t["pw"][-10:], f"{w.BASE}/api/v1/opds"),
"| wrong ->", c("-u", "olvaso:wrong", f"{w.BASE}/api/v1/opds"), "| none ->", c(f"{w.BASE}/api/v1/opds"))
say("3.6 lock-out: 12 wrong sign-ins for `admin` as a stranger, then the right one:")
codes = [c("-H", "Content-Type: application/json", "-X", "POST", "--data", json.dumps({"username": "admin", "password": "w" + secrets.token_hex(3)}), f"{w.BASE}/api/v1/auth/login") for _ in range(12)]
say(" 12 wrong ->", sorted(set(codes)), "| right at once ->", fixtures.Grimmory()._token(w, sub, t["user"], t["pw"])[0])
say("2.2/2.8 the book file on the household's drive (userdata, browsable):", " | ".join(w.guest(
"find /mnt/felhom-drives -path '*userdata/media/grimmory*' -name '*.epub' 2>/dev/null | head -3").strip().splitlines()) or "NOT FOUND")
say("2.7 sizes:", " ".join(w.guest(f"for v in $(docker volume ls -q | grep -i {app}); do echo $v=$(du -sh $(docker volume inspect -f '{{{{.Mountpoint}}}}' $v) | cut -f1); done; du -sh /mnt/felhom-drives/*/userdata/{app} 2>/dev/null | head -3").split()))
say("0.4 outbound connections held at rest:", " ".join(w.guest(
f"docker exec {app} sh -c \"cat /proc/net/tcp /proc/net/tcp6 2>/dev/null\" | awk 'NR>1 && $4==\"01\"{{print $3}}' | sort -u | head -8").split()) or "none")
CHECKS = {"radicale": checks_radicale, "karakeep": checks_karakeep, "dawarich": checks_dawarich, "grimmory": checks_grimmory}
SAMPLER = open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "sampler.sh")).read()
def install():
w.login()
say(f"##### {app} on 9202 — controller", w.guest("docker inspect felhom-controller --format {{.Config.Image}}").strip(),
"| catalog", w.guest("cd /var/lib/docker/volumes/felhom-controller-data/_data/data/catalog-cache && git log --oneline -1").strip())
say("guest swap:", " ".join(w.guest("free -m | grep -i swap").split()), "(the swap-0 venue for 5.1 is the bench)")
names = os.environ.get("NAMES", app)
w.guest(f"cat > /root/na-sampler.sh <<'SEOF'\n{SAMPLER}\nSEOF\nrm -f /root/na-mem-{app}.csv.stop; "
f"(NAMES='{names}' OUT=/root/na-mem-{app}.csv DUR=1200 nohup bash /root/na-sampler.sh >/dev/null 2>&1 &)")
poll_path = os.environ.get("POLL_PATH", "/")
w.guest(f"""cat > /root/na-poll.sh <<'PEOF'
out=/root/na-poll-{app}.txt; : > $out; t0=$(date +%s)
while [ $(( $(date +%s) - t0 )) -lt 300 ]; do
r=$(curl -sk --max-time 4 -o /root/na-poll.body -w '%{{http_code}}' -H 'Host: {HOST}' https://127.0.0.1{poll_path})
echo "$(date +%H:%M:%S) $r $(head -c 70 /root/na-poll.body | tr '\\n' ' ')" >> $out; sleep 1
done
PEOF
(nohup bash /root/na-poll.sh >/dev/null 2>&1 &)""")
t0 = time.time()
ok = w.deploy(app, sub)
say(f"deploy -> {ok} ({round(time.time()-t0)} s to `deployed`)")
healthy = None
for _ in range(150):
hs = [w.guest(f"docker inspect -f '{{{{if .State.Health}}}}{{{{.State.Health.Status}}}}{{{{else}}}}nohc{{{{end}}}}' {c}").strip() for c in containers()]
if hs and all(h in ("healthy", "nohc") for h in hs):
healthy = time.time() - t0
break
time.sleep(2)
say(f"4.3 press -> every container healthy: {round(healthy) if healthy else 'NEVER (300 s)'} s; restarts:",
w.guest(f"for c in $(docker ps -a --filter label=com.docker.compose.project={app} --format '{{{{.Names}}}}'); do echo -n \"$c=$(docker inspect -f '{{{{.RestartCount}}}}' $c) \"; done").strip(),
"| controller state:", w.stack(app).get("state"))
toks = json.load(open(TOK)) if os.path.exists(TOK) else {}
if os.environ.get("PROBE_PATH"): # the setup gate's probe, read BEFORE the seed (the household) makes the admin
w.wait_app(sub, os.environ["PROBE_PATH"], want=("200",), tries=60)
say("3.3 probe BEFORE the first admin:", (w.app_curl(sub, os.environ["PROBE_PATH"])[2] or "")[:120])
toks[app] = FX[app].seed(w, sub, say)
if os.environ.get("PROBE_PATH"):
say("3.3 probe AFTER the first admin:", (w.app_curl(sub, os.environ["PROBE_PATH"])[2] or "")[:120])
if isinstance(toks[app], dict) and w.GENERATED.get(app):
toks[app]["__generated"] = w.GENERATED[app]
json.dump(toks, open(TOK, "w"), default=str); os.chmod(TOK, 0o600)
say(f"C1 seed reads back: {FX[app].verify(w, sub, toks[app], say) if toks[app] else 'NO SEED: ' + str(getattr(FX[app], 'tried', ''))}")
time.sleep(5)
w.guest("pkill -f na-poll.sh || true")
poll = w.guest(f"cat /root/na-poll-{app}.txt").strip().splitlines()
codes = {}
for l in poll:
k = (l.split(" ") + ["?"])[1]
codes[k] = codes.get(k, 0) + 1
say(f"3.5 a stranger's {len(poll)} polls of {poll_path} from the press: codes {codes}; first {poll[:1]}; last {poll[-2:]}")
with open(f"{EVD}/poll.txt", "w") as fh:
fh.write("\n".join(poll) + "\n")
def checks():
w.login()
toks = json.load(open(TOK))
if app in CHECKS:
CHECKS[app](toks[app])
rc, code, body = w.app_curl(sub, "/felhom-no-such-page-xyz/")
say(f"1.8 unknown page: http={code} debug-page={'DEBUG = True' in body or 'Traceback' in body}")
for c in containers():
say(f"1.5 process in {c}:", w.guest(f"docker exec {c} sh -c 'cat /proc/1/cmdline | tr \"\\000\" \" \"' 2>/dev/null").strip()[:200])
main = app
say("4.4 negative control: docker STOP", main, "(sidecars keep running; pause is not used — Docker counts a paused container as running) — state before:", w.stack(app).get("state"))
w.guest(f"docker stop {main} >/dev/null")
seen = []
for i in range(18):
time.sleep(5)
s = w.stack(app).get("state")
if not seen or seen[-1][1] != s:
seen.append((5 * (i + 1), s))
say(f" states while stopped (s, state): {seen}; the front door: {w.app_curl(sub, '/')[1]}")
w.guest(f"docker start {main} >/dev/null")
back = None
for i in range(36):
time.sleep(5)
if w.stack(app).get("state") == "running":
back = 5 * (i + 1)
break
say(f" running again {back} s after docker start")
def remove():
w.login()
w.guest(f"touch /root/na-mem-{app}.csv.stop"); time.sleep(3)
csv = w.guest(f"cat /root/na-mem-{app}.csv").strip().splitlines()
with open(f"{EVD}/mem-box.csv", "w") as fh:
fh.write("epoch,name,id,anon,current,swap,oom_kill,limit,restarts\n" + "\n".join(csv) + "\n")
per = {}
for l in csv:
p = l.split(",")
if len(p) >= 9 and p[3].isdigit():
d = per.setdefault((p[1], p[2]), {"anon": 0, "swap": 0, "oom": 0, "lim": p[7], "n": 0})
d["anon"] = max(d["anon"], int(p[3])); d["n"] += 1
d["swap"] = max(d["swap"], int(p[5]) if p[5].isdigit() else 0); d["oom"] = max(d["oom"], int(p[6]) if p[6].isdigit() else 0)
for (n, i), d in per.items():
say(f"5.1 (box) {n} [{i}] {d['n']} samples: peak anon {d['anon']/1048576:.1f} MiB = "
f"{100.0*d['anon']/int(d['lim']) if d['lim'].isdigit() else -1:.1f} % of the limit; peak swap {d['swap']} B; oom_kill {d['oom']}")
c1, _ = w.ctl("POST", f"/api/stacks/{app}/stop")
for _ in range(24):
time.sleep(5)
if w.stack(app).get("state") != "running":
break
code, d = w.ctl("POST", f"/api/stacks/{app}/remove", {"remove_hdd_data": os.environ.get("REMOVE_HDD") == "1", "remove_backups": True})
say(f"2.6 remove (remove_hdd_data={os.environ.get('REMOVE_HDD') == '1'}) -> {code} {str(d)[:300]}")
time.sleep(6)
say(" left after: stack dir / containers / volumes:", w.guest(
f"ls -d /opt/docker/stacks/{app} 2>/dev/null; docker ps -a --format '{{{{.Names}}}}' | grep -E '^{app}'; docker volume ls -q | grep -i {app}").strip() or "nothing")
w.guest(f"rm -f /root/na-sampler.sh /root/na-poll.sh /root/na-poll.body /root/na-poll-{app}.txt /root/na-mem-{app}.csv /root/na-mem-{app}.csv.stop")
{"install": install, "checks": checks, "remove": remove}[phase]()