Repository navigation
Description
Problem
The systemd unit that containarium service writes hardens the daemon with ProtectSystem=strict, and its ReadWritePaths does not include the directory the daemon binary lives in. The sentinel auto-updater, however, downloads the new binary to /usr/local/bin/containariumd.new (a path hardcoded in internal/server/dual_server.go:3857) and renames it into place. On a unit generated by this template the download can therefore never be written, so sentinel auto-update cannot work, while docs/UPGRADE-AND-ROLLBACK.md says it does (line 22: the daemon "checks the sentinel every 5 minutes … and installs a changed binary"; line 106: a daemon with a sentinel URL "installs whatever the sentinel serves within about 5 minutes").
All references are to origin/main at e904c99a0 (v0.132.1).
What the code does (verified by reading)
internal/cmd/service.go(the unit template):ExecStart=/usr/local/bin/containariumd daemon …(line 96),ProtectSystem=strict(line 152), and
ReadWritePaths=-/var/lib/containarium /var/lib/incus /etc/containarium /etc /home /var/lock /run/lock -/opt/containarium /var/log -/mnt/incus-data(line 154)./usr/local/binis not listed, and nothing in the template adds the binary's directory.internal/server/autoupdate.go:tmpPath := u.binaryPath + ".new"(line 179) followed byos.Rename(binaryPath → .old)andos.Rename(.new → binaryPath)(lines 216-224). UnderProtectSystem=strictthe first write to/usr/local/bin/containariumd.newfails withEROFS.- History: the template has carried
ProtectSystem=strictsince the first service-install commit (2026-02-23); the updater has written.newsince 2026-04-05.scripts/containarium.servicehas the sameProtectSystem=strictand aReadWritePathsthat also omits/usr/local/bin. I did not check every installer or deploy script, so hosts whose unit was written some other way may not have the restriction.
Observed
On a host whose unit matches this template and which has a sentinel URL configured, the daemon logged, on the first poll after the sentinel started serving a different binary:
[auto-update] new version detected (local=43345f53287b..., remote=fd28622c8227...)
[auto-update] check failed: download: open /usr/local/bin/containariumd.new: read-only file system
The host kept running its old binary and retried on the 5-minute tick.
Why it stayed hidden: the updater only writes when the sentinel serves a different binary, so a host whose binary already matches never touches the filesystem. The first real update is the first time the failure can appear, and it appears only as a log line at the same level as an ordinary failed poll.
Impact
- Operators who follow the documented model ("uploading a binary to the sentinel is a fleet-wide rollout") will find hosts that silently never update, and nothing alerts.
- The accidental safety (a host does not upgrade itself) is not a designed property, and anyone who "fixes" the failure by loosening
ProtectSystemglobally weakens the hardening for the whole daemon.
Proposal (pick one deliberately)
- Make the updater work under the hardened unit: add the directory of the running binary to
ReadWritePathswhen generating the unit (the smallest grant compatible with a rename-based swap; it makes every binary in that directory writable by the daemon, so the staging-plus-helper alternative below is the narrower one), and keepProtectSystem=strict; or stage the download under/var/lib/containariumand swap with a small privileged helper, so the daemon never needs write access to its own binary directory. - Or declare auto-update unsupported on the hardened unit: refuse
--sentinel-urlauto-update at startup with a clear message, and correct the docs. - Either way, make the failure loud: log it at warning level with the cause, count consecutive failures, expose a metric, and add a
containarium doctorcheck that flags "auto-update enabled but the binary directory is not writable" (the doctor already probes writability foruseradd, but, as its own comment ininternal/cmd/doctor.gosays, it only gives a true answer when run under the unit's constraints; a check for this should readsystemctl show -p ProtectSystem,ReadWritePathsor run inside the daemon, because from a plain root shell it would pass falsely).
Acceptance
\1- [ ] The same holds for the GitHub-tag upgrade path (internal/server/github_upgrade.go:113, which also writes <binary>.new).\n- [ ] On a host with the generated unit and a sentinel serving a changed binary, the daemon installs it (or refuses to start auto-update with a clear error, if option 2 is chosen).
-
docs/UPGRADE-AND-ROLLBACK.mdmatches whatever is chosen.
Activity
hsinatfootprintai commentedon Nov 9, 2026
Correction and an independent confirmation.
1. The target path is hardcoded, not "next to the running binary" (my issue text was imprecise).
internal/server/dual_server.go:3857definesconst daemonBinaryPath = "/usr/local/bin/containariumd". That one constant is passed to both:ds.containerServer.SetBinaryPath(daemonBinaryPath)(line 3863), the path used byTriggerUpgrade(containarium backends upgrade, the MCPupgrade_backendtool,POST /v1/backends/upgrade), andNewAutoUpdater(ds.config.SentinelURL, daemonBinaryPath, 5*time.Minute)(line 3868), the sentinel auto-updater.So the manual upgrade API fails the same way under the generated unit, not just sentinel auto-update, and a host whose daemon binary lives elsewhere but which still has a stale copy at
/usr/local/bin/containariumdwould have the updater write to, and swap, the wrong file (without such a copy the local-checksum step fails before anything is downloaded).2. A host in the wild already carries the workaround. An operator added this drop-in on one production host (dated about a week after the unit was last regenerated), with this comment:
Another host with the same generated unit and no such drop-in fails at the first update (see the log lines above). So behaviour now depends on which hosts someone happened to patch by hand.
Proposal adjustments: option 1 in the issue body is exactly this drop-in, so shipping
-/usr/local/binin the generatedReadWritePathsis the smallest fix that matches what operators are already doing; additionally derive the target from the running executable (or make it a flag) instead of the constant, and coverTriggerUpgradein the same test.