Select the guest module from the inventory runtime fact #23
No reviewers
Labels
No labels
bug
duplicate
enhancement
help wanted
invalid
question
wontfix
bug
duplicate
enhancement
help wanted
invalid
question
wontfix
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
allod/archetypes!23
Loading…
Reference in a new issue
No description provided.
Delete branch "agent/runtime-guest-module-selection"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Makes the inventory
runtimefact decide which guest module a machine composes. Before this,flake.nixhard-codedvm.nixosModules.qemuGuestinto every dev and privacy VM, so a machine declaringmicrovmstill built as an ordinary libvirt guest and the fact labelled a build it could not affect.guestModuleFormaps the fact to a guest module and throws on anything it cannot map. There is no default branch — a fallback is precisely what made the fact decorative.Two commits, and the split is the validation argument rather than tidiness. The first bumps the
inventoryandvminputs, which are prerequisites: the lockedinventorypredates the runtime fact, somachines.<name>.runtimecannot resolve, and the lockedvmpredates the guest-module split, so there is nomicrovmGuestto select. The second makes the selection. Comparing derivation paths across the second commit attributes any difference to the selection alone; comparing across the PR would conflate the two and could not.Closes #22
Refs allod/strategy#20
Risk
R2. The libvirt path — the entire current fleet — is a measured no-op, and the microvm branch is unreachable from real data: no inventory machine declares
microvm, and one that did would fail evaluation on allod/vm's persistent-volume contract until that milestone lands. That fail-closed state is intended, not an oversight. Both guest modules are already contract-checked inallod/vm; this PR selects between two proven modules rather than defining guest behavior. Rollback is a straight revert of either or both commits.Scored below the parent plan's R3 row for archetypes guest integration because that row covers all of milestone 4 — credentials, volumes, networking,
extendModules, nested boot. It would flip to R3 if a fresh evaluation moved any current derivation, or if real inventory data made the microvm branch reachable.Validation
nix flake checkgreen, 10 checks. System derivations unchanged across the selection commit:nixpkgsanddiskoare identical at bothvmrevisions, which is why the bump lands clean —archetypessetsnixpkgs.follows = "vm/nixpkgs", so avmbump that moved nixpkgs would have moved the whole fleet.The new
runtime-module-selectioncheck compares two independently derived facts: the inventory declaration, and the marker each guest module sets on itself (qemu-guest.nixsets[ "libvirt" ],microvm-guest.nixsets[ "microvm" ]). A selector that ignored its input would report[ "libvirt" ]against a declared"microvm"and fail. Four negative cases fail as intended, including a microvm guest missing its persistent volume — that one is what proves the fixtures' toplevel forcing is real work, since NixOS assertions hang offbaseSystemAssertWarnand reading the marker alone leaves allod/vm's contracts unevaluated.Non-vacuity was checked by sabotage in both directions — see the residual-risk section below.
Residual risk worth a human eye
Both sabotage directions pin cleanly since
50681fb. Mappinglibvirtto the microvm guest reportscomposed guest runtime disagrees with the declared inventory fact for: allod-dev, privacy-1; mappingmicrovmto the qemu guest reportsa machine declaring microvm composed libvirt. The selection comparison reads the marker off an unforced, volume-free fixture, so a wrongly selected module surfaces as the mismatch rather than as an unmatchedmicrovm.volumesdefinition. The volume-bearing fixture is retained only for forcing allod/vm's contracts.The
vmbump adds microvm.nix and its ownspectruminput (git+https://spectrum-os.org/git/spectrum, rev24c4346e) to the lock graph. That is upstream's dependency arriving through allod/vm's sole pin, as the parent plan's contract 1a intends, but it is a new fetch source and worth a look.vmFacts.<name>.runtime(parent contract 1) is deliberately not exported here — it changes a public output consumed host-side bynexusand interacts with thevm-facts-coherenceprojections. It belongs with the consumers that need it.Issue corrections
Three claims in #22 did not survive checking, and the plan records them:
inventory.lib.vmRuntimesdoes not exist anywhere (the capability is real viainventory.machines.<name>.runtime, which forces the same validation chain); the issue's out-of-scope volume declarations contradict its own microvm validation criterion, resolved by having the fixture supply a placeholder the builder does not; and "every piece already exists and is merged" was true of the source repos but not of this one, whose lock pinned both inputs too early.Plan:
allod/strategydev-plans/runtime-guest-module-selection.md, reviewed bygpt-5.6-solathigheffort — 3 high and 1 medium finding, all folded in before implementation.A dev microVM needs persistent storage (parent contract 13: the user's home and
/nix/var/nix). This PR does not declare those volumes, so a machine selectingmicrovmfails to evaluate rather than building something unusable — fail-closed, not a supported configuration. Whether those declarations belong here is an open scope question raised in the comments.Review feedback received: a dev microVM with no drive is useless and was never the design; can the circular logic be broken with a disk; how does the hypervisor gap get closed. Two of the three are fixed in
50681fb. The third is a scope question for you, below.A driveless dev microVM was never the design — you are right
The parent plan is explicit. Contract 6a: a guest that cannot realise a store path "cannot run
nix build,nix flake check, ornix flake update" and so "does not do the job the archetype exists for". Contract 13 names the required set for the dev archetype as the user's home plus/nix/var/nix. The scope section says the framework declares those mount points.So nothing here proposes shipping a driveless dev microVM. What this PR deferred was archetypes declaring the volumes, and the effect is that a machine selecting
microvmfails to evaluate rather than building something unusable. No driveless dev microVM can be produced. That is the fail-closed state, not a supported configuration.The open question is whether the split was worth making at all, which is the scope question at the bottom.
The disk does not break the circularity — not forcing the toplevel does
A real disk behaves exactly like the placeholder. The problem was never that the image was fake; it was that a
microvm.volumesdeclaration is meaningless under a qemu guest, so once the wrong module is selected the definition is unmatched and Nix raises that while assembling the option tree, before any value is read.What actually breaks it: the marker does not need a volume. Measured directly on an unforced configuration with nothing declared:
So the two jobs are now split. The selection comparison uses the volume-free fixture and stays pinned in both directions. The volume-bearing fixture is kept only for forcing allod/vm's contracts, which is where a volume is genuinely required.
Sabotaging the direction that previously could not be pinned now reports:
The other direction still names its machines:
composed guest runtime disagrees with the declared inventory fact for: allod-dev, privacy-1. The caveat in this PR's description is obsolete and the code comment explaining it is gone.The hypervisor gap is closed here
The plan said to guard the boundary rather than leave it to call-site discipline, and the first implementation left it to call-site discipline. Now the check asserts hypervisors keep the guest-runtime marker undeclared, so a later refactor routing
mkHypervisorthroughsharedModulesfails here instead of at whatever consumer notices first.Read with
lib.hasAttrByPath, nottryEval— an undeclared option is a raw attribute error, whichtryEvaldoes not catch. That is the same harness bound the plan records for the negative cases.Still green
nix flake checkpasses 10 checks and the fleet derivations are unchanged from the lock bump.Scope question for you
Do the volume declarations come into this PR, or stay a separate change?
Argument for here: it is the difference between an interface and a usable machine, and the fixture placeholder disappears once real declarations exist.
Argument against: contract 13 puts the image path in deployment data — "the private deployer explicitly creates, formats, labels, owns by that VM's runner principal, and assigns the image before enabling the VM" — so the public repo cannot declare a complete volume entry on its own. Landing half of it here would leave a second placeholder behind, which is what this change just removed.
That is a boundary judgement I should not make alone, so tell me which way and whether the follow-up belongs in a private repo.
Two review points:
allod-devhard-coded into fixtures, and whether the microvm runtime respects the no-shared-disk boundary. The first is fixed incc0804d. The second rests on a premise that is not the case, with evidence below — but there is a real unproven area, and it is not this one.allod-dev no longer appears in this check
The fixtures took the machine name literally, which bakes one example machine into a check about the runtime fact — a deploy redirecting the inventory input at its own machine set would have had to keep the name to stay green. They now take the first machine of type
devand throw a named error if inventory declares none.Pre-existing checks (
netrc-activation,dev-forge-opt-out,pi-integration,agent-vm-status) still name it. Separate cleanup, not swept in here.There is no shared disk
A microvm guest shares no filesystem with the host.
allod/vmasserts it, and the message says why:microvm.shares must be empty ... A share puts virtiofsd or 9p between the host filesystem and the guest.A second assertion requiresmicrovm.storeOnDiskso/nix/storecomes from a generated disk image rather than a host share.What the guest gets is block devices backed by host files. That is structurally the same arrangement the libvirt guests use today:
disk.nixdeclares/dev/vda, a host image file behind a virtual disk. Neither model puts a filesystem driver across the boundary.Microvms do have their own disks — what they lack is a persistent root
The root is tmpfs, in RAM, deliberately. Persistent state lives on explicitly declared volumes, each a separate host file attached to exactly one VM. So the property is not "no disk", it is "nothing persists unless it was declared". Contract 13 requires
autoCreate = falseon every one, so no framework path creates, grows or relabels an image.On isolation this is an upgrade, and measurably so
Upstream microvm.nix runs every VM as one shared
microvm:kvmprincipal —nexus/nix/microvm/host.nix:11says exactly that, and that this module replaces it. Nexus gives each VM its own system user and group, withkvmheld only as a supplementary group for/dev/kvm.Each VM's QEMU then runs inside a private mount namespace containing only:
/nix/store, read-only — needed so the runner andsetprivstay reachable afterpivot_root/proc/dev/{null,zero,full,random,urandom,kvm,net/tun}and optionally/dev/vhost-netThen
pivot_root, the old root detached and removed, and a single privilege drop to that VM's principal.Compare the current libvirt host:
virtualisation.libvirtd.enable = truewith no per-VM user configuration at all. So the microvm path is stricter than what runs today and stricter than upstream's own default.Can a guest reach the hypervisor
Only by breaking QEMU's device emulation, which is the same exposure libvirt has today —
/dev/kvmreachability is inherent to any KVM guest. What differs is what escaped code holds afterwards: an unprivileged per-VM uid, inside a namespace with no sibling objects, no writable host path, and specifically no path the root unit executes (contract 16 tightens upstream's0775 microvm:kvmstate directory for exactly that reason).One object genuinely is shared across guests and worth naming: the host's
/nix/store, bind-mounted read-only into every namespace. It is read-only and holds public package content, but an escape could read it and learn what the host has installed.What is actually unproven
Not the isolation — that has an implementation and a dedicated check (
nexus/checks/microvm/isolation.nix) that starts two guests and has each attack the other's credentials, images, QMP socket and runner.The unproven part is storage lifecycle. The parent plan says so itself: contract 6a "is an empirical specification, recorded as such deliberately ... no pass has executed the assembled lifecycle in a booted guest." Its acceptance test 9 is the evidence that settles it, and the plan already forbids a dev microvm landing before those boots pass.
I have not run either check in this session; both belong to
allod/nexus, not this PR. If the storage lifecycle is the worry, that is the right thing to demand evidence for before any machine selects this runtime — and the plan's own sequencing already blocks on it.Ran an independent adversarial review of the diff — something I should have done when this PR opened rather than only reviewing the plan. It found four real defects, one of them introduced by the previous commit. All fixed in
b784e6d.What it refuted
The dev fixtures assumed a machine's definition is keyed on its own name. The builders do not assume that:
machineConfigurationsresolvesdefinitionName = profile.definition or name, because machines may share one definition. The reviewer executed the case — gave the dev machine aprofile.definitionpointing at a shared definition — andnix flake checkdied with a rawattribute 'allod-dev' missing, surfacing inside an unrelated assertion's stack. That is exactly the deploy-redirect scenario the previous commit was written to support, so the decoupling was half done. Now resolved the same way the builders resolve it, with a named throw.The both-guest-modules fixture depended on the head dev machine declaring libvirt. It took the default runtime. If that machine ever declares microvm — a deploy's inventory, or the eventual first microvm machine — the fixture composes
microvmGuesttwice,guest-basededups withlib.unique, exclusivity never fires, and the fixture evaluates cleanly. The check would then fail with "negative cases did not fail" for a reason having nothing to do with exclusivity. Runtime is now pinned to libvirt explicitly.Two negative cases were not pinned to the contract they name. The reviewer probed the failed assertion set and found
bothRuntimestrips six assertions, only one of which is exclusivity — delete allod/vm's exclusivity assertion and the case stays green. The check's own comment cited the "failed for some other reason" trap that allod/inventory records, without applying the lesson. Both cases now assert their specific diagnostic appears inconfig.assertions, which is readable without forcing the toplevel. Verified non-vacuous by sabotaging the needle:The selector's throw stated a false premise. It said inventory admits a value archetypes cannot map. Inventory's enum and this table hold the same two values, so no machine datum can reach it — it guards the override paths (
runtimeargument,profileData/profile-settings atmachineConfigurations, a future inventory growing a third runtime). Message and comment corrected, and the key list now derives from the table so it cannot drift.Also removed two assertions whose conditions could never be false —
forcedreturnstrueor throws, so those messages could never print and only advertised a failure mode that does not exist — and stopped naming inventory's check attribute without a diagnostic if a redirected inventory lacks it.What survived the attack
Re-measured independently, on clean trees, with
--no-eval-cache: all machine derivations byte-identical, includinginstaller, which I had not checked. The marker is genuinely independent of the selector's input. Both sabotage directions still produce their named diagnostics. The hypervisor guard is real rather than vacuous — the predicate istruefor both guests andfalsefor nexus, so it can actually fire. The call-site shadowing path (profileDatamerged over builder args) is defended: it flips the marker and the check catches it. The volume placeholder cannot escape into a real machine's configuration.One correction to my earlier claim, from the reviewer:
vm-facts-coherence's derivation did change. The sole delta is the inventory source store path baked into its build script, a direct consequence of the input bump; the generatedvm-facts.jsonis byte-identical. Benign, but it will rebuild, and "nothing changed" was too broad — the accurate statement is that no machine's system derivation changed.Still open
The volume declarations question from the previous comment is unanswered, and it is the one thing here I will not decide alone.