Skip to content

feat(packages): update Docker from the dashboard - #17

Merged
inkly merged 33 commits into
mainfrom
feat/docker-update
Oct 10, 2026
Merged

inkly merged 33 commits into
mainfrom
feat/docker-update

Conversation

@inkly

@inkly inkly commented Oct 10, 2026

Copy link
Copy Markdown
Collaborator

Adds an "Update Docker" action to System packages. The Docker engine upgrade that the dashboard only showed as a copyable command now runs from the dashboard, in a transient systemd unit (casaos-docker-update.service) with its own log and nonce markers. The owner's own case is a major jump (docker-ce 28.0.4 -> 29.8.0 on Debian 11).

Also on this branch, the first commit: package tools now run with LC_ALL=C. apt-cache policy prints "Installé :" and "Candidat :" on a French host, so the Docker line looked for "Installed:" and "Candidate:", found nothing, and said no newer Docker was offered.

What it does

  • GET /v1/sys/packages gains docker.update: the plan (from, to, major_jump, plan_id, packages, new dependency packages flagged new), or a refusal code.
  • GET /v1/sys/docker/containers: running containers with restart policy, published ports, host network and whether the Docker socket is mounted. Read through the docker CLI behind the existing command seam, no new dependency.
  • POST /v1/sys/docker/update {plan_id}: Authorization header only (no ?token=), refresh tokens refused. Returns in seconds.
  • GET /v1/sys/docker/update/status: state, phase, outcome, containers that did not return, rollback_command on failure. It never takes the updater mutex and never talks to Docker, so it answers while dockerd is down.
  • The unit script is a constant (POSIX sh), parameters travel as --setenv after validation. Every $ of the script argument is doubled, because systemd expands ${...} in the command line of a transient unit; a Go port of systemd's replace_env_n checks the argument as the shell will receive it.
  • Steps: re-simulate the transaction and refuse anything that is not the confirmed plan, download first while the apps still run, install, wait for dockerd, report the containers that did not come back. It never restarts Docker, never starts a container, never rolls back.
  • Alerts: the four app:container-* events are muted during the run and for 10 minutes after; one push message per run (once per nonce, also across a core restart).

Refusals

Static (in the check, repeated at the POST): unsupported, origin (not docker-ce from Docker's repo), held (a hold is the owner's deliberate act), dpkg (an unfinished operation), daemon, swarm, plan, disk (under 1 GiB free).
At the POST: running, maintenance, apps (AppManagement busy or unreachable: fail closed), changed (the plan moved since the confirmation), nothing.

Plan rule: only the 7 engine packages may be upgraded; new dependency packages are allowed (at most 10; docker-ce 29 depends on nftables, which 28 did not); no removal; an upgrade of any other package is refused, so a Docker update never drags the base system along.

Verification

  • Unit tests in pkg/dockerpkg, pkg/alerts, service, route, route/v1; the unit script runs under busybox ash against fake apt-get, docker, systemctl; mutation checks on every guard.
  • Two adversarial reviews (7 + 4 lenses, every finding attacked by independent skeptics). The first found a blocker the tests could not see (systemd rewriting ${...}); it is fixed and the second review confirmed the fix against systemd v247 to v259.

Not verified

No real systemd, apt or dockerd has run this. The proof workflow (docker-update-proof.yml in CasaOS-Install: Debian 11 major jump, Ubuntu 24.04, Debian 12, Debian 13, Ubuntu 26.04) has not run yet, nor the rollback after a major jump. Do not release before it is green.

inkly added 30 commits October 9, 2026 08:56
… on a translated host

apt-cache policy prints "Installé :" and "Candidat :" on a French host, and the
Docker check looked for "Installed:" and "Candidate:". It found no candidate and said
no newer Docker was offered, though apt offered one. Package tools now run with LC_ALL=C.
Pure helpers in pkg/dockerpkg for the Update Docker button: the seven
engine package names, the dpkg version check, the major version of an
engine and when an upgrade jumps it, and the parser of an apt simulation
into a plan (what it installs, upgrades, removes), with the list of what
makes the plan unacceptable, its id, its pins and its engine versions.
Nothing in them runs a command, and nothing apt printed gets out of them
unless it passed validation.
…er update

