Repository navigation
nvx-rs: define the activation record of a layered root and its limits - #871
Open
Enrique Saurez (esaurez) wants to merge 13 commits into
Conversation
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
Workload identity validation accepts u32::MAX, which the existing guest entry rejects.
1 open issue
What changed in this PR
Introduces feature-independent nvx::activation types and limits for layered-root configuration, the first step of #838. Encoding, identity calculation, transport, and application remain outside this PR.
Changes:
- Defines activation records, restore references, configuration settings, and Ready metadata.
- Adds validation and tests for limits, invalid combinations, and opaque identifiers.
- Documents the module in the crate and repository guides.
| File | Description |
|---|---|
nvx-rs/src/lib.rs |
Exposes and documents the activation module without feature gates. |
nvx-rs/src/activation/tests.rs |
Tests boundaries, validation errors, record variants, and identifiers. |
nvx-rs/src/activation/mod.rs |
Defines activation types, public limits, and validation. |
nvx-rs/README.md |
Documents the activation API and feature-independent availability. |
doc/project-structure.md |
Adds activation to the repository layout. |
Review effort: Balanced
This was referenced Oct 24, 2026
The runtime adapter's layered launches had no network. `launch --network FILE` now reads a network in the serde form of a plan's network, which the plan checks as it reads it, and puts it on the adapter's plan, which renders it through NVX's lowering of a policy, after its block devices. The launch's record keeps it in the plan. A launch reads its network before anything else, so it refuses one that the plan refuses before it needs NVX's artifacts or creates the store. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a7c15634-094a-4a66-af59-25a58e732f4b
The root configuration gains an optional listeners field, after the workload identity, which says whether the guest's listeners are published. Executions, and the account check of a workload identity, enter the root with that publication, so that while listeners are published the entry refuses CAP_NET_BIND_SERVICE and CAP_NET_RAW at stage 0. A layered launch names the publication of its network as OpenVMM publishes listeners: with ingress or host-loopback access allowed, and none without a network. The host gives an execution's start the URL of a proxy-only network's proxy in the proxy variables of its environment, with layered::with_proxy, from the plan that LaunchRecord::read_plan reads back from the store. The network device, its addresses, and the announcer of the guest's listeners stay with NVX's /init. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a7c15634-094a-4a66-af59-25a58e732f4b
The host program's exec reads the plan that the launch recorded, and gives each execution the URL of the proxy of the VM's network, if any, in its environment with layered::with_proxy, after its --env entries, unless they name the proxy variables themselves. The VM scenarios can now give their VM a network, which the launch reads from a file that the scenario writes. In a proxy-only network, the executions find the proxy's URL in their environment, and root may hold CAP_NET_BIND_SERVICE and CAP_NET_RAW. A network with ingress allowed publishes the guest's listeners, so the guest refuses both at stage 0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a7c15634-094a-4a66-af59-25a58e732f4b
The proxy's variables lengthen an execution's start, so a start that the framing carried when exec parsed its command line may no longer fit once exec gives it the proxy of the VM's network. exec found out only as it sent that start, after it had sent the starts before it, and then failed without collecting their outcomes. exec now gives every start the proxy's variables, and checks each as it checked them without the proxy, before it attaches; it runs none of the executions if one no longer fits. A start takes its key once exec has attached, and a key's encoding has the same size whatever its value. On Linux, whose command lines carry a start as long as the framing allows, the proxy-only VM scenario checks that exec refuses such a batch before it attaches, that none of its executions ran, and that the VM still runs the next command's executions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a7c15634-094a-4a66-af59-25a58e732f4b
The layered scenarios check what each kind of network lets the guest's executions reach, as NVX's proxy-only scenario probes its network: a network that denies everything, which attaches no network device; an allow rule; a proxy-only network, whose other destinations never arrive; outbound host-loopback access, which publishes no listener of the guest; and host-loopback access in both directions, through which the host reaches a listener of the guest, at its port or through a forward. Before the launch, the test binds the host's side of the network, whose ports the network names, each drawn at random from the dynamic range: a test proxy on host loopback, and the sockets of the probes' destinations. A scenario whose network stops traffic first boots a VM of an open network over the same sockets, the positive control, in which every probe arrives and the host reaches the guest's listener. Each VM with a network has its network devices checked, and that OpenVMM asks /init to publish the guest's listeners exactly where the root configuration says that they are published. The scenarios' guest moves to 192.0.2.2/24, in a range reserved for documentation, so that no host's own address is on its network, and the proxy-only network's proxy is the test proxy, at a port of its own. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a7c15634-094a-4a66-af59-25a58e732f4b
The VM scenarios run at the same time. The host's side of a network draws the ports of a listener of the guest and of a forward, then releases them, since OpenVMM binds them only once it runs, and again for the scenario's VM after the positive control's. Another scenario could draw the same ports in between, and one of the two VMs would then fail to bind them. Each host's side now holds the ports that it drew, in a set that the scenarios share, until its scenario ends, and draws none that another holds. A scenario that ends confirmed that its VMs ended; one that fails keeps its ports for good, since one of its VMs might still run. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a7c15634-094a-4a66-af59-25a58e732f4b
OpenVMM names the guest's DNS server, one of the gateway's addresses, with a virtnet_dns= token when the network policy lets the guest reach the gateway's DNS service, and NVX's init writes the initramfs's /etc/resolv.conf from it. Nothing reads it for a layered root, whose executions see the image's own file (#860). cmdline::dns_server reads the token as OpenVMM renders and reads it, strictly: the command line split at ASCII whitespace, as OpenVMM splits it, rather than as the kernel does, at most one token, and one IPv4 or IPv6 address in the form that OpenVMM writes. cmdline::write_resolver writes the resolver file that names it, with the content that the init writes, so that a root's executions and the initramfs agree. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 28b0282d-1872-4f0c-bc51-7d2a72ebd2db
A layered root's executions are to see the guest's resolver file over the root's /etc/resolv.conf, as they see a generated machine ID over its /etc/machine-id (#860). A live share or a host mapping there would cover that file, or be covered by it, and only /etc itself and /etc/machine-id are reserved today. Add /etc/resolv.conf to RESERVED_MOUNT_TARGETS, which the host's planner and the guest's mapper both refuse, so that a share at it, or below it, is refused before launch, and in the guest. A neighbor such as /etc/resolvconf stays free. Test it on the host, in the guest's checks, and with the kernel's mounts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 28b0282d-1872-4f0c-bc51-7d2a72ebd2db
A layered root's executions see the image's own /etc/resolv.conf, which an image may ship empty, so they resolve no names even where their network policy allows DNS. And Entry::bind_runtime leaves its binds in the caller's mount namespace with no way to remove them, so a caller that tears the root down in that namespace fails, since a teardown detaches no busy mount (#860). Entry::resolver names a resolver file, such as the one that the guest's init wrote from the command line's DNS server, and bind_runtime binds it over the root's /etc/resolv.conf, after the machine ID and the time status, read-only, nosuid, and nodev, through the same walk and attach. As at /etc/machine-id, a symbolic link at /etc/resolv.conf itself, which no bind can cover, is replaced with an empty file first. Without a resolver file, the image's own stays. bind_runtime now returns the RuntimeBinds that it made, which RuntimeBinds::remove unmounts in reverse order, attempting each and reporting every one that stays, so that a caller removes them before the root's teardown, which then succeeds. If a bind fails, the call removes the binds that it made before it. A caller whose mount namespace ends before the teardown, as the managed agent's does, drops them as before. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 28b0282d-1872-4f0c-bc51-7d2a72ebd2db
When a runtime bind fails, Entry::bind_runtime removes the binds that it made before it, but it discarded the failures of that removal. A bind that stayed mounted, such as a busy one, then went unreported, and the caller, which never received the binds, learned of it only when the root's teardown failed. Report it instead: if the removal fails, the call returns an Error::Rollback that holds the bind's error and the TeardownError of the binds that stay, and whose source is the bind's error. The managed agent maps it to the errno of the bind that failed, as before. The call still attempts every removal, whatever failed before. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 28b0282d-1872-4f0c-bc51-7d2a72ebd2db
This was referenced Oct 27, 2026
A clone that the host restores from a template keeps the template's captured configuration and command line, so nothing tells its guest the image, upper, export, network settings, and workload view of its own launch, and nothing identifies the settings that a guest applied (#838). The host, the guest, and every adapter's framing need one record of them, which they all check by the same rules. nvx::activation defines it without features, beside nvx::protocol. An ActivationRecord holds the version of its schema, the restore section of a clone, which names the restore's transaction ID and the template that it restores, by its store generation and the identity of its key, and the Configuration that the activation applies: the image, with the identity of its binding and the UUID or disk GUID that the guest expects; the upper, a tmpfs or the scratch with its binding's identity and journal policy; the overlay mode; one export, a directory or an aggregate, with its plan's identity and its 1 to 4,096 mappings; the network, with its policy's identity, a DNS address, the listeners' publication, and a proxy endpoint; the workload view, its hostname, machine-ID mode, and optional workload identity; and the CPU count and memory size that the guest must have. The record names objects by 32-byte identities that the host computes, and never holds their content; the other inputs of the guest's root assembly are invariants of the template. ReadyActivation is what Ready reports: the configuration identity, and a clone's transaction ID. The limits are public constants: 1,024 bytes for a record, 4,096 mappings, 64 bytes for a hostname, and 255 for a proxy endpoint. ActivationRecord::new admits a configuration only once Configuration::check finds that it keeps them, pairs an export with its mappings, and holds a nonzero UID and GID, a hostname without a NUL, as the guest's entry requires, a proxy endpoint of visible ASCII without an @, and so without credentials, and nonzero sizes. The canonical encoding and the configuration identity follow. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8f7bd07b-b09f-48d5-bc49-ad06f47ef3f9
The kernel reads an ID of u32::MAX as "unchanged", so the guest's entry refuses it, and the managed agent ignores such a workload ID on its command line. Configuration::check refused only 0, so a record could hold a workload identity that no guest applies. It now refuses u32::MAX too, for the UID and the GID alike, and the tests take u32::MAX - 1 as the largest ID that a record holds. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8f7bd07b-b09f-48d5-bc49-ad06f47ef3f9
The template store publishes each template under a nonzero 64-bit ID, which it assigns in the order of publication and never reuses, and a clone's record names the template that it restores by that ID and the identity of the template's key (#838, #839). StoreGeneration held 16 opaque bytes, so the store would have had to fit its ID into bytes of another shape, by a convention of its own. StoreGeneration now wraps a NonZeroU64: StoreGeneration::new takes one, StoreGeneration::get returns it, two generations of one store compare in the order of their publication, and a generation displays as its decimal number. The identity of the template's key stays 32 bytes, and the transaction ID 16. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8f7bd07b-b09f-48d5-bc49-ad06f47ef3f9
Enrique Saurez (esaurez)
force-pushed
the
esaurez/838-pr1-activation-record-types-20261024
branch
from
October 27, 2026 01:11
dc4517f to
3032e6d
Compare
Enrique Saurez (esaurez)
changed the base branch from
dev
to
esaurez/860-pr1-resolver-runtime-binds-20261024
October 27, 2026 01:11
This was referenced Oct 27, 2026
Pedro Henrique Penna (ppenna)
force-pushed
the
esaurez/860-pr1-resolver-runtime-binds-20261024
branch
from
October 27, 2026 05:05
50f7fe1 to
f3e0317
Compare
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 #876; this PR retargets to dev when the pull requests below it merge. The chain on
devis #866 → #882 → #883 → #876 → #871 → #873 → #874, after #863, #864, #865, #867, #868, #869, #870, #872, #875, and #878, which have merged.A clone that the host restores from a template keeps the template's captured configuration, so nothing tells its guest the image, upper, export, network, and workload view of its own launch, and nothing identifies the settings that a guest applied. This adds
nvx::activation, the record of those settings and its limits, without features, besidenvx::protocol, so that the host, the guest, and every adapter's framing check it by the same rules. It changes no behavior: nothing builds, carries, or applies the record yet.Part of #838: the first of its three pull requests. The second, #873, encodes the record canonically, and the third, #874, adds the encoding of Ready's activation and the conformance vectors, and removes the configuration identity, so that Ready reports only that the activation applied, and a clone's transaction ID.
What changed
The record (
nvx-rs/src/activation/mod.rs). AnActivationRecordholds the schema'sVERSION, a clone'sRestoreSection, and theConfiguration:Image: EROFS or GPT, theIdentityof the image's binding, the EROFS UUID or GPT disk GUID that the guest expects, if any, and a GPT disk's first-partition bound;Upper: a tmpfs, or the scratch, with its binding'sIdentityand itsJournalpolicy;OverlayMode;Export, a directory or an aggregate, with its plan'sIdentity, which covers the export's whole shape, and its 1 to 4,096 mappings;Network: its policy'sIdentity, an optional DNS address, the listeners'Publication, in the vocabulary ofnvx::guest::entry, and an optional proxy endpoint;WorkloadView: the hostname, theMachineIdMode, the image's own machine ID or a generated one, and an optionalWorkloadIdentity, a UID and a GID, neither 0 noru32::MAX;ExpectedResources: the CPU count and the memory size that the guest must have.A clone's
RestoreSectionholds the restore's 16-byteTransactionIdand theTemplateReference: the template'sStoreGeneration, the nonzero publication ID that its store assigns in the order of publication and never reuses, and theIdentityof the template's key. AnIdentityis 32 bytes that the host computes, such as the SHA-256 digest of an object's canonical form; the record never holds the objects that identities name.ReadyActivationis what Ready reports: theConfigurationIdentity, which #874 removes so that nothing hashes the record, and a clone's transaction ID.The other inputs of the root's assembly are invariants of the template, as the module documents: the runtime directory, the block devices' MMIO windows and sizes, the GPT partitions that the guest expects, of which there are none, and how long the guest waits for its devices. Secrets, per-execution environments, and host mapping tables never enter the record.
The limits are public constants:
MAX_RECORD_BYTES(1,024),MAX_MAPPINGS(4,096,protocol::MAX_HOST_MAPPINGS),MAX_HOSTNAME_BYTES(64), andMAX_PROXY_ENDPOINT_BYTES(255).ActivationRecord::newadmits a configuration only onceConfiguration::checkfinds that it keeps them, and otherwise reports the first one broken, in the order of the fields. Besides each limit, an export without mappings, mappings without an export, and a zero UID or GID, it refuses:u32::MAX, which the kernel reads as an unchanged ID, and which the guest's entry and the managed agent refuse;@, which would start credentials;Documentation. The module documentation describes the configuration, the template's invariants, and the limits, with an example. The crate's README and
doc/project-structure.mdlist the module.Tests (
nvx-rs/src/activation/tests.rs): each limit at and past its edge; an export without mappings and mappings without an export, refused; a UID or GID of 0 oru32::MAX, refused, each alone; a configuration with every part at its limit, admitted; cold-boot and clone records; the order in which limits are reported; the errors' messages; the opaque values' bytes and hexadecimal forms; and store generations, nonzero numbers that compare in the order of publication and display in decimal.Validation
At
3032e6d, the head, rebased onto #876 without changes to the contents of its commits, on Windows and on Linux x86_64:cargo fmt --check;cargo clippywith--all-targets --all-features,--lib, and--lib --no-default-features,-D warnings;cargo testwith all, default, and no features;cargo docwith-D warnings;cargo +1.89 check;aci_edge_sandboxes' checks;bundledartifacts test, the macOS check of both crates, the guest binaries' lint, tests, build, and check, and the Markdown link check.At each of the three commits,
cargo clippywith--all-targets --all-features,--lib,--lib --no-default-features, and--lib --no-default-features --features guest,-D warnings, passed on both systems.Before the rebase, on
dev, the same checks passed at the first and the third commits, and at the heads of #873 and #874 that contained the second, which answers the review, and CI passed at the third, in run 40121956945. At the first commit, one test outside this change failed once on Linux, in the all-features run on a busy host:guest::cgroup::tests::settlement_collects_statuses_between_its_checks_until_the_deadline, whosecollections.get() > 1assertion races a 50 ms deadline. At the same head, it passed 50 of 50 runs alone, and the all-features suite passed 3 of 3 runs. While this pull request targets #876's branch,dev's CI doesn't run on it.The third commit makes the
StoreGenerationthe nonzero number that the template store of #839 assigns to each publication, in place of 16 opaque bytes, so that a record names a template's generation in the store's own terms.The guide sections that #838 names follow the numbering of #591, which isn't on
dev, so the record is documented in the module and the crate's README.