Derive VM target and host-key pin from the deploy flake in rebuild-vm-from-host and provision-vm-from-host #5

Closed
opened 2026-07-14 16:24:53 +01:00 by vnprc-agent · 1 comment

Rework scripts/lib/rotation-common.sh (and its callers scripts/rebuild-vm-from-host and scripts/provision-vm-from-host) so a single deploy-flake reference is the source of truth for the target IP, the username, and the host-key pin — invoking rebuild-vm-from-host <vm> or provision-vm-from-host <vm> should need no manual INVENTORY=, MACHINE_PROFILES=, or IDENTITY_CONFIG=.

Context

Both scripts currently source what they need from three independently-maintained checkouts selected by environment variables. MACHINE_PROFILES is the flake that gets built (--flake "${MACHINE_PROFILES}#<vm>") and also the source of the secrets input revision read from its flake.lock. IDENTITY_CONFIG is the secrets checkout, used both for the username (nix eval path:${IDENTITY_CONFIG}#lib.vmUsernames.<vm>) and for the host-key material (git show <secrets-rev>:machine-host-keys.json). INVENTORY is the inventory checkout, used for the target IP (${INVENTORY}/scripts/vm-specs.json).

Under the private-first template model an operator builds a deploy flake that already pins the correct inventory and secrets revisions and composes them with the public profiles/nexus/vm framework. The provisioning scripts do not read from that flake; they read the three side checkouts, which the operator must point and keep current by hand. In practice these drift and surface as failures that look unrelated to the real cause: a build flake whose default (public template) secrets pin does not match the operator's real secrets checkout produces pinned secrets@<rev> is unavailable locally; an INVENTORY pointed at the public template inventory yields a placeholder target IP and ssh-keyscan returned no presented SSH host key; a stale INVENTORY checkout missing a newly added machine yields unknown VM '<vm>'. Each is a mispointed or stale checkout rather than a build problem — the deploy flake itself builds every machine correctly.

Scope

The first change is in scripts/lib/rotation-common.sh: replace the "resolve the secrets pin from MACHINE_PROFILES/flake.lock and read files from separate IDENTITY_CONFIG and INVENTORY checkouts" logic with derivation from one deploy-flake reference. The two callers scripts/rebuild-vm-from-host and scripts/provision-vm-from-host then pass that single reference instead of three checkout paths. The deploy-flake location must stay configurable with the same env-override shape the current variables use, since this is public tooling and each operator points at their own deploy flake.

Default approach: resolve the deploy flake's flake.lock to its pinned inventory and secrets revisions, then read vm-specs.json and machine-host-keys.json at exactly those revisions (same repos, but always the pinned commits instead of whatever a side checkout happens to be on). This keeps local checkouts but removes the drift, and is the smaller change.

Follow-up alternative, only if the default proves unworkable: expose the target IP and the pinned host keys as flake outputs so the scripts use nix eval <deploy>#... with no side checkouts at all. This eliminates checkout state entirely but requires adding read-only outputs to the profiles, inventory, and secrets flakes and is a larger, cross-repo change; leave it as a follow-up.

Validation

rebuild-vm-from-host <vm> and provision-vm-from-host <vm> complete with no INVENTORY/MACHINE_PROFILES/IDENTITY_CONFIG set, against a configured deploy flake, with target IP, username, and host-key pin all derived from that flake. Update tests/rebuild-vm-from-host.sh, tests/provision-vm-from-host.sh, tests/rotation-common.sh, and the shared tests/provisioning-contract.sh to cover the new derivation; the token-rotation flow that also sources rotation-common.sh must still pass tests/rotate-token.sh. The actual VM rebuild/provision against a live host is a manual host-side gate for the human and is not part of automated validation.

Boundaries

Preserve the host-key pinning preflight (assert_any_vm_host_key_material_pinned and assert_vm_host_key_material_pinned) at equal strength: it is a security control that stops nixos-rebuild switch from landing on a host presenting an unexpected or absent SSH host key, so re-sourcing its inputs must not weaken it, and a regression test that the preflight fails closed on an unpinned key is required. Out of scope: the deploy-flake composition itself, the profiles profileDefinitions model, and the inventory and secrets data schemas (except adding read-only outputs if the follow-up alternative is chosen). Host-side only: no live rebuild or provision from a dev VM. Keep fixtures on the public template inventory/secrets; no host-specific addressing or secrets in code or tests.

Design status

The default-vs-follow-up choice is a genuine design fork with a security-sensitive component, so treat this as design-then-implement: the dev plan should pick the approach, enumerate the exact touched functions in rotation-common.sh and their new inputs, and define the test matrix before any code lands.

Rework `scripts/lib/rotation-common.sh` (and its callers `scripts/rebuild-vm-from-host` and `scripts/provision-vm-from-host`) so a single deploy-flake reference is the source of truth for the target IP, the username, and the host-key pin — invoking `rebuild-vm-from-host <vm>` or `provision-vm-from-host <vm>` should need no manual `INVENTORY=`, `MACHINE_PROFILES=`, or `IDENTITY_CONFIG=`. ## Context Both scripts currently source what they need from three independently-maintained checkouts selected by environment variables. `MACHINE_PROFILES` is the flake that gets built (`--flake "${MACHINE_PROFILES}#<vm>"`) and also the source of the `secrets` input revision read from its `flake.lock`. `IDENTITY_CONFIG` is the secrets checkout, used both for the username (`nix eval path:${IDENTITY_CONFIG}#lib.vmUsernames.<vm>`) and for the host-key material (`git show <secrets-rev>:machine-host-keys.json`). `INVENTORY` is the inventory checkout, used for the target IP (`${INVENTORY}/scripts/vm-specs.json`). Under the private-first template model an operator builds a deploy flake that already pins the correct `inventory` and `secrets` revisions and composes them with the public profiles/nexus/vm framework. The provisioning scripts do not read from that flake; they read the three side checkouts, which the operator must point and keep current by hand. In practice these drift and surface as failures that look unrelated to the real cause: a build flake whose default (public template) secrets pin does not match the operator's real secrets checkout produces `pinned secrets@<rev> is unavailable locally`; an `INVENTORY` pointed at the public template inventory yields a placeholder target IP and `ssh-keyscan returned no presented SSH host key`; a stale `INVENTORY` checkout missing a newly added machine yields `unknown VM '<vm>'`. Each is a mispointed or stale checkout rather than a build problem — the deploy flake itself builds every machine correctly. ## Scope The first change is in `scripts/lib/rotation-common.sh`: replace the "resolve the secrets pin from `MACHINE_PROFILES/flake.lock` and read files from separate `IDENTITY_CONFIG` and `INVENTORY` checkouts" logic with derivation from one deploy-flake reference. The two callers `scripts/rebuild-vm-from-host` and `scripts/provision-vm-from-host` then pass that single reference instead of three checkout paths. The deploy-flake location must stay configurable with the same env-override shape the current variables use, since this is public tooling and each operator points at their own deploy flake. Default approach: resolve the deploy flake's `flake.lock` to its pinned `inventory` and `secrets` revisions, then read `vm-specs.json` and `machine-host-keys.json` at exactly those revisions (same repos, but always the pinned commits instead of whatever a side checkout happens to be on). This keeps local checkouts but removes the drift, and is the smaller change. Follow-up alternative, only if the default proves unworkable: expose the target IP and the pinned host keys as flake outputs so the scripts use `nix eval <deploy>#...` with no side checkouts at all. This eliminates checkout state entirely but requires adding read-only outputs to the profiles, inventory, and secrets flakes and is a larger, cross-repo change; leave it as a follow-up. ## Validation `rebuild-vm-from-host <vm>` and `provision-vm-from-host <vm>` complete with no `INVENTORY`/`MACHINE_PROFILES`/`IDENTITY_CONFIG` set, against a configured deploy flake, with target IP, username, and host-key pin all derived from that flake. Update `tests/rebuild-vm-from-host.sh`, `tests/provision-vm-from-host.sh`, `tests/rotation-common.sh`, and the shared `tests/provisioning-contract.sh` to cover the new derivation; the token-rotation flow that also sources `rotation-common.sh` must still pass `tests/rotate-token.sh`. The actual VM rebuild/provision against a live host is a manual host-side gate for the human and is not part of automated validation. ## Boundaries Preserve the host-key pinning preflight (`assert_any_vm_host_key_material_pinned` and `assert_vm_host_key_material_pinned`) at equal strength: it is a security control that stops `nixos-rebuild switch` from landing on a host presenting an unexpected or absent SSH host key, so re-sourcing its inputs must not weaken it, and a regression test that the preflight fails closed on an unpinned key is required. Out of scope: the deploy-flake composition itself, the profiles `profileDefinitions` model, and the inventory and secrets data schemas (except adding read-only outputs if the follow-up alternative is chosen). Host-side only: no live rebuild or provision from a dev VM. Keep fixtures on the public template inventory/secrets; no host-specific addressing or secrets in code or tests. ## Design status The default-vs-follow-up choice is a genuine design fork with a security-sensitive component, so treat this as design-then-implement: the dev plan should pick the approach, enumerate the exact touched functions in `rotation-common.sh` and their new inputs, and define the test matrix before any code lands.
Member

Resolved by nexus commit cb0758a — "Derive provisioning source from deploy flake". The code diff is at that link.

Process note — the PR was accidentally skipped: this landed directly on master instead of going through a pull request. Branch protection has since been enabled on nexus master, so future changes require a PR. Because there was no pre-merge PR to review, an implementation-review pass was run after the fact against the converged dev plan (nexus-deploy-flake-provisioning-source in allod/strategy, converged through three rotated plan-review passes: claude-fable-5 → claude-opus-4-8 → gpt-5.5).

Post-hoc implementation review (claude-opus-4-8) — clean, no changes warranted:

  • Acceptance genuinely passes: nix flake check is green, and a forced fresh rebuild ran shellcheck -x + bash -n + all ten test suites in-sandbox, all passing.
  • The fail-closed host-key pin is preserved and genuinely asserted: the pinning preflight refuses before nixos-rebuild / new-vm / nixos-anywhere on any absent, mismatched, or entry-without-material key, and the suites assert that refusal together with an explicit "no build ran" check. No fail-open path exists.
  • Both seams handed from plan review to implementation review are fixed: the age host-key secret is read (and its fixture committed) at the pinned secrets rev, with real absent-at-pin / garbage-at-pin / ciphertext-skew negatives; and forge_key is resolved up front at the inventory pin so the late bootstrap gate can't die on an unbound variable, with an anti-drift assertion (a pinned non-null value beats a working-tree null).
  • Full plan conformance: the env surface is exactly DEPLOY_FLAKE / INVENTORY_CHECKOUT / SECRETS_CHECKOUT with INVENTORY / MACHINE_PROFILES / IDENTITY_CONFIG removed from both scripts; the changed rotation-common.sh helpers have no callers outside the two in-scope scripts, so rotate-token and the out-of-scope scripts are unaffected; and the zero-env goal is proven via env -u plus fixtures planted under $HOME.

One immaterial note (not a defect, no code change): with an explicit positional IP override, an unknown VM now fails one step earlier — at the username eval ("cannot resolve username for …") rather than the host-key assert the plan prose predicted — still loud and fail-closed before any build.

Closing: implemented and independently reviewed clean.

Resolved by nexus commit [`cb0758a`](https://forge.anarch.diy/allod/nexus/commit/cb0758a63ff20da1bbfac2c2e2a477d78196a73a) — "Derive provisioning source from deploy flake". The code diff is at that link. Process note — the PR was accidentally skipped: this landed **directly on `master`** instead of going through a pull request. Branch protection has since been enabled on `nexus` `master`, so future changes require a PR. Because there was no pre-merge PR to review, an implementation-review pass was run after the fact against the converged dev plan (`nexus-deploy-flake-provisioning-source` in `allod/strategy`, converged through three rotated plan-review passes: claude-fable-5 → claude-opus-4-8 → gpt-5.5). Post-hoc implementation review (claude-opus-4-8) — **clean, no changes warranted**: - Acceptance genuinely passes: `nix flake check` is green, and a forced fresh rebuild ran `shellcheck -x` + `bash -n` + all ten test suites in-sandbox, all passing. - The fail-closed host-key pin is preserved and genuinely asserted: the pinning preflight refuses before `nixos-rebuild` / `new-vm` / `nixos-anywhere` on any absent, mismatched, or entry-without-material key, and the suites assert that refusal together with an explicit "no build ran" check. No fail-open path exists. - Both seams handed from plan review to implementation review are fixed: the age host-key secret is read (and its fixture committed) at the pinned secrets rev, with real absent-at-pin / garbage-at-pin / ciphertext-skew negatives; and `forge_key` is resolved up front at the inventory pin so the late bootstrap gate can't die on an unbound variable, with an anti-drift assertion (a pinned non-null value beats a working-tree null). - Full plan conformance: the env surface is exactly `DEPLOY_FLAKE` / `INVENTORY_CHECKOUT` / `SECRETS_CHECKOUT` with `INVENTORY` / `MACHINE_PROFILES` / `IDENTITY_CONFIG` removed from both scripts; the changed `rotation-common.sh` helpers have no callers outside the two in-scope scripts, so `rotate-token` and the out-of-scope scripts are unaffected; and the zero-env goal is proven via `env -u` plus fixtures planted under `$HOME`. One immaterial note (not a defect, no code change): with an explicit positional IP override, an unknown VM now fails one step earlier — at the username eval ("cannot resolve username for …") rather than the host-key assert the plan prose predicted — still loud and fail-closed before any build. Closing: implemented and independently reviewed clean.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
allod/nexus#5
No description provided.