The docker inspect and docker info templates, and their parsers: one JSON
object per container (name, image, restart policy, host network, published
ports) and one for the daemon (version, swarm). Every free-text field goes
through {{json}}, and the parsers skip what is malformed and validate what
they keep, since container names and images are third-party text.
The first line of the log is the core's, with a random nonce; the parser
takes the nonce from that line only and counts a later line as a marker
only if it has the exact shape and carries that nonce. Apt output, a
container named like a marker and a forged nonce are ignored, every field
is validated again, and the last terminal marker wins. The rollback
command is built from validated pins only.
…on order

The fake had one answer for every install simulation, a lexicographic dpkg
version compare (29.10 came before 29.9) and "success, no output" for every
command it did not know, which hides a check the code forgot to make.

It now answers docker info, ps and inspect (healthy, stopped, hanging until
its deadline, in a swarm), dpkg-query for any package with a status, and
an install simulation made of the lines of the names it was asked for. A
strict option reports an unanswered command as a test failure and as a
failed command; the existing tests do not set it and behave as before.
The compare orders epoch and number runs numerically.

The generic unit's tests that need /bin/bash skip when the host has none.
GET /v1/sys/packages gains docker.update when the engine has something to
update: available, a refusal code (unsupported, origin, held, daemon, swarm,
plan, disk, looked at in that order), the names that make a plan
unacceptable, from/to/major_jump, the plan id and the packages with their
whole dpkg versions.

The plan is apt's own simulation of upgrading the engine's packages that are
on the box (--only-upgrade, --no-remove, no recommends, no lock), made only
once the cheap reasons have passed. A simulation that fails, a removal, a
package that is not the engine's, a new package and a name or version that
is not fit for a command line all refuse it, and a refused plan carries
nothing hostile to the page. The disk needs 1 GiB in apt's cache and in
/usr, read with statfs behind a seam; a place that cannot be read counts
only if neither can.

dockerUpdatePreflight is what the start of the update will call again
under the lock. The fields of the Docker line are as they were.
GET /v1/sys/docker/containers answers {running, containers} from the docker
command alone: docker info for the daemon, docker ps -q for the ids, one
docker inspect for the containers, all within 5 seconds. Only hexadecimal
ids go to inspect, names and images are validated and bounded by the parser,
and a daemon that does not answer gives running false and an empty list.
It does not take the updater's lock, so it answers while a check or an
update is under way.
… there are some

The Docker update names the apps that hold it. appsBusy stays, as the wrapper that treats no answer as busy.
A constant run by /bin/sh; its parameters travel in the environment of the unit. Guard, download, install, wait for the daemon, say which containers did not come back.
…ld not start

The log of a run is whole even when systemd-run refuses: first line, what it said, one terminal marker with the run's nonce.
StartDockerUpdate checks again what the page checked, the apps and the plan the owner confirmed, writes the first line of the log and starts the unit. DockerUpdateStatus reads only the log and systemd, takes no lock and asks Docker nothing.
…n in the header only

POST /v1/sys/docker/update and GET /v1/sys/docker/update/status. The start refuses a token in the query and the refresh token; every refusal is a 409 whose data carries the reason and its code.
…ll its end once

Hub.Muted is asked for the four app:container-* events only (not the apps'
-error events, not backup:error). While it says yes they are dropped, raise
and resolve alike, without touching the Hub's memory of what it sent, so no
"resolved" follows an alert that was never sent.

When a run of the Docker update ends the owner gets one push message
(updated to X / failed, by reason / installed, restart pending), with the
names of the containers that did not come back. The key is one per run, so
the six hours of dedupe do not swallow the next run. The run comes from
Hub.LastDockerRun; a watcher started with Run (a run that ended or goes on
while the core was down) and by WatchDockerUpdate (a run the button just
started) polls it every 5 seconds, for two hours at most. The nonce of the
last run told is kept in /var/lib/casaos/docker-update.notified, written
atomically before the message is sent, and in memory, behind a mutex: two
watchers racing, or a restart, never tell a run twice.
…ow a run ended

