Self-updating binary install: upgrade from the dashboard - #9
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.sha256beside the artifact. The two travel different write paths — the manifest is committed tomain, 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 tomaincan 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/binnor create transient systemd units — the issue'ssystemd-runsuggestion 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/selfbehind acurrentsymlink thatExecStartresolves.An upgrade stages, verifies, snapshots the database (
VACUUM INTO, because WAL), swaps the symlink, andexec()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 tracksstaged → swapped → confirmed; the new process confirms itself on boot once the store opened and migrations ran;nudo-boot-guard— a std-only binary run asExecStartPrefrom 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_UPGRADEfrom 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
:targetmodal 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.rsruns 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--versionthrough 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.