Declare the persistent volumes a microvm archetype requires #26

Merged
vnprc merged 3 commits from agent/microvm-persistent-volumes into master 2026-07-31 11:57:38 +01:00
Member

Gives a microVM the persistent disks it needs to be usable. Since #23 the inventory runtime fact selects the guest module, but nothing declared volumes — so a machine choosing microvm could not evaluate at all, failing on allod/vm's store-volume contract. This closes that: /nix/var/nix for every microVM guest, plus the dev user's home for dev machines.

It declares disks. It does not create, format, label or own an image — contract 13 keeps that an explicit deployer action, and allod/nexus's launcher already refuses a missing or unreadable image before QEMU starts.

Closes #25
Refs allod/strategy#20

Risk

R2, conditional on one gate: this slice validates no runtime behaviour. Nothing here proves a guest boots, mounts, or retains a byte. microvm-test must not be enabled or booted as a microVM on the strength of this PR — the store-lifecycle boots in a later slice are what license that, and the parent plan already blocks on them. Without that gate the honest score is R3.

Every libvirt machine is untouched: declarations are gated on runtime == "microvm" at the Nix level, so a libvirt machine's module list is unchanged. Rollback is a straight revert; nothing creates or mutates an image, so a revert cannot strand state.

Validation

All four system derivations byte-identical to master, verified on clean trees:

allod-dev  1lhcxfhy9iddfssp3df3r2zwkwnd3ilm
privacy-1  zzzaralwv032jdmbqc1q6ms9fm15w7yf
nexus      fad4pq24f8iavzr3q369alz8932jq9q3
installer  2yj5038jnsgzbv6ghhmyafs5kspn9jrg

Review also diffed the full check set: all nine pre-existing checks unchanged, only runtime-module-selection moves. nix flake check green, 10/10, reporting 19 negative cases.

neededForBoot renders to x-initrd.mount, measured both ways — with it, /dev/disk/by-label/home /home/allod ext4 x-initrd.mount; without, defaults. Upstream auto-adds it only for the store-overlay mount, so the home volume sets it explicitly and an assertion now pins it against a profile forcing it off.

Review history

Plan reviewed by gpt-5.6-sol at high effort, then the diff reviewed adversarially. Both found real defects.

The plan review refuted my central safety claim. An earlier draft said omitting neededForBoot causes silent data loss — a machine that comes up healthy and discards work. That is wrong: the home entry renders defaults with no nofail, so a corrupt image fails local-fs.target loudly into emergency. The real reason neededForBoot is required is contract 13's own — it moves validation into the initrd, before any writer can use the tmpfs directory underneath. Narrower defect, still worth closing, but the test exists to close a write window, not to prevent silent loss. The plan records the correction rather than quietly rewriting it.

The diff review found the blocker: five of six sabotage fixtures for the image-root rules could not distinguish the assertion they named from a neighbour, because their needles were unanchored and the per-image validator produced the same substrings. Proven by deleting the assertion and watching five stay green. Now anchored to the option name; re-verified independently by neutering the assertion, at which point all six fire.

Seven smaller fixes rode along, including a real edge case: a volume declared with mountPoint = null — which upstream supports and nexus fixtures use — collapsed to "" and tripped a bogus duplicate-mount-point error.

Residual risk worth a human eye

A privacy microVM gets no home volume, so that user's home lives on the tmpfs root and is discarded on reboot with no diagnostic. That follows contract 13, which scopes home to the dev archetype, but it is a real behavioural consequence nobody has written down as deliberate. Worth a decision before any privacy machine moves to this runtime.

The check now requires a privacy machine in the inventory, where before it needed only a dev machine. The throw is named and actionable and symmetric with the existing dev-machine throw, but it is a new input requirement on a redirected inventory.

nix flake check peak memory rose from ~4.0 GB to ~5.9 GB on a 7 GiB box with no swap. allod/memory nix.md already records configurations being OOM-killed near 7 GiB, so the next slice inherits a tighter budget. Sabotage fixtures are consumed once as thunks specifically to bound this; holding all sixteen live was measured as an OOM kill.

