Wire a microvm guest into the host that starts it, so declared credential names become real host paths #38

Closed
opened 2026-08-06 19:43:13 +01:00 by allod-agent · 2 comments
Member

Connect a microvm guest to the host that starts it, so the credential names the guest declares become the actual file paths QEMU reads at boot.

Everything on both sides of that join now exists and nothing joins them. A selected microvm guest declares which credentials it expects by name and deliberately supplies no values (allod/archetypes#29, contract 7); allod/nexus owns the host root those values are derived from and asserts that the two sides agree (nix/microvm/host.nix:234-257). But mkHypervisor composes only nexus.nixosModules.host (flake.nix:748), never nexus.nixosModules.microvmHost, and nothing anywhere sets microvm.vms.<name>. So no host can start a credentialed microvm guest at all, and the agreement nexus asserts is asserted about nothing.

Primary goals:

  • One crossing, in one direction — the host's evaluated plaintext-root option reaches the guest through extendModules on the already-exported guest nixosConfiguration, so allod/nexus never imports allod/archetypes and never restates the credential-name set.
  • The host runs the extended guest, not the standalone one — the runner the host installs is built from the extended result, and a configuration that wires the unextended base result fails rather than booting a guest whose -fw_cfg file= arguments name nothing.
  • A host-root override moves real arguments — changing nexus.microvm.hostPlaintextRoot changes every credentialFiles value and the built runner's -fw_cfg file= arguments, proved by a second fixture at a non-default root rather than by a text scan for the default.
  • One revision set for host and guestnexus.inputs.vm.follows = "vm" so the host module and the guest module come from one allod/vm, one microvm.nix, and one nixpkgs (contract 1a), checked against the lock graph rather than assumed.
  • A rebuild restarts a changed guest — each selected microvm sets restartIfChanged = true explicitly, because supplying evaluatedConfig flips upstream's default to false and switch-to-configuration would otherwise skip the unit on every rebuild.

Current state

allod/archetypes after allod/archetypes#29:

  • Each selected microvm guest exports config.allod.archetypes.microvm.credentialNames — a closed set derived from what the machine declares, readOnly and internal (modules/microvm-credentials.nix:160). Its microvm.credentialFiles is {}, and the module asserts that it is either empty or keyed exactly by that name set (modules/microvm-credentials.nix:278), which is what makes an extended result legal and a mismatched one an evaluation error.
  • mkHypervisor composes nexus.nixosModules.host and nothing microvm-related (flake.nix:748).
  • The nexus input follows nixpkgs, home-manager and agenix, but not vm (flake.nix:19-24).

allod/nexus:

  • nixosModules.microvmHost composes allod/vm's re-exported upstream host module with the Allod host module (flake.nix:50-52), and is deliberately not part of the baseline host — a libvirt-only host does not acquire microvm.nix by importing nixosModules.host.
  • nexus.microvm.hostPlaintextRoot defaults to /run/allod/microvm and is a real noswap tmpfs mount (nix/microvm/host.nix:316). Per-VM credentials live under its derived active/<machine> child (nix/microvm/host.nix:43,85).
  • nexus.microvm.machines.<name>.credentialSources maps each credential name to an absolute host source path, supplied by deployment composition (nix/microvm/host.nix:353).
  • The host asserts that the source names equal the guest's microvm.credentialFiles keys and that each value is the exact <root>/active/<machine>/<credential> string (nix/microvm/host.nix:234-257). Both assertions are unreachable today because no host composes a guest.

The join

For each machine whose runtime fact is microvm, the hypervisor configuration:

  1. Composes nexus.nixosModules.microvmHost alongside nexus.nixosModules.host. A fleet with no microvm machine composes neither, so a libvirt-only host's generated output is unchanged.
  2. Calls extendModules on that machine's exported guest nixosConfiguration, adding one module that sets microvm.credentialFiles to builtins.listToAttrs over the guest's own config.allod.archetypes.microvm.credentialNames, each value the exact string <host's evaluated nexus.microvm.hostPlaintextRoot>/active/<machine>/<name>.
  3. Assigns that extended result to microvm.vms.<name>.evaluatedConfig, and sets microvm.vms.<name>.restartIfChanged = true.

The values are strings and never Nix paths: a path literal would copy the host's credential into the world-readable store, which is the failure contract 7 exists to name. The guest module already rejects a store-prefixed or non-string value, so the extension is checked by the configuration it extends.

Validation

  • Agreement (contract 17). A check that evaluates both sides of one composed hypervisor fixture and compares them: the host's prepared credential names equal the extended guest's credentialFiles keys, every value equals the host-option-derived path, and the runner the host installs is the extended guest's, not the standalone one. It compares evaluated values, not prose and not a text scan for a default path.
  • The non-default root. A second hypervisor fixture at a safe non-default hostPlaintextRoot, where every credentialFiles value and the built runner's -fw_cfg file= arguments follow it. A fixture retaining a default-root path fails.
  • The unextended result is refused. A fixture wiring the standalone guest into microvm.vms.<name>.evaluatedConfig fails, and for the reason it names rather than for a missing attribute somewhere downstream.
  • X-RestartIfChanged=true. Read off the rendered microvm@<name> drop-in, with a paired fixture that drops the explicit setting and shows the drop-in going false.
  • One revision set (contract 1a). A check over the lock graph and the evaluated host and guest module origins proving both reach the same vm, microvm.nix and nixpkgs revisions.

Every assertion added here carries a paired sabotage that fails for the diagnostic it names, per the same rule the credential slice followed.

Scope

In scope, all in allod/archetypes: the nexus.inputs.vm.follows redirect, composing the microvm host module, the extendModules join, restartIfChanged, and the checks above.

Out of scope and tracked elsewhere:

  • Real host values. credentialSources paths, volume image locations, TAP attachment, addressing and cutover are deployment composition and belong to the private integration plan. Nothing here supplies a real path.
  • Booting the public example. Milestone 4 of the plan also wants the public microvm example booted in nested checks, which needs an example machine whose runtime fact is microvm — an allod/inventory data change, not this one. It needs its own issue and carries a constraint worth stating there: the machine that carries runtime = "microvm" must be an example machine and nothing else, because per-machine encrypted secret filenames are keyed to the name and renaming afterwards is a human-only host action.
  • Rotation. Runtime-dispatched SSH host-key and Forge SSH key rotation are allod/nexus#21 and allod/nexus#22.
  • The guest-closure scan. Carried forward from allod/archetypes#29: a scan of the built microvm guest closure for age identities and private key material, which belongs with the slice that builds a guest.

Part of allod/strategy#20

Connect a microvm guest to the host that starts it, so the credential names the guest declares become the actual file paths QEMU reads at boot. Everything on both sides of that join now exists and nothing joins them. A selected microvm guest declares which credentials it expects by name and deliberately supplies no values (allod/archetypes#29, contract 7); `allod/nexus` owns the host root those values are derived from and asserts that the two sides agree (`nix/microvm/host.nix:234-257`). But `mkHypervisor` composes only `nexus.nixosModules.host` (`flake.nix:748`), never `nexus.nixosModules.microvmHost`, and nothing anywhere sets `microvm.vms.<name>`. So no host can start a credentialed microvm guest at all, and the agreement nexus asserts is asserted about nothing. Primary goals: - **One crossing, in one direction** — the host's evaluated plaintext-root option reaches the guest through `extendModules` on the already-exported guest `nixosConfiguration`, so `allod/nexus` never imports `allod/archetypes` and never restates the credential-name set. - **The host runs the extended guest, not the standalone one** — the runner the host installs is built from the extended result, and a configuration that wires the unextended base result fails rather than booting a guest whose `-fw_cfg file=` arguments name nothing. - **A host-root override moves real arguments** — changing `nexus.microvm.hostPlaintextRoot` changes every `credentialFiles` value and the built runner's `-fw_cfg file=` arguments, proved by a second fixture at a non-default root rather than by a text scan for the default. - **One revision set for host and guest** — `nexus.inputs.vm.follows = "vm"` so the host module and the guest module come from one `allod/vm`, one microvm.nix, and one nixpkgs (contract 1a), checked against the lock graph rather than assumed. - **A rebuild restarts a changed guest** — each selected microvm sets `restartIfChanged = true` explicitly, because supplying `evaluatedConfig` flips upstream's default to false and `switch-to-configuration` would otherwise skip the unit on every rebuild. ### Current state `allod/archetypes` after allod/archetypes#29: - Each selected microvm guest exports `config.allod.archetypes.microvm.credentialNames` — a closed set derived from what the machine declares, `readOnly` and internal (`modules/microvm-credentials.nix:160`). Its `microvm.credentialFiles` is `{}`, and the module asserts that it is either empty or keyed exactly by that name set (`modules/microvm-credentials.nix:278`), which is what makes an extended result legal and a mismatched one an evaluation error. - `mkHypervisor` composes `nexus.nixosModules.host` and nothing microvm-related (`flake.nix:748`). - The `nexus` input follows `nixpkgs`, `home-manager` and `agenix`, but not `vm` (`flake.nix:19-24`). `allod/nexus`: - `nixosModules.microvmHost` composes `allod/vm`'s re-exported upstream host module with the Allod host module (`flake.nix:50-52`), and is deliberately not part of the baseline host — a libvirt-only host does not acquire microvm.nix by importing `nixosModules.host`. - `nexus.microvm.hostPlaintextRoot` defaults to `/run/allod/microvm` and is a real `noswap` tmpfs mount (`nix/microvm/host.nix:316`). Per-VM credentials live under its derived `active/<machine>` child (`nix/microvm/host.nix:43,85`). - `nexus.microvm.machines.<name>.credentialSources` maps each credential name to an absolute host source path, supplied by deployment composition (`nix/microvm/host.nix:353`). - The host asserts that the source names equal the guest's `microvm.credentialFiles` keys and that each value is the exact `<root>/active/<machine>/<credential>` string (`nix/microvm/host.nix:234-257`). Both assertions are unreachable today because no host composes a guest. ### The join For each machine whose `runtime` fact is `microvm`, the hypervisor configuration: 1. Composes `nexus.nixosModules.microvmHost` alongside `nexus.nixosModules.host`. A fleet with no microvm machine composes neither, so a libvirt-only host's generated output is unchanged. 2. Calls `extendModules` on that machine's exported guest `nixosConfiguration`, adding one module that sets `microvm.credentialFiles` to `builtins.listToAttrs` over the guest's own `config.allod.archetypes.microvm.credentialNames`, each value the exact string `<host's evaluated nexus.microvm.hostPlaintextRoot>/active/<machine>/<name>`. 3. Assigns that extended result to `microvm.vms.<name>.evaluatedConfig`, and sets `microvm.vms.<name>.restartIfChanged = true`. The values are strings and never Nix paths: a path literal would copy the host's credential into the world-readable store, which is the failure contract 7 exists to name. The guest module already rejects a store-prefixed or non-string value, so the extension is checked by the configuration it extends. ### Validation - **Agreement (contract 17).** A check that evaluates both sides of one composed hypervisor fixture and compares them: the host's prepared credential names equal the extended guest's `credentialFiles` keys, every value equals the host-option-derived path, and the runner the host installs is the extended guest's, not the standalone one. It compares evaluated values, not prose and not a text scan for a default path. - **The non-default root.** A second hypervisor fixture at a safe non-default `hostPlaintextRoot`, where every `credentialFiles` value and the built runner's `-fw_cfg file=` arguments follow it. A fixture retaining a default-root path fails. - **The unextended result is refused.** A fixture wiring the standalone guest into `microvm.vms.<name>.evaluatedConfig` fails, and for the reason it names rather than for a missing attribute somewhere downstream. - **`X-RestartIfChanged=true`.** Read off the rendered `microvm@<name>` drop-in, with a paired fixture that drops the explicit setting and shows the drop-in going false. - **One revision set (contract 1a).** A check over the lock graph and the evaluated host and guest module origins proving both reach the same `vm`, microvm.nix and nixpkgs revisions. Every assertion added here carries a paired sabotage that fails for the diagnostic it names, per the same rule the credential slice followed. ### Scope In scope, all in `allod/archetypes`: the `nexus.inputs.vm.follows` redirect, composing the microvm host module, the `extendModules` join, `restartIfChanged`, and the checks above. Out of scope and tracked elsewhere: - **Real host values.** `credentialSources` paths, volume image locations, TAP attachment, addressing and cutover are deployment composition and belong to the private integration plan. Nothing here supplies a real path. - **Booting the public example.** Milestone 4 of the plan also wants the public microvm example booted in nested checks, which needs an example machine whose `runtime` fact is `microvm` — an `allod/inventory` data change, not this one. It needs its own issue and carries a constraint worth stating there: the machine that carries `runtime = "microvm"` must be an example machine and nothing else, because per-machine encrypted secret filenames are keyed to the name and renaming afterwards is a human-only host action. - **Rotation.** Runtime-dispatched SSH host-key and Forge SSH key rotation are allod/nexus#21 and allod/nexus#22. - **The guest-closure scan.** Carried forward from allod/archetypes#29: a scan of the built microvm guest closure for age identities and private key material, which belongs with the slice that builds a guest. Part of allod/strategy#20
Author
Member

Implemented, with two additions to absorb and one verification request

The join is implemented and under review. Two things go beyond what this issue's scope names, and neither is optional; recording them here rather than leaving them to be found in the diff.

1. The join sets the guest's microvm.socket, because a composed host does not evaluate without it

This issue's "The join" section lists three steps and none of them is the QMP socket. But allod/nexus refuses upstream's default — a bare "<hostName>.sock", relative to the unit's working directory — and requires a normalized absolute path strictly below /run/allod, outside both the host plaintext root and the namespace roots (nix/microvm/host.nix contract 8). Nothing in allod/archetypes or allod/vm sets it.

So composing nexus.nixosModules.microvmHost produces a configuration that fails evaluation on nexus's own assertion, and the goal this issue states — a host that can start a credentialed guest — is unreachable until someone owns that path. The join owns it, for the same reason it owns the credential paths: the host is the only side that knows where its own runtime tree lives. The value is /run/allod/microvm-qmp/<machine>/control.sock, the layout nexus's host fixtures already assume, written as a framework literal rather than an option because no deployment has needed to move it.

If the framework would rather this lived in allod/vm's guest module, that is a defensible call and a separate change — but it cannot simply be dropped.

2. Each wired VM sets autostart = false

Upstream defaults microvm.vms.<name>.autostart to true, and microvms.target then wants the unit, so wiring a guest without disarming it makes a host rebuild start it. allod/strategy#20's agent gates make enabling autostart for a real machine a human-only act, so the framework declares the VM without arming it and a deployment opts in. Measured: with the line removed, config.microvm.autostart comes back naming the machine.

One planned assertion was cut as unprovable

The join asserts exactly one rule — that every wired guest's credentialFiles keys equal its declared credentialNames, which is the property allod/nexus cannot state because it must not learn the name set. Two neighbouring conditions were written and removed: nexus dereferences vm.guest.microvm.credentialFiles while building its own assertion list (nix/microvm/host.nix:259), so a VM wired in upstream's other two shapes, or wired to something that is not a microvm guest, raises a raw attribute error before any archetypes assertion can render. Both still fail loudly, just not with a message from this repo.

Worth knowing for whoever writes the next slice: a check that reads config.microvm.vms or a rendered unit does not force config.assertions — nixpkgs only enforces those through system.build.toplevel. The first working version of this check passed while its own fixtures were configurations no host could build, and that is exactly how the socket problem above stayed hidden. Every positive fixture now forces toplevel.drvPath and reports the failed assertion messages.

Verification request

Everything is proved against the public example fleet, which is not the fleet that matters. Two requests, both pure evaluation, neither needing hardware, a boot, or real key material.

  1. No machine moves. At the head of the archetypes branch for this issue, compare nixosConfigurations.<each>.config.system.build.toplevel.drvPath against the parent commit and report which machines move. A pass is: none. Every change is gated on the fleet declaring a microvm machine and no machine declares that runtime, so any machine that moves is a leak in the gating. Measured on the public fleet: all four unchanged, both for the nexus.inputs.vm.follows = "vm" lock change alone and for the whole commit.

  2. The join against real machine data. For a scratch evaluation with one private machine switched to runtime = "microvm", and the deployment's own nexus.microvm.machines.<name>.credentialSources supplied, report whether the hypervisor evaluates and what config.microvm.vms.<name>.evaluatedConfig.config.microvm.credentialFiles contains. A pass is: it evaluates, and every value is <the deployment's hostPlaintextRoot>/active/<machine>/<name> keyed exactly by that machine's declared names.

Two things a deployment needs to know before it can evaluate a microvm machine at all: credentialSources must be supplied for it and must name exactly the credentials the machine declares, and the QMP socket now lands under /run/allod/microvm-qmp, which must not collide with that deployment's hostPlaintextRoot.

Nothing here asks for a rebuild or a cutover, and no machine should select the microvm runtime on the strength of this change.

## Implemented, with two additions to absorb and one verification request The join is implemented and under review. Two things go beyond what this issue's scope names, and neither is optional; recording them here rather than leaving them to be found in the diff. ### 1. The join sets the guest's `microvm.socket`, because a composed host does not evaluate without it This issue's "The join" section lists three steps and none of them is the QMP socket. But `allod/nexus` refuses upstream's default — a bare `"<hostName>.sock"`, relative to the unit's working directory — and requires a normalized absolute path strictly below `/run/allod`, outside both the host plaintext root and the namespace roots (`nix/microvm/host.nix` contract 8). Nothing in `allod/archetypes` or `allod/vm` sets it. So composing `nexus.nixosModules.microvmHost` produces a configuration that fails evaluation on nexus's own assertion, and the goal this issue states — a host that can start a credentialed guest — is unreachable until someone owns that path. The join owns it, for the same reason it owns the credential paths: the host is the only side that knows where its own runtime tree lives. The value is `/run/allod/microvm-qmp/<machine>/control.sock`, the layout nexus's host fixtures already assume, written as a framework literal rather than an option because no deployment has needed to move it. If the framework would rather this lived in `allod/vm`'s guest module, that is a defensible call and a separate change — but it cannot simply be dropped. ### 2. Each wired VM sets `autostart = false` Upstream defaults `microvm.vms.<name>.autostart` to true, and `microvms.target` then wants the unit, so wiring a guest without disarming it makes a host rebuild start it. `allod/strategy#20`'s agent gates make enabling autostart for a real machine a human-only act, so the framework declares the VM without arming it and a deployment opts in. Measured: with the line removed, `config.microvm.autostart` comes back naming the machine. ### One planned assertion was cut as unprovable The join asserts exactly one rule — that every wired guest's `credentialFiles` keys equal its declared `credentialNames`, which is the property `allod/nexus` cannot state because it must not learn the name set. Two neighbouring conditions were written and removed: nexus dereferences `vm.guest.microvm.credentialFiles` while building its own assertion list (`nix/microvm/host.nix:259`), so a VM wired in upstream's other two shapes, or wired to something that is not a microvm guest, raises a raw attribute error before any archetypes assertion can render. Both still fail loudly, just not with a message from this repo. Worth knowing for whoever writes the next slice: a check that reads `config.microvm.vms` or a rendered unit does **not** force `config.assertions` — nixpkgs only enforces those through `system.build.toplevel`. The first working version of this check passed while its own fixtures were configurations no host could build, and that is exactly how the socket problem above stayed hidden. Every positive fixture now forces `toplevel.drvPath` and reports the failed assertion messages. ### Verification request Everything is proved against the public example fleet, which is not the fleet that matters. Two requests, both pure evaluation, neither needing hardware, a boot, or real key material. 1. **No machine moves.** At the head of the archetypes branch for this issue, compare `nixosConfigurations.<each>.config.system.build.toplevel.drvPath` against the parent commit and report which machines move. A pass is: **none.** Every change is gated on the fleet declaring a microvm machine and no machine declares that runtime, so any machine that moves is a leak in the gating. Measured on the public fleet: all four unchanged, both for the `nexus.inputs.vm.follows = "vm"` lock change alone and for the whole commit. 2. **The join against real machine data.** For a scratch evaluation with one private machine switched to `runtime = "microvm"`, and the deployment's own `nexus.microvm.machines.<name>.credentialSources` supplied, report whether the hypervisor evaluates and what `config.microvm.vms.<name>.evaluatedConfig.config.microvm.credentialFiles` contains. A pass is: it evaluates, and every value is `<the deployment's hostPlaintextRoot>/active/<machine>/<name>` keyed exactly by that machine's declared names. Two things a deployment needs to know before it can evaluate a microvm machine at all: `credentialSources` must be supplied for it and must name exactly the credentials the machine declares, and the QMP socket now lands under `/run/allod/microvm-qmp`, which must not collide with that deployment's `hostPlaintextRoot`. Nothing here asks for a rebuild or a cutover, and no machine should select the microvm runtime on the strength of this change.
Owner

merged

merged
vnprc closed this issue 2026-08-22 02:59:18 +01:00
Sign in to join this conversation.
No description provided.