2026-10-08 day: legal drafts (R-813), R-304 design, R-232(a) evidence, R-762 bench proof; R-899/R-243/R-304/R-232/R-762 updated; R-900, R-901 opened; 127 -> 130 (R-902 by the website session)
gates / gates (push) Successful in 3m43s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-08 08:36:03 +02:00
parent 663421ddfb
commit c7aade4079
43 changed files with 3412 additions and 9 deletions
@@ -0,0 +1,50 @@
# R-304 — the household's old recovery code and the retained packages: a one-page design (2026-10-08)
**Status:** design only. Nothing here is built except today's honesty fix (below). Customer data and promises are the
operator's: they are the two questions at the end.
## Where it stands (read in source today, not from the row)
- **Retention works** and the material opens the old store (drill 2026-08-12, `audits/DRILL-retained-key-2026-08-12.md`).
- **R-311 shipped** (hub v0.103.0, agent v0.129.0, controller v0.214.0): after the current package refuses a code, the
agent fetches the retained packages (`GET /hosts/<id>/escrow/retained`, self-scoped, cap 16) and tries up to 6. If one
opens, the screen says „the code is correct, it opens an earlier package; contact support" (HTTP 422).
- **R-312 is DECIDED (2026-08-13): no in-product route from the recovery screen to a set-aside store** — „re-evaluate on
a real customer request". So retention is an **operator-only** capability today, by decision.
- **What was still false until today:** the agent answered „the code did not open the sealed bundle" (400) also when it
had NOT tried every earlier package — the hub withheld rows (no key material, over the cap), a package was malformed,
the 6-try cap stopped the loop, or the retained list could not be read. **Fixed on main today** (agent 424
`older_unchecked`, controller `RecoveryOlderUnchecked`, Hungarian + English: „we do not know whether your code is
wrong … contact support"). Ships with tomorrow's releases. The fix is in the agent and the controller, not the hub:
the hub never sees the code (zero-knowledge, `07` §2), so it cannot check a row; it already reports what it withheld
(`unopenable_count`, `truncated_count`), and the agent now counts those.
## „Which package?" — the question is smaller than it looked
Each escrow ceremony seals with a NEW recovery code (the household is shown it once). A code opens only the package it
sealed. So when a household holds several old codes, **each code selects its own package** — no list, no choice screen.
The agent already tries newest-superseded first. The only real limits are the two caps (16 served, 6 tried), which
today's fix turns from a silent „wrong code" into an honest „not all checked".
## Options for really serving the old copy
| | What | Costs | Customer data / promise |
|---|---|---|---|
| **A** | Keep it operator-only (R-312). The screen's „contact support" is the route. | Nothing more. The operator needs SQLite, `age` and a shell (the drill's §4) — slow, error-prone, undocumented as a runbook. | No change. |
| **B** | In-product: when a retained package opens and carries a repository password, the screen offers a READ-ONLY browse of the old store (list + download), never a restore into place. | New surface: the old store's location (moved aside / orphaned, R-241), a second repository password in memory, a second browse path. ~2 sessions + a drill. | Changes a promise (the household can reach old history alone). **Reverses R-312** — operator only. |
| **C** | Operator-assisted, first slice: when the agent answers 422 (opens retained) or 424 (not all checked), the controller sends ONE operator event naming the box and the package date; plus a runbook „open a retained package for a household" (the drill's §4 written down, the household types the code on its own box). | Small: one event type (operator-only), one runbook. ~½ session. | No new promise; makes the existing „contact support" true in practice. |
**Pick: C now; B only on R-312's own trigger** (a real customer asks). C makes the sentence the screen already says —
„contact support" — something the operator can act on the same day, without reversing a decision.
**First slice of C:** controller: on `RecoveryCodeOpensRetained` / `RecoveryOlderUnchecked`, send `recovery_retained_needed`
(operator-only, warning, once per box per day) with the package date and the class — never the code. Hub: allowlist +
`operatorOnlyEvents` in the same commit. Docs: `runbooks/RUNBOOK-open-retained-package.md` from the drill's §4.
## Two questions for the operator
1. **May the product keep promising, in the capability map and the countdown banner, that old backups „stay
recoverable"?** Today that is true only with your hands. *If you do nothing:* the promise stays worded as it is, and
the honest route is „contact support" (A/C).
2. **Do you want C's first slice built (an operator mail when a household's code opens — or may open — an old
package)?** *If you do nothing:* nothing is built; you learn of such a household only when they write to you.
@@ -0,0 +1,30 @@
# R-232 (a) — DooPlex's backup mails the operator when it fails (2026-10-08)
**Operator word:** "Yes" in chat (2026-10-08, asked: "May I change DooPlex's backup notification so a failed run sends a
mail? One setting, plus one test mail. I will not start a backup run.").
**What changed (DooPlex, unversioned scripts — R-231):**
- `/opt/backup/scripts/backup-config.sh`: `notify_failure` now also sends a mail through Resend (the API the CI failure
mail uses) from `monitoring@felhom.eu` to `admin@felhom.eu`. The webhook branch is unchanged. The mail never changes a
backup's exit code (`return 0`) and logs its outcome to `backup.log`. Before/after: `backup-config.sh.before`,
`backup-config.sh.after` (no secret in either). The old file is also kept beside it as
`backup-config.sh.bak-20261008-080524`.
- `/etc/backup/resend-api-key`: new, `600 root`, 36 bytes, copied from the k3s Secret `felhom-system/resend-api` with
`umask 077` and never printed.
- Nothing else in DooPlex's backup changed. **No backup run was started.**
**Proof (two channels):**
1. The function's own output (`test-mail.txt`): `sudo bash -c 'source …/backup-config.sh; notify_failure "TEST - R-232
wiring check, no backup ran"'` → `notify_failure: mail accepted id=01a11a1d-…`, `rc=0`, and the line
`[INFO] notify_failure: failure mail sent to admin@felhom.eu` in `backup.log`.
2. The inbox (Gmail connector, which reads the admin@ catch-all): one message, 2026-10-08T06:05:25Z, from
`monitoring@felhom.eu`, subject `[DooPlex backup] FAILED: TEST - R-232 wiring check, no backup ran`, label INBOX.
**Not proven:** a real failure path end to end (no backup was forced to fail, by the brief). The callers are the
existing `ERR` traps and `backup-all.sh`'s component check, unchanged.
**Rollback:** `sudo cp -p /opt/backup/scripts/backup-config.sh.bak-20261008-080524 /opt/backup/scripts/backup-config.sh`
and `sudo rm /etc/backup/resend-api-key`.
**If the Resend key is rotated:** this file must be refreshed too (a second consumer of `Secret/resend-api`, beside the
hub and contact-mailer).
@@ -0,0 +1,178 @@
#!/bin/bash
# Dooplex Cluster Backup Configuration
# Source this file in backup scripts: source /opt/backup/backup-config.sh
# ============================================================================
# BACKUP DESTINATIONS
# ============================================================================
export BACKUP_BASE="/mnt/5_hdd/backup"
export BACKUP_K3S="${BACKUP_BASE}/k3s"
export BACKUP_SECRETS="${BACKUP_BASE}/secrets"
export BACKUP_MANIFESTS="${BACKUP_BASE}/homelab-manifests"
# NOT under BACKUP_BASE. DATA_SOURCE_DIR moved to /mnt/5_hdd/data in the
# 2026-08-14 migration off the failed 4_hdd, so leaving this repo under
# BACKUP_BASE (also 5_hdd) would put the backup on the same physical disk as
# its source -- protection against accidental deletion, none against loss of
# sda1. 1_hdd holds no Longhorn replicas and only serves Plex reads, so backup
# writes do not contend with live volume I/O.
export BACKUP_DATA="/mnt/1_hdd/backup/data"
export BACKUP_LONGHORN="${BACKUP_BASE}/longhorn-pvc"
export BACKUP_LOGS="${BACKUP_BASE}/logs"
# ============================================================================
# RESTIC REPOSITORIES (each category has its own repo for flexibility)
# ============================================================================
export RESTIC_REPO_K3S="${BACKUP_K3S}/restic-repo"
export RESTIC_REPO_SECRETS="${BACKUP_SECRETS}/restic-repo"
export RESTIC_REPO_DATA="${BACKUP_DATA}/restic-repo"
export BACKUP_POSTGRESQL="${BACKUP_BASE}/postgresql"
export POSTGRESQL_DUMP_DIR="${BACKUP_POSTGRESQL}/dumps"
export RESTIC_REPO_POSTGRESQL="${BACKUP_POSTGRESQL}/restic-repo"
# ============================================================================
# RESTIC PASSWORD (change this!)
# Store in /etc/backup/restic-password or set RESTIC_PASSWORD_FILE
# ============================================================================
export RESTIC_PASSWORD_FILE="/etc/backup/restic-password"
# ============================================================================
# RETENTION POLICY
# ============================================================================
export RETENTION_KEEP_LAST=7
export RETENTION_KEEP_DAILY=7
export RETENTION_KEEP_WEEKLY=4
export RETENTION_KEEP_MONTHLY=6
# ============================================================================
# SOURCE DIRECTORIES
# ============================================================================
export K3S_SERVER_DIR="/var/lib/rancher/k3s/server"
export K3S_CONFIG_DIR="/etc/rancher/k3s"
export DATA_SOURCE_DIR="/mnt/5_hdd/data"
# Claude Code auto-memory store (R-229, 2026-08-06). Rides in the User Data component because it is
# small, exists on this host only, and is in NO git repository -- /mnt/5_hdd/felhom.eu/git is not a
# repo, so nothing else preserves it. BACKED UP, NOT COMMITTED: it is auto-written and may name
# hosts and paths that the project's secrets rule keeps out of committed files.
# CAVEAT: BACKUP_BASE is on the SAME physical disk (/mnt/5_hdd) as this source, so this protects
# against accidental deletion, NOT against loss of sda1.
export CLAUDE_MEMORY_DIR="/mnt/5_hdd/felhom.eu/git/.claude-memory"
# ============================================================================
# EXCLUDES
# ============================================================================
export DATA_EXCLUDES=(
"*.tmp"
"*.temp"
"*.cache"
"**/cache/**"
"**/Cache/**"
"**/.cache/**"
"**/node_modules/**"
"**/__pycache__/**"
"**/Thumbs.db"
"**/.DS_Store"
)
# ============================================================================
# NOTIFICATION (optional - configure as needed)
# ============================================================================
export NOTIFY_ON_FAILURE="true"
# export NOTIFY_WEBHOOK_URL="https://your-webhook-url"
# R-232 (a), 2026-10-08 (operator yes in chat): a failed run is MAILED through the project's existing mail path
# (Resend, the same API the CI failure mail uses) to the operator. The key is stored out-of-band, root-only, in
# NOTIFY_RESEND_KEY_FILE (copied from the k3s Secret felhom-system/resend-api); it is never printed.
export NOTIFY_RESEND_KEY_FILE="/etc/backup/resend-api-key"
export NOTIFY_MAIL_FROM="DooPlex backup <monitoring@felhom.eu>"
export NOTIFY_MAIL_TO="admin@felhom.eu"
# ============================================================================
# HELPER FUNCTIONS
# ============================================================================
log() {
local level="$1"
shift
echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$level] $*" | tee -a "${BACKUP_LOGS}/backup.log"
}
log_info() { log "INFO" "$@"; }
log_warn() { log "WARN" "$@"; }
log_error() { log "ERROR" "$@"; }
check_restic() {
if ! command -v restic &> /dev/null; then
log_error "restic is not installed. Install with: apt install restic"
exit 1
fi
}
check_kubectl() {
if ! command -v kubectl &> /dev/null; then
log_error "kubectl is not installed"
exit 1
fi
}
ensure_dirs() {
mkdir -p "${BACKUP_K3S}" "${BACKUP_SECRETS}" "${BACKUP_MANIFESTS}" \
"${BACKUP_DATA}" "${BACKUP_LONGHORN}" "${BACKUP_LOGS}" "${BACKUP_POSTGRESQL}"
}
init_restic_repo() {
local repo="$1"
if [ ! -d "${repo}" ]; then
log_info "Initializing restic repository: ${repo}"
restic -r "${repo}" init
fi
}
apply_retention() {
local repo="$1"
log_info "Applying retention policy to ${repo}"
restic -r "${repo}" forget \
--keep-last ${RETENTION_KEEP_LAST} \
--keep-daily ${RETENTION_KEEP_DAILY} \
--keep-weekly ${RETENTION_KEEP_WEEKLY} \
--keep-monthly ${RETENTION_KEEP_MONTHLY} \
--prune
}
notify_failure() {
local message="$1"
if [ "${NOTIFY_ON_FAILURE}" = "true" ] && [ -n "${NOTIFY_WEBHOOK_URL}" ]; then
curl -s -X POST -H "Content-Type: application/json" \
-d "{\"text\": \"🚨 Backup Failed: ${message}\"}" \
"${NOTIFY_WEBHOOK_URL}" || true
fi
# R-232 (a): the mail. Never fails the caller (a broken mail must not change a backup's exit code); logs its outcome.
if [ "${NOTIFY_ON_FAILURE}" = "true" ] && [ -r "${NOTIFY_RESEND_KEY_FILE}" ]; then
if NOTIFY_MSG="${message}" NOTIFY_HOST="$(hostname)" NOTIFY_LOG="${BACKUP_LOGS}/backup.log" python3 - <<'PY'
import json, os, sys, urllib.error, urllib.request
key = open(os.environ["NOTIFY_RESEND_KEY_FILE"]).read().strip()
msg, host = os.environ.get("NOTIFY_MSG", ""), os.environ.get("NOTIFY_HOST", "?")
body = json.dumps({
"from": os.environ["NOTIFY_MAIL_FROM"], "to": [os.environ["NOTIFY_MAIL_TO"]],
"subject": "[DooPlex backup] FAILED: %s" % msg,
"text": "DooPlex's backup reported a failure.\n\nHost : %s\nFailure: %s\nLog : %s\n\n"
"See journalctl -u dooplex-backup and the log above. This mail is sent by notify_failure "
"in /opt/backup/scripts/backup-config.sh (R-232 a).\n" % (host, msg, os.environ.get("NOTIFY_LOG", "")),
}).encode()
req = urllib.request.Request("https://api.resend.com/emails", data=body, method="POST",
headers={"Authorization": "Bearer %s" % key, "Content-Type": "application/json",
# Cloudflare fronts api.resend.com and blocks the default Python-urllib agent (error 1010).
"User-Agent": "dooplex-backup/1.0"})
try:
with urllib.request.urlopen(req, timeout=30) as r:
print("notify_failure: mail accepted id=%s" % json.load(r).get("id"))
except urllib.error.HTTPError as e:
sys.exit("notify_failure: Resend HTTP %s" % e.code)
except Exception as e:
sys.exit("notify_failure: mail not sent (%s)" % type(e).__name__)
PY
then log_info "notify_failure: failure mail sent to ${NOTIFY_MAIL_TO}"
else log_error "notify_failure: the failure mail could NOT be sent"
fi
fi
return 0
}
@@ -0,0 +1,142 @@
#!/bin/bash
# Dooplex Cluster Backup Configuration
# Source this file in backup scripts: source /opt/backup/backup-config.sh
# ============================================================================
# BACKUP DESTINATIONS
# ============================================================================
export BACKUP_BASE="/mnt/5_hdd/backup"
export BACKUP_K3S="${BACKUP_BASE}/k3s"
export BACKUP_SECRETS="${BACKUP_BASE}/secrets"
export BACKUP_MANIFESTS="${BACKUP_BASE}/homelab-manifests"
# NOT under BACKUP_BASE. DATA_SOURCE_DIR moved to /mnt/5_hdd/data in the
# 2026-08-14 migration off the failed 4_hdd, so leaving this repo under
# BACKUP_BASE (also 5_hdd) would put the backup on the same physical disk as
# its source -- protection against accidental deletion, none against loss of
# sda1. 1_hdd holds no Longhorn replicas and only serves Plex reads, so backup
# writes do not contend with live volume I/O.
export BACKUP_DATA="/mnt/1_hdd/backup/data"
export BACKUP_LONGHORN="${BACKUP_BASE}/longhorn-pvc"
export BACKUP_LOGS="${BACKUP_BASE}/logs"
# ============================================================================
# RESTIC REPOSITORIES (each category has its own repo for flexibility)
# ============================================================================
export RESTIC_REPO_K3S="${BACKUP_K3S}/restic-repo"
export RESTIC_REPO_SECRETS="${BACKUP_SECRETS}/restic-repo"
export RESTIC_REPO_DATA="${BACKUP_DATA}/restic-repo"
export BACKUP_POSTGRESQL="${BACKUP_BASE}/postgresql"
export POSTGRESQL_DUMP_DIR="${BACKUP_POSTGRESQL}/dumps"
export RESTIC_REPO_POSTGRESQL="${BACKUP_POSTGRESQL}/restic-repo"
# ============================================================================
# RESTIC PASSWORD (change this!)
# Store in /etc/backup/restic-password or set RESTIC_PASSWORD_FILE
# ============================================================================
export RESTIC_PASSWORD_FILE="/etc/backup/restic-password"
# ============================================================================
# RETENTION POLICY
# ============================================================================
export RETENTION_KEEP_LAST=7
export RETENTION_KEEP_DAILY=7
export RETENTION_KEEP_WEEKLY=4
export RETENTION_KEEP_MONTHLY=6
# ============================================================================
# SOURCE DIRECTORIES
# ============================================================================
export K3S_SERVER_DIR="/var/lib/rancher/k3s/server"
export K3S_CONFIG_DIR="/etc/rancher/k3s"
export DATA_SOURCE_DIR="/mnt/5_hdd/data"
# Claude Code auto-memory store (R-229, 2026-08-06). Rides in the User Data component because it is
# small, exists on this host only, and is in NO git repository -- /mnt/5_hdd/felhom.eu/git is not a
# repo, so nothing else preserves it. BACKED UP, NOT COMMITTED: it is auto-written and may name
# hosts and paths that the project's secrets rule keeps out of committed files.
# CAVEAT: BACKUP_BASE is on the SAME physical disk (/mnt/5_hdd) as this source, so this protects
# against accidental deletion, NOT against loss of sda1.
export CLAUDE_MEMORY_DIR="/mnt/5_hdd/felhom.eu/git/.claude-memory"
# ============================================================================
# EXCLUDES
# ============================================================================
export DATA_EXCLUDES=(
"*.tmp"
"*.temp"
"*.cache"
"**/cache/**"
"**/Cache/**"
"**/.cache/**"
"**/node_modules/**"
"**/__pycache__/**"
"**/Thumbs.db"
"**/.DS_Store"
)
# ============================================================================
# NOTIFICATION (optional - configure as needed)
# ============================================================================
export NOTIFY_ON_FAILURE="true"
# export NOTIFY_WEBHOOK_URL="https://your-webhook-url"
# ============================================================================
# HELPER FUNCTIONS
# ============================================================================
log() {
local level="$1"
shift
echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$level] $*" | tee -a "${BACKUP_LOGS}/backup.log"
}
log_info() { log "INFO" "$@"; }
log_warn() { log "WARN" "$@"; }
log_error() { log "ERROR" "$@"; }
check_restic() {
if ! command -v restic &> /dev/null; then
log_error "restic is not installed. Install with: apt install restic"
exit 1
fi
}
check_kubectl() {
if ! command -v kubectl &> /dev/null; then
log_error "kubectl is not installed"
exit 1
fi
}
ensure_dirs() {
mkdir -p "${BACKUP_K3S}" "${BACKUP_SECRETS}" "${BACKUP_MANIFESTS}" \
"${BACKUP_DATA}" "${BACKUP_LONGHORN}" "${BACKUP_LOGS}" "${BACKUP_POSTGRESQL}"
}
init_restic_repo() {
local repo="$1"
if [ ! -d "${repo}" ]; then
log_info "Initializing restic repository: ${repo}"
restic -r "${repo}" init
fi
}
apply_retention() {
local repo="$1"
log_info "Applying retention policy to ${repo}"
restic -r "${repo}" forget \
--keep-last ${RETENTION_KEEP_LAST} \
--keep-daily ${RETENTION_KEEP_DAILY} \
--keep-weekly ${RETENTION_KEEP_WEEKLY} \
--keep-monthly ${RETENTION_KEEP_MONTHLY} \
--prune
}
notify_failure() {
local message="$1"
if [ "${NOTIFY_ON_FAILURE}" = "true" ] && [ -n "${NOTIFY_WEBHOOK_URL}" ]; then
curl -s -X POST -H "Content-Type: application/json" \
-d "{\"text\": \"🚨 Backup Failed: ${message}\"}" \
"${NOTIFY_WEBHOOK_URL}" || true
fi
}
@@ -0,0 +1,5 @@
notify_failure: mail accepted id=01a11a1d-d554-7f1f-acb7-ba5c268b70a6
[2026-10-08 08:05:25] [INFO] notify_failure: failure mail sent to admin@felhom.eu
rc=0
[2026-10-08 03:14:36] [INFO] ========================================================
[2026-10-08 08:05:25] [INFO] notify_failure: failure mail sent to admin@felhom.eu
@@ -0,0 +1,69 @@
# R-762 (wger's real web server: gunicorn with 2 workers): 2026-10-08 day, Part D
**Status: the bench half is done. The 9202 half is NOT done, so nothing was committed to the catalog.**
To reach 9202, the drill catalog had to be brought up to date and wger un-hidden in it (wger is `lifecycle: hidden`,
and the controller refuses to deploy a hidden app, `router.go` L461). The permission check refused that write
(„Modify Shared Resources"). The brief says a refusal stops the item, so it stopped there. The drill repo, 9202's
catalog setting and the live catalog were not changed. `app-catalog-felhom.eu` is clean at `32d1346`.
## The definition tested
`definition-docker-compose.yml` is the current `templates/wger/docker-compose.yml` with two more env lines and a comment:
`WGER_USE_GUNICORN=True` and `WEB_CONCURRENCY=2`.
Checked in the image `wger/server:2.7` (`sha256:1c5789b9…`, the same digest the ladder records) on bench 9401:
- `/home/wger/entrypoint.sh` runs `gunicorn wger.wsgi:application --preload --bind 0.0.0.0:$PORT` only when
`WGER_USE_GUNICORN == "True"`. Otherwise it runs `manage.py runserver`.
- There is no `-w`, no `gunicorn.conf.py` in the working directory `/home/wger/src`, and no GUNICORN or WEB_ env in the
image.
- gunicorn 26.1.0's own `Config()` gives `workers` 1 by default and 2 with `WEB_CONCURRENCY=2`. The default timeout is
30 s.
## Bench 9401 (demo-hp), two runs
1. **`upgrade-test.py --soak 600 --move-to wger wger@gunicorn`** (harness v5): FROM the template as it stands (runserver)
TO the gunicorn definition. Raw output: `bench/R762-gunicorn.log` and `bench/evidence/MV-wger/`.
- The verdict is `proven`. The seed was read back before and after the switch. The app was healthy after it. The
abort was `starts-and-serves`. Measured at 2026-10-08T06:19:56Z.
- The memory watch ran for 606.5 s with 11,584 requests (all 302) and `load: reached`.
- wger: anon peak 178,151,424 B = 170 MiB = **44.2 %** of 384M; 0 oom_kills; 0 restarts; the cgroup peak was
100 % (page cache).
- wger-files: anon peak 15.8 %; 0 kills; 0 restarts.
- `to-full.log` has „Using gunicorn on port 8000..." and **2 × „Booting worker"** (pids 18 and 19).
2. **`check/benchcheck.sh`**: the definition started fresh in project `r762b`, then the login page, its CSS, a photo
and the workers were read. Results in `check/`.
- Ready after 77 s. **Login page 200.**
- **CSS 200** for all 3 of the page's own links, through wger-files (`text/css`; 2481 B, 277042 B and 1006 B).
- Web login 302 with a session. `POST /api/v2/gallery/` with a 179 B PNG returned 201. **The photo read back through
wger-files: 200, 179 B, `image/png`.**
- Control: an unknown `/static/` file returned 404.
- The container's env holds `WGER_USE_GUNICORN=True` and `WEB_CONCURRENCY=2`. Its log has **2 × „Booting worker"**.
- anon peak 174,047,232 B = 166 MiB = 43.2 %. oom 0, oom_kill 0. restarts=0, oomkilled=false.
- 20 parallel login GETs all returned 200.
- This run is short. The 10-minute watch is run 1.
The night's figure (`night-burndown-2026-10-06/r762/`) was 157 MiB, 41 %, on the template without traefik's env. Today
it is 166 to 170 MiB, 43 to 44 %, on the full catalog template.
## The ladder: why no step file was written
The ladder records image moves. This change moves no image: `from` and `to` would both be
`{wger: wger/server:2.7, wger-files: nginx:1.30.5-alpine}` (step key `10df849ded803b6e`). The writer handles
from == to as a re-test, and `ladder.check_entry` refuses that entry:
„a re-test (from == to) whose digest is the same as its digest_from tests nothing new — no new digest" (tool output,
2026-10-08). `09` §5.4's render table says „deployed, pinned, catalog images equal → the catalog template — fixes flow".
So a compose-only change reaches an installed wger at its next `up -d`, and the product's restart is `up -d`
(`manager.go` ~L1411). It needs no ladder entry. No box runs wger (hub read, 07:58).
## Teardown
- **Machine (bench 9401):** project `r762b` was taken down with `down -v`; afterwards 0 containers and 0 `r762`
volumes. The harness ran its own `down -v`. `/root/r762b` and the helper scripts were deleted. `/opt/upg/templates/wger@gunicorn`
was deleted. `/opt/upg`'s scripts and `templates/wger` were updated to the catalog's `32d1346` copies; they were
older before. `/opt/upg/evidence/MV-wger` now holds today's run (the 10-06 run is kept in
`audits/design-build-2026-10-06/F/bench/`). `/opt/upg/R762-gunicorn.log` stays. **9401 was stopped** (`pct status`:
stopped), as it was found.
- **Machine (9202):** only GETs and a dashboard login. 0 wger containers; `repo_url` is still the live catalog. Its
deployed list was paperless-ngx, privatebin and recipe-importer at 08:0x CEST and paperless-ngx and privatebin at
08:35. **This session did not touch recipe-importer**; something else removed it in that window.
- **Host (demo-hp):** the `/tmp` copy files were deleted. Nothing else was created.
- **Hub:** nothing.
- No Docker command ran on DooPlex. Nothing was pruned.
The image's default admin password is redacted in every file here.
@@ -0,0 +1,130 @@
[06:05:43] scratch drive folders cleared before FROM (R-656): none existed
[06:05:43] MV-wger: deploying wger at FROM {'wger': 'wger/server:2.7', 'wger-files': 'nginx:1.30.5-alpine'}
[06:07:17] FROM settled=True in 93.0s :: {"wger": {"status": "running", "health": "healthy", "restarts": 0, "exit": 0}, "wger-files": {"status": "running", "health": "healthy", "restarts": 0, "exit": 0}}
[06:07:17] fixture: the BOX walk's own (Wger), through upgrade_boxport
[06:07:17] wger: the generated admin password does not log in (POST /en/user/login -> 200) — running the template's own after_install command (the app's CLI, as the product does after an install)
[06:07:19] wger: after_install :: version 8.3.1, blocking by username FELHOM_AFTER_INSTALL_OK
[06:07:21] wger: POST /api/v2/weightentry/ http=201
[06:07:21] wger: readback of the seeded weight entry http=200 found=True
[06:07:21] C1 (seed reads back BEFORE): True
[06:07:21] MV-wger: swapping to TO {'wger': 'wger/server:2.7', 'wger-files': 'nginx:1.30.5-alpine'}
[06:07:33] TO up -d rc=0
[06:08:35] TO settled=True in 62.1s :: {"wger": {"status": "running", "health": "healthy", "restarts": 0, "exit": 0}, "wger-files": {"status": "running", "health": "healthy", "restarts": 0, "exit": 0}}
[06:08:35] migration lines observed: 6
[06:08:35] wger: readback of the seeded weight entry http=200 found=True
[06:08:35] RESULT (seed reads back AFTER): True
[06:08:36] memory watch: 600s, 4 callers on 1 path(s) at 172.18.0.2:8000
[06:08:51] + 15s wger=288M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=292
[06:09:06] + 30s wger=291M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=580
[06:09:21] + 46s wger=292M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=872
[06:09:37] + 61s wger=291M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=1160
[06:09:52] + 76s wger=292M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=1452
[06:10:07] + 91s wger=292M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=1740
[06:10:22] + 106s wger=291M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=2028
[06:10:37] + 121s wger=293M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=2320
[06:10:52] + 136s wger=292M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=2608
[06:11:07] + 152s wger=292M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=2896
[06:11:23] + 167s wger=293M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=3188
[06:11:38] + 182s wger=292M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=3476
[06:11:53] + 197s wger=293M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=3768
[06:12:08] + 212s wger=293M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=4056
[06:12:23] + 227s wger=292M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=4344
[06:12:38] + 242s wger=293M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=4636
[06:12:54] + 258s wger=292M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=4924
[06:13:09] + 273s wger=293M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=5216
[06:13:24] + 288s wger=292M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=5504
[06:13:39] + 303s wger=292M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=5792
[06:13:54] + 318s wger=293M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=6084
[06:14:09] + 334s wger=292M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=6372
[06:14:25] + 349s wger=293M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=6664
[06:14:40] + 364s wger=293M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=6952
[06:14:55] + 379s wger=293M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=7240
[06:15:10] + 394s wger=293M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=7529
[06:15:25] + 409s wger=293M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=7820
[06:15:40] + 424s wger=293M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=8108
[06:15:55] + 440s wger=293M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=8400
[06:16:11] + 455s wger=292M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=8688
[06:16:26] + 470s wger=293M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=8978
[06:16:41] + 485s wger=293M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=9268
[06:16:56] + 500s wger=293M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=9556
[06:17:11] + 515s wger=293M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=9848
[06:17:26] + 530s wger=293M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=10136
[06:17:42] + 546s wger=293M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=10428
[06:17:57] + 561s wger=293M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=10716
[06:18:12] + 576s wger=293M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=11008
[06:18:27] + 591s wger=293M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=11296
[06:18:42] + 606s wger=293M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=11584
[06:18:42] memory watch: killed=False tight=[] requests=11584 codes={'302': 11584}
[06:18:42] MV-wger: ABORT — putting the FROM images back
[06:19:56] wger: readback of the seeded weight entry http=200 found=True
[06:19:56] ABORT: app came back in 62.0s; data present=True
{
"harness_version": 5,
"edge": "MV-wger",
"app": "wger",
"note": "definition step to wger@gunicorn",
"from": {
"wger": "wger/server:2.7",
"wger-files": "nginx:1.30.5-alpine"
},
"to": {
"wger": "wger/server:2.7",
"wger-files": "nginx:1.30.5-alpine"
},
"verdict": "proven",
"seed_read_before": true,
"seed_read_after": true,
"healthy_after": true,
"migration_observed": "\u001b[2Kwger | Performing database migrations",
"abort": "starts-and-serves",
"abort_detail": null,
"engine_state_after": null,
"memory": {
"soak_s": 606.5,
"requested_s": 600,
"requests": 11584,
"codes": {
"302": 11584
},
"first_kill": null,
"containers": {
"wger": {
"limit": 402653184,
"peak": 402653184,
"peak_pct": 1.0,
"anon_peak_sampled": 178151424,
"anon_peak_pct": 0.442,
"swap_peak": 0,
"oom_kills": 0,
"restarts": 0,
"oomkilled_flag": false,
"measured": true
},
"wger-files": {
"limit": 33554432,
"peak": 9281536,
"peak_pct": 0.277,
"anon_peak_sampled": 5308416,
"anon_peak_pct": 0.158,
"swap_peak": 0,
"oom_kills": 0,
"restarts": 0,
"oomkilled_flag": false,
"measured": true
}
},
"unmeasured": [],
"venue_swap_bytes": 0,
"load": "reached"
},
"marks": [],
"bench_overrides": null,
"duration_s": 62.1,
"measured_at": "2026-10-08T06:19:56Z",
"evidence": "evidence/MV-wger",
"scratch_cleared": [],
"files_changed": [],
"files_changed_detail": [],
"files_ignored": [],
"total_s": 853.6
}
@@ -0,0 +1 @@
174047232
@@ -0,0 +1,53 @@
#!/bin/bash
# R-762 bench check (2026-10-08): the gunicorn definition up on its own, then the login page, its CSS, a photo read back,
# the workers in the container's own log, and the app's memory (anon). Project r762b only; down -v at the end.
set -u
W=/root/r762b; O=$W/out; rm -rf $W; mkdir -p $O; cd $W
cp /opt/upg/templates/wger@gunicorn/docker-compose.yml docker-compose.yml
umask 077
printf 'DOMAIN=bench.invalid\nSUBDOMAIN=fitness\nSECRET_KEY=%s\n' "$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')" > .env
docker compose -p r762b up -d > $O/up.txt 2>&1
ID=$(docker inspect -f '{{.Id}}' wger); CG=$(find /sys/fs/cgroup -maxdepth 6 -type d -name "*$ID*" | head -1)
touch $W/.sampling; ( max=0; while [ -f $W/.sampling ]; do a=$(awk '$1=="anon"{print $2}' $CG/memory.stat 2>/dev/null); [ -n "$a" ] && [ "$a" -gt "$max" ] && max=$a && echo $max > $O/anon-max; sleep 0.5; done ) &
echo "cgroup: $CG" > $O/cg.txt
IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' wger)
FIP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' wger-files)
t0=$(date +%s); for i in $(seq 1 300); do c=$(curl -s -o /dev/null -w '%{http_code}' http://$IP:8000/en/user/login); [ "$c" = 200 ] && break; sleep 1; done
echo "ready_after_s=$(( $(date +%s)-t0 )) login=$c" > $O/checks.txt
curl -s http://$IP:8000/en/user/login | grep -o '/static/[^"]*\.css' | head -3 | while read l; do echo "css $l via wger-files: $(curl -s -o /dev/null -w '%{http_code} %{size_download}B %{content_type}' http://$FIP$l)"; done >> $O/checks.txt
# a photo through wger's own gallery API, signed in through its own login form as the image's seeded admin (bench-only
# throwaway box; the password is not written anywhere). As a browser behind traefik: Host + X-Forwarded-Proto https, the
# cookies carried by hand (Django's csrftoken is Secure; curl drops it over http).
H=(-H "Host: fitness.bench.invalid" -H "X-Forwarded-Proto: https" -H "Origin: https://fitness.bench.invalid" -H "Referer: https://fitness.bench.invalid/en/user/login")
curl -s -D $W/h1 -o $W/b1 "${H[@]}" http://$IP:8000/en/user/login
CSRFC=$(grep -i '^set-cookie: csrftoken=' $W/h1 | sed 's/^[^=]*=//; s/;.*//' | tr -d '\r')
FORM=$(grep -o 'name="csrfmiddlewaretoken" value="[^"]*"' $W/b1 | head -1 | sed 's/.*value="//; s/"$//')
curl -s -D $W/h2 -o /dev/null "${H[@]}" -H "Cookie: csrftoken=$CSRFC" --data-urlencode "csrfmiddlewaretoken=$FORM" --data-urlencode login=admin --data-urlencode password=<image-default, redacted> http://$IP:8000/en/user/login
SID=$(grep -i '^set-cookie: sessionid=' $W/h2 | sed 's/^[^=]*=//; s/;.*//' | tr -d '\r')
CSRF2=$(grep -i '^set-cookie: csrftoken=' $W/h2 | sed 's/^[^=]*=//; s/;.*//' | tr -d '\r'); [ -n "$CSRF2" ] && CSRFC=$CSRF2
echo "web login: $(head -1 $W/h2 | tr -d '\r'), session cookie: $([ -n "$SID" ] && echo yes || echo no)" >> $O/checks.txt
rm -f $W/h1 $W/h2 $W/b1
python3 - > $W/p.png <<'PY'
import struct, zlib, sys, os
n=64; rgb=os.urandom(3); raw=b"".join(b"\x00"+rgb*n for _ in range(n))
def ch(t,d): return struct.pack(">I",len(d))+t+d+struct.pack(">I",zlib.crc32(t+d)&0xffffffff)
sys.stdout.buffer.write(b"\x89PNG\r\n\x1a\n"+ch(b"IHDR",struct.pack(">IIBBBBB",n,n,8,2,0,0,0))+ch(b"IDAT",zlib.compress(raw))+ch(b"IEND",b""))
PY
SZ=$(stat -c %s $W/p.png)
R=$(curl -s -w '\n%{http_code}' "${H[@]}" -H "Cookie: csrftoken=$CSRFC; sessionid=$SID" -H "X-CSRFToken: $CSRFC" -F "image=@$W/p.png;type=image/png" -F date=2026-10-08 -F description=r762 http://$IP:8000/api/v2/gallery/)
echo "POST /api/v2/gallery/ (${SZ} B PNG) -> $(echo "$R" | tail -1)" >> $O/checks.txt
P=$(echo "$R" | head -n -1 | python3 -c 'import json,sys,re; print(re.sub(r"^https?://[^/]+","",json.load(sys.stdin).get("image","")))' 2>/dev/null)
[ -z "$P" ] && echo "photo: NO PATH returned (the upload failed)" >> $O/checks.txt
[ -n "$P" ] && echo "photo $P via wger-files: $(curl -s -o /dev/null -w '%{http_code} %{size_download}B %{content_type}' http://$FIP$P)" >> $O/checks.txt
echo "control: an unknown /static/ file via wger-files: $(curl -s -o /dev/null -w '%{http_code}' http://$FIP/static/r762-nonexistent.css)" >> $O/checks.txt
# light load: 20 login GETs, 10 parallel
seq 1 20 | xargs -P 10 -I{} curl -s -o /dev/null -w 'conc %{http_code} %{time_total}\n' http://$IP:8000/en/user/login > $O/conc.txt
sleep 3; rm -f $W/.sampling; sleep 1
grep -E '^(oom|oom_kill) ' $CG/memory.events > $O/events.txt; cat $CG/memory.max > $O/memory.max; cat $CG/memory.peak > $O/memory.peak 2>/dev/null
docker inspect -f 'restarts={{.RestartCount}} oomkilled={{.State.OOMKilled}} status={{.State.Status}} health={{.State.Health.Status}}' wger > $O/inspect.txt
docker exec wger sh -c 'env | grep -E "^(WGER_USE_GUNICORN|WEB_CONCURRENCY)="' > $O/env-in-container.txt
docker logs wger 2>&1 | sed 's/<image-default, redacted>/<image-default, redacted>/g' > $O/wger.log
grep -E 'Using gunicorn|Using django|Booting worker|Listening at|Starting gunicorn|Starting development server' $O/wger.log > $O/server-lines.txt
docker compose -p r762b down -v > $O/down.txt 2>&1
rm -f $W/p.png .env
echo done
@@ -0,0 +1 @@
cgroup: /sys/fs/cgroup/system.slice/docker-1dd3339a4eeb9ed63540b35d7ca09acca030259a68038e233e7270947dadcb05.scope
@@ -0,0 +1,8 @@
ready_after_s=77 login=200
css /static/css/workout-manager.7007d84ce531.css via wger-files: 200 2481B text/css
css /static/bootstrap-compiled.80a6279921f8.css via wger-files: 200 277042B text/css
css /static/css/bootstrap-custom.400ad578123c.css via wger-files: 200 1006B text/css
web login: HTTP/1.1 302 Found, session cookie: yes
POST /api/v2/gallery/ (179 B PNG) -> 201
photo /media/gallery/1/badf61de-7554-44b9-b72f-87df80fbd01d.png via wger-files: 200 179B image/png
control: an unknown /static/ file via wger-files: 404
@@ -0,0 +1,20 @@
conc 200 0.044549
conc 200 0.080295
conc 200 0.119458
conc 200 0.140526
conc 200 0.147622
conc 200 0.169720
conc 200 0.175291
conc 200 0.200793
conc 200 0.203844
conc 200 0.221900
conc 200 0.196650
conc 200 0.178437
conc 200 0.147859
conc 200 0.152214
conc 200 0.146952
conc 200 0.140927
conc 200 0.146328
conc 200 0.139658
conc 200 0.140157
conc 200 0.139606
@@ -0,0 +1,14 @@
Container wger-files Stopping
Container wger-files Stopped
Container wger-files Removing
Container wger-files Removed
Container wger Stopping
Container wger Stopped
Container wger Removing
Container wger Removed
Volume r762b_wger_media Removing
Volume r762b_wger_data Removing
Volume r762b_wger_static Removing
Volume r762b_wger_media Removed
Volume r762b_wger_static Removed
Volume r762b_wger_data Removed
@@ -0,0 +1,2 @@
WGER_USE_GUNICORN=True
WEB_CONCURRENCY=2
@@ -0,0 +1,2 @@
oom 0
oom_kill 0
@@ -0,0 +1 @@
restarts=0 oomkilled=false status=running health=starting
@@ -0,0 +1 @@
402653184
@@ -0,0 +1 @@
402653184
@@ -0,0 +1,5 @@
Using gunicorn on port 8000...
[2026-10-08 08:32:13 +0200] [28] [INFO] Starting gunicorn 26.1.0
[2026-10-08 08:32:13 +0200] [28] [INFO] Listening at: http://0.0.0.0:8000 (28)
[2026-10-08 08:32:13 +0200] [29] [INFO] Booting worker with pid: 29
[2026-10-08 08:32:13 +0200] [30] [INFO] Booting worker with pid: 30
@@ -0,0 +1,14 @@
Volume "r762b_wger_static" Creating
Volume "r762b_wger_static" Created
Volume "r762b_wger_data" Creating
Volume "r762b_wger_data" Created
Volume "r762b_wger_media" Creating
Volume "r762b_wger_media" Created
Container wger Creating
Container wger Created
Container wger-files Creating
Container wger-files Created
Container wger Starting
Container wger Started
Container wger-files Starting
Container wger-files Started
@@ -0,0 +1,295 @@
*** Using settings from env: settings.main
level=INFO ts=2026-10-08 08:31:01,000 module=apps path=/home/wger/.local/lib/python3.12/site-packages/axes/apps.py line=53 message=AXES: BEGIN version 8.3.1, blocking by username
*** Database is empty or incomplete, setting it up now
*** Using settings from env: settings.main
Operations to perform:
Apply all migrations: account, actstream, allauth_idp_oidc, auth, authtoken, axes, config, contenttypes, core, easy_thumbnails, exercises, gallery, gym, mailer, manager, measurements, mfa, nutrition, sessions, sites, socialaccount, token_blacklist, trophies, weight
Running migrations:
Applying contenttypes.0001_initial... OK
Applying auth.0001_initial... OK
Applying account.0001_initial... OK
Applying account.0002_email_max_length... OK
Applying account.0003_alter_emailaddress_create_unique_verified_email... OK
Applying account.0004_alter_emailaddress_drop_unique_email... OK
Applying account.0005_emailaddress_idx_upper_email... OK
Applying account.0006_emailaddress_lower... OK
Applying account.0007_emailaddress_idx_email... OK
Applying account.0008_emailaddress_unique_primary_email_fixup... OK
Applying account.0009_emailaddress_unique_primary_email... OK
Applying actstream.0001_initial... OK
Applying actstream.0002_remove_action_data... OK
Applying actstream.0003_add_follow_flag... OK
Applying allauth_idp_oidc.0001_initial... OK
Applying allauth_idp_oidc.0002_client_default_scopes... OK
Applying allauth_idp_oidc.0003_client_allow_uri_wildcards... OK
Applying contenttypes.0002_remove_content_type_name... OK
Applying auth.0002_alter_permission_name_max_length... OK
Applying auth.0003_alter_user_email_max_length... OK
Applying auth.0004_alter_user_username_opts... OK
Applying auth.0005_alter_user_last_login_null... OK
Applying auth.0006_require_contenttypes_0002... OK
Applying auth.0007_alter_validators_add_error_messages... OK
Applying auth.0008_alter_user_username_max_length... OK
Applying auth.0009_alter_user_last_name_max_length... OK
Applying auth.0010_alter_group_name_max_length... OK
Applying auth.0011_update_proxy_permissions... OK
Applying auth.0012_alter_user_first_name_max_length... OK
Applying authtoken.0001_initial... OK
Applying authtoken.0002_auto_20160226_1747... OK
Applying authtoken.0003_tokenproxy... OK
Applying authtoken.0004_alter_tokenproxy_options... OK
Applying axes.0001_initial... OK
Applying axes.0002_auto_20151217_2044... OK
Applying axes.0003_auto_20160322_0929... OK
Applying axes.0004_auto_20181024_1538... OK
Applying axes.0005_remove_accessattempt_trusted... OK
Applying axes.0006_remove_accesslog_trusted... OK
Applying axes.0007_alter_accessattempt_unique_together... OK
Applying axes.0008_accessfailurelog... OK
Applying axes.0009_add_session_hash... OK
Applying axes.0010_accessattemptexpiration... OK
Applying gym.0001_initial... OK
Applying core.0001_initial... OK
Applying config.0001_initial... OK
Applying config.0002_auto_20190618_1617... OK
Applying config.0003_delete_languageconfig... OK
Applying sessions.0001_initial... OK
Applying weight.0001_initial... OK
Applying weight.0002_auto_20150604_2139... OK
Applying weight.0003_auto_20160416_1030... OK
Applying weight.0004_multiple_weight_entries_per_day... OK
Applying weight.0005_add_uuid... OK
Applying nutrition.0001_initial... OK
Applying nutrition.0002_auto_20170101_1538... OK
Applying nutrition.0003_auto_20170118_2308... OK
Applying nutrition.0004_auto_20200819_2310... OK
Applying nutrition.0005_logitem... OK
Applying nutrition.0006_auto_20201201_0653... OK
Applying nutrition.0007_auto_20201214_0013... OK
Applying nutrition.0008_auto_20210102_1446... OK
Applying nutrition.0009_meal_name... OK
Applying nutrition.0010_logitem_meal... OK
Applying nutrition.0011_alter_logitem_datetime... OK
Applying nutrition.0012_alter_ingredient_license_author... OK
Applying gym.0002_auto_20151003_1944... OK
Applying gym.0003_auto_20151003_2008... OK
Applying gym.0004_auto_20151003_2357... OK
Applying gym.0005_auto_20151023_1522... OK
Applying gym.0006_auto_20160214_1013... OK
Applying gym.0007_auto_20170123_0920... OK
Applying gym.0008_auto_20190618_1617... OK
Applying exercises.0001_initial... OK
Applying manager.0001_initial... OK
Applying manager.0002_auto_20150202_2040... OK
Applying manager.0004_auto_20150609_1603... OK
Applying core.0002_auto_20141225_1512... OK
Applying core.0003_auto_20150217_1554... OK
Applying core.0004_auto_20150217_1914... OK
Applying core.0005_auto_20151025_2236... OK
Applying core.0006_auto_20151025_2237... OK
Applying core.0007_repetitionunit... OK
Applying core.0008_weightunit... OK
Applying core.0009_auto_20160303_2340... OK
Applying core.0010_auto_20170403_0144... OK
Applying core.0011_auto_20201201_0653... OK
Applying core.0012_auto_20210210_1228... OK
Applying core.0013_auto_20210726_1729... OK
Applying nutrition.0013_ingredient_image... OK
Applying nutrition.0014_license_information... OK
Applying nutrition.0015_alter_ingredient_creation_date_and_more... OK
Applying nutrition.0016_alter_logitem_options_and_more... OK
Applying nutrition.0017_remove_nutritionplan_language_alter_logitem_meal... OK
Applying nutrition.0018_nutritionplan_goal_carbs_nutritionplan_goal_energy_and_more... OK
Applying nutrition.0019_alter_image_license_author_and_more... OK
Applying nutrition.0020_full_text_search... OK
Applying nutrition.0021_add_fibers_field... OK
Applying nutrition.0022_add_remote_id_increase_author_field_length... OK
Applying nutrition.0023_fiber_spelling... OK
Applying nutrition.0024_remove_ingredient_status... OK
Applying nutrition.0025_add_last_image_check... OK
Applying nutrition.0026_add_start_and_end_fields... OK
Applying nutrition.0027_prefill_end_date... OK
Applying nutrition.0028_ingredient_dietary_properties... OK
Applying nutrition.0029_ingredient_nutriscore... OK
Applying manager.0005_auto_20160303_2008... OK
Applying manager.0006_auto_20160303_2138... OK
Applying manager.0007_auto_20160311_2258... OK
Applying manager.0008_auto_20190618_1617... OK
Applying manager.0009_auto_20201202_1559... OK
Applying manager.0010_auto_20210102_1446... OK
Applying manager.0011_remove_set_exercises... OK
Applying manager.0012_auto_20210430_1449... OK
Applying manager.0013_set_comment... OK
Applying manager.0014_auto_20210717_1858... OK
Applying manager.0015_auto_20211028_1113... OK
Applying exercises.0002_auto_20150307_1841... OK
Applying exercises.0003_auto_20160921_2000... OK
Applying exercises.0004_auto_20170404_0114... OK
Applying exercises.0005_auto_20190618_1617... OK
Applying exercises.0006_auto_20201203_0203... OK
Applying exercises.0007_auto_20201203_1042... OK
Applying exercises.0008_exercisebase... OK
Applying exercises.0009_auto_20201211_0139... OK
Applying exercises.0010_auto_20201211_0205... OK
Applying exercises.0011_auto_20201214_0033... OK
Applying exercises.0012_auto_20210327_1219... OK
Applying exercises.0013_auto_20210503_1232... OK
Applying exercises.0014_exerciseimage_style... OK
Applying exercises.0015_exercise_videos... OK
Applying exercises.0016_exercisealias... OK
Applying exercises.0017_muscle_name_en... OK
Applying core.0013_userprofile_email_verified... OK
Applying core.0014_merge_20210818_1735... OK
Applying exercises.0018_delete_pending_exercises... OK
Applying exercises.0019_exercise_crowdsourcing_changes... OK
Applying manager.0016_move_to_exercise_base... OK
Applying manager.0017_alter_workoutlog_exercise_base... OK
Applying exercises.0020_historicalexerciseimage_historicalexercisevideo... OK
Applying exercises.0021_deletionlog... OK
Applying exercises.0022_alter_exercise_license_author_and_more... OK
Applying exercises.0023_make_uuid_unique... OK
Applying exercises.0024_license_information... OK
Applying exercises.0025_rename_update_date_exercise_last_update_and_more... OK
Applying exercises.0026_deletionlog_replaced_by... OK
Applying exercises.0027_alter_deletionlog_replaced_by_and_more... OK
Applying exercises.0028_add_uuid_alias_and_comments... OK
Applying exercises.0029_full_text_search... OK
Applying exercises.0030_increase_author_field_length... OK
Applying exercises.0032_rename_exercise... OK
Applying core.0015_alter_language_short_name... OK
Applying core.0016_alter_language_short_name... OK
Applying manager.0018_flexible_routines... OK
Applying manager.0019_flexible_routines_migration... OK
Applying manager.0021_flexible_routines_cleanup... OK
Applying core.0017_language_full_name_en... OK
Applying core.0018_rounding... OK
Applying core.0019_delete_daysofweek... OK
Applying core.0020_add_trophies_enabled_to_userprofile... OK
Applying core.0021_add_unit_type_to_repetitionunit... OK
Applying nutrition.0030_add_indices... OK
Applying nutrition.0031_start_weight_unit_merge... OK
Applying nutrition.0032_continue_weight_unit_merge... OK
Applying nutrition.0033_finalize_weight_unit_merge... OK
Applying nutrition.0034_ingredient_trigram_gin_index... OK
Applying nutrition.0035_add_uuids... OK
Applying nutrition.0036_alter_image_license_author_and_more... OK
Applying nutrition.0037_powersync_synced_ingredient_tables... OK
Applying measurements.0001_initial... OK
Applying measurements.0002_auto_20210722_1042... OK
Applying measurements.0003_alter_measurement_unique_together_and_more... OK
Applying measurements.0004_add_uuids... OK
Applying measurements.0005_alter_measurement_date... OK
Applying trophies.0001_initial... OK
Applying trophies.0002_load_initial_trophies... OK
Applying manager.0022_alter_rir_type... OK
Applying manager.0023_change_validators... OK
Applying manager.0024_log_and_session_uuid... OK
Applying manager.0025_change_pk_to_uuid... OK
Applying trophies.0003_migrate_context_data_uuids... OK
Applying manager.0026_change_pk_to_uuid_swap... OK
Applying core.0022_move_email_verified_to_emailaddress... OK
Applying core.0023_create_publication... OK
Applying manager.0027_cleanup_fields... OK
Applying manager.0028_backfill_session_day... OK
Applying gallery.0001_initial... OK
Applying exercises.0033_uniqueness_constraint_translations... OK
Applying exercises.0034_add_exercise_image_dimensions... OK
Applying exercises.0035_add_is_ai_generated... OK
Applying exercises.0036_add_markdown_description_field... OK
Applying exercises.0037_replace_variation_with_uuid_field... OK
Applying exercises.0038_sync_model_changes... OK
Applying exercises.0039_translation_alias_trigram_gin_index... OK
Applying exercises.0040_alter_exercise_license_author_and_more... OK
Applying core.0024_backfill_emailaddress... OK
Applying core.0025_remove_unused_fields_in_userprofile... OK
Applying core.0026_alter_userprofile_birthdate_alter_userprofile_height... OK
Applying core.0027_powersync_publication... OK
Applying core.0028_longlivedsession... OK
Applying core.0029_userprofile_timezone... OK
Applying easy_thumbnails.0001_initial... OK
Applying easy_thumbnails.0002_thumbnaildimensions... OK
Applying mailer.0001_initial... OK
Applying mailer.0002_auto_20190618_1617... OK
Applying mailer.0003_auto_20201201_0653... OK
Applying manager.0029_alter_workoutsession_options_and_more... OK
Applying measurements.0006_health_sync... OK
Applying measurements.0007_migrate_weight... OK
Applying measurements.0008_dynamic_type... OK
Applying mfa.0001_initial... OK
Applying mfa.0002_authenticator_timestamps... OK
Applying mfa.0003_authenticator_type_uniq... OK
Applying sites.0001_initial... OK
Applying sites.0002_alter_domain_unique... OK
Applying socialaccount.0001_initial... OK
Applying socialaccount.0002_token_max_lengths... OK
Applying socialaccount.0003_extra_data_default_dict... OK
Applying socialaccount.0004_app_provider_id_settings... OK
Applying socialaccount.0005_socialtoken_nullable_app... OK
Applying socialaccount.0006_alter_socialaccount_extra_data... OK
Applying token_blacklist.0001_initial... OK
Applying token_blacklist.0002_outstandingtoken_jti_hex... OK
Applying token_blacklist.0003_auto_20171017_2007... OK
Applying token_blacklist.0004_auto_20171017_2013... OK
Applying token_blacklist.0005_remove_outstandingtoken_jti... OK
Applying token_blacklist.0006_auto_20171017_2113... OK
Applying token_blacklist.0007_auto_20171017_2214... OK
Applying token_blacklist.0008_migrate_to_bigautofield... OK
Applying token_blacklist.0010_fix_migrate_to_bigautofield... OK
Applying token_blacklist.0011_linearizes_history... OK
Applying token_blacklist.0012_alter_outstandingtoken_user... OK
Applying token_blacklist.0013_alter_blacklistedtoken_options_and_more... OK
Applying weight.0006_delete_weightentry... OK
*** Using settings from env: settings.main
Installed 1 object(s) from 1 fixture(s)
Installed 33 object(s) from 1 fixture(s)
Installed 7 object(s) from 1 fixture(s)
Installed 3 object(s) from 1 fixture(s)
Installed 5 object(s) from 1 fixture(s)
Installed 8 object(s) from 1 fixture(s)
Installed 6 object(s) from 1 fixture(s)
Installed 1 object(s) from 1 fixture(s)
Installed 12 object(s) from 1 fixture(s)
Installed 16 object(s) from 1 fixture(s)
Installed 8 object(s) from 1 fixture(s)
Installed 872 object(s) from 1 fixture(s)
Installed 2429 object(s) from 1 fixture(s)
Installed 1 object(s) from 1 fixture(s)
Installed 1 object(s) from 1 fixture(s)
Installed 1 object(s) from 1 fixture(s)
*** Using settings from env: settings.main
*** Password for user admin was reset to '<image-default, redacted>'
Installed 3 object(s) from 1 fixture(s)
Running in production mode, running collectstatic now
level=INFO ts=2026-10-08 08:31:51,700 module=apps path=/home/wger/.local/lib/python3.12/site-packages/axes/apps.py line=53 message=AXES: BEGIN version 8.3.1, blocking by username
11362 static files copied to '/home/wger/static', 11362 post-processed.
Performing database migrations
level=INFO ts=2026-10-08 08:32:06,548 module=apps path=/home/wger/.local/lib/python3.12/site-packages/axes/apps.py line=53 message=AXES: BEGIN version 8.3.1, blocking by username
System check identified some issues:
WARNINGS:
?: (axes.W006) AXES_LOCKOUT_PARAMETERS does not contain 'ip_address'. This configuration allows attackers to bypass rate limits by rotating User-Agents or Cookies.
HINT: Add 'ip_address' to AXES_LOCKOUT_PARAMETERS.
Operations to perform:
Apply all migrations: account, actstream, allauth_idp_oidc, auth, authtoken, axes, config, contenttypes, core, easy_thumbnails, exercises, gallery, gym, mailer, manager, measurements, mfa, nutrition, sessions, sites, socialaccount, token_blacklist, trophies, weight
Running migrations:
No migrations to apply.
Your models in app(s): 'exercises', 'gallery' have changes that are not yet reflected in a migration, and so won't be applied.
Run 'manage.py makemigrations' to make new migrations, and then re-run 'manage.py migrate' to apply them.
level=INFO ts=2026-10-08 08:32:09,710 module=apps path=/home/wger/.local/lib/python3.12/site-packages/axes/apps.py line=53 message=AXES: BEGIN version 8.3.1, blocking by username
System check identified some issues:
WARNINGS:
?: (axes.W006) AXES_LOCKOUT_PARAMETERS does not contain 'ip_address'. This configuration allows attackers to bypass rate limits by rotating User-Agents or Cookies.
HINT: Add 'ip_address' to AXES_LOCKOUT_PARAMETERS.
Set site URL to fitness.bench.invalid
Using gunicorn on port 8000...
level=INFO ts=2026-10-08 08:32:12,670 module=apps path=/home/wger/.local/lib/python3.12/site-packages/axes/apps.py line=53 message=AXES: BEGIN version 8.3.1, blocking by username
[2026-10-08 08:32:13 +0200] [28] [INFO] Starting gunicorn 26.1.0
[2026-10-08 08:32:13 +0200] [28] [INFO] Listening at: http://0.0.0.0:8000 (28)
[2026-10-08 08:32:13 +0200] [28] [INFO] Using worker: sync
[2026-10-08 08:32:13 +0200] [29] [INFO] Booting worker with pid: 29
[2026-10-08 08:32:13 +0200] [30] [INFO] Booting worker with pid: 30
[2026-10-08 08:32:13 +0200] [28] [INFO] Control socket listening at /home/wger/.gunicorn/gunicorn.ctl
level=INFO ts=2026-10-08 08:32:14,549 module=database path=/home/wger/.local/lib/python3.12/site-packages/axes/handlers/database.py line=295 message=AXES: Successful login by {username: "********************", ip_address: "********************", user_agent: "curl/8.14.1", path_info: "/en/user/login"}.
level=INFO ts=2026-10-08 08:32:14,551 module=database path=/home/wger/.local/lib/python3.12/site-packages/axes/handlers/database.py line=425 message=AXES: Cleaned up 0 expired access attempts from database that were older than 2026-10-08 06:27:14.291696+00:00
@@ -0,0 +1,159 @@
# wger - Edzésnapló és fitnesz tervező
# Domain: ${SUBDOMAIN}.${DOMAIN}
# Database: None (file-based)
# RAM: ~100M (mem_limit: 384M) | Pi-compatible: Yes
#
# Environment variables:
# DOMAIN - Your domain (e.g., demo-felhom.eu)
# SECRET_KEY - Titkosítási kulcs (auto-generated)
services:
wger:
image: wger/server:2.7
container_name: wger
# R-737 (2026-09-30): wger's app login API (the mobile app's) signs JWTs with JWT_PRIVATE_KEY / JWT_PUBLIC_KEY — an
# RSA pair the deploy's generators cannot make, and without it a CORRECT password answered 500. So the pair is made
# ONCE by wger's own `manage.py generate-jwt-keys`, kept 0600 on wger's own data volume (a restore brings the same
# key back), and loaded before the image's own entrypoint. Never printed.
entrypoint:
- /bin/sh
- -c
- |
K=/home/wger/db/.felhom-jwt.env
if [ ! -s "$$K" ]; then
(cd /home/wger/src && python3 manage.py generate-jwt-keys 2>/dev/null) | grep -E '^JWT_(PRIVATE|PUBLIC)_KEY=' > "$$K.tmp"
if [ "$$(grep -c . "$$K.tmp")" = 2 ]; then mv "$$K.tmp" "$$K" && chmod 600 "$$K"; else rm -f "$$K.tmp"; echo "felhom: JWT keys could not be made" >&2; fi
fi
if [ -s "$$K" ]; then set -a; . "$$K"; set +a; fi
exec /home/wger/entrypoint.sh
restart: unless-stopped
environment:
- TZ=Europe/Budapest
- SECRET_KEY=${SECRET_KEY}
# A wger 2.4+ a TELJES DJANGO_DB_* halmazt beolvassa, akkor is, ha az
# engine sqlite -- enélkül indulás nélkül kilép ("Set the DJANGO_DB_USER
# environment variable"). Az USER/PASSWORD/HOST/PORT értékeket az sqlite
# backend figyelmen kívül hagyja, de jelen kell lenniük.
# A DATABASE a wger_data kötetre mutat (/home/wger/db), oda, ahol a wger
# saját alapértelmezett sqlite fájlja is volt -- így meglévő telepítés
# adatai nem "tűnnek el" egy másik útvonalra.
# R-712 (measured 2026-09-29 on 9202): behind traefik wger saw the request as http and refused a browser's
# https Origin with "CSRF verification failed" — nobody could sign in from a browser.
- CSRF_TRUSTED_ORIGINS=https://${SUBDOMAIN}.${DOMAIN}
- X_FORWARDED_PROTO_HEADER_SET=True
# R-738: the image runs `manage.py migrate` at start ONLY with this switch (entrypoint.sh). Without it an update
# that brings migrations leaves wger serving its front page over an unmigrated database (login 500).
- DJANGO_PERFORM_MIGRATIONS=True
# R-752 (decided by CC unattended 2026-10-01, `09` §3 decision 58 — operator may reverse): django-axes locked by
# ip_address, and behind the tunnel every visitor has the tunnel's address (R-753) — a stranger's 10 wrong tries
# locked out EVERY household member for 30 min. Now only the targeted name, for 5 min (each try during a lock
# restarts it — wger 2.7 hard-codes that — so short is kinder), counted in the database (the default cache
# handler warns axes.W001). Measured on 9202: the other member unaffected; the targeted one in again at 7.5 min.
- AXES_LOCKOUT_PARAMETERS=username
- AXES_COOLOFF_TIME=5
- AXES_HANDLER=axes.handlers.database.AxesDatabaseHandler
# R-763 (2026-10-05): the image defaults both to True. After the install the admin exists (after_install sets its
# password), so nobody needs wger's own sign-up: a stranger could make an account through the front door, and every
# anonymous visit to the dashboard made a guest user row (wger's middleware create_temporary_user). Both read by
# settings/main.py as env.bool (wger 2.7 L179-180); the sign-up view then redirects to the features page.
- ALLOW_REGISTRATION=False
- ALLOW_GUEST_USERS=False
# R-764 (2026-10-05): mail through the box's relay (smtp_mapping in .felhom.yml, tls_mode plaintext -> :2526).
# wger 2.7 settings/main.py:162 reads the EMAIL_* group ONLY when ENABLE_EMAIL is true, and then env.str() with
# no default on EMAIL_HOST_USER / EMAIL_HOST_PASSWORD — so both stay defined-EMPTY here (the relay takes no
# login; an absent one would stop wger at start). ENABLE_EMAIL is the gate: False unless the mail toggle injects
# "True". EMAIL_USE_TLS False: Django's STARTTLS verifies the certificate and the relay's is self-signed.
- ENABLE_EMAIL=${ENABLE_EMAIL:-False}
- EMAIL_HOST=${EMAIL_HOST:-}
- EMAIL_PORT=${EMAIL_PORT:-2526}
- EMAIL_HOST_USER=
- EMAIL_HOST_PASSWORD=
- EMAIL_USE_TLS=False
- EMAIL_USE_SSL=False
- FROM_EMAIL=${FROM_EMAIL:-wger Workout Manager <wger@example.com>}
- DJANGO_DB_ENGINE=django.db.backends.sqlite3
- DJANGO_DB_DATABASE=/home/wger/db/database.sqlite
- DJANGO_DB_USER=wger
- DJANGO_DB_PASSWORD=wger
- DJANGO_DB_HOST=localhost
- DJANGO_DB_PORT=5432
- SITE_URL=https://${SUBDOMAIN}.${DOMAIN}
# R-762 (2026-10-06): production mode, as upstream's own prod.env (wger-project/docker config/prod.env). With
# DJANGO_DEBUG=False the image's entrypoint runs `collectstatic` at every start (entrypoint.sh:27) into
# /home/wger/static, and Django stops serving /static and /media itself (it never did in production — upstream
# puts nginx in front). wger-files below serves both from the shared volumes.
- DJANGO_DEBUG=False
# R-762 (2026-10-08): the real web server. The image's entrypoint.sh runs `gunicorn wger.wsgi:application --preload
# --bind 0.0.0.0:$PORT` when WGER_USE_GUNICORN is "True" (else Django's development server, `manage.py runserver`).
# It passes no -w and the image has no gunicorn.conf.py, so the worker count is gunicorn's own WEB_CONCURRENCY
# (unset = 1). Two workers: measured on the bench and on 9202 (2 x "Booting worker" in the log), anon peak well
# under the 384M limit — with --preload the workers share the app's pages with the master.
- WGER_USE_GUNICORN=True
- WEB_CONCURRENCY=2
volumes:
- wger_data:/home/wger/db
- wger_media:/home/wger/media
- wger_static:/home/wger/static
networks:
- traefik-public
deploy:
resources:
limits:
memory: 384M
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:8000"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
labels:
- "traefik.enable=true"
- "traefik.http.routers.wger.rule=Host(`${SUBDOMAIN}.${DOMAIN}`)"
- "traefik.http.routers.wger.entrypoints=websecure"
- "traefik.http.routers.wger.tls=true"
- "traefik.http.routers.wger.tls.certresolver=letsencrypt"
- "traefik.http.services.wger.loadbalancer.server.port=8000"
# R-762 (2026-10-06): the file server upstream's production compose puts in front of wger (its `nginx` service and
# config/nginx.conf: `location /static/ { alias /wger/static/; }`, `location /media/ { alias /wger/media/; }`).
# Here traefik is already the front door, so traefik sends ONLY /static/ and /media/ to this nginx and everything
# else to wger as before — no config file is needed: nginx's stock config serves /usr/share/nginx/html, and the two
# volumes are mounted there read-only. Every gate the box puts in front of the app wraps EVERY router of the stack
# (stacks/setup_gate.go, family_gate.go), so this router is gated exactly like wger's own.
wger-files:
image: nginx:1.30.5-alpine
container_name: wger-files
restart: unless-stopped
depends_on:
- wger
volumes:
- wger_static:/usr/share/nginx/html/static:ro
- wger_media:/usr/share/nginx/html/media:ro
networks:
- traefik-public
deploy:
resources:
limits:
memory: 32M
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1/"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
labels:
- "traefik.enable=true"
- "traefik.http.routers.wger-files.rule=Host(`${SUBDOMAIN}.${DOMAIN}`) && (PathPrefix(`/static/`) || PathPrefix(`/media/`))"
- "traefik.http.routers.wger-files.entrypoints=websecure"
- "traefik.http.routers.wger-files.tls=true"
- "traefik.http.routers.wger-files.tls.certresolver=letsencrypt"
- "traefik.http.services.wger-files.loadbalancer.server.port=80"
volumes:
wger_data:
wger_media:
wger_static:
networks:
traefik-public:
external: true
@@ -0,0 +1,14 @@
{
"wger": {
"status": "running",
"health": "healthy",
"restarts": 0,
"exit": 0
},
"wger-files": {
"status": "running",
"health": "healthy",
"restarts": 0,
"exit": 0
}
}
@@ -0,0 +1,81 @@
wger-files | /docker-entrypoint.sh: /docker-entrypoint.d/ is not empty, will attempt to perform configuration
wger-files | /docker-entrypoint.sh: Looking for shell scripts in /docker-entrypoint.d/
wger-files | /docker-entrypoint.sh: Launching /docker-entrypoint.d/10-listen-on-ipv6-by-default.sh
wger-files | 10-listen-on-ipv6-by-default.sh: info: Getting the checksum of /etc/nginx/conf.d/default.conf
wger-files | 10-listen-on-ipv6-by-default.sh: info: Enabled listen on IPv6 in /etc/nginx/conf.d/default.conf
wger-files | /docker-entrypoint.sh: Sourcing /docker-entrypoint.d/15-local-resolvers.envsh
wger-files | /docker-entrypoint.sh: Launching /docker-entrypoint.d/20-envsubst-on-templates.sh
wger-files | /docker-entrypoint.sh: Launching /docker-entrypoint.d/30-tune-worker-processes.sh
wger-files | /docker-entrypoint.sh: Configuration complete; ready for start up
wger-files | 2026/10/08 06:18:54 [notice] 1#1: using the "epoll" event method
wger-files | 2026/10/08 06:18:54 [notice] 1#1: nginx/1.30.5
wger-files | 2026/10/08 06:18:54 [notice] 1#1: built by gcc 15.2.0 (Alpine 15.2.0)
wger-files | 2026/10/08 06:18:54 [notice] 1#1: OS: Linux 7.0.14-20-pve
wger-files | 2026/10/08 06:18:54 [notice] 1#1: getrlimit(RLIMIT_NOFILE): 524288:524288
wger-files | 2026/10/08 06:18:54 [notice] 1#1: start worker processes
wger-files | 2026/10/08 06:18:54 [notice] 1#1: start worker process 30
wger-files | 2026/10/08 06:18:54 [notice] 1#1: start worker process 31
wger-files | 2026/10/08 06:18:54 [notice] 1#1: start worker process 32
wger-files | 2026/10/08 06:18:54 [notice] 1#1: start worker process 33
wger-files | 2026/10/08 06:18:54 [notice] 1#1: start worker process 34
wger-files | 2026/10/08 06:18:54 [notice] 1#1: start worker process 35
wger-files | 127.0.0.1 - - [08/Oct/2026:06:19:24 +0000] "GET / HTTP/1.1" 200 896 "-" "Wget" "-"
wger-files | 127.0.0.1 - - [08/Oct/2026:06:19:54 +0000] "GET / HTTP/1.1" 200 896 "-" "Wget" "-"
wger | *** Using settings from env: settings.main
wger | level=INFO ts=2026-10-08 08:18:55,562 module=apps path=/home/wger/.local/lib/python3.12/site-packages/axes/apps.py line=53 message=AXES: BEGIN version 8.3.1, blocking by username
wger | Running in production mode, running collectstatic now
wger | level=INFO ts=2026-10-08 08:18:57,102 module=apps path=/home/wger/.local/lib/python3.12/site-packages/axes/apps.py line=53 message=AXES: BEGIN version 8.3.1, blocking by username
wger |
wger | 22725 static files deleted, 11362 static files copied to '/home/wger/static', 11362 post-processed.
wger | Performing database migrations
wger | level=INFO ts=2026-10-08 08:19:23,909 module=apps path=/home/wger/.local/lib/python3.12/site-packages/axes/apps.py line=53 message=AXES: BEGIN version 8.3.1, blocking by username
wger | System check identified some issues:
wger |
wger | WARNINGS:
wger | ?: (axes.W006) AXES_LOCKOUT_PARAMETERS does not contain 'ip_address'. This configuration allows attackers to bypass rate limits by rotating User-Agents or Cookies.
wger | HINT: Add 'ip_address' to AXES_LOCKOUT_PARAMETERS.
wger | Operations to perform:
wger | Apply all migrations: account, actstream, allauth_idp_oidc, auth, authtoken, axes, config, contenttypes, core, easy_thumbnails, exercises, gallery, gym, mailer, manager, measurements, mfa, nutrition, sessions, sites, socialaccount, token_blacklist, trophies, weight
wger | Running migrations:
wger | No migrations to apply.
wger | Your models in app(s): 'exercises', 'gallery' have changes that are not yet reflected in a migration, and so won't be applied.
wger | Run 'manage.py makemigrations' to make new migrations, and then re-run 'manage.py migrate' to apply them.
wger | level=INFO ts=2026-10-08 08:19:27,332 module=apps path=/home/wger/.local/lib/python3.12/site-packages/axes/apps.py line=53 message=AXES: BEGIN version 8.3.1, blocking by username
wger | System check identified some issues:
wger |
wger | WARNINGS:
wger | ?: (axes.W006) AXES_LOCKOUT_PARAMETERS does not contain 'ip_address'. This configuration allows attackers to bypass rate limits by rotating User-Agents or Cookies.
wger | HINT: Add 'ip_address' to AXES_LOCKOUT_PARAMETERS.
wger | Set site URL to fitness.gate.invalid
wger | Using django's development server on port 8000...
wger | level=INFO ts=2026-10-08 08:19:29,776 module=apps path=/home/wger/.local/lib/python3.12/site-packages/axes/apps.py line=53 message=AXES: BEGIN version 8.3.1, blocking by username
wger | level=INFO ts=2026-10-08 08:19:31,051 module=apps path=/home/wger/.local/lib/python3.12/site-packages/axes/apps.py line=53 message=AXES: BEGIN version 8.3.1, blocking by username
wger | level=INFO ts=2026-10-08 08:19:31,063 module=autoreload path=/home/wger/.local/lib/python3.12/site-packages/django/utils/autoreload.py line=681 message=Watching for file changes with StatReloader
wger | Performing system checks...
wger |
wger | System check identified some issues:
wger |
wger | WARNINGS:
wger | ?: (axes.W006) AXES_LOCKOUT_PARAMETERS does not contain 'ip_address'. This configuration allows attackers to bypass rate limits by rotating User-Agents or Cookies.
wger | HINT: Add 'ip_address' to AXES_LOCKOUT_PARAMETERS.
wger |
wger | System check identified 1 issue (0 silenced).
wger | October 08, 2026 - 08:19:32
wger | Django version 6.0.8, using settings 'settings.main'
wger | Starting development server at http://0.0.0.0:8000/
wger | Quit the server with CONTROL-C.
wger |
wger | WARNING: This is a development server. Do not use it in a production setting. Use a production WSGI or ASGI server instead.
wger | For more information on production servers see: https://docs.djangoproject.com/en/6.0/howto/deployment/
wger | [08/Oct/2026 08:19:54] "HEAD / HTTP/1.1" 302 0
wger | [08/Oct/2026 08:19:54] "HEAD /en/ HTTP/1.1" 302 0
wger | [08/Oct/2026 08:19:54] "HEAD /en/software/features HTTP/1.1" 200 0
wger | [08/Oct/2026 08:19:56] "GET /en/user/login HTTP/1.1" 200 43827
wger | level=WARNING ts=2026-10-08 08:19:56,238 module=log path=/home/wger/.local/lib/python3.12/site-packages/django/utils/log.py line=249 message=Forbidden: /api/v2/weightentry/
wger | [08/Oct/2026 08:19:56] "GET /api/v2/weightentry/?weight=105.51 HTTP/1.1" 403 58
wger | [08/Oct/2026 08:19:56] "GET /en/user/login HTTP/1.1" 200 43827
wger | level=INFO ts=2026-10-08 08:19:56,495 module=database path=/home/wger/.local/lib/python3.12/site-packages/axes/handlers/database.py line=295 message=AXES: Successful login by {username: "********************", ip_address: "********************", user_agent: "curl/8.14.1", path_info: "/en/user/login"}.
wger | level=INFO ts=2026-10-08 08:19:56,504 module=database path=/home/wger/.local/lib/python3.12/site-packages/axes/handlers/database.py line=425 message=AXES: Cleaned up 1 expired access attempts from database that were older than 2026-10-08 06:14:56.348758+00:00
wger | [08/Oct/2026 08:19:56] "POST /en/user/login HTTP/1.1" 302 0
wger | [08/Oct/2026 08:19:56] "GET /api/v2/weightentry/?weight=199.99 HTTP/1.1" 200 52
wger | [08/Oct/2026 08:19:56] "GET /api/v2/weightentry/?weight=105.51 HTTP/1.1" 200 159
@@ -0,0 +1,6 @@
wger | Performing database migrations
wger | Apply all migrations: account, actstream, allauth_idp_oidc, auth, authtoken, axes, config, contenttypes, core, easy_thumbnails, exercises, gallery, gym, mailer, manager, measurements, mfa, nutrition, sessions, sites, socialaccount, token_blacklist, trophies, weight
wger | Running migrations:
wger | No migrations to apply.
wger | Your models in app(s): 'exercises', 'gallery' have changes that are not yet reflected in a migration, and so won't be applied.
wger | Run 'manage.py makemigrations' to make new migrations, and then re-run 'manage.py migrate' to apply them.
@@ -0,0 +1,60 @@
[06:05:43] scratch drive folders cleared before FROM (R-656): none existed
[06:05:43] MV-wger: deploying wger at FROM {'wger': 'wger/server:2.7', 'wger-files': 'nginx:1.30.5-alpine'}
[06:07:17] FROM settled=True in 93.0s :: {"wger": {"status": "running", "health": "healthy", "restarts": 0, "exit": 0}, "wger-files": {"status": "running", "health": "healthy", "restarts": 0, "exit": 0}}
[06:07:17] fixture: the BOX walk's own (Wger), through upgrade_boxport
[06:07:17] wger: the generated admin password does not log in (POST /en/user/login -> 200) — running the template's own after_install command (the app's CLI, as the product does after an install)
[06:07:19] wger: after_install :: version 8.3.1, blocking by username FELHOM_AFTER_INSTALL_OK
[06:07:21] wger: POST /api/v2/weightentry/ http=201
[06:07:21] wger: readback of the seeded weight entry http=200 found=True
[06:07:21] C1 (seed reads back BEFORE): True
[06:07:21] MV-wger: swapping to TO {'wger': 'wger/server:2.7', 'wger-files': 'nginx:1.30.5-alpine'}
[06:07:33] TO up -d rc=0
[06:08:35] TO settled=True in 62.1s :: {"wger": {"status": "running", "health": "healthy", "restarts": 0, "exit": 0}, "wger-files": {"status": "running", "health": "healthy", "restarts": 0, "exit": 0}}
[06:08:35] migration lines observed: 6
[06:08:35] wger: readback of the seeded weight entry http=200 found=True
[06:08:35] RESULT (seed reads back AFTER): True
[06:08:36] memory watch: 600s, 4 callers on 1 path(s) at 172.18.0.2:8000
[06:08:51] + 15s wger=288M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=292
[06:09:06] + 30s wger=291M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=580
[06:09:21] + 46s wger=292M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=872
[06:09:37] + 61s wger=291M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=1160
[06:09:52] + 76s wger=292M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=1452
[06:10:07] + 91s wger=292M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=1740
[06:10:22] + 106s wger=291M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=2028
[06:10:37] + 121s wger=293M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=2320
[06:10:52] + 136s wger=292M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=2608
[06:11:07] + 152s wger=292M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=2896
[06:11:23] + 167s wger=293M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=3188
[06:11:38] + 182s wger=292M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=3476
[06:11:53] + 197s wger=293M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=3768
[06:12:08] + 212s wger=293M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=4056
[06:12:23] + 227s wger=292M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=4344
[06:12:38] + 242s wger=293M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=4636
[06:12:54] + 258s wger=292M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=4924
[06:13:09] + 273s wger=293M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=5216
[06:13:24] + 288s wger=292M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=5504
[06:13:39] + 303s wger=292M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=5792
[06:13:54] + 318s wger=293M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=6084
[06:14:09] + 334s wger=292M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=6372
[06:14:25] + 349s wger=293M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=6664
[06:14:40] + 364s wger=293M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=6952
[06:14:55] + 379s wger=293M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=7240
[06:15:10] + 394s wger=293M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=7529
[06:15:25] + 409s wger=293M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=7820
[06:15:40] + 424s wger=293M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=8108
[06:15:55] + 440s wger=293M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=8400
[06:16:11] + 455s wger=292M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=8688
[06:16:26] + 470s wger=293M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=8978
[06:16:41] + 485s wger=293M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=9268
[06:16:56] + 500s wger=293M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=9556
[06:17:11] + 515s wger=293M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=9848
[06:17:26] + 530s wger=293M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=10136
[06:17:42] + 546s wger=293M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=10428
[06:17:57] + 561s wger=293M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=10716
[06:18:12] + 576s wger=293M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=11008
[06:18:27] + 591s wger=293M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=11296
[06:18:42] + 606s wger=293M/384M peak=384M kills=0 rs=0 wger-files=6M/32M peak=8M kills=0 rs=0 reqs=11584
[06:18:42] memory watch: killed=False tight=[] requests=11584 codes={'302': 11584}
[06:18:42] MV-wger: ABORT — putting the FROM images back
[06:19:56] wger: readback of the seeded weight entry http=200 found=True
[06:19:56] ABORT: app came back in 62.0s; data present=True
@@ -0,0 +1,58 @@
wger-files | /docker-entrypoint.sh: /docker-entrypoint.d/ is not empty, will attempt to perform configuration
wger-files | /docker-entrypoint.sh: Looking for shell scripts in /docker-entrypoint.d/
wger-files | /docker-entrypoint.sh: Launching /docker-entrypoint.d/10-listen-on-ipv6-by-default.sh
wger-files | 10-listen-on-ipv6-by-default.sh: info: Getting the checksum of /etc/nginx/conf.d/default.conf
wger-files | 10-listen-on-ipv6-by-default.sh: info: Enabled listen on IPv6 in /etc/nginx/conf.d/default.conf
wger-files | /docker-entrypoint.sh: Sourcing /docker-entrypoint.d/15-local-resolvers.envsh
wger-files | /docker-entrypoint.sh: Launching /docker-entrypoint.d/20-envsubst-on-templates.sh
wger-files | /docker-entrypoint.sh: Launching /docker-entrypoint.d/30-tune-worker-processes.sh
wger-files | /docker-entrypoint.sh: Configuration complete; ready for start up
wger-files | 2026/10/08 06:07:33 [notice] 1#1: using the "epoll" event method
wger-files | 2026/10/08 06:07:33 [notice] 1#1: nginx/1.30.5
wger-files | 2026/10/08 06:07:33 [notice] 1#1: built by gcc 15.2.0 (Alpine 15.2.0)
wger | *** Using settings from env: settings.main
wger-files | 2026/10/08 06:07:33 [notice] 1#1: OS: Linux 7.0.14-20-pve
wger | level=INFO ts=2026-10-08 08:07:35,364 module=apps path=/home/wger/.local/lib/python3.12/site-packages/axes/apps.py line=53 message=AXES: BEGIN version 8.3.1, blocking by username
wger | Running in production mode, running collectstatic now
wger | level=INFO ts=2026-10-08 08:07:37,454 module=apps path=/home/wger/.local/lib/python3.12/site-packages/axes/apps.py line=53 message=AXES: BEGIN version 8.3.1, blocking by username
wger |
wger | 22725 static files deleted, 11362 static files copied to '/home/wger/static', 11362 post-processed.
wger | Performing database migrations
wger | level=INFO ts=2026-10-08 08:08:06,559 module=apps path=/home/wger/.local/lib/python3.12/site-packages/axes/apps.py line=53 message=AXES: BEGIN version 8.3.1, blocking by username
wger | System check identified some issues:
wger |
wger | WARNINGS:
wger | ?: (axes.W006) AXES_LOCKOUT_PARAMETERS does not contain 'ip_address'. This configuration allows attackers to bypass rate limits by rotating User-Agents or Cookies.
wger | HINT: Add 'ip_address' to AXES_LOCKOUT_PARAMETERS.
wger | Operations to perform:
wger-files | 2026/10/08 06:07:33 [notice] 1#1: getrlimit(RLIMIT_NOFILE): 524288:524288
wger | Apply all migrations: account, actstream, allauth_idp_oidc, auth, authtoken, axes, config, contenttypes, core, easy_thumbnails, exercises, gallery, gym, mailer, manager, measurements, mfa, nutrition, sessions, sites, socialaccount, token_blacklist, trophies, weight
wger | Running migrations:
wger | No migrations to apply.
wger | Your models in app(s): 'exercises', 'gallery' have changes that are not yet reflected in a migration, and so won't be applied.
wger | Run 'manage.py makemigrations' to make new migrations, and then re-run 'manage.py migrate' to apply them.
wger | level=INFO ts=2026-10-08 08:08:10,187 module=apps path=/home/wger/.local/lib/python3.12/site-packages/axes/apps.py line=53 message=AXES: BEGIN version 8.3.1, blocking by username
wger | System check identified some issues:
wger |
wger | WARNINGS:
wger | ?: (axes.W006) AXES_LOCKOUT_PARAMETERS does not contain 'ip_address'. This configuration allows attackers to bypass rate limits by rotating User-Agents or Cookies.
wger | HINT: Add 'ip_address' to AXES_LOCKOUT_PARAMETERS.
wger | Set site URL to fitness.gate.invalid
wger | Using gunicorn on port 8000...
wger | level=INFO ts=2026-10-08 08:08:12,859 module=apps path=/home/wger/.local/lib/python3.12/site-packages/axes/apps.py line=53 message=AXES: BEGIN version 8.3.1, blocking by username
wger | [2026-10-08 08:08:13 +0200] [17] [INFO] Starting gunicorn 26.1.0
wger-files | 2026/10/08 06:07:33 [notice] 1#1: start worker processes
wger-files | 2026/10/08 06:07:33 [notice] 1#1: start worker process 30
wger-files | 2026/10/08 06:07:33 [notice] 1#1: start worker process 31
wger-files | 2026/10/08 06:07:33 [notice] 1#1: start worker process 32
wger-files | 2026/10/08 06:07:33 [notice] 1#1: start worker process 33
wger-files | 2026/10/08 06:07:33 [notice] 1#1: start worker process 34
wger-files | 2026/10/08 06:07:33 [notice] 1#1: start worker process 35
wger-files | 127.0.0.1 - - [08/Oct/2026:06:08:03 +0000] "GET / HTTP/1.1" 200 896 "-" "Wget" "-"
wger-files | 127.0.0.1 - - [08/Oct/2026:06:08:33 +0000] "GET / HTTP/1.1" 200 896 "-" "Wget" "-"
wger | [2026-10-08 08:08:13 +0200] [17] [INFO] Listening at: http://0.0.0.0:8000 (17)
wger | [2026-10-08 08:08:13 +0200] [17] [INFO] Using worker: sync
wger | [2026-10-08 08:08:13 +0200] [18] [INFO] Booting worker with pid: 18
wger | [2026-10-08 08:08:13 +0200] [19] [INFO] Booting worker with pid: 19
wger | [2026-10-08 08:08:13 +0200] [17] [INFO] Control socket listening at /home/wger/.gunicorn/gunicorn.ctl
wger | level=WARNING ts=2026-10-08 08:08:33,548 module=wsgi path=/home/wger/.local/lib/python3.12/site-packages/gunicorn/http/wsgi.py line=450 message=WSGI app sent body bytes on a no-body response (method=HEAD status=200); dropping per RFC 9110.
@@ -0,0 +1,14 @@
{
"wger": {
"status": "running",
"health": "healthy",
"restarts": 0,
"exit": 0
},
"wger-files": {
"status": "running",
"health": "healthy",
"restarts": 0,
"exit": 0
}
}
@@ -0,0 +1,70 @@
{
"harness_version": 5,
"edge": "MV-wger",
"app": "wger",
"note": "definition step to wger@gunicorn",
"from": {
"wger": "wger/server:2.7",
"wger-files": "nginx:1.30.5-alpine"
},
"to": {
"wger": "wger/server:2.7",
"wger-files": "nginx:1.30.5-alpine"
},
"verdict": "proven",
"seed_read_before": true,
"seed_read_after": true,
"healthy_after": true,
"migration_observed": "\u001b[2Kwger | Performing database migrations",
"abort": "starts-and-serves",
"abort_detail": null,
"engine_state_after": null,
"memory": {
"soak_s": 606.5,
"requested_s": 600,
"requests": 11584,
"codes": {
"302": 11584
},
"first_kill": null,
"containers": {
"wger": {
"limit": 402653184,
"peak": 402653184,
"peak_pct": 1.0,
"anon_peak_sampled": 178151424,
"anon_peak_pct": 0.442,
"swap_peak": 0,
"oom_kills": 0,
"restarts": 0,
"oomkilled_flag": false,
"measured": true
},
"wger-files": {
"limit": 33554432,
"peak": 9281536,
"peak_pct": 0.277,
"anon_peak_sampled": 5308416,
"anon_peak_pct": 0.158,
"swap_peak": 0,
"oom_kills": 0,
"restarts": 0,
"oomkilled_flag": false,
"measured": true
}
},
"unmeasured": [],
"venue_swap_bytes": 0,
"load": "reached"
},
"marks": [],
"bench_overrides": null,
"duration_s": 62.1,
"measured_at": "2026-10-08T06:19:56Z",
"evidence": "evidence/MV-wger",
"scratch_cleared": [],
"files_changed": [],
"files_changed_detail": [],
"files_ignored": [],
"total_s": 853.6
}