DockerUpdateMuted is true while the Docker unit runs (or its last line may be
on its way) and for ten minutes after the run's own terminal line, read from
the log and systemd through the status reader, with no deadline in memory.
It takes no updater lock and reuses its answer for five seconds, because the
alerts ask it for each container event.

DockerUpdateRun is the status and the nonce of the run it is about, which is
what tells one run from the next for a notification sent once.
OnDockerUpdateQueued is called once the log of a new run is written, whatever
systemd does next, and not for a start that was refused.
main gives the alerts the service's DockerUpdateMuted and the last run of the
Docker update (nonce, outcome, error code, version, names of the containers
that did not come back), and has the update button's start hand a new run to
the alerts' watcher. All of it is set before alerts.Default.Run starts.
…gine depends on

Docker 29 depends on nftables, which Docker 28 did not, so the owner's own box
(Debian 11, 28.0.4 to 29.8.0) was refused by the rule that any new package
refuses the plan. A plan is now acceptable when nothing is removed, every
upgrade is of an engine package, and every new package has a valid name and
version, is not one of the distribution's Docker packages (docker.io,
containerd, docker-compose-v2, docker-buildx) and there are at most ten of
them. Pins are the upgrades only, Names is every name the guard may see, and
Dependencies lists the new packages that are not the engine's.

The packages of the plan carry "new": true for a package that is not installed
yet; the lists of updates of the check keep their JSON. A plan with new
packages and nothing to upgrade is nothing to update.
…s, not pins

The unit gets two lists: CASAOS_DU_PINS, the upgrades apt is told to install,
and CASAOS_DU_NAMES, every package it may install, the new dependencies of the
new engine included. The guard accepts an Inst of one of the names with or
without the version it replaces, and still refuses a removal, an Inst outside
the names, an upgrade of a package that was not pinned and a simulation that
fails. dockerUpdateArgs checks both lists again before the root shell: names
valid and unique, every pin among them, at most ten new, none of them a
distribution's Docker package, at most seven plus ten in all.
…temd

