Skip to content

update-adopt CLI honesty: no-candidate, a status that admits it cannot read root state, and the --no-check-sigs note - #408

Open
mecattaf wants to merge 1 commit into
mainfrom
fix/update-adopt-cli-honesty
Open

mecattaf wants to merge 1 commit into
mainfrom
fix/update-adopt-cli-honesty

Conversation

@mecattaf

Copy link
Copy Markdown
Owner

Backlog row FDC-M3. Closes item 4 of #354's 2026-09-14 status comment ("the nix copy publish step needs --no-check-sigs; a 404 manifest logs fetch-failed where no-candidate would be clearer") and the two neighbouring CLI answers that read as "fine" when they are not. #354 stays open: the remaining items there are live-adoption milestones, not this.

The three behaviours

1. update-adopt status as a non-root user exits non-zero.
/var/lib/update-adopt is 0700 root. A run as tom read nothing and printed load_state()'s defaults — "state": "idle", every other field null, exit 0 — which is exactly what a healthy host with nothing staged prints. It now reports the facts it can still see (current, booted, profile, the running generation's revision), sets the new state_unreadable field, leaves state / candidate / last_refusal / last_known_good / pending_reboot at null, and exits 1 with a line on stderr saying to run it as root. Nothing staged yet on a directory this user can read is still a real answer and still exits 0.

Verified live on the coordinator, as tom, against the real 0700 state directory:

$ update-adopt status --json            # deployed build
rc=0   "state": "idle"                  # ← the dishonest answer

$ UPDATE_ADOPT_CONFIG=/nix/store/rkvfsn9d…-update-adopt.json \
    python3 modules/update-adopt.py status --json   # this branch
rc=1   "state": null
       "state_unreadable": "[Errno 13] Permission denied: '/var/lib/update-adopt/state.json'"

modules/fleet-status.nix already collects every host, the coordinator included, as root over ssh precisely because this state is root-only; if the verb is ever run unprivileged, c_updates now grades the answer unknown instead of "up to date".

2. A 404 manifest logs no-candidate, not fetch-failed.
curl gets -w %{http_code}, so "the NAS answered, and has published nothing for this host" is told apart from "the NAS did not answer at all". Both still exit 0 — neither is this host's failure — but the journal line an operator reads now names the right one, and the refusal detail carries the URL and the code.

Verified against the live NAS publisher (http://nas:8734/candidates/…): an unpublished host is rc 22 / http_code 404, a published host rc 0 / 200, an unreachable endpoint rc 7 / 000. Verified hermetically by test_missing_manifest_is_no_candidate, with test_unreachable_nas_is_not_a_failure holding the other half of the distinction.

3. --no-check-sigs has a runbook note.
New docs/fleet-candidate-adoption.md (indexed in docs/README.md) covers the hand-publication path — nix copy --to ssh-ng://root@nas --no-check-sigs <closure> then update-center --publish-only HOST PATH FLAKEREF — and says why the flag belongs there: a locally built path is unsigned because signing happens on the way into a cache, the receiving daemon honours the flag only for a trusted user (trusted-users = [ "root" "@wheel" ], modules/common.nix), and the trust in that transfer is the authenticated SSH channel plus who built the bytes, not a signature. It also names the three places the flag is never legitimate — substituting from http://nas:8080/fleet, where the signing key is the whole proof; silencing a signature error whose cause is unknown; and any unit or timer, with home/update-center-seed.nix as the counter-example that needs no flag because its paths are narHash-addressed. Adoption's own trust (the ssh-keygen -Y manifest signature, the store-path shape check, and re-reading the revision from the closure's own fleet-revision.json) is unchanged.

Tests

Three cases added to the existing hermetic suite in tests/update-adopt/test_update_adopt.py; its fake curl now models a 404 and an unreachable NAS separately, instead of collapsing both into exit 22.

$ ~/.local/bin/runtime-test -- python3 -m unittest discover -s tests/update-adopt
Ran 33 tests in 8.099s — OK

$ nix build .#checks.x86_64-linux.update-adopt --no-link
Ran 33 tests in 6.136s — OK   (exit 0)

$ ~/.local/bin/runtime-test -- python3 -m unittest discover -s tests/fleet-status
Ran 14 tests in 8.367s — OK   (the status JSON gained a key; nothing downstream broke)

nix flake check does not pass, on this branch or on main: checks.x86_64-linux.fleet-connectivity fails evaluation on an unrelated model-roster assertion (coordinator.services.local-models.artifacts, 11 entries against 9 — the two openwakeword-* rows and qwen3-tts-tokenizer-f32). Confirmed pre-existing by evaluating that check straight from main. The targeted check for this change was built instead, above.

Nothing here was deployed: no nixos-rebuild, no home-manager switch. The live update-adopt on the coordinator is still the old build; the exit-code change reaches it at the next switch.

🤖 Generated with Claude Code

…tate, and write the --no-check-sigs note (#354)

Item 4 of #354's 2026-09-14 status comment, and the two CLI answers beside it
that read as "fine" when they are not.

1. `status` exits 1 when it cannot read what it is reporting on.
   /var/lib/update-adopt is 0700 root, so a run as tom read nothing and
   printed load_state()'s defaults — `"state": "idle"`, every other field
   null, exit 0 — which is indistinguishable from a healthy host that has
   nothing staged. It now prints the facts /run and /nix still give it, sets
   `state_unreadable`, leaves the state-derived fields null, and exits 1.
   Nothing staged yet on a readable directory is still a real answer and
   still exits 0. fleet-status already collects every host as root over ssh
   (modules/fleet-status.nix); if it ever ran the verb unprivileged it would
   now grade the answer unknown instead of "up to date".

2. A 404 manifest is `no-candidate`, not `fetch-failed`. curl gets
   `-w %{http_code}`, so "the NAS answered, with nothing published for this
   host" and "the NAS did not answer" are different journal lines. Both still
   exit 0: neither is this host's failure. MEASURED against the live NAS —
   an unpublished host is rc 22 / 404, a real one rc 0 / 200, an unreachable
   endpoint rc 7 / 000.

3. docs/fleet-candidate-adoption.md writes down when `--no-check-sigs` on the
   `nix copy` publish step is legitimate: a locally built closure is unsigned,
   the receiving daemon honours the flag only for a trusted user
   (modules/common.nix), and the trust in that transfer is the SSH channel and
   who built the bytes. It also names the three places the flag is never
   legitimate, starting with substitution from the fleet cache, where the
   signing key is the whole proof.

tests/update-adopt gains three cases (404 -> no-candidate, an unreadable state
directory -> rc 1, no state yet -> rc 0) and its fake curl now models a 404
and an unreachable NAS separately. 33 tests pass, in-tree and through
`nix build .#checks.x86_64-linux.update-adopt`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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