One coverage regression, flagged in a comment where it happens. Every microVM fixture now carries volume declarations by design, so a selector mis-mapping microvm to the qemu guest surfaces as The option `microvm' does not exist during option-tree construction rather than as a marker mismatch. Loud, not silent; the libvirt direction stays pinned over every real machine.

Plan: allod/strategy dev-plans/microvm-persistent-volumes.md.

Gives a microVM the persistent disks it needs to be usable. Since #23 the inventory `runtime` fact selects the guest module, but nothing declared volumes — so a machine choosing `microvm` could not evaluate at all, failing on allod/vm's store-volume contract. This closes that: `/nix/var/nix` for every microVM guest, plus the dev user's home for dev machines. It declares disks. It does not create, format, label or own an image — contract 13 keeps that an explicit deployer action, and allod/nexus's launcher already refuses a missing or unreadable image before QEMU starts. Closes #25 Refs allod/strategy#20 ## Risk R2, conditional on one gate: **this slice validates no runtime behaviour.** Nothing here proves a guest boots, mounts, or retains a byte. `microvm-test` must not be enabled or booted as a microVM on the strength of this PR — the store-lifecycle boots in a later slice are what license that, and the parent plan already blocks on them. Without that gate the honest score is R3. Every libvirt machine is untouched: declarations are gated on `runtime == "microvm"` at the Nix level, so a libvirt machine's module list is unchanged. Rollback is a straight revert; nothing creates or mutates an image, so a revert cannot strand state. ## Validation All four system derivations byte-identical to master, verified on clean trees: ``` allod-dev 1lhcxfhy9iddfssp3df3r2zwkwnd3ilm privacy-1 zzzaralwv032jdmbqc1q6ms9fm15w7yf nexus fad4pq24f8iavzr3q369alz8932jq9q3 installer 2yj5038jnsgzbv6ghhmyafs5kspn9jrg ``` Review also diffed the full check set: all nine pre-existing checks unchanged, only `runtime-module-selection` moves. `nix flake check` green, 10/10, reporting 19 negative cases. `neededForBoot` renders to `x-initrd.mount`, measured both ways — with it, `/dev/disk/by-label/home /home/allod ext4 x-initrd.mount`; without, `defaults`. Upstream auto-adds it only for the store-overlay mount, so the home volume sets it explicitly and an assertion now pins it against a profile forcing it off. ## Review history Plan reviewed by `gpt-5.6-sol` at `high` effort, then the diff reviewed adversarially. Both found real defects. The plan review refuted my central safety claim. An earlier draft said omitting `neededForBoot` causes silent data loss — a machine that comes up healthy and discards work. That is wrong: the home entry renders `defaults` with no `nofail`, so a corrupt image fails `local-fs.target` loudly into emergency. The real reason `neededForBoot` is required is contract 13's own — it moves validation into the initrd, before any writer can use the tmpfs directory underneath. Narrower defect, still worth closing, but the test exists to close a write window, not to prevent silent loss. The plan records the correction rather than quietly rewriting it. The diff review found the blocker: five of six sabotage fixtures for the image-root rules could not distinguish the assertion they named from a neighbour, because their needles were unanchored and the per-image validator produced the same substrings. Proven by deleting the assertion and watching five stay green. Now anchored to the option name; re-verified independently by neutering the assertion, at which point all six fire. Seven smaller fixes rode along, including a real edge case: a volume declared with `mountPoint = null` — which upstream supports and nexus fixtures use — collapsed to `""` and tripped a bogus duplicate-mount-point error. ## Residual risk worth a human eye **A privacy microVM gets no home volume**, so that user's home lives on the tmpfs root and is discarded on reboot with no diagnostic. That follows contract 13, which scopes home to the dev archetype, but it is a real behavioural consequence nobody has written down as deliberate. Worth a decision before any privacy machine moves to this runtime. **The check now requires a privacy machine in the inventory**, where before it needed only a dev machine. The throw is named and actionable and symmetric with the existing dev-machine throw, but it is a new input requirement on a redirected inventory. **`nix flake check` peak memory rose from ~4.0 GB to ~5.9 GB** on a 7 GiB box with no swap. `allod/memory` `nix.md` already records configurations being OOM-killed near 7 GiB, so the next slice inherits a tighter budget. Sabotage fixtures are consumed once as thunks specifically to bound this; holding all sixteen live was measured as an OOM kill. **One coverage regression, flagged in a comment where it happens.** Every microVM fixture now carries volume declarations by design, so a selector mis-mapping `microvm` to the qemu guest surfaces as ``The option `microvm' does not exist`` during option-tree construction rather than as a marker mismatch. Loud, not silent; the libvirt direction stays pinned over every real machine. Plan: `allod/strategy` `dev-plans/microvm-persistent-volumes.md`.
A machine that selects the microvm runtime now declares the disks it
needs to keep state, so it evaluates instead of failing on allod/vm's
store-volume contract.