systemd expands the words of a unit's command line before it runs it: a
${NAME} becomes the value of NAME in the unit's environment, or nothing, and
only $$ is left as a $. The script went to `sh -c` as it was written, so
${Package}, ${Version}, ${previous# } and ${pin%%=*} were gone, PREVIOUS came
out empty and the guard refused every real plan. The Go tests ran the script
directly and could not see it.

The argument now has every $ doubled (--expand-environment=no needs systemd
254). A port of systemd's replace_env_argv checks that the shell is given the
script byte for byte, and that the commands of the System packages update and
of the ReCasaOS update, which have no ${...} and no $$, are left alone too.
…check

check() held the updater's lock across apt-get update, the simulations and the
questions to Docker, up to five minutes. The start took the same lock first, so
a POST that came while a check ran waited for it, the page gave up after its 60
seconds with "could not start", and the update started afterwards all the same.

The checks now take turns on a lock of their own, and the updater's lock is
only held for the short question of whether an update runs. A start during
apt-get update is answered by the dpkg lock probe (maintenance) at once.
A container that bind-mounts /var/run/docker.sock or /run/docker.sock talks to
the daemon with a client of its own (Traefik, Portainer, Watchtower...), which
a new major Docker may no longer understand: it comes back running and is
broken. The inspect template now reads .Mounts and the container list says
docker_socket (only there when true) so the confirmation can name them.

The source is third-party text, compared after path.Clean and never kept, and
only a bind counts.
…emd would not start it

The status gains `phase`, read from the markers the unit has already written
(PREVIOUS, DOWNLOADED, INSTALLED, DAEMON): preparing, downloading (the apps
run), installing, waiting_docker, waiting_containers, and "" once the run is
over or when nothing runs. The page tells "your apps keep running" from
"Docker is restarting" with it. Run gains DownloadedAt and PreviousSeen for it.

A unit that systemd-run could not start is no longer written as `guard`, which
the status, the page and the push told as "no longer what you confirmed": the
core's last line says `start`, and the status, the alert text and the error
message say the update could not be started and nothing was changed.
…will not start

The push said "(they do not start by themselves)" of every name the unit
reported as not returned, but the unit also reports the containers that have a
restart policy and were only still starting when it stopped waiting after 90
seconds, and Docker is starting those. The run now carries each container's
restart policy: those with none ("" or "no") are "Not running again", the
others "Still starting after 90 seconds", each list cut on its own.
…p in systemd yet

The only guard against a second start was the unit's ActiveState, and systemd
queues the start of a unit that systemd-run returns from: it reads inactive
until it takes it up. A second POST in that moment passed, rewrote the log
over the first run's (its first line holds the nonce) and wrote a failed line,
so the run that went on was read as failed for good.

dockerBlocked now also refuses when the log is that of a run that has not
ended (running, or finalizing within the 30 seconds of grace), and the refusal
answers with that run's status. A run that ended, or was lost long ago, does
not block.
…ot answer

unitActive took any failure of `systemctl show` (PID 1 busy, the ten seconds of
its timeout) for "the unit is not running", and the status then reported a run
whose log had been quiet for 30 seconds as failed with no_result. The unit is
quiet for minutes at a time (the wait for the daemon, for the containers), the
page and the alerts stop at the first end they read, and the push tells a run
once: a hiccup meant a permanent false failure and a missed success.

Whether systemd answered is now told apart from what it said: when it did not,
a run with no terminal marker stays running while its log is younger than 30
minutes. When it answers, nothing changes.
… in the future

The alerts about the containers are muted for ten minutes after a run's own
last line. The age of that line was only tested to be under ten minutes, so a
date in the future (a log line anyone who can write the log can date) gave a
negative age and a mute until that date came. A last line more than a minute
ahead of the clock, the skew the existing test allows, no longer mutes.
…the Docker status

The status answers with `phase` (feat(packages): say which step the Docker
update is at), and the exact-body tests of the routes did not know it; the 500
of a unit systemd would not start carries error_code `start`.
The script tests ran the constant, which is what the shell would get only if
systemd left it alone. They now run the argument the core gives systemd-run
after the port of what systemd does to the words of a command, so that the
owner's case (PREVIOUS with pins, the guard letting the real plan through)
fails the way it would on a box if a ${...} reached systemd undoubled.
inkly added 3 commits October 9, 2026 17:52
…arts by itself

A policy such as on-failure does not bring a container back after the daemon
stopped it cleanly, so the push now says the container is still not running
after 90 seconds and that, if it stays stopped, it is to be started from the
dashboard.
The status read only the last 128 KiB of the run log, so a noisy install
pushed PREVIOUS, DOWNLOADED and INSTALLED out of what it parsed: a failed
install had no rollback command and no version to come from, and a run that
was installing was called preparing.

The markers are now read from the whole log, streamed line by line (16 MiB at
most, the first and the last half of that beyond it, no more than 4 KiB of a
line held), the first line of the log being the run's nonce whatever it says.
The log field of the status is still the 128 KiB tail.
… unfinished

The simulations of the check and of the guard take no lock, so they pass on
a box whose dpkg was interrupted (a power cut in the middle of an install),
and the first real apt call then fails and the run was reported as a failed
download, with advice about the network.

New static refusal `dpkg`, looked at after `held` and before `daemon`, and
again at the start: `dpkg --audit` prints anything (LC_ALL=C, through the
command seam), or /var/lib/dpkg/updates holds an entry whose name is only
digits, which is what apt itself stops at. Reason: 'A previous package
operation was left unfinished.', no refusal_detail. While a package manager
holds dpkg's lock nothing is said, since a package manager at work looks
interrupted to both; the start answers `maintenance` then.
@inkly
inkly merged commit 0a2a0b3 into main Oct 10, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant