# Verification How Docksentry v2.18.0-beta.43 is checked, and what each check actually asserts. > Generated by `scripts/verify.py` from a real run of the suite — do not edit by hand. > The suite runs locally, not in CI: some procedures drive a real container runtime, and a hosted runner without one would report skips as if they were coverage. `scripts/verify.py --check` re-runs it and fails if this file no longer describes what the suite does. **All 214 procedures pass**, 5250 named checks. ## By area | Area | Procedures | Checks | | |---|---:|---:|---| | Registry & update detection | 32 | 697 | `██████████████████████` | | Self-update | 16 | 288 | `██████████████████████` | | Containers & recreate | 14 | 253 | `██████████████████████` | | Monitoring & events | 6 | 192 | `██████████████████████` | | Multi-host | 10 | 289 | `██████████████████████` | | Notifications | 19 | 692 | `██████████████████████` | | Web interface | 8 | 246 | `██████████████████████` | | Other | 109 | 2593 | `██████████████████████` | | **Total** | **214** | **5250** | | ## Not in the automated run These need something a plain checkout cannot assume. They are listed so their absence from the total above is visible. - **`scripts/check_layout_widths.py`** — Measures the layout at 1024–3840px in a real browser. Needs a running instance and Docker; skips with a message rather than failing. - **`scripts/sim_crashloop.py`** — Drives a container into a restart loop to exercise the monitor's crash path end to end. Needs Docker. - **`scripts/lint_contracts.py`** — Checks the internal call contracts between modules. ## Procedures ### Registry & update detection
✅ auto update all — 4 checks AUTO_UPDATE_ALL global auto-update (#45, @NotRetarded). - AUTO_UPDATE_ALL on: every container auto-updated - AUTO_UPDATE_ALL on: nothing left as manual - off: only the opt-in container auto-updated - off: the rest reported as manual
✅ check debug dump — 5 checks The debug trace goes to Telegram only when a check actually failed (#63). - the Telegram debug dump is gated on a real failure - …and is not the old unconditional dump - …it still requires debug on and a bot to send through - the dump sits inside check_all, where failed_checks lives - …and failed_checks is populated on a failed container check
✅ check diag — 51 checks Registry diagnostics in the update check (#53, @LeeNX). - gate: DEBUG off leaks no diagnostic line - gate: DEBUG off still prints the verdict - gate: debug_log stays empty - gate: _vdebug prints nothing without DEBUG - gate: _vdebug prints with DEBUG - gate: _vdebug feeds debug_log - digest: local printed in full - digest: local keeps repo prefix - digest: no 30-char truncation left - digest: remote printed in full - digest: _get_local_digests still returns bare - env: line present with DEBUG - env: printed once - env: mirror named - env: daemon proxy named - env: docker info cached process-wide - env: no mirrors → none - env: no daemon proxy → none - env: docker info failure → unknown - env: our proxy unset → none - env: our proxy reported - env: proxy password masked - env: mask leaves clean URLs alone - http: digest still returned - http: request URL logged - http: status logged - http: content-type logged - http: redirect target logged - http: auth category logged - auth: token returned - auth: token never printed - auth: category printed - auth: realm printed - auth: cached for the run (one negotiation) - auth: rejected token is forgotten - auth: config credentials named as such - auth: basic header not printed - auth: long scope truncated - resolve: full sweep without DEBUG resolves nothing - resolve: single-container check resolves - resolve: single check prints the version without DEBUG - resolve: single check without DEBUG stays otherwise quiet - resolve: version named next to the verdict - resolve: build date named - resolve: local size named - resolve: missing label reported - resolve: pending update still resolves (unchanged) - resolve: update verdict unchanged - inspect: prefix kept, junk dropped - inspect: cached, second call makes no call - inspect: missing docker → empty, no crash
✅ check glob only — 7 checks A globbed /check or /update scopes the check via `only=` (#53, @LeeNX). - /check glob: check_all got only=matched - /check glob: only the matched were looked up - /check glob: did NOT check db/cache - /check glob: notified with web-1's update - /update glob: check_all got only=matched - /update glob: only the matched were looked up - /update glob: run_updates got only pending matches
✅ check per host — 9 checks `/check` answers per host, as each one finishes (#2, @famewolf). - the host with updates answers - the clean host says so itself, at once - the dead host reports its failure - …and no aggregate line repeats what each host already said - answers arrive in completion order - all-clean multi-host: one line per host - …and still no aggregate - single host keeps the original aggregate - …and no per-host line it never had
✅ check quality — 5 checks The tests are code too, and they rot in four specific ways. - 'and-or-precedence' stays at 0 - 'bare-true' stays at 0 - 'count-threshold' stays at 16 - 'dead-fstring-key' stays at 0 - the scanner is looking at real files
✅ check resilience — 11 checks What a failed check must NOT do (wud#570/#711, wud#116/#419/#945). - a failing ps raises rather than returning [] - the exception type is specific - another host's entries survive a scan of this one - an entry we could not re-verify is carried over - …and counted, so the log can say how many - an entry the scan verified as gone is dropped - an entry found this run is not double-added - …and it is the fresh one that survives - with nothing failing, nothing is carried - a corrupt entry does not take the merge down - check_all writes what the merge returns
✅ check scoped — 19 checks Scoped update check — `UpdateChecker.check_all(only={...})`. - scoped: exactly one remote lookup - scoped: returns a list - scoped: reports a's update - merge: b's entry untouched - merge: a still pending - merge: b not re-checked - up to date: empty return - up to date: a removed from pending - up to date: b still there - unknown name: empty return - unknown name: no remote lookups - unknown name: pending unchanged - full run: checks everything - full run: returns a's update - full run: file overwritten wholesale - missing file: created with the update - corrupt file: treated as empty - debug log: reset per run (no trace of b) - debug log: mentions the scoped container
✅ checkimages — 15 checks /checkimages command + docksentry-selfupdate hint in /check (#2, @famewolf). - msg: nothing → 'Nothing to clean up' - msg: nothing → NO reclaimable line - msg: 20 GiB shows as decimal GB - msg: auto-cleanup OFF hint present - msg: auto-cleanup ON hint present - msg: no OFF hint when ON - msg: small totals are whole megabytes - /checkimages walks every managed host - …tags each answer with its host - …and measures on each host's own checker - walk: every host produces a line - walk: the reachable hosts report their size - walk: the unreachable host is reported, not skipped - hint: fires when checker says yes - hint: silent when checker says no
✅ checkimages proxy failure — 13 checks A host whose `system df` is blocked reports "could not check", not "clean". - breakdown raise mentions the proxy hint - breakdown RAISES on a failed system df - reclaimable_bytes swallows the failure to 0 - success-with-zero does NOT raise - blocked host: exactly one reply - blocked host: reply is host_check_failed - blocked host: reply is not ok - blocked host: NOT reported as 'nothing to reclaim' - reclaimable host: chan_reclaim_some - reclaimable host: ok - reclaimable host: figure carried through - genuine zero: chan_reclaim_none - genuine zero: still ok (not a failure)
✅ cron dow — 36 checks Cron day-of-week, and SMTP certificate verification. - cron 0 9 * * 1 - cron 0 9 * * 5 - cron 0 3 * * 6 - cron 0 3 * * 0 - cron 0 3 * * 7 - cron 0 9 * * 1-5 - cron 0 9 * * 6,0 - cron 0 9 * * 1-7 - cron 0 9 * * * - the 'Weekly (Mondays)' preset fires on Monday - the context we pass does verify - SMTP_SSL is given a verifying context - starttls is given a verifying context - SMTP_TLS_VERIFY=false is the only way to stop verifying - SMTP_TLS='ssl' still encrypts - SMTP_TLS='SSL' still encrypts - SMTP_TLS='starttls' still encrypts - SMTP_TLS='StartTLS' still encrypts - SMTP_TLS='STARTTLS' still encrypts - SMTP_TLS=' ssl ' still encrypts - SMTP_TLS='' means STARTTLS, not plaintext - SMTP_TLS=None means STARTTLS, not plaintext - SMTP_TLS=' ' means STARTTLS, not plaintext - an unknown SMTP_TLS value sends nothing at all - no credentials and no TLS: nothing to warn about - …but no TLS WITH a password says the password is in clear - an unverified certificate with a password names the password - …and without one does not invent a password - …and a verified certificate says nothing at all - the interface shows starttls when that is what is set - the interface shows ssl when that is what is set - the interface shows none when that is what is set - …and does not show STARTTLS for a value that is not valid - …but says it is not valid, and that no mail is going out - …while an unset value is still STARTTLS, as it is elsewhere - the escape hatch is named in the error the operator reads
✅ dry run — 5 checks Logic test for the /check --dry-run preview builder (roadmap dry-run, #2). - title - standalone path when compose file missing - dependents listed for head - major jump flagged - nothing-changed framing
✅ env override — 69 checks Test the "settings.json overrules an env var" detection (#53, @LeeNX). - set + differs -> reported - set + differs: saved value still wins - entry carries var name - entry carries both values - entry knows the Settings tab - entry not marked secret - set + same -> not reported - unset + saved off-default -> not reported - unset + saved off-default: saved applies - BLIND SPOT: env value == default is not reported - BLIND SPOT: saved value still wins regardless - MONITOR -> monitor_enabled - MONITOR_INTERVAL -> monitor_interval_seconds - MONITOR_ENABLED is not a real var -> ignored - every mapped var is really read by from_env() - every persistent key is mapped or knowingly env-less - no map entry outside PERSISTENT_KEYS - declared defaults match from_env() fallbacks - every mapped key has a declared default - Dockerfile declares persistent vars at all - Dockerfile does not declare DEBUG - Dockerfile ENV values all equal our defaults - Dockerfile env + saved settings -> no lines at all - Dockerfile env + one real setting -> exactly one line - Dockerfile env + LeeNX's DEBUG=true -> exactly that one line - secret web_password: reported - secret web_password: marked secret - secret web_password: no values in entry - secret web_password: env value absent from message - secret web_password: saved value absent from message - secret web_password: variable name still named - secret web_password: saved value still wins - secret web_password: and is no longer plaintext on disk - secret web_password: env value still does not authenticate - secret discord_webhook: reported - secret discord_webhook: marked secret - secret discord_webhook: no values in entry - secret discord_webhook: env value absent from message - secret discord_webhook: saved value absent from message - secret discord_webhook: variable name still named - secret discord_webhook: saved value still wins - secret webhook_url: reported - secret webhook_url: marked secret - secret webhook_url: no values in entry - secret webhook_url: env value absent from message - secret webhook_url: saved value absent from message - secret webhook_url: variable name still named - secret webhook_url: saved value still wins - telegram_topic_id treated as secret - unknown key defaults to secret - allow-list stays inside PERSISTENT_KEYS - no secret key on the allow-list - line: one line per override - line: names the variable and its env value - line: names the saved value - line: says where to change it - line: names settings.json - line: docker_stop_timeout points at Settings › Updates - tab map: web_password -> General - tab map: healthcheck_max_starting -> Updates - tab map: docker_stop_timeout -> Updates - tab map: monitor_enabled -> Notifications - tab map: monitor_interval_seconds -> Notifications - multiple overrides reported together - list value that matches is not reported - string values rendered as-is - saved value == default is still an override - no settings.json -> nothing reported - no settings.json -> env applies
✅ event labels — 116 checks The crash alert says WHEN it measured, and says so even when idle (#66). - an idle box reports the floor it stayed under - a busy box has nothing to explain - a stats call that failed stays silent - the list itself is unchanged on an idle box - the picture carries the quiet reading - and no list to go with it - the alert still says something about CPU - it names the floor - it is not an error message - a real hog is still named - evidence is labelled as the death - the top list is the evidence, not the fresh stats - the victim line is read in this pass - the top list says when it died - there is a phrase for the later moment - the victim line carries it - the death list does not - a fallback picture is labelled as the check after - the fallback list uses the other label - and now the two lines DO share their moment - an event from before this reads as the death - ar has all seven labels - ar words the two moments differently - ar puts the victim in the later moment - ar names the floor in the quiet line - ar keeps the list placeholder - ar is actually translated - de has all seven labels - de words the two moments differently - de puts the victim in the later moment - de names the floor in the quiet line - de keeps the list placeholder - de is actually translated - en has all seven labels - en words the two moments differently - en puts the victim in the later moment - en names the floor in the quiet line - en keeps the list placeholder - es has all seven labels - es words the two moments differently - es puts the victim in the later moment - es names the floor in the quiet line - es keeps the list placeholder - es is actually translated - fr has all seven labels - fr words the two moments differently - fr puts the victim in the later moment - fr names the floor in the quiet line - fr keeps the list placeholder - fr is actually translated - hi has all seven labels - hi words the two moments differently - hi puts the victim in the later moment - hi names the floor in the quiet line - hi keeps the list placeholder - hi is actually translated - it has all seven labels - it words the two moments differently - it puts the victim in the later moment - it names the floor in the quiet line - it keeps the list placeholder - it is actually translated - ja has all seven labels - ja words the two moments differently - ja puts the victim in the later moment - ja names the floor in the quiet line - ja keeps the list placeholder - ja is actually translated - ko has all seven labels - ko words the two moments differently - ko puts the victim in the later moment - ko names the floor in the quiet line - ko keeps the list placeholder - ko is actually translated - nl has all seven labels - nl words the two moments differently - nl puts the victim in the later moment - nl names the floor in the quiet line - nl keeps the list placeholder - nl is actually translated - pl has all seven labels - pl words the two moments differently - pl puts the victim in the later moment - pl names the floor in the quiet line - pl keeps the list placeholder - pl is actually translated - pt has all seven labels - pt words the two moments differently - pt puts the victim in the later moment - pt names the floor in the quiet line - pt keeps the list placeholder - pt is actually translated - ru has all seven labels - ru words the two moments differently - ru puts the victim in the later moment - ru names the floor in the quiet line - ru keeps the list placeholder - ru is actually translated - tr has all seven labels - tr words the two moments differently - tr puts the victim in the later moment - tr names the floor in the quiet line - tr keeps the list placeholder - tr is actually translated - uk has all seven labels - uk words the two moments differently - uk puts the victim in the later moment - uk names the floor in the quiet line - uk keeps the list placeholder - uk is actually translated - zh has all seven labels - zh words the two moments differently - zh puts the victim in the later moment - zh names the floor in the quiet line - zh keeps the list placeholder - zh is actually translated
✅ glob — 7 checks Test glob matching for commands (#40, @LeeNX). - _is_glob true for *,?,[ - _is_glob false for plain name - glob *-even matches both even - glob ctf-* matches all three - _old leftover excluded - ? single-char glob - case-insensitive match
✅ healthcheck exit — 11 checks The exit code Docker acts on, which nothing had ever run. - the script Docker runs every minute parses and runs - a Web UI that is listening is healthy - …even with a bot token that would never answer - a Web UI that was meant to listen and does not is unhealthy - …and a working bot token does not paper over it - a webhook-only install is healthy - …and so is a Discord-webhook-only one - an install with nothing configured is unhealthy - …and so is half a Telegram setup - a Telegram-only install with an unanswerable token is unhealthy - an unreadable WEB_PORT is unhealthy rather than silently healthy
✅ image age — 13 checks Hold an update back until the image has been public for N days. - off by default - a fresh image is held - an aged image passes - the boundary is inclusive - the decision carries age and requirement - a label raises the bar - a label lowers it too - a label of 0 disables it - a nonsense label falls back to the global - no date, no hold - a malformed date does not hold - a missing key does not hold - a failing label read falls back to the global
✅ image inherit — 36 checks Image-inherited config must not be pinned onto the new image (#35). - env: inherited APP_VERSION dropped - env: user TZ kept - label: inherited OCI version dropped - label: inherited vendor dropped - label: compose label kept - user: inherited not pinned - workdir: inherited not pinned - stopsignal: inherited not pinned - healthcheck: inherited not pinned - healthcheck: no interval either - sticky: stale OCI version dropped despite mismatch - sticky: compose label still kept - override: user kept - override: workdir kept - override: stopsignal kept - override: healthcheck kept - override: --no-healthcheck kept - ns: podman default 'private' dropped - ns: real 'host' mode kept - ulimit: podman RLIMIT_NOFILE -> nofile - ulimit: docker short name untouched - ulimit: no raw RLIMIT_ passes through - swappiness: cgroup v2 -> flag suppressed - swappiness: cgroup v1 -> flag emitted - swappiness: default/None -> flag emitted (no regression) - swappiness: -1 never emitted (v1) - swappiness: -1 never emitted (v2) - None: replicates env - None: replicates labels - None: replicates user - None: replicates healthcheck - real: image LABEL lands in container Config - real: image USER lands in container Config - real: image WORKDIR lands in container Config - real: image HEALTHCHECK lands in container Config - real: filter drops every inherited field
✅ image ref — 17 checks Image-reference parsing and platform selection (cross-tool audit). - hub digest pin -> skipped - ghcr digest pin -> skipped - tag+digest pin -> skipped - bare image ID -> skipped - bare official - user/repo:tag - ghcr - port registry - localhost registry - platform: each host gets its own - platform: amd64 host reads amd64 - platform: arm64 host reads arm64 - platform: still cached per host - platform: silent daemon falls back - platform is (os, arch) pair - platform cached (same object) - platform matches daemon
✅ labels — 49 checks Test container-label config overrides (#42, #52, @LeeNX) and the `-?` help alias (#15, @LeeNX). - parse: basic - parse: empty/None -> {} - bool: true variants - bool: false value - bool: absent -> None - bool: empty/None -> None - exclude: enable=false - exclude: exclude=true - exclude: enable=true -> kept - exclude: no labels -> kept - protect: label true overrides toggle off - protect: label false overrides toggle on - protect: no label -> toggle (on) - protect: no label -> toggle (off) - protect: inspect failure -> toggle (on, never unprotect) - pin: label true -> skipped - pin: label false -> kept - pin: absent -> stored pin rules - auto: label false beats AUTO_UPDATE_ALL - auto: label true beats missing toggle - auto: no label + all_auto - auto: no label + toggle - auto: no label + nothing -> manual - trust: label true - trust: label false - trust: absent -> stored (off) - ask-major: label true forces gate - ask-major: label false skips gate - ask-major: absent -> stored list - link: label beats stored + OCI + heuristic - link: no label -> stored value - link: empty label -> stored value (not cleared) - link: whitespace-only label -> stored value - link: unsafe label (javascript:) -> stored value - link: unsafe label (no host) -> stored value - link: inspect failure -> stored value - link: stored beats image.source - link: no label + no stored -> image.source - link: image.url when no source label - link: registry heuristic is last resort - link: nothing anywhere -> ('', 'none') - link: comma in query survives - link: case preserved - link: resolve_container_link returns the url only - alias: /protect -? -> /help protect - alias: /status -? -> /help status - alias: plain command unchanged - alias: -? not sole arg unchanged - alias: command with arg unchanged
✅ manual check hosts — 17 checks A manual check covers every managed host, not just the local one. - a single host yields one checker - …with a blank host name, so nothing gains a label - two hosts yield two checkers - …each carrying its own name - …local first - no registry falls back to the one checker - the check button asks the local host - the check button asks the second host too - and reports what both of them found - a single-host install still checks once - …and reports its findings - an unreachable host does not stop the others - …and the reachable host's findings still arrive - the dry run resolves host targets - …loops over them - …and asks each host's own checker - …tolerating one unreachable host
✅ manual update gates — 5 checks Test that the MANUAL update path (run_updates) honours the same group gates as the auto path (#2, @famewolf — the two had drifted). - head was attempted first (group order) - dep1 skipped after head failed (group_aborted) - dep2 skipped after head failed (group_aborted) - only the failed head was actually updated - a head whose update RAISED also aborts the group
✅ monitor only — 18 checks Monitor-only containers, and wildcard name matching (#55, @LeeNX). - prefix pattern matches - prefix pattern does not over-match - several patterns - a plain name still works - a plain name is not a prefix - empty list matches nothing - blank entries are ignored - matching is case-sensitive - ? and [] work - pattern makes a container monitor-only - an unmatched container is not - label opts a container in - label opts a matched container OUT - an unrelated label changes nothing - update is refused - the refusal says why - the refusal is recorded in history - a label-read failure still refuses
✅ newer version — 20 checks A pinned tag never reports an update — and the tag matcher that says so. - an arch-prefixed tag is not a candidate - the real next version wins - a v-prefix matches only v-prefixes - …and a bare tag never matches a v-prefixed one - an implausible leap is skipped - a genuine major jump survives - a newer version is reported - nothing to report stays silent - a moving tag is not advised on - a two-component tag is advised on - shapes are not mixed: two matches two - …and three matches three - suffix and shape hold together - a year-like tag is not read as a version - the advisory stays within the pinned major - …and says nothing at the top of that line - major confirmation still sees across majors - a failed lookup is swallowed - the advisory is not appended to updates - it goes to its own store
✅ private ca — 9 checks A registry behind the operator's own CA (wud#604, wud#111, wud#52). - a verify failure names SSL_CERT_FILE - …and warns off INSECURE_REGISTRIES - an unrelated TLS error keeps its own message - HTTP 429 unchanged - HTTP 401 unchanged - HTTP 404 unchanged - a private-CA host is not implicitly insecure - only a named host goes plain - a hashed CA directory exists to fall back on
✅ registry auth — 27 checks Registry credential matching, and compose files behind one label. - hub alias: docker.io - hub alias: index.docker.io - hub alias: registry-1.docker.io - hub alias: https://index.docker.io/v1/ - hub alias: registry.hub.docker.com - scheme is stripped - path is stripped - case is folded - eu.gcr.io is not gcr.io - own registry still authenticates - gcr.io does NOT get eu.gcr.io's secret - example.com does NOT get the private registry's secret - an unrelated registry gets nothing - docker hub resolves through its aliases - docker.io resolves too - credsStore-style empty entry yields nothing - single file is unchanged - two files are split - order is preserved - whitespace around commas survives - empty label yields nothing - a comma inside a real path is not split - a partly-missing split is not trusted - read-only bind keeps its propagation - writable bind keeps its propagation - rprivate is not emitted - volumes are unaffected
✅ registry reach — 30 checks Registries Docksentry could not reach at all, and Discord's field cap. - https by default - http only when named - an unnamed host stays https - patterns work - there is no automatic http fallback - a Basic challenge is answered - the auth kind is recorded - no credentials, no header - the return value is a full header - 3 updates -> 1 message(s) - 3 updates: no embed over 25 - 3 updates: none lost - 25 updates -> 1 message(s) - 25 updates: no embed over 25 - 25 updates: none lost - 26 updates -> 2 message(s) - 26 updates: no embed over 25 - 26 updates: none lost - 60 updates -> 3 message(s) - 60 updates: no embed over 25 - 60 updates: none lost - a single message has no part number - a pair is parsed - a malformed entry is dropped - a half-empty entry is dropped - hub alias reaches the canonical host - an exact host matches - an unmapped host is untouched - no map, no change - the pull side is explicitly out of scope
✅ remote version — 10 checks Old→new version in the "Updates Available" notification (#44, @LeeNX). - badge: old → new arrow - badge: only old (current) - badge: only new - badge: equal collapses to one - badge: none → empty - single-arch: version + created - multi-arch: picks amd64 + extracts version - registry failure → {} - empty remote digest → no update - differing remote digest → one update
✅ running image check — 13 checks Running-image update check (#53, @LeeNX). - normal up-to-date: no update (unchanged) - normal update: one update (unchanged) - divergence: UPDATE AVAILABLE (not 'up to date') - digestless running img, tag current: no false update - digestless running img, tag stale: fallback still reports - unreadable running id: falls back, no crash, no false update - no digests anywhere: skipped, no false update - digestless, run_id==tag_id: no false update (fallback) - digestless running + digestless tag, ids differ: no false update - #53 gitea-runner: UPDATE AVAILABLE - #53 gitea-runner: old_version is the running 2.0.1 - #53 gitea-runner: new_version is the remote 2.3.0 - #53 gitea-runner: divergence spelled out in the log
✅ tag pagination — 12 checks Registry tag pagination (diun#43, #518, #653). - a path is joined onto the host - no header, no next page - no rel=next, no next page - a malformed header stops rather than guesses - a relative path without a slash is refused - same-host absolute url is followed - a cross-host absolute url is refused - a missing header object is safe - the crawl is bounded - a repeated url breaks the loop - results are cached per run - a failed page keeps what was fetched
✅ trust running — 3 checks Regression test for the per-container "trust running state over healthcheck" flag (#9). - running but unhealthy, flag OFF: reported unhealthy - …and with the flag ON: accepted as healthy - a crash loop is still a crash loop with the flag ON
✅ update policy — 35 checks Per-container update policy (v1.53.0, roadmap #2 — @NotRetarded/@famewolf). - [wiring] the bot asks the engine for the policy decision - [wiring] …and hands back what it said, unchanged - [wiring] …and the same for the minimum-age decision - [bump] major 1.2.3 -> 2.0.0 - [bump] minor 1.2.3 -> 1.3.0 - [bump] patch 1.2.3 -> 1.2.4 - [bump] equal -> None - [bump] unparseable -> None - [bump] empty old -> None - [bump] empty new -> None - [bump] both empty -> None - [bump] prefix v1.2.3 -> v1.3.0 = minor - [bump] prefix mixed v1.2.3 -> 2.0.0 = major - [matrix] policy=all level=major -> allow - [matrix] policy=all level=minor -> allow - [matrix] policy=all level=patch -> allow - [matrix] policy=all level=None -> allow - [matrix] policy=minor level=major -> block - [matrix] policy=minor level=minor -> allow - [matrix] policy=minor level=patch -> allow - [matrix] policy=minor level=None -> allow - [matrix] policy=patch level=major -> block - [matrix] policy=patch level=minor -> block - [matrix] policy=patch level=patch -> allow - [matrix] policy=patch level=None -> allow - [precedence] label minor overrides env all - [precedence] label all overrides env minor - [precedence] no label falls back to env patch - [precedence] invalid label -> all (fail-open) - [precedence] invalid env -> all (fail-open) - [auto] minor bump applied under policy=minor - [auto] major bump NOT applied under policy=minor - [auto] major bump reported as policy-held - [auto] policy-held update surfaced in updates-available - [manual] major bump applied on manual path (policy bypassed)
### Self-update
✅ release link — 25 checks Auto-detected GitHub/GitLab repo links point at the releases page (#52, @LeeNX). - github bare repo → releases/latest - github trailing slash - github .git suffix - github http (not https) still rewritten - github www. host - gitlab bare repo → /-/releases - deeper/1-seg left alone: https://github.com/amayer1983/docksentry/release - deeper/1-seg left alone: https://github.com/amayer1983/docksentry/tree/ma - deeper/1-seg left alone: https://github.com/amayer1983/docksentry/blob/ma - deeper/1-seg left alone: https://github.com/amayer1983 - deeper/1-seg left alone: https://gitlab.com/group/subgroup/app - other/invalid left alone: 'https://vaultwarden.example.com' - other/invalid left alone: 'https://hub.docker.com/_/redis' - other/invalid left alone: 'https://example.com/owner/repo' - other/invalid left alone: 'ftp://github.com/owner/repo' - other/invalid left alone: 'not a url' - other/invalid left alone: '' - idempotent on an already-rewritten url - gitea.com bare repo -> releases/latest - codeberg (forgejo) rewritten - self-hosted git.example.com rewritten - a host merely containing 'gitea' rewritten - not a forge, left alone: digit.example.com - not a forge, left alone: legit.io - gitea deep link left alone
✅ self detection — 3 checks Robust self-container detection (#41, @NotRetarded). - QNAP: resolves via Config.Hostname fallback - normal: resolves directly (no scan) - no match -> empty string
✅ selfupdate chain — 27 checks The chain that replaces Docksentry with a new Docksentry. - the old container is cleared before anything else - …then we stop, with the configured grace - …then rename ourselves aside, then start the new image - …and only then remove the backup - the stop grace is the configured one, not Docker's ten seconds - a failed rename restarts the container we still have - a failed run rolls back instead of leaving nothing - …and says so in words the boot can find again - the rename failure has a guard of its own - …and so does the run failure, a different one - …and they are not the same clause - a hostile container name cannot become a second command - …and neither can a hostile image reference - …while an ordinary name still reaches the script - the script writes the phrase on a rollback - …and the boot check tests for it as live code - …and announces when it is found - 'previous' resolves to the release below the running one - …counted from what is running, not from the top of the file - …and the oldest release says there is nothing before it - …and an unreachable changelog says that, not 'none' - an explicit version is pinned as given - …and something that is not a version is refused - a failed pull stops the update before the swap - …and says the pull was what failed - an identical image is not swapped onto itself - …while a genuinely new image is
✅ selfupdate decoupled — 23 checks Updating Docksentry itself lives in the core, not on a front end (#63). - every moved function is in the module - …and none of them is still on the Telegram bot - the module asks its caller for exactly seven names - …and never imports a front end - build_script works as a plain function - …and honours the stop timeout it is given - host_docker_socket falls back to the default socket - …and otherwise mounts the socket we ourselves are given - host_mount_source finds the host side of a mount - …and answers None for one that is not mounted - resolve_target pins an explicit version - …and leaves the running tag alone when none is asked for - …and refuses something that is not a version - should_retag_moving: a moving tag is re-pinned - …and an explicit version is not - the 'already up to date' report reaches Telegram - …and every other channel too - …marked urgent, so quiet hours cannot swallow it - …and the lock is free afterwards - a self-update behind a running batch queues - …and says so on every channel - the context translates from config.language - …and follows it when it changes at runtime
✅ selfupdate diag — 7 checks Self-update failure diagnostics (#43, @LeeNX). - mount source: resolves /data host path - mount source: unknown dest → None - mount source: no mounts → None - failure: reports on 'rolling back' marker - failure: log consumed (unlinked) - success: no report (silent) - success: log still consumed
✅ selfupdate marker — 10 checks Test for the self-update restart marker (#2, @famewolf). - fresh marker -> self-update (suppress external-signal line) - marker consumed (deleted) - stale marker (>1h) -> ignored - absent marker -> not a self-update - _write_selfupdate_marker writes to self.config path - written marker round-trips through consume -> self-update - version line shows v_old -> v_new when label readable - version line omitted when label unreadable - helper mounts the container's real host socket (podman) - helper socket falls back to /var/run/docker.sock
✅ selfupdate private — 33 checks A self-update asked for in private stays private across the restart. - a self-update nobody asked for privately writes the marker it always wrote - a private one records who to answer once we are back - the reply route is only believed from a fresh marker - …and it starts out as 'nobody', so a boot with no marker is an ordinary boot - main.py routes its boot notices through one function - an ordinary boot still tells every channel, Discord included - after a private self-update the Discord channels hear nothing - …the operator's own channels are untouched - …and the person who asked is told, privately - a direct message that bounces is not lost - …and it says why it ended up in the channel - …without sending it twice to the channels that already had it - the notifier can withhold one message from named channels - the pre-restart 'restarting' notice skips them as well - a boot notice is public unless it is marked as the answer - …and only that one is skipped for the channel - …and only that one is sent as a direct message - the self-update result is the answer - …and so is a self-update that failed - the storage warning is not - nor the ordinary start-up line - a queued self-update is still private when it finally runs - …and an old one-item queue entry still runs, publicly - /selfupdate on Discord calls nothing TelegramBot lacks - …and borrows nothing from the Telegram bot at all today - …and hands the work to the module that does it - a private request keeps its progress out of the channel - …and a public one answers on the interaction - …ephemeral or not as the operator chose - …and a switched-off channel forces it either way - …with the channel only as the last resort - …and a DM that will not open falls back rather than vanishing - …and only records a reply route when replies are private
✅ selfupdate pull hint — 24 checks A denied pull says which of the two things it probably is. - the hint is only added when the pull was denied - …matched without caring about case - …and it rides along with the raw error, not instead of it - a pull that failed for another reason is unchanged - the image is named, not described - both causes are offered - …and the credentials are named so they can be set - neither cause is asserted over the other - ar has the hint, with the image in it - de has the hint, with the image in it - en has the hint, with the image in it - es has the hint, with the image in it - fr has the hint, with the image in it - hi has the hint, with the image in it - it has the hint, with the image in it - ja has the hint, with the image in it - ko has the hint, with the image in it - nl has the hint, with the image in it - pl has the hint, with the image in it - pt has the hint, with the image in it - ru has the hint, with the image in it - tr has the hint, with the image in it - uk has the hint, with the image in it - zh has the hint, with the image in it
✅ selfupdate queue — 26 checks Self-update vs. running container updates (#2, @famewolf). - queued while batch runs - queue announced - body not run while queued - lock still held by batch - queued run announced - queued target passed through - queue cleared - second run is a no-op - body runs when lock free - lock released after no-update path - lock kept once swap in flight - plain selfupdate queues as (None,) - plain selfupdate dequeues with None - auto skips while batch runs - auto inner not called - auto runs when free - auto inner called with defer - auto lock released when no swap - auto applied - auto lock kept after swap - crash: queued selfupdate NOT run - crash: cancellation announced - crash: queue cleared - crash: original exception preserved - queued-run exception contained - lock not leaked on exception
✅ selfupdate recovery — 12 checks Self-update recovery net (#43, @LeeNX). - fail: stop + rename to _old happened - fail: the stop carries a timeout, not Docker's default - fail: rolls back (rename _old -> ds + start) - fail: does NOT rm the _old backup - ok: recreate + rm _old cleanup ran - ok: no rollback (no rename back / start) - quote: backtick label is single-quoted - quote: $ value is single-quoted - quote: no command substitution error from sh - quote: backtick label reaches docker intact - quote: $ stays literal (not expanded) - quote: run_parts round-trips via shlex.split
✅ selfupdate retag — 6 checks Logic test for the /selfupdate latest|stable force-re-tag decision (#2). - latest from pinned tag -> retag - stable from pinned tag -> retag - latest while already on latest -> no recreate - version pin target -> no (digest-differs path handles it) - None target -> no - previous target -> no
✅ selfupdate routing — 10 checks A reply goes to whoever asked; an event goes to everyone (#63). - a reply reaches the front end that asked - …and nobody else - an event reaches the all-channel seam - the same holds when Discord is the asker - …and its event still reaches every channel - with no asker, a reply falls back to the seam - with_reply does not rewire the shared context - Telegram passes its own sender as the reply - Discord answers where the command was typed - the 'restarting' event goes through tell(), not the reply
✅ selfupdate stop — 19 checks A self-update that stops us properly, and says when it did not (#62). - the swap stops us with an explicit timeout - …never bare, which would mean Docker's ten seconds - …and the default is generous rather than Docker's - the swap still does: docker rename - the swap still does: docker run -d - the swap still does: docker rm - the swap still does: Selfupdate backup failed - the swap still does: Selfupdate recreate failed - the timeout comes from DOCKER_STOP_TIMEOUT - …with the same floor the rest of the shutdown path uses - the exit marker starts out saying 'not finished' - …and is rewritten once everything has stopped - a shutdown that never finished is reported - …the first write comes before anything is stopped - …and the second after - a marker from an older version is not mistaken for a kill - the message names the mechanism - …says what to change - …and does not claim damage it cannot show
✅ selfupdate toolbar — 16 checks The self-update trigger is reachable from the icon bar, not only Settings. - the icon bar is found at all (guards the rest) - icon bar offers a self-update - …as a POST, not a link to the settings page - …and it is a button you can press - …and it is there on every page, not just one - the endpoint is one the server actually handles - Settings still offers it too - it confirms before firing - …with its own title and label, like the Settings one - …and is marked destructive - the tooltip is a real string, not a key - …and it is what the button carries - the V2 list gets the same icon bar button - no own container name -> no button - …but the rest of the icon bar survives - …in V2 as well
✅ web selfupdate row — 29 checks Web UI render test for the Docksentry row in the status table (#51). - auto_selfupdate=True: own row shows the auto badge - auto_selfupdate=True: own row carries the ⚙ marker - auto_selfupdate=True: own row does not use the 🏷 label marker - auto_selfupdate=False: own row shows — - auto_selfupdate=False: own row still carries the marker - own row does not post to /api/autoupdate - own row links to Settings › Updates instead - docksentry.auto=true on ourselves does not override AUTO_SELFUPDATE=false - docksentry.auto=false on ourselves does not override AUTO_SELFUPDATE=true - foreign row keeps the /api/autoupdate toggle - foreign row has no self marker - foreign row not auto without opt-in - foreign row auto via the opt-in list - foreign row still honours the docksentry.auto label - empty own_name: no row treated as self - empty own_name: docksentry falls back to the opt-in list - empty own_name: nginx falls back to the opt-in list - _is_own_container: own name -> True - _is_own_container: other name -> False - _is_own_container: empty own name -> False for everything - legend capitalises 'auto' - legend keeps the lowercase 'auto' in the table header - legend shows the capitalised word in English - legend has no hardcoded English 'major-confirm' - _legend_word does not mangle 'Auto-Update' - _legend_word strips the emoji and capitalises - _strip_emoji leaves the text alone - stop confirm modal is emoji-free - stop button tooltip is a full sentence
✅ whatsnew — 18 checks Tell people what the version they just pulled actually changed. - headlines are found - only the bold lead is taken - a repeat is dropped - a plain line is not a headline - the trailing full stop goes - it stops at the next version - an older section reads on its own - a section with no headlines yields none - an unknown version yields none - a missing changelog yields none - the headline list is capped - the message names both versions - the message links the release - the link points at a tag, not the repo root - the shipped changelog yields headlines for the current version - every bold lead in the section becomes a headline - a previous version is required - the version is recorded for next time
### Containers & recreate
✅ container backend — 93 checks Container backend argv seam (v2 groundwork). - argv: ps -q (web _get_containers) - argv[0]=='docker': ps -q (web _get_containers) - argv: ps -a --format Names (monitor / web groups all_ids) - argv[0]=='docker': ps -a --format Names (monitor / web groups all_ids) - argv: inspect *refs (web _get_containers) - argv[0]=='docker': inspect *refs (web _get_containers) - argv: inspect single ref (web detail page) - argv[0]=='docker': inspect single ref (web detail page) - argv: inspect --format *refs (web groups_detect) - argv[0]=='docker': inspect --format *refs (web groups_detect) - argv: image inspect *imgs (web _get_containers) - argv[0]=='docker': image inspect *imgs (web _get_containers) - argv: image inspect --format Size single (web detail page) - argv[0]=='docker': image inspect --format Size single (web detail page) - argv: logs --tail N NAME (web detail / logs page) - argv[0]=='docker': logs --tail N NAME (web detail / logs page) - argv: logs NAME (no tail) - argv[0]=='docker': logs NAME (no tail) - argv: stats --no-stream --format (monitor _memory_snapshot) - argv[0]=='docker': stats --no-stream --format (monitor _memory_snapshot) - argv: info --format - argv[0]=='docker': info --format - argv: version --format - argv[0]=='docker': version --format - argv: system df --format - argv[0]=='docker': system df --format - argv: pull - argv[0]=='docker': pull - argv: stop --time - argv[0]=='docker': stop --time - argv: stop without --time - argv[0]=='docker': stop without --time - argv: kill - argv[0]=='docker': kill - argv: start - argv[0]=='docker': start - argv: rename - argv[0]=='docker': rename - argv: rm - argv[0]=='docker': rm - argv: rm -f - argv[0]=='docker': rm -f - argv: rm multiple - argv[0]=='docker': rm multiple - argv: image prune -a --force --filter - argv[0]=='docker': image prune -a --force --filter - argv: image prune bare - argv[0]=='docker': image prune bare - argv: image save - argv[0]=='docker': image save - argv: generic run: compose up - argv[0]=='docker': generic run: compose up - run forwards timeout - run defaults capture_output=True - run defaults text=True - text mode: stop is True - text mode: kill is True - text mode: start is False - text mode: rename is False - text mode: rm is False - text mode: pull is True - a call without a timeout still gets one - an explicit timeout still wins - remote fails faster than local - long operations keep their own timeout - DockerBackend.cli_binary == 'docker' - get_backend returns a DockerBackend - PodmanBackend.cli_binary == 'podman' - argv: podman ps - argv: podman pull - argv: podman stop - argv: podman compose (generic run) - argv: remote ps - argv: remote pull - argv: remote rm -f - argv: remote compose (generic run) - remote carries its host name - remote defaults name to the endpoint - argv: remote podman uses --url - argv: remote docker still uses -H - argv: context:// on podman → --connection - argv: context:// on docker → --context - context:// keeps the configured endpoint verbatim - a URL endpoint still takes the endpoint flag - local backend emits no global args - local backend name is 'local' - resolve_cli('podman') → podman - resolve_cli('docker') → docker - resolve_cli('auto') picks one of the two - resolve_cli(junk) falls back to auto - get_backend(container_cli='podman') → PodmanBackend - get_backend(container_cli='docker') → DockerBackend - default_backend() follows get_backend
✅ cooldown — 9 checks Tests for per-container update cooldown (#2). - set/get roundtrip - clamp over 600 - clamp negative to 0 - bad value -> 0 - zero clears entry - unset container -> 0 - sleeps cooldown when more remain - no sleep when last in batch - no sleep when cooldown unset
✅ dependents recreate — 4 checks End-to-end test for the recreate-dependents cascade (#8). - plain restart of sidecar fails after head recreate (the bug) - recreate_dependent rejoins sidecar to new head - cascade recreates netns sidecar + restarts plain member - cascade message reports success (no failures)
✅ disk guard — 0 checks Unit test for the disk-warning in-memory re-send guard (v1.23.6). - _(this procedure reports only a final verdict)_
✅ disk pressure — 26 checks Disk pressure handled where it happens, and a doomed layer named before it dies (#2 @famewolf, #63). - ENOSPC on a remote host triggers cleanup THERE - …and not locally - …and the batch says so, naming the host - …while the failed update still reports as ❌ - a non-disk failure triggers no cleanup - local disk over the threshold gets a mid-batch cleanup - …exactly once, not once per container - …reported with the measured percentage - without DISK_WARN_AUTO_CLEANUP nothing is pruned - a local disk reading prunes the LOCAL host… - …never the remote one the batch happens to run for - the once-per-batch mark resets for the next batch - the batch never calls cleanup_guarded (it holds the lock) - …it calls cleanup_images directly - one measurement, shared by both recreate paths - …standalone measures before the recreate - …compose measures before force-recreate - …and both success messages carry the note - 9.8 GB in the layer produces a warning - …that says where the data belongs - 120 kB of caches stays quiet - a failed inspect never blocks the update - the threshold is past caches territory (500 MB) - a big layer is marked in /status - a small one is shown unmarked - +0B is still shown (the owner insisted, and it IS an answer)
✅ disk reclaim — 14 checks Extended disk warning: reclaimable + auto-cleanup hint (#2, @famewolf). - parse: 20.1GB → ~20.1e9 - parse: 20.1GB (50%) [Docker's real format] - parse: 2.113MB - parse: 0B → 0 - parse: empty/junk → 0 - reclaimable is the images figure, not every row - …and the rest is reported separately - reclaimable: docker error → 0 - reclaimable: no output → 0 - msg: shows GB reclaimable - msg: warns auto-cleanup OFF - msg: no auto-cleanup hint when ON - msg: no reclaim line when 0 - msg: falls back to MB for small totals
✅ disk warn rules — 18 checks When the disk warning fires, and when it stays quiet. - below the threshold it says nothing - …right up to the last percent below it - at the threshold it warns - …and above it - a lower threshold warns earlier - …and a higher one later - having warned two hours ago, it stays quiet - …and still quiet at twenty-two hours - …but warns again after twenty-three - …and not five minutes before that - staying quiet is not the same answer as being fine - an unreadable state file does not silence it - the warning says how full the disk is - …and how much is left, in gigabytes - a threshold inside the range is stored as given - …one below the range is raised to 50 - …one above it is lowered to 100 - …and something that is not a number leaves it alone
✅ groups cmd — 8 checks Logic test for the /groups Telegram command (roadmap, #2). - list shows both groups - head crown only on multi-member - detail running icon - detail stopped icon - restart button on restart_dependents group - no button on single-member group - partial name resolve - not-found message
✅ netns dependent recreate — 11 checks Rebuilding a sidecar that vanishes when you stop it. - an AutoRemove sidecar is rebuilt after its head moved - …and the config was read before the stop, not after - …so it exists again afterwards - …and it rejoins the head's CURRENT container, not the dead one - …so the id from its own stale inspect is not reused - inspect comes before stop - a sidecar that survives its stop is rebuilt too - …having been renamed aside first - a rebuild that fails reports the failure - …and puts the backup back - a container that is already gone is reported, not guessed at
✅ netns recreate — 4 checks End-to-end test for the Gluetun-style netns-sidecar recreate fix (#2). - netns_target_name resolves head name - build_run_args uses container: (not stale id) - stale-id recreate fails (reproduces the bug) - name-override recreate succeeds + joins new head
✅ op coordination — 15 checks Operation coordination under the shared update mutex (#2, @famewolf). - cleanup: busy -> (None, busy-msg) - cleanup: prune NOT run while busy - cleanup: batch lock untouched - cleanup: runs when free - cleanup: exactly one prune - cleanup: lock released after - cleanup: mutex held during prune - queued selfupdate runs after cleanup - lifecycle: the lock is what says 'busy' - lifecycle: stop refused while updating - lifecycle: restart refused while updating - lifecycle: start refused while updating - lifecycle: the lock releasing clears it - lifecycle: passes guard when free - lifecycle: the bot passes update_running to the core
✅ protect stop — 4 checks Test for the per-container stop-protection flag (#38, @LeeNX). - toggle/is roundtrip - stop refused while protected (container still running) - restart allowed while protected - stop works again after unprotect
✅ recovery — 23 checks Picking up after Docksentry itself was killed (#2, @NotRetarded). - a mid-swap container is restored - …renamed back to its own name - …and started, not just renamed - the journal is cleared - a completed swap needs no repair - the live container is left alone - …nothing was renamed - …and nothing was started - …but the stale backup is removed - …and with no backup there, nothing is removed - both names gone is reported, not invented - …without conjuring a container - a stale journal is reported only - …and moves nothing - a failed rename asks for a hand - …and does not claim to have started it - no journal, no message - an unreadable daemon is survived - …and says what went wrong - state file and no exit marker = hard kill - a clean shutdown is not a hard kill - a first-ever boot is not a hard kill - every language renders the messages
✅ rename timeout recovery — 24 checks A timeout is ours, not the daemon's (#2, @famewolf). - a rename that times out but completes counts as done - …and the daemon really did it - a rename that times out and did not happen is still a failure - a rename of something absent is a failure - no rename is left on the old 10-second constant - …they follow DOCKER_STOP_TIMEOUT like the rest - recreate_dependent renames through the safe helper - …and returns a reason instead of throwing - a dependent left as _old is recovered - …back under its own name - …and started again - a live container is never replaced by its backup - nothing happens when there is no backup for that name - his exact leftover state heals on the next run - …and it is started again - …and reported as fixed, not as a failure - a dependent gone without a backup is still reported - an unanswerable probe is not mistaken for 'did not happen' - …and the rename is then reported as not done - the probe says True for a container that is there - …False for one that is not - the rollback guards its remove - …renames through the timeout-tolerant helper - …and never raises out of the handler that called it
### Monitoring & events
✅ crash diagnostics — 17 checks What a crash alert has to answer, and the clock it says it in (#2). - his 23:29 reads as 23:29, not 03:29 - the same instant is 05:29 in Berlin - UTC stays itself - no fractional part - an explicit offset is honoured - microseconds parse - rubbish yields empty, not a crash - None yields empty - a bare date yields empty - the victim's own line is found - a host prefix is stripped for the lookup - a vanished container yields nothing - an observed OOM says yes - an observed non-OOM says no - no evidence says nothing at all - no watcher says nothing at all - every language renders the new lines
✅ event resources — 16 checks The event log keeps the memory/CPU picture, not just the exit code (#2). - oom collects the picture - crash_restart collects the picture - exited collects the picture - recovered collects nothing - unhealthy collects nothing - the event is written - resources ride along - detail keeps its own meaning - no empty resources key - a remote host still records what it can - an idle CPU is left out - an old event yields an empty picture - the renderer reads it defensively - the row uses monitor_host_memory - the row uses monitor_top_memory - the row uses monitor_top_cpu
✅ event watcher — 32 checks Event watcher: parsing, evidence lifetime, burst sharing, reconnect. - docker die parses - docker oom parses - podman died parses as die - podman clean exit parses - noise ignored: '' - noise ignored: 'not json' - noise ignored: '[]' - noise ignored: 'null' - noise ignored: '{"Type":"image","Actio' - noise ignored: '{"Action":"die"}' - noise ignored: '{"Action":"start","Act' - exec_die is not a container death - evidence recorded on a crash - evidence is consumed - stale evidence is dropped - clean exit records nothing - clean exit costs no stats call - die does not erase the oom flag - saw_oom peeks, does not consume - exit code comes from the stream - exit code peeks, does not consume - no event, no exit code - podman exit code arrives as an int - a stack collapsing costs one stats call - every container in the burst has evidence - a crash-looping host cannot grow this forever - a refused stats call does not raise - a refused stats call still records the death - a dropped stream is reconnected - evidence survives the reconnect - remote watcher keeps the endpoint flag - podman remote uses --url, not -H
✅ health output — 15 checks Show what the health check said, not what the container said. - the probe's own output is reported - …with the exit code of the command Docker ran - …and the most recent attempts, not the first ever - a silent failure still names its exit code - a torrent of output is trimmed - …and multi-line output is flattened - a container with no healthcheck says nothing - …and neither does a null health block - …nor output we cannot parse - a passing probe adds no noise - a daemon that will not answer costs nothing - the older Podman spelling is tried too - both failure paths collect it - …and the rollback path reads it first - the message labels it as the health check
✅ monitor — 97 checks Container state monitoring (#2, @NotRetarded). - memory diagnostics attach to oom - memory diagnostics attach to crash_restart - memory diagnostics attach to exited - a recovery message stays clean - host memory reads as a human string - host memory carries no translatable words - remote monitor reports no host memory - cpu line names the hog - memory ranking survives the cpu column - one stats call feeds both readings - idle host gets no cpu line - a burst of events shares one stats call - cached snapshot still renders - next tick measures afresh - crash_restart takes the stream's exit code - unknown container keeps its old code - exited is left alone - no watcher, no change - the watcher's real exit code reaches the event - the detail dict is not mutated in place - label on host '' - label on host 'local' - label on host 'nas' - labelling follows container_store - baselines are per monitor - diff has no health events - first unhealthy flip is silent (pending) - confirmed unhealthy fires on next pass - confirmed unhealthy does not repeat - recovery of confirmed fires once - one-pass flap is fully silent - no healthcheck -> no health events - restart-recovery: confirmed first - restart-recovery: ->starting fires recovered once - restart-recovery: later episode alerts again - restart-recovery: ->starting->healthy fires once, no double - restart-recovery: unconfirmed blip stays silent - prune: vanished container drops stuck flag - running unhealthy-at-baseline: no spurious alert - stopped frozen-unhealthy: no event ever - stopped frozen-unhealthy: stale flags cleared, no event - genuine flip: pending, silent - genuine flip: confirms once with prev=healthy - genuine flip: does not re-fire - alerted then stops: no recovered event, flag cleared - no unhealthy event ever has prev=='unhealthy' - tick: unhealthy-at-baseline never alerts - tick: still-unhealthy-at-baseline still silent - tick: stopped frozen-unhealthy never alerts - zero exit is silent - non-zero exit fires with code - oom exit fires as oom - restart count increase fires - count reset (recreate) is silent - crash loop caught 'restarting' fires crash_restart - crash loop caught 'exited' + count up fires crash_restart - crash alert carries the exit code of the run that died - crash alert carries the restart time, in local time - crash alert still carries the count - a missing timestamp degrades to empty, not to garbage - one-shot exit, count unchanged, fires exited - oom precedence over crash_restart when count up - vanished container is silent - new container is baselined silently - first tick: silent baseline - health flip pending and our own exit ignored: nothing sent - confirmed unhealthy notifies on next pass - message rendered via i18n key - recovery of confirmed notifies once - one-pass flap: zero notifications - vanish/reappear: first episode alerts - vanish/reappear: flag pruned on vanish - vanish/reappear: second episode alerts again - exit fires immediately - oom fires immediately - crash_restart fires immediately - tick skipped while updating - baseline dropped while updating - post-update tick re-baselines silently - docksentry.monitor=false opts out - exclude_containers honored - oom msg carries memory snapshot - oom msg carries log tail - unhealthy msg carries tail, no snapshot - recovery msg stays clean - tail failure doesn't break notify - mem: GiB - mem: MiB - mem: decimal MB - mem: plain bytes - mem: garbage -> 0 - event: persisted to file - event: structure complete - event: capped at MAX_EVENTS - event: newest survives the cap - event: write failure doesn't break tick - the alert is logged before the channels are tried
✅ podman live — 15 checks Podman against a REAL podman, including a remote endpoint (#7, #23). - local podman answers through the backend - ps works locally - podman remote uses --url, not -H - docker remote still uses -H - remote podman answers over the emitted flag - remote ps succeeds - generic run() reaches the remote too - context:// emits --connection - a real podman answers on the named connection - an unknown connection name fails, it does not fall back - a pod and a member can be created - the rebuilt command is argv, all strings - it rejoins the pod rather than the infra namespace - podman accepts the rebuilt run command - and the container is still in its pod afterwards
### Multi-host
✅ docker hosts — 24 checks DOCKER_HOSTS parsing and per-host backends (#7, multi-host groundwork). - parses name:endpoint pairs - splits on the first colon only - tolerates loose whitespace - empty input → no hosts: '' - empty input → no hosts: None - empty input → no hosts: ' ' - empty input → no hosts: ',,' - missing endpoint skipped - empty endpoint skipped - bad name skipped - name starting with dash skipped - good entries survive a bad neighbour - names lowercased - duplicate names dropped - first duplicate wins - reserved name rejected: local - reserved name rejected: all - 'all' is reserved - checker keeps the backend it was given - checker's commands carry the host endpoint - no backend given → local, no endpoint flag - nas scan drops only nas entries - legacy entry (no host key) survives a remote scan - local scan clears local entries
✅ flag list multihost — 14 checks Listing a flag across hosts shows what is set, not a wall of "none". - only the host with entries is listed - …and it is the right host - …with the entry in it - empty hosts produce no line at all - nothing anywhere says 'none' exactly once - …host-less (not attributed to one of several) - several hosts with entries are all listed - single host, empty: one host-less 'none' line - single host, set: one host-less list line - cooldown list: only the host with one is shown - cooldown list: one 'none' when none anywhere - Telegram has a read/write-aware flag resolver - …and the flag handlers use it - Discord lists across all hosts when no container is named
✅ host scoped store — 34 checks Per-host container state (#7, multi-host). - local host key is bare - no host given → bare - remote host key is prefixed - round-trip remote - bare key reads as local - pin on local doesn't pin on nas - both hosts can pin the same name - each host sees only its own - stored as distinct keys - unpin on nas leaves local pinned - bulk save keeps the other host's entries - bulk save applies to own host - existing flat data reads as local - cooldowns are per host - cooldown listing is per host - notes are per host - auto-update is per host - auto listing is per host - group members stored host-keyed - group reads back with plain names - other host doesn't see the group - group lookup by member is host-scoped - group lookup returns plain member names - get_pinned returns the same type as the store - get_autoupdate returns the same type as the store - get_ask_before_major returns the same type as the store - get_trust_running returns the same type as the store - get_protect_stop returns the same type as the store - get_update_windows returns the same type as the store - get_cooldowns returns the same type as the store - get_pending_major returns the same type as the store - get_notes returns the same type as the store - get_links returns the same type as the store - get_groups returns the same type as the store
✅ host state wiring — 44 checks Per-host container state is actually WIRED UP (#7). - pin @nas pins on nas - pin @nas does NOT pin on local - pin @nas is stored host-keyed - a second pin on local leaves nas' pin intact - both pins coexist on disk - unpin on local leaves nas pinned - auto-update toggle is per host - toggling local auto-update leaves nas on - toggling nas off leaves local on - per-host toggle reply names the host - cooldown is per host - local cooldown doesn't disturb nas' - protect-stop is per host - link override is per host - link resolver picks the entry's own host override - unknown host errors and writes nothing - engine: local recreate ignores nas' cooldown - engine: nas recreate uses nas' cooldown - engine: ask-major list is the update's host's - engine: a local group doesn't order another host's batch - engine: the local group DOES order the local batch - auto pass: only nas' web was updated - auto pass on nas keeps local's pending entry - auto pass on nas keeps nas' other pending entry - auto pass on nas dropped exactly its own processed entry - single update: the host key picked nas' checker - single update on nas leaves local's same-named entry - removing (nas, web) leaves (local, web) - a host-less legacy entry is treated as local - checker: local sees only unprefixed pins - checker: nas sees its own pins, unprefixed - checker: a nameless backend is the local host - single host: a one-host registry writes the same bytes as none - single host: a one-host registry sends the same replies as none - adding a second host doesn't change what local writes - single host: pinned file is the plain unprefixed list - single host: autoupdate file is the plain unprefixed list - single host: cooldown file is the plain unprefixed dict - single host: protect file is the plain unprefixed list - single host: links file is the plain unprefixed dict - single host: no `/` ever reaches a state key - single host: no reply carries a host marker or hint - single host (no-registry): auto pass leaves exactly the untouched entry - single host (one-host): auto pass leaves exactly the untouched entry
✅ hosts — 30 checks The managed-host registry (#7, multi-host). - no config → exactly one host - that host is local - single host is not 'multi' - local host sends no endpoint flag - local + configured hosts - local stays first - is_multi once there are remotes - lookup by name - lookup is case-insensitive - unknown host → None - empty name → None - only local is flagged local - remote checker carries its endpoint - second remote uses its own endpoint - same name pinned on two hosts - nas untouched by the other two - local key stays unprefixed on disk - remote inherits the podman CLI - no @token → no target - empty input - @host alone - @host after the name - @host before the name - @host in the middle - @all is a distinct target - @all is not a host named 'all' - host names are case-normalised - bare @ is left alone - only the first @token is consumed - glob arguments survive
✅ multihost link — 16 checks The repo/changelog link is read on the host the container runs on (#7, #52). - a container on nas is read on nas - …and the local machine is never asked about it - the OCI read carries that host's endpoint - the docksentry.link read goes to the same machine - the local container keeps its own link - …read without any endpoint flag - /changelog's (url, kind) pair is host-resolved too - an explicit checker decides where to look - an update's host reaches the link lookup - …and each entry gets its own host's repo - an unknown host falls back to the local reader - single host still resolves the link - single host: the OCI argv is unchanged - single host: nothing carries an endpoint flag - a Podman install asks podman - …even without a host registry
✅ multihost live — 6 checks Multi-host end to end against a REAL container daemon (#7). - registry has local + the remote - remote host lists containers over its endpoint - remote view matches the local one - pin via remote is visible on remote - pin via remote is NOT on local - stored under the host-prefixed key
✅ multihost routing — 49 checks Multi-host command targeting in the Telegram bot (#7). - single host: hosts=None and a one-host registry reply identically - single host: no `@host` marker anywhere - single host: no local-only hint anywhere - single host: /status is the plain overview - single host: /update unchanged - single host: /help has no multi-host block - single host: unknown container answers as before - single host: a glob matching nothing answers as before - single host: `@` argument is not treated as a host - read /check: both hosts checked - read /check: remote results carry @nas - read /check: local results carry no marker - read /status: containers from both hosts - read /status: 4 containers total - read /updates: both hosts listed - read /updates @nas: only that host - write /update: only the local host checked - write /update: only the local host updated - write /update: reply carries the local-only hint - write /stop: acted on the local host only - write /stop: reply carries the local-only hint - @nas: only the named host was checked - @nas: the named host was updated - @nas: no local-only hint when aimed explicitly - @nas: lifecycle hit the remote host - @nas: lifecycle reply names the host - @nas: bare check runs on that host only - @nas: /status is that host's overview - one reply for a container only the local host has - one reply for a container only the remote host has - remote /status detail carries no lifecycle buttons - a container on both hosts answers once per host - local /status detail keeps its buttons - a name no host has answers once - @all: every host checked - @all: every host updated - @all: no local-only hint - unknown host: `/check @typo` errors and does nothing - unknown host: `/update web @typo` errors and does nothing - unknown host: `/stop web @typo` errors and does nothing - unknown host: `/status @typo` errors and does nothing - unknown host: `/updates @typo` errors and does nothing - /help documents @host and @all - /help documents the read/write split - /help lists the managed hosts - /status survives a host that times out (does not raise) - …and still shows the reachable host's containers - …and names the dead host as unreachable - …and actually tried the dead host (its run was called)
✅ remote transports — 15 checks Multi-host over a real network endpoint, not a socket that looks like one. - the image installs an ssh client - ssh is documented and therefore must be installed - the TCP endpoint answers - the host registry carries it - multi-host is active - the endpoint survives to the host - containers are listed over TCP - it is NOT reading the local daemon - the endpoint reaches argv - a TLS daemon is recognised as speaking TLS - …and the plaintext one is not - the pre-flight says TLS rather than 'nothing answered' - the TLS daemon hands out client certificates - DOCKER_CERT_PATH gets a tcp:// endpoint all the way in - …and so does context:// with the certificates in it
✅ web multihost — 57 checks Web UI actions on several hosts (#7). - single host: a one-host registry renders byte-identically to none - single host: no host column - single host: no host filter - single host: no data-host on any row - single host: form fields carry the bare container name - single host: only the LOCAL pending entry lights a row up - multi: every host's containers are listed - multi: the host column is back - multi: the host filter offers every host plus 'all' - multi: an unreachable host is one line, not a broken page - multi: the unreachable row quotes what the CLI said - multi: and drops podman's `podman machine` boilerplate - multi: a remote row has the same actions as a local one - multi: every form on a remote row carries that host's key - multi: a remote row's update button follows that host's pending - multi: a local row with no pending update has no update button - no host in the request resolves to the local objects - an explicit `local` resolves the same way - a remote key resolves that host's backend and checker - a remote key resolves that host's store view - an unmanaged host resolves to nothing — never to local - an empty name resolves to nothing - single host: a remote key is refused, not taken as local - pin on a remote host writes that host's key - pin on host A does not pin on host B - pin with no host given pins locally - unpin on a remote host leaves the local pin alone - pin naming an unmanaged host changes nothing - every per-container toggle lands on the named host only - a remote per-container form returns to the table, not to a local detail URL - lifecycle on a remote row uses that host's backend - …and leaves the local host alone - lifecycle with no host given stays local - lifecycle naming an unmanaged host does nothing - the self-kill guard still refuses a local stop of ourselves - the self-kill guard does not refuse a same-named remote container - a stop-protected container is refused in the Web UI too - …and restart is still allowed on it - nothing runs while an update holds the lock - …and it works again once the lock is free - update on a remote row runs that host's entry through its checker - update on a remote row drops only that host's pending entry - update with no host given uses the local entry and local checker - a mixed batch is one call per host, each with its own checker - a mixed batch removes both hosts' entries, keyed by host - the update lock is released again afterwards - an update naming an unmanaged host runs nothing at all - a busy update lock blocks the whole batch, not just one host - bulk pin splits a mixed selection per host - bulk auto-update-on lands on the named host only - bulk auto-update-off removes only that host's entry - check-one on a remote row uses that host's checker - check-one with no checker given uses the local one - a deferred major shows up once per host, with its host key - rejecting a remote major clears that host's entry only - confirming a major hands the host key to the shared resumer - the rendered keys ARE container_store.host_key output
### Notifications
✅ api retry — 10 checks Telegram api_call bounded retry on transient network failures (#2, @NotRetarded). - retries then succeeds (3rd attempt) - gives up after 3 attempts -> None - long-poll not retried (1 attempt) - a reset long poll says nothing - …while a name that will not resolve still does - …and a reset on a real send is still reported - a short run of reset polls stays quiet - …but a poll that has been dead for minutes speaks up - …and an answer resets the clock - HTTP 4xx returns body, not retried
✅ discord borrows exist — 6 checks Discord borrows no behaviour from the Telegram bot (#63). - Discord borrows no method from the Telegram bot - the only thing written on the bot is the translator - /selfupdate drives the shared self-update - /changelog reads the shared changelog - /restart asks the shared restart guard - the scan still sees all three shapes it is meant to catch
✅ discord bot — 268 checks Discord slash commands onto the shared engine (v2.0 Discord bot). - every command has a name - every command has a description - descriptions fit Discord's 100-char limit - names are lowercase (Discord rejects capitals) - options are typed - option names are lowercase - option descriptions fit the 100-char limit - command names fit the 32-char limit - option names fit the 32-char limit - names use no characters Discord rejects - command names are unique - option names are unique within a command - required options come before optional ones - option types are ones Discord knows - …and that check would catch a bad ordering - interaction is acknowledged immediately - the ack is dispatched off the gateway loop - acknowledgement is deferred (buys 15 minutes) - no host given → every host - named host → just that one - unknown host → None, never a fallback - status lists every host's containers - remote entries are labelled with their host - local entries carry no label - …while remote ones still do - host option narrows the listing - unknown host is reported, nothing is listed - …and it names the hosts that do exist - container option filters - check finds the pending update - check backs off while an update runs - a dead host is reported, the rest still lists - a long listing is clipped below the 2000 limit - clipping says it clipped - single host output carries no host marker - /hosts says so plainly on a single host - token without application id is disabled - both present → enabled - /pin confirms the pin - /pin writes to the local host - /pin with no host does NOT touch the other host - /pin host:nas pins there - …and says which host it acted on - …while the local pin is untouched - the two pins are separate keys in the raw store - pinning twice is reported, not duplicated - /pin on an unknown host errors - …and changes nothing at all - …and never falls back to local - /unpin removes the local pin - …and leaves the other host pinned - /unpin confirms - /unpin of something unpinned says so - /unpin on an unknown host errors - …and the real pin survives - /unpin host:nas clears it there - /autoupdate turns it on - /autoupdate does not leak to the other host - /autoupdate toggles back off - /autoupdate host:nas targets that host - …and the local host stays empty - /autoupdate on an unknown host errors - /protect turns protection on - /protect does not leak across hosts - /protect toggles back off - /protect host:nas targets that host - /protect on an unknown host errors - /cooldown sets the value - …and says so - /cooldown does not leak across hosts - /cooldown reports the CLAMPED value, not the asked-for one - /cooldown 0 clears it - /cooldown rejects a non-number - /cooldown host:nas targets that host - …and the local one is still clear - /cooldown on an unknown host errors - …and writes nothing - an unknown container is reported - …and nothing is pinned - /history with no file says so - /history lists entries - /history marks failures - /history is newest first - /history filters by container - /history says when the filter matches nothing - /events with no file says so - /events lists events - /events is newest first - /events renders the detail payload - /logs returns the log text - /logs fences the output - /logs defaults to 30 lines - /logs honours the lines option - /logs clamps an absurd line count - /logs host:nas reads from that host - …and the remote log is labelled - /logs on an unknown host errors - /logs of an unknown container is reported - a long log stays under Discord's 2000 limit - …and its code fence is still closed - /groups with none configured says so - /groups lists the group - /groups shows the members - /groups marks the leader - a group is per host — the other host has none - …and that empty line names the host it is about - …while a local group row carries no marker - /settings shows the schedule - /settings shows excludes - /settings lists the managed hosts - /settings stays under the 2000 limit - /maintenance reports off by default - /maintenance 2h turns it on - …and the state is read back - /maintenance forever has no end time - /maintenance off turns it off - /maintenance rejects nonsense - …and a rejected duration changed nothing - /update runs the container through the shared batch engine - /update is a manual update, never the auto path - /update with no host acts on the LOCAL host only - /update drops just that host's pending entry - /update reports the result - /update host:nas updates there - …and only that entry leaves the pending file - /update of a container with no pending update says so - …and runs nothing - /update on an unknown host errors - …and starts no update - …and leaves the pending file untouched - /update refuses while the shared lock is held - …and starts nothing - …and the pending file is untouched - …and the refusal did not swallow the lock - /updateall does not act on the first invocation - …it hands back a button - …and previews what it would do - the button lives in an action row - the confirm button is styled as dangerous - custom_id is namespaced to this bot - custom_id fits Discord's 100-char limit - /updateall with no host previews only the local host - the button press runs the update - …on the local host only - …clearing only the local pending entry - a spent confirmation cannot be pressed again - …and nothing runs the second time - Cancel changes nothing - …and burns the token with it - an unknown token is refused - a foreign custom_id is not claimed - …and the action in the custom_id has to match the record - /updateall on an unknown host errors - …and offers no button at all - …and parks no confirmation - a confirmed /updateall still refuses while the lock is held - …and updates nothing - someone else cannot press my confirmation - …and it runs nothing - …while the asker still can - an expired confirmation is refused - …and an expired one runs nothing - /stop asks before it acts - …and stops nothing yet - …and names what it would stop - the stop button carries the stop action - the press stops it - …and confirms it - /stop with no host stops only the local container - /stop host:nas stops there - /stop on an unknown host errors - …and offers no confirmation - …and stops nothing - /stop refuses a protected container - …without even offering the button - …and a protected container is not stopped - protection set after the question is still honoured - a protect LABEL refuses the stop too - /restart restarts - …with a graceful timeout - /start starts - /restart host:nas acts there only - /restart on an unknown host errors - …and touches no container - /restart is refused while an update runs - …and issues no command - /start is refused too - /restart refuses to restart Docksentry itself - /stop refuses to stop Docksentry itself - …without offering a button for it first - /cleanup runs the prune - …and reports what it freed - /cleanup skips while the update lock is held - …and prunes nothing - …and left the lock alone - /cleanup reaches the other host too - …and still cleans the local one - …and names each host in its answer - /checkimages is a read — it spans every host - …and points at /cleanup when there is something to free - /checkimages host:nas answers for that host - /checkimages says so when there is nothing to reclaim - /checkimages on an unknown host errors - a fresh interaction is answered by editing it - an answer past the 15-minute window is not edited - …it is posted into the channel instead - …addressed to whoever asked - …carrying the result itself - …and explaining why it is not ephemeral - a rejected edit falls back to the channel - an answer with no channel to fall back to is logged - the channel fallback respects the 2000-char limit - a slow command warns before it starts - …and still delivers the real answer afterwards - a button press is acknowledged immediately - …deferred, like a command - another bot's button is left alone - an interaction type we don't handle is ignored - a queued self-update is handed on after the lock is released - …and the lock really was released - the worker pool has a small, positive bound - the pool refuses work once every slot is taken - an over-limit interaction is answered, not silently dropped - …and told plainly that nothing was started - …with an immediate reply, not a deferred promise it can't keep - …and no command ran - a finished worker hands its slot back - stop() waits for a command that is still running - …and really waited rather than getting lucky - …while the wait is still bounded - a transient 500 does not publish a private answer - …and it is logged rather than lost without trace - a 429 does not publish one either - an expired token still falls back to the channel - …and so does a webhook Discord no longer knows - a 404 for anything else is not treated as an expiry - /updateall updates only the containers it previewed - …leaving what turned up afterwards pending - …and saying that it did not touch them - /updateall says so when the approved set shrank - …and updates what is left of it - /updateall with nothing approved left runs nothing - …and explains that, rather than claiming nothing was pending - …and leaves the unapproved entry alone - a press we cannot attribute to anyone is refused - …and an unattributable press runs nothing - …while a failed identity check leaves the button pressable - a confirmation with no identified owner is not pressable - …and it too runs nothing - single host: no command emits a host marker - single host: state still lands in the store - single host: keys stay unprefixed - every COMMANDS entry has a handler - an unknown command still says so - no reply exceeds Discord's 2000-character limit - auth: an interaction from the configured guild passes - auth: another guild is refused - auth: a missing guild (DM) is refused - auth: no DISCORD_GUILD_ID → nothing is authorised - auth: …and the bot refuses to start at all - auth: a listed user passes - auth: an unlisted user is refused - auth: an unidentifiable user is refused when a list is set - auth: an empty list means anyone in the guild - auth: an unauthorised interaction gets no reply at all - auth: every command is admin-only by default - auth: no command is usable in a DM - auth: hardening does not mutate COMMANDS
✅ discord gateway — 51 checks The Discord gateway client, against a fake gateway (v2.0 Discord bot). - sends IDENTIFY after HELLO - IDENTIFY carries the token - IDENTIFY declares intents - sends heartbeats - heartbeat carries the sequence - READY is surfaced - session id is remembered - dispatch reaches the handler - first connection identifies - after RECONNECT it RESUMEs - RESUME carries the session - RESUME carries the sequence - an invalidated session re-IDENTIFYs, never RESUMEs - an unacknowledged heartbeat forces a reconnect - bad token (4004) is fatal - disallowed intents (4014) is fatal - a normal close (1000) is retryable - rate limited (4008) is retryable - a 4004 close ends run_forever instead of reconnecting - …and it never re-IDENTIFYs on a rejected token - …and it says why, naming the token - a dropped session is RESUMEd once - a RESUME rejected with 4009 is not RESUMEd again - …and the dead session id is forgotten - close 4007 clears the session so the next attempt identifies - close 4009 clears the session so the next attempt identifies - a disallowed-intents close raises the fatal error - …while an ordinary close is only a reconnect - three healthy connections do not compound the backoff - a connection that never reached READY is not 'healthy' - …one that stayed up past the threshold is - a failed handshake clears the health mark - …so the penalty grows instead of staying at one second - …and the log stops repeating the same number - a failure before the RESUME keeps the session - …and does not claim resumes were refused - a RESUME that goes out and gets nowhere does count - …after exactly three of them, not two - …and the next attempt identifies fresh - …and the session really is gone - a disconnect we mean to resume from does not close normally - …while stop() really is finished, and says so - …and so is a connection the gateway refused outright - …which also stops the loop rather than re-identifying - a ws:// resume URL is refused - …and so is any other scheme - a wss:// resume URL is honoured - …and carries the version and encoding we connect with - …but a URL that brought its own query keeps it - …and a gateway URL without a query adds nothing - no resume URL at all falls back to the configured gateway
✅ discord host options — 63 checks Every Discord write command can be aimed at a host. - the command table was read - /pin exists - /pin can be aimed at a host - /unpin exists - /unpin can be aimed at a host - /autoupdate exists - /autoupdate can be aimed at a host - /protect exists - /protect can be aimed at a host - /trustrunning exists - /trustrunning can be aimed at a host - /askmajor exists - /askmajor can be aimed at a host - /cooldown exists - /cooldown can be aimed at a host - /note exists - /note can be aimed at a host - /setlink exists - /setlink can be aimed at a host - /stop exists - /stop can be aimed at a host - /start exists - /start can be aimed at a host - /restart exists - /restart can be aimed at a host - /update exists - /update can be aimed at a host - /updateall exists - /updateall can be aimed at a host - _cmd_flag exists - _cmd_flag reads the host option - _cmd_autoupdate exists - _cmd_autoupdate reads the host option - _cmd_cooldown exists - _cmd_cooldown reads the host option - _cmd_note exists - _cmd_note reads the host option - _cmd_pin exists - _cmd_pin reads the host option - _cmd_protect exists - _cmd_protect reads the host option - _cmd_lifecycle exists - _cmd_lifecycle reads the host option - _cmd_setlink exists - _cmd_setlink reads the host option - _cmd_update exists - _cmd_update reads the host option - _cmd_updateall exists - _cmd_updateall reads the host option - no command offers a host option it ignores - /pin can be asked with no container - /autoupdate can be asked with no container - /protect can be asked with no container - /trustrunning can be asked with no container - /askmajor can be asked with no container - /cooldown can be asked with no container - /unpin still needs a container - …because its spec has no list keys - /pin with no container lists what is pinned - …and an empty list says so rather than nothing - all 16 languages are checked - the usage keys they replaced are gone everywhere - both chats format a size identically
✅ discord rest — 22 checks Discord REST client, against a local fake API (v2.0 Discord bot). - GET /users/@me returns the body - sends the bot token - sends a descriptive User-Agent - uses the right path - 429 is retried, not raised - waits exactly the retry_after - falls back to the Retry-After header - an absurd retry_after is refused - …and it did not sleep on it - 401 raises - 401 is not retried - 5xx is retried - guild commands use the guild path - registration is a bulk PUT - global commands use the global path - immediate response is type 4 - response carries the content - responses are ephemeral by default - deferred response is type 5 - editing the original is a PATCH - edit targets @original - messages post to the channel
✅ edited message — 6 checks Recalled-command handling (#15, @famewolf). - normal message passes through - recent edit (<=120s) honoured - stale edit (>120s) ignored - edit with no edit_date ignored - empty update -> {} - message wins over a co-present edit
✅ i18n partial — 9 checks A missing placeholder must not discard the whole message (#2, @NotRetarded). - no raw braces survive - the container name comes through - the exit code comes through - what was never stored is marked - a partially-complete event keeps its count - a complete event is untouched - the ordinary path is unmarked - all languages recover - the missing-marker mapping exists
✅ notifier plugins — 17 checks Notifier plugin layer — registry, best-effort dispatch, ntfy request. - registry: discord only - registry: smtp only - registry: discord+webhook, stable order - registry: nothing configured -> empty - registry: ntfy discovered from env - registry: expected channel set - dispatch: raising channel isolated - ntfy: POST to topic url - ntfy: Title header - ntfy: Priority high on failure - ntfy: body carries detail - ntfy: Priority default on success - ntfy: bot_label prefixes title - ntfy: server+topic configured - ntfy: server+topic joined url - ntfy: message strips bold markers - ntfy: unset -> not configured
✅ notifier resilience — 45 checks A notification survives a short outage, and a failed one says so (#66, @NotRetarded). - a send into a dead network reports failure, not silence - …and writes a line that can be grepped for - …and the message is held rather than dropped - …a second alert during the same outage is held too - a flush while still down delivers nothing - …and tries the channel once, not once per held message - …and both messages are still held - once the network is back the alerts go out - …and the queue is empty again - …the log says they were held messages - …in the order they were raised, not reversed - …carrying the original alert, intact - …marked as late, so it does not read as fresh - …and naming the time it actually happened - an alert past the age limit is dropped, not delivered late - …saying why it was dropped - …and it does not stay in the queue - …while one just inside the window still goes - …telling the reader how late it is - …without touching the alert itself - a long outage cannot grow the queue past its cap - …the oldest are the ones dropped, the newest survive - held messages keep their order - the queue is memory only — nothing is written to disk - a message Telegram refuses is reported as failed - …with the reason in the log - …and is NOT held: repeating a 400 only repeats it - a monitor alert that did not get out is named in the log - …while quiet hours are not reported as a failure - a bot with no token is a silence, not a failure - a rate-limited message is not abandoned - …it waits exactly as long as Telegram asked - …and says so in the log - …but an hour-long limit is not sat out inside the request - Discord and the webhook hold a message too - …and each says so in the log - …and both go out once the network is back - …late-marked, like the Telegram one - a 400 from Discord is not held for retry - a message queued during a flush is not lost - …and the older one still comes first - the queue is guarded by a lock - flush takes the batch out under it, not in place - …and merges survivors back under it too - the send itself happens OUTSIDE the lock
✅ notifier retry — 15 checks Notifier bounded retry — Discord + Webhook (audit follow-up to v1.38.1). - discord: retries then succeeds on 3rd attempt - discord: gives up after 3 -> None - webhook: gives up after 3 -> None - discord: HTTP 4xx not retried (server rejection) - webhook: HTTP 4xx not retried - webhook: success on first attempt (no retry) - STOP is falsy, so nothing reads it as delivered - …a STOPped message counts as dropped, never as sent - …and the log does not claim it was delivered - a STOP blocks its channel for the rest of the pass - …while the messages behind it are kept, not lost - …but another channel is not punished for it - an alert older than the window is dropped, not sent late - …while one inside it is still delivered - a flush cannot leave the queue over its cap
✅ notifiers selfhosted — 42 checks The self-hostable notification channels: Apprise, Gotify, Matrix. - apprise: URL alone is enough - apprise: title carries BOT_LABEL - apprise: remote host is labelled - apprise: local host is not labelled - apprise: a failure is type=failure - apprise: a success is type=success - apprise: stateless targets are passed through - apprise: stateful sends no urls - apprise: a failing send never prints the token in APPRISE_URLS - apprise: …nor the response body it came in - apprise: …but it still says the send failed, with the status - apprise: unset → not configured - gotify: URL without a token is not configured - gotify: URL + token → configured - gotify: /message is appended to the base URL - gotify: token travels in a header - gotify: a full endpoint URL is not doubled - gotify: a failure is high priority (bypasses quiet hours) - gotify: a success is normal priority - gotify: remote host is labelled - gotify: token removed → not configured - matrix: without a room it is not configured - matrix: homeserver + token + room → configured - matrix: sends with PUT - matrix: uses the client v3 send endpoint - matrix: bearer token in the header - matrix: each send gets a fresh transaction id - matrix: room id is URL-escaped - matrix: plain body is always present - matrix: HTML variant is offered - matrix: remote host is labelled - matrix: a failure is marked in the text - matrix: a container name is escaped into the HTML body - matrix: a docker error is escaped too - matrix: an ampersand in an image ref is escaped - matrix: the plain body is left alone (it is not HTML) - matrix: a safe changelog URL is still a link - matrix: a javascript: changelog URL is never made into an href - matrix: …and it is still shown, as text - matrix: a URL that would break out of the attribute is not linked - matrix: the update LIST escapes names and images too - matrix: unset → not configured
✅ ntfy resilience — 26 checks ntfy survives a short outage, and never repeats a refusal (#66). - a push into a dead network is held, not dropped - …and says so in a line that can be grepped for - …keeping the error ntfy already logged - once the topic answers again the push goes out - …exactly once - …marked as late, so it does not read as fresh - …carrying the original alert, intact - a 400 from ntfy is NOT held for retry - …and still logs `ntfy error: HTTP 400` - a 401 from ntfy is NOT held for retry - …and still logs `ntfy error: HTTP 401` - a 403 from ntfy is NOT held for retry - …and still logs `ntfy error: HTTP 403` - a 404 from ntfy is NOT held for retry - …and still logs `ntfy error: HTTP 404` - a 413 from ntfy is NOT held for retry - …and still logs `ntfy error: HTTP 413` - a DNS failure holds the message - a timeout holds the message - a TimeoutError holds the message - a reset connection holds the message - a failure of our own making is not held - …but it is still logged - an unconfigured ntfy holds nothing - the HTTPError clause comes before the network one - …and the clause order decides which statuses are held
✅ ntfy title — 10 checks ntfy titles with non-ASCII characters (dc#120). - ascii is left alone - empty is safe - None is safe - emoji is encoded - the encoded value is a latin-1 header - an umlaut is encoded too - an umlaut is NOT sent raw - round-trips: 🖥 pve1 - round-trips: Grün Größe - round-trips: [🖥 pve1] 3 Updates
✅ send only — 6 checks Send-only Telegram mode (#2, @famewolf). - send-only: never calls getUpdates - send-only: never calls setMyCommands - send-only: makes no api calls at all - default: polling ON - env false: polling OFF - env true: polling ON
✅ smtp — 21 checks Test the native e-mail / SMTP notifier (#2). - has_channels true when configured - has_channels false when unset - starttls used - auth login called - multi-recipient parsed - BOT_LABEL prefixes subject - From set - ssl: no starttls - none+no-user: no starttls, no login - version: old → new arrow - version: single known - version: none → empty - the subject names the container - …and what happened to it - …and still says who is writing - …without the headline's emoji - …while the body keeps it - two different alerts get two different subjects - an empty alert still has a subject - …and a very long one is cut, not sent whole - the configured prefix still comes first
✅ smtp resilience — 30 checks A mail lost to the network is held; one the server refused is not. - a name that does not resolve is the network - a refused connection is the network - a timeout is the network - a server that hangs up mid-conversation is the network - a wrong password is NOT the network - a refused sender is NOT the network - a refused recipient is NOT the network - an unsupported command is NOT the network - a certificate we do not trust is NOT the network - …and smtplib's own errors really are OSErrors - a handshake cut off mid-way is the network - a half-returned network cannot turn one alert into a stack - …and taken-then-dropped twice stops at two copies - …both of them the same mail, not two alerts - an outage longer than one flush does not cost the alert - a drop on the envelope keeps its free retries - dying on the DATA command still gets a free retry - a DATA command answered with 554 is a refusal, not a handover - a 4xx after the body is still 'come back later' - …and a 5xx after it is a refusal, not a maybe - …including one that calls it spam - a greylisted mail is come back to, not thrown away - a 5xx refusal is never queued at all - mixed recipient codes follow the recoverable one - a server that says no is not asked again every 30s - every mail carries a Message-ID of its own - …with the domain from SMTP_FROM, display name and all - …while a different notification gets a different one - a held mail is re-sent without registering itself again - quit() cannot replace the error that got us here
✅ telegram split — 14 checks A long report must arrive, not vanish (#56, @LeeNX). - unchanged at 5 chars - unchanged at 0 chars - unchanged at 3899 chars - a long report is split - every chunk fits - nothing is dropped - no chunk leaves a fence open - a fence is reopened after the break - fenced content is all still there - an oversized single line is cut, not dropped - …and keeps every character - send_message does the splitting - buttons ride on the last chunk - the markdown retry is still there
✅ websocket client — 31 checks The hand-rolled WebSocket client (RFC 6455), needed for the Discord bot. - accept key matches the RFC example - client frames set the mask bit - masking key makes the payload differ - masking is reversible - 125 bytes: 7-bit length - 126 bytes: 16-bit length - 70000 bytes: 64-bit length - FIN is always set outbound - handshake completes against a real server - client sends a Sec-WebSocket-Key - client announces version 13 - client sends the Upgrade header - receives a whole message - reassembles a fragmented message - ping is answered without surfacing - client answered the ping with a pong - pong echoes the ping payload - client message arrives intact - server close ends the stream - frame size cap is sane - a non-upgrading server is refused - a 500 whose reason phrase contains 101 is refused - a genuine 101 is still accepted - a timeout mid-message does not return a partial message - recv() resumes the message it was in the middle of - a close still ends the stream - the close status code is kept - an endless fragment stream is refused - …and it gives up near the frame cap, not gigabytes later - a close can carry the code the caller means - …and normal closure is still what it does by default
### Web interface
✅ audit — 60 checks Who did what, through which front end — and no secrets in the file. - no secret value survives redaction - harmless fields are kept - redaction keeps the length - an empty secret is marked empty - an unknown secret-looking key is hidden - every entry is written - the front end is recorded - the actor is recorded - the target is recorded - newest first when read back - no empty detail key - no empty target key - the log is capped - the cap drops the OLDEST - an unwritable path is survived - a corrupt file is survived - an unconfigured log is a no-op - a Web UI POST leaves a trace - …as the web, naming the endpoint and its target - …including an endpoint nobody thought about - …while a read leaves none - a button press leaves a trace - …naming the button, not the one-time token - …and the person who pressed it - …while a press from a stranger is not recorded - a Discord command leaves a trace - …as discord, naming the command - every command goes through the one seam - the body is replayed to the handlers - an unattended update is recorded - …as the scheduler, not as a person - …naming the containers it touched - …with the host, since two machines may run the same name - a tick with nothing to update writes nothing - a Telegram command is recorded - …as telegram, by the user who sent it - …naming the command and its target - a command from a stranger is not recorded as an action - plain chat text leaves no entry - …and a command recorded is still a command answered - …and that one is recorded too - a token is recorded by its name, not as a person - …and a signed-in person by their user name - …and the two are told apart by the source - the history page lists the entry - …with the time and the target - …and the section can be found by searching for 'Audit' - the page escapes what it shows - …and shows the redaction marker, never a value - a JSON upload is not parsed as a form - …so no part of the file reaches the log - …and what IS recorded says what arrived - …while a form body is still recorded field by field - a secret in the form never reaches the file - …while the entry is still written - …and says a secret was there - a long value is cut - …and marked as cut, not silently shortened - …while a short one is kept whole - the entry on disk is short too
✅ disabled reasons — 4 checks A disabled button must say why it is disabled (#55, @LeeNX). - the row has buttons disabled by monitor-only - every one of them names the reason - pin explains monitor-only first - the label marker keeps its distance
✅ form nesting — 11 checks No nested
in the Web UI, and every settings field reaches the form. - no opens inside another - Settings: form declares its id and is empty - Settings: every field is attached to the form - Settings: the save button is attached to the form - Settings: the handler reads nothing the page cannot send - Settings: the page sends nothing the handler ignores - Connections: form declares its id and is empty - Connections: every field is attached to the form - Connections: the save button is attached to the form - Connections: the handler reads nothing the page cannot send - Connections: the page sends nothing the handler ignores
✅ icons — 4 checks Every icon key the templates use must exist. - every used icon key is defined - there are icons at all - every glyph is wrapped for sizing - all entries parsed
✅ link render — 61 checks Web UI render test for container repo / changelog links (#52, @LeeNX). - web UI applies prefer_release_url to auto-detected links - web UI leaves an explicit link alone - store javascript: -> no href in the status table - store javascript: -> no 🔗 anchor at all in the table - store javascript: -> no href on the detail page - store javascript: -> no repo row on the detail page - store javascript: -> still visible as form text - store 'data:text/html;base64,PHNj' -> no anchor - store 'vbscript:msgbox' -> no anchor - store '//evil.example/x' -> no anchor - store 'https://exa mple.com' -> no anchor - store 'https://x.example/"onclick' -> no anchor - rejected store value falls through to the OCI label - valid link -> href in the status table - valid link -> target=_blank - valid link -> rel=noopener noreferrer - valid link -> 🔗 as the visible marker - valid link -> exactly one anchor in the row - valid link -> tooltip names the origin - valid link -> href on the detail page - detail anchor carries rel=noopener noreferrer - detail overview row shows the URL as text - no link at all -> no anchor in the row - order: docksentry.link beats store + OCI - order: label origin named in the tooltip - order: store beats the OCI source label - order: OCI source beats OCI url - order: OCI source origin named - order: OCI url used as fallback - registry guess is not linked in the status table - registry guess still resolves for other surfaces - unsafe docksentry.link falls through to the stored link - label set -> input disabled - label set -> save button disabled too - label set -> 🏷 marker on the section - label set -> 🏷 uses the existing web_label_authoritative text - label set -> field shows the EFFECTIVE (label) URL - label set -> the label URL is what gets linked - no label -> input usable - no label -> no 🏷 on the link section - no label -> field shows the stored URL - unsafe label -> form stays usable - /api/link feedback: ?link=saved renders a notice - /api/link feedback: ?link=cleared renders a notice - /api/link feedback: ?link=rejected renders a notice - /api/link feedback: no notice without the query param - restore: valid entry kept - restore: javascript: entry dropped - restore: data: entry dropped - restore: relative path dropped - restore: non-string value dropped - restore: padded valid URL kept, trimmed - restore: padded javascript: still dropped - restore: exactly two entries survive - restore: drop count reported - restore: drop count visible in the toast text - restore: drop also listed under errors - restore: clean bundle keeps everything - restore: clean bundle reports plain 'links' - restore: clean bundle drops nothing - render gate holds independently of the restore filter
✅ link safety — 39 checks Test the shared link validator (#52, @LeeNX). - reject: javascript: - reject: JavaScript: (mixed case) - reject: JAVASCRIPT: (upper) - reject: leading space before scheme - reject: leading tab before scheme - reject: leading newline before scheme - reject: data:text/html - reject: vbscript: - reject: file: - reject: scheme-relative //evil.example - reject: http:// without host - reject: https:/// without host - reject: bare hostname (no scheme) - reject: relative path - reject: 600+ char URL - accept: exactly at length cap - reject: one char over cap - reject: closing paren - reject: opening paren - reject: newline inside URL - reject: carriage return inside URL - reject: embedded space - reject: double quote - reject: single quote - reject: angle brackets - reject: backslash - reject: NUL byte - reject: backtick - reject: empty string - reject: None - reject: non-string - reject: whitespace only - accept: plain https URL - accept: http URL - accept: URL with port - accept: query string with comma - accept: fragment + encoded space - accept: uppercase scheme (case-insensitive) - accept: IP host
✅ metrics api — 27 checks The machine-readable endpoints and the tokens that guard them. - plain text is untouched - a quote is escaped - a backslash is escaped - a newline is removed - empty is safe - None is safe - backslash then quote - tokens are compared in constant time - …and never with a plain == - the right token in a Bearer header is recognised - …and in a query string too, for scrapers that cannot set one - a wrong token is not recognised - …nor one that shares the prefix - …nor one that shares the suffix - …and no token at all is no token - with no tokens configured, none is accepted - each token is reported under its own name - a wrong token is rejected, not passed on - …while the right one is served - …and no token at all is not treated as a wrong one - only the two read paths accept a token - the token check is GET-only - no gauge carries a _total suffix - the gauge is named plainly - gathering does not start a check - every failed-host view names itself - …and the reader never guesses local for it
✅ settings fields render — 40 checks Web UI render test for the five settings that gained a form field. - password value is NOT rendered anywhere in the HTML - web_password field is in the General tab - web_password field is type=password - web_password field renders empty (value="") - web_password field has a placeholder - healthcheck_max_starting field is in the Updates tab - healthcheck field shows the current value - docker_stop_timeout field is in the Updates tab - docker_stop_timeout field shows the current value - pull_timeout field is in the Updates tab - pull_timeout field shows the current value - Monitoring heading is in the Notifications tab - monitor_enabled checkbox is in the Notifications tab - monitor_enabled reflects the config (checked) - monitor_interval_seconds field is in the Notifications tab - monitor_interval shows the current value with a 15s floor - Discord bot token field is on the Connections page - bot token value is NOT rendered on either page - password value is NOT rendered on either page - SMTP password is NOT rendered on either page - no plugin-channel credential is rendered - each credential still offers a way to remove it - and the plain values are shown - SMTP server field shows its value - SMTP port field shows its value - the password field is type=password and renders empty - a saved SMTP password offers a way to remove it - the encryption choice reflects the config - certificate verification is on the page and checked - bot token field is type=password and renders empty - a saved token is signalled by the placeholder, not the value - a saved token offers an explicit way to remove it - application id is on the Connections page, with its value - server id is on the Connections page, with its value - the bot's own channel has a field - …and command visibility is a switch, unchecked here - the allowed-user list is on the Connections page - every Discord field associates with the connections form - password field is not on the Updates tab - monitor fields are not on the Updates tab
### Other
✅ alert backoff — 15 checks A container that keeps failing is reported less and less often. - the first repeat waits the plain half hour - the wait grows - …and stops growing at the cap - …so it never falls silent - the cap is hours, not days - a day of one crash loop is a handful of alerts, not fifty - another container is unaffected - …and so is a different kind on the same one - a long quiet spell starts the count over - the crash gate asks for the grown wait - …and so does every other kind - neither gate uses the flat wait any more - both branches count through the same helper - a crash loop costs a handful of alerts in twelve hours - …with the gap doubling, not flat
✅ api token card — 29 checks The one setting the interface said nothing at all about. - the card splits each entry on the colon - …and renders only the name - …the secret half is never interpolated into the page - …and is only ever tested for emptiness - it is not a form - it says when nothing is configured - it flags an entry with no secret after the colon - it says when the environment is being overruled - …in the empty case too, which is where it matters - …and the notice never leaks the values either - usage is recorded where the token is verified - …only after compare_digest succeeds - …and nowhere else - the page admits a restart clears the record - …in those words - the empty-usage wording does not claim 'never' - every string used by the card exists in English - …and in German - a missing translation falls back to English, not to the key - no string mentions a token value - the audit section has an anchor - …and carries the untranslated word - …as a quieter second label, not as the heading - …and that class is styled - …and is not itself translatable - render: the token value is never in the HTML - render: the token name IS shown - render: a colon-less entry is flagged, not silently accepted - render: says so when nothing is configured
✅ apprise resilience — 28 checks Apprise survives a short outage, and never repeats a refusal (#66). - a send into a dead network is held, not dropped - …and says so in a line that can be grepped for - …keeping the error Apprise already logged - once the API answers again the notification goes out - …exactly once - …marked as late, so it does not read as fresh - …carrying the original alert, intact - a 400 is NOT held for retry - …and the token never reaches the log (400) - …while the status is still named (400) - a 401 is NOT held for retry - …and the token never reaches the log (401) - …while the status is still named (401) - a 404 is NOT held for retry - …and the token never reaches the log (404) - …while the status is still named (404) - a 500 is NOT held for retry - …and the token never reaches the log (500) - …while the status is still named (500) - a 4xx points at the two settings worth checking - a held message holds the alert, not the credential - a DNS failure holds the message - a timeout holds the message - a TimeoutError holds the message - a reset connection holds the message - an unconfigured Apprise holds nothing - the HTTPError clause comes before the network one - …and the clause order decides which statuses are held
✅ auto label rule — 7 checks `docksentry.auto` on the container beats both toggles. - auto=false keeps a container manual under AUTO_UPDATE_ALL - …while an unlabelled one is still updated - auto=true opts in without the Web UI switch - …and auto=false overrides the switch being on - with no label the stored switch decides - …and without it nothing is updated - a label lookup that fails falls back to the switch
✅ backup channels — 80 checks Getting a backup out, and back in, without a browser (#2). - Discord uploads to the interaction's own webhook - …as multipart - …with the JSON body under payload_json - …the file under files[0] - …and the two tied together by an attachments entry - the file keeps its real name and extension - the boundary cannot occur in the payload - …and the body closes properly - a successful upload is parsed - a refused upload raises rather than looking successful - …and is not retried into a duplicate - the untested part is labelled as untested - Discord has the command - …dispatched like the rest - …and it says it already answered - …because an empty answer would post a second message - a dropped backup is recognised - …and nothing is restored yet - …the offer names where it came from - …and what it would overwrite - …with a confirm and a cancel - …the button carries a token, not the bundle - a non-JSON attachment is ignored in silence - an implausibly large file is refused before downloading - …and something that is not JSON says so - …JSON without a schema_version is not a backup - the press restores - …and reports that it applied something - …settings among them - …and a second press finds nothing left - a restart is offered after a restore that applied something - …and not after one that applied nothing - the restart goes through our own shutdown handler - …rather than asking the daemon to stop us mid-reply - …and the answer is sent before the process goes - an unknown settings key is dropped - …and an unsafe link never reaches the store - …while the safe one does - a command is never mistaken for an attachment - the Web UI import goes through the same restore - …and no longer keeps its own copy of it - a container that will come back is restarted - …and the message names the policy - …and the request is recorded for the next boot - a restart policy of 'no' means we stay up - …and 'no' is explained rather than just refused - a restart policy of 'none' means we stay up - …and 'none' is explained rather than just refused - a restart policy of '' means we stay up - …and '' is explained rather than just refused - a daemon that will not answer counts as no - …and so does not knowing which container we are - a requested restart is told apart from an external stop - …and a stale request cannot mask a real one an hour later - …the marker is consumed, not left to fire twice - /restart is reachable without restoring something first - no Telegram command name is declared twice - …nor a Discord one - Discord's restart takes an optional container - …and says what leaving it empty does - …while /stop still requires one - …while /start still requires one - restarting Docksentry from Discord asks the same guard - …rather than growing a second copy of the rule - …and Discord goes down only after its answer is out - a Discord restore points at the restart too - the attachment fetch identifies itself - …with the same agent every other call uses - an HTTP refusal reports its status code - …and a network failure reports what went wrong - …and a file that is not JSON says which part failed - no blank 'could not read that attachment' is left - a declared gigabyte is refused before the body is read - an oversized backup import is refused - …and says how big it may be, in MB - …in the reader's language, not in bytes - a backup just under the cap is not refused for its envelope - …while a file over it still is, envelope or not - …while a real bundle within the limit is accepted - …and the read is bounded by the cap, not by the claim
✅ backup write safety — 13 checks How the local backup bundle is written to disk. - a bundle is actually written - …readable only by its owner - …not group-readable - …and not world-readable - …even under a wide-open umask - concurrent writes do not raise - …and actually ran - no bundle on disk is unparsable - …and none carries two instances' data - …and every one of them is 0600 - …and no temp file is left behind - an unwritable directory reports failure, not a crash - …and leaves no debris
✅ batch host routing — 12 checks Every batch entry runs on the host it belongs to (#2, @famewolf). - a local entry runs on the local checker - a remote entry runs on ITS host's checker - …and never on the local one - an entry without a host key stays local (pre-#7 files) - every entry reports - an unknown host is refused, not run locally - …and the refusal says which host and why - a pull whose inspect failed is not a success - …it says the container was NOT updated - /cleanup walks every managed host - …and hands it every host, not just the local one - …and Discord's /cleanup walks them too
✅ broadcast decoupled — 14 checks The all-channel seam lives in the core, not on a front end (#63). - broadcast.Broadcast.announce exists - telegram_bot's announce is an adapter, not the seam - Discord's /testchannel goes through the seam - …and stops borrowing announce from the Telegram bot - main.py builds the seam once - …gives it to the Telegram bot - …and to every DiscordBot it constructs - both sides get the text - …Telegram as an unattended message - …and the keyboard goes to Telegram alone - a switched-off Telegram is skipped - …and the other channels still hear it - a throwing channel does not swallow the message - the seam works with no Telegram bot at all
✅ changelog decoupled — 13 checks Changelog logic lives in the neutral core, not in a front end (#63). - changelog.fetch exists - changelog.version_key exists - changelog.parse_entries exists - changelog.entry_for exists - telegram_bot no longer defines _parse_changelog_entries - telegram_bot no longer defines _version_key - telegram_bot no longer defines _changelog_entry_for - telegram_bot no longer defines _fetch_changelog - …and calls the core instead - Discord's /changelog calls the core - …and stops borrowing it from the Telegram bot - …and renders the entries instead of join-crashing on them - the render produces a string, not a TypeError
✅ changelog parity — 15 checks Both front ends say the same thing about the changelog (#63). - a newer release is reported as newer - the newest version reports what IT brought - a version not in the file says so, without inventing one - both front ends render the same content - …Telegram gets single asterisks it can parse - …Discord gets its own double asterisks - a GitHub heading becomes bold, not a stray ### - an image embed is dropped rather than shown raw - Telegram asks the core what to say - …and Discord asks the same question - Telegram renders through the shared body renderer - …and so does Discord - Discord handles the 'unknown' case - Discord handles the 'current' case - Discord names its truncation and links the file
✅ changelog versions — 11 checks `/changelog` compares versions correctly, betas included (#63). - a beta is newer than the previous stable - the final release is newer than its own beta - a later beta is newer than an earlier one - a plain stitched version still parses - garbage sorts to the bottom, never crashes - on the newest beta, no entry is reported as newer - …so v2.17.0 is NOT called a new version - from 2.17.0, the betas ahead of it are returned - …and 2.17.0 itself is not among them - a beta heading is found by the exact-entry lookup - …and its body comes back with it
✅ channel backoff — 25 checks "Not now" is not "no" — the same answer from every channel. - ntfy: a 'not now' answer is held for another go - ntfy: …and a refusal is not asked again - gotify: a 'not now' answer is held for another go - gotify: …and a refusal is not asked again - apprise: a 'not now' answer is held for another go - apprise: …and a refusal is not asked again - matrix: a 'not now' answer is held for another go - matrix: …and a refusal is not asked again - discord: a 'not now' answer is held for another go - discord: …and a refusal is not asked again - webhook: a 'not now' answer is held for another go - webhook: …and a refusal is not asked again - the table and TEMPORARY_STATUS agree - the held message is the alert, not an empty entry - a short rate limit is waited out in place - …and the request is tried again after it - an hour-long one is not slept through - …it goes to the queue instead - the whole send waits at most the budget, not that per attempt - …and the budget is spent, so the rest goes to the queue - a hostile Retry-After is not slept on - …and does not raise out of the notifier - …the alert is held rather than lost - all three in-line waits agree on the number - …because they take it from one place, not three literals
✅ channel doors — 41 checks An unattended message goes through the seam, or it goes wrong. - a switched-on Discord channel is announced to - …and a switched-off one is not - …with no exception for replies - a message held for a channel that was switched off is dropped - …while one for a channel still on is delivered - every plugin that queues a message asks again before resending - outside quiet hours: an urgent report reaches the plugins - outside quiet hours: …and Telegram - outside quiet hours: a routine one is marked auto for Telegram - during quiet hours: an urgent report reaches the plugins - during quiet hours: …and Telegram - during quiet hours: a routine one is marked auto for Telegram - routine traffic is still silenced on the plugins at night - the real gate silences routine traffic at night - …and lets the urgent class through - …while by day the routine class goes out as before - a finished container is reported through the seam - …and one built without a seam falls back rather than raising - the mass-stop footer for Telegram mentions the attachment - …and the one for every other channel does not - …in all 16 languages - …and the monitor sends the right one to each - 'window_skipped' goes out through the seam - 'policy_held' goes out through the seam - 'age_held' goes out through the seam - 'major_confirm_prompt' goes out through the seam - …and _say_once is the seam with a memory in front - a public /selfupdate answers on the interaction - …publicly, because the operator asked for public replies - …and not in the configured channel - a private one takes the direct message - …and says nothing on the interaction or in the channel - a DM that will not open falls back to the interaction - …ephemerally, because the request was private - with the channel off a public request still answers the asker - the scheduler's host notice is the seam, not a pair - …and its fallback still reaches the plugins - …and with no seam wired in it still reaches both legs - the bot has an adapter for the urgent door too - all four kept pairs say why they are kept - no hand-written fan-out pair has appeared
✅ channel parity — 20 checks What one front end can do, the other can too (#61, @NotRetarded). - both front ends offer the same number of commands - …and the same ones - every Telegram command has a handler - every Discord command is dispatched - …to a handler that exists - the new commands call helpers that exist - no unattended message goes to Telegram alone - the seam sends to Telegram - …and to every other channel - …and asks whether there are any first - …and a failing channel cannot stop the others - a keyboard is not forced on channels that have none - TelegramBot.announce delegates to the seam - e-mail can carry a backup - …under its real filename - …as JSON, not as an opaque blob - …with the bytes intact - a plain message is unchanged - the advertised command count is the real one - no document advertises a stale command count
✅ channel switch — 32 checks Three states per channel, and a switch that cannot lie. - an untouched channel is not complete - …and names every field it needs - a half-filled channel names only what is still missing - ntfy asks for the topic URL when nothing is set - …and for the topic once a server is set - …and is complete with both - …or with the full URL alone - a complete, switched-on channel is dispatched to - …and counts as a channel - switching it off stops dispatch - …and it stops counting as a channel - …but it is still reported as complete, only off - _smtp_configured follows the switch - a switch cannot activate an incomplete channel - isolating a channel silences the others - …every field of theirs is blanked - …the original config is untouched - …and a switched-off channel can still be tested - …nor are quiet hours allowed to swallow the test - discord declares the fields it owns - webhook declares the fields it owns - discordbot declares the fields it owns - smtp declares the fields it owns - ntfy declares the fields it owns - gotify declares the fields it owns - apprise declares the fields it owns - matrix declares the fields it owns - the summary counts Telegram from its own two variables - send_message checks the Telegram switch - …and so does the command loop - the switch persists across restarts - …while the token itself stays env-only
✅ channel test button — 6 checks "Send test" answers about now, and leaves nothing behind. - with the server up, the test reports success - …and leaves nothing in the retry queue - with the server down, the test does not report success - …and says a real alert would be held and retried - …while still quoting what the server said - …and the test message is not left in the queue
✅ clean restart not a crash — 27 checks A container that finishes cleanly and comes back has not crashed. - a clean exit is recorded, not thrown away - …and still costs no snapshot - a crash-restart with a known exit 0 is dropped - …after the real code has been resolved, not before - an unresolved code becomes None, not 0 - a real snapshot code is not thrown away - an install without the event stream still gets its alert - …and gets a wording that admits it - the no-code wording says the restart happened - …and does not print a number for the exit code - …and says why there is none - ar has the wording, with the name in it - de has the wording, with the name in it - en has the wording, with the name in it - es has the wording, with the name in it - fr has the wording, with the name in it - hi has the wording, with the name in it - it has the wording, with the name in it - ja has the wording, with the name in it - ko has the wording, with the name in it - nl has the wording, with the name in it - pl has the wording, with the name in it - pt has the wording, with the name in it - ru has the wording, with the name in it - tr has the wording, with the name in it - uk has the wording, with the name in it - zh has the wording, with the name in it
✅ cleanup rules — 20 checks What cleanup is allowed to delete, and what it must keep. - the prune carries a grace period - …the one that was configured, not a fixed one - …and an unreadable setting falls back to a day - …while still being an actual prune - a backup older than the window is deleted - …and one inside it is kept - …with the boundary read from the setting - a retention of zero falls back to the default, not to purge - an unreadable retention setting falls back to a week - cleanup before the first backup leaves nothing behind - an unused locally-built image is saved first - …to a file that exists afterwards - an image that lives in a registry is not copied - …nor a dangling build leftover - …nor one a container is still using - with the setting off no copy is taken - …and with it on, a copy is taken and the prune runs - …with the copy BEFORE the prune, not after it - a save that failed is not counted as a backup - a failed backup step does not cancel the prune
✅ cleanup shared — 36 checks /cleanup: one host walk, one wording, and no doubled checkmarks. - all 16 languages are examined - en: an empty cleanup is one ✅, not two - de: an empty cleanup is one ✅, not two - ja: an empty cleanup is one ✅, not two - a successful cleanup carries its own ✅ - …and exactly one - …and the removed-images line is translated - …including the overflow count - …and the size is ours, not docker's - a failed prune carries ❌ and the reason - a raising prune is reported in the reader's language - telegram_bot.py does not sniff the cleanup message for English - discord_bot.py does not sniff the cleanup message for English - web_ui.py does not sniff the cleanup message for English - the Web UI does not prefix its own ✅/❌ either - every managed host is cleaned, not just the local one - …and each answers separately - …tagged with the host it came from - an unreachable host does not stop the walk - …and is reported as the failure it is - a skip is reported, but not as an error - …and says what it says, not a second version of it - a host with no backend says so - Telegram /cleanup calls the core - …and keeps no per-host loop of its own - Discord /cleanup calls the core - …and no longer stops at the local host - cleanup_backed_up exists in every language - cleanup_backed_up keeps its placeholders - cleanup_removed exists in every language - cleanup_removed keeps its placeholders - cleanup_more exists in every language - cleanup_more keeps its placeholders - cleanup_error exists in every language - cleanup_error keeps its placeholders - cleanup_images writes no English of its own
✅ client disconnect — 11 checks A browser hanging up is not an error report. - the reset from #58 prints nothing at all - under DEBUG it prints one line, not a traceback - …that names the peer - …and says it is harmless - ConnectionAbortedError is quiet too - BrokenPipeError is quiet too - TimeoutError is quiet too - a real exception still prints its traceback - …and names itself - …even though DEBUG is off - an unrelated OSError is not mistaken for a disconnect
✅ command host coverage — 48 checks Every container-touching Telegram command reaches every host (#2). - /status exists in the dispatcher - /status routes by host, not local-only - /check exists in the dispatcher - /check routes by host, not local-only - /update exists in the dispatcher - /update routes by host, not local-only - /updates exists in the dispatcher - /updates routes by host, not local-only - /updateall exists in the dispatcher - /updateall routes by host, not local-only - /cleanup exists in the dispatcher - /cleanup routes by host, not local-only - /checkimages exists in the dispatcher - /checkimages routes by host, not local-only - /logs exists in the dispatcher - /logs routes by host, not local-only - /audit exists in the dispatcher - /audit routes by host, not local-only - /pin exists in the dispatcher - /pin routes by host, not local-only - /unpin exists in the dispatcher - /unpin routes by host, not local-only - /autoupdate exists in the dispatcher - /autoupdate routes by host, not local-only - /cooldown exists in the dispatcher - /cooldown routes by host, not local-only - /protect exists in the dispatcher - /protect routes by host, not local-only - /note exists in the dispatcher - /note routes by host, not local-only - /trustrunning exists in the dispatcher - /trustrunning routes by host, not local-only - /askmajor exists in the dispatcher - /askmajor routes by host, not local-only - /setlink exists in the dispatcher - /setlink routes by host, not local-only - /groups exists in the dispatcher - /groups routes by host, not local-only - /stop exists in the dispatcher - /stop routes by host, not local-only - /logs works through a per-host backend - /logs reaches that host, not the bot's own - /logs does not act through self.backend - /audit works through a per-host backend - /audit reaches that host, not the bot's own - /audit does not act through self.backend - /logs-style sweep finds a remote-only container - …and a local-only container resolves on local only
✅ compose clash vs mounted — 20 checks Refusing a mount and having one already are not the same answer. - the refusal is measured against our data directory - …and no longer against every mount we hold - …including a mount that merely contains it - a mount we already have is a different finding - …and it names the source to go and check - …and the one that would work, when the daemon knows it - …only when it differs from what they have - …and says so when the daemon names nobody - …including when it names our own mount back at us - the refusal still comes first - the refusal explains what it would hide - the correction does not send anyone to DATA_DIR - …it points at the source instead - the dead end says the file was made somewhere else - every language carries it - the chats answer it through the shared helper - …and so does the update note - the page reads the holder from the shared helper - …and so does /audit - …and neither keeps its own copy of the manager list
✅ compose fallback precise — 33 checks The Compose-fallback note names what was lost, or stays quiet (#65). - an ordinary container produces no note - …and an empty inspect dict does not blow up - …nor does None - a CMD-SHELL healthcheck round-trips, so no note - an exec-form healthcheck is reported - …and it is the only thing reported - a healthcheck inherited from the image is not a loss - …but the same check with a changed interval is - a sub-second interval is reported - …and a whole-second one is not - timings with no test are reported - an explicit NONE is carried, so no note - no image config means the healthcheck is left alone - blkio_config is reported under its compose name - …and so is the IOps half of it - a plain blkio_weight is carried and stays quiet - cgroup_parent is reported - device_cgroup_rules is reported - storage_opt is reported - publish-all is reported - …and PublishAllPorts=false is not - a long-form tmpfs mount is reported as tmpfs - the short tmpfs form is carried and stays quiet - an unknown mount type names itself - ExposedPorts alone produces no note - every set field is named, each once - the order is fixed, so the message reads the same twice - the unreachable-file note is gated on a real loss - the remote-host fallback got the same note - a container with no config_files label got it too - no losses means an empty note, not a message - the new keys exist in every language - the rendered note names the fields
✅ compose note knows mounts — 14 checks «Mount it» is the wrong thing to say to somebody who did. - nothing mounted is nothing found - a mount above the path is found - …and the closest one wins - a mount beside it is not a cover - a name that merely starts the same is not a cover - the file mounted on its own counts - a mount without a destination is ignored - the note asks what we already mount - …and says so instead of 'mount it' - our own mounts are asked once, not per update - the message exists - …names the file, the source and where it is mounted - …and points at the source rather than repeating the advice - every language carries it
✅ compose paths — 33 checks A compose path that belongs to a stack manager says so (#2, #65). - recognises Portainer in compose - …and points at /data/compose - recognises Dockge or Dockhand in data - …and points at /app/data/stacks - no guess for '/home/leet/stacks/app/compose.yml' - no guess for 'compose.yml' - no guess for '/opt/stacks/media/compose.yml' - no guess for '/opt/dockers/cft-base/x/compose.yml' - no guess for '' - no guess for None - one mount covers every stack - the manager wins over the bystander mounted just as deep - …whichever order the daemon lists them in - the deeper mount wins over the shallower one - a named volume answers with the volume, not a path - nothing covering the path is not a holder - …and neither is an empty daemon - two managers at the same depth is refused, not guessed - …and the caller keeps that apart from an absence - [en] the message names the manager - [en] …and the path to mount at - [en] …and is not the untranslated key - [de] the message names the manager - [de] …and the path to mount at - [de] …and is not the untranslated key - [ja] the message names the manager - [ja] …and the path to mount at - [ja] …and is not the untranslated key - [ar] the message names the manager - [ar] …and the path to mount at - [ar] …and is not the untranslated key - a recognised path gets the specific message - …and an unrecognised one keeps the general one
✅ compose reach view — 27 checks The detail page says where the compose file is, and whether we can open it. - an absolute path that exists reads as reachable - a missing file reads as not reachable - the path is shown even when it cannot be opened - a relative label is resolved, not given up on - a two-file label is split, and both count - one missing file is enough to be unreachable - no label at all says nothing rather than guessing - it asks the update path's own resolver - a recognised manager gets its root, not one mount per stack - anything else gets the file's own directory - a plain project is mounted onto itself, not its parent - two files in one directory make one mount, not two - two directories make two mounts - a named volume is named, not turned into a directory - a manager we have never heard of still resolves - a host path resolves to nothing, so the self-mount stands - a mixed label keeps the line it does know - …and admits it does not know the other - two equally deep mounts and no manager: say nothing - the manager breaks the tie against a plain container - no mount information at all is not a guess - a mount that would shadow our own data is not offered - …and it says why, instead of going quiet - without that collision the line is still offered - a mount we already have is not mistaken for our data - …it says the source is the wrong one - …and names the one that would work
✅ compose recreate budget — 10 checks A compose recreate waits long enough for the grace it grants (#2). - the compose path is still where it was - the compose recreate grants the stop grace - …and forces the container to be replaced - …without dragging its dependencies along - …and the grace is docker_stop_timeout - …with a default rather than a crash when it is unset - the wait around the recreate contains the grace - …and leaves the working budget the path had before - …and no call there waits a flat 120 any more - the standalone stop still sizes its own wait
✅ compose relative config — 11 checks A relative `config_files` label resolves against `working_dir` (#65). - _compose_files can be told the working directory - a bare filename resolves against working_dir - …and the file is then actually found - comma-joined relative files resolve too - an absolute path ignores working_dir - absolute comma-joined paths are unchanged - no working_dir leaves a relative path alone - an empty label is still nothing - a comma inside a filename is not split blindly - …and a split that does not resolve is left whole - the compose update hands its working_dir over
✅ config damage — 47 checks A damaged settings.json must not be able to keep the instance down. - truncated: the process still starts - truncated: …and not with UnboundLocalError - truncated: the damaged file is kept, not deleted - truncated: …and moved out of the way - truncated: …with its contents intact - truncated: the operator is told - empty: the process still starts - empty: …and not with UnboundLocalError - empty: the damaged file is kept, not deleted - empty: …and moved out of the way - empty: …with its contents intact - empty: the operator is told - garbage: the process still starts - garbage: …and not with UnboundLocalError - garbage: the damaged file is kept, not deleted - garbage: …and moved out of the way - garbage: …with its contents intact - garbage: the operator is told - a list: the process still starts - a list: …and not with UnboundLocalError - a list: the damaged file is kept, not deleted - a list: …and moved out of the way - a list: …with its contents intact - a list: the operator is told - a bare string: the process still starts - a bare string: …and not with UnboundLocalError - a bare string: the damaged file is kept, not deleted - a bare string: …and moved out of the way - a bare string: …with its contents intact - a bare string: the operator is told - null: the process still starts - null: …and not with UnboundLocalError - null: the damaged file is kept, not deleted - null: …and moved out of the way - null: …with its contents intact - null: the operator is told - a healthy file is read, not quarantined - …and its values are applied - …quietly - a missing file is not damage - an empty object is a legitimate file - repeated damage does not raise - three failures in one second keep three files - …and all three are different - an unreadable file does not stop the start - …and is left exactly where it is - …and is still reported
✅ container flags — 28 checks What the flag commands DO, decided once (#63). - pinning answers with the added key - …and writes it - pinning twice says it is already pinned - …and writes nothing the second time - a toggle reports on - …and off the second time - a stale pin can be lifted after the container is gone - …and unpinning something unpinned says so - list mode returns the names, sorted - …and an empty one says so - a refused host is answered, not raised - …and nothing is written - a cooldown over the limit is answered as the clamped value - zero clears it - no value shows the current one - a value that will not parse is fatal - …and writes nothing anywhere, before touching a host - a note is set - …and cleared by an empty one - no host named means the local one - a named host is the only target - @all reaches every host - an unknown host is fatal, and names itself - a single-host install walks the pseudo-host - …which is what keeps its replies untagged - pinning on one host does not touch the other - …and the reply carries which host it was - every key the core names exists
✅ container info decoupled — 11 checks Container facts live in the neutral core, not in a front end (#63). - container_info.state exists - container_info.stats exists - container_info.disk_facts exists - telegram_bot no longer defines _container_state - telegram_bot no longer defines _container_stats - telegram_bot no longer defines _disk_facts - …and calls the core instead - Discord calls the core - …and stops borrowing it from the Telegram bot - state() returns a dict from a backend it was handed - …with no dependency on any bot instance
✅ container scope — 16 checks What Docksentry is allowed to touch — driven, not mirrored. - without a switch set, every container is in scope - …except Docksentry itself, which the update path must not touch - …though a reader asking for it does see it - EXCLUDE_CONTAINERS keeps that container out - …and leaves the others alone - …and a glob excludes the whole family - …and a list excludes each of them - a pinned container is not in scope at all - …and unpinning it brings it back - docksentry.pin=true does the same from a compose file - …and docksentry.pin=false does not pin anything - docksentry.enable=false takes a container out of scope - docksentry.exclude=true does too - …and enable=true leaves it in - three different switches together leave nothing in scope - a host whose `ps` failed is not reported as empty
✅ data dir default — 11 checks A fresh install stops claiming /data; an existing one keeps it. - our own files in /data decide it — that install keeps working - our own files in the new place decide it too - a stranger's volume at /data is not our database - an old install booting empty for the first time keeps /data - …even though the image always mounts the new path too - a fresh install with the new mount takes the new place - nothing anywhere: the fresh default - the fresh default is not /data - a failing stat falls forward, it does not crash the boot - DATA_DIR still overrides the default - the image no longer reserves /data
✅ dead host memory — 15 checks A host that just failed is not asked again on the next page load. - an unknown host is not being skipped - a failed host is skipped for a while - …and the wait is bounded, not forever - …and it quotes the reason it was given - a different host is unaffected - a host that answers is forgotten immediately - repeated failures are asked about less often - …but the wait stops growing - one success puts it back to the short wait - once the wait is over it is asked again - a nameless host is not remembered - a config reload can drop the whole memory - the page asks the memory before it probes - …and clears it when the host answers - …and records the failure it just had
✅ debt closers — 25 checks The last promises the suite could not see. - a container the kernel killed is marked as OOM - …while the same exit code without the flag is not - …and the exit code survives either way - an OOM death is reported as an OOM - …and an ordinary one as an exit - a registry nobody listed is asked over https - …and a listed one over http - …with an empty list meaning nothing is insecure - …and a wildcard entry matching the way the docs say - the checker passes the operator's list, not an empty one - a policy-blocked bump is not applied unattended - …while an allowed one is - a 1.x → 2.0 bump is detected as major - …while a minor bump is not - …nor a patch - …nor the same version again - …and the two tags come back with the verdict - a non-semver tag is not guessed at - a major bump waits for confirmation - …while a minor bump on the same container goes through - …and a major bump nobody asked to confirm goes through - …and a manual update is never held for confirmation - an image too fresh to trust is held back - …and one old enough goes through - …and the two gates are independent of each other
✅ dependent failure report — 17 checks A failed dependent has to say why, and has to be visible (#2, @famewolf). - the failing container is named - …and the REASON is in the message, not only the log - …so the message is actionable on its own - the first line reports the failure, not the successes - …marked so it cannot be skimmed past - …and the successes come after, not before - …on their own line - the old 'N ok (…), failed N (…)' shape is gone - an all-good run still reads as one cheerful line - every failure gets its own line - …and the survivors are still listed - a plain restart failure also carries its reason - kill/rm no longer time out at a hard-coded 15s - …it follows DOCKER_STOP_TIMEOUT - …with a floor, so a tiny setting cannot make it worse - …and survives a missing or zero setting - no lifecycle call is left on the old constant
✅ engine init — 17 checks A real UpdateEngine, built the way main.py builds one. - __init__ sets _update_lock - __init__ sets _update_queue - __init__ sets _update_queue_lock - __init__ sets _queued_selfupdate - __init__ sets _swap_in_flight - __init__ sets notifier - …and the lock starts free - …the queue starts empty - …nothing is mid-swap on a fresh engine - the queue still queues - …and hands entries back - …and drops them by predicate - no constructor code is stranded after a return - a self-update that fails still frees the lock - …and so does the scheduled one - no release is guarded by a bare attribute read - …they read it defensively instead
✅ entrypoint survives update — 15 checks An image that changes its ENTRYPOINT must not brick the update. - a container matching its own image is not an override - …so the new image's entrypoint is what runs - a genuine override is kept - …with the rest of its tokens after the image - an entrypoint is never emitted without its arguments - an unknown old image emits no entrypoint at all - the swap script keeps the tokens after the image - …and adds nothing when there are none - a container stuck on the shim path is let go of it - …without touching the caller's own dict - …but an image that asks for it keeps it - a real override is left alone - the swap does not throw away the tail any more - the image's entrypoint runs main.py directly - …and why it is not an init is written down
✅ error human — 24 checks A timeout says how long we waited, not what we typed. - a timeout says how long we waited - …and does not print the command back at the reader - …nor the quoting Python produces for a list - a whole-number timeout has no decimal tail - …but a real fraction keeps it - trimming the tail does not eat the number - anything else is passed through - …and still clipped when it is long - ar words it in its own language - de words it in its own language - en words it in its own language - es words it in its own language - fr words it in its own language - hi words it in its own language - it words it in its own language - ja words it in its own language - ko words it in its own language - nl words it in its own language - pl words it in its own language - pt words it in its own language - ru words it in its own language - tr words it in its own language - uk words it in its own language - zh words it in its own language
✅ flag command parity — 34 checks /trustrunning, /askmajor and /cooldown behave the same in both chats. - trustrunning: empty list says so - trustrunning: a partial name resolves - trustrunning: turning it on says on - trustrunning: …and it is recorded - trustrunning: toggling again says off - trustrunning: a non-empty list is listed - trustrunning: an unknown container is an error, not a write - askmajor: empty list says so - askmajor: a partial name resolves - askmajor: turning it on says on - askmajor: …and it is recorded - askmajor: toggling again says off - askmajor: a non-empty list is listed - askmajor: an unknown container is an error, not a write - cooldown: nothing set → empty - cooldown: setting it reports the value - cooldown: no seconds shows the current value - cooldown: listing works with no container named - …and each entry carries its value - cooldown: an over-large value is answered with the clamp - cooldown: zero clears it - cooldown: a bad value is fatal, before any host is touched - …and nothing was written on any host - cooldown: @all writes on every host - …and each reply knows which host it is from - a bare name has no detail - a pair carries one - Telegram /trustrunning goes through the core - Telegram /trustrunning keeps no logic of its own - Telegram /askmajor goes through the core - Telegram /askmajor keeps no logic of its own - Telegram /cooldown goes through the core - Telegram /cooldown keeps no logic of its own - Discord /cooldown can list too
✅ gluetun docs — 24 checks The VPN-sidecar documentation says what the code actually does. - the docs cover the VPN sidecar case at all - …by name, so searching for gluetun finds it - …and the README links to it - doc: the first container in the group is the head - code: agrees — head is members[0] - doc: says the restart-dependents tick is required - code: agrees — the flag gates the cascade - doc: says netns dependents are recreated, not restarted - code: agrees — recreate for netns, restart otherwise - doc: explains WHY (the head's ID dies, the name survives) - code: agrees — rejoins by name - doc: says the rebuild keeps the same image - doc: says the old container is kept until the new one is up - code: agrees — renames to _old and rolls back - doc: mentions --rm containers are handled - code: agrees — AutoRemove is handled - doc: says an unhealthy head does not stop the repair - code: agrees — warns and continues - doc: describes the wait as the group's own, at least 30s - code: agrees — max(group wait, 30) - doc: says membership is detected per update, not stored - code: agrees — inspects each dependent at repair time - doc: mentions the manual restart-dependents button - …and that button exists
✅ gotify resilience — 24 checks Gotify survives a short outage, and never repeats a refusal (#66). - a push into a dead network is held, not dropped - …and says so in a line that can be grepped for - …keeping the error Gotify already logged - once the server answers again the push goes out - …exactly once - …marked as late, so it does not read as fresh - …carrying the original alert, intact - a 401 is NOT held for retry - …and still names the application token (401) - a 403 is NOT held for retry - …and still names the application token (403) - a 400 is NOT held for retry - …and still logs the status and body (400) - a 404 is NOT held for retry - …and still logs the status and body (404) - a 500 is NOT held for retry - …and still logs the status and body (500) - a DNS failure holds the message - a timeout holds the message - a TimeoutError holds the message - a refused connection holds the message - a Gotify without a token holds nothing - the HTTPError clause comes before the network one - …and the clause order decides which statuses are held
✅ gpu recreate — 26 checks A recreated container keeps its GPU (#62's neighbour). - --gpus all round-trips byte for byte - …also when the driver field is empty - a count survives as a count - one device needs no quoting - a device list is a quoted CSV field - extra capabilities are quoted and keep gpu first - a non-nvidia driver is named - no DeviceRequests, no flag - a malformed entry produces nothing rather than garbage - two requests are not squeezed into one flag - the recreate carries the flag - end to end: the rebuilt run has the GPU - …and still the rest of its config - DeviceRequests is honoured now - …and out of the silent-skip list - a set-but-skipped field is reported, not silent - …all of them - an unset skip-field is not noise - Telegram's /audit shows it - …and Discord's - a stock container audits clean - derived path lists are never reported - an explicit cgroupns host survives the recreate - …and the default mode stays flagless - a real skipped field is still reported - the issue-plea is gated on unknown findings
✅ group rules — 15 checks The three group rules nothing was holding. - moving a container to another group takes it out of the first - …and it is in the new one - …while the other members stay where they were - a group emptied by the move disappears - a wait inside the range is kept - …one that is absurd is capped - …a negative one becomes zero, not a hang - …and something unreadable falls back to thirty seconds - the batch pauses between containers of one group - …for as long as the group asks - …and not at all when it asks for nothing - …and both members are updated either way - a dependent cascade never waits less than thirty seconds - …and a longer wait than that is kept - …including the cascade after a head rollback
✅ guard mutations — 179 checks Does the suite actually hold the promises, or only run near them? - queued work is picked up even when nobody drains it - the sweep leaves the queue alone while somebody holds the lock - a queue entry remembers when it was queued - a drained entry forgets its timestamp - the 'already queued' answer says how long - a tag that disagrees with version.py does not build - the outer page frame filters the language attribute too - a backup bundle is written only its owner can read - a damaged settings file does not take the start down - a settings file of the wrong shape is damage, not a crash - a damaged settings file is kept, not overwritten - two quarantines in one second do not overwrite each other - a value cannot close the page's script block - an unknown language code is never rendered - a stored link is checked before the V2 page gets it - two writers cannot publish a mixture of their writes - a failed write removes its own temp file - an unreadable pin list means protected, not unprotected - an unreadable ask-before-major list means ask - an unreadable protect-stop list means protected - a file that exists but will not parse is reported as unreadable - concurrent pins are serialised and none is lost - the alert is logged before the channels are tried - the mail subject says which container and what happened - a mail the server took is not delivered a third time - a connection that dies before the server says 'go ahead' is retried free - a greylisting relay's 'come back later' is come back to - an outage during the TLS handshake holds the alert - a server that refuses outright is not asked again every 30s - a stopped daemon is not reported as a permission problem - an ssh key refusal is not reported as a socket permission - containers that finished mid-check do not condemn the host - a socket that refuses us blocks the host - the phrase for 'not TLS' does not produce a TLS finding - a TLS port is named as one, not called unreachable - …and the port is asked rather than guessed from the words - …and a refusal is not made to wait for a handshake - /hosts check reaches the pre-flight in Telegram - …and the Discord option reaches it too - EXCLUDE_CONTAINERS keeps a container out of every run - a pinned container is never listed and never updated - docksentry.pin on the container does the same - docksentry.enable=false takes a container out of scope - the update path never touches Docksentry's own container - quiet hours hold routine notifications back - …and a window across midnight really is two intervals - …but an urgent alert gets out of them anyway - maintenance mode holds routine notifications back too - the SMTP password is never sent to an unverified server - a rate-limited alert is held, not thrown away - …and a refused one is not asked again every 30 seconds - the ssh hint is spoken in the reader's language - …and the scheduled check asks for it that way too - a container that is merely slow is not rolled back - …and a crash loop is not mistaken for a slow start - docksentry.auto=false keeps a container manual under AUTO_UPDATE_ALL - a major version bump waits for confirmation - an image too fresh to trust is held back - secrets are redacted before anything is written to the audit log - an OOM kill is recognised as one - a held notification is not kept past its usefulness - only the registries named in the setting are treated as insecure - a failed pull stops the self-update before the swap - …and an identical image is not swapped onto itself - a hostile container name cannot reach the self-update shell - …and the boot report that names a failed recreate stays live - …and `/selfupdate previous` finds the release below this one - the disk warning waits the documented twenty-three hours - the batch really pauses between a group's containers - a group member whose update raised still aborts the group - a new password ends the old password's sessions - settings.json is not left world-readable - the settings page does not print the bot token - cleanup only prunes images idle for the grace period - …and only deletes backups older than the retention window - …and a locally-built image is copied before it is pruned - …while an image that can be pulled again is not copied - a Web UI POST leaves a trace - …and so does a Discord command - …and so does a Telegram button press - a Telegram command leaves a trace - a token is recorded as a token, not as a person - the history page shows the trail - a logged value is a pointer, not a copy - a server asking for an hour does not get it - …and the wait budget is for the send, not for each attempt - …and a negative Retry-After never reaches sleep() - …and reads at most the cap, not the length the caller claims - the Web door asks whether the file is a backup at all - only the person who dropped a bundle may restore it - a refused press leaves the owner's buttons alone - …and cancelling one offer does not forget another - …and an expired offer is swept out of memory - the backup cap weighs the file, not the envelope around it - a body that is not a form is not read as one - apprise URLs are a secret like their four neighbours - …and ssh refusing its own config is a refusal, not silence - …and one that looked healthy before the wait ran out is accepted - a locally-built image is copied BEFORE the prune, not after - a major version bump is detected, not just acted on - a backup dropped on Discord has the same size ceiling - …and the same question of whether it is a backup at all - a STOPped message is never logged as delivered - a host nobody measured is not called usable - a docker cli talking to podman is not reported as docker - the pre-flight does not promise what it never tested - a capitalised or unknown SMTP_TLS never means plaintext - one message cannot make the retry queue grow past its cap - a slow pre-flight does not stop the Telegram bot answering - a failed update rolls back to the old container - …and never destroys the container when there is no backup - …removing the broken one forcibly, so a wedged container cannot block it - …and a restored-but-not-started container is not called a success - the Web UI rejects a POST that did not come from its own page - …including one with no Origin and no Referer at all - …and the login form is checked too - a maintenance window holds an auto-update back - …and its weekday list is not ignored - …and the small hours belong to the day the window opened on - the Web UI asks for a password when one is set - …and reads it fresh, so setting one takes effect at once - the weekly report fires on its day and hour, not every tick - …and only once a day - …and not at all when it is switched off - a Web UI that stopped listening reports the container unhealthy - …and a webhook-only install is not reported unhealthy forever - a --rm sidecar's config is read before it is stopped, not after - the session cookie cannot be read by a script - …and is not sent on a cross-site request - an API token is compared in constant time - …and not by its prefix - a compose recreate waits longer than the grace it grants - a failed pull reports the end of what the tool said - …and the time a pull may take comes from the configuration - …and still grants it - the bot forwards the policy question to the engine - tapping the same update twice does not produce a bogus warning - …which needs the single-update path to say what it is doing - …and finished work is not called a mistake - a server that answers after the body is believed over a guess - …and mixed recipient codes follow the recoverable one - who refused decides, not which scheme was typed - …and a daemon refusal over ssh is still the socket's - a very long endpoint does not split the two chats - an empty SMTP_TLS means STARTTLS, as it does everywhere else - a weekday list that cannot be read does not mean every day - a revoked API token is rejected, not passed to the password check - the Web UI's session cookie keeps its path and its lifetime - a scheme that is not Basic does not authenticate - a sidecar rejoins the head's current container, not its dead one - the interface does not claim STARTTLS for a value that is not valid - a test send leaves nothing in the retry queue - …and says a real alert would have been held, not just 'failed' - the certificate warning names what is actually at risk - /metrics reports that the program is up - …and counts the containers it is watching - …and the updates that are pending - …and says which hosts answered - …as Prometheus text, not as a web page - the JSON endpoint reports the same numbers - …under the names it documents - a container name cannot break the metrics format - a self-update clears the old backup before it starts - …stops itself with the configured grace, not Docker's ten seconds - …restarts the container it still has if the rename fails - …rolls back if the new container will not run - …and removes the backup only once the new one is up - the next boot reports a self-update that rolled back - an unattended update leaves a record too - a declared gigabyte is refused before the body is read - the disk warning fires at the configured threshold - …and says it once a day, not once a tick - …and the settings page cannot store a threshold that never fires - a group pauses between its containers - …and a dependent restart never waits less than thirty seconds - a container belongs to one group, not two - …and a group's wait cannot be absurd - the unguarded list is still 0 long, not longer - a change no check can see comes back green
✅ hardkill report — 11 checks A force-killed container is reported, and compose gets the same grace (#62, @NotRetarded). - exit_code reads the int - exit_code 143 reads 143 - exit_code '' → None - stop 137 → ok - stop 137 → says force-killed with the code - stop 0 → plain 'stopped' - stop 143 (clean SIGTERM) → plain 'stopped' - standalone stop uses max(StopTimeout, floor) = 60s - recreate result appends the force-kill note - compose recreate passes --timeout with the stop grace - compose stop_grace comes from docker_stop_timeout
✅ health outcomes — 17 checks The four answers the health wait can give, produced rather than injected. - a container that stays 'starting' is reported as starting - …which the rollback path does not act on - …and the state it was last seen in comes back with it - a healthy container is reported healthy - …and so is one with no healthcheck that keeps running - a container that dies inside the stability window is not healthy - an unhealthy container is reported unhealthy - …and so is one that is not running at all - with trust_running set, unhealthy-but-up is accepted - …but a stopped container is still a failure - a climbing restart count is a crash loop - …even while it looks healthy - …and trust_running does not forgive one - a count that was already high is not a crash loop - slow-then-healthy is accepted - …and so is one that was healthy when the wait ran out - …while one that never looked healthy is still 'starting'
✅ health rollback wiring — 9 checks The decision to roll back, not the rolling back. - a container that came up unhealthy is rolled back - …and the update is not reported as successful (unhealthy) - a container that came up crashloop is rolled back - …and the update is not reported as successful (crashloop) - a healthy container is NOT rolled back - …and its update is reported as successful - an outcome of '' is not treated as a failure - an outcome of 'starting' is not treated as a failure - an outcome of 'none' is not treated as a failure
✅ host views parallel — 14 checks Managed hosts are asked side by side, and asked once. - three hosts that take 0.3s each do not take 0.9s - …and it really did wait for all of them - every host is in the answer - each host is asked for its containers exactly once - …and the ids it answered are handed to the lister - the slowest host still comes first if it is configured first - a dead endpoint does not take the page with it - …it becomes a row that says which host and why - …and the live hosts either side are unaffected - a host still in its cooldown is not asked again - …and its row says how long until the retry - a failure is remembered, and counted - a host that answers clears its failure memory - the lister still fetches ids when it is not given any
✅ hostcheck — 93 checks The step before adding a host, which did not exist (#7, @LeeNX). - an endpoint we do not speak is named as such - …without asking the network anything - …and it is not usable - an endpoint nobody answers is unreachable - …and says what the CLI said - a transport that raises is reported, not propagated - a failure gets the ssh hint attached - …and the hint travels with the finding - …and a working host is not asked for one - a healthy host answers every question - …naming the engine and version that answered - …and how many containers it saw - …and it is usable - podman is recognised from what it calls itself - …and context:// is an endpoint we speak - an engine that will not name itself is not guessed at - a socket proxy that only lists is caught - …and is not called usable - …with the server's own words - a daemon that refuses to list stops at that - a host with no containers says the inspect right is untested - …and is not blocked for it - …and is not called ready either - a socket that answers and refuses is not called unreachable - …and a refused socket is not usable - …while a timeout stays unreachable - 'Cannot connect to the Docker daemo…' is unreachable - 'stat /var/run/docker.sock: no such…' is unreachable - 'Cannot connect to Podman. Please v…' is denied - 'ssh: connect to host nas port 22: …' is unreachable - 'Got permission denied while trying…' is denied - ssh + permission denied while tryi… is denied - ssh + bob@nas: Permission denied (… is denied_ssh - context + ssh: Permission denied (publ… is denied_ssh - unix + permission denied while tryi… is denied - an ssh key refusal is its own finding, not the socket's - …and still blocks the host - a container that disappeared does not condemn the host - …and the next one is the one reported - three for three gone is not a socket proxy - …and does not block the host - …and gets the verdict instead of silence - …while a refusal is not asked a second time - a host with no containers gets a verdict of its own - …and a host that already said 'ready' needs no second one - …and a refused one gets no verdict at all - the scheme advice offers nothing that cannot work in here - …though both are still accepted if somebody has one - a pre-flight only reads - Telegram renders the findings, not a placeholder - …and Discord says the same thing about the same endpoint - the ssh refusal talks about a key, not about a group - …and Discord says the same about it - the verdict reaches the reader in both chats - …including when the endpoint is one we do not speak - /hosts check reaches the pre-flight in Telegram - a slow endpoint does not block the listening thread - …and the answer still arrives afterwards - …and without an endpoint it says how to use it - …and the option reaches it in Discord - …while /hosts without it is still the host list - …and Discord registers the option so it can be typed - …as a string option that cannot break the registration - an empty finding list is not 'usable' - a docker cli talking to podman is reported as podman - every kind EMITTED names can actually be produced - a daemon that says we spoke HTTP to HTTPS is understood - …and a CLI that says nothing useful is answered by the port - …naming the endpoint it was asked about - the words for 'this is not TLS' are not read as TLS - a name that will not resolve is not probed - …nor a socket that answered and said no - a TLS error of its own (x509: certificate …) still lands here - …and the wording for x509: certificate … blames no cause - a TLS error of its own (remote error: tls:…) still lands here - …and the wording for remote error: tls:… blames no cause - a TLS error of its own (tls: certificate r…) still lands here - …and the wording for tls: certificate r… blames no cause - a port that does not speak TLS is not blamed on TLS - …and a refusal stays a refusal - a TLS-only host is not reported as usable - nothing listening does not look like TLS - …and a non-tcp endpoint is never probed - …nor an endpoint with no port to probe - …and `usable` is a verdict, not something preflight emits - every finding has wording in all 16 languages - a 4000-character endpoint does not split the two chats - …and the findings survive in both - …because the endpoint itself is bounded - 'ready' does not promise the host can be managed - …and says that write permission was not tested - 'usable' says inspect and write stayed untested - the ssh refusal points at a key, the socket one does not
✅ import hygiene — 12 checks Every module a function uses is one it can actually see (#2). - every module used is one the code can see - …and this check would notice if one were missing - Discord's /restore imports the json it uses - a host dropping out of the scheduled run is reported - …naming the host and what went wrong - …once, not every night - …and again when it comes back - …which is also said once - a host that never failed says nothing - the report goes to every channel - …and the seam itself reaches both, the plugins urgently - …and a failure to report cannot stop the check
✅ leftover backups — 11 checks `_old` containers piled up, and nothing ever removed them. - no backup removal is left unforced - and there are several of them, all forced - recovery removes the backup once the swap is proven done - …and only in that branch - …guarded on the backup actually existing - the page lists leftovers by name - …only when the live container is there as well - …found among all containers, not just the running ones - …matched within one host, not across them - …and offers a command rather than removing anything - nothing in the Web UI removes them for you
✅ leftover not during update — 6 checks The rollback copy of a running update is not called litter. - a leftover is only counted when its live twin exists - …and not while that container is being updated - the busy set comes from the engine, not a second bookkeeping - the badge reads the same place - only the exact _old suffix counts - Docksentry still does not delete it itself
✅ lifecycle core — 49 checks stop / start / restart is one implementation, not one per chat. - stop answers with the stopped key - …and actually stopped it - …and counts as a change - start shells out to `start` - …and says started - restart is graceful, with a generous timeout - …and says restarted - a partial name resolves - an ambiguous partial refuses instead of guessing - refusal 1: it will not stop itself - …and did not stop anything - refusal 1 covers restart too - …but not start — that cannot kill PID 1 - refusal 2: nothing runs while an update does - …and it is the wording that explains what to do next - refusal 3: a protected container is not stopped - …but restart stays allowed on it - the self refusal is checked before the busy one - a docksentry.protect label protects on its own - …and an explicit false label lifts the toggle - a failing inspect falls back to the toggle, not to unprotected - a failed stop says so - …and carries the reason - …and is not counted as a change - a failed start carries the daemon's own words - an unknown container is fatal, not a per-host error - no argument gets the usage line - a glob leads with a header - …that counts the matches - …and acts on each one - …rendered as one message, not one per container - `_old` leftovers never match a glob - a glob does not exempt the self guard - …nor the protect guard - a glob matching nothing says so once - a plain name is not treated as a pattern - a container living on one host is found there - …and the host that does not have it stays quiet - a container on no host is one error, not one per host - Telegram has no _lifecycle_action of its own - Discord has no _lifecycle_action of its own - Telegram's lifecycle branch calls the core - Discord's lifecycle path calls the core - the status button calls it too - neither reimplements the protect rule - neither reimplements glob matching - all 16 languages are checked, not just en - the duplicate busy wording is gone everywhere - …and the surviving one exists everywhere
✅ live fixtures — 6 checks No procedure may own a name on the daemon that another run also owns. - no procedure creates a daemon object under a fixed name - no procedure binds a daemon port from a constant - no procedure starts a container and ignores the result - the scanner read every procedure on disk - every shape the audit got through is caught now - …and the shapes the suite uses are not flagged
✅ log driver recreate — 9 checks A recreate never separates log options from their log driver (#2). - json-file with options names its driver - …and carries the options - bare json-file stays silent - a chosen driver is always named - …with its options next to it - a missing LogConfig emits nothing - opts imply the driver (json-file) - opts imply the driver (journald) - opts imply the driver (local)
✅ log folding — 15 checks A crash loop's log tail says its error once, not five times (#63). - a repeated two-line trace is folded - …and says how many times it repeated - …keeping the error itself intact - …and it is much shorter - a log with nothing repeating is left alone - a short log is left alone - empty stays empty - two occurrences are not folded - the lines before the loop are kept - …and only the loop is folded - _tail_logs folds before it truncates - a container without a health probe reports only its state - …and one with a probe reports both - no user-facing message interpolates health raw - every crash/health message uses the helper
✅ logs merge — 8 checks A container that has written nothing must not close the connection. - a silent command leaves stderr not-None - …and it is the empty string - the call site's own idiom no longer raises - stdout survives the merge - stderr survives the merge - an unmerged call still separates the streams - no front end builds its own logs argv - logs() asks for the merged pipe
✅ machine endpoints — 23 checks What `/metrics` and `/api/status` actually report. - /metrics answers at all - …as Prometheus text, not as a web page - it says the program is up - …and which version is answering - it counts every container on every reachable host - …and every pending update - a host that answered is reported up - …and one that did not is reported down, not left out - …with each host's own container count - …and its own pending count - each container with an update is named - …and one without an update is not - every metric declares its HELP and TYPE - …and no gauge pretends to be a counter - /api/status answers as JSON - …with the same totals as /metrics - …and every host, reachable or not - …saying which one did not answer - …and naming the containers that have updates - an install with nothing to report still answers - …with zeroes rather than missing lines - a hostile host name cannot break the exposition - …and its quotes are escaped rather than dropped
✅ mass stop — 27 checks A host going down is ONE message, not one per container (#63, @famewolf). - a whole host stopping sends exactly one message - …and it reads as the host going down - …with one attached log file - …named for the host - …carrying every container's section - …and the actual log tails - the tick still reports all 12 events (for callers/tests) - audit log keeps all 12 deaths individually - …plus one mass_stop summary row - a single crash still fires its own 💥 alert - …and does not produce a host digest or file - three deaths stay three individual alerts - …each with its inline log tail, no file - four deaths coalesce into one digest - …and 4 of 6 still reads as host-down - a minority cluster does not claim the host went down - …it says N stopped at once instead - split reboot's second wave sends no duplicate host msg - with the toggle off, every container alerts individually - a mass stop still gets through - …and a genuine unhealthy flip alongside it does too - silent container reads as 'no output', not 'unreachable' - a failed log fetch still reads as unreachable/gone - _tail_logs none_on_error: failed fetch → None - _tail_logs none_on_error: silent → '' - _tail_logs none_on_error: has output → text - _tail_logs default path: failure → '' (contract kept)
✅ matrix resilience — 35 checks Matrix survives a short outage — once, not twice (#66). - a send into a dead network is held, not dropped - …and says so in a line that can be grepped for - …keeping the error Matrix already logged - once the homeserver answers again the message goes out - …exactly once - …marked as late, so it does not read as fresh - …carrying the original alert, intact - a retry re-uses the first attempt's transaction ID - …which is the only thing that can stop a double post - two separate sends still get two transaction IDs - …and one held message keeps its ID across every attempt - …so the homeserver sees one message, not four - a 401 is NOT held for retry - …and still names the token and the room (401) - a 403 is NOT held for retry - …and still names the token and the room (403) - a 400 is NOT held for retry - …and still logs the status and detail (400) - a 404 is NOT held for retry - …and still logs the status and detail (404) - a 500 is NOT held for retry - …and still logs the status and detail (500) - a 429 IS held — it is 'not now', not 'no' - …and the log says which status it was - …and it does not claim nothing answered - an answer that is not JSON is NOT held for retry - …and it is still logged - a DNS failure holds the message - a timeout holds the message - a TimeoutError holds the message - a refused connection holds the message - a Matrix without a room holds nothing - the HTTPError clause comes before the network one - ValueError is out of the network clause - the clause order keeps a refusal out of the network path
✅ mobile tables visible — 11 checks A table with no card version must not vanish on a phone. - the narrow rule hides only the marked table - …and no longer every table on the site - the cards still appear in its place - exactly one table steps aside on a phone - …and its card list follows it - all ten tables are accounted for - …and the other nine keep rendering - a wide table scrolls sideways instead of being cut off - a row whose Compose file is out of reach says so - …worked out from labels already in hand - …and not from a fresh daemon round trip per row
✅ no double update report — 8 checks A finished update is reported once per channel, not twice. - Telegram gets the head and the results - the other channels get the head - …and not the results they already had one by one - a plain announcement still reaches both alike - the auto-update summary splits head from results - …and no longer glues them together for everyone - run_updates still reports to Telegram alone - _run_web_update_batch still reports to Telegram alone
✅ no hardcoded replies — 5 checks A connection carries no sentences of its own (#63). - no connection writes sentences of its own - Discord reads the shared translations - …resolved per call, so /lang applies at once - …while the markup conversion stays in the connection - every key asked for is a real one
✅ notify parity — 0 checks Every channel announces the same thing, in your language (#63). - _(this procedure reports only a final verdict)_
✅ plaintext host warning — 26 checks A `tcp://` host is unencrypted, and now we say so (#60, @NotRetarded). - warns about tcp://pve1:2375 - warns about tcp://192.168.1.5:2375 - warns about tcp://nas.local:2375 - warns about tcp://10.0.0.9:2376 - stays quiet about ssh://root@nas - stays quiet about ssh://user@host/run/podman/podman.sock - stays quiet about unix:///var/run/docker.sock - stays quiet about context://nas - stays quiet about (empty) - stays quiet about tcp://localhost:2375 - stays quiet about tcp://127.0.0.1:2375 - stays quiet about tcp://[::1]:2375 - a socket-proxy container name warns too (cannot be told apart) - …and the message offers that reading - …and the comment says so rather than claiming otherwise - the warning names the host - …and the endpoint - …and says what the risk actually is - …and what to do instead - it is emitted where the hosts are built, not per check - the security doc now covers multi-host - …and says the reverse proxy does not cover it - …and compares the transports - …and names the two cases where tcp:// is fine - no README example configures a plaintext remote host - …and the example points at the security doc
✅ podman recreate — 12 checks Recreating a container on Podman — which never once worked. - every argument is a string - the numeric stop signal survives, as text - --runtime oci is not emitted - --runtime runc is not emitted either - a deliberately chosen runtime is kept - a pod member is recreated with --pod - …and not with --network container: - …with no per-container network flags - a plain Podman container gets no --pod - …and keeps its own network mode - Docker gets no --pod - Docker keeps its stop signal by name
✅ precommit gate — 3 checks Whatever CI runs, the local suite runs too. - the gate CI runs exists - CI still invokes it by that path - …and it passes
✅ pull failure report — 22 checks A failed pull has to say why, and the pull has to be given enough time. - the reason is out of reach of a head-only cut - a failed compose pull reports failure - …and the message names the step - …and carries the reason from the END of stderr - …and marks that it was trimmed - …and is one line, not compose's block - the compose pull was actually run - …with the timeout from the configuration (240) - a different setting gives a different timeout (7200) - …so the old hardcoded 1800 is gone - the standalone pull uses the setting too - …and its message carries the reason as well - pull_timeout persists across a save - …can be set from the environment - …has the 1800 default recorded - …and sits on the Updates tab - the constructor takes pull_timeout - …defaulting to the same 1800 the registry promises - …without becoming a required argument - clip keeps the tail - …and the head - …and stays within its budget
✅ quiet enforcement — 26 checks Quiet hours and maintenance mode, driven through the gate they use. - 23:00–07:00 is quiet at 23:00 - 23:00–07:00 is quiet at 00:00 - 23:00–07:00 is quiet at 03:00 - 23:00–07:00 is quiet at 06:00 - 23:00–07:00 is awake at 07:00 - 23:00–07:00 is awake at 12:00 - 23:00–07:00 is awake at 22:00 - …quiet from the first minute of the window - …and awake again at the end of it - a window inside one day is quiet only inside it - with no window set, it is never quiet - …and a window of zero length is not 'all day' - a start of '' does not silence everything - a start of 'nonsense' does not silence everything - a start of '25:00' does not silence everything - a start of None does not silence everything - during quiet hours, an update list is held back - …and an update result is too - …and a routine message is - outside them, the same update list goes out - …and so does the same result - …and the same message - an urgent message goes out during quiet hours anyway - maintenance mode holds a routine notification back - …and an urgent message still gets through it - the weekly report is not held by quiet hours
✅ reaper — 17 checks We are PID 1, so orphans land on us — and now we collect them. - only commands we never start ourselves are collected - never waitpid(-1) — that races subprocess for its children - …each zombie is collected by its own PID - only zombies are touched, never live processes - …and only children of PID 1 - does nothing when not PID 1 - …and does not start a thread either - main starts it - …after the scheduler, never in the way of startup - the image still starts the app directly, no init in front - no home-made SIGCHLD reaper anywhere - the probe ran as PID 1 - orphaned ssh processes did pile up without a reaper - one pass collected every one of them - a zombie of another name is left alone - …and counted rather than hidden - subprocess.run kept all 40 of its own exit codes meanwhile
✅ release tag guard — 15 checks A tag that disagrees with app/version.py must not build. - the guard step is in the workflow - …and it is a script, not a placeholder - …and it runs before the push step - the release that went wrong is refused - …and the message names the tag - …and the version it found - a matching stable tag passes - …and so does a matching pre-release tag - a stable file under a beta tag is refused - a stale version file is refused - an unreadable version file is refused - …and says so rather than guessing - v2.18.0 is still caught - …and it is the only tag that is - …while everything else passes
✅ reply params — 5 checks Every core Reply supplies exactly the placeholders its template wants. - the scan actually found Reply calls - …including keys chosen by a ternary - no core Reply names a key that does not exist - every placeholder a template wants is supplied - no value is measured and then dropped (known: 2)
✅ restart self reachable — 9 checks Bare `/restart` restarts Docksentry, and stays reachable. - the dispatcher chain was found - the self-restart branch matches exactly, not by prefix - the lifecycle branch was found - self-restart is tested BEFORE the lifecycle branch - bare /restart lands on the self-restart branch - …which is the one that restarts the bot - /restart still lands on the lifecycle branch - /restartx is unchanged — still the lifecycle branch - bare /stop keeps its usage line from the lifecycle branch
✅ restore host boundary — 20 checks A restore overwrites only what the bundle knew about (#2). - the bundle's own entries land - the bundle's host is overwritten as asked - …but the uncovered host's pins survive - …its auto-update list survives - …its notes survive - …its update windows survive - a group living wholly on the uncovered host survives - a group of covered containers is the bundle's to define - what was kept is said, with the host's name - nothing about the local overwrite is flagged as an error - a bundle that speaks for the host may clear it - …and reports no kept entries - prefixed keys in an old bundle claim their host - entries for an unmanaged host are restored, not dropped - …with a warning naming the host - …that says how they become active - a new bundle names every host it speaks for - …and the round trip parses - bundle_hosts reads the declaration - …and infers local for a bare old bundle
✅ restore parity — 39 checks A restore refuses the same things whichever door it came through. - both doors agree on how big a backup may be - Discord refuses a backup over the limit - …and Discord says why rather than failing silently - …while Discord accepts one under it - Telegram refuses a backup over the limit - …and Telegram says why rather than failing silently - …while Telegram accepts one under it - Web refuses a backup over the limit - …and Web says why rather than failing silently - …while Web accepts one under it - Discord does not offer to restore JSON that is not a backup - …and Discord names the missing schema_version - …Discord does not offer a file that is not JSON - …and Discord says so - …and Discord does not even read a non-.json file - Telegram does not offer to restore JSON that is not a backup - …and Telegram names the missing schema_version - …Telegram does not offer a file that is not JSON - …and Telegram says so - …and Telegram does not even read a non-.json file - Web does not offer to restore JSON that is not a backup - …and Web names the missing schema_version - …Web does not offer a file that is not JSON - …and Web says so - Discord still offers a real backup - Telegram still offers a real backup - Web still offers a real backup - the person who dropped the file may restore it - …and nobody else may - …and is told why - a question left unanswered expires - …without being told it is restoring - …while one answered inside the window still works - a stranger's press leaves the buttons for the owner - …while the owner's own press takes them off - cancelling an offer forgets it - …and leaves a newer offer alone - an expired offer is swept on the next press, not just refused - …while a live offer survives an unrelated press
✅ rollback — 20 checks The promise people lean on hardest, driven for the first time. - with no backup, nothing is touched - …and it says the rollback did not happen - …and the container the user still has is still there - with a backup, the broken container is removed - …forcibly, because a wedged container used to block the rename - …then the backup is renamed into its place - …and started - …in that order - …and the rollback reports success - …leaving the service under its own name again - a failed remove does not abandon the rollback - a failed rename ends the rollback - …and nothing is started on top of the wreckage - a container restored but not started is not called a success - …though it was restored, so the backup is in place - a backend that raises on rm does not escape the rollback - a backend that raises on start does not escape the rollback - …and a raising remove still tries the rename - every failure path in the recreate still rolls back - …and the catch-all uses the literal backup name
✅ secret handling — 14 checks Three promises about secrets that nothing was holding. - a session exists to begin with - saving a new password signs the old sessions out - …without the save itself failing - …while an unrelated save leaves them alone - …and an empty password field changes nothing - settings.json is readable only by its owner - …and a filesystem that refuses chmod is survived - the settings page renders - …and the bot token is not in it - …nor the chat id - …but enough of the token to recognise it - …and enough of the chat id - a short token is hidden entirely, not partly - an unconfigured bot says so
✅ selfcheck — 53 checks Docksentry answers "what can you see?" itself, without a shell. - the report reads the keys the checker really returns - it knows its own name - …and how many mounts it has - a covered path that is still missing reads as a wrong source - …naming the mount it found - …and not telling them to mount it again - with nothing mounted it says to mount it - …and names what to mount - an unrecognised path names its own directory - …so the sentence never has a hole in it - a readable compose file reports as fine - …and raises neither complaint - a non-Compose container is not reported - the headline counts match the findings - the daemon is asked who really holds the file - …and it names the directory that would work - …and the container it read that off - …and then does not claim it is on another machine - every container is inspected, not only the running ones - our own mount is never named as the holder - …instead it says the stack was made somewhere else - three missing stacks say it once - …while each still gets its own wrong-source line - a daemon that will not answer is reported as unknown - …and never as 'it is somewhere else' - an ambiguous lookup is unknown too, not an absence - a holder that sits on our data directory is refused - …and the refusal names the volume that has it - …and an unrelated data directory is not refused - our own mount is not handed back when we do not know our name - with nothing mounted it still names the volume that holds it - a mixed answer claims neither 'elsewhere' nor 'unknown' - …while still naming the one it did find - a clean report says so instead of stopping - …and counts the files it looked at - …and stays away when one of them is unreachable - …and says nothing when there is nothing to look at - a readable compose file raises none of them - every finding has wording in all 16 languages - the bot really has no `checker` attribute - a bare /audit produces a report rather than an exception - …and the report has the container in it - no handler reaches for an attribute the class does not have - the bare branch hands the checker down - Telegram asks the core for the findings - …Telegram words them with its own translator - …Telegram stays quiet about what is fine - Discord asks the core for the findings - …Discord words them with its own translator - …Discord stays quiet about what is fine - a bare /audit audits us, in Telegram - …and in Discord - Discord no longer demands a container name
✅ selfrestart decoupled — 19 checks Going down on purpose lives in the core, not on a front end (#63). - selfrestart.policy exists - selfrestart.record_request exists - selfrestart.go_down exists - policy() demands a checker rather than defaulting - telegram_bot no longer defines the policy check - …and its restart_self is the adapter onto the core - Discord asks the core, with its own checker - …and stops borrowing the restart from the Telegram bot - a container that will come back is allowed to restart - …and without a checker it refuses, visibly - a policy of 'no' means we stay up - a policy of 'none' means we stay up - a policy of '' means we stay up - a daemon that will not answer counts as no - …and so does a daemon that is not there - the request is recorded for the next boot - …with a timestamp the staleness rule can read - …and it names which front end asked - an unwritable marker does not stop the restart
✅ settings post — 40 checks Saving a settings form: clearing a field, and the Discord bot. - an emptied Discord webhook is cleared - an emptied webhook URL is cleared - an emptied topic id is cleared - an emptied bot label is cleared - an emptied allowed-user list is cleared - and the save was actually written - a field that was not submitted keeps its value - an empty token field means unchanged, not cleared - a submitted token replaces the old one - the clear checkbox is what removes it - the SMTP server is saved - the port is saved as a number - the encryption choice is saved - an empty SMTP password means unchanged - its clear checkbox is what removes it - an unknown encryption value is refused - a non-numeric port is ignored, not zeroed - unticking verification on this form turns it off - a POST without the form marker cannot turn it off - ntfy server and topic are saved - the Gotify URL is saved - the Matrix room is saved - empty credentials mean unchanged, for all of them - a submitted credential replaces the old one - and each has its own clear checkbox - an emptied plain plugin field is cleared - an unticked switch that WAS shown turns the channel off - …and the others are untouched - ticking it turns the channel back on - a switch that was never shown is left alone - saving another tab does not restart the bot - …and says nothing about Discord - changing the server id restarts the bot - a clean start is reported - re-saving the same values does not restart it - a rejected token is reported as such - …with what Discord actually said - …and the value is still saved, so it can be corrected - the reason is URL-encoded, not raw - with no restart hook the page asks for a restart
✅ settings visibility — 31 checks What you set, and what you can see (#2, @famewolf + @NotRetarded). - a field inside an adv-only block counts as hidden - …however deeply it is nested - …including a textarea, not just inputs - a field outside one does not - …and the block ends where its tag ends - a void element does not open a scope - broken markup yields no findings rather than an error - a hidden setting that is on is reported - …and so is one merely moved off its default - a hidden setting still at its default is not - a visible setting is never reported, changed or not - values are rendered for a person, not a debugger - his own case produces exactly one line - an untouched install is told nothing - the disclosure is scoped to simple mode - …and offers the switch that reveals them - …reading it from the page that was just rendered - only loggable keys reach the hidden list - adopting recomputes the list it came from - adopting refuses a key that is not being overruled - …writes the value in rather than deleting the key - …and refreshes, so the row does not survive its own click - the reasoning is recorded where the next person will look - raw values stay out of the override entries - …and are read from the private seed map instead - a saved false really does beat an env true - …while the entry itself carries no value for a secret - adopting reports that it did something - …and refuses a second time, having nothing left to do - adopting it ends the override - …and it survives the next save, which writes every key
✅ setup password — 19 checks The first-run wizard makes you set a password (#60, @NotRetarded + owner). - no password and no opt-out does not finish setup - …and sends the user back to the wizard with an error - …and stores no password - a mismatched confirmation does not finish setup - a matching password finishes setup - …stored as a scrypt hash, not plaintext - …that the password then verifies against - …and the username is kept - …and it lands on the dashboard - the no-password tick finishes setup - …and leaves no password set (open, as chosen) - the skip endpoint refuses to open a passwordless dashboard - the blanket skip link is gone from the wizard - the wizard has five steps now - …step one is the password - …with a confirm field and the no-password tick - …and the password field is a password input, not text - the logout control is an SVG, not the power glyph - …and still carries an accessible label
✅ silent rejection — 33 checks Why the bot went quiet, and how it says so now (#2, @famewolf). - a send into a migrated group is not lost - …it is retried against the new id - …and the old id was tried first, i.e. nothing was guessed - the log names the new id, not just the error - …and says which setting to change - the chat is told once, on the next message - …naming both the dead id and the live one - …without swallowing the message it rode along with - …and never again - a second migration is not followed - …and the first one still stands - the new id is not written to persistent settings - commands from the migrated group are accepted - …and the dead id no longer counts as the chat - a rejection asks Telegram whether the chat merely moved - …and the command is then honoured, not dropped - …asked once per boot, not once per stray message - a command from the wrong chat is still refused - …and now says why, without DEBUG - …naming both ids, so it can be acted on - …but only once per boot - a user off the allow-list is still refused - …and says why - …naming the id that was refused - …and warns that a saved value beats the compose file - …once, not per stranger - the two reasons are reported independently - a legitimate command still passes - …and says nothing at all - …and costs no API call - an empty allow-list still means everyone in the chat - DEBUG still prints its per-message detail - the rejection notice is printed, not sent to the chat
✅ ssh hint — 31 checks Say why the SSH host refuses, not just that it did (#2, @famewolf). - a refused key with no .ssh in the container is explained - …saying the container has its own filesystem - …and giving the mount that fixes it - …with the host's own path on the left, not the container's - a mount without known_hosts is a different answer - with both present it points at authorized_keys instead - an unknown host key gets its own explanation - …and with known_hosts present it does not suggest mounting - …and never suggests turning the check off - a refused connection is not blamed on keys - a tcp host gets no ssh advice - …nor does a context endpoint - an error we cannot place gets no guess - …and neither does an empty one - ssh refusing its own config is explained - …naming the config and not the keys - …while a key refusal still gets the key sentence - …and the pre-flight calls it a refusal, not silence - …while a stopped daemon is still nothing answered - all sixteen languages carry the five sentences - the English floor matches the language file - a de reader gets the de sentence - a ja reader gets the ja sentence - a ru reader gets the ru sentence - a en reader gets the en sentence - the languages are not all the same text - a broken translator still yields English - every reporting surface asks for the hint - …and every one of them asks in the reader's language - a broken hint cannot break the sites that call it directly - …and a hint that throws does not take the pre-flight down
✅ startup message scope — 4 checks The startup banner is only sent where it exists. - the banner is built in exactly one place - …and it is read somewhere - it is built behind the post-selfupdate guard - every read sits inside that same guard
✅ status parity — 39 checks One /status, assembled once, rendered per front end (#2). - both front ends get the same number of lines - …and identical content once bold is stripped - state and health - the header leads with name, state and uptime - stanzas are separated by blank lines - uptime - live CPU and memory - net and disk I/O ride along - two-field stats still work - the version, not just the latest tag - what the probe said, when unhealthy - Docksentry's own flags - group and note - a pending update is announced - ports carry a visible glyph, not the washed-out plug - the live-cost line uses the bar chart - a stopped container shows its exit code - …and no uptime or load for something not running - the overview leads with a health icon - …names the version next to the image - …and carries the uptime - healthy is green - …and an unversioned image shows no empty parens - no store, no stats, no problem - the update path still skips ourselves (PID 1) - a reader sees the whole truth, ourselves included - Telegram renders through status_render - …and Discord too - Discord's overview includes ourselves - …via the shared overview line - a tcp+udp double mapping shows once - net I/O carries direction arrows - disk I/O says read and write - the image states its size - …and its age - the writable layer is named - a zero layer is shown too — 0B is an answer, not noise - no disk facts, no disk lines - volume sizes are not fetched anywhere
✅ status view — 22 checks Two ways to draw the status page, and a switch between them. - an upgrade keeps the page it already had - the compact list can be asked for - …case and whitespace do not matter - 'v2' falls back to the table rather than guessing - 'compact' falls back to the table rather than guessing - '' falls back to the table rather than guessing - 'true' falls back to the table rather than guessing - persisted like every other preference - …seeded from the environment - …with a default the override check can compare against - a saved choice survives a restart - the chooser is on the settings page - …offering both, and only both - …and the form that saves settings accepts it - it is not hidden behind the advanced mode - the page says which layout it drew - …and the list view is what asks for its stylesheet - documented, like every other variable - …saying which one is the default - …and that the interface can change it too - the old WEB_UI_V2 switch is gone - the scroll wrapper closes right after the table, not around the cards
✅ stop confirms — 47 checks A stop asks first, in both chats, with the same question. - /stop asks before it stops - …and stops nothing yet - …offering a confirm and a cancel - the press is what stops it - …and it says so - a second press does nothing - …and says the confirmation is gone - cancelling stops nothing - …and the token is dead afterwards - a protected container is refused straight away - …with no button to press - stopping Docksentry is refused straight away - …with no button either - an unknown container is answered, not asked about - /start does not ask - a glob asks once, for the whole set - …naming what it would stop - …until the press - `/stop *` says up front what it will skip - …and asks only about the rest - …and the press honours that - a glob matching nothing is answered, not asked about - another user cannot press your confirmation - …and is told why, in a toast rather than in the chat - …and the button still works for the person who asked - a record with no asker is refused, not waved through - a confirmation older than the TTL is dead - …and says so - …but one just inside it still works - the TTL matches Discord's - one container gets the singular question - several get the plural one - …which counts them - …and does not list thirty names at you - both chats ask from the same plan - …and phrase it from the same place - neither keeps its own protect check for it - the press re-runs the guards - …from the raw argument, not a captured name list - all 16 languages are checked - confirm_stop_many is complete in every language - confirm_stop_btn is complete in every language - bare /stop answers with usage, not silence - …and offers no button - bare /restart restarts Docksentry - …and does not answer with the lifecycle usage - bare /start is not captured as a lifecycle usage
✅ storage check — 68 checks Where /data really lives, and who gets told (#2, @famewolf). - his own mounts are diagnosed - …and the cause is reported ahead of the effect - the log names the path he actually mounted - …and the one we use - …and gives him the corrected line - …and says an anonymous volume dies on recreate - a named volume is fine - a bind mount at /data is fine - /data/compose is not a stray mount - sockets and timezones are left alone - an anonymous volume at /data is flagged - nothing mounted at /data is flagged - a data directory mounted at /app/data is flagged - a data directory mounted at /config is flagged - a data directory mounted at /opt/ds/data is flagged - a custom DATA_DIR is honoured - an inspect we could not run yields no findings - …and is a different answer from an empty mount list - mounts are read from our own container - a failed inspect answers 'do not know' - …as does output we cannot parse - …and not knowing our own name stops the check - a broken daemon does not take startup down - all 16 languages carry the three storage messages - …and none of them has a placeholder we do not pass - a known cause is reported instead of the generic warning - …the old warning survives for a volume that really lost it - …and a surviving volume with no settings.json is not an alarm - loss is recorded, not inferred - a fresh volume has never held settings - …and asking does not create one - an install that predates the marker is not mistaken for fresh - …and gets the marker stamped on the spot - a settings.json that vanishes is now known to be a loss - marking twice is harmless - the marker is not a persistent setting - …and every save records it - routine scan lines are silent without debug - …and present with it - …and still reach the Web UI's debug view - …recorded for the debug view too - failure diagnostics stay unconditional - 'Skipped (self)' goes through _trace - 'Skipped (excluded)' goes through _trace - 'Skipped (pinned' goes through _trace - 'Skipped (image ID)' goes through _trace - 'Resolved image ID' goes through _trace - 'Checking:' goes through _trace - 'Rollback: restored' stays on _debug - 'escalating to kill' stays on _debug - 'Stop failed' stays on _debug - 'Dependent recreate failed' stays on _debug - 'Registry error: HTTP' stays on _debug - the backup file is named after the instance - …sanitised, so a label with emoji cannot break the filename - …and a hostile one cannot escape the directory - …and an unlabelled instance keeps the old name - a hostname somebody chose stands in for a missing label - a container id is not mistaken for a machine name - the Web UI export uses the shared builder - a first boot writes a copy - …even with nothing saved yet - an unchanged instance does not write again - a change writes a copy that contains it - a burst of saves collapses into one file - …and the boot path is not held back by it - state_mtime sees every file the bundle is built from - a boot that lost settings does not overwrite the good copies
✅ storage foreign mount — 12 checks A volume somebody else mounted is not our lost data directory. - a healthy data dir plus a stranger's volume says nothing - a healthy data dir on its own says nothing - no data mount at all is still reported - a path that only we would use is still flagged - an anonymous volume is still reported - the old data path is not accused of being ours - a mount inside the data dir is ours by definition - nothing to look at yields nothing, not a guess - the volume our own image creates is not an accusation - an anonymous volume is never offered as the mount to use - …while a real bind at the same place still is - the current default path is exempt like the old one
✅ store concurrency — 29 checks Two things the state files must survive: two writers, and damage. - two writers produce no unparsable file - …and no file mixing both writes - …and the target never disappears - …and the contention actually happened - …and no temp file is left behind - a failed write leaves nothing behind - is_pinned: healthy file answers truthfully - is_pinned: a truncated file protects anyway - is_pinned: …including a container not in the list - is_pinned: a wrong-shaped file protects anyway - is_pinned: a missing file is not damage - is_ask_before_major: healthy file answers truthfully - is_ask_before_major: a truncated file protects anyway - is_ask_before_major: …including a container not in the list - is_ask_before_major: a wrong-shaped file protects anyway - is_ask_before_major: a missing file is not damage - is_protect_stop: healthy file answers truthfully - is_protect_stop: a truncated file protects anyway - is_protect_stop: …including a container not in the list - is_protect_stop: a wrong-shaped file protects anyway - is_protect_stop: a missing file is not damage - an unreadable list says so on stdout - concurrent pins all survive - …without raising - …and unpin still removes - every host keeps its own entries - …and no host's entries leak into another's - view and store share one lock object - …and two different files do not
✅ translation integrity — 6 checks A translation must carry everything the English one carries. - all 16 languages are examined - every placeholder survives translation - no message has an unbalanced backtick - the leading status emoji survives - multi-line messages keep their lines - every message can be formatted at all
✅ update button scope — 0 checks A host-scoped update notification keeps its scope after a button tap. - _(this procedure reports only a final verdict)_
✅ update queue — 22 checks Tapping four containers runs four, not one. - a queued container reports its position - a repeat tap is recognised, not queued twice - entries come back out in the order they were tapped - an empty queue hands back nothing - the queue is bounded - …and the cap is a refusal, not a silent drop - group-mates can be taken out together - …and everything else stays queued - dropping everything returns everything - every queued tap actually runs - …and the queue ends up empty - a failure with no group does not stop the rest - a failure in a group skips its group-mates - …but not containers outside that group - …and it says which ones it skipped, and why - a pending self-update stops the drain - …and names what will not run - …leaving nothing queued to be lost by the restart - the drain re-takes the lock per entry - …and hands the entry back if it loses the race - …rather than holding it across the whole queue - a busy lock queues the tap instead of discarding it
✅ update queue duplicate — 14 checks Tapping the same update twice, and what the second tap is told. - a second tap during the update is told it is the same one - …and is not left in the queue afterwards - …so nothing claims the container is not in the update list - …and the update itself still finished - …and the second tap's answer names the host - …as does the line that confirms the tap - the single-update path says what it is updating - …and stops saying it once the update is done - a queued entry whose work is done says exactly that - …naming the host it belonged to - a name that is not pending is still 'not in update list' - a different container tapped meanwhile is still queued and run - …and is not told it is the same one - …and really does run, rather than being dropped
✅ update queue sweep — 32 checks Queued work must not depend on who happens to hold the lock. - a first queue gets position 1 - a second gets position 2 - …because the two hosts are told apart - re-queueing the same key is refused - the wait is measured from when it was queued - …and a key that is not queued has no wait - the snapshot keeps the queue order - …and carries each entry's wait - …and is a copy, not the queue itself - has_queued_updates says yes while full - taking returns them in order - …and forgets its timestamp - …so a re-queued key starts its wait again - dropping empties the queue - …and leaves no timestamps behind - an empty queue does not reach the drain - queued work with a free lock is drained - …and the poll loop's checker is handed through - a held lock is left alone - …and the next sweep takes it - a failing drain does not escape the sweep - something calls the sweep - …from the poll loop, not from a command - en: the queued answer names the container - en: …and how long it has been waiting - en: …and does not leave a placeholder showing - en: status has a word for the queue - de: the queued answer names the container - de: …and how long it has been waiting - de: …and does not leave a placeholder showing - de: status has a word for the queue - the status page reads the queue
✅ update window enforcement — 28 checks A maintenance window that actually holds an update back. - None is no restriction at all - {} is no restriction at all - {'start': '', 'end': ''} is no restriction at all - {'start': 'nonsense', 'end': '03:00'} is no restriction at all - {'start': '09:00', 'end': '09:00'} is no restriction at all - inside a same-day window, it is open - …and closed before it - …and closed after it - …open from its first minute - …and shut at its last - a weekday list keeps the window shut on other days - …and an empty weekday list means every day - weekdays given as digit strings still count - …and a whole number written as a float does too - a weekday list of ['Sat'] does not mean every day - a weekday list of ['-1'] does not mean every day - a weekday list of [5.5] does not mean every day - a weekday list of [None] does not mean every day - a weekday list of [True] does not mean every day - one readable weekday among rubbish still counts - a window across midnight is open late on its own day - …and still open after midnight, on the following day - …but not in the small hours of the day it opens on - …and not in the evening of the day it closes on - …and shut in the middle of either day - a container whose window is shut is not auto-updated - …while one without a window still is - …and inside its window it is updated after all
✅ updating label — 27 checks While an update runs, the label says so — and to which version. - a fresh engine is updating nothing - mid-update the engine names the container - …and the version it is heading for - …and lets go when it is done - an unknown target is empty, not guessed - a failed update lets go as well - claims are host-scoped - the lock still answers 'is anything running?' - callers get a copy, not the live dict - waiting: the row says an update is available - running: it stops saying that - running: it names the version instead - running: waiting and running do not read the same - running with no known target: still says something - …without printing a blank where the version would be - the narrow-screen card moved with the row - the row's update button is dead while it runs - …and live while it only waits - V2: the row knows it is updating - V2: …and carries wording the client can print - V2: an idle row is unchanged - V2: the wording is not hardcoded in the client - every language file has the new keys - the version is a placeholder, not glued on - a translated language words it differently - …and still shows the version - no target -> a wording without one
✅ v260 batch — 18 checks Four small things decided together, asserted together. - every outcome glyph is counted - the group note is not counted as a container - an empty batch counts nothing - …and so does None - the glyph table covers what the engine emits - the bot builds the count into the heading - a claimed container is badged - …from labels the row already has - …and nothing else keys off that label - both CLIs' endpoint fields are tried - …and a failed template is caught by its text, not its exit code - …exit code still counts too - …best-effort, never raised - …and a line without an endpoint is dropped - the hint skips the default context - …and the endpoint that just failed - …and says nothing when there is nothing to say - contexts are only consulted for an unreachable host
✅ v271 batch — 28 checks Two things @NotRetarded found by using the bot rather than reading it. - every host/container option asks Discord for autocomplete - …and nothing else claims it - …every command with a host is covered, not just /status - no option mixes autocomplete with fixed choices - …and only string options carry it - a host option is offered every managed host - …local first, so the local host names itself - typing filters the suggestions - …case-insensitively - a container option is offered the containers - …matched anywhere in the name, not just the start - …from the host already chosen in the same command - …and from the local host when no host is chosen yet - autocomplete is not acknowledged first - an unreachable host answers with no suggestions - …and still answers, rather than leaving a spinner - more containers than Discord allows are capped at 25 - an interaction from another guild gets no suggestions - the bot channel sends embeds, not plain text - …byte-for-byte the same embeds as the webhook - an update result links the container name to the release - …and a pending update carries its Source link - …with the version badge the bot used to leave out - the bot channel still speaks through the bot - …and is still discovered as its own channel - …configured by its own fields - the weekly report's embed reaches the bot channel - …and respects the channel's switch
✅ v2 output safety — 32 checks What the V2 page puts on screen cannot be turned into script. - the escaper does not emit a literal closing tag - …nor a bare '<' at all - …nor a bare '>' - …nor a bare '&' - …nor a raw U+2028 / U+2029 - …and the browser still parses it back - …unchanged, including non-ASCII - the allowlist finds the languages we ship - a hostile language code is refused - …and so is an unknown but harmless one - …and a non-string - a language we ship is kept - …including when it arrives with a region suffix - the rendered page has no injected script block - …and its data block is still valid JSON - …and the attribute carries no quote to break out of - a real language reaches the page - link refused: 'javascript:alert(1)' - link refused: 'JavaScript:alert(1)' - link refused: '\tjavascript:alert(1)' - link refused: 'data:text/html,' - link refused: '//evil.example/x' - link refused: 'vbscript:msgbox(1)' - link kept: https://example.com/changelog - link kept: http://192.168.1.10:8080/releases - an empty link stays empty - …and so does a missing one - container_rows emits exactly one 'link' value - …and it is the output of the safety check - the page frame sets a lang attribute at all - …and every one of them is filtered - every link case was actually measured
✅ v2 unreachable host — 8 checks One unreachable host must not take the whole V2 status page down. - a dead host does not raise - the live host's rows still come through - the dead host is still named - …and marked, not shown as an empty host - it carries no phantom containers - the count counts only real rows - all hosts down still answers - the host picker marks it too
✅ web auth gate — 17 checks The gate, not the lock — and the gate was the part nobody drove. - with no password set, the page is open - …which is what keeps a fresh install usable - with a password set, a bare request is refused - …and so is the wrong password - …and an empty password - …and a header that is not Basic at all - …and Basic with rubbish behind it - …and Basic with no colon in it - the right password gets in - …and so does a valid session, with no header at all - with a username set, the right pair gets in - …and the wrong user with the right password does not - a plaintext WEB_PASSWORD is honoured - an open install really is open - …and setting a password closes it without a restart - …and clearing it opens it again - an unauthenticated POST is asked for a password
✅ web csrf — 14 checks The one check that stands between a cached login and another site. - a POST from the page itself gets through - …and so does one with only a Referer - a POST from another site is rejected - …including one that only differs in port - …and one whose Referer is elsewhere - …and a hostname that merely starts the same - a POST with neither Origin nor Referer is rejected - …and one with no Host header at all - …and one whose Origin is unparseable - …and one whose Origin is empty - the login form is checked as well - …and a genuine login still works - test_settings_post.py no longer switches the CSRF check off - test_setup_password.py no longer switches the CSRF check off
✅ web login — 79 checks A login page, a username, and a password that is not on disk (#60). - a hash verifies against its own password - …and against nothing else - the password is not recoverable from the hash - two hashes of one password differ - …and both still verify - the cost parameters travel with the hash - …so a hash made with other parameters still verifies - a malformed hash lets nobody in - a plaintext stored password still verifies - …and rejects the wrong one - an empty stored password admits nobody - no configured username accepts any name - a configured username accepts only itself - …ignoring surrounding whitespace - a session validates and names its user - an unknown token does not - …nor an empty one - two sessions get different tokens - a token is long enough not to be guessed - signing out ends the session on the server - …which is what makes a stolen cookie useless - an idle session expires - …and so does one past its absolute age - using a session renews its idle clock - the store is bounded against a login flood - a password change ends every session - a browser is sent to the login page - …and a script still gets 401 with WWW-Authenticate - …and /api and /metrics are never redirected - POST /login runs before the auth check - …but not before the CSRF check - next= keeps a real path on this site - next= rejects an absolute URL - next= rejects a protocol-relative //host - next= rejects a backslash the browser reads as // - next= rejects an embedded CR or LF (header injection) - next= rejects a tab and a NUL - next= falls back to / on empty - the login page never renders the password - …and marks the fields for password managers - …and is not cached - the login page does not leak the configured username - a failed login does not say which half was wrong - the session cookie is HttpOnly - …and SameSite=Lax, so it is not sent on a cross-site POST - …and not Secure, which would break plain HTTP - …scoped to the whole site - …and lasting the configured number of hours - …and the token itself, or there is no session - a password saved in the interface is hashed - …and the plaintext is not stored anywhere - Basic Auth still authenticates - …and now checks the username as well - …and verifies through webauth, so a hash works - a session is accepted as well - no password set still means open - an existing plaintext password is migrated on start - …once, not on every start - …and not when an env var is overruling the file - …and it keeps working if the migration fails - a page's background fetch gets 401 without WWW-Authenticate - a script still gets WWW-Authenticate - a browser navigation still goes to the login page - …but an /api path is never redirected - the page redirects itself to login on a 401 - …and does not loop when already on the login page - a correct password gets 302 and a session cookie - …the session it hands out actually validates - …and it is recorded in the audit trail as ok - a wrong password gets 401 - …and no session cookie - …no session is created - …and it is recorded as failed - any username passes when none is configured - a wrong username is refused even with the right password - a hostile next= is neutralised in the real redirect - …and cannot inject a header through the redirect - a bad session value on load clamps instead of crashing - concurrent logins never crash the store and honour the cap
✅ web uses real bot — 6 checks The Web UI may only call methods the bot actually has. - the Web UI does talk to the bot at all - every bot member the Web UI calls exists - the Web UI test's stub bot invents no methods - Start/Stop/Restart goes through lifecycle.act - …and hands it the update-running flag - …so stop-protection now applies here too
✅ weekly report channels — 25 checks Who gets the weekly report (#59, @NotRetarded). - every switched-on channel receives the weekly report - …including smtp, which never got one before - …including ntfy, which never got one before - …including gotify, which never got one before - …including matrix, which never got one before - …including apprise, which never got one before - a switched-off Discord webhook gets nothing - …while its URL is still saved - …and the bot channel still gets it - …so Discord receives it exactly once - switching any single channel off silences that one - an incomplete channel is not a recipient - nothing configured means nobody is sent to - Discord gets the embed it always got - …and the bot channel gets the identical one - the generic webhook keeps its weekly_report event - everything else gets the plain text - the report asks the facade instead of naming channels - …and names no channel of its own - Telegram is still sent to separately - a report that reached nobody is not marked as sent - Telegram channel off + nothing else: not marked as sent - Telegram channel on: sent and marked - Telegram off but another channel on: still marked - the Telegram weekly copy ignores quiet hours (auto=False)
✅ weekly schedule — 14 checks When the weekly report fires — the part its own test replaced. - switched off, it never fires - on the configured day and hour, it fires - …and not on another day - …and not before the hour - later the same day it still fires - …right up to the end of the day - once it has gone out today, it does not go again - …and the week after, it does - an unreadable weekday does not fire - an unreadable hour does not fire - an empty hour falls back to the default, it does not stop - deciding to send does not record it as sent - …and asking twice still says yes - …until the caller says it went out