Repository navigation
nvx-rs: capture templates into their store, and publish them - #887
Open
Enrique Saurez (esaurez) wants to merge 4 commits into
Open
Enrique Saurez (esaurez) wants to merge 4 commits into
Enrique Saurez (esaurez) wants to merge 4 commits into
Conversation
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
Index recovery, eligibility timing for queued creations, and store-parent durability remain unresolved.
3 open issues
What changed in this PR
Adds template capture and publication to NVX’s Rust host library, building on template planning and storage to support future clone restores.
Changes:
- Adds create, refresh, and recovery APIs with supervised capture and two-stage publication.
- Adds platform-specific file checks, sparse-file support, and non-replacing renames.
- Extends fake OpenVMM and tests for capture failures, recovery, concurrency, and file safety.
| File | Description |
|---|---|
nvx-rs/tests/template_capture.rs |
Adds end-to-end capture and recovery tests with fake OpenVMM. |
nvx-rs/src/testing/fake_openvmm.rs |
Emulates capture launches, snapshots, and failure scenarios. |
nvx-rs/src/host/template/store/tests.rs |
Tests Windows snapshot directory permissions. |
nvx-rs/src/host/template/store/capture/tests.rs |
Tests publication, recovery, serialization, and file checks. |
nvx-rs/src/host/template/store/capture.rs |
Implements capture, publication, and recovery APIs. |
nvx-rs/src/host/template/store.rs |
Exports capture APIs and adjusts snapshot directory validation. |
nvx-rs/src/host/template/mod.rs |
Re-exports capture types and documents their role. |
nvx-rs/src/host/sys/mod.rs |
Registers the Linux filesystem helper. |
nvx-rs/src/host/sys/fs.rs |
Implements Linux non-replacing rename. |
nvx-rs/src/host/supervisor/mod.rs |
Documents capture slots in the lock order. |
nvx-rs/src/host/store/files.rs |
Makes private file creation available within the crate. |
nvx-rs/src/host/platform/windows.rs |
Adds snapshot permission checks, sparse files, and safe renames. |
nvx-rs/src/host/platform/other.rs |
Adds unsupported-platform implementations. |
nvx-rs/src/host/platform/linux.rs |
Adds capture file checks and filesystem helpers. |
nvx-rs/README.md |
Documents capture APIs, behavior, and testing. |
Review effort: Balanced
Enrique Saurez (esaurez)
force-pushed
the
esaurez/841-pr2-capture-publication-20261024
branch
from
October 25, 2026 01:23
327019d to
130073b
Compare
Enrique Saurez (esaurez)
force-pushed
the
esaurez/841-pr2-capture-publication-20261024
branch
from
October 25, 2026 01:46
130073b to
b431a4f
Compare
Store::create_template serves a capture class with a generation that the store holds and that is eligible at the host's time, which it reads once it holds the class's slot, or captures one, and Store::refresh_template always captures; Store::recover_captures ends the captures that a crash interrupted. A capture runs in its class's slot, a sandbox store under captures/ whose lock serializes the class's captures. In a private transaction there, it writes the capture's record, creates the dormant scratch, lowers the adapter's plan into a TemplatePlan with a fresh storage generation, and launches the template's VM through supervisor::launch, as a launch of its own. OpenVMM opens snapshot-capable block devices itself, by path, without following links and, on Windows, sharing only reads and deletion, so the capture hands it paths: it checks the transaction, the placeholder through its binding and at its path, and the scratch at its path, and holds no handle that could write either file as the VM starts. Once the VMM has exited, the capture checks the outcome report, the snapshot's manifest and artifacts, and the key, then publishes the transaction in two commits: a rename into generations/ that replaces nothing, then the index, whose commit also syncs the store's own entry in its parent. Each capture, and each recovery, first drops the generations whose payloads a crash lost, then indexes a payload whose publication a crash interrupted, once it has made the rename durable; a creation makes the store durable before it serves a generation that the store holds. Older generations and their leases stay untouched. Store::snapshot_dir accepts the snapshot directories that OpenVMM creates, whose own access control lists on Windows pass no entries on to the files created in them, while every directory that the host creates keeps its check. The supervisor's lock order gains a capture slot's lock, which comes before the template store's. The fake OpenVMM runs the VMs of captures as a scenario asks, and records whether a handle could write their block devices; the template-capture tests run captures through the supervisor against it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 32eea669-a52c-4a53-87fa-521c498537e5
Enrique Saurez (esaurez)
force-pushed
the
esaurez/841-pr2-capture-publication-20261024
branch
from
October 25, 2026 01:55
b431a4f to
aebe922
Compare
OpenVMM requires the control socket of a VM that captures a snapshot, on Linux, in the directory of the snapshot's destination. It refused every capture, whose VM listened in the slot of its class while the destination is in the capture's transaction. The capture now launches its VM from a sandbox store in the transaction itself, whose journal records the VMM until the VM has ended. The host then moves the VMM's log and outcome report to the slot, which keeps those of the class's latest capture, closes the transaction's lock, and removes its file before it checks and publishes the transaction. Recovery ends a launch that a transaction records before it publishes or retires the transaction. The supervisor's lock order now names the transaction's lock, which a capture or its recovery takes under its slot's lock, and releases before it takes the store's lock to publish. The fake OpenVMM refuses a capture whose control socket is elsewhere, as OpenVMM does. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 32eea669-a52c-4a53-87fa-521c498537e5
OpenVMM refuses, on Unix, a control console unless its socket's directory is a directory, not a link, of its user with exactly the mode 0700, and, if the VM restores or captures a snapshot, the canonical directory that holds the snapshot. The fake OpenVMM checked the second rule for captures alone. It now checks both rules, with OpenVMM's messages, for every control console that it serves. As in OpenVMM, a snapshot that the VM restores takes precedence over a capture's destination, so a restore that the fake accepts must listen beside its snapshot too. The fake also refuses a control console that names no endpoint after listen=. The tests of captures' command lines use private directories. New tests refuse other modes, a link, a missing directory, and an empty listener, and check the rule for a restored snapshot. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 32eea669-a52c-4a53-87fa-521c498537e5
Enrique Saurez (esaurez)
pushed a commit
that referenced
this pull request
Oct 25, 2026
Merges the template mode of the runtime adapter's guest (#840 PR 2, on #822's chain and #877) into the capture and publication of templates (#887, on #839's stack). The adapter host's launch keeps both sides: it reserves its launch with a LaunchId, as the supervisor's launch now requires, and its OpenVMM inherits the files that the prepared launch checked. The command's closure is passed where the launch takes it, since the inherited handles borrow the prepared launch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 32eea669-a52c-4a53-87fa-521c498537e5
A capture checks, just before its VM starts, that the placeholder at its path is the file that its binding's check opened, and that the scratch at its path is the file that the capture created. It compared the identities that platform::opened_file_identity returns, which on Windows are the volume serial number and the 64-bit file index of BY_HANDLE_FILE_INFORMATION. That index is not guaranteed unique on ReFS, which Dev Drives use, so another file at either path could pass as the one that the capture checked. Compare the identities that the files' seals record instead: the device or volume and the file ID, which on Windows is the 128-bit ID of FILE_ID_INFO, unique on ReFS too. Linux keeps st_dev and st_ino, and other hosts still cannot identify files. A test renames another file of the scratch's length over the scratch before the VM starts, which the capture refuses, and checks that the scratch's check compares the whole of a 128-bit file ID. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 32eea669-a52c-4a53-87fa-521c498537e5
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Stacked on #884; this PR retargets when its parent merges.
Chain: #871 → #879 → #881 → #885 → #886 → #884 → this PR.
Part of #841 (PR 2 of 3: capture and publication).
Updates, as fast-forward commits on aebe922:
--snapshot-destination, and refuses to start otherwise; the captures of nvx-rs: lower, capture and publish platform templates #841 PR 3, which run VMs through the runtime adapter, found that on Linux. The slot keeps the VMM's log and outcome report of its class's latest capture, the fake OpenVMM now enforces the rule, and the tests check it.--restore-snapshotfirst, else of--snapshot-destination.FileSeal's device and file ID, as clones do in nvx-rs: plan the clones of templates, and check their block geometry #888. On Windows that is the volume and the 128-bit file ID ofFILE_ID_INFO, where it was the volume serial number and the 64-bit file index ofBY_HANDLE_FILE_INFORMATION, which Microsoft doesn't guarantee unique on ReFS, the file system of Dev Drives: there, another file at the placeholder's or the scratch's path could have passed as the one that the capture checked. Linux keepsst_devandst_ino.What and why
createandrefreshcapture a tenant-free template through the supervisor, and publish it into the template store as a new generation.Store::create_template(&Capture, now: impl Fn() -> Result<HostTime>, deadline) -> Result<Created>serves the capture class with a generation that the store holds,Created::Existing: the latest that the index lists of the class, of the key of the class's latest generation, that no tombstone quarantines, and that is eligible at the host's time, asStore::selectchooses among a key's generations byGeneration::eligibility. Without one, it captures one,Created::Captured. It callsnowonce, after it holds the class's slot and has recovered it and the store's index, so a generation that a racing creation captured while it waited counts, and creations that race for a class make one generation between them. Callers passHostTime::now, or a closure that adds a margin.Store::refresh_template(&Capture, deadline) -> Result<Generation>always captures a new generation, which later selections of its key take first. Older generations and their leases are untouched.Store::recover_captures(deadline) -> Result<Vec<Generation>>ends the captures that a crash interrupted, as an adapter does once as it starts.The issue calls the first two
Store::createandStore::refresh; #881'sStore::create(dir)already creates the store itself, so they take a_templatesuffix.Captureholds a capture's inputs: itsCaptureClass, the adapter'sSandboxPlan, the placeholder'sDistroBinding, the distro policy revision that the caller accepts, and the runtime'sArtifacts.Serialization and the transaction
Each class has a slot,
captures/<the first 16 hex digits of its ClassIdentity>/: a sandbox store whose lock serializes the class's captures, and which keeps the VMM's log and outcome report of the class's latest capture. A capture works in a private transaction directory in its slot, on the store's volume, which is also the sandbox store of its VM, whose launch journal records the VMM: at the pinned OpenVMM, a VM that captures a snapshot must have its control socket, on Linux, in the directory that holds its--snapshot-destination, and a sandbox store's VM listens in the store's directory. The capture holds the transaction's lock, under its slot's, until the VM has ended, and releases it before it publishes. The capture writescapture.json(the store, the class, and the storage generation), creates the dormant scratch there as a sparse file, lowers the adapter's plan into aTemplatePlan(#884) with a freshStorageGeneration, and cold-launches the template's VM throughsupervisor::launchfrom the transaction, as a launch of its own (LaunchId::generate, since a transaction holds no lease). It opens no application session and waits for no Ready: the guest captures itself, and OpenVMM publishes the snapshot into the transaction and exits. A template's VM never becomes a sandbox: its launch is never committed. Once the VM has ended, the capture moves the VMM's log and outcome report to the slot.Launching a capture: block devices by path
At the pinned OpenVMM, OpenVMM opens snapshot-capable block devices itself, by path, whenever
--snapshot-destinationis given: without following a link (O_NOFOLLOW), and on Windows without following a reparse point (FILE_FLAG_OPEN_REPARSE_POINT), sharing only reads and deletion. APreparedLaunch's/proc/self/fd/Npaths and inherited scratch claim therefore don't serve a capture, so, as decided for #841, the capture hands OpenVMM paths, not handles:DistroBinding(identity and length), and then at its path: a regular file, not a link or other reparse point, the file that the binding's check found, owned by the user or an administrator, and that no one else may change; and the scratch at its path, the same way: the file that the capture created, of the template's length.FileSeals record: the device or volume, and the file ID, on Windows the 128-bit one ofFILE_ID_INFO, which ReFS keeps unique where the 64-bit file index is not.TemplatePlan::newrequires, so the file that the host checks is the one that OpenVMM opens from the transaction, its working directory.Store's documentation states the rule, under "Block devices by path", so that a restore of a clone (nvx-rs: restore clones from a template through Activate, Ready and Start #844) takes the same one.supervisor::launchruns and supervises the process, and the devices and the NIC are rendered bySandboxPlan::openvmm_arguments, throughTemplatePlan::openvmm_arguments, which is what aPreparedLaunchrenders besides its handle substitution. The capture therefore builds noPreparedLaunch.Checks after the source exits, before publication
capture, the categorysuccess, and the status 0.TemplateManifest::readreads, whose artifacts are exactlymanifest.bin,memory.bin, andstate.bin, private regular files of the lengths that the manifest records, checked without reading their content, and whose key,TemplateKey::new, is its class's. A snapshot that is missing, or that lacks its manifest, fails withErrorKind::Io, as a lost capture, notNotFound.Publication in two commits
key.bin, durably, which marks the transaction as checked.capture.jsonandkey.binbeside it, togenerations/<the next store generation>without replacing anything (renameat2withRENAME_NOREPLACEon Linux,MoveFileExWwithoutMOVEFILE_REPLACE_EXISTINGon Windows), makes the rename durable, and commits the index with the full key, syncing the store's directory and then its parent, sinceStore::createsyncs neither.Only indexed generations can be acquired, and a committed payload is never rewritten. A capture that fails before its transaction is checked tears its VM down and retires the transaction once the VMM has ended; a transaction whose launch may still run stays. Recovery, by the next capture of the class or by
recover_captures, ends the launch that the transaction's sandbox store records, asLockedStore::recoverdoes, then publishes a checked transaction and retires any other, once it has moved its VMM's log and outcome report to the slot; every capture, creation or refresh, also drops the generations whose payloads a crash lost, which then take none of the store's capacity, and completes the store's interrupted publications, before it looks for a generation or counts the store's against its capacity. A creation that serves a generation that the store holds syncs the store's directory and its parent first, since the commit that listed it may have been interrupted. A payload at the index's next store generation is indexed first, once the recovery has made its rename durable as the publication would have (it syncs the payload,generations/, and the slot that the payload left), and a generation whose payload a crash lost is dropped from the index. A checked transaction is synced again before recovery publishes it, since the crash may have come before the capture synced it.recover_capturesreturns every generation that it published or whose publication it completed.Lock order
A capture holds its slot's lock until its VM has ended, and takes the store's lock under it to publish. It also holds its transaction's lock, the lifecycle lock of the VM's sandbox store, until its VM has ended, and releases it before it publishes; so does a recovery, which ends the transaction's launch. The supervisor's lock order (#886) now starts with a capture slot's lock: the slot's, then the template store's, then a sandbox's or a capture transaction's, then the scratch claim. Nothing takes a slot's lock while it holds another.
Fix to #881:
Store::snapshot_dirand the snapshot directories that OpenVMM createsOn Windows, OpenVMM creates the snapshot directory and each artifact with a protected access control list of their own,
D:P(A;;FA;;;SY)(A;;FA;;;OW), with no entries that files inherit, andsnapshot_dirrefused that directory, sincecheck_owner_only_dirrequires such entries. The crate-privateplatform::check_snapshot_dir(path: &Path) -> io::Result<Option<String>>keeps every other rule (a plain directory, not a link or reparse point; a trusted owner; a list that is present, with only allow and deny entries, for trusted principals or the owner's aliases), and drops the inheritance rule alone: OpenVMM gives each artifact its own list, and the directory's list already lets no one else create a file in it. On Linux and other hosts it ischeck_owner_only_dir. Only the snapshot directory's check changes: the directories of the generations and of the payloads, and every directory that the host creates, keepcheck_owner_only_dir.Other changes
CAPTURES_NAME(captures),TRANSACTION_NAME(transaction), andCAPTURE_RECORD_NAME(capture.json).platform::foreign_writer_problem,make_sparse, andrename_no_replace, crate-private, on Linux, Windows, and other hosts.fake-openvmm-capture.jsonnext to the kernel asks. On Unix, it checks where every control console listens, as OpenVMM does: in a directory, not a link, of the user with exactly the mode 0700, which holds the snapshot that the VM restores or captures, if any.Store's layout and "Captures and publication", the template module, the supervisor's lock order, andnvx-rs/README.md.Tests
With OpenVMM emulated in the test process, through hooks that fail or crash a capture at each point (
src/host/template/store/capture/tests.rs):Io), after the check (the transaction outlives the failure until recovery publishes it), and after the rename (the publication completes); crashes, which recovery ends; payloads lost in a crash are dropped from the index;captures/that are not slots; stores too deep for a capture's control socket are refused (Linux).snapshot_diraccepts a snapshot directory with OpenVMM's exact descriptor, and still refuses a payload directory without inheritable entries (Windows).Through
supervisor::launch, withnvx-fake-openvmm(tests/template_capture.rs):The fake OpenVMM's own tests (
src/testing/fake_openvmm.rs), on Unix:listen=;Validation
No VM is needed for this change: everything below ran without one, at this head.
nvx-rs, withNVX_BUNDLE=skip:cargo fmt --all --check;cargo clippy --locked --all-targets --all-features -- -D warnings;cargo clippy --locked --lib --no-default-features -- -D warnings;cargo clippy --locked --all-targets --no-default-features --features <feature> -- -D warningsforhost,cli,testing,serde, andbundled;cargo test --locked --all-features;cargo test --locked;cargo build --locked --lib --no-default-featuresandcargo test --locked --lib --no-default-features, also with--features host;RUSTDOCFLAGS="-D warnings" cargo doc --locked --no-deps --all-features; andcargo +1.89 check --locked --all-targets --all-features. Then the commands ofcheck-aci-edge-sandboxesforaci_edge_sandboxes, those ofcheck-nvx-rsfortests/runtime-adapter(protocol-only and host-only), andpython scripts/check_markdown_links.py.--features guest;cargo check --target aarch64-apple-darwinfornvx-rsandaci_edge_sandboxes; from the root, the ignoredcargo test --locked --manifest-path nvx-rs/Cargo.toml --no-default-features --test openvmm_vectors -- --ignored, which this PR does not change;python3 scripts/nvx.py check-guest-binaries; and the runtime adapter's static musl guest configuration.tests/template_capture.rspassed 5 times in a row on each host. On Linux, the fake detects writers through a read lease (F_SETLEASE): on the Linux host's file system, the lease was granted with no writer, and refused withEAGAINwhile a writer was open.capture.rs, whose VM listened in its slot, in place of 491de89's, all 6 tests oftests/template_capture.rsfail: the fake refuses each capture before it starts, as OpenVMM does.check_scratchthat compares only the low 64 bits of the file IDs, the new test of the scratch's identity fails.--cpu-profile host, since that development host cannot boot its CPU's built-in profile. This head changes only how the capture identifies the files that it checks before its VM starts, which the tests above exercise on both hosts.dev; a stacked PR runs none.Follow-up
Older identity checks share the weakness on ReFS. They predate this stack, and this PR leaves them as they are:
LogClaim), and whichlog_claim_heldchecks on Windows;They compare
platform::opened_file_identityandplatform::file_identity, which on Windows are the volume serial number and the 64-bit file index. A follow-up can move them toFileSealidentities; for the log's claim, that changes the journal's record.