Every microvm guest gets a /nix/var/nix volume - allod/vm imports
microvm-store.nix into every microvmGuest with no archetype guard, so
that half is not dev-only. A dev guest also gets one for the user's
home, with neededForBoot stated because upstream adds it only for the
mount equal to microvm.writableStoreOverlay. Both entries state every
load-bearing field rather than inheriting an upstream default, and both
are labelled, because unlabelled volumes mount by drive letter and a
later reorder could silently remap two valid ext4 images onto each
other's mount points.

Image paths derive from a validated, microvm-only NixOS option defaulting
to /var/lib/allod-microvm-volumes, and the same validator runs over every
merged entry's image: no Nix path, no relative or non-normalized path, no
trailing slash, nothing under the store or /run/allod. Every assertion
reads the merged config.microvm.volumes, so a profile that overrides an
entry is caught rather than trusted.

Declarations are gated on the builders' module lists, not with lib.mkIf,
which defers a value but still produces the definition and would be an
unmatched-option error on every libvirt machine. Every libvirt machine's
derivation is unchanged: allod-dev, privacy-1, nexus and installer all
keep the drvPath they had before this commit.

runtime-module-selection loses the placeholder volume fixture it needed
to evaluate a microvm guest at all, and gains fifteen sabotage fixtures,
each pinned to the diagnostic it names, plus a generated-fstab assertion
that both volumes are x-initrd.mount and a paired sabotage that drops
neededForBoot from the home entry. Fixtures are consumed one at a time
because holding them all live is an OOM kill on an 8 GB builder.

Refs allod/strategy#20
Adversarial review of the diff found one blocker and eight smaller
defects.

Five of the six image-root sabotages did not defend the assertion they
named. The module runs one path validator over the root and again over
every image derived from it, so an unanchored needle like 'must be an
absolute path' was satisfied by the derived-image message alone: with
the root's own assertion deleted, only the Nix-path case went red. Every
needle is now anchored to the option name the message starts with, and
deleting that assertion block turns all six red.

A null mountPoint collapsed to the empty string, so two unmounted images
- a legal declaration upstream, which skips them when folding volumes
into fileSystems - read as two entries sharing a mount point. That false
positive would have preempted allod/nexus's own named diagnostic for the
shape. Mount-point comparisons now run over mounted volumes only, the
image diagnostic falls back to naming the image when there is no mount
point, and a fixture adding two unmounted images must evaluate with no
diagnostic at all.

neededForBoot was the only load-bearing field with no merged-result
assertion, so a profile could force it false and silently reopen the
write window this slice exists to close, with no reporter anywhere -
allod/vm asserts it for /nix/var/nix and nothing else. Added, and the
fixture that already sabotaged it for the fstab assertion now pins the
new diagnostic too.

