#!/usr/bin/env python3 """Part C, the pilot: measure on scratch guest 9202 what wger's onboarding record ("as it is now") has no evidence for. argv[1] = drill | walk | restore drill — point 9202 at the drill catalog (`09` §6.5; R-615: the cache dir goes too), save the config first walk — one fresh wger install from the drill catalog and the reads below; then remove it through the product restore — put 9202 back on the saved config (the live catalog) and print the controls Reads (each names the checklist row it answers): 0.4/0.5/1.7/1.8 the image's own env defaults, the container's env NAMES (values never printed except the named non-secret switches), Django's DEBUG, the settings' env reads 1.5 the server process inside the container 3.5 the default login polled once a second from the install press, through traefik, as a stranger 4.3 press → healthy, RestartCount 5.1 the wger container's cgroup from its birth, every 2 s: anon, swap, oom_kill (swap reported, so a pass on swap is visible) 3.9/0.7 the browser form (https Origin + CSRF) and the phone app's route, right and wrong 1.8 an unknown page through traefik: a Django debug page or not 2.8 a progress photo uploaded through the API and fetched back through traefik 2.7 the volumes' size after the seed 4.4 negative control: the container paused → does the controller read it unhealthy? 2.6 remove through the product → what is left Secrets: the generated admin password lives in this process and a 0600 file in SC only; nothing prints it. """ import json, os, re, sys, time, tempfile, secrets, base64, io, subprocess sys.path.insert(0, "/mnt/5_hdd/felhom.eu/git/app-catalog-felhom.eu/scripts") import box_walk as w import upgrade_fixtures_box as fxb EV = os.environ["EV"] VOL = "/var/lib/docker/volumes/felhom-controller-data/_data" DRILL_REPO = "https://gitea.dooplex.hu/admin/app-catalog-drill.git" SAVE = f"{VOL}/controller.yaml.pre-checklist1001" SUB = "fitness" OUT = [] def p(*a): line = " ".join(str(x) for x in a) print(line, flush=True) OUT.append(line) def dump(name): with io.open(os.path.join(EV, name), "w", encoding="utf-8") as fh: fh.write("\n".join(OUT) + "\n") def creds(): for l in io.open(os.path.expanduser("~/.git-credentials")).read().strip().split("\n"): m = re.match(r'https://(admin):([^@]+)@gitea\.dooplex\.hu', l) if m: return m.group(1), m.group(2) raise SystemExit("no admin credential") def to_drill(): u, t = creds() p(w.guest(f""" set -e test -f {SAVE} || cp -p {VOL}/controller.yaml {SAVE} python3 - <<'PY' import re p = "{VOL}/controller.yaml" s = open(p).read() s = re.sub(r'(^\\s+repo_url: ).*$', r'\\g<1>{DRILL_REPO}', s, count=1, flags=re.M) s = re.sub(r'(^git:(?:\\n\\s+.*)*?\\n\\s+token: ).*$', r'\\g<1>"{t}"', s, count=1, flags=re.M) s = re.sub(r'(^git:(?:\\n\\s+.*)*?\\n\\s+username: ).*$', r'\\g<1>"{u}"', s, count=1, flags=re.M) open(p, "w").write(s) PY rm -rf {VOL}/catalog-cache {VOL}/data/catalog-cache docker restart felhom-controller >/dev/null sleep 20 echo "saved copy: $(ls -l {SAVE} | awk '{{print $5, $9}}')" grep -A6 '^git:' {VOL}/controller.yaml | sed 's/token:.*/token: /' """)) w.login() w.sync_rescan() controls("drill") dump("C0-repoint-drill.txt") def controls(want): head = w.guest(f"cd {VOL}/data/catalog-cache 2>/dev/null && git log --oneline -1 && git remote get-url origin | sed 's#//[^@]*@#//#'").strip() p("CONTROL 1 — the box's catalog clone:", head) live = subprocess.run(["git", "ls-remote", "https://gitea.dooplex.hu/admin/app-catalog-felhom.eu.git", "refs/heads/main"], capture_output=True, text=True).stdout.split()[:1] p("CONTROL 2 — the LIVE catalog main (unchanged by this act):", live) p("CONTROL 3 — expected source:", want) def restore(): p(w.guest(f""" set -e cp -p {SAVE} {VOL}/controller.yaml rm -rf {VOL}/catalog-cache {VOL}/data/catalog-cache docker restart felhom-controller >/dev/null sleep 20 grep -A6 '^git:' {VOL}/controller.yaml | sed 's/token:.*/token: /' """)) w.login() w.sync_rescan() controls("live") p("leftovers named wger:", w.guest("docker ps -a --format '{{.Names}}' | grep -i wger; docker volume ls -q | grep -i wger; ls -d /opt/docker/stacks/wger 2>/dev/null").strip() or "none") dump("C9-restore-live.txt") SAMPLER = r""" cat > /root/wger-sampler.sh <<'SH' #!/bin/bash out=/root/wger-mem.csv; : > $out t0=$(date +%s) id="" while [ $(( $(date +%s) - t0 )) -lt 1200 ]; do if [ -z "$id" ]; then id=$(docker ps -aq --no-trunc --filter name=^wger$ | head -1); fi if [ -n "$id" ]; then cg=$(ls -d /sys/fs/cgroup/system.slice/docker-$id.scope 2>/dev/null || find /sys/fs/cgroup -maxdepth 4 -type d -name "*$id*" 2>/dev/null | head -1) if [ -n "$cg" ] && [ -f "$cg/memory.stat" ]; then anon=$(awk '$1=="anon"{print $2}' $cg/memory.stat) cur=$(cat $cg/memory.current); sw=$(cat $cg/memory.swap.current 2>/dev/null || echo NA) oom=$(awk '$1=="oom_kill"{print $2}' $cg/memory.events); lim=$(cat $cg/memory.max) rc=$(docker inspect -f '{{.RestartCount}} {{.State.Status}} {{if .State.Health}}{{.State.Health.Status}}{{end}}' $id 2>/dev/null) echo "$(date +%s.%N | cut -c1-14),$anon,$cur,$sw,$oom,$lim,$rc" >> $out fi fi [ -f /root/wger-sampler.stop ] && break sleep 2 done SH chmod +x /root/wger-sampler.sh; rm -f /root/wger-sampler.stop nohup /root/wger-sampler.sh >/dev/null 2>&1 & echo sampler started """ POLLER = r""" cat > /root/wger-poll.sh <<'SH' #!/bin/bash # a STRANGER: no dashboard session, no gate cookie — the public default login, once a second, through traefik out=/root/wger-poll.txt; : > $out t0=$(date +%s) while [ $(( $(date +%s) - t0 )) -lt 600 ]; do r=$(curl -sk --max-time 4 -o /root/wger-poll.body -w '%{http_code}' -H 'Host: fitness.enkisfelhom.hu' \ -H 'Content-Type: application/json' --data '{"username":"admin","password":"adminadmin"}' \ https://127.0.0.1/allauth/app/v1/auth/login) b=$(head -c 90 /root/wger-poll.body | tr '\n' ' ') echo "$(date +%H:%M:%S) $r $b" >> $out # the first answer that is the APP's own JSON (not the gate, not traefik's 404) ends the poll: every further # wrong try would count toward wger's 5-failure lock (decision 58) and spoil the household's login below case "$b" in *'"status"'*|*access_token*) break;; esac sleep 1 done SH chmod +x /root/wger-poll.sh nohup /root/wger-poll.sh >/dev/null 2>&1 & echo poller started """ def walk(): w.login() p("##### wger on 9202 — controller", w.guest("docker inspect felhom-controller --format {{.Config.Image}}").strip()) p("catalog clone:", w.guest(f"cd {VOL}/data/catalog-cache && git log --oneline -1").strip()) p("before: stack/volumes named wger:", w.guest("ls -d /opt/docker/stacks/wger 2>/dev/null; docker volume ls -q | grep -i wger").strip() or "none") p("guest swap (free -m):", " ".join(w.guest("free -m | grep -i swap").split())) p(w.guest(SAMPLER).strip()) p(w.guest(POLLER).strip()) t_press = time.time() p("deploy press at", time.strftime("%H:%M:%S")) ok = w.deploy("wger", SUB) p("deploy ->", ok) opened = None for _ in range(120): time.sleep(5) lg = w.guest("docker logs --since 30m felhom-controller 2>&1 | grep -E 'wger: install hold OPENED|wger.*after_install' | tail -3") if "hold OPENED" in lg: opened = lg.strip() break p("controller log (after_install / hold):", opened) healthy = None for _ in range(90): h = w.guest("docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}} {{.RestartCount}}' wger").strip() if h.startswith("healthy"): healthy = time.time() - t_press break time.sleep(2) p("4.3 press -> docker healthy: %.0f s; RestartCount: %s; start_period in compose: %s" % ( healthy or -1, w.guest("docker inspect -f '{{.RestartCount}}' wger").strip(), w.guest("grep -m1 start_period /opt/docker/stacks/wger/docker-compose.yml").strip())) p(" controller state:", w.stack("wger").get("state")) time.sleep(8) w.guest("pkill -f wger-poll.sh || true") poll = w.guest("cat /root/wger-poll.txt").strip().splitlines() codes = {} for l in poll: c = l.split(" ")[1] if len(l.split(" ")) > 1 else "?" codes[c] = codes.get(c, 0) + 1 p("3.5 stranger polls of admin/adminadmin from the press: %d tries; codes %s" % (len(poll), codes)) p(" first 3:", poll[:3]) p(" last 3:", poll[-3:]) p(" any 200 with a token:", any(" 200 " in l and "access_token" in l for l in poll)) dump("C1-install.txt") # --- the static reads inside the container OUT.clear() p("1.5 the server process:", " | ".join(w.guest("docker exec wger sh -c \"ps -eo args 2>/dev/null || cat /proc/[0-9]*/cmdline | tr '\\\\0' ' '\" | grep -E '[m]anage.py|[g]unicorn|[u]vicorn|[d]aphne' | head -4").strip().splitlines())) p("1.7 image entrypoint:", w.guest("docker image inspect wger/server:2.7 --format '{{json .Config.Entrypoint}} {{json .Config.Cmd}}'").strip()) p("1.7 entrypoint switches (if-tests in /home/wger/entrypoint.sh):") p(w.guest("docker exec wger grep -nE 'if \\[\\[? *\"?\\$' /home/wger/entrypoint.sh").rstrip()) p("0.4/1.7 the IMAGE's env defaults for those switches (+ debug/mail/sync):") env_img = w.guest("docker image inspect wger/server:2.7 --format '{{range .Config.Env}}{{println .}}{{end}}'") keep = re.compile(r"^(DJANGO_DEBUG|DJANGO_PERFORM_MIGRATIONS|WGER_USE_GUNICORN|SYNC_\w+|DOWNLOAD_\w+|LOAD_ONLINE\w*|" r"DJANGO_COLLECTSTATIC\w*|ENABLE_EMAIL|EXERCISE\w*|ALLOW_\w+|USE_CELERY|DJANGO_CLEAR\w*|YARN\w*|TZ)=") for l in env_img.splitlines(): if keep.match(l): p(" image:", l) p(" the RUNNING container's env, the same switches (values: only these named, non-secret ones):") env_run = w.guest("docker inspect wger --format '{{range .Config.Env}}{{println .}}{{end}}'") for l in env_run.splitlines(): if keep.match(l) or l.startswith(("AXES_", "CSRF_TRUSTED", "X_FORWARDED", "SITE_URL")): p(" container:", l) names = sorted({l.split("=", 1)[0] for l in env_run.splitlines() if "=" in l}) p(" container env NAMES (all):", " ".join(names)) p("1.6 the env names wger's settings READ (settings/main.py, env.*('NAME'...)):") reads = w.guest("docker exec wger sh -c \"grep -ohE \\\"env\\\\.[a-z]+\\\\('[A-Z_0-9]+'\\\" /home/wger/src/settings/*.py | sed -E \\\"s/.*\\\\('//; s/'$//\\\" | sort -u\"").split() p(" ", " ".join(reads)) secretish = [r for r in reads if re.search(r"KEY|SECRET|TOKEN|PASSWORD|PEM", r)] p(" of which key/secret-like:", secretish) p(" set in the container:", [r for r in secretish if r in names], " NOT set:", [r for r in secretish if r not in names]) dj = w.guest("""cat > /tmp/djs.py <<'PY' import os, sys sys.path.insert(0, '/home/wger/src'); os.chdir('/home/wger/src') os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'settings.main') import django; django.setup() from django.conf import settings as s print('DEBUG', s.DEBUG) print('EMAIL_BACKEND', getattr(s, 'EMAIL_BACKEND', None)) print('WGER_SETTINGS sync/download:', {k: v for k, v in getattr(s, 'WGER_SETTINGS', {}).items() if any(x in k for x in ('SYNC', 'DOWNLOAD', 'USE_CELERY', 'ALLOW', 'EMAIL', 'TWITTER', 'MASTODON'))}) print('JWT key loaded:', bool(os.environ.get('JWT_PRIVATE_KEY')), '(env in this shell is not the server env)') PY docker cp /tmp/djs.py wger:/tmp/djs.py && docker exec wger python3 /tmp/djs.py 2>&1 | tail -4""") p("1.8/0.4 Django settings inside the app:\n" + dj.rstrip()) p("0.4/0.5 the container's first-start log lines about the network (sync/download/http):") p(w.guest("docker logs wger 2>&1 | grep -iE 'sync|download|http|fixture|ingredient|exercise' | head -12").rstrip()) p(" outbound TCP connections the wger process holds right now:", w.guest( "docker exec wger sh -c 'cat /proc/net/tcp /proc/net/tcp6 2>/dev/null' | awk 'NR>1 && $4==\"01\"{print $3}' | sort | uniq -c | head").strip() or "none") dump("C2-static-reads.txt") # --- logins, as each client does OUT.clear() pw = (w.GENERATED.get("wger") or {}).get("ADMIN_PASSWORD") or "" with io.open(os.path.join(os.environ["SC"], "wger.pw"), "w") as fh: fh.write(pw) os.chmod(os.path.join(os.environ["SC"], "wger.pw"), 0o600) W = fxb.Wger() jar = tempfile.mktemp(prefix="wger-jar-") hdr, why = W._login(w, SUB, pw, jar) p("3.9 browser form, https Origin + CSRF, RIGHT password:", why) jar2 = tempfile.mktemp(prefix="wger-jar2-") hdr2, why2 = W._login(w, SUB, pw + "x", jar2) p("3.9 browser form, WRONG password:", why2, "(refused)" if hdr2 is None else "(LET IN!)") for label, pwd in (("RIGHT", pw), ("WRONG", pw + "y")): rc, code, out = w.app_curl(SUB, "/allauth/app/v1/auth/login", "-H", "Content-Type: application/json", data=json.dumps({"username": "admin", "password": pwd}), method="POST") keys = [] try: keys = sorted((json.loads(out).get("meta") or {}).keys()) or sorted(json.loads(out).keys()) except Exception: pass p(f"3.9/0.7 phone-app route /allauth/app/v1/auth/login, {label} password: http={code} keys={keys}") if label == "RIGHT" and code == "200": tok = (json.loads(out).get("meta") or {}).get("access_token") or "" rc, c2, _ = w.app_curl(SUB, "/api/v2/weightentry/", "-H", f"Authorization: Bearer {tok}") p(" the API with that token: http=%s" % c2) # --- 1.8 an unknown page rc, code, body = w.app_curl(SUB, "/felhom-no-such-page-xyz/") p("1.8 unknown page through traefik: http=%s debug-page=%s traceback=%s" % ( code, "DEBUG = True" in body, "Traceback" in body)) dump("C3-logins.txt") # --- seed (the fixture's own front door) + a photo OUT.clear() say = lambda *a: p(*a) t = W.seed(w, SUB, say) p("seed:", {k: v for k, v in (t or {}).items() if k != "pw"}) png = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==") fn = tempfile.mktemp(suffix=".png") open(fn, "wb").write(png) if hdr: rc, code, out = w.app_curl(SUB, "/api/v2/gallery/", *hdr, "-F", f"image=@{fn};type=image/png", "-F", "date=2026-10-01", "-F", "description=felhom pilot", method="POST") p("2.8 POST /api/v2/gallery/ (multipart, through traefik): http=%s %s" % (code, re.sub(r'"image":"[^"]*"', '"image":"…"', out[:160]))) try: url = json.loads(out).get("image") or "" except Exception: url = "" if url: path = re.sub(r"^https?://[^/]+", "", url) rc, code, body = w.app_curl(SUB, path, *hdr) p("2.8 GET the uploaded image back through traefik (%s): http=%s bytes=%d same=%s" % ( path, code, len(body.encode("latin-1", "ignore")), "PNG" in body[:8])) rc, code, body = w.app_curl(SUB, path) p("2.8 the same image with NO session: http=%s" % code) p("verify (fixture readback, with its negative controls):", W.verify(w, SUB, t, say) if t else "no seed") p("2.7 the volumes' size after the seed:", " ".join(w.guest( "for v in $(docker volume ls -q | grep -i wger); do du -sh $(docker volume inspect -f '{{.Mountpoint}}' $v) | awk -v v=$v '{print v\"=\"$1}'; done").split())) dump("C4-seed-photo-size.txt") # --- 4.4 negative control OUT.clear() p("4.4 negative control: docker pause wger at", time.strftime("%H:%M:%S"), "— controller state before:", w.stack("wger").get("state")) w.guest("docker pause wger") seen = [] for i in range(45): time.sleep(4) st = w.stack("wger") s = (st.get("state"), (st.get("health") or {}).get("status") if isinstance(st.get("health"), dict) else st.get("health")) if not seen or seen[-1][1] != s: seen.append((round(4 * (i + 1)), s)) if s[0] in ("unhealthy", "degraded", "stopped", "error"): break p(" states seen while paused (s, (state, health)):", seen) w.guest("docker unpause wger") back = None for i in range(45): time.sleep(4) if w.stack("wger").get("state") == "running": back = 4 * (i + 1) break p(" after unpause, state running again after %s s" % back) dump("C5-negative-control.txt") # --- 5.1 the sampler OUT.clear() w.guest("touch /root/wger-sampler.stop") time.sleep(3) csv = w.guest("cat /root/wger-mem.csv").strip().splitlines() rows = [l.split(",") for l in csv if l.count(",") >= 6] if rows: anon = [int(r[1]) for r in rows if r[1].isdigit()] sw = [int(r[3]) for r in rows if r[3].isdigit()] oom = [int(r[4]) for r in rows if r[4].isdigit()] lim = rows[0][5] p("5.1 wger cgroup from birth: %d samples over %.0f s; limit %s" % (len(rows), float(rows[-1][0]) - float(rows[0][0]), lim)) p(" peak anon %.1f MiB (%.1f %% of the limit); peak swap %s bytes; oom_kill max %s; last: %s" % ( max(anon) / 1048576, 100.0 * max(anon) / int(lim) if lim.isdigit() else -1, max(sw) if sw else "NA", max(oom) if oom else "NA", rows[-1][6])) with io.open(os.path.join(EV, "C6-wger-mem.csv"), "w") as fh: fh.write("epoch,anon,current,swap,oom_kill,limit,restarts status health\n" + "\n".join(csv) + "\n") dump("C6-first-start-memory.txt") # --- 2.6 remove through the product OUT.clear() c1, d1 = w.ctl("POST", "/api/stacks/wger/stop") p("2.6 stop ->", c1) for _ in range(24): time.sleep(5) if w.stack("wger").get("state") != "running": break code, d = w.ctl("POST", "/api/stacks/wger/remove", {"remove_hdd_data": False, "remove_backups": True}) p("2.6 remove, KEEP drive data (wger has no drive data) ->", code, str(d)[:200]) time.sleep(6) p(" left after: stack dir / containers / volumes:", w.guest( "ls -d /opt/docker/stacks/wger 2>/dev/null; docker ps -a --format '{{.Names}}' | grep -x wger; docker volume ls -q | grep -i wger").strip() or "nothing") w.guest("rm -f /root/wger-sampler.sh /root/wger-poll.sh /root/wger-poll.body /root/wger-sampler.stop /tmp/djs.py; docker exec felhom-controller true") p(" the image (kept per decision 53 until the sweep):", w.guest("docker images --format '{{.Repository}}:{{.Tag}}' | grep -i wger").strip()) dump("C7-remove.txt") for j in (jar, jar2, fn): if os.path.exists(j): os.unlink(j) if __name__ == "__main__": {"drill": to_drill, "walk": walk, "restore": restore}[sys.argv[1]]()