ADR 0026: One-click update from the web UI
Status: Accepted Date: 2026-11-08
Context
Updating a unit means SSH and scripts/install. A Pi Zero can’t build core,
and a Pi Zero W took 53 minutes of a 56-minute install compiling
better-sqlite3 until releases started carrying an ARMv6 build of it. Owners
should be able to update from Settings, with no shell, without the unit
building anything, and without being left with a dead unit when an update
goes wrong.
Two rules stand in the way. Core runs as an ordinary account that can’t replace system files or restart services. And core is LAN-only: AGENTS.md says never to add a route that reaches the internet from the unit (ADR 0012).
sleepypod solves the first with sp-update, a root script that downloads a
release tarball and swaps it in. It still runs pnpm install on every update
and rolls back only on a service that won’t start.
Decision
A root helper with fixed arguments
scripts/seedypod-update.cjs is installed root-owned as
/usr/local/sbin/seedypod-update by scripts/install, with the install prefix
written in, like the restore helper (ADR 0021). It uses Node built-ins only and
never loads anything from the prefix, which core’s account owns. Overrides
for tests are ignored as root.
/etc/sudoers.d/seedypod-update lists every command core may run, each with
its exact arguments:
<core user> ALL=(root) NOPASSWD: /usr/local/sbin/seedypod-update --status
<core user> ALL=(root) NOPASSWD: /usr/local/sbin/seedypod-update --check
<core user> ALL=(root) NOPASSWD: /usr/local/sbin/seedypod-update --start
<core user> ALL=(root) NOPASSWD: /usr/local/sbin/seedypod-update --channel stable
<core user> ALL=(root) NOPASSWD: /usr/local/sbin/seedypod-update --channel devsudo refuses anything else, so core can’t pass a URL, a path, a repository or
a version. The helper checks its arguments again and exits 2 on anything
else. The repository (seedypod/core) and the download prefix are constants
in the helper. Core calls it from one place, src/update/client.ts.
--now (update in the foreground) and --rollback (put the previous version
back) aren’t in sudoers: they’re for an owner on SSH.
Channels
- stable: the newest
vX.Y.Zrelease (/releases/latest), built frommain. - dev: the rolling
dev-latestprerelease, built fromdev.
The choice is kept in /var/lib/seedypod-update/channel, root-owned, so it
survives a database restore and can only be one of the two. With no choice
saved, a unit installed from main is on stable and anything else is on dev.
Only prebuilt artifacts, each checked
The unit downloads what CI built and nothing else: seedypod-core.tar.gz, the
hald binary for its CPU when hald runs on this machine, and, on a Pi Zero W
whose dependencies changed, the ARMv6 better-sqlite3. dev-release.yml and
release.yml attach a <asset>.sha256 next to the
tarball and each hald binary (the better-sqlite3 build already had one).
Each download must match every checksum the release has for it: the
.sha256 asset and GitHub’s own digest for the asset. At least one must
exist, or the helper refuses. A mismatch refuses the update before anything
on the unit changes or stops. Two more checks happen before the swap:
- The tarball’s
.git-infocommit must be the release tag’s commit, so adev-latestcaught half-published is refused rather than installed. - The release must run on the Node the unit has. A release that needs a new
Node is refused with “update over SSH with
scripts/install”.
Nothing is compiled. When pnpm-lock.yaml is unchanged, node_modules is
kept as it is, which is the common case. When it changed, pnpm install --prod --frozen-lockfile runs as core’s account (never as root) with the
store node_modules was linked from. A Pi Zero W needs the ARMv6
better-sqlite3 from the release for that; if the release has none for its
Node, the update is refused instead of compiling for an hour. pnpm has a
30-minute limit, so a compile that slips through fails and rolls back.
Swap, health check, rollback
The helper runs the update as its own transient systemd unit
(systemd-run), since stopping core would otherwise stop the helper with
it. In order:
- Download and check everything, unpack to
<prefix>.next. - Stop core, and hald if its binary changed. Copy the database
(
data/seedypod.dbwith its-waland-shm) aside. - Move every top-level entry of the prefix except
data/,node_modules/,.pnpm-store/and.env*into<prefix>.previous/code/, then move the release’s entries in. Each move is a rename on one filesystem.<prefix>.previous/meta.jsonrecords each step as it happens. - Install dependencies if the lockfile changed, with the old
node_modulesmoved aside. Swap the hald binary by rename. - Start the services. Core is healthy when
/api/healthcheckanswers,/api/systemreports the new version, and core sees hald if it did before. It has 10 minutes.
Any failure in steps 3 to 5 puts back everything meta.json lists,
including the database (a new core may have migrated it), then starts the
services and checks that the old version answers. The status says
rolled-back, or rollback-failed if the old version doesn’t come back
either. After a successful update, <prefix>.previous keeps the previous
code until the next update. The database copy is deleted, and the restore
and update helpers are refreshed from the checked release.
While it runs, the helper puts a file in /run/seedypod-busy/ so hald’s USB
recovery won’t reboot the unit mid-update. A lock file stops two updates at
once.
Progress
The helper writes status.json (the step, the versions, the error) and
update.log to /var/lib/seedypod-update, readable by everyone. Core reads
them, and while an update runs it emits update events on the event bus
(ADR 0015) so Settings follows along over the SSE subscription. While core
itself restarts, the page polls until core answers again, then shows the
result.
The exception to LAN-only
Core still never connects to the internet. The helper does, only when someone on the LAN asks it to:
- Settings → Update → Check runs
--check: two GET requests toapi.github.com(the release, and its tag’s commit). - Update runs
--start: those two again, then downloads fromgithub.com/seedypod/core/releases/download/, which redirects to GitHub’s asset storage. If dependencies changed, pnpm fetches packages from the npm registry, and prebuild-install fetches better-sqlite3’s prebuilt binary for 64-bit ARM from its GitHub release.
Nothing checks for updates on a timer, and nothing reports anything about the
unit. The requests carry no unit id, only a user-agent: seedypod-update.
GitHub sees the unit’s public IP address, as it would for scripts/install.
Trust
The trust root is GitHub: TLS to api.github.com and github.com, and the
seedypod/core repository’s releases. The sha256 checks catch a corrupt,
truncated or half-published download. They don’t protect against someone who
can publish releases to seedypod/core, because the checksums come from the
same release. That’s the trust scripts/install already asks for when an
owner pipes it into bash. Signed releases (minisign or Sigstore, with the
public key in the helper) would close that gap and are left for later.
Anyone on the LAN can start an update, as they can restore a backup or run the pump (ADR 0012). The optional API key covers it like any other route. They can only choose the channel, never the source.
Consequences
- An update from the web replaces core’s files and hald’s binary. It does
not redo the rest of
scripts/install: the systemd units, polkit rule, port 80 forward, login logo and Node itself stay as they are. A release that needs those changes needsscripts/installover SSH. The update helper and restore helper are refreshed, so later updates use the newest helper. - An update stops the unit for as long as core takes to start. On a Pi Zero that’s up to a few minutes. Schedules don’t run while it’s stopped, and hald stops the pump when it restarts.
- Rollback restores the database copy, so readings and changes made in the minutes the new version ran are lost. A clean update loses nothing.
<prefix>.previouscosts the previous code’s disk space, plus the previousnode_moduleswhen dependencies changed.- A power cut mid-swap leaves
<prefix>.previous/meta.json.sudo seedypod-update --rollbackputs the previous version back from it. - Releases from before this ADR have no
.sha256assets. GitHub’s digest still checks their downloads.
Sources: sleepypod sp-update · seedypod/core#128: One-click update from the web UI · ADR 0012: Deployment and trust model · ADR 0021: SD card wear and fast recovery
Imported from docs/adr/0026-web-update.md in seedypod/core. Edit it there, not here.