Skip to content

nvx-rs: define the activation record of a layered root and its limits - #871

Open
Enrique Saurez (esaurez) wants to merge 13 commits into
esaurez/860-pr1-resolver-runtime-binds-20261024from
esaurez/838-pr1-activation-record-types-20261024
Open

Enrique Saurez (esaurez) wants to merge 13 commits into
esaurez/860-pr1-resolver-runtime-binds-20261024from
esaurez/838-pr1-activation-record-types-20261024

Conversation

@esaurez

@esaurez Enrique Saurez (esaurez) commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #876; this PR retargets to dev when the pull requests below it merge. The chain on dev is #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, beside nvx::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). An ActivationRecord holds the schema's VERSION, a clone's RestoreSection, and the Configuration:

  • the Image: EROFS or GPT, the Identity of 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;
  • the Upper: a tmpfs, or the scratch, with its binding's Identity and its Journal policy;
  • the OverlayMode;
  • an Export, a directory or an aggregate, with its plan's Identity, which covers the export's whole shape, and its 1 to 4,096 mappings;
  • the Network: its policy's Identity, an optional DNS address, the listeners' Publication, in the vocabulary of nvx::guest::entry, and an optional proxy endpoint;
  • the WorkloadView: the hostname, the MachineIdMode, the image's own machine ID or a generated one, and an optional WorkloadIdentity, a UID and a GID, neither 0 nor u32::MAX;
  • the ExpectedResources: the CPU count and the memory size that the guest must have.

A clone's RestoreSection holds the restore's 16-byte TransactionId and the TemplateReference: the template's StoreGeneration, the nonzero publication ID that its store assigns in the order of publication and never reuses, and the Identity of the template's key. An Identity is 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. ReadyActivation is what Ready reports: the ConfigurationIdentity, 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), and MAX_PROXY_ENDPOINT_BYTES (255). ActivationRecord::new admits a configuration only once Configuration::check finds 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:

  • a UID or GID of u32::MAX, which the kernel reads as an unchanged ID, and which the guest's entry and the managed agent refuse;
  • a hostname with a NUL, as the guest's entry does;
  • a proxy endpoint with anything other than visible ASCII characters, or with an @, which would start credentials;
  • a zero CPU count, memory size, or first-partition bound, which no guest matches.

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.md list 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 or u32::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 clippy with --all-targets --all-features, --lib, and --lib --no-default-features, -D warnings; cargo test with all, default, and no features; cargo doc with -D warnings; cargo +1.89 check;
  • the runtime adapter's protocol-only and host-only configurations, and, on Linux, its guest-only configuration;
  • aci_edge_sandboxes' checks;
  • on Linux, also the bundled artifacts 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 clippy with --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, whose collections.get() > 1 assertion 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 StoreGeneration the 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.

Copilot AI balanced review requested due to automatic review settings October 24, 2026 17:56

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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

Comment thread nvx-rs/src/activation/mod.rs Outdated
Copilot AI balanced review requested due to automatic review settings October 24, 2026 18:14

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The additive data model has focused validation and boundary coverage, with no unresolved findings or runtime behavior changes.

No open issues 🎉

1 resolved since last review

Review effort: Balanced

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The additive contract and boundary tests have no unresolved findings, and the prior workload-ID validation issue is fixed.

No open issues 🎉

Review effort: Balanced

Enrique Saurez and others added 10 commits October 26, 2026 17:42
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
Enrique Saurez and others added 3 commits October 26, 2026 18:11
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
@esaurez
Enrique Saurez (esaurez) force-pushed the esaurez/838-pr1-activation-record-types-20261024 branch from dc4517f to 3032e6d Compare October 27, 2026 01:11
Copilot AI balanced review requested due to automatic review settings October 27, 2026 01:11
@esaurez
Enrique Saurez (esaurez) changed the base branch from dev to esaurez/860-pr1-resolver-runtime-binds-20261024 October 27, 2026 01:11

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The additive model matches the stated scope, has thorough boundary tests, and has no unresolved blocking findings.

No open issues 🎉

Review effort: Balanced

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants