Skip to content

Self-updating binary install: upgrade from the dashboard - #9

Merged
Loa212 merged 1 commit into
mainfrom
self-upgrade
Jul 29, 2026
Merged

Self-updating binary install: upgrade from the dashboard#9
Loa212 merged 1 commit into
mainfrom
self-upgrade

Conversation

@Loa212

@Loa212 Loa212 commented Jul 29, 2026

Copy link
Copy Markdown
Owner

Closes #1.

A binary install can now apply a release from the dashboard: staged, verified against a published digest, swapped atomically, and rolled back on its own if the new version cannot stay up. Containers keep docker pull; a binary install that predates the release layout keeps the manual commands, now printed alongside the one-time migration to it.

Verification, without a signature

The release workflow publishes each artifact's sha256 into releases.json, and the upgrade verifies the download against that rather than the .sha256 beside the artifact. The two travel different write paths — the manifest is committed to main, the artifact hangs off a GitHub Release — so this defeats anyone who can swap a release asset but cannot push to the default branch.

Cosign keyless was considered and deferred (discussed on the issue): an attacker who can poison the manifest can push to main, and anyone who can push to main can run the release workflow and sign whatever they like — so its attestation mostly covers a threat the digest already covers, at the cost of the heaviest verification dependency available. The download URL is constructed from the verified version and pinned, the anti-rollback ladder gates the action, and nothing is piped to a shell at any point.

Swap, exec, confirm, guard

The shipped unit runs unprivileged under ProtectSystem=strict, so the process can neither write /usr/local/bin nor create transient systemd units — the issue's systemd-run suggestion is not available to it. Instead the packaged install moves to the deploy engine's own layout pointed at itself: versioned directories under /var/lib/nudo/self behind a current symlink that ExecStart resolves.

An upgrade stages, verifies, snapshots the database (VACUUM INTO, because WAL), swaps the symlink, and exec()s the new binary through it. exec failing is the best failure in the design: it returns, the old process is still running, the symlink swaps back, and nothing was interrupted. A journal file tracks staged → swapped → confirmed; the new process confirms itself on boot once the store opened and migrations ran; nudo-boot-guard — a std-only binary run as ExecStartPre from a stable path deliberately outside the symlink it may revert — puts the previous release back after three unconfirmed starts.

Off by default, twice: NUDO_ALLOW_SELF_UPGRADE from whoever installs, and a dashboard toggle from whoever operates. The database is never restored automatically — that would silently discard writes made after the snapshot — and the page names the snapshot and the manual steps.

Dashboard

The banner opens a dialog with the release notes and three ways out that mean different things: update now, skip this version (recorded, so a newer release still asks), and close. That dialog becomes a five-step progress view once the upgrade starts, polled so it rides through the restart that kills the connection. It is a :target modal rather than a scripted one precisely because the process serving it is the one being replaced. The running version now appears under the brand in the rail.

The tests that guarded "a page, not a button"

Narrowed rather than deleted. The banner still submits nothing, and no variant of the upgrade page fetches a script or pipes into a shell — that was always the property that mattered. A form is now allowed in exactly one configuration (managed layout, both opt-ins, newer release available), and a test pins that too.

Testing

crates/allinone/tests/self_upgrade.rs runs the whole cycle against the real binary — a fixture release served over loopback, the RPC driven for real — and asserts the same process (exec, not a respawn) comes back confirmed as the new version, with --version through the symlink agreeing. A sibling test proves a release that cannot exec rolls back with the old version still serving. Both run in CI, as do the release-publishing script tests, which were not previously run anywhere.

cargo fmt --check, clippy -D warnings, and all 23 suites pass.

Closes #1. A binary install can now apply a release from the dashboard:
staged, verified against a published digest, swapped atomically, and rolled
back on its own if the new version cannot stay up. Containers keep
`docker pull`, and a binary install that predates the release layout keeps
the manual commands, now printed alongside the one-time migration to it.

Verification, without a signature. The release workflow publishes each
artifact's sha256 into releases.json, and the upgrade verifies the download
against that rather than against the `.sha256` beside the artifact. The two
travel different write paths — the manifest is committed to main, the
artifact hangs off a GitHub Release — so this defeats anyone who can swap a
release asset but cannot push to the default branch. Cosign keyless was
considered and deferred: an attacker who can poison the manifest can push to
main, and anyone who can push to main can run the release workflow and sign
whatever they like, so its attestation mostly covers a threat the digest
already covers, at the cost of the heaviest verification dependency
available. The URL is constructed from the verified version and pinned, the
anti-rollback ladder gates the action, and nothing is piped to a shell at any
point.

Swap, exec, confirm, guard. The shipped unit runs unprivileged under
ProtectSystem=strict, so the process can neither write /usr/local/bin nor
create transient systemd units — `systemd-run` was not available. Instead the
packaged install moves to the deploy engine's own layout pointed at itself:
versioned directories under /var/lib/nudo/self behind a `current` symlink
that ExecStart resolves. An upgrade stages, verifies, snapshots the database
with VACUUM INTO, swaps the symlink, and execs the new binary through it.
exec failing is the best failure here: it returns, the old process is still
running, the symlink swaps back, and nothing was interrupted. A journal file
tracks staged/swapped/confirmed, the new process confirms itself on boot once
the store opened and migrations ran, and nudo-boot-guard — a std-only binary
run as ExecStartPre from a stable path deliberately outside the symlink it
may revert — puts the previous release back after three unconfirmed starts.

Off by default, twice: NUDO_ALLOW_SELF_UPGRADE from whoever installs, and a
dashboard toggle from whoever operates. The database is never restored
automatically, because that would silently discard writes made after the
snapshot; the page names the snapshot and the manual steps.

The dashboard grows the flow this needs. The banner opens a dialog with the
release notes and three ways out that mean different things — update now,
skip this version (recorded, so a newer release still asks), and close — and
that dialog becomes a five-step progress view once the upgrade starts,
polled so it rides through the restart that kills the connection. It is a
:target modal rather than a scripted one precisely because the process
serving it is the one being replaced. The running version now appears under
the brand in the rail, which is the first thing anyone reads out when
reporting a problem.

The two tests that kept this a page rather than a button are narrowed rather
than deleted: the banner still submits nothing, and no variant of the upgrade
page fetches a script or pipes into a shell — that was always the property
that mattered. A form is now allowed in exactly one configuration, and a test
pins that too.

crates/allinone/tests/self_upgrade.rs runs the whole cycle against the real
binary: a fixture release served over loopback, the RPC driven for real, and
the same process — exec, not a respawn — asserted to come back confirmed as
the new version with `--version` agreeing. A sibling test proves a release
that cannot exec rolls back with the old version still serving.
@Loa212
Loa212 merged commit 477393b into main Jul 29, 2026
3 checks passed
@Loa212
Loa212 deleted the self-upgrade branch July 29, 2026 18:00
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.

Self-updating binary install: upgrade from the dashboard

1 participant