The moved-image-root fixture now forces its toplevel, so a non-default
root is proved to yield an evaluable system rather than only a
projection that reads correctly. size joins the volume projection and
its expectation: the one field with a comment defending its value was
the one nothing would have noticed changing.

Also corrected three stale or wrong comments - the negative-case count,
which fixtures force a toplevel, and the reason the account-moved
fixture forces home-manager's copy of the home path - and dropped a
redundant gnugrep nativeBuildInputs that stdenv already provides.

Refs allod/strategy#20
The unmounted-volume guard proved only that such a declaration is not
rejected. A change that silently dropped null-mountPoint entries from
the merged list would have left it green while the declarations vanished
before allod/nexus could report them, and nexus at host composition is
the only thing that reports an unmounted image.

Refs allod/strategy#20
Author
Member

Scoped stability pass done on 5b8ea7f, rotated to a different model than the one that authored both the implementation and the fixes. All eight fixes verified sound and falsifiable where they claim falsifiability; both deliberate skips judged correct; no regression in the recursion workaround, size = 0 semantics, the either str path typing, the privacy-builder placement, or the four libvirt derivations.

It confirmed the blocker fix independently: neutering the image-root assertion now turns up exactly six findings, one per fixture, each naming its own anchored needle. Before the fix that experiment produced one. It also verified the reworded comments are now true at source, checking the upstream microvm options, allod/vm's store module, and nexus's host composition rather than taking them on trust.

One LOW finding, now closed in 3a3b430. The unmounted-volume guard proved only that such a declaration is not rejected, never that it survives into the merged list — so a change silently dropping null-mountPoint entries would have left it green while the declarations vanished before allod/nexus could report them, and nexus at host composition is the only thing that reports an unmounted image. Verified falsifiable: simulating that drop names both images.

Final state — nix flake check exit 0, all 10 checks, 5.71 GB peak measured with time -v. All four derivations still byte-identical.

Worth recording for whoever picks up the next slice: that peak sits on a 7 GiB box with no swap, and allod/memory nix.md already notes configurations being OOM-killed near 7 GiB. Headroom was measured at ~840 MiB during a run. The next slice inherits that budget, and sabotage fixtures are already consumed one at a time specifically to stay inside it.

Scoped stability pass done on `5b8ea7f`, rotated to a different model than the one that authored both the implementation and the fixes. All eight fixes verified sound and falsifiable where they claim falsifiability; both deliberate skips judged correct; no regression in the recursion workaround, `size = 0` semantics, the `either str path` typing, the privacy-builder placement, or the four libvirt derivations. It confirmed the blocker fix independently: neutering the image-root assertion now turns up **exactly six** findings, one per fixture, each naming its own anchored needle. Before the fix that experiment produced one. It also verified the reworded comments are now true at source, checking the upstream microvm options, allod/vm's store module, and nexus's host composition rather than taking them on trust. One LOW finding, now closed in `3a3b430`. The unmounted-volume guard proved only that such a declaration is *not rejected*, never that it *survives* into the merged list — so a change silently dropping null-mountPoint entries would have left it green while the declarations vanished before allod/nexus could report them, and nexus at host composition is the only thing that reports an unmounted image. Verified falsifiable: simulating that drop names both images. Final state — `nix flake check` exit 0, all 10 checks, 5.71 GB peak measured with `time -v`. All four derivations still byte-identical. Worth recording for whoever picks up the next slice: that peak sits on a 7 GiB box with no swap, and `allod/memory` `nix.md` already notes configurations being OOM-killed near 7 GiB. Headroom was measured at ~840 MiB during a run. The next slice inherits that budget, and sabotage fixtures are already consumed one at a time specifically to stay inside it.
vnprc approved these changes 2026-07-31 11:57:31 +01:00
vnprc merged commit 3a3b430ec2 into master 2026-07-31 11:57:38 +01:00
vnprc deleted branch agent/microvm-persistent-volumes 2026-07-31 11:57:39 +01:00
Sign in to join this conversation.
No description provided.