# 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
✅ 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