feat(docker): the docker module roadmap, end to end #66

Merged
thisilike merged 92 commits from feat/docker-storage into main 2026-09-23 14:07:55 +02:00
Owner

85 commits, 183 files. It is large because it is the whole docker roadmap rather than one story, and it carries two independent workstreams. They touch different files almost entirely, so they can be read separately:

1. The self-update sidecar (2 commits)

  • 4bfb14f update OpsDeck from OpsDeck through a sidecar container
  • 2f28e84 rollback keeps the stack updatable, verify runs once

A detached host process cannot work — a setsid child stays in the container's cgroup, which the runtime kills once PID 1 exits. So the successor is started by a throwaway sidecar built from OpsDeck's own image, which is removed once the new process has read its verdict. docs/docker-self-update-sidecar.md has the reasoning.

Includes one core change, packages/server/src/tasks/service.ts: start() now skips runs already held in #runs, because a run started from a module's register() was being re-adopted from the store at boot and its handler launched twice. Regression test in packages/server/tests/tasks_test.ts.

2. Everything else — the docker module roadmap

Storage (measurement, prune plans, bind mounts, logs), images, networks, volumes as a managed thing, the engine page, compose verbs, version pinning, per-service scope and auto-update, and the operations surface over the task system.

Worth a reviewer's attention

The task system. service.ts gained a second fix (dd5f4e9): every ordinary run was taking the released-early branch, because handlerLive was cleared in a finally that ran after #finish had read it. Result was a warning on every run saying its work had not returned when it just had, plus a claim on a lock nobody held. The window closes before any awaiting caller resumes, so nothing downstream misbehaved — but it made the real signal (a released run whose work is still moving containers) indistinguishable from noise.

Mobile parity is now enforced by a test. A schema client's navigation is one entry per module and nothing under it, so a page nothing links to does not exist. Four were in that state. ui_test.ts now walks the link graph so the next page cannot be added orphaned.

One fact, one derivation. Several bugs here were the same shape: a verdict computed in two places that disagreed. A container in a restart loop was red on the stack card and green on its own row, because the row re-derived state from state while the server counts restarts. Removal refusals printed a machine token (not-in-plan). A compose failure paired the reason registry-denied with a detail saying the tag did not exist. Each is now single-sourced.

DOCKER_CONFIG used to lie. It named where the host keeps registry logins and governed only what OpsDeck reported — the chrooted docker CLI went on reading /root/.docker. Setting it produced a UI claiming an authentication every pull then failed. ExecOptions gained env and the module binds it once onto its host facade.

Verification

  • deno task check, deno lint, check:svelte clean; 188 server tests, 643 docker module tests, 57 UI tests.
  • Deployed and exercised on a real host for several hours: stack updates, prunes, volume export/import round trip, registry auth against a private registry, restart-loop detection, and a genuine upstream breakage (paperless :latest shipping a broken build) diagnosed through it.
  • UI verified with Playwright, including the schema renderer at phone width.

Known gaps, stated rather than hidden

  • mod.ts (3.7k lines) and actions.ts (2.4k) are past a comfortable size. The seams exist in the comment banners; splitting them is a large no-behaviour-change diff I did not want inside this one.
  • Compose file editing stays web-only. A schema form cannot show a diff, a stale-sha conflict, or what it is about to overwrite; a test asserts no schema form posts to /file or /label.
  • Four short tables (engine drift, network members, container mounts, image layers) did not get sort controls — none is a list you scan for an outlier.

🤖 Generated with Claude Code

85 commits, 183 files. It is large because it is the whole docker roadmap rather than one story, and it carries **two independent workstreams**. They touch different files almost entirely, so they can be read separately: ### 1. The self-update sidecar (2 commits) - `4bfb14f` update OpsDeck from OpsDeck through a sidecar container - `2f28e84` rollback keeps the stack updatable, verify runs once A detached host process cannot work — a `setsid` child stays in the container's cgroup, which the runtime kills once PID 1 exits. So the successor is started by a throwaway sidecar built from OpsDeck's own image, which is removed once the new process has read its verdict. `docs/docker-self-update-sidecar.md` has the reasoning. Includes one core change, `packages/server/src/tasks/service.ts`: `start()` now skips runs already held in `#runs`, because a run started from a module's `register()` was being re-adopted from the store at boot and its handler launched twice. Regression test in `packages/server/tests/tasks_test.ts`. ### 2. Everything else — the docker module roadmap Storage (measurement, prune plans, bind mounts, logs), images, networks, volumes as a managed thing, the engine page, compose verbs, version pinning, per-service scope and auto-update, and the operations surface over the task system. ## Worth a reviewer's attention **The task system.** `service.ts` gained a second fix (`dd5f4e9`): every ordinary run was taking the released-early branch, because `handlerLive` was cleared in a `finally` that ran *after* `#finish` had read it. Result was a warning on every run saying its work had not returned when it just had, plus a claim on a lock nobody held. The window closes before any awaiting caller resumes, so nothing downstream misbehaved — but it made the real signal (a released run whose work is still moving containers) indistinguishable from noise. **Mobile parity is now enforced by a test.** A schema client's navigation is one entry per module and nothing under it, so a page nothing links to does not exist. Four were in that state. `ui_test.ts` now walks the link graph so the next page cannot be added orphaned. **One fact, one derivation.** Several bugs here were the same shape: a verdict computed in two places that disagreed. A container in a restart loop was red on the stack card and green on its own row, because the row re-derived state from `state` while the server counts restarts. Removal refusals printed a machine token (`not-in-plan`). A compose failure paired the reason `registry-denied` with a detail saying the tag did not exist. Each is now single-sourced. **`DOCKER_CONFIG` used to lie.** It named where the host keeps registry logins and governed only what OpsDeck *reported* — the chrooted docker CLI went on reading `/root/.docker`. Setting it produced a UI claiming an authentication every pull then failed. `ExecOptions` gained `env` and the module binds it once onto its host facade. ## Verification - `deno task check`, `deno lint`, `check:svelte` clean; 188 server tests, 643 docker module tests, 57 UI tests. - Deployed and exercised on a real host for several hours: stack updates, prunes, volume export/import round trip, registry auth against a private registry, restart-loop detection, and a genuine upstream breakage (paperless `:latest` shipping a broken build) diagnosed through it. - UI verified with Playwright, including the schema renderer at phone width. ## Known gaps, stated rather than hidden - `mod.ts` (3.7k lines) and `actions.ts` (2.4k) are past a comfortable size. The seams exist in the comment banners; splitting them is a large no-behaviour-change diff I did not want inside this one. - Compose file editing stays web-only. A schema form cannot show a diff, a stale-sha conflict, or what it is about to overwrite; a test asserts no schema form posts to `/file` or `/label`. - Four short tables (engine drift, network members, container mounts, image layers) did not get sort controls — none is a list you scan for an outlier. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
A service pinned to `postgres:16.4` whose registry now serves `16.6` has
nothing to pull, so `updateAvailable` is false and the row read "up to
date" while a neutral grey badge beside it said `16.6 exists`. Two claims,
one of them wrong, and the quiet one was right.

The badges become `warning`, and the container's `updateText` — the one
string the schema clients render — now says which version exists and why
it is not being offered: pinned (edit the compose file) or outside
UPGRADE_SCOPE. It sits after the pull/restart cases, so a real pending
update still wins the line.

The stack card gains a `newer` count for the same reason: it summed only
`updateAvailable`, so a card over three yellow rows badged "up to date".
Counted per image rather than per container, because one version deployed
three times is one version to decide about.
Three documents, in the shape of `tasks-ui-stories.md`: what the docker
module's disk surface is for (S1-S24), what every other corner it does not
serve yet is for (M1-M23), and the build order that turns both into work
items with named test files.

The stories carry the failure each exists to prevent, because a story
without one is decoration. The plan carries the decisions already taken —
measuring cadence, volume default selection, the label namespace, the
self-update mechanism — so that no work item reopens them, and a status
table that survives being picked up by someone who was not here.
Every test built its own `any`-shaped context, so each knew a different
subset of the contract. `mod_test.ts` matched engine paths with
`startsWith`, which makes `/v1.44/containers/json` and
`/v1.44/containers/<id>/json` the same route; `actions_test.ts` had a
three-method `TaskHandle` whose `step` ignored `compensate` entirely, so
nothing there could have caught a rollback that ran in the wrong order.
Both stubs also dropped `requireRole`, which left "a viewer is refused"
untestable at the level it is decided.

`test_ctx.ts` models the three things a stub gets wrong and a test then
believes: compensation ordering (registered before the body, reversed on a
throw, exactly as `tasks/service.ts` does it), the middleware chain, and
replace-by-key progress through the SDK's own `mergeProgress`. Routes on
`fakeDocker` match on method and on the path with the API version prefix
and query string stripped, so `"/volumes"` answers the filtered call the
readers actually make — and an unrouted path THROWS rather than answering
plausibly, which is what turns "nothing was fetched before the guard" into
an assertion.

The migration keeps every existing assertion. Two are now stronger for
free: the pin guards assert the host call log is empty rather than relying
on a stub that throws, and the persistence test seeds its second boot with
what the first one actually wrote, so the set() and get() keys are checked
against each other rather than against a literal copied into the test.

Also records in the plan how `done(<sha>)` can name a commit: one later,
since a row cannot carry its own sha.
116 kB of real answers from the rig's engine, for the storage parsers to be
tested against instead of objects a test author invented. The shapes that
break a parser are the ones nobody would think to write down, and three of
them were already waiting here:

- `/images/json` answers `Containers: -1`, meaning "not counted" — only
  `/system/df` counts them. Read as a count it makes every image on the
  host unused, which is a prune preview offering to delete all of them.
- engine 29.x reports an untagged image as `RepoTags: []`, not the
  `["<none>:<none>"]` the story assumed, so `dangling` has to mean "no
  tags".
- compose's `tmpfs:` never reaches `Mounts`; it is `HostConfig.Tmpfs`, so a
  mount table built from `Mounts` alone drops it silently.

All three are recorded in the plan's Open list against the WI that will hit
them. `containers_sized.json` carries a fourth for free: `SizeRw` is ABSENT
on most rows rather than zero.

The seed stacks have no volumes, no bind mounts and no superseded, dangling
or layer-sharing images, so capturing against them records empty answers.
Section 6's scenarios bring those shapes into existence and are written here
as part of the same infrastructure, plus two the section did not list:
`mounts.sh` for the inspect shape and `layers.sh` for a dangling image and a
pair sharing layers — `derived:latest` is 7.80 MB of which 7.80 MB is
shared, so deleting it frees four bytes, which is the whole point of the
unique-size column.

`fat-log.sh` does NOT use section 6's `yes | head -c 200000000`. That is 100
million one-character lines, and json-file wraps each in ~70 bytes of
envelope: it wrote 1.5 GB in ten seconds onto the developer's real disk
before it was stopped, heading for about 7 GB. The committed script writes
200k lines of 1 kB and lands at 214 MB.

Capture is a script rather than a snippet in the plan, because a shape that
has to be reproduced by hand is one nobody reproduces, and the parsers then
keep passing against a recording of an engine three versions old.
An invalid schema makes `ctx.ui.register` throw, and a module whose
register() throws is disabled at load — surfaced as a notification and on
/system, which is nowhere a green suite would show it. Running core's own
`validateModuleUi` over `dockerUi` moves that from "found on the next
deploy" to "found in a second".

The page list is asserted whole rather than by containment. A schema-only
client renders exactly these pages, so one lost in a refactor is a control
that vanished from the phone with every web test still green; asserting the
set catches the addition too, which is the reminder that a new page is a
mobile decision. Every WI that adds one edits the list in its own commit.

Verified by mutation rather than by writing it red, since the schema was
already valid: a typo in a page path fails the list assertion and an unknown
block type fails the validator.
The unit tests prove the parsers and the views. Nothing proves the last
link: that a value the server computed reached a rendered page. Every defect
in that gap looks the same — green backend, green typecheck, a card showing
an em dash.

Split, because the halves cost differently. `docker.ts` checks everything
that needs no renderer and refuses to let a smoke run mean nothing: the app
answers, the module is in /api/core/modules, /stacks holds exactly the fake
projects (a run against a real host would assert `jellyfin` against
somebody's actual containers), and /modules/docker/index.js is served —
which is the `build:shell`-alone failure that blanks every page while every
other gate stays green. The browser half is driven with the Playwright MCP
browser, so no driver dependency enters the repository for a gate that runs
by hand a few dozen times, and `deno install --frozen` keeps answering for a
lockfile nothing was added to.

`docker_checks.ts` holds the list and names the WI answerable for each
check, so a string that starts failing has an owner.

Two things learned driving it, both now in the plan: `location.href` inside
an evaluate destroys the execution context mid-call, so each page needs its
own navigate; and checks have to be written from what the page RENDERS.
Writing these three from the route's JSON got two wrong — a stacks card
whose containers are all up prints no ratio at all, and the stack page lists
services, not container names. Corrected and all three verified passing.
Six readers and five parsers, split the way the cost is: `/system/df` walks
every layer on the host, so the fetches stay thin and the arithmetic — the
part that needs testing a dozen ways — stays pure.

One rule runs through all of it: absent and zero are different answers. The
engine omits `SizeRw` rather than sending 0, sends -1 for a volume size it
did not compute and for a container count it did not take, and sends null
where a list is empty. Each becomes `null` here and stays null to the page,
because a volume of unknown size added into a total as 0 is a total that is
quietly short by however much nobody measured, and a container whose layer
was never measured would otherwise read as one measured and found empty.

Three specifics worth naming:

`containers` is null from `/images/json`, which answers -1 for every image
because it does not count them — only df does. Read as a count it marks the
entire host unused, and the prune preview then offers to delete all of it.

`uniqueBytes` is clamped at zero rather than asserted. Some drivers report a
SharedSize above Size, and "deleting this frees -4 MB" is not a number a
page can render. The fixture makes the honest case visible: alpine:3.20 is
entirely shared with the image built from it, so deleting it frees nothing.

`dangling` means no tags, not `<none>:<none>`. Engine 29 answers
`RepoTags: []`; older ones use the placeholder, and taking it literally
leaves the image out of every dangling listing.

Readers are tested for the one thing a thin wrapper gets wrong that a type
checker cannot see: which path they ask for, query string included. A df
call that lost its `type=` filter parses perfectly and costs a full layer
walk on every prune.
`docker system df` omits container log files and knows nothing about bind
mounts, which between them are routinely most of what filled the disk — a
40 GB json-file log under a container the usage page calls 200 MB is the
canonical case. Three readers: log sizes through the host-root mount, the
data root's filesystem through `df`, and `du` over a bind mount.

`df -P` is parsed by anchoring on its four numeric columns and the
percentage, not by splitting on whitespace. POSIX puts the mount target last
precisely so it may contain spaces, and a network device may hold them too;
a split-based parser reads `/mnt/my backup` as a mount called `/mnt/my`,
which is then the wrong filesystem sitting under a threshold alert.

The reported mount has the host-root prefix stripped back off. `df` runs
through the bind mount, so it answers `/host/root` — a path the operator has
never typed and cannot act on.

Reads go through `ctx.exec` and `makeHostPath`; only `du` goes through
`ctx.host.run`. The plan spelled the log path as `hostRoot + logPath`, but
`makeHostPath` is the codebase's single definition of that join and already
knows a `direct` strategy means the root is "/" whatever OPSDECK_HOST_ROOT
says — a second copy of that arithmetic is how the last regression happened.

Nothing here throws on the measuring path. A rotated-away log, a data root
`df` cannot see, a `du` refused for permissions: each is null, rendered as
unknown rather than as zero. One unreadable log file is not a reason to have
no storage page, and a throw would take the whole snapshot with it.

`du` probes before it runs, so an unavailable host answers with a reason
instead of filling the audit log with failures nobody caused.
Where a container keeps its data, how its output is written, and where the
image said the data should go. Three facts the stacks poll already had an
inspect open for and threw away.

`parseMounts` reads BOTH places the engine keeps them. `Mounts` holds binds
and volumes; compose's `tmpfs:` shorthand never reaches it and lives in
`HostConfig.Tmpfs` instead, so a table built from `Mounts` alone shows a
container's disk and silently omits the RAM it also writes to. Only
`--mount type=tmpfs` appears in both, which is why the merge deduplicates by
target.

`max-size` stays the operator's own string ("10m") rather than being parsed
to bytes. It is echoed back into a compose snippet the operator copies, and
a value round-tripped through a byte count comes back as 10485760.

`unmountedDeclared` is the story-4 warning: a `VOLUME` the image declares
with nothing mounted at it is a container writing its state into its own
writable layer, where the next recreate throws it away. A mount at a PARENT
of the declared path counts — a volume at /var/lib/postgresql keeps
/var/lib/postgresql/data off the layer just as well — and the comparison is
segment-wise, so /var/lib/postgres is not a parent of
/var/lib/postgresql/data.

The inspect stays enrichment: when it fails, every field it feeds keeps its
empty default and the container is still listed, which is the one thing the
stacks page exists to show.

`stripImageDefaults` now takes `Pick<ImageConfig, "env" | "labels">`.
`volumes` is on ImageConfig for the mount table, and asking three callers to
state a field the function never reads is how a test comes to assert a value
that decides nothing.

The fake containers gain shapes the storage stories need: an unbounded
json-file log on jellyfin, a rotated one elsewhere, a bind of
/var/run/docker.sock on traefik, and a postgres that declares
/var/lib/postgresql/data with nothing mounted at it.

Done before WI-12 rather than after it, and the plan's order is updated to
match: the snapshot's Logs kind, its per-stack bind mounts and its marking
of OpsDeck's own volumes are all built from these fields. In the original
order three WIs would each have shipped a Logs kind reading zero over an
empty set, and this one would have gone back to rewire an action it did not
write.
One measurement of where the disk went: every kind, what a prune would
free, per-stack attribution, and the series that turn a creeping leak into a
slope.

`buildSnapshot` is pure and takes everything it needs, because the defects
here are arithmetic ones that go unnoticed: per-stack totals summing to more
than the host, a reclaimable figure including the image OpsDeck needs in
order to restart, a volume of unknown size counted as zero. The fixture
tests assert invariants rather than numbers — a number copied out of a first
run only proves the code still does whatever it did that day.

Two rules run through it. Unknown is not zero: a volume df did not size, a
log that could not be stat'ed, a filesystem df could not see, each stays
null and is left out rather than added as 0. And reclaimable excludes
OpsDeck: its image, writable layer, volumes and log are marked and kept out
of every reclaimable figure while still counting toward what the disk holds,
because a total that hid them would disagree with `df` and be the first
number an operator stopped trusting.

`superseded` is deliberately narrower than "unused": nothing runs it AND
either it has no tag or a RUNNING container is on the same repo at another
image id. Plain "unused" is every image of every stopped stack, and offering
those as cleanup is how a stack that was down for a week cannot come back.

Per-stack image bytes count only images exactly one project uses; one two
stacks share is charged once to `shared` and the page says so. That is what
keeps the parts from summing to more than the whole.

`storage-measure` is a schedule, not a request handler — `docker system df`
walks every layer, which would make the storage page the slowest thing in
OpsDeck and re-walk the host on every refresh. It drops on self-conflict
(two walks at once only slow each other), offers no cancel (the engine
finishes the walk whether or not anyone is listening, and a dead button is
worse than none), and expects a success within twice its cadence: one missed
run is a blip, two is a page quietly serving last week.

Steps are engine, host, assemble, publish, persist — persist last, always,
because a storage write queues behind whatever metrics flush is on the one
DuckDB connection and must not delay the publish. A failed engine step
leaves the previous snapshot alone rather than replacing it with an empty
one.

Metrics tags stay single-purpose: `storage.stack` is tagged by project and
carries no host component. `ctx.metrics.write` has one tag slot, `hostId`
lives on the persisted record as the plan's host-key decision asks, and
inventing a separator now would bake a wire format nothing can read.

The fake path builds the snapshot INPUT and runs the same assembly, so dev
without a host exercises the real arithmetic rather than a hand-written
answer.
Seven routes, all answering from the last measurement and never by
measuring: a page that paid for a df walk on load would be the slowest thing
in OpsDeck. Each answer carries when it was taken, which is story 2's whole
point.

Redaction moves out of mod.ts into redact.ts and grows a second rule. Env
values and labels were already admin-only; host paths now are too. A viewer
may see every number on the storage page — that is story 22 — but a
mountpoint, a bind source, a log path and the data root together are a map
of the operator's filesystem handed to a role whose definition is that it
changes nothing. Nulling them keeps the row: the volume is still listed,
still sized, still attributable, it just stops saying where it lives.
`redactPaths` is applied to every storage route rather than to the ones that
looked dangerous, because a mountpoint withheld on /storage/volumes and
served on /volume/:name is the same disclosure through a different door.

Each list is sorted by the number its story says the operator came for: what
deleting an image frees (unique, not total), what a container has grown,
what a volume holds, what a log has cost.

`measuring` is asked of the task store rather than tracked in a local. The
action has no target, so `isBusy` cannot answer for it — the plan's test
assumed it could — and a local would miss a run the scheduler started or one
another client set off. A measure POST that is dropped for a run already in
flight hands back that run's id rather than an error: the caller wanted a
measurement, and one is happening.

`formatBytes` here is powers of 1000 with SI suffixes, matching
`docker system df`. An operator comparing the two should not have to work
out why OpsDeck says 11.2 GiB where docker said 12.1GB.

The route tests drive the real `listStacks` against an engine mock built
from the fake stacks, rather than handing the module its containers
directly — otherwise the volume page's "who mounts this" would be asserted
against data that never went through the parser that produces it.
Story 24's point is that the edge cases can be seen before a real disk is
full — the first look at the volume-prune dialog must not be on production.
So the fake measurement now holds all of them, and the tests assert the fake
is INTERESTING as well as consistent: a fake that reclaims nothing cannot
show the prune dialog working.

The three images the preview needs are listed rather than derived. The
interesting ones are exactly those no container points at, so deriving the
image set from the containers cannot produce them.

Story 24 asked for a superseded `postgres:16`, and the fake's postgres is
deliberately stopped — which is precisely the case where superseded must NOT
fire. So the superseded example is a `jellyfin/jellyfin:10.8` whose repo is
running at another id, and postgres carries the negative case instead: an
older `postgres:15` that stays out of the preview because its stack is
merely down. Both halves of the rule are now visible in dev, which is better
than the story asked for.

`fsLevel` loses its invented second band. It had "warn" at the threshold and
"error" ten points above, which is a number the operator never chose sitting
between the one they did and the colour they asked it to produce. Stories 8
and 9 describe one threshold and red past it; that is what it does now. The
notification level and the dot colour are separate things, and WI-18 still
raises a `warning`.
One page for where the disk went: the composition of Docker's footprint, the
data root's filesystem against its threshold, the reclaimable total, and
five tabs — images, container layers, volumes, logs, bind mounts.

Every verdict is served, not recomputed here. `storage_model.ts` holds only
what the READER chooses: the image sort, and the one rollup that keeps forty
rows from having to be read. `kindRows`, `reclaimableTotal` and `fsLevel`
live in backend/view.ts, because the phone renders the same facts from the
same route and two implementations of "is this disk in trouble" drift — and
only one of them would be the one on the phone.

Running the page found four defects the type checker and 340 unit tests did
not:

`console: true` with `target: () => null` is refused by core, which DISABLED
the whole module at load — a notification and a line on /system, nowhere a
green suite looks. The fake's `tasks.define` now enforces the two rules core
enforces at registration, so this class cannot reach a running server again.

The page never called `load()`. `liveRefresh` polls only while the live
channel is DOWN, and the "storage" topic fires once per measurement — hours
apart — so the page sat on its skeleton until the next one landed.

In fake mode the snapshot was neither persisted nor reloaded, and the
schedule's row survives a restart so `firstRun: "immediate"` does not fire
again: after the first boot, `OPSDECK_FAKE_DATA=1` showed "not measured yet"
for six hours, which is exactly the state story 24 exists to keep a
developer out of. The fake snapshot is now built at boot.

And the fake gave a stopped container a 2.79 GiB writable layer inside an
858 MiB rootfs. `SizeRootFs` includes the layer, so that is a row no real
host can produce — and the page rendered it without complaint.

`formatBytes` in view.ts becomes binary to match `@opsdeck/ui` and the
schema's `format: "bytes"`. SI would agree with `docker system df` and
disagree with the numbers beside it on this very page, since the tables
format client-side.

The smoke check list gains a `click` field. A tabbed page shows one pane at
a time, so a check naming strings from all of them could never pass; naming
the click is honest about what the reader has to do to see them. All four
storage checks verified passing in the browser.
Story 6 turns "Docker uses 80 GB" into "media is 40 of them": each stack
card carries its footprint, and the stack page carries the breakdown —
images, container layers, volumes, logs — with the bind mounts listed
underneath, each either showing its cached `du` result or saying "not
measured", because that path may be 4 TB of media and measuring it is a run.

Story 7 gives the container page its mount table: type, source, target and
read-only, with volume names linking to their volume page and bind sources
linking into the file browser — but only where that module is actually
installed, since a link to a module nobody has is a 404 dressed as a
feature. Beside it, the writable layer and the log with its rotation, or
"no rotation" where the file has no ceiling.

Story 4's warning is the one worth having: postgres declares
/var/lib/postgresql/data as a VOLUME and mounts nothing there, so the page
says in words that it is writing into its own layer and a recreate discards
it.

`relTime` moves into the module's shared frontend file. Three pages now say
when something was last measured, and the storage page had already grown a
second, subtly different spelling of the same sentence.

Verified in the browser: the stack card shows 40.6 GiB for media and
4.67 GiB for infra, the stack page's bind mounts list /srv/media and
/srv/downloads as unmeasured, jellyfin's card shows a 37.3 GiB json-file log
badged "no rotation" beside a read-only /srv/media bind and a tmpfs, and the
postgres page carries the declared-volume warning.

Two smoke checks had to be rewritten from what the page RENDERS rather than
from the source: `Card` uppercases its title and the info grid uppercases
its labels, so "Storage" and "Writable layer" never appear literally. The
check file now says so.
Story 9's one line on the overview card — total, reclaimable, and the data
root filesystem's percent — and story 8's threshold notification.

`fsAlert` reports the EDGE, not the level. `ctx.notify` deduplicates
identical text under one key, but this message carries a percentage, so
every measurement past the threshold is "changed text under the same key",
which it correctly treats as news: the operator would get a push every six
hours until they freed space. The state is persisted for the same reason —
a module-level flag does not survive a restart, and a restart would re-raise
what the operator already saw. Both are written down in
`NotificationInput`'s own docs and both are easy to read past.

An unknown percentage is not "under". A df that could not run leaves the
edge exactly where it was, so a measurement that cannot see the disk neither
raises an alarm nor, worse, clears one that is still true.

The clear uses the same key, so it replaces the warning rather than sitting
beneath it: someone who fixed the problem should not be left with the alarm
still on screen.

The alert step sits after publish and before persist — the alert is the
whole point of measuring a filling disk and must not queue behind a DuckDB
write.

The widget line was verified at the slot's narrowest. At 260px it clipped by
four pixels, and what clipped was the percent — the part that decides
whether to act, and the exact thing the CSS comment beside it said must
never be what gives way. The percent is now unshrinkable and the middle
ellipsizes instead; checked at 260, 300 and natural width, with no vertical
overflow either.
Story 23: the same storage picture on a client that renders only the schema.
A `fields` grid of the kinds, a `usage` bar for the data root's filesystem,
lists of volumes and logs, "Measure now" as an action, and a `volume/:name`
page reached from either. The container hub gains the Mounts list, so story
7's table is not web-only.

Every number bound here is one the server already computed. The schema binds
plain paths and cannot do arithmetic, which is the reason `view.ts` computes
the totals and the verdicts at all — so the phone and the web page show the
same figures rather than two derivations of them.

Logs are their own field rather than folded into the total, labelled
uncounted, because `docker system df` does not count them and a total that
did would be one the CLI contradicts.

`VolumePage.svelte` lands here rather than with the export controls in
WI-25. The web shell does not fall back to the schema for a route a custom
frontend does not define — a module with its own pages owns all of them — so
WI-17's mount-table volume links were "Page not found" on the web while
working perfectly on the phone. That was a dead link I shipped two commits
ago, and leaving it for six more work items to fix was not the right trade.
WI-25 adds export and import to the page instead of creating it; the plan
records that.
The engine has no dry run, so the plan IS the confirmation: the dialog lists
it and the button states the bytes. That makes `prunePlan` pure and
exhaustively tested, and its two most important rules negative.

OpsDeck is ABSENT from every plan, not merely unticked. Its own container is
stopped, its image is used by nothing else and its volume has no live
reference, so without the rule three of these plans would each have offered
to delete the thing the manager restarts from. Absent rather than unticked,
because an unticked row is one click from being ticked.

"Unused" is not "unwanted". A volume whose owning project still exists is
listed, NOT ticked, and says why — story 12's nextcloud_db, gone because
nextcloud was stopped for a week. Images get two controls rather than one:
"superseded" is a tag a running stack moved past, "unused" also sweeps the
images of every stopped stack and says on each row that the next compose up
re-pulls it.

Images are planned at their UNIQUE size, since that is what deletion frees.
Containers the engine will remove itself (`--rm`) are left out: offering one
is a race with the engine dressed up as a choice. Volumes are removed per
NAME rather than through `/volumes/prune`, which from API 1.42 prunes only
anonymous volumes by default — the selection has to be ours.

The guard runs before the first DELETE and refuses three ways: any live
stack update (an unused-images sweep mid-pull deletes layers the pull is
still assembling), any id that is OpsDeck's, and any id a FRESH plan does
not hold — the browser's list came from a measurement that may be hours old,
and a tick was a decision about what that list said.

A 409 on one item is recorded as a warning row and the sweep carries on; the
run succeeds, and the bytes it reports are only the ones actually freed. One
thing being in use is not the sweep failing.

The volume phrase is enforced by the ROUTE, not only by the schema's
`confirmPhrase`: a client that does not implement the phrase falls back to a
one-tap confirmation, and a tap is more reflexive than a click.

The last step re-measures in full rather than with the scoped `type=` walk
the plan suggested. A scoped df answers about one kind, and splicing that
into the existing snapshot is a merge whose failure mode is a total that is
quietly wrong; a prune is interactive and rare, so one more df walk is the
cheaper mistake. `takeMeasurement` is now shared with `storage-measure`,
called directly rather than as a nested run because the prune already holds
the lock a measurement would wait for.
Story 13, built as one id through the sweep actions rather than as three new
routes. The guard, the self-refusal, the fresh-plan re-check and the run
record are then identical whether the operator removed one image or thirty —
a targeted fix should not be a second code path with its own omissions.

No `force` on the image delete. Force UNTAGS rather than deleting, so it
reports success and leaves the bytes exactly where they were, which is
rarely what anyone meant by "remove this image".

`?wait=1` answers with the outcome instead of a run id, because a row that
deletes itself wants to know whether it did.

A refused delete comes back as 409 naming the container holding it.
`conflictContainers` pulls the id out of the engine's prose — "image is
being used by running container 6c75a504be81" — so the UI can say which one
rather than showing the operator a sentence with two hex strings in it.

`containerRemoveCapability` carries the warning that matters: removing a
compose-managed container looks permanent and is not, since the next `up`
recreates it. Deleting one to be rid of it and deleting one to reclaim its
layer are different expectations of the same click, and only the second is
met.
Story 14. `truncate -s 0`, not `rm`: the json-file driver holds the file
open, so deleting it frees nothing until the container restarts — and
restarting the service you need most at the worst possible moment is exactly
the fix this exists to avoid. Truncating returns the bytes immediately and
the container keeps writing to the same inode.

Only json-file. The `local` driver writes a compressed, chunked format, and
emptying that leaves the driver reading a file it no longer understands.

The run re-stats afterwards rather than assuming: a truncate that silently
did nothing would otherwise be reported as having freed the whole file.

The confirmation says the history is gone, because that is the trade and it
is not reversible. What it buys is time — so the card carries the three
`logging:` lines to paste into the compose file, rather than a sentence
describing them. Truncation is not the fix; those lines are.

Verified in the browser: jellyfin's 37.3 GiB json-file log shows "no
rotation", the "Empty it" control, the snippet, and a confirmation that
states the history is lost.
Story 15: stopped containers older than a day, superseded images, and build
cache older than a week, each on its own schedule, all disabled at first
declaration — a schedule that starts deleting things because someone
upgraded is not opt-in.

The absences are the design, so they are asserted in a test and stated in
the route's own response. Volumes are data. "Unused images" is every image
of every stopped stack. Automation doing on a timer what a human is
deliberately made to hesitate over is exactly the failure story 15 exists to
prevent, and `/storage/schedules` says why each is missing, where an
operator would go looking for them.

A schedule cannot compose a selection: the row was written months ago and
holds args, not ids. So an OMITTED `ids` now means "the default selection,
computed at fire time" — the same list the dialog pre-ticks. An `ids` that
is present and empty stays an error, because that is a client sending
nothing, which is a different thing. A scheduled run that finds nothing to
do says `nothing-to-prune` rather than falling back to everything.

`setEnabled` takes the module-local name, not the module-qualified key
`list()` returns — the version that accepted either and guessed is what
produced `docker:docker:updates`.
The dialog IS the confirmation. The engine has no dry run, so there is
nothing to show afterwards and nothing to undo: what the operator ticks is
exactly what gets deleted, and the button states what that frees. Each row
carries the reason the server gave it, so the volume whose stack is merely
stopped explains itself in place rather than in a footnote.

Five separate controls, never one with a kind picker — clearing the build
cache must not be one mis-click from removing a volume. Each carries the
wording that makes its own blast radius plain, and "all unused images" says
outright that it includes stopped stacks' images and that the next compose
up re-pulls them.

The plan is re-fetched when the dialog opens rather than reused from the
page behind it: that list came from a measurement that may be hours old, and
the server re-checks every id against a fresh plan anyway. Showing what will
happen beats being corrected by a failure.

`prune_model.ts` keeps the checklist and the button in step, which is the
one thing that must never drift, since the number on the button is what gets
read before the click. `togglePlan` returns a new array rather than mutating
— an in-place flip is how a total updates in one place and not the other.
The phrase is trimmed and case-insensitive: the pause is the point, not the
keyboard precision.

For the schema, a prune action posts no body, and the route now reads an
ABSENT `ids` as "the default selection" — the same rows its preview list
shows. A present-but-empty `ids` stays an error, because that is a client
sending nothing. Two preview lists are bound on the storage page, since the
one thing story 23 refuses to approximate is giving the phone an action
without its plan.

Verified in the browser: superseded offers two items and "Free 2.16 GiB";
volumes lists the orphan and the anonymous one with their reasons, omits
`media_config` entirely, and keeps the confirm button disabled until the
phrase is typed.
Story 19: the escape hatch that makes "unused" and "unimportant" different
clicks.

The engine has no volume-level archive endpoint — the only way to read a
volume's bytes over the API is `/containers/{id}/archive` against something
that has it mounted. So the whole problem is "which container do we read
this through", and the answers are ordered by how little they disturb: a
stopped holder first, because tarring under a live writer archives a
half-written moment; then a running holder, which at least owns the data;
then a container we create.

The addressed path is the mount TARGET inside the container, never the
volume's host mountpoint. The archive endpoint resolves paths in the
container's own filesystem, so handing it /var/lib/docker/volumes/x/_data
tars whatever happens to be at that path in there — usually nothing.

A throwaway reader uses an image already on the host and is never started.
Pulling one would make "download my data" depend on a registry being
reachable, which is often exactly why someone is taking a backup.
`AutoRemove` is off so it cannot race our own delete and hide the failure if
it loses, and the delete runs in a `finally` — a throwaway that outlives its
request is a container the next prune offers to delete.

Export is not a run: the bytes go to the caller, so there is nothing for a
run row to hold. Import is, because it writes and outlives its request; the
upload is staged to a file, and the staged file is removed in a `finally`
whatever happened.

Importing over live or non-empty data is refused rather than merged. A tar's
files landing beside what is already there leaves neither the backup nor the
original, which is the worst of the three possible outcomes.

The cancel is honestly limited and the comment says so: aborting stops the
PUT, and what already landed stays. An import is not atomic and pretending
otherwise would be the lie.
Story 20, the one gap that would otherwise send someone back to the
terminal. Not a run: one engine call that either works or does not, and a
run row for it would be terminal before anyone could open it.

The name is checked here rather than left to the engine, so a bad one comes
back naming the rule instead of as a 502 carrying a regex. A `form` block
puts it on the phone too, which is the whole reason the rule lives
server-side.
Stories 6 and 21. Bind mounts are where the data usually is and the one
thing `docker system df` cannot see, but measuring one means walking it —
and the path may be 4 TB of media. So it is a run, started per path, cached
and dated, with a button on the row rather than a number nobody waited for.

The guard is the part that matters. `du` runs on the host as root, so the
path must be a bind source this module already knows about — never whatever
the caller sent. Without that this is an authenticated "how big is any
directory on the host", which is a different feature nobody asked for. A
volume's mountpoint is refused too, and there is a test for it: the bind
list is the allow-list, not "paths we have heard of".

The cache is its own route rather than folded into the snapshot. These are
individual answers taken on demand and dated one by one, and putting them in
the snapshot would make a `du` from last Tuesday look like part of this
morning's walk.

Cancel is offered and honestly limited, and the run log says so: the SDK's
`ExecOptions` carries no signal, so cancelling ends the run and leaves `du`
to finish or hit its ten-minute timeout. The operator gets their page back,
which is worth having; claiming the work stopped would not be.
`deno lint | tail -1` prints "Checked 233 files" whether or not it found
anything, so seven work items ran with lint effectively unchecked. The full
`deno task ci` at the storage phase boundary is what caught it, which is the
argument for running it at phase boundaries rather than only at the end.

Four were dead weight left by decisions taken later in the same work item:
`DF_SCOPE` became unreachable when WI-20's re-measure went full rather than
scoped, `StackView` when redaction moved out of mod.ts in WI-13, and
`pruneFake` stopped needing to be async when its route tests moved to
mod_test.ts. None of them changed behaviour; all of them were noise a reader
would have had to discount.

The plan's gate step now says to check the exit code, not the last line.
Story M1. The stacks poll already opened an inspect per container and threw
most of it away: restart count, started and finished, OOM-killed, user,
command, platform, PID, limits, networks with their IPs, and the ways the
container can reach past its own boundary.

An OOM kill takes the HEADLINE rather than a field on row nine, and that is
the point of it. The kernel killing a container for memory and the container
crashing look identical in `State` — both "Exited (137)" — and the remedies
are nothing alike. `statusText` now says "OOM-killed (exit 137)" and the
verdict is `error` whatever the restart policy would otherwise make of it.

`Memory: 0` and `NanoCpus: 0` from the engine mean UNLIMITED, so both become
null. A page rendering "0 B" as the memory limit would describe a container
that cannot allocate anything, which is the opposite of what the engine
said.

`risks` is an inventory, not a judgement — traefik mounting the docker
socket is how traefik works. The question an operator actually has is "which
of these could do that to my machine", and answering it today means reading
four separate parts of an inspect. Capabilities are named one by one, since
"added capabilities" says nothing about which.

Closes the Open item from WI-15: five files hand-built a whole
`ContainerInfo`, and this WI added fourteen fields at once and broke all of
them. `fakeContainer` now lives beside `fakeCtx`, and `fake.ts` has its own
`base()` so the app's fake data does not depend on a test file.

The container page header rendered `status` rather than the server's
`statusText`, so the OOM headline was computed and then discarded one layer
short of the screen. Found by driving it.
Story M3. A container dying every forty seconds reads "Up 12 seconds" at
every thirty-second poll, so `state` is blind to it by construction — the
only thing that sees it is `RestartCount`, which only goes up.

The verdict counts restarts that happened INSIDE the window rather than
subtracting the oldest count from the newest. A container that crashed
twenty times last week and none since is not looping, and the subtraction
would say it was, forever, with an alert that never cleared.

A count that goes DOWN is a recreate resetting the counter, not negative
restarts. Subtracting there would cancel out a real loop that began right
after the recreate, which is a common enough shape to be worth the two
lines.

Restarts during a stack update are not sampled at all. An update restarts
containers on purpose, and counting those would make every update look like
a loop for the following hour — the fastest way to teach an operator to
ignore the alert.

The alert is its own key and its own edge, because a looping container is
`running` at every poll and the state machine would never have fired. The
message carries a count, so re-raising while it is still looping would be
"changed text under the same key" and push every thirty seconds; it raises
once, and "has settled" replaces it half an hour after the last restart.

The ring is bounded by age as well as by count — a fast poll would otherwise
fill it with the last four minutes and a slow one would keep last week — and
persisted, because a loop that survives a module restart is exactly the one
worth knowing about.
Captured in one rig session for the work items that need them, so the rig
does not have to come up five more times.

`inspect_unhealthy.json` is a check that actually fails: nine consecutive
failures, five probes at exit 1 carrying real stderr. The existing
`inspect_store.json` has a healthy one, and a streak cannot be tested
against five successes.

`stats_1/2.json` are a pair one second apart from a live container, so the
CPU formula has real deltas to work from — and they carry an honest gap: the
rig's nested engine reports no `memory_stats.usage`, no `.limit` and a null
`blkio_stats`, on every container. That makes them the right fixture for
"the engine did not say", which is the case a formula turns into NaN, and it
means the memory arithmetic has to be tested against a hand-built sample.
The README says so.

`stats_idle.json` is the same endpoint against a container with every
counter at zero — the shape that makes a naive cpu percentage divide by
zero.

`events.ndjson` carries a die with `exitCode: "3"` as a STRING, which is how
the engine sends it. `pull_progress.ndjson` is a real pull with sized
`Downloading` lines, a `Digest:` and a `Status:`.

`--until now` is not accepted by this engine's CLI, so the event capture
goes through the API with unix timestamps — which is how the module reads
events anyway.
Story M2. The check command, its interval, timeout and retries, the last
probes with their exit codes and output, and the streak as a sentence: "3 of
3 failed since 14:02".

The streak is stated against the retries the check was GIVEN, because the
number alone means nothing — 1 of 3 is a container inside its own tolerance,
about to recover or not; 9 of 3 has been broken for a while.

The unhealthy notification now carries what the check printed. A push saying
only "docker health check is failing" sends the operator to a page to read
the one line that could have come with it. The LAST non-empty line, since a
check that runs a script prints its progress and then its error, and
truncated at 200 characters so a check that dumps a stack trace does not
become the notification.

`Test: ["NONE"]` is an image saying the inherited check does not apply, not
a check to describe — reading it literally would put an empty Health card on
the page.
Story M4. The engine's event stream has no history — ask it about the last
five minutes and that is all there is — so the events the refresh poll
already reads are kept instead of being consumed for a refresh trigger and
dropped. An operator arriving at nine with "it restarted at some point last
night" needs exactly the thing the stream throws away.

Keyed by container ID, not name. A compose recreate makes a new container
with the same name, and "what happened to this container" must not silently
become "what happened to everything that has ever had this name". The STACK
view spans them instead, which is the right granularity for "what happened
to paperless last night".

Bounded twice, because each bound alone allows a failure the other prevents:
a crash-looping container produces thousands of events an hour and would
push every other container's history out, while a quiet host would keep one
event from March forever.

`exec_*` is dropped. On a container with a ten-second health check those are
99% of the stream — every probe the engine runs, plus OpsDeck's own shell
sessions — and none of them is the container's history.

Events now carry `timeNano` where the engine sends it. Two events in the
same second are routine (die then destroy), and second resolution collapses
them into one moment on the timeline.

Persisted at most every 30 s. A crash-looping host bursts every few seconds,
and each write queues behind whatever metrics flush is on the one DuckDB
connection.

A destroyed container's events survive it until retention expires them,
which is the point of keeping them at all.
Story M5. Every counter the engine exposes is cumulative, so every number
here is a difference between two samples — and every difference that cannot
be taken comes back null rather than as a plausible zero. That distinction
is the substance of this work item, because the plausible zero is always a
CLAIM: "idle", "no memory in use", "under its limit".

It is a real distinction, not a hypothetical one. The rig's own engine
reports no `memory_stats.usage`, no `.limit` and a null `blkio_stats` on
every container, so those paths are exercised by the committed fixtures. But
a measured zero IS zero: a real one-second gap in which a container used no
CPU reads 0 %, and calling that unknown would hide a genuinely idle
container. The tests pin both halves.

Memory subtracts the page cache — `cache` on cgroup v1, `inactive_file` on
v2 — because `usage` raw grows to fill whatever is available for anything
touching disk, and reporting it makes every database look about to be
killed. A limit at or above the host's memory is the ABSENCE of a limit, so
it reads "no limit" rather than 4 % of the machine.

CPU is scaled by cores, the way `docker stats` reads, so 200 % means two
cores saturated. The engine's own `precpu_stats` is preferred over our
previous sample when it sends one: that is the pair the engine measured,
rather than whatever two moments the sampler happened to ask at.

Sampling is capped at four concurrent requests. Sixty running containers
would otherwise open sixty simultaneous connections to one docker socket
every tick — a self-inflicted outage on the thing being measured — and
stopped containers are not asked at all.

Per-project sums go under their OWN series rather than into `stats.cpu` with
a `project:` tag. One series carrying both a container name and a project is
a series no query can filter honestly; it is the same reasoning as the
storage tags.

Live numbers come from the push channel, so the one long-lived stream per
container exists only while a page is open. History comes from the sampler,
which is what the schema's charts bind to — a chart cannot hold a stream.
Story M6, and the data WI-20's networks prune kind was waiting for.

Members come from the CONTAINER side. `GET /networks` answers with no
`Containers` at all unless each network is inspected individually, and doing
that would be a request per network per poll to learn something the
container inspects already told us — the fixture asserts the listing really
is empty of them, so the shortcut cannot be quietly reintroduced.

An orphan is a network with nothing attached whose owning project is gone.
The engine's own three are never orphans whatever is attached: removing
`bridge` breaks every container that did not ask for a network, and the
engine recreates it anyway.

The prune plan offers orphans at zero bytes, because a network costs no
measurable disk and a plan claiming otherwise would put a fictional number
on the confirm button. They are not one of the storage `kinds` for the same
reason, but they belong with the rest of the cleanup.

Host-network containers are marked rather than listed with a blank IP: one
is reachable at the host's address on every port it binds, which is a
different fact from "we do not know its address".

The reader lives apart from the parser, so the rules can be tested without
dragging a `dockerFetch` into every case — the same split `storage_engine`
already has.
Story M8. Age comes from the image inspect the stacks poll already makes, so
it costs nothing new.

Deliberately a second, independent verdict. A current image can still be
old — upstream may simply have stopped — and a green "up to date" on a base
nobody has rebuilt in two years is precisely what this interrupts. The two
badges sit side by side and neither suppresses the other; there is a test
that asserts exactly that pairing.

Off until somebody sets a threshold, and then the age is still reported
while only the VERDICT is withheld. "Old" means something different for
alpine and for a vendor appliance, so a default would be a colour everyone
learns to ignore.

`numberOrNull` treats an empty or unparseable knob as off rather than as
zero, which would have made every image critical the moment someone typed a
stray space into the env file.
Stories M10, M11 and M12 all need a label, so they get one namespace read by
one parser rather than three features each inventing their own — which is
how `opsdeck.autoupdate` and `opsdeck.auto_update` both end up half-working.

Every parse is forgiving of shape and strict about meaning, and the
DIRECTION each mistake falls in is the design:

A mistyped `opsdeck.update` ALLOWS. Denying on a typo would freeze a service
out of every update, which is a failure nobody notices for months.

A mistyped `opsdeck.upgrade-scope` falls back to the global default, never
to the widest. A typo must never be the reason a database took a major
version bump.

A window that cannot be parsed turns auto-update OFF rather than running at
all hours. The operator wrote a window because they wanted one, and ignoring
it would do the thing at exactly the hour they were avoiding.

Every one of those also produces a warning the row shows, so a label that is
not doing what the file says is visible rather than silently discarded.

`checkUpdates` takes a resolver instead of one scope. The check is keyed by
image REFERENCE and two services can share an image while disagreeing about
how far it may move, so the check uses the widest of them — offering the
narrower would hide a move the other service is allowed to make. What
actually happens when the move is applied is still per service.

Windows that cross midnight are the normal case, not the edge: 22:00-05:00
is two ranges, and the obvious `and` is never true.

Found by driving it: the `/stacks` LIST route was the one stackView call
site that never got the new arguments — so every badge computed correctly
and none of them reached the page they are rendered on.
Story M10. The SKIPS are the substance, so they are a pure function with
their own tests and their own row in the run: "fourteen updates in one
click" is only safe when the stack that must never be touched is visibly not
in the list, and a skip that is a mystery is worse than no button.

Order of the checks is order of authority. OpsDeck's own stack can never be
updated from inside itself whatever its labels say; a `deny` beats "there is
an update"; a stack already being worked on is not queued behind itself. A
deny on any ONE service takes the whole stack out, because an update is per
compose project and there is no way to honour it for one service and update
the rest — the skip names which service, so that is not a mystery either.

Sequential, never parallel. Each stack update pulls gigabytes and recreates
containers; fourteen at once would saturate the network, the disk and the
engine together and turn "update everything" into an outage. There is a test
that fails if anyone makes it concurrent.

A failed stack does not stop the loop — the thirteen that would have worked
are the reason the button exists — and every outcome lands in the result, so
the summary says which one went wrong.

Cancel only lands between stacks. The one mid-update finishes rather than
being abandoned halfway, which is the only clean place to stop.
Story M19. Root stays the default — it is what works in a container whose
only user is root, and what a debugging session usually wants — but it stops
being the only option, which is the whole point: "every shell is root
because there was no other button" is not a security posture.

`cmd` is one command line handed to the container's own shell, not an argv.
An operator typing `psql -U postgres` means that line; making them quote it
into a three-element array would be a form nobody uses. An explicit command
also skips the shell probe entirely: it is what was asked for, and falling
back to whatever other shell happens to exist would run something else.

The Terminal is keyed on the query, because an exec's user and command are
fixed when the session is created. Without the key the form would change the
URL and silently do nothing.

`test_ctx` gains a `handler()` accessor so a WebSocket route can be driven
without a socket — `upgradeWebSocket` is identity in the fake, so the
route's handler is the module's own callback.
Story M14. Three compose verbs OpsDeck could see but not invoke: `down`,
`restart` and `pull`. They are actions, not routes that shell out, so they
hold the same `project:<name>` lock as an update — a `down` racing an update
is two compose invocations addressing the same files.

`down` is the one with teeth. `-v` is never implied and never a default, and
the route refuses `volumes: true` unless the operator sent the project name
back verbatim. A checkbox is not enough friction for an operation that
deletes every database inside a stack and that nothing in OpsDeck can undo.
The schema action carries the phrase in its query rather than a body, which
it has no way to compose: that is not a hole, because the phrase and the
typed confirmation come from the same schema entry, and what the check exists
to stop is a body-less call that meant the harmless `down`.

Neither `down` nor `pull` has an engine-API fallback. `down` also removes the
project's network, and `pull` needs the host's registry credentials; a loop
over /containers would be a different operation wearing the same label, which
is worse than refusing with the reason compose is unusable. `restart` does
fall back — it is the one verb the engine expresses identically.

`runCompose` is split out of `composeUp` so all three get the same `--ansi
never` line parsing, the same five-line tail and the same
`classifyComposeFailure`: a stack that refuses to come down reports what
compose said, not "exit 1".

The web controls live on the stack page, not in the stacks list: that row is
already six icons wide, and three more would make the dangerous one a
neighbour of the harmless ones. The fake replay for down and restart emits
lifecycle frames only — a fake that showed layer bars would teach the
operator the wrong thing about what the button costs.
Story M15. `service-recreate` and `service-scale`, both compose-only:
`up -d --force-recreate --no-deps <svc>` and
`up -d --no-recreate --no-deps --scale svc=N`.

`--no-deps` is the whole point. `container-update` already recreates a single
container, but it walks the stack's dependency order; fixing a sidecar must
not cycle the database it talks to. `--no-recreate` on scale is the same
promise from the other side: asking for more replicas must not restart the
ones already running.

Because `--no-deps` is exactly the promise that dependents are NOT restarted,
the route asks for the same depends_on acknowledgement a single-container
update asks for, before the run exists — a run never stops to ask permission
— and the web reuses `DependentsConfirm` so the wording cannot drift.

Neither verb has an engine-API fallback: the engine has no notion of a
compose service, and scaling in particular is compose inventing container
names, so an approximation would leave containers the next `up` treats as
orphans.

`classifyComposeFailure` learns compose refusing the project itself. The
scale refusal is the one an OpsDeck button can produce — a service with
`container_name` cannot have two instances — and compose says so as a WARNING
on stderr and then exits 1, so the text is all there is. Captured from
compose 5.5.1, not written from memory. It sorts after the registry patterns:
a run that failed to pull and also warned about a name is a pull failure.

Replicas are bounded at 50 and must be a whole number, so a typo cannot ask
the host for a thousand containers, and may arrive in the query as well as
the body — a schema action has no body to put them in.
Story M12. The yellow "2026.8.3 exists" badge said a newer version is out and
that the scope will not reach it, and then led nowhere — the only way to act
on it was to go and edit the compose file by hand. It is now the control:
click it and choose patch, minor or major for that service.

Writing the label goes through the pin path, not around it — restore ledger,
`compose config -q`, commit — so a scope change leaves the same trail as a
version change. That is the point: "who widened this and when" is answered by
`git log`, not by a setting inside OpsDeck that the compose file knows
nothing about. Nothing is recreated afterwards; a scope is a statement about
future updates, and applying it now would turn a two-click answer into an
unannounced deployment.

`rewriteServiceLabel` edits the text and never re-serialises the YAML, like
every other edit this module makes to a file somebody maintains by hand. Both
compose spellings are handled because both are in the wild, and an appended
entry takes the form the block already uses. A service with no `labels:` at
all gets one at the END of its block, so the next service is untouched.
Only the first file that declares the service is written: compose merges
labels, so writing the same one into an override as well is a second source
of truth for a value that was never in dispute.

`filePatch` learns that an edit's `after` may span several lines — that is
how a line is added — so the plus count is no longer assumed equal to the
minus count. The minus side still measures against one base, because a change
still replaces exactly one line of the original.

`LABEL_VALUES` closes the set of what may be written. Reading a label stays
forgiving, because a typo must not silently freeze a service out of updates;
writing one is not, because an action that wrote whatever it was handed would
turn a typo in a request body into a line in the operator's file, and the
next read would warn about a value OpsDeck put there itself.
Story M11. `opsdeck.auto-update=true` on a service, an optional
`opsdeck.auto-update.window`, and an hourly schedule that is declared and
OFF. Declared so it can be found; off so nothing deploys itself on a fresh
install, and `enabled: false` on a redeclared schedule keeps whatever the
operator set, so turning it on survives every deploy.

Hourly because WHEN belongs to the labels: the job wakes, finds nothing
inside its window, and says so. A daily schedule would pick one hour for
everybody and make every service's window a lie.

Three rules are load-bearing and each has a test:

- **Pinned moves never happen.** Reaching a newer version of a pinned tag
  means editing the operator's compose file, and nothing unattended writes to
  those. Only `updateAvailable` — the same tag, a different digest — counts.
- **Consent is per service, and a stack update is not.**
  `compose up -d --pull always` addresses the whole project, so a stack with
  one opted-in service and one that has an update and did NOT opt in is
  skipped entirely, with `mixed-consent` and the service named. A neighbour
  with nothing to pull is not consent that was needed.
- **An unparseable window is off**, never all hours: the window existed
  because they wanted one, and running at all hours would do the thing at
  exactly the time they were avoiding.

Then the watch, which is why this is not a schedule pointed at "update all":
unattended means nobody saw it go green. Five minutes after the last stack
comes back, the touched stacks are re-read and compared to what they were
before — the snapshot is taken before anything is pulled, because afterwards
the old containers are gone. Keyed by service, not by container id: an update
replaces the container, and an id-keyed comparison finds nothing on either
side and reports every update as a complete recovery.

Only regressions notify, keyed `auto:<project>` so a nightly job that keeps
breaking one stack updates one row instead of filling the bell. An update
that FIXED something raises nothing; an unattended job that cries every night
is one nobody reads by Friday.

The settle wait honours the run's signal — a five-minute `setTimeout` that
ignores it makes Cancel a button that does nothing for five minutes while
holding the `update-all` lock.
Story M18. "What happened at 03:14" was a scroll through whatever `tail=300`
happened to cover. The engine has had `since`, `until`, per-stream selection
and `timestamps` all along; what was missing was somewhere to say so.

Bounds are parsed forgivingly and emitted as unix SECONDS, which is the only
shape every engine version accepts — RFC3339 works on modern daemons and is
rejected outright by older ones, and this has to answer the same way on both.
`2h`, an ISO timestamp, unix seconds and the milliseconds a browser hands
over without thinking all land in the same place. What cannot be read is
DROPPED: a `since` that silently became the epoch hands back the container's
entire history, which on a chatty service is the outage this prevents.

Bounded logs are a query, so follow stops while a bound is set — a stream
appending "now" to a window that ended at 03:20 is a view of neither — and
the way back is a "jump to now" button rather than a guess. The find box
filters client-side over what is already loaded, because it is a reading aid,
not a second query.

The download is its own admin-only route. A tail on a page is a glance; a
file is a copy that leaves the machine, and container logs carry tokens,
connection strings and whatever the application decided to print. It always
carries timestamps, and `tail=all` is reachable there and nowhere else,
because nothing has to render it.
Story M20. Version, API, storage driver, data root, log driver, cgroup
driver, live-restore, CPUs, memory, registry mirrors, insecure registries,
and the warnings the daemon raises about itself — on a page instead of over
ssh.

Both halves are shown, because they are the same thing right up until
somebody edits `daemon.json` and does not restart the daemon, which is the
state people are in when they come looking. `docker info` answers what the
engine is doing now; the file says what it was told to do; the page names the
difference rather than picking one and being confidently wrong.

The drift comparison is deliberately narrow and quiet. Only keys the file
actually SETS are compared — reporting "the file is silent, the daemon says
overlay2" would make every host permanently drifted. `log-opts` is shown and
never diffed, because `/info` reports the driver and not its options, and
claiming drift on a value we cannot see sends somebody to restart a daemon
over nothing. Arrays compare as sets and a trailing slash on a mirror is not
a difference; a panel that is always red is a panel nobody reads on the day
it matters.

Admin only, and not because the numbers are secret: mirrors and insecure
registries name internal hosts, and the data root is the operator's disk
layout. An absent daemon.json is `null` and no error — most hosts have none
and are not misconfigured — while one that does not parse is reported as an
error beside the facts, because the daemon either refused it or is running on
the last good one.

The rendered strings are computed server-side: the portable schema has no
boolean format and cannot turn an array into a line, so a phone built from it
would otherwise show "true" in one place and nothing in another.
Story M7. An image existed in OpsDeck only as "update available" on a
container row. It now has a page: repo and tags, digest, created, age, size,
what deleting it would free, and which containers run it — with layers and
the full inspect behind each row.

Attribution comes from the last measurement, never from the listing:
`/images/json` answers `Containers: -1` for every image, and reading that as
a count marks the whole host unused. `usedBy` is this module's own answer —
the containers it can name — which is what an operator actually wants when
deciding whether an image is safe to remove.

`image-pull` fetches without recreating anything. "Fetch tonight, recreate in
the window" is a thing operators do and the update action cannot express,
because it always recreates. Afterwards the rows using that tag read
"pulled — recreate to apply": a state this module already computed and that
nothing could previously reach on purpose.

The run is targeted at the IMAGE, not at a project — a pull touches no
running container, so there is no stack to lock, and two projects sharing a
base image must not queue behind each other. One pull per reference is the
only collision that matters.

The engine's ndjson is parsed into the same `ComposeProgress` the compose
stream emits, so the pull modal, the layer rows and the aggregate bar all
work unchanged. `Already exists` completes its layer immediately: the fixture
is a real `nginx:1.27-alpine` pull onto a host that already had `-slim`, so
seven of eight layers arrive that way, and a parser that only handled the one
it watched download would report the pull as 12 % complete and finished.

An error ends the pull by throwing rather than by returning a field, because
the engine has two shapes for it — `errorDetail` mid-stream at HTTP 200 and a
bare `message` in a 404 body — and that is exactly the pair that gets
half-handled.
Story M9. `docker compose pull` works against a private registry and
OpsDeck's own update check did not, on the same host, about the same image —
so every private image carried a permanent yellow badge. Both now read
`~/.docker/config.json`.

The credential goes to the token REALM, which is the registry v2 handshake:
the login is exchanged for a scoped bearer token and it is that token, never
the login, that reaches the registry. The realm is chosen by the registry, so
the protection is choosing a credential per registry HOST — and refusing to
send one over plain http, because a remote server does not get to downgrade
the transport its own credential travels on.

Credential helpers are named and never run. `credsStore` and `credHelpers`
point at an executable OpsDeck would have to run on the host to obtain a
secret the helper exists to keep out of a file. Those hosts are reported as
"handled by a helper", which is a different sentence from "login needed" and
points somewhere.

Secrets stay in one closure. `GET /registries` answers which hosts have a
login and never the login; a test asserts the serialised response contains
neither half, because that assertion is the only thing between a convenient
field and a token in somebody's browser history.

The credential provider is module-level in `updates.ts` rather than a
parameter: the answer is a property of the host, not of any call, and
threading it through the digest fetch, the tag listing, the tag cache and
`checkUpdates` would put one value in four signatures and hand every test a
fifth argument to ignore.

Registry errors now name the host, and the badge says
`login needed: ghcr.io`. Appended to the existing message rather than added
as a field, because the message already travels through persistence and the
projection and a second field would have to be threaded through both to say
what the first one is saying.
Story M16. The pin flow was the only writer, so a one-line change to a
compose file needed an editor over ssh even though OpsDeck was already
showing that file. Same write path — validate, restore ledger, commit — with
the operator's text instead of a rewritten image line.

Two guards carry it. The path must be one the stack ALREADY lists, not "under
the project directory": an override can legitimately live outside it, and a
prefix test permissive enough for that also permits `..` back out. And the
content the operator started from must still be what is on disk — otherwise
two admins, or an admin and a `compose up` that rewrote something, and
last-writer-wins silently discards the other change. A stale save is a 409
carrying the sha the file changed to, and the editor offers to reload rather
than doing it: the text in the box is the operator's work.

Validation runs on a temp copy BESIDE the real file, never in /tmp. Compose
resolves `env_file:`, `extends:` and every relative path against the file's
own directory, so a copy validated elsewhere is a different document; the
name carries the pid so two saves do not validate each other's text. A
compose file that does not parse never exists on disk, because the next thing
anybody runs on that host is `docker compose up`.

The editor reads RAW, unlike the listing. Env values are masked on the page
because most of the time nobody needs them, but writing the masks back would
destroy the values they hide.

`wholeFilePatch` is new, because hunks built from line REPLACEMENTS cannot
express a deletion and an operator's edit routinely does. The commit it
produces is still minimal: the patch only builds the index, and git computes
the recorded diff from the trees.

`fakeCtx`'s scripted host commands gained longest-prefix and `*` matching. An
argv carrying a temp path or a pid cannot be written out in full, and a test
should not have to reproduce the module's flag order to script `compose
config -q`.
Story M17. Half the lifecycle lived outside OpsDeck: you could update, pin,
stop and remove a stack, and the only way to make one was ssh. A name, a
compose file, an optional .env — written under the configured stacks root,
validated, brought up, and then an ordinary stack like any other.

`STACKS_ROOT` is required and has no default. A default would be this module
deciding where an operator's services live and being wrong on a host that
already has a convention. Unset means the feature is ABSENT: the route is not
registered, `/stacks` answers `canCreate: false`, and the schema's form is
not in the schema — so "the button is not there" and "the route is not there"
are one fact rather than two that can disagree. A relative root counts as
unset, because it would resolve against whatever the server's cwd happens to
be.

The name is checked here rather than left to compose, because it becomes a
PATH before it becomes a project: anything with a slash, a dot or a space is
a directory somewhere else, and `..` is the file system.

One compensation covers the whole run and is registered before the first
byte, so every later step can fail without knowing how to undo the ones
before it. A half-made stack is worse than none — the next attempt fails on
"directory exists" and the operator is cleaning up over ssh, which is the
session this feature exists to remove.

`dockerUiFor` builds the schema rather than mutating the exported constant:
that constant is what the schema test validates, and a module editing it at
register() would make the test a statement about whichever host ran last.
Story M21. "Did my change apply?" was unanswerable: the compose file was on
one page and `docker inspect` on another, in a different shape, with the
image's own defaults mixed into both. The Config tab renders the inspect back
into the vocabulary of the file beside it, so the two can be read against
each other by eye.

The image's defaults are subtracted, which is most of the value. An
environment list repeating forty variables the image set is a list nobody
reads to the end, and the operator's one line is in the middle of it; on the
committed golden, subtraction takes the rendering from twenty-two lines to
thirteen and every one that remains is a decision somebody made.

Volumes come from `Mounts`, not from `HostConfig.Binds`: a compose-declared
named volume does not appear in Binds at all, so a rendering built from Binds
silently omits exactly the mounts whose data matters most. Ports are quoted,
because `8080:80` unquoted is a YAML 1.1 sexagesimal and the oldest footgun
in compose. Compose's own labels are left out — they describe the deployment,
not the service, and eight of them bury the operator's two.

Drift has three answers and the third is not "no". `config --hash` against
the file, compared with the `config-hash` label the container carries: equal
means applied, different means the file moved on and nothing recreated it,
and a stack with no usable compose files or a compose too old for `--hash`
reads "cannot tell" — because a confident "no drift" is the answer that sends
somebody looking somewhere else. Cached ten seconds per project, since the
answer costs a shell-out and only changes when a file is edited.

It is a rendering, not a round trip. Nothing here is meant to be pasted back:
several fields have no faithful inverse, and the ones that do are already in
the operator's file. A viewer sees which secrets exist and not what they are.
Story M13. The one stack that could not be updated from the UI was the one
the operator looks at most, and "manage it from the host" was permanent. The
obstacle is real — the process doing the update is inside the container being
replaced — so the work is detached from the process that dies.

A host-side `compose up -d --pull always` is launched into its own session:
`systemd-run --collect` where the host has it, `setsid sh -c '… &'` where it
does not. Either way the child is out of this container's session and cgroup
before the container goes. Its output goes to a file beside the compose file,
because there will be nobody on the other end of a pipe and a failed update
has to leave its reason somewhere readable afterwards.

The run does not finish, and that is not a bug: the marker is written and
awaited FIRST (one that lands after the container is gone never lands), the
launch returns, and core records the run `interrupted` at the next boot —
which is right, core never resumes one. Recovery is the module's, expressed
as a new run: the instance that comes up finds the marker and starts
`self-update-verify`, linked `retry_of`, so the operations view shows one
story rather than an interrupted run and an unexplained restart.

Verification compares version AND commit, because a nightly keeps the version
and moves the commit and comparing only the version would call every nightly
update a failure ten minutes later. `dev` on EITHER side proves nothing: a
checkout "becoming" a checkout is not an update, and a versioned build
appearing to become one is a misconfigured container. That case fails with
"cannot be confirmed", which is a different problem from a failed update and
says so. The marker is deleted on every outcome, or it re-runs on every boot
for the rest of the host's life.

The route's guard is the inverse of every other in this module: it REQUIRES
the stack to contain OpsDeck, and every other mutating route keeps its
refusal, so nothing else became looser. No engine-API fallback, deliberately:
that updater recreates from the OLD image's defaults, and getting that wrong
on OpsDeck itself leaves nothing to fix it from.

Verified against the rig as far as the rig allows — the button appears
exactly where every other one is refused, the action runs, and it declines
with `compose-unavailable` and leaves no marker, because the rig starts
OpsDeck with `docker run`. That gap is recorded under Open.
Stories M22 and S23 list what the schema has to carry, block type by block
type. They are prose, and prose does not fail a build — so every claim they
make is now restated against the schema the module actually serves. A control
that exists on exactly one client is what both stories exist to prevent, and
this is the only place that can notice one appearing.

The sweep found one real absence: auto-update was a web-only switch. It is
now two schema actions gated on the server's own answer — on and off, each
with its own confirmation — because a schema action carries no body and a
toggle whose state the schema cannot read is not a toggle. The route takes
the switch from the query as well as the body for the same reason.

Two places where the delivered block differs from the one the story names are
asserted deliberately, with the reason beside them. The storage kinds are a
`fields` grid rather than a list: they are a fixed set of five totals, and
five one-row list items read worse on a phone than a grid does. Accepting
either shape silently would have made the test decorative.

The last assertion is the one about absence: no schema form posts to the file
editor or the label writer. Editors stay on the web, and "what the schema
cannot express is absent, not approximated" is only true while something
checks it.
The module's front page was stacks and updates, and the shell's sidebar has
one entry per module — so storage, volumes, images, networks and the engine
were reachable only from the command palette or by knowing the URL, which is
the same as not existing. Every docker page now carries a row of links to the
six sections, and the front page opens with the host itself: what docker
holds as a composition bar, what is reclaimable, how many stacks, containers,
images and volumes there are, and how full the filesystem under the data root
is.

Volumes get a page of their own. The list, the create, the export, the remove
and the prune all existed already, spread across the storage page and each
volume's own page, which made "manage my volumes" four places and a URL you
had to know. Holders are links, and there can be several: a volume mounted by
two services is ordinary, deduplicated by container id so one container
mounting it at two targets is one holder. Deleting asks for the volume's name
typed back, and says plainly that a stopped stack's volumes are "unused" to
docker.

Two storage cards were bare definition lists with no CSS at all, so the
browser's default put every value on its own indented line — five rows of
whitespace where a picture belongs. The stack's storage is now the same
composition bar the storage page uses, with the total beside it, and the
container's is a two-column key/value grid.

The container page's info grid had the cards as its direct children, which
ties every row's height to the tallest card in it — that is what left a page
of white beside History. It is two real columns now, each a stack of cards.
The stack page puts Services and Storage side by side for the same reason.

Fake data grows a second holder for `media_config`, because a page that
renders a list of holders has to be seen rendering more than one.
Eight pages each did their own header, their own back link and their own
vertical rhythm, and one of them — the engine page — quietly lost its
navigation entirely. `DockerPage` now owns the title, the section nav and the
spacing between cards, so a page is only responsible for what is inside it,
and `Facts` replaces the hand-rolled definition lists, two of which had no
CSS at all and rendered as a label on one line and its value indented on the
next.

Content stops at a readable measure. The shell gives a module up to 1680px,
and a key/value list stretched across that puts a label at one edge of the
screen and its value at the other; a table or a chart opts out with `wide`.
The host card on the front page was the worst of it — the composition
legend's elastic column opened a gutter the size of the legend itself, and
the filesystem bar printed its percentage twice.

Networks became something you can manage rather than read: create (attachable
and internal offered, since neither can be added afterwards), remove, prune.
Members are a table, because three facts per member only mean anything read
down a column. Images gained per-row remove — disabled where a container is
on the image, since the engine's refusal is a worse way to learn that than a
dead button — and the storage page's Logs tab gained the truncate that only
the container page had, plus a download of the same file.

Six prune buttons in a row became one `Prune…` with the choice behind it:
each kind listed with what it would actually free and the sentence that
explains it, which is also where "what is superseded?" is now answered. What
it deliberately does not do is let several be ticked at once — clearing the
build cache must never be one mis-click from removing a volume.

Fixes found while testing this by hand:

- A prune dialog dismissed by its backdrop, Escape or ✕ left the page still
  holding a control it thought was on screen, so opening the same prune a
  second time changed no state and nothing happened.
- **Volume export and import were not a round trip.** The engine roots its
  tar at the basename of the path it was given, so an exported volume came
  back as `volume/…` and importing it produced `/volume/volume/note.txt` — a
  backup that restores to the wrong place, with both halves reporting
  success. The import now reads the archive's first header and extracts into
  the parent when the prefix is the mount's own name. Verified end to end
  against a real engine: a volume with a file and a subdirectory exports,
  imports into a fresh volume, and comes back identical.
- A page header's actions mixed buttons with the odd bare line of text
  ("measured 2 min ago"), which sat on different baselines.
- The reclaimable chart printed raw bytes on its axis — 100000000000.0
  where 93.1 GiB belongs.
- Card borders at 8 % over the page tint were an edge you had to look for.
Every row in these tables names something with a page of its own, and none of
them said so: the storage page's containers, volumes and logs, the images
page's "used by", the network member lists. A table of names you then have to
go and find is a report, not a tool.

The header and the section row now share the content's measure. They sat
outside it, so on a wide screen the page actions were flush with the window
while the cards they act on stopped 400px short.

The container's storage card was a definition list, a button and an unstyled
table stacked together. It is a row per fact — label, number, what can be
done about it in fixed places — with the mounts under their own heading, and
the runaway log gained the same download/clear pair the storage table has.

"Right now" drew CPU as a bare sparkline: one sample is a number, and a line
through one sample is a line. It is a gauge, and the history stays on the
charts below where it has axes.

docs: stories 24–29 (module) and 25–26 (storage) record what this and the two
commits before it added — getting around, volumes and networks as places,
prune with the numbers on it, log actions where the sizes are, one page
layout, the export/import round trip, and rows that link.
Six hours was the cautious default — `docker system df` walks every layer on
the host and that is seconds of IO — but a storage page whose numbers are
five hours old is a page an operator learns to distrust, and the one moment
they need it is while something is filling the disk. Seconds of IO twice an
hour is the cheaper mistake.

The knob was already there and keeps its name:
`OPSDECK_MOD_DOCKER_STORAGE_INTERVAL_MS`. A host where the walk is genuinely
expensive sets it back, and a test now pins both the new default and the
override, because a schedule's cadence is the kind of thing that drifts from
its documentation without one.
`docs/configuration.md` lists all 34 core variables and every module key with
its default and what happens when it is unset, in the places an operator
looks: the server, modules, auth, retention, host access, push, then one
table per module. The README links it and says the prefix rule once, because
`OPSDECK_MOD_DOCKER_STORAGE_INTERVAL_MS` is not guessable from
`STORAGE_INTERVAL_MS` in the code.

A test walks the core schema and every module's `ctx.config.get` and asserts
each key appears in the reference. A knob nobody documents is a knob nobody
finds, and that failure is silent — the code keeps working and the operator
keeps not knowing — so the suite fails instead of the next person. Verified
by pointing a module at an undocumented key and watching it go red.

fix(docker): the self-update version came from the module's own namespace.
`ctx.config.get("OPSDECK_VERSION")` reads
`OPSDECK_MOD_DOCKER_OPSDECK_VERSION`, which nothing sets, so the verification
compared "dev" to "dev" and could never confirm an update. It reads the
process environment the build stamps, with module-scoped `VERSION`/`COMMIT`
kept as an override for tests.
Eighteen findings, and two of them were real bugs rather than rough edges.

**A dialog nobody could reach.** `Modal` never took focus, so Escape went to
the page behind it, Tab walked that page, and a screen reader was never told
a dialog had opened. It now moves focus in, traps Tab at both ends, closes on
Escape and puts focus back where it came from. The prune review, the prune
picker, the file editor, the create-stack form and the update progress all
inherit it.

**A create modal that only rendered while a job was running.** The
create-stack dialog had been inserted inside `{#if job.project}` — the
conditional that belongs to the update-progress and scope modals — so "New
stack…" opened nothing on an idle host. Lifted out, along with the new
confirmation dialog that had landed in the same place.

The rest:

- Stopping a stack or a container, and force-recreating a stack, fire from an
  icon and fired with no question asked. The schema has always confirmed
  them; the web now does too, with a sentence naming what it touches.
- Create fired once per click. `busy` reaches the dialog's buttons now, and
  the handlers refuse a second entry, because the disabled state lands a
  frame later than a fast double-click.
- "Prune unused…" was enabled from the page's own orphan count while the
  dialog was built from the plan, so it opened on "nothing to remove". Both
  now read the plan, and the fake host's snapshot carries the same networks
  its listing invents.
- Names: the empty one only failed server-side, and there was no length at
  all — a 300-character volume was accepted and then wrecked the table. One
  rule, checked in the dialog and at the route, capped at 64.
- A pull with junk in the box opened a progress modal titled "Pulling not a
  tag" and then showed the route and the JSON shape it wanted. Validated
  before anything opens, and the API's own words no longer reach the screen.
- An unreadable `since` was dropped by the server while the page went into
  its bounded state — follow off, "jump to now" showing, full tail on screen.
  It now says what is wrong with it and stays unbounded.
- The container's log row drew its badge on top of its download link.
- The storage page's open tab lives in the URL, like the container page's.
- Saving a file said nothing; it names the file it wrote.
- "Age" showed a calendar date; it shows an age, with the date on hover.
- Disabled controls say why: an image in use, a replica count that has not
  changed, a prune with nothing in it.
- Fake data: creates persist for the process and appear in their lists, a
  `.env` reads like an env file, the log tail ends with a newline so the
  first pushed line does not land on the end of it, and the history empty
  state stops being 200px tall.
- The typed confirmation was case-sensitive in one dialog and not in the
  other. It is case-insensitive in both, and the routes agree, because a
  button that enables and then 400s is worse than either rule.
- The phone gains network create and prune, and the two image prunes. Per-row
  removal stays web-only: it is `DELETE /volume/:name`, a schema action only
  POSTs, and pointing one at the prune route would have taken its default
  selection instead of the named volume.
A browser-side file that nothing but a .svelte component imports is a file
deno check never visits — the graph stops at the component boundary — so it
has to be named in check:frontend or it silently goes unchecked. Same rule as
diff.ts and the model files beside it (CLAUDE.md, "What checks what").
Ten places in this module pulled the server's sentence out of an ApiError,
each with its own copy of the `e.body as { error?: string }` cast, and two
of them additionally re-derived "is this the depends_on 409". The envelope
is one shape; a change to it should not be a ten-file edit.

errors.ts owns the three readings: errorBody for a body a page wants more
than the message out of, conflictBody for the fields that belong to a 409
alone, errorText for the sentence. No behaviour change — every call site
keeps the message it produced before, including StacksPage's "already
running" special case and FileEditor's stale-sha branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three pages fetched /stacks to build the same container name -> id map so
that a name in a table could be a link. The storage page's copy ran ONCE at
mount and never refreshed: after any recreate its Containers, Logs and
Images tables linked at ids the engine had already discarded, and the images
page kept a stale stack list whenever a reload failed, so "used by" could
outlive the list it annotated.

containerIndex() is the one map, refreshed on docker:stacks like the two
pages that got it right, and holding its previous answer through a failed
tick rather than clearing it. The volume page's holder map now reads the
same listing instead of fetching its own alongside the volumes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A page that supplies a `cell` snippet to style ONE column has to render
every other column itself, so the storage page carried five copies of the
component's own fallback, each with a pair of `as unknown as Record` casts
— nine in the file — and a local `plain()` helper that only two of the five
used.

formatCell(column, row, empty?) is that fallback, generic over the row so
the cast lives in one place. DataTable renders its default through it. The
`empty` argument keeps each table's existing wording: "—" everywhere on the
storage page except the logs table, which reads "".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
They were written out three times — the section nav, the palette commands
and the route table — and the three had already drifted: the palette offered
five of the six, in a different order from the nav, with its own hand-typed
labels. A seventh section meant remembering all three.

sections.ts is the list; the nav maps it to links and index.ts maps it to
both palette entries and routes, keeping only what a route table alone knows
(which component renders the path). Stacks gains the palette entry it was
missing, and the five that were there keep their labels and destinations.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The volumes page and the networks page each carried the same eight lines:
fetch the prune preview, count the pre-selected items, use it to decide
whether the Prune button is live and what its tooltip says. Two copies of a
rule that exists because the page's idea of "orphaned" and the planner's are
two derivations — the kind of thing that must be read from one place or it
is not a rule.

prunableCount(kind) is that read. Both pages keep their own wording, their
own disabled state and their reload after a prune closes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three checks, all `(raw: string) => string | null`, all there so the operator
learns about a bad input before spending a round trip on it: the name rule in
names.ts, the image reference inline in ImagesPage, the log time bound inline
in ContainerPage. Same shape, three places, and the inline pair were only
reachable by svelte-check.

names.ts becomes validate.ts and holds all three. Every rule and message is
unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CSS that outlived its markup: `.back` on three pages, left behind when
DockerPage took over the back link; `.grid` on the engine page, left behind
when Facts took over the definition list; `.prune-intro`/`.prune-row` on the
storage page, left behind when the Reclaim-space card became one button.

Two helpers exported for nobody: tarFirstEntry and configPaths are called
only from inside their own module and imported by no test, so the export was
surface without a reader.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The shell lists every page a module declares that carries no `:param`,
labelled "<module> › <page title>". This module registered five of its six
sections a second time with the same hrefs, and the palette keys its rows by
href — so svelte reported each_key_duplicate on every one of them, and
whichever row lost the collision did not render.

Only the param routes are left, which are the ones the shell cannot produce:
every stack and every container by name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
WI-43's first cut launched the compose run as a detached host process
(systemd-run, else `setsid … &`). A setsid child is still in the
container's cgroup, which the runtime kills once PID 1 exits, so compose
died between "old container removed" and "new container created" and left
the host without OpsDeck. systemd-run escapes the cgroup only with
`pid: host` on a systemd host, and nothing supervised the result either way.

The launch is now a sidecar: a throwaway container created through the
engine API from OpsDeck's own current image, with the host-root bind read
off our own inspect rather than assumed, running the same
`chroot /host/root docker compose up -d` every other stack update uses.
It gates the new container (HEALTHCHECK, then /api/core/health via docker
exec, then 30 s without a restart) and rolls back to a tag of the previous
image when the gate fails, because at that moment no OpsDeck may exist to
do it. The `self-update` run does preflight (own inspect, the image the
compose FILE names) and the pull in-process, writes the marker with the
whole handoff before anything irreversible, hands off, and is expected to
end `interrupted` — the honest record. The successor's verify run waits
for the sidecar, reads its verdict and holds it against the image it is
actually running before closing the story; markers from the first cut keep
the version-only verification.

Routes answer 409 with the reason when the sidecar cannot run and require
`confirmed=1`; the stack read carries `selfUpdate: { available, reason }`;
`listStacks` leaves `opsdeck.sidecar` containers out. The stack page
follows the run in the usual modal with a watcher that reloads once a
different process answers /api/core/system (a rollback brings the same
version back), and the page that comes back picks up the verify run. The
schema gets an "Update OpsDeck" action in its own block — the first is at
the eight-action cap.

The dev rig now runs OpsDeck as a compose project (rendered from the same
argument list, `pull_policy: never`), builds `rig-next` and `rig-broken`
variants, and gains two scenarios; the happy path has run green. Known
gap: the verify run does not yet show the sidecar's own log lines.
The app renders the portable schema and nothing else, and a schema action
has no verb field — deliberately, since a client rendering a schema it did
not write should not be picking HTTP semantics. That put a whole class of
operation out of its reach: "remove this one" was DELETE on four routes, so
the phone could sweep every unused image and not delete the one in front of
it. Each removal now answers to POST at <thing>/remove — the same handler,
role, self-refusal and run, so there is no second code path to forget about.

Three more shapes the schema binds and could not get:

- GET /network/:id, because a list row cannot carry a control; removing one
  network has to be a page, and a page needs one network. It says whether
  the network may go at all rather than making the client restate the rule.
- GET /storage/binds, flat. The snapshot keys bind mounts by project and a
  schema `rows` names one array, so the flattening and the du join were done
  in the browser and nowhere else.
- daemonView().configFiles, so the engine page can show daemon.json — and so
  "there is no daemon.json" is a row that says so rather than a block that
  renders nothing.

Superseded images now carry a `reason` like every other prune kind. The web
put that sentence in the dialog's heading; a client that renders rows and no
heading showed a list of names with nothing under them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`repeat(auto-fill, minmax(380px, 1fr))` reads as "at least 380px", and it
means it: in a column narrower than that the track stays 380 and the card
renders wider than the thing holding it, so the whole page scrolls sideways.
Every schema page did this at phone width — the docker module's volumes page
by 74px. `min(380px, 100%)` lets the track give way, which is what the
pattern was always meant to do.

A 64-hex anonymous volume name is the other half: nothing in it can break,
so it pushed its own card wide. List titles and subtitles now wrap inside
the card.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A schema client's navigation is one entry per MODULE and nothing under it,
so a page nothing links to is a page that does not exist. Four were in
exactly that state — images, networks, the engine, and volumes as anything
but a row on the storage report — because the web hides the problem: its own
section nav reaches every page whether the schema links to it or not. The
root page now carries the same five sections the web puts under its title,
and a test walks the link graph so the next page cannot be added orphaned.

What the phone could not do at all, and now can:

- remove one volume, image, network or container, each from a page of its
  own, reached by tapping the row. Deleting a volume asks for its name
  typed, as the web does.
- pull an image, and see what an image is made of before removing it.
- restore a volume from a .tar, which is the other half of the download
  that was already there.
- download a container's whole log, or empty one.
- recreate or scale ONE service of a stack.
- measure a bind mount, and see the bind mounts at all.
- read daemon.json rather than only the drift from it.
- see reclaimable space over time, and what each container's layers cost.

Two bugs the schema surface made obvious:

- the volume prune could never run from a schema client. A schema action
  posts no body — that is why the wide controls take their arguments in the
  query string — and the route read the typed phrase from the body alone,
  so the app collected the confirmation, discarded it, and answered 400
  asking for what had just been typed.
- a refused removal printed its own machine token. "HTTP 502: not-in-plan"
  is what somebody saw for deleting a volume a container was holding. The
  token stays as `reason`; `error` is now a sentence, in both clients.

Volumes get their own page, as they did on the web, so the storage page is
a storage report again. Editing a compose file stays web-only: a schema form
composes a new thing and cannot show a diff, a stale-sha conflict or what it
is about to overwrite — the same reason the file editors were left out
before, now checked by a test that no form posts to /file or /label.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The gap the schema work closed, written down where the other 29 are: what
the app could not reach, why a page nothing links to does not exist on a
client whose nav is one entry per module, and what stays web-only on
purpose.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two defects the dev rig turned up on the first real runs of the sidecar.

The rollback wrote an override file under /run/opsdeck-self-update/ and
recreated OpsDeck with `-f override`. The restored container's compose
labels then named that file, the verify run deleted it during cleanup, and
every compose action on the stack — including the next self-update — was
refused with "compose file not found". The sidecar now re-points the compose
file's own image reference at the previous image locally
(`docker tag opsdeck/rollback:<run> <targetRef>`) and re-runs the operator's
unmodified files with `--pull never`, so labels and config hash are exactly
what a plain `up` produces and the next update pulls the reference afresh.
A digest-pinned reference cannot be re-pointed; that case keeps an override
in the project directory, named in the log. The rollback scenario now also
asserts the stack is still updatable afterwards.

The verify run recorded no sidecar log and judged from the running image
alone: the sidecar was already gone when it looked. The engine's debug log
showed the whole inspect → logs → delete sequence twice, 63 ms apart —
two handlers for the same run. A module may start a run from register(),
before TaskService.start() has run, and the write marking that row `running`
queues behind start()'s read of the live runs on the serialized DuckDB
queue; start() then re-adopted a row it already held in memory and launched
its handler again. start() now skips rows in #runs. A regression test drives
that interleaving deterministically (fails on the old code with
"handler ran 2 times").

Also: the log read reports HTTP status, byte and line counts into the run
instead of failing silently, and the rig's shared helpers gain the
API/wait/assert functions the two scenarios use. Both scenarios pass on a
clean rig.
"registry login needed" was shown for ANY failed tag listing. `tagsError`
carries every way reading a tag list can fail — a timeout, an HTTP 500, a
list too long to page through safely — and only one of them is a registry
asking for credentials. A public image whose check timed out sent the
operator to `docker login`, which is a fix for a problem they do not have.
The host was also pulled out of any trailing "(...)", and the pagination
error ends in one too, so "login needed: partial" named a registry that does
not exist. Only the credentials sentence is matched now; everything else
reads "update check failed" and carries its own text on hover, which is what
Badge's new `title` is for.

The delete confirmation asked for a volume's name typed in full, and docker
names an anonymous volume with 64 hex characters. Retyping that is not a
safety measure, it is a wall whose only door is paste — one keystroke, which
confirms nothing. A name somebody chose is still typed whole; docker's own
is confirmed by its first twelve characters, still specific to that volume
and typeable on a phone.

The schema says the same thing a different way, because a schema
`confirmPhrase` interpolates page params and cannot branch: two actions on
one route, gated on `volume.anonymous`, so the named case keeps its typed
phrase and the anonymous one gets a sentence that says what goes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The question a destructive dialog asks is the one sentence that has to be
readable, and it was the one running under the dialog's edge: a heading
naming a docker anonymous volume is 64 hex characters with nowhere to break,
so "Delete acc0ee…403f085?" was cut off mid-word. Headings wrap now, which
is a fix for every caller, not just this one.

Wrapping alone would have made it three lines of hex. A heading asks WHAT is
being destroyed, so it names the volume short; the exact name moves into the
body, where there is room for it and where it still has to be read before
the phrase below can be typed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deleting a volume answered at the POST with the run it had just started, and
the page treated that as the outcome: the dialog closed, the list reloaded,
the row was still there. The obvious read is that nothing happened, so the
next click started a second removal and got "another storage job is running"
— a 409 about the first one, which reads as the delete having failed while
it was in fact working. A 40 GiB volume is a walk over its files and the
engine takes as long as it takes.

Both removals follow their run now, the way every other long operation in
this module already did. The confirmation stays open and busy for exactly as
long as the work, and a failure keeps it open with the reason on it rather
than closing over it.

Images had the same defect wearing different clothes: `?wait=1` holds the
request for two minutes and then answers "removal failed" if the engine is
still going, which for a multi-gigabyte image is a lie about work that is
going fine. Same fix.

Not changed, and why: network removal and log truncation also use `?wait=1`,
but neither can outlast it — removing a network is a table edit and
truncating a log is one syscall. Stack start/stop, the pins and the label
edit block on the server through `runNow`, so their buttons are honest
already, if silent. Container start/stop/restart are direct engine calls.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pinning a version and setting an upgrade scope both rewrite the operator's
compose file and recreate the service, and their routes answer only once
that is done — up to ten minutes behind a button reading "applying…" with
nothing to say what of.

Neither route can hand back a run id first, and should not be made to: the
id would arrive before the outcome, and the outcome is what carries the
mapped refusal — "no such image in the registry", "compose rejected the
file" — which a client would otherwise have to re-derive from the run
record, in a second copy of pinMessage.

It does not have to. Both runs target the project and `dockerRuns` is
already subscribed to every docker run, so the run turns up client-side
WHILE the POST is still in flight. Following it costs one effect, and the
step it is on goes inside the dialog that started it rather than in a
progress modal on top of it — a second modal over the first is worse than
the spinner it would replace.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A prune in fake mode reported "Removed" for each item and changed nothing:
the next measurement rebuilt the snapshot from the fixture and handed the
row straight back. So every removal looked exactly like a half-finished one,
and a developer had no way to tell the bug from the fixture. That is not
hypothetical — it is why "the delete looks instant and the row stays"
reached a real host.

The fake host now keeps what it has been told to remove, and its listings
filter on it: volumes, images and containers out of the snapshot input,
networks out of the /networks listing. Process-lifetime, like the created-
volumes ledger beside it, and reset per test since tests share a process.

While wiring it: the same network was `netoldapp001` in the storage snapshot
and `netold000001` in the network listing. One fake host calling one network
two names is a host no real one resembles, and it made the prune plan and
the listing impossible to join — which is exactly what the removal needed to
do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
uPlot's default y range pads the data by a percentage and then picks ticks
INSIDE that padding, so the lowest gridline sits above the lowest sample and
the line runs below it. Every pixel is where the scale says and the chart
still reads as broken, because a reader takes the bottom gridline for the
bottom of the chart.

niceRange rounds the bounds outward to a whole 1/2/5×10ⁿ step, so the bottom
gridline IS the floor. The floor snaps to zero only when the data comes
within one step of it — a series that has been near zero is one whose
distance from zero is the point, while 40–42 % CPU is not improved by seven
eighths of an empty chart.

Not addressed here: the steps are round in the underlying unit, so a byte
series lands on 2.5 GB boundaries and renders as "46.6 GiB" — correct, and
still an odd-looking label. Making the step binary-aware needs the component
to know the unit, which it currently does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A pin whose follow-up update fails leaves the compose file ahead of the
host: the file says nginx:1.31-alpine, the container still runs 1.29. The
next pin of a different tag then looks for a line naming what is RUNNING,
finds one naming something else, and fell through to "no compose file
declares an image for this service" — which is not true, and gives the
operator nothing to do about it. The state is ordinary and recoverable;
only the message made it a dead end.

rewriteServiceImage now distinguishes "this service's image line says
something else" from "there is no such line", and the refusal names both
sides: what the file declares, what the container runs, and that recreating
the stack reconciles them. The half-applied case where the file already
holds the requested tag is unchanged — that one still reads as satisfied so
the caller reconciles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The card header was one flex row holding the health dot, the name, the
summary, up to three badges, the footprint and the stack controls — and the
name was the only thing set to ellipsize. So a stack called openpgp-wkd
rendered as "open…" while three badges beside it had room to spare, which is
the card failing at the one job its title has.

Two rows now: the name on its own, then everything that describes or acts on
it, wrapping freely. The name wraps rather than truncating, because no
prefix of a compose project name identifies the stack.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
DataTable has sorted since it was written, and every hand-rolled table in
the app had none — because the rule lived inside a component you had to
adopt wholesale to get it. The volumes and images tables cannot adopt it:
their rows carry a `<th scope="row">`, badges, per-cell titles and buttons,
all of which a generic renderer would cost them. So the rule comes out
instead of the tables going in.

sortRows/compareValues is that rule, and DataTable now uses it too rather
than keeping a second copy. SortHeader is the clickable `<th>`, with
aria-sort, for a table that keeps its own markup.

One behaviour is new rather than extracted: absent values sort last in BOTH
directions. Sorting them as "" put every dash first the moment you reversed
a column, burying the rows you reversed it to see.

Both pages keep the order they opened in — volumes biggest-first, images by
what deleting them frees, which is why each page is opened — and now every
other column answers its own question. Text sorts naturally, so v9 comes
before v10.

Left alone: the drift table on the engine page, a network's member list, a
container's mounts and an image's layers. All are short, none is a list you
scan for an outlier, and a sort control on four rows is furniture.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`#claims` exists for a run RELEASED while its work is still in flight: core
cannot stop a compose child, so the lock has to outlive the run until the
handler really returns. An ordinary run has nothing outstanding by the time
it finishes — but `handlerLive` was cleared in a `finally` that ran AFTER
`#finish` had already read it, because finishing sat inside the same `try`
as the handler call.

So every successful and every failed run logged "run is terminal but its
work has not returned" about work that had just returned, claimed a lock
nobody was holding, and scheduled a second sweep to undo both. Inside that
window `isBusy(target)` and the gate reported a finished run as still
holding its target; it closes before an awaiting caller resumes, which is
why nothing downstream ever misbehaved and why the log was the only symptom.

That log line is the cost worth naming: a warning that fires on every run is
one nobody reads, and it is the same line that reports the real case — a
released run whose work is still moving containers. It could not be told
from the noise.

Only the handler call is inside the try now, so the flag is false before the
run is finished. The regression test watches from inside the finish path,
which is the only place the difference shows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A project whose first image is in a registry the host cannot authenticate to
fails that pull, abandons the rest, and then reports "No such image:
nginx:1.31-alpine" about an image it never tried to fetch. Two things turned
that into a wrong answer:

The classifier took the LAST message as the detail — compose usually ends
with its own summary, and here it ended with a second per-image error. So
the reason `registry-denied` arrived paired with a detail saying the tag did
not exist, and an operator goes looking for a version that is sitting in the
registry all along. The detail is now the message that earned the verdict;
the last line stays the fallback when nothing matched.

And the progress modal never showed the verdict at all. The server
classifies a compose failure precisely so the reason survives the projection
a non-admin gets, and the modal rendered only the log — so the
classification reached nobody, and four lines of daemon output invited the
reader to pick one. The failure now leads, in words, with the daemon's own
line kept underneath it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
DOCKER_CONFIG named where the host keeps its registry logins, and governed
only what OpsDeck REPORTED about them. The docker CLI this module drives
runs as root through the chroot and went on reading /root/.docker — so an
operator who set it got a UI claiming an authentication that every pull then
failed. That is worse than the honest failure it replaced: the badge said
logged in, and the update said "No such image" about the next image in the
project.

It is a real situation, not a hypothetical one: `docker login` as yourself
puts the credentials in your home directory, and nothing in OpsDeck runs as
you. Verified on a host in that state — the same pull fails as root through
the chroot and succeeds with DOCKER_CONFIG pointing at the user's config.

ExecOptions gains `env`, merged over the server's rather than replacing it,
and the module binds it once onto its host facade so one setting reaches
every docker command it runs. Delegating explicitly rather than spreading,
because `available`, `strategy` and `reason` are getters over a probe that
finishes after register() and a spread would freeze their pre-probe values.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A stack card showed red over a container row showing green, and both were
reporting the same container. The card is the server's rollup, which turns
red when any container's statusLevel is error; the row is the client's own
containerState(), which reads `state` and finds "running".

They disagreed because only one of them could see the fault. The engine
brings a looping container back faster than the poll, so it reads `running`
at almost every sample — which is exactly why the server counts restarts
instead, and says so in a comment. The client had no field for that count at
all: `looping` is on the wire for every container and was missing from the
Container type, so the row asked the only question it could and got a
reassuring answer. One container, two verdicts, nothing to tell an operator
which to believe.

Found on a real host: f-scraper, RestartCount 1208, four restarts in the
last hour, green dot.

The row now reads the same fact, before the state, and says what it means —
"restarting repeatedly — the engine keeps bringing it back".

Also reunites a doc comment with the field it documents: "How old the IMAGE
is" sat above updatePolicy, six fields from imageAgeDays.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
feat(docker): explain auto-update where the switch is
All checks were successful
Build and Deploy / verify (pull_request) Successful in 1m50s
Build and Deploy / build (pull_request) Has been skipped
Dependency Check / dependencies (pull_request) Successful in 1m10s
0609043b7b
Unattended updates are two gates — the switch on this page and a label on
each service — and the second one is invisible from here. An operator who
turns the switch on and reads "nothing opted in" has no way to learn what to
write or where; one who reads "1 opted in" cannot tell which rules decided
that. Both were answerable only by reading the source.

An info button beside the switch now says: the label to copy, the optional
window and that an unparseable one means off, that a pinned version never
moves because nothing unattended writes to the operator's files, and the
three refusals — mixed consent within a stack, OpsDeck's own stack and
opsdeck.update=deny, and a project already busy.

A modal rather than a tooltip: this is a procedure with two labels to copy
from, and a hover box that vanishes on mouse-out is the wrong shape for
something you type out of.

The cadence comes from the server rather than the text assuming the default,
since AUTO_UPDATE_EVERY is configurable and the help would otherwise say
"every hour" to somebody who set six.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
julian requested changes 2026-09-22 12:03:46 +02:00
Dismissed
julian left a comment

Reviewed at 0609043 (85 commits, 183 files). Read as two workstreams plus the core changes, each area against the full files rather than only the diff.

Verified locally

  • deno fmt --check, deno lint, deno task check, deno task check:svelte: clean.
  • deno task test: 1007 passed, 0 failed, 1 ignored (the pre-existing windows-only case in commit_test.ts). test:builder 3 passed.
  • service.ts: both fixes are correct. The #runs.has() guard in start() stops the re-adoption double launch, and moving #finish out of the try means handlerLive is false before it is read. tasks_test.ts:1804 and :1860 genuinely exercise the old behaviour.
  • exec.ts env: Deno.Command merges env over the parent environment unless clearEnv is set, so the comment is right and PATH/HOME survive.
  • Route surface (mod.ts / actions.ts): every mutating route carries requireRole("admin"); ids either match /^[0-9a-f]{12,64}$/, go through encodeURIComponent, or are resolved against the snapshot before use. No string-built shell commands. Prune re-validates the plan at execute time; self-container refusals are by identity, not by absence from the plan.
  • Test fakes reject unmocked routes; fixtures are checked against disk; no sanitizer opt-outs, no only.

Blocking

Digest-pinned self-update rollback leaves the stack unable to up.
self_update.sh:110-114: when TARGET_REF is a digest reference, the rollback writes $PROJECT_DIR/.opsdeck-rollback.yml with image: $ROLLBACK_TAG and brings the service up from it. The comment above explains the file must stay because the recreated container's compose labels name it. Fine so far.
actions.ts:2384 (removeSidecarLeftovers) then deletes handoff.rollbackTag unconditionally, and the verify run calls it on every outcome (actions.ts:2746). The comment there says "after a rollback the file's own reference names that image and stays", which is true for the retag path but not for the digest path: the override's only reference to the old image is the tag that was just removed. The container keeps running, but the next compose up -d on that stack (any config change, or the next update attempt) resolves the override, finds no local image for the tag, and tries to pull a name that never existed on any registry.

Fix options: in the digest case write the override with the old image's RepoDigests[0] (the image is still pulled, so name@sha256:... resolves locally) instead of the rollback tag, or record in SelfUpdateHandoff that an override was written and skip the tag delete when it was. Either way the dev-rig only drives tag references (scenarios/self-update.sh, self-update-rollback.sh), so a digest scenario would have caught this and should be added.

Should fix, not blocking

  • mod.ts:1851 volume export: await res.arrayBuffer() on the archive stream. The comment says reading fully is deliberate so the throwaway container can be released, but on a large volume this is the server's RSS. Streaming res.body through and releasing in a finally attached to the stream's close (or a TransformStream flush) keeps the same ordering without the buffer. Same shape at mod.ts:2362 for log download when tail=all.
  • docs/docker-self-update-sidecar.md:186-191 and :395 still describe the override at /run/opsdeck-self-update/rollback-<runId>.yml and an rm override on success. The code (and the note at doc line 17) keeps it in the project directory on purpose. Reconcile the doc with the script.
  • registry_auth.ts:218: { DOCKER_CONFIG: dockerConfig, ...opts?.env } lets a caller's env override the binding the facade exists to enforce. Reverse the spread order.
  • actions.ts:2196 compose edit writes with Deno.writeTextFile directly. A crash mid-write leaves a truncated file; the restore rollback covers the failure case but not the crash case. Temp file plus rename is cheap here.

Nits

  • frontend/diff.ts:28 splits on \n only; CRLF content pasted into the editor diffs every line. Normalise \r\n first.
  • FileEditor.svelte: closing the modal with unsaved edits discards them silently. A dirty check before close is enough.
  • stats.ts:71: online_cpus === 0 yields 0% rather than null. Guard cores <= 0.
  • stats.ts:113: memory limit 0 from the engine means unlimited; it currently falls out as percent = null by accident of limitBytes = 0. An explicit check reads better.
  • ui/sort.ts: missing values now sort last where DataTable used to sort them first via localeCompare(""). Intentional and tested, but it is a behaviour change for the zfs pages that share DataTable.

Everything else read clean: prune planning, volume import guards, registry credential handling (never reaches the UI, sent only to the realm over HTTPS), loop detection single-sourced, compose edit path confinement, orphan-page test, Svelte cleanup on unmount.

Reviewed at 0609043 (85 commits, 183 files). Read as two workstreams plus the core changes, each area against the full files rather than only the diff. ## Verified locally - `deno fmt --check`, `deno lint`, `deno task check`, `deno task check:svelte`: clean. - `deno task test`: 1007 passed, 0 failed, 1 ignored (the pre-existing windows-only case in `commit_test.ts`). `test:builder` 3 passed. - `service.ts`: both fixes are correct. The `#runs.has()` guard in `start()` stops the re-adoption double launch, and moving `#finish` out of the `try` means `handlerLive` is false before it is read. `tasks_test.ts:1804` and `:1860` genuinely exercise the old behaviour. - `exec.ts` `env`: Deno.Command merges `env` over the parent environment unless `clearEnv` is set, so the comment is right and PATH/HOME survive. - Route surface (`mod.ts` / `actions.ts`): every mutating route carries `requireRole("admin")`; ids either match `/^[0-9a-f]{12,64}$/`, go through `encodeURIComponent`, or are resolved against the snapshot before use. No string-built shell commands. Prune re-validates the plan at execute time; self-container refusals are by identity, not by absence from the plan. - Test fakes reject unmocked routes; fixtures are checked against disk; no sanitizer opt-outs, no `only`. ## Blocking **Digest-pinned self-update rollback leaves the stack unable to `up`.** `self_update.sh:110-114`: when `TARGET_REF` is a digest reference, the rollback writes `$PROJECT_DIR/.opsdeck-rollback.yml` with `image: $ROLLBACK_TAG` and brings the service up from it. The comment above explains the file must stay because the recreated container's compose labels name it. Fine so far. `actions.ts:2384` (`removeSidecarLeftovers`) then deletes `handoff.rollbackTag` unconditionally, and the verify run calls it on every outcome (`actions.ts:2746`). The comment there says "after a rollback the file's own reference names that image and stays", which is true for the retag path but not for the digest path: the override's only reference to the old image is the tag that was just removed. The container keeps running, but the next `compose up -d` on that stack (any config change, or the next update attempt) resolves the override, finds no local image for the tag, and tries to pull a name that never existed on any registry. Fix options: in the digest case write the override with the old image's `RepoDigests[0]` (the image is still pulled, so `name@sha256:...` resolves locally) instead of the rollback tag, or record in `SelfUpdateHandoff` that an override was written and skip the tag delete when it was. Either way the dev-rig only drives tag references (`scenarios/self-update.sh`, `self-update-rollback.sh`), so a digest scenario would have caught this and should be added. ## Should fix, not blocking - `mod.ts:1851` volume export: `await res.arrayBuffer()` on the archive stream. The comment says reading fully is deliberate so the throwaway container can be released, but on a large volume this is the server's RSS. Streaming `res.body` through and releasing in a `finally` attached to the stream's close (or a `TransformStream` `flush`) keeps the same ordering without the buffer. Same shape at `mod.ts:2362` for log download when `tail=all`. - `docs/docker-self-update-sidecar.md:186-191` and `:395` still describe the override at `/run/opsdeck-self-update/rollback-<runId>.yml` and an `rm override` on success. The code (and the note at doc line 17) keeps it in the project directory on purpose. Reconcile the doc with the script. - `registry_auth.ts:218`: `{ DOCKER_CONFIG: dockerConfig, ...opts?.env }` lets a caller's `env` override the binding the facade exists to enforce. Reverse the spread order. - `actions.ts:2196` compose edit writes with `Deno.writeTextFile` directly. A crash mid-write leaves a truncated file; the restore rollback covers the failure case but not the crash case. Temp file plus rename is cheap here. ## Nits - `frontend/diff.ts:28` splits on `\n` only; CRLF content pasted into the editor diffs every line. Normalise `\r\n` first. - `FileEditor.svelte`: closing the modal with unsaved edits discards them silently. A dirty check before close is enough. - `stats.ts:71`: `online_cpus === 0` yields 0% rather than `null`. Guard `cores <= 0`. - `stats.ts:113`: memory limit `0` from the engine means unlimited; it currently falls out as `percent = null` by accident of `limitBytes = 0`. An explicit check reads better. - `ui/sort.ts`: missing values now sort last where `DataTable` used to sort them first via `localeCompare("")`. Intentional and tested, but it is a behaviour change for the zfs pages that share `DataTable`. Everything else read clean: prune planning, volume import guards, registry credential handling (never reaches the UI, sent only to the realm over HTTPS), loop detection single-sourced, compose edit path confinement, orphan-page test, Svelte cleanup on unmount.
The sidecar's digest-path override named opsdeck/rollback:<run>, and the
verify run deletes that tag on every outcome. The container kept running,
but the next compose up on the stack resolved the override to a name no
registry has.

The handoff now records the old image's own RepoDigest (preferring the
target's repository) and the override names that; name@sha256 resolves
locally for an image pulled by that name. An image with no RepoDigest
(built or loaded locally) still gets the tag, and the cleanup then keeps it
after a rollback. rollbackRef is optional in the marker, because the
successor reads a marker the previous build wrote.

Tested with a chroot shim driving the real script, plus an (unrun)
dev-rig scenario that pins the file to a digest from an in-rig registry.
Both read the whole engine response into memory before answering, so a
large volume or a tail=all log became the server's RSS. The export's own
doc comment already promised the stream was handed straight through.

The export now releases its throwaway reader when the stream is drained,
cancelled or fails, which keeps the ordering the buffer existed for. The
log file goes through the same sniffing demuxer the live tail uses, and
that demuxer no longer copies the remaining buffer once per frame.
The pseudo-code, cleanup step, rig expectations and risk list still described an override under /run/opsdeck-self-update/ that is removed on success. The script re-points a tag and keeps a digest's override in the project directory; the doc now says so, including that a kept override shadows the next digest the operator writes.
hostWithDockerConfig spread the caller's env last, so an explicit DOCKER_CONFIG in one call would have silently replaced the configured directory. No caller does that today; the order now makes the setting authoritative rather than a default.
Every write to an operator's compose file (pin, label, editor) went through Deno.writeTextFile in place. The restore undo covers a failed step, not a process killed mid-write. hostFs.write now writes a sibling and renames it over the file. Rename alone would have turned a symlinked compose file into a regular one and handed the file to root with the default mode, so the target is resolved first and owner and mode are carried over; where chown is refused it falls back to the in-place write.
A textarea hands back LF whatever it was given, so a CRLF file diffed as every line changed after one keystroke, and saving really did rewrite every line to LF. The preview was honest about that; normalising only the diff would have hidden it. The editor now works on LF and the save puts the file's own line endings back. Pasted CRLF never reaches the text: the textarea normalises it.

Closing the dialog (X, Escape, backdrop) with unsaved edits now keeps it open and asks. Modal grew an onbeforeclose hook for it.
fix(docker): zero cores or a zero memory limit is unknown, not a reading
All checks were successful
Build and Deploy / verify (pull_request) Successful in 1m55s
Build and Deploy / build (pull_request) Has been skipped
Dependency Check / dependencies (pull_request) Successful in 1m11s
33742c0aa6
online_cpus 0 got past the ?? 1 default and turned any usage into 0 %. A memory limit of 0 came out as limited-to-nothing (unlimited false, percent null only because limitBytes was falsy); it now reads as unlimited, which is what the engine means by it.
Author
Owner

Thanks, all of it verified against the code before touching anything. Point by point, at 33742c0:

Blocking

Digest rollback override names a tag the cleanup deletes. Confirmed, and fixed in d7abc9b with the RepoDigests approach. In the digest case, the handoff now inspects the old image and records rollbackRef: a repo@sha256: reference on it, preferring the target's repository. ROLLBACK_REF goes to the sidecar, and the override names ${ROLLBACK_REF:-$ROLLBACK_TAG}. Your fix left one hole: an image with no RepoDigest at all (built or docker loaded locally) has nothing to fall back to. That case still writes the tag, and removeSidecarLeftovers now takes the sidecar result and keeps the tag when keepsRollbackTag() holds (digest target, no rollbackRef, not ok). rollbackRef is optional in isMarker, because the successor reads a marker the previous build wrote.
Tests: self_update_test.ts runs the real self_update.sh against a chroot shim for all three paths (digest with ref, digest without, tag). I checked that the first assertion fails on the old script. I also added the rig scenario you asked for, docker/dev-rig/scenarios/self-update-rollback-digest.sh. It runs registry:2 inside the rig, pushes base/broken, pins the file to the broken digest, and checks that the override names the base digest, that the digest resolves locally and that compose up on the stack still works after cleanup. It has not been run yet: I haven't had a rig up for it, and it needs registry:2 to be pullable inside the rig.

A related problem you didn't raise, now in the doc's risk list (71e3ac8) rather than fixed: the restored container's labels name the override, so the override also shadows the next digest the operator writes into the file until someone deletes it. That is a design question (re-pin through pin_apply is the real fix, already listed as a follow-up), not something for this PR.

Should fix

  • mod.ts volume export buffering. Confirmed, and the route's own JSDoc already claimed "the stream is handed straight through". Fixed in 5b77172: releasingStream() (volume_archive.ts) releases the throwaway reader once, when the stream is drained, cancelled by the client, or errors. The route releases directly only if it never handed the stream over. Tested for all three.
  • Log download tail=all. Confirmed, same commit. It now streams through the existing logStreamDemuxer (the one the live tail uses), and there's a test with a frame split across chunks. While in there, I found that stdcopyDemuxer did buf.slice() per frame, copying the rest of the chunk for every log line. That's quadratic per chunk and would have hurt on exactly this path, so it's now subarray.
  • Sidecar doc. Confirmed stale in five places (pseudo-code, cleanup step, rig expectations, manual cleanup, risk #6). Fixed in 71e3ac8.
  • registry_auth.ts spread order. Fixed in 85480f8, but I'd tone down "the binding the facade exists to enforce": no caller passes DOCKER_CONFIG today, and the callers are this module's own code, so it was a default rather than an enforced binding. The reversed order is still the better contract, and the test now pins it.
  • Compose edit write not atomic. Fixed in d40baf4, though "cheap" undersells it. The write is hostFs().write, which the pin and label writers share, so this changes every write to an operator's compose file. A plain temp+rename would have (a) turned a symlinked compose file into a regular file and (b) handed the operator's file to root with the umask mode. replaceFile() resolves the symlink target, carries mode and owner over, and falls back to an in-place write if chown is refused. It's tested for symlink, mode, no temp left behind, and a fresh file.

Nits

  • diff.ts CRLF: I disagree with the diagnosis and the fix. Pasted CRLF never reaches text, because a textarea's value normalises CRLF to LF. The real case is a CRLF file on disk: after one keystroke text is all LF. The preview then showed every line changed, and that was true, because the save really did rewrite every line ending. Normalising only in diffLines would have made the preview hide a whole-file rewrite. So a23c0b3 fixes the cause instead: the editor works on LF (splitEol), and the save restores the file's own endings (joinEol). The preview is then both small and accurate. There's a test.
  • FileEditor closes over unsaved edits. Fixed in a23c0b3. Modal has a new onbeforeclose hook, and X/Escape/backdrop with a dirty buffer now keeps the dialog open with an inline "Discard them / Keep editing". I used an inline prompt rather than a nested ConfirmDialog, because two focus traps fight each other.
  • stats.ts:71 zero cores. Fixed in 33742c0.
  • stats.ts:113 limit 0. Fixed in 33742c0. You called it cosmetic, but it wasn't quite: limit 0 produced unlimited: false with limitBytes: 0, a limit of nothing. It now reads as unlimited, and there's a test.
  • ui/sort.ts missing-last. No change. It's intentional, the comment in compareValues says why ("a reversed sort that opens with a screen of dashes has buried the rows the operator reversed it to see"), and it's tested. For the zfs pages it's the same improvement.

Checks at 33742c0

deno fmt --check, deno lint, deno task check and check:svelte are clean (svelte warning counts unchanged from before). deno task test: 1015 passed, 0 failed, 1 ignored (+8). test:builder: 3 passed.

Two commits (85480f8, a23c0b3) are unsigned, because the YubiKey touch timed out.

Thanks, all of it verified against the code before touching anything. Point by point, at 33742c0: ## Blocking **Digest rollback override names a tag the cleanup deletes.** Confirmed, and fixed in d7abc9b with the `RepoDigests` approach. In the digest case, the handoff now inspects the old image and records `rollbackRef`: a `repo@sha256:` reference on it, preferring the target's repository. `ROLLBACK_REF` goes to the sidecar, and the override names `${ROLLBACK_REF:-$ROLLBACK_TAG}`. Your fix left one hole: an image with no `RepoDigest` at all (built or `docker load`ed locally) has nothing to fall back to. That case still writes the tag, and `removeSidecarLeftovers` now takes the sidecar result and keeps the tag when `keepsRollbackTag()` holds (digest target, no `rollbackRef`, not `ok`). `rollbackRef` is optional in `isMarker`, because the successor reads a marker the previous build wrote. Tests: `self_update_test.ts` runs the real `self_update.sh` against a `chroot` shim for all three paths (digest with ref, digest without, tag). I checked that the first assertion fails on the old script. I also added the rig scenario you asked for, `docker/dev-rig/scenarios/self-update-rollback-digest.sh`. It runs `registry:2` inside the rig, pushes base/broken, pins the file to the broken digest, and checks that the override names the base digest, that the digest resolves locally and that `compose up` on the stack still works after cleanup. **It has not been run yet**: I haven't had a rig up for it, and it needs `registry:2` to be pullable inside the rig. A related problem you didn't raise, now in the doc's risk list (71e3ac8) rather than fixed: the restored container's labels name the override, so the override also **shadows the next digest the operator writes into the file** until someone deletes it. That is a design question (re-pin through `pin_apply` is the real fix, already listed as a follow-up), not something for this PR. ## Should fix - **`mod.ts` volume export buffering.** Confirmed, and the route's own JSDoc already claimed "the stream is handed straight through". Fixed in 5b77172: `releasingStream()` (volume_archive.ts) releases the throwaway reader once, when the stream is drained, cancelled by the client, or errors. The route releases directly only if it never handed the stream over. Tested for all three. - **Log download `tail=all`.** Confirmed, same commit. It now streams through the existing `logStreamDemuxer` (the one the live tail uses), and there's a test with a frame split across chunks. While in there, I found that `stdcopyDemuxer` did `buf.slice()` per frame, copying the rest of the chunk for every log line. That's quadratic per chunk and would have hurt on exactly this path, so it's now `subarray`. - **Sidecar doc.** Confirmed stale in five places (pseudo-code, cleanup step, rig expectations, manual cleanup, risk #6). Fixed in 71e3ac8. - **`registry_auth.ts` spread order.** Fixed in 85480f8, but I'd tone down "the binding the facade exists to enforce": no caller passes `DOCKER_CONFIG` today, and the callers are this module's own code, so it was a default rather than an enforced binding. The reversed order is still the better contract, and the test now pins it. - **Compose edit write not atomic.** Fixed in d40baf4, though "cheap" undersells it. The write is `hostFs().write`, which the pin and label writers share, so this changes every write to an operator's compose file. A plain temp+rename would have (a) turned a symlinked compose file into a regular file and (b) handed the operator's file to root with the umask mode. `replaceFile()` resolves the symlink target, carries mode and owner over, and falls back to an in-place write if chown is refused. It's tested for symlink, mode, no temp left behind, and a fresh file. ## Nits - **`diff.ts` CRLF: I disagree with the diagnosis and the fix.** Pasted CRLF never reaches `text`, because a textarea's value normalises CRLF to LF. The real case is a CRLF file **on disk**: after one keystroke `text` is all LF. The preview then showed every line changed, and that was *true*, because the save really did rewrite every line ending. Normalising only in `diffLines` would have made the preview hide a whole-file rewrite. So a23c0b3 fixes the cause instead: the editor works on LF (`splitEol`), and the save restores the file's own endings (`joinEol`). The preview is then both small and accurate. There's a test. - **FileEditor closes over unsaved edits.** Fixed in a23c0b3. `Modal` has a new `onbeforeclose` hook, and X/Escape/backdrop with a dirty buffer now keeps the dialog open with an inline "Discard them / Keep editing". I used an inline prompt rather than a nested ConfirmDialog, because two focus traps fight each other. - **`stats.ts:71` zero cores.** Fixed in 33742c0. - **`stats.ts:113` limit 0.** Fixed in 33742c0. You called it cosmetic, but it wasn't quite: limit 0 produced `unlimited: false` with `limitBytes: 0`, a limit of nothing. It now reads as unlimited, and there's a test. - **`ui/sort.ts` missing-last.** No change. It's intentional, the comment in `compareValues` says why ("a reversed sort that opens with a screen of dashes has buried the rows the operator reversed it to see"), and it's tested. For the zfs pages it's the same improvement. ## Checks at 33742c0 `deno fmt --check`, `deno lint`, `deno task check` and `check:svelte` are clean (svelte warning counts unchanged from before). `deno task test`: 1015 passed, 0 failed, 1 ignored (+8). `test:builder`: 3 passed. Two commits (85480f8, a23c0b3) are unsigned, because the YubiKey touch timed out.
thisilike force-pushed feat/docker-storage from 33742c0aa6
All checks were successful
Build and Deploy / verify (pull_request) Successful in 1m55s
Build and Deploy / build (pull_request) Has been skipped
Dependency Check / dependencies (pull_request) Successful in 1m11s
to 1c76917b80
All checks were successful
Build and Deploy / verify (pull_request) Successful in 1m53s
Build and Deploy / build (pull_request) Has been skipped
Dependency Check / dependencies (pull_request) Successful in 1m11s
2026-09-23 12:10:50 +02:00
Compare
Author
Owner

Force-pushed to sign two commits that went out unsigned. The tree is identical to 33742c0. New SHAs for the ones in my reply above: 85480f8 → 1a6043e, d40baf4 → 3a69570, a23c0b3 → 2f01d12, 33742c0 → 1c76917.

Force-pushed to sign two commits that went out unsigned. The tree is identical to 33742c0. New SHAs for the ones in my reply above: 85480f8 → 1a6043e, d40baf4 → 3a69570, a23c0b3 → 2f01d12, 33742c0 → 1c76917.
julian approved these changes 2026-09-23 13:57:28 +02:00
julian left a comment

Re-review at 1c76917, covering the seven commits since 0609043 (d7abc9b..1c76917).

Verified locally

  • deno fmt --check, deno lint, deno task check: clean. deno task test: 1015 passed, 0 failed, 1 ignored.

Previous blocker: resolved

The digest rollback is fixed. digestRollbackRef prefers the target's repository and falls back to any local RepoDigest, both of which resolve without a pull. The override names ${ROLLBACK_REF:-$ROLLBACK_TAG} and quotes it properly. keepsRollbackTag covers the no-RepoDigest hole you found. The calls that pass no result (the create/start failures) run before any override exists, so deleting the tag there is correct. self_update_test.ts runs the real script for all three paths.

The rig scenario self-update-rollback-digest.sh has not been run. Please run it before or shortly after the merge. It is the only end-to-end proof of the digest path.

Should fix (follow-up is fine)

replaceFile resolves absolute symlinks in the container's namespace, not the host's. pin_apply.ts replaceFile calls Deno.realPath(hostPath(file)). When the strategy is not direct, hostPath gives /host/opt/stack/compose.yaml. If that path is a symlink to /srv/stacks/compose.yaml, the kernel resolves the target inside OpsDeck's container rather than under /host. Two outcomes:

  • Usually realPath gets NotFound, the code falls back to path, and the rename replaces the operator's symlink with a regular file. That is the exact case the commit says it prevents, and /srv/stacks/compose.yaml goes stale.
  • If a file exists at that path inside the container, the write goes there. This bypasses the host-root guard.

The test (pin_apply_test.ts:458) uses an absolute symlink with no host-root prefix, so it cannot catch this. The same resolution was already in the old writeTextFile and in every readTextFile(hostPath(p)), so this is a class issue and not a regression. Still, replaceFile now claims to preserve symlinks. A resolver that follows links one hop at a time with Deno.readLink, re-roots absolute targets under the host root and re-checks for escape would fix reads and writes together.

Nits

  • volume_archive.ts releasingStream: if release() throws, its error replaces the read error in the catch branch. The stream still errors, because pull rejects, but the operator sees the cleanup failure instead of the cause. await once().catch(() => {}) before ctl.error(e) would keep the cause.
  • diff.ts splitEol: a file with mixed endings (any CRLF present) is saved as all-CRLF, and the preview does not show that change, because both sides are normalised. This is the whole-file rewrite your CRLF reasoning was meant to surface, now limited to mixed files. Rare. Either say so in the editor or keep LF-only lines as they are.

Approving: the blocker is fixed and the rest can land as follow-ups.

Re-review at 1c76917, covering the seven commits since 0609043 (d7abc9b..1c76917). ## Verified locally - `deno fmt --check`, `deno lint`, `deno task check`: clean. `deno task test`: 1015 passed, 0 failed, 1 ignored. ## Previous blocker: resolved The digest rollback is fixed. `digestRollbackRef` prefers the target's repository and falls back to any local `RepoDigest`, both of which resolve without a pull. The override names `${ROLLBACK_REF:-$ROLLBACK_TAG}` and quotes it properly. `keepsRollbackTag` covers the no-RepoDigest hole you found. The calls that pass no `result` (the create/start failures) run before any override exists, so deleting the tag there is correct. `self_update_test.ts` runs the real script for all three paths. The rig scenario `self-update-rollback-digest.sh` has not been run. Please run it before or shortly after the merge. It is the only end-to-end proof of the digest path. ## Should fix (follow-up is fine) **`replaceFile` resolves absolute symlinks in the container's namespace, not the host's.** `pin_apply.ts` `replaceFile` calls `Deno.realPath(hostPath(file))`. When the strategy is not `direct`, `hostPath` gives `/host/opt/stack/compose.yaml`. If that path is a symlink to `/srv/stacks/compose.yaml`, the kernel resolves the target inside OpsDeck's container rather than under `/host`. Two outcomes: - Usually `realPath` gets NotFound, the code falls back to `path`, and the rename replaces the operator's symlink with a regular file. That is the exact case the commit says it prevents, and `/srv/stacks/compose.yaml` goes stale. - If a file exists at that path inside the container, the write goes there. This bypasses the host-root guard. The test (`pin_apply_test.ts:458`) uses an absolute symlink with no host-root prefix, so it cannot catch this. The same resolution was already in the old `writeTextFile` and in every `readTextFile(hostPath(p))`, so this is a class issue and not a regression. Still, `replaceFile` now claims to preserve symlinks. A resolver that follows links one hop at a time with `Deno.readLink`, re-roots absolute targets under the host root and re-checks for escape would fix reads and writes together. ## Nits - `volume_archive.ts` `releasingStream`: if `release()` throws, its error replaces the read error in the `catch` branch. The stream still errors, because `pull` rejects, but the operator sees the cleanup failure instead of the cause. `await once().catch(() => {})` before `ctl.error(e)` would keep the cause. - `diff.ts` `splitEol`: a file with mixed endings (any CRLF present) is saved as all-CRLF, and the preview does not show that change, because both sides are normalised. This is the whole-file rewrite your CRLF reasoning was meant to surface, now limited to mixed files. Rare. Either say so in the editor or keep LF-only lines as they are. Approving: the blocker is fixed and the rest can land as follow-ups.
Sign in to join this conversation.
No reviewers
No labels
No milestone
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
OpsDeck/core!66
No description provided.