Repository navigation
Make nvx-rs the backend of nvx.py sandbox, egress, and artifact lookup - #597
Open
Pedro Henrique Penna (ppenna) wants to merge 38 commits into
Open
Pedro Henrique Penna (ppenna) wants to merge 38 commits into
Pedro Henrique Penna (ppenna) wants to merge 38 commits into
Conversation
Replace native C helper compilation with the shared Rust guest-binary builder introduced by #528. Build one checked set of all nine executables per initramfs, install it at the established /sbin/nvx-* paths, and record the aggregate source and individual binary digests. Remove the obsolete C compiler flags, helper inventories, and build functions. Keep C test-only flags local until the C-driven suites are deleted later in this stack. Part of #358. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Build one checked set of Rust guest executables in the digest-pinned Rust 1.95.0 image and reuse its exact bytes in Alpine, Ubuntu, and Azure Linux through a guest-only base, so the kernel and Linux source targets never compile them. Keep the toolchain available only for Ubuntu's two fresh determinism attempts, and remove the guest C compilers from the base. Bind reusable binary sets to normalized Rust-source, toolchain, target, profile, and executable digests, and refuse the output of an earlier build that a rebuild would upgrade in place. Record all nine helpers in the Azure Linux package manifest. Part of #358. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Cover the nvx-rs guest inputs in every image identity, package the crate without its target directory, and declare the exact Rust toolchain and musl target in SOURCE-MANIFEST.json. Verify those pins and inputs from nvx.py verify and release packaging. Make device-I/O benchmark provenance validate the shared Rust source digest, and bind the Azure Linux cache input to the newly tracked files. Part of #358. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Hash the complete nvx-rs tree except target, the exact toolchain pin, and the shared build modules into every guest cache, and advance the guest cache schemas. Because hashFiles hashes contents only, also append the path-aware Rust source digest that the guest package manifests record, which the new guest-binary-source-identity command prints, so a module move that keeps every file's bytes still rebuilds the images. Treat nvx-rs changes as OpenVMM VMM-test inputs so they rebuild and boot the affected images. Keep Copilot setup and both Linux setup paths on the pinned musl target, and revalidate Copilot setup for every nvx-rs change. Part of #358. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Remove the nine remaining guest C implementations and the C-compiled Python suites whose behavior the nvx-rs unit, guest-binary, loopback, and real-VM tests now cover. Drop the deleted modules from both CLI validation legs. Add a repository regression that rejects C, header, or assembler sources outside the OpenVMM submodule. Part of #358. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Document the one pinned build shared by native builds, Docker, and CI, the nine binaries shipped by every guest, their provenance, cache inputs, test ownership, and release source. Now that the Rust definitions are authoritative, replace the obsolete source comparisons with literal wire values and remove C-era wording. Extend the third-party notices for the Rust standard libraries, compiler_builtins and its compiler-rt-derived routines, libc, nix, getrandom, and their linked transitive crates, and musl's complete guest-binary scope. Closes #358. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Port scripts/nvx_tools/egress_policy.py, which compiles the files of
nvx.py's --network-egress-policy-file option, to
nvx::host::network::policy, so that the nvx-rs CLI can replace it.
Python stays authoritative until nvx.py delegates to nvx-rs. The port
accepts the same files, compiles them to the same rules, and fails with
the same messages:
- compile_policy_file reads at most 1 MiB and one byte of a file, and
compile_policy_bytes and compile_policy compile its contents or an
already parsed serde_json value. The CompiledEgressPolicy result has
the serde form {"allow": [...], "deny": [...]}.
- The format keeps Python's root fields, the MXC and legacy flat rule
forms, the tcp, udp, icmp, and any protocols, and TCP and UDP port
ranges, and runs every check in Python's order with its message.
- The lowering keeps Python's steps: address-only rules, rules for every
port of ICMP, TCP, and UDP widened into the address-only cover, the
sweep over TCP and UDP port boundaries, the budget of 256 native rules
that both families of an action share, and the sort by family,
address, prefix length, protocol, and ports.
network.rs becomes network/mod.rs, beside address.rs, the networks and
address intervals, and lowering.rs, the rule model and the lowering.
openvmm_arguments still expands NetworkPolicy rules as before.
The format depends on Python semantics, which a crate-private
host::python module provides for this and later ports:
- python::json reads a document as json.loads does, with hooks in the
place of object_pairs_hook and parse_int: NaN and Infinity are floats,
numbers beyond a float's range are infinities, integers keep their
digits, and a \ud800 escape stays an unpaired surrogate. It keeps
values in an arena and never recurses. The policy reader's hooks
refuse a repeated field when its object closes and an integer of more
than 64 digits when it is read, so their errors surface in Python's
order. serde_json, which nvx-rs already depends on, cannot do this: it
refuses NaN, Infinity, 1e400, and lone surrogates, which Python reads
and the compiler then reports with its own messages. No dependency is
added.
- python::text holds strings that may contain unpaired surrogates,
ordered by code point as Python orders them, and shows a surrogate as
\udXXX, which is how Python prints it.
- python::path gives str(pathlib.Path(path)) for POSIX and Windows
paths, as CPython 3.14 splits them, and the normalized path that
Python opens, so that messages name files as nvx.py does.
Networks are read as ipaddress.ip_interface reads them: host bits are
cleared, IPv4 netmasks and hostmasks, prefix lengths with leading zeros
up to Python's 4300-digit int limit, and embedded IPv4 addresses are
accepted, and IPv6 scopes are found and refused. They print as Python
3.13 and later print them, with IPv4-mapped addresses in dotted form.
One input differs. Python's decoder raises RecursionError for a
document nested more deeply than its stack allows, at a depth that
depends on the Python version and the platform. This reader reads any
depth, so such a document fails as an invalid policy instead.
The tests port each case of scripts/test_egress_policy.py under its
name. The cases that mock ipaddress.summarize_address_range and
_merge_intervals to bound the work read thread-local counters of the
lowering instead. Unit tests cover the JSON reader, strings, paths, and
networks against values that Python 3.14 produced.
Part of #359.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add scripts/generate_egress_policy_fixtures.py, which runs the Python
compiler of scripts/nvx_tools/egress_policy.py over a corpus of policy
files and records each file with its outcome, its native rules or its
error message, in nvx-rs/tests/fixtures/egress-policy.json. The Rust
tests replay every entry through compile_policy_bytes and, from a real
file, compile_policy_file, and need no Python, so the fixture outlives
the Python compiler, which a later change in this stack deletes.
The corpus holds 784 policy files:
- every file that scripts/test_egress_policy.py compiles, which the
script captures by running that suite, except those of the two tests
whose files exceed 64 KiB, which pin their complete outcomes in
Python and in their Rust ports;
- hand-written cases that reach every message and JSON edge: syntax
that json.loads refuses, NaN, Infinity, floats, Booleans, and 64- and
65-digit integers as ports, repeated fields and long integers in
document order, unpaired surrogates in names, networks, scopes, and
protocols, unknown fields in code-point order, each check of both
rule forms in order, network spellings as ip_interface reads them,
the rule limit across protocols and families, and files at and beyond
the 1 MiB limit; and
- 200 random policies from seed 359 that mix both rule forms and both
families, dense and sparse networks written with host bits, masks,
and exceptions, overlapping protocols and port ranges, crowds of
rules near the limit, broken fields, and varied, truncated, and
altered JSON text.
The fixture also records the errors for a missing file and a directory,
and how PurePosixPath and PureWindowsPath print 55 paths, which the
tests compare with python::path. Messages name the policy file
{path}, and show unpaired surrogates as Python prints them. Inputs are
text, or hexadecimal for bytes that are not UTF-8, with runs of 256 or
more characters recorded as repetitions, and each entry is a line of
ASCII JSON, about 1.1 MB in all.
The script needs Python 3.13 or later, whose ipaddress module prints
IPv4-mapped IPv6 addresses in dotted form, as the port does. Its output
depends only on the seed and the corpus size, so --check verifies that
the fixture is current. --random-cases and --output write larger
corpora, and NVX_EGRESS_POLICY_FIXTURE points the tests at one. The
script passes ruff and strict pyright on Linux and Windows.
Part of #359.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
nvx::host::network::openvmm_arguments, which the OpenVMM backend of aci_edge_sandboxes calls, expanded each EgressRule into OpenVMM rules on its own, then sorted and deduplicated the strings. It now lowers each action's rules with the lowering of egress policy files, so that NVX has one lowering. An EgressRule is a policy file's MXC rule whose empty to or ports stands for an absent field, and messages name its fields after EgressRules::name, where a file's messages say allow or deny, as in "network.egress.allow[0].to[1].cidr is not a valid IPv4 or IPv6 CIDR: x". openvmm_arguments keeps the checks that only a NetworkPolicy has, which the Python compiler lacks: no device for a closed posture, the /29 limit of host-loopback access, and the rules of host-loopback forwards. The check that no deny rule covers the guest network of forwards now reads the deny rules as the lowering parsed them, so it follows the same normalization. Before, it parsed them with the stricter Cidr and missed a network written with host bits, which the old expansion also refused. The observable changes, which aci_edge_sandboxes/README.md lists as breaking changes: - The OpenVMM rules follow family, address, prefix length, protocol, and ports, instead of string order. - Merging destinations and port ranges, and subsuming narrower rules, usually needs fewer rules, so some policies beyond the limit of 256 now fit: 256 adjacent /24 destinations become one /16 rule. Port ranges that overlap only partly across networks can also need a rule for their union. - The backend's rule checks report the compiler's messages. Through AciEdgeSandbox, whose request validation runs first, only the limit message, now "network.egress.allow emits at most 256 native rules", changes. The error codes are unchanged. - Without that validation, the backend accepts networks with host bits and IPv4 netmasks, and refuses port 0, which it passed to OpenVMM. Tests: the nvx-rs network tests pin the new order, merging, messages, and coverage check. ipv6_rules_expand_exactly, now ipv6_rules_lower_exactly, expects ::/0 first. inexpressible_rules_are_rejected, now invalid_rules_are_rejected_with_the_policy_compilers_messages, checks each message, and its 300-destination rule, which now lowers to one rule, moved to rules_lower_like_egress_policy_files. exceptions_that_split_a_network_too_finely_are_rejected expects the limit message. network_policies_convert_field_for_field expects the new ICMP message and also checks the scope and limit messages. The other aci_edge_sandboxes tests, including the fake-OpenVMM test that pins the launch arguments, pass unchanged. Part of #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Describe nvx::host::network::policy in nvx-rs/README.md: what it ports and that Python stays authoritative until nvx.py delegates to nvx-rs, the policy format, the lowering and the order of its rules, how openvmm_arguments, and so aci_edge_sandboxes, share that lowering, the fidelity of its messages and the one input on which it differs from Python, and the parity fixture with its generator. The module table gains the policy module, and its network row describes NetworkPolicy and the shared lowering instead of the expansion. doc/project-structure.md lists the generator, the parity fixture, and the compiler in its descriptions of scripts/ and nvx-rs/. Part of #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
An MXC rule pairs each family of its destinations with each of its port selectors, and `combine`, formerly `mxc_rules`, gave every pair its own copy of the family's address intervals. Python's `_parse_mxc_rule` and `_parse_legacy_rule` share one tuple among the rules of a family instead. A policy file well within the 1 MiB limit, such as 8192 destinations, none adjacent to another, with 8192 port selectors, made the port copy 2 GiB of 32-byte intervals, and the unions of `_compile_family` flattened the shared addresses once for each selector again. - `Rule.addresses` is an `Rc<[Interval]>` that the rules of one rule's selectors share: for MXC rules, legacy rules, and the typed rules of `openvmm_arguments` alike, which all build their rules with `combine`. - The union of the rules for every protocol, and of those for every port of each protocol, merges each distinct address set once. Python merges the same addresses once for each selector; the union is the same. - The port sweep asks once for each address set whether `covered` holds it entirely, rather than once for each rule. - An `AddressSet` compares rule addresses by value, as Python's tuples compare, and compares a shared allocation with itself without reading it, as CPython's dictionaries do. The sweep counts its active rules by it, as `_lower_protocol_rules` counts them in a `Counter`. The output, the errors, and the order of both are unchanged: the parity fixture and the ported Python tests still pass. New tests check that the rules of a rule's selectors share one allocation, that address sets compare by value, that the unions read shared addresses once (64 intervals rather than 4096), and that three 8192-by-8192 policies compile as Python 3.14 compiles them: to `0.0.0.0/0` with a covering rule, and to the 256-rule limit errors without one. Python takes 15 to 27 seconds for each. Before this change, the debug build of that test took 171 seconds and peaked at 4.05 GiB; it now takes 0.5 seconds and 48 MiB. Part of #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Port SandboxLaunch, SandboxLayer, SandboxMount, and the helpers of scripts/nvx_tools/sandbox.py to a new nvx::host::sandbox module, so that the nvx-rs CLI can launch sandboxes as nvx.py sandbox does. Python stays authoritative and unchanged until nvx.py delegates to nvx-rs. The port keeps the Python contract byte for byte: the limits, option semantics, OpenVMM arguments, kernel command line, and messages, with refused requests as Unsupported, missing images as Unavailable, and failed host queries as Io errors in Python's [Errno N] form. - Layers: one to three EROFS layers, distro, runtime, and custom, ordered by role, with duplicate roles refused in sorted order. A --layer value is ROLE,PATH,EROFS_UUID, and the UUID takes every form that uuid.UUID reads: braces, urn:uuid:, no hyphens, and uppercase. Layer and scratch paths with ',' or ';' are refused. - Settings: an absolute entrypoint and nonempty arguments without whitespace, a lowercase RFC 1123 hostname label, a non-root workload identity, 65534:65534 by default, which parse_workload_identity reads as int(field, 10) does, and positive limits, with nvx_pids_max one above the request. - Shares: --mount GUEST_TARGET,HOST_PATH[,ro|rw], whose target ends at the first comma and whose mode follows the last one; the target rules, at most 4096 UTF-8 bytes, and the reserved targets; at most 128 denied, allowed, and writable paths each; the overlap and single-owner checks; caller ownership refused on Windows; file and directory shares; the aggregate at /run/nvx/shares with children named INDEX-SHA256(TARGET)[:32] and its nvx_share= tokens; and the host-path checks, by Path.resolve() and (st_dev, st_ino) lineage. - Kernel command line: NUL and the nvx_* and virtfs_* tokens refused, the tokens in Python's order, and the 1024 bytes that OpenVMM leaves of the 2048-byte x86 command line, which also hold OpenVMM's bootstrap tokens for the shares and the NUL. The private python module that the egress policy compiler introduced gains what these rules rely on, after CPython 3.14: builtins, with str.isspace, which unlike char::is_whitespace includes U+001C to U+001F, str.split() and str.strip(), repr of a str, and int, with signs, underscores, Unicode digits, surrounding whitespace, and the 4300-digit limit; uuid, for uuid.UUID; unicode, the Unicode 16.0.0 tables of str.isprintable and int; and fs, the queries of os.path and os.stat, with Path.resolve() ported from posixpath.realpath and ntpath.realpath. On Windows, os.stat identifies a file by the 64-bit volume serial number and 128-bit file ID of FILE_ID_INFO, as Python 3.12 and later do, through the windows-sys features that the crate already enables. Operating-system strings that are not valid Unicode, which Python would carry as surrogates, are refused. python::path gains PurePath, a Unicode path of either pathlib flavor with its parts, parents, joins, and case-insensitive comparison on Windows, and Flavor, with the splitroot, join, isabs, and split of posixpath and ntpath, and ntpath.normpath. One parser serves both python_path and PurePath, so the printed and the pure paths cannot disagree. Windows paths follow CPython on Windows, whose splitroot and normpath are C functions that index UTF-16 code units: a character beyond the Basic Multilingual Plane before a colon is no drive there, although Python's own ntpath, which other hosts use, reads one. scripts/generate_egress_policy_fixtures.py becomes scripts/nvx_rs_fixtures.py, one generator for every Python-parity file of nvx-rs, which now also writes the Unicode tables, with one --check for all of them. It needs CPython 3.14, whose Unicode database the tables record, and says so; --output and --random-cases still write a larger egress policy corpus. The egress policy fixture regenerates byte for byte, except its comment, which names the script. Aggregate child names need SHA-256 on the host, so the host feature enables sha2 0.10, which the bundled build script already uses and locks, as does the aci_edge_sandboxes agent, instead of another crate. The unit tests port the launch-profile cases of scripts/test_nvx_tools.py, and check require_file, share kinds, host-path identity, and bind and hard-link identity on temporary directories on Linux and Windows. Part of #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Port the pure checks that scripts/nvx.py applies to the options of nvx.py sandbox and nvx.py run to nvx::host::sandbox::options, so that the nvx-rs CLI keeps their order, semantics, and messages and stays a thin layer over the library. - SandboxOptions::check: the checks of command_sandbox that precede the launch, in its order: --dry-run outside run, --environment with --environment-file, managed exec options outside exec, the managed exec environment, network policy options outside run and provision, systemd entrypoints, --outcome-report outside run and exec, share options without --mount, --mount outside run and provision, --net without --network-profile or the reverse, and a missing --layer or --scratch. - sandbox_mounts, from _sandbox_mounts: with one --mount, every --mount-deny, --mount-allow, and --mount-write names a path in it; with several, each names a path in the --mount before it. Each share takes the kind that mount_kind finds for its host path. - validate_sandbox_systemd_policy and SYSTEMD_ENTRYPOINTS: a distro layer's manifest, next to it, must be a JSON object of format 1 that names the layer and its SHA-256 digest and lists packages with names, none of them systemd. The format is compared as == compares it, so true and 1.0 match, and the manifest is read with universal newlines, as read_text reads it. - validate_run_share_targets, from _validate_run_share_targets: the guest targets of several nvx.py run shares, or of a shared file. - validate_exec_environment and exec_environment_from_file: at most 256 nonempty KEY=VALUE entries of at most 4096 UTF-8 bytes, without NUL or a repeated key, from --environment or from a JSON array in a file of at most 1 MiB, as encode_exec_environment checks them. These checks fail with the messages of Python's JSON decoder and UTF-8 codec, so the private python module gains them: - python::json keeps its API, its hooks, and its arena, but its reader is now a port of the C scanner of CPython 3.14, scan_once_unicode and the functions that it calls, with the checks of JSONDecoder.decode around them. It accepts what the previous reader accepted, as the egress policy fixture and tests confirm, and a refusal now carries the message and the position of Python's JSONDecodeError, such as Expecting ',' delimiter, instead of the offset at which reading stopped, which is not always Python's position: an unterminated string fails at its opening quote. syntax_error formats it as str() of the error, with its line, column, and character. Loads are the hooks of json.loads without arguments, whose int refuses more than 4300 digits, and Document::get reads a member as the dict that Python builds does. - python::text gains PyText::encode_utf8 and decode_utf8, with the messages of UnicodeEncodeError and UnicodeDecodeError, so PyText is the one string type that holds surrogates. - python::path gains name and with_name, and python::fs exists. Argument parsing and type conversion stay with the CLI, as do the lifecycle operations. The unit tests port the option cases of scripts/test_nvx_tools.py: the attribution of policy paths to shares, file shares, the systemd policy, the run share targets, and the environment rules, with the environment files and distro manifests on temporary files. The JSON and codec errors have tests of their own. Part of #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Record what the Python code does with fixed and seeded random inputs, and compare the Rust port with those records in golden tests, so that the port stays faithful byte for byte without Python at test time, also after nvx.py delegates to nvx-rs and the Python code goes away. scripts/nvx_rs_fixtures.py now also writes three fixtures to nvx-rs/tests/fixtures, which its --check covers with the others: - python.json: the str.isspace, decimal digit, and str.isprintable tables; repr, str.split(), str.strip(), int, and uuid.UUID of strings; str(), parts, name, parents, and is_absolute of pure paths, joins, and Windows path equality; ntpath.normpath; and the values and errors of json.loads and of the UTF-8 codec. - sandbox.json: parse_workload_identity, SandboxLayer.parse, parse_mount_value, validate_mount_target, aggregate_child_name, SandboxMount with its OpenVMM arguments, command-line fragment, and absolute paths, and SandboxLaunch with its OpenVMM arguments and kernel command line. The launches include, for each kind of share set, command lines that fill the 1024-byte budget exactly and that exceed it by one byte. - sandbox_options.json: _validate_run_share_targets, _sandbox_mounts, encode_exec_environment, the --environment-file rules of command_sandbox, which runs with exec_workload patched, and _validate_sandbox_systemd_policy, on temporary files. The generator patches sandbox.Path with pure POSIX and Windows paths whose current directory is fixed, so that the cases of both flavors come out the same on any host: CPython 3.14 writes identical files on Linux and Windows. It writes ASCII JSON with one case per line. The random corpus is mostly valid input, so that most cases reach the arguments and command lines, and invalid at a fixed rate. Each fixture has a random generator of its own, so the egress policy fixture is unchanged. The Rust golden tests read the fixtures with serde_json, run the same inputs through the port, and report each case that differs. They run the cases of both path flavors on every host, through the internal constructors that take a flavor and a fixed current directory, and the file cases on temporary files. The JSON cases go through python::json with the hooks of json.loads, so they check the reader that the egress policy compiler shares. .gitattributes marks the fixtures, about 1.2 MB, as generated, so that reviews collapse them. Part of #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Extend nvx::host::artifacts to every artifact of the guests that nvx.py runs, so that nvx-rs can stand in for the runtime lookups of scripts/nvx_tools/common.py: artifact_path, openvmm_binary_path, and the require_file checks of nvx.py run and nvx.py sandbox. - Guest describes the Alpine, Ubuntu, and Azure Linux guests of scripts/nvx_tools/guests.py: their names, distributions, initramfs images and package manifests, and the Ubuntu distro layer, ubuntu-distro.erofs, with its manifest. Guest::from_name fails as guest_descriptor does. - ArtifactDir names them in a repository checkout, as build/NAME and openvmm/target/release/openvmm[.exe], where local builds write them and where nvx.py download installs a release, or in an extracted release package, as guest/NAME and bin/openvmm[.exe], where package_release stages them. ArtifactDir::from_dir tells the layouts apart as Artifacts::from_dir does, and makes the directory absolute without resolving links, so callers pass the resolved REPO_ROOT of nvx.py as it is. - require_openvmm, require_kernel, and require_initramfs check the files with the messages of nvx.py: "OpenVMM release binary not found: PATH", "Linux direct kernel not found: PATH", and "DISTRIBUTION initramfs not found: PATH". sandbox_artifacts checks the OpenVMM executable, the kernel, and the Alpine initramfs in the order of nvx.py sandbox run and start, which call the last "initramfs". - paths lists every artifact as ArtifactPaths, which serializes as JSON, with the guests in nvx.py's order, for nvx-rs artifacts. Like nvx.py, the lookup reads no SOURCE-MANIFEST.json, and checks only the files that a command needs. Artifacts and its discovery, which aci_edge_sandboxes uses, are unchanged: Artifacts::from_dir only shares its layout detection, and the systemd policy of the sandbox options now takes the distro manifest suffix from here. The tests cover both layouts, the layout of this repository, the order and messages of the checks, and the JSON form. Part of #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Describe in nvx-rs/README.md the nvx::host::sandbox module, with its mount and options submodules, and the guest artifact lookup that ArtifactDir, Guest, and ArtifactPaths add to nvx::host::artifacts, beside the discovery that aci_edge_sandboxes uses. A new Python parity section explains which Python semantics the ports keep, including the egress policy compiler, the private python module that implements them, and the fixtures that scripts/nvx_rs_fixtures.py records, with the commands that regenerate and check them, the CPython 3.14 that they need, and why CI does not check them; the egress policy section points to it. The features table names sha2, which the host feature now enables. Update the trees and the nvx-rs summary of doc/project-structure.md for the sandbox launch profile and the golden fixtures. Part of #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add the cli feature, which implies host, and the nvx-rs binary, with required-features = ["cli"]: the command-line interface to which nvx.py will delegate its sandbox, egress-policy, and artifact steps (#359). This commit adds the command-line framework and three commands; the managed lifecycle of nvx-rs sandbox follows in the next commit, and until then provision, start, exec, stop, and deprovision fail after their option checks. - nvx-rs [--root PATH] artifacts prints ArtifactDir::paths as JSON, as json.dumps(value, indent=2, sort_keys=True) writes it. The root is --root, which nvx.py will pass, or else the release whose bin/ holds the executable, or the checkout whose nvx-rs/target/ holds it, either with its SOURCE-MANIFEST.json. The NVX_OPENVMM variable names an OpenVMM executable to run instead of the root's, such as a test double; a relative path is made absolute. - nvx-rs egress compile PATH prints the rules of compile_policy_file as {"allow": [...], "deny": [...]}, each list in the order in which OpenVMM receives them, and fails with the compiler's message. - nvx-rs sandbox ports the sandbox parser and command_sandbox of scripts/nvx.py: every option with its name, default, type, and choices, and the checks of SandboxOptions::check, the egress policy file, the mounts, the launch, and the systemd policy, in Python's order and with its messages. A one-shot run prints ">> COMMAND", quoted as subprocess.list2cmdline quotes it on Windows and as shlex.join does elsewhere, and unless --dry-run runs OpenVMM with the standard streams and exits with its status. The artifact checks keep the messages and order of nvx.py, and --hypervisor auto resolves to whp on Windows and to kvm elsewhere. The parser reproduces argparse step by step: --opt value and --opt=value; values that start with a dash only after "=", unless they look like negative numbers or hold a space; the alternation of options and positionals; the optional operation positional, which defaults to run; repeated options that accumulate; and the messages and status 2 of usage errors, with unrecognized arguments reported by the top-level parser after the subcommand parsed. It does not expand abbreviations, since nvx.py will pass canonical options; that is an intended deviation. Help and usage are argparse's for 80 columns. Errors print "error: MESSAGE" and exit with status 1, as main() of nvx.py does for a ScriptError. Ctrl-C prints "Interrupted" and exits with status 130. A SIGINT handler on Unix, installed only if SIGINT is not ignored, and a console control handler on Windows record it, and a run then gives OpenVMM 0.25 s to exit before it kills it on POSIX, as subprocess.run does, and waits for it on Windows, where Python cannot interrupt the wait either. Text ends its lines as Python's print ends them, with \r\n on Windows. The private python module of #594 and #595 gains dumps, json.dumps with indent=2, sort_keys=True, and ensure_ascii; shell, subprocess.list2cmdline, shlex.quote, and shlex.join; and float() in builtins for positive_float. The sys and platform modules gain the interrupt primitives, and windows-sys its console bindings. Unit tests compare the parser with argparse, the Python helpers with CPython, and cover the discovery of the artifact root. Golden scenarios recorded from nvx.py follow in a later commit. Part of #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Port scripts/nvx_tools/sandbox_lifecycle.py, and the ControlSession of
control_session.py that it uses, so that nvx-rs sandbox provision,
start, exec, stop, and deprovision keep the contract of nvx.py
sandbox: the same state directory, files, formats, locks, messages,
outputs, and exit statuses. Either implementation can operate a
sandbox that the other provisioned or started.
State, in sandbox/state.rs:
- config.json in the lowest of formats 1 to 6 that holds the sandbox,
and runtime.json, format 1, with the pid, start_time, and
control_endpoint of OpenVMM. Both are written as json.dumps(value,
indent=2, sort_keys=True) + "\n" writes them, ASCII-escaped and with
\r\n line ends on Windows as write_text produces them, through a
temporary file, chmod 0600, and a rename.
- Reading applies Python's semantics to whatever another writer left:
strict UTF-8, == comparisons of the format, str() and int() of the
fields, and the order and messages of _deserialize_launch.
Locks, in sandbox/lock.rs: lifecycle.lock is locked as Python locks
it, so the implementations exclude each other: flock(LOCK_EX |
LOCK_NB) on POSIX, and on Windows CreateFileW with read, write, and
delete sharing and FILE_FLAG_OPEN_REPARSE_POINT, then LockFile of the
first byte, as msvcrt.locking(LK_NBLCK, 1) takes it. The
standalone-regular-file checks, the retries every 25 ms within
--timeout, the hand-off waits, the "deprovisioned" tombstone, and the
removal of the directory are ported, with Windows errors classed as
CPython maps them to errno.
Process identity, in sandbox/identity.rs, is Python's: on Linux the
start time in /proc/PID/stat, with zombies as exited, and on Windows
the creation time, so that a runtime record that either implementation
wrote is valid for the other.
The lifecycle, in sandbox/lifecycle.rs:
- start generates a 32-byte capability, never all zero, writes
control.capability, passes the capability on OpenVMM's standard
input through a pipe closed before OpenVMM starts, appends its
output to openvmm.log, and starts it detached, in its own session
on POSIX and process group on Windows, with _start's command line:
the managed lifecycle, its network options in _start's order, and
the endpoint STATE/control.sock, or //./pipe/openvmm-microvm-HEX
with a uuid4 on Windows. It records runtime.json, then connects,
attaches, and pings. A failed start ends OpenVMM and removes the
runtime files in _start's order, and reports OpenVMM's exit as
_startup_exit_message does.
- exec buffers the workload's output, writes standard output, then
standard error, and exits with the workload's status. With
--outcome-report, it checks the destination before it runs and
writes {"operation_id", "outcome", "schema_version": 1} as
_write_new_json does.
- stop asks the guest to shut down, waits for OpenVMM, reads its
outcome report, removes the runtime files, and prints nothing, as
nvx.py sandbox stop does. deprovision removes the state directory.
The session, in session.rs, keeps the observable behavior of
control_session.py on nvx::host::session, whose ControlSession gains
send_request and read_response for callers that check the responses
themselves: connection retries every 25 ms until the timeout, then
"managed control endpoint did not become available: PATH"; one
deadline per operation for its request and its responses; "managed
control request timed out" or "managed control response timed out"
by the direction of the transfer; ControlEndpointClosed for a closed
endpoint, but not for a reset; a session closed after a failed
transfer; and exec's checks, in their order and with their messages,
its exit categories, and its refusals with their status and ASCII
category. Waits run in 250 ms slices, so that Ctrl-C ends them.
Intended deviations: an endpoint that a process other than the
launched OpenVMM serves counts as one that has not appeared yet, as
in nvx::host::session; OpenVMM starts without a console window on
Windows, as nvx::host::process starts it; exec refuses more than the
1 MiB of output that the guest agent forwards; and where Python would
end with a traceback, as for a configuration without a field that
_start reads, nvx-rs prints "error: MESSAGE" with the exception's
message and exits with status 1.
The base modules gain what the port needs: process::Launched waits
for, terminates, and kills OpenVMM as Popen does; the Windows platform
module locks the first byte of a file and reads exit codes; and the
python module adds value, str(), int(), and == of JSON values, and
lstat, lexists, and fstat with st_file_attributes on Windows.
Unit tests cover the formats, the JSON writer, identity parsing, the
locks, and the lifecycle helpers. The tests of control_session.py and
golden scenarios recorded from nvx.py follow.
Part of #359.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Port scripts/test_control_session.py to the session of nvx-rs sandbox, in host/cli/session/tests.rs, except the cases that the tests of nvx::host::session, whose framing the session reuses, already cover. Session::connect now attaches through Session::attach, which the tests call on their own transports. The ControlSessionTests run on an in-memory stream pair, like the socketpair of the Python tests, with a guest thread that plays OpenVMM's endpoint: exec streams output and returns its status; refuses relative entrypoints, more than 64 arguments, and invalid arguments, timeouts, working directories, environments, and response timeouts before it sends anything; encodes the legacy and extended requests, with their working directory and omitted, empty, replaced, or layered environments, exactly; accepts the whole u32 timeout range; refuses unknown exit categories, including "cancelled", which the CLI never requests; and keeps the status and category of a refusal. A reset is not a closed endpoint, every transfer of an operation gets no more than what is left of its timeout, and a failed transfer closes the session. The stream tests run on the endpoints of each host, which this process serves: on POSIX, Unix-domain sockets whose closure and partial reads report a closed endpoint; on Windows, named pipes whose closure reports a closed endpoint after the data that they held, and whose read timeout closes the session. A stalled endpoint, which reads nothing, times out the request within its timeout on both hosts, and an endpoint that never appears times out with its path. The Windows tests create their pipe servers through windows-sys, so their module allows unsafe code. Not ported: the capability-pipe tests, since the pipe is nvx::host::process::spawn's, which writes the capability and closes the pipe before OpenVMM starts, and which every start of the golden lifecycle scenarios exercises, since the fake OpenVMM requires exactly the capability and the end of the file on its standard input; the mocked transfers that move nothing, which the closed-endpoint tests cover; reads that time out without losing data, which nvx::host::session's timed_out_polls_keep_partial_records covers; and the cancellation of an interrupted pipe write, since the CLI checks Ctrl-C between waits and bounds each write by its timeout. Part of #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
As the #359 discussion decided, nvx-rs sandbox start now negotiates the guest's control features, with a required set of its own rather than GuestFeatures::REQUIRED, which describes the direct agent of nvx::host::instance. This is the one intended change from nvx.py sandbox start, which does not ask. GuestFeatures::LAYERED_SANDBOX holds exactly what the CLI's launches use: EXEC_ENVIRONMENT (bit 4), because exec sends a working directory and an environment in an extended EXEC. It holds no CANCEL (bit 0): the CLI never sends APP_CANCEL, since, as nvx.py did, Ctrl-C ends an exec by closing the session, and the guest agent ends the workload when OpenVMM resets the session that it ran for. It holds no WORKLOAD_ACCOUNT (bit 2), which no sandbox launch requests and the layered root does not perform (#566). The NVX guest agents, the Rust agent of #561 and the C agent before it, advertise bits 0, 2, and 4 in sandbox mode. After its PING, start sends FEATURES. An agent that predates the request refuses it as an unsupported operation and so provides none. If the guest lacks a required feature, start ends OpenVMM and removes the runtime files, as a failed start does, and fails with error: the guest image lacks control features that layered sandboxes need (per-execution environments); use an NVX release or build that includes them which names the missing features with GuestFeatures::missing, as the reject_guest of nvx::host::instance does. Tests cover the required set, the features responses of current, older, and misbehaving agents, and, with the golden scenarios that follow, a start against guests that lack the feature or predate the request. Part of #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Extend nvx::testing::fake_openvmm, and so nvx-fake-openvmm, to the sandbox profile that nvx.py sandbox and nvx-rs sandbox launch, so that both can run end to end without a VM. A --microvm-sandbox-block option selects the profile; direct launches, which nvx::host::instance and aci_edge_sandboxes use, are unchanged. - The fake accepts the sandbox's arguments and checks them as OpenVMM would: read-only distro, runtime, and custom layers and one scratch disk, each an existing file; nvx_sandbox=1, one nvx_entrypoint=, and one nvx_hostname= on the kernel command line; one --mount of a directory, or the --mount-child children of an aggregate at /run/nvx/shares; --mount-owner and --network-proxy; and the network options, which it summarizes as OpenVMM's report does. - --microvm-lifecycle managed serves the control console as before, with the capability on standard input, and its guest advertises GuestFeatures::SANDBOX_AGENT. The guest targets of directory shares, from --mount and the nvx_share= tokens of an aggregate, are directories that a workload can enter. - --microvm-lifecycle one-shot reads no capability and serves no console: it runs the entrypoint and arguments of the kernel command line on its own standard streams, /bin/echo, /bin/sleep, /bin/true, or /bin/false, with any other program not found and status 127, writes an outcome report of operation "run" if asked, and exits with the workload's status, or with fake_exit_status=STATUS. - The outcome reports of sandboxes take the form of OpenVMM's: the schema version, a random instance ID, the backend, the outcome, the network policy, and every teardown step, as JSON with a final newline. The existing knobs keep working for sandboxes, among them fake_exit_on_start, fake_guest_features, and fake_legacy_guest, which the golden scenarios and the guest-feature tests use. The fake OpenVMM tests still pass unchanged. Part of #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Record what nvx.py sandbox does, as a command, and replay it with nvx-rs in tests that need no Python, so that the CLI stays faithful to nvx.py after PR D deletes the Python code. scripts/nvx_rs_fixtures.py, the generator of #595, now also writes nvx-rs/tests/fixtures/cli.json, which its single --check covers. It builds nvx-rs and nvx-fake-openvmm with cargo and plays scenarios in fresh temporary roots, each with fake guest artifacts and a copy of nvx-fake-openvmm as the root's OpenVMM executable: - 24 usage errors and helps of the sandbox parser, 69 option and launch checks of command_sandbox, 11 dry runs, 11 one-shot runs, the lifecycles of sandboxes in every configuration format, and 8 scenarios of the refusals and failures of the lifecycle operations, 215 steps in all on POSIX and 209 on Windows, where a caller-owned share, and so format 3, is refused; - nvx-rs egress compile and nvx-rs artifacts, from the Python functions that they port, compile_policy_file and the paths of scripts/nvx_tools/common.py and guests.py. Each step records its exit status, standard output and error, and the files that it leaves, such as config.json in formats 1 to 6, runtime.json, the state directory's entries and modes, outcome reports, and the command line that start passed to OpenVMM. The generator runs nvx.py in a subprocess, with artifact_path and openvmm_binary_path patched, unbuffered and with UTF-8 standard streams, no terminal size, and no colors. It replaces the root, as printed and as JSON escapes it, process IDs, start times, and random identifiers with placeholders, and the program names of nvx.py with those of nvx-rs, whose parser names have the same length, so usage wraps alike. The posix and windows sections hold the scenarios of each kind of host, recorded there; the script rewrites or checks the section of the host that runs it and keeps the other. Recording needs CPython 3.14.5 or later, whose argparse quotes the choices of an invalid choice, as nvx-rs does. The script then plays every scenario with nvx-rs, and each lifecycle with nvx.py and nvx-rs taking turns both ways, so that each provisions, starts, executes in, stops, and deprovisions a sandbox of the other, and fails if any step differs from nvx.py. Both sections pass on Windows and Linux. nvx-rs/tests/cli.rs replays the section of its host with nvx-rs, four scenarios at a time, in canonical temporary roots. It also checks what nvx-rs adds: a start against guests that lack per-execution environments or predate the features request, the default root of the executable in a checkout, a relative NVX_OPENVMM, the top-level usage errors, and on Linux, Ctrl-C during a run, which ends OpenVMM, and during an exec, which leaves the sandbox running. .gitattributes marks the fixture, about 370 KB, as generated. This changes the generator of #595 only by adding the CLI section. Part of #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Describe in nvx-rs/README.md the nvx-rs CLI that the cli feature builds, which PR D's nvx.py will run: - its commands and how it finds the artifact root: --root, which nvx.py will pass, or else the release or checkout that holds the executable, and the NVX_OPENVMM override; - the JSON forms of artifacts and egress compile, and the contract of sandbox: its options, outputs, exit statuses, outcome reports, and the state directory that it shares with nvx.py; - the guest features that start requires, and why bits 0 and 2 are not among them; - the other intended differences from nvx.py; - the test hooks, --root, NVX_OPENVMM, and the fake OpenVMM's sandbox profile, and the golden scenarios with their interoperability checks. The layout, module, and features tables, the Python parity and testing sections, and the introduction mention the CLI, and doc/project-structure.md lists it in the nvx-rs tree and summary. Part of #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
A terminal's Ctrl-C reaches the whole foreground process group, so nvx-rs sandbox run and the OpenVMM that it runs on the terminal receive it together. run_openvmm polled OpenVMM before it checked for the interrupt, so when OpenVMM had already exited from the same Ctrl-C, the run returned OpenVMM's status instead of ending as interrupted: on Unix, a SIGINT-ended OpenVMM made nvx-rs exit with 254 and print nothing, where nvx.py prints "Interrupted" and exits with 130. Python's wait raises KeyboardInterrupt once SIGINT arrives, whether or not the child has exited meanwhile. run_openvmm now checks for the interrupt after each poll and before it reports a status, and only waits for, then kills, an OpenVMM that is still running. main() also lets a recorded Ctrl-C decide how any command ends, as KeyboardInterrupt does wherever it finds a Python command, so that a command that finished while Ctrl-C arrived still prints "Interrupted" and exits with 130. A new Linux test sends SIGINT to the CLI's whole process group, as a terminal does, during a one-shot run whose fake OpenVMM the signal ends at once. It failed with status 254 before this change, in three of three runs, and passes now; the test that interrupts the CLI alone still passes. Reported in the review of #596. Part of #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
nvx.py sandbox will run nvx-rs sandbox with the options that its own argparse front end read, and its type conversions, such as --layer, --workload-user, and the integer options, move to nvx-rs. A value that nvx-rs then refuses would read "usage: nvx-rs sandbox ..." and "nvx-rs sandbox: error: ...", where nvx.py printed the usage and name of its own parser. A new global option, --prog PROG, names the program in usage, help, and error messages, nvx-rs by default; nvx.py will pass its own name. Each parser now describes its syntax as a Syntax value, its command after the program's name, the usage parts of its options and positionals, and the help after its usage, instead of static usage and help text, and Exit::Usage and Exit::Help carry the text that the syntax formats for the program. format_usage ports argparse's HelpFormatter._format_usage for 80 columns: a usage that fits stays on one line; otherwise a name of at most three quarters of the width is followed by the options, and the positionals start a new line, while a longer name takes a line of its own. For the default name, every usage and help is byte for byte what the static text printed, which the golden scenarios check, and for nvx.py, whose name has the length of nvx-rs, the usage wraps as Python's does. The top-level usage lists the new option, so the eight golden steps whose unrecognized arguments the top-level parser reports, and the generator's copy of that usage, change accordingly. The top-level help describes --prog and wraps the help of --root as argparse wraps it; it had one line of 79 columns. Unit tests compare the wrapping of short and long names, and of usages without options, with CPython 3.14's argparse, and a CLI test checks the usage error of nvx.py sandbox for a root --workload-user, recorded from CPython, a long name, the top-level parser, and help. The README documents the option. This changes the CLI of #596; it adds the option and keeps every default output. Part of #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
nvx.py will delegate its sandbox command, and the egress policy and artifact steps of its run command, to the nvx-rs CLI (#359). The new module scripts/nvx_tools/nvx_rs.py finds that executable, builds it when it is missing, and runs it, so that the commands that follow only choose what to delegate. Discovery runs, in order: - the executable that the NVX_RS environment variable names, which must exist; - nvx-rs/target/release/nvx-rs[.exe] in the checkout, binary_path(), where a release build puts it; and - that executable after `cargo build --release --locked --features cli --manifest-path nvx-rs/Cargo.toml --target-dir nvx-rs/target`, which runs from the repository root, so that rust-toolchain.toml selects the pinned Rust release, with its target directory pinned against CARGO_TARGET_DIR. The command line goes to standard error, and so does Cargo's standard output, so that a build never mixes with the output of the command that needs the executable. Without Cargo, nvx.py fails with a ScriptError that names NVX_RS, the build command, rustup, and `nvx.py download`, which PR E makes install a release's bin/nvx-rs[.exe] at binary_path(), as it installs OpenVMM where the OpenVMM build puts it. run() runs `nvx-rs --prog PROGRAM --root ROOT ...` on nvx.py's own standard streams, with nvx.py's repository root, and returns the status that nvx.py then exits with: nvx-rs's own, 128 + N for a signal N that ends nvx-rs, as a shell reports it, and a Windows status above 2^31 as the signed value that SystemExit passes on with every Python. Ctrl-C belongs to nvx-rs, which prints Interrupted and exits with 130. While nvx-rs runs, nvx.py replaces its SIGINT handler, so that it neither raises KeyboardInterrupt, prints Interrupted again, nor kills nvx-rs before it finishes, as subprocess.run would after 0.25 s. On POSIX hosts, it hands nvx-rs the SIGINT, SIGTERM, and SIGHUP that reach nvx.py alone, which a terminal's Ctrl-C to the whole group makes a harmless repeat, including a signal that arrives before nvx-rs has started; if SIGINT ends nvx-rs before it can report it, nvx.py prints Interrupted for it. On Windows, Ctrl-C reaches every process of the console, so nvx.py only ignores it. The handlers are installed before nvx-rs starts and restored when it ends. query() runs nvx-rs for a JSON result, with standard input closed and its output captured, and raises a ScriptError with the message that nvx-rs printed after "error: ", so that nvx.py prints what it printed for the Python code that nvx-rs replaces. compile_egress_policy() and artifacts() read `nvx-rs egress compile` and `nvx-rs artifacts` into typed results and refuse JSON of another shape. NvxRsTests covers discovery and its order, the build command, its working directory, and its output, the missing-Cargo and failed-build errors, the command line, the exit statuses, and the JSON readers. Process tests run nvx_rs.run in a fresh Python with a stand-in for nvx-rs: standard streams and statuses pass through, and on POSIX hosts, SIGINT to the process group or to nvx.py alone ends the command with one Interrupted and status 130, and signals that end nvx-rs give a shell's statuses. Mocks cover an interrupted wait and the restoration of the handlers. With NVX_RS set, a test checks the JSON of the real nvx-rs against what nvx.py reads. Part of #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
nvx.py sandbox now runs `nvx-rs --prog nvx.py --root REPO sandbox` for every operation, with nvx.py's standard streams, and exits with the status of nvx-rs, which ports command_sandbox, the lifecycle of sandbox_lifecycle.py, and the control session of control_session.py (#596). Python no longer checks, converts, or runs anything for the command; the modules that nvx-rs replaced stay for the microVM tests until a later commit moves those onto nvx-rs. argparse stays the front end, so that the help, abbreviations, and the usage errors that it reports (exit 2) work as before, and the help is unchanged byte for byte at 80 and 120 columns. Every option keeps its name, metavar, choices, and help, but none has a type converter or a default: --layer, --workload-user, the integer options, --timeout, and the paths are plain strings that nvx-rs converts and checks, and nvx-rs applies the defaults, so Python duplicates neither. A _SandboxOption action records each option as nvx-rs receives it: - under its canonical name, so that `--dry` reaches nvx-rs as --dry-run, since nvx-rs does not expand abbreviations; - in command-line order, so that each --mount-deny, --mount-allow, and --mount-write follows the --mount whose path it names, which nvx-rs attributes as nvx.py did; and - with its value attached, --option=VALUE, so that a value that starts with "-" stays a value. The operation, run by default, comes first. Only options that the user gave are passed, so that the defaults live in nvx-rs alone. --prog makes the usage errors that nvx-rs reports for the values that it converts read "usage: nvx.py sandbox ..." and "nvx.py sandbox: error: ...", as argparse printed them; the name has the length of nvx-rs, so the usage wraps the same. Usage errors keep their status and form, but which of two errors in one command line is reported changes: argparse reports an invalid choice or an unrecognized argument before nvx-rs sees a value that it refuses, where it used to report whichever came first. Ctrl-C ends the command with nvx-rs's Interrupted and status 130, once. scripts/nvx_rs_fixtures.py recorded the golden fixtures of nvx-rs from the Python implementation of nvx.py sandbox, the egress compiler, and the Python semantics that the ports keep, and imported the helpers that this commit removes from nvx.py. With nvx.py running nvx-rs, the recordings are final, so the generator goes, and the fixtures stay as frozen references that a deliberate change updates by hand. Their metadata, .gitattributes, the README, the module comments that named the generator, and doc/project-structure.md say so. The CLI tests of the Python implementation go, since the golden scenarios of nvx-rs cover each of their cases, as do the systemd, environment, network-option, and policy checks, and the tests of `run` that also ran sandbox keep their `run` half. New tests check the recorded arguments for abbreviations, attached values, order, and the default operation, the call of nvx-rs and its status through main(), the usage errors that argparse keeps, a missing nvx-rs, and, through a fresh nvx.py with a stand-in for nvx-rs, shared standard streams and, on POSIX hosts, one Interrupted and status 130 after Ctrl-C to the process group. With NVX_RS set, the real nvx-rs runs a dry run of options that need an attached value, an abbreviation, and a share policy, a usage error with nvx.py's name, and a lifecycle error. Part of #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
nvx.py run keeps assembling OpenVMM's command line, but the two steps of it that Python duplicated now go through nvx-rs (#359): - --network-egress-policy-file compiles with `nvx-rs egress compile`, whose JSON lists the native rules of each action in the order in which OpenVMM receives them, in place of compile_policy_file of egress_policy.py. The option checks before it stay in nvx.py, as they belong to run's own options, and a refused policy fails with the compiler's message, as before. - The OpenVMM executable, the kernel, and the initramfs of the guest come from `nvx-rs --root REPO artifacts`, in place of openvmm_binary_path and artifact_path of common.py. nvx-rs prints absolute, unchecked paths of the same files; nvx.py still checks each with require_file, in the same order and with the same messages, and names the guest's distribution as nvx-rs prints it. The lookup runs after the egress policy compiles and before the first check, so a run that fails its checks never needs nvx-rs, and the command lines, outputs, and messages of `run` are unchanged. One visible difference: NVX_OPENVMM, which names another OpenVMM for nvx-rs, now also selects the OpenVMM of nvx.py run, as it does for nvx.py sandbox. The microVM scenarios and the build, release, and benchmark code keep using artifact_path and openvmm_binary_path: they build, package, and measure the checkout's artifacts. Tests of run that pass the option checks look up the checkout's artifacts through a mixin that answers nvx_rs.artifacts without nvx-rs. The egress test checks that run passes the compiler's rules, and with NVX_RS set, that the real nvx-rs lowers the policy as the Python compiler did and refuses a scoped IPv6 network with its message before any artifact lookup. New tests check that a refused policy fails before the lookup, and that run takes the paths that nvx-rs prints, checks them in order with nvx.py's messages, and reports a missing executable as before. Part of #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Two microVM scenarios still used Python code that nvx-rs replaced (#359), which a later commit deletes: - The L3/L4 policy, port-range, and IPv6 egress scenarios compiled their policy files with compile_policy_file of egress_policy.py to record and check the native rules that `nvx.py run` passes to OpenVMM. They now compile them with `nvx-rs egress compile`, through compile_egress_policy of scripts/nvx_tools/nvx_rs.py, which nvx.py run itself uses, so the scenarios check the rules of the compiler that runs. - The public sandbox lifecycle took the names of the state files and runtime_process_running from sandbox_lifecycle.py. The names are now its own constants, as nvx.py sandbox documents them, and openvmm_running replaces the process check with the scan that the scenario already makes: an OpenVMM process with the recorded ID whose arguments name the scenario's fixture, which no other process names. kill_openvmm takes that name too. The tests of the scenarios compile their cases with nvx-rs, through discovery, as the scenarios do: the check that each port range lowers to one native rule, the IPv6 case table, and the bounded L3/L4 policy. The port-range test, which replaces subprocess.run for the public dry runs, lets nvx-rs's own runs through, and counts only the dry runs. The L3/L4 policy tests, which replace threading.Thread, which subprocess uses on Windows, take their policy's rules from a stub, and a reservation that fails before any policy compiles. The process check of the lifecycle needs no patch of its own, since it follows the scan that the tests already replace, and the kill test ends a real process that a replaced scan reports. Part of #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The managed-lifecycle scenario of `nvx.py test-microvm` boots a guest whose init hands the boot to the managed agent, and drove its control session with control_session.py, the Python client of the protocol that #359 removes. Its checks of the protocol move to an ignored integration test, tests/managed_lifecycle.rs, which drives the session with nvx::host::session, as nvx::host::instance does for nvx-rs sandbox: - A first session pings the agent and runs a workload in /tmp with exactly EMPTY= and an entry with spaces, an equals sign, and a snowman, and the workload prints them back. - A second session reaches the same guest: an explicitly empty environment holds no variable, and an exact one holds exactly its entries. - The timeouts 0, 3600001, 86400000, and 4294967295 ms are accepted. - Workloads run as the configured identity, 65534:65534, which the scenario checked only through the refused /root before. - State persists across requests, while working directories and environments do not, and a direct-mode guest has no workload machine ID. - A workload that outlives its 100 ms timeout ends with the timeout category and status 124, and the guest stays usable. - The guest refuses /does-not-exist, /etc/passwd, and /root as working directories with the cwd-failed category, the errors 2, 20, and 13, no output, and a diagnostic that names the directory, and nothing of the refused workload runs. - Stop is acknowledged, and OpenVMM exits with status 0. The test starts OpenVMM itself, with the command line, endpoint, timeout, and output directory that the scenario passes in NVX_MANAGED_LIFECYCLE_* variables, which the next commit sets. OpenVMM reads its capability from a pipe that already holds it and its end, as nvx::host::process::spawn prepares it (#440), writes its output to managed-lifecycle.log, and is killed if the test fails. The guest console of a microVM listens on TCP, and the test copies it, from the address in NVX_MANAGED_LIFECYCLE_CONSOLE, to managed-lifecycle-guest.log until OpenVMM closes it, so that the scenario can scan it. The test records OpenVMM's process ID in NVX_ADVERSARIAL_OPENVMM_PID_JOURNAL, as the scenario did, for the adversarial campaigns. Like the Python client, it bounds each exchange with the scenario's timeout. Part of #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The managed-lifecycle scenario was the last user of control_session.py, the Python client of the control protocol, which #359 leaves to nvx-rs alone. Its orchestration stays in Python, and its protocol checks run in nvx-rs's ignored test tests/managed_lifecycle.rs: - run_managed_lifecycle builds OpenVMM's command line as before, with absolute paths, since Cargo runs the test in the nvx-rs directory, and runs `cargo test --manifest-path nvx-rs/Cargo.toml --locked --test managed_lifecycle -- --ignored --nocapture` from the repository root, so that rust-toolchain.toml selects the Rust release, as test-aci-edge-sandboxes runs the aci_edge_sandboxes test. Without Cargo, it fails and says why. - The guest console still listens on TCP, since a microVM console takes no file, and the test, which starts OpenVMM, copies it to managed-lifecycle-guest.log. The time ABI monitor scans that log once the test ends, so that a violation still fails the run, before the test's failure, which it may explain. - The scenario still checks and keeps managed-outcome.json, which the adversarial campaigns collect, and that OpenVMM refuses the managed lifecycle without a control endpoint. The checks of OpenVMM's report that also hold for the layered root now run through nvx.py sandbox too: the sandbox-lifecycle scenario checks that the report of a stopped sandbox names the backend, that no network policy was requested, and that every teardown step completed, besides the managed success that it checked. managed-exec-config already covers the working directories, environments, timeouts, and refused directories for the layered root. The unit tests replace the capability-pipe test of the Python spawn with tests of the Cargo command, its environment, the absolute paths, the missing Cargo, a failed test, a violation on the console, an invalid report, and an unrefused transition. The sandbox stand-in writes a full report, and three new regressions show that the lifecycle acceptance catches another backend, a requested network policy, and an incomplete teardown. The structural test of the TCP consoles counts five, and checks that the managed lifecycle monitors its file console. Part of #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
nvx.py now runs nvx-rs for the sandbox command and for the egress and artifact steps of run, and the microVM tests drive the control protocol through nvx-rs, so the Python implementations that nvx-rs ported have no caller left. #359 asks that Python duplicate none of the control-protocol, lifecycle, or egress logic, so they go: - scripts/nvx_tools/control_session.py, the control-session client, which nvx::host::session and the CLI's session replace, and its tests, scripts/test_control_session.py, which nvx-rs ported to src/host/cli/session/tests.rs; - scripts/nvx_tools/egress_policy.py, the egress policy compiler, which nvx::host::network::policy and `nvx-rs egress compile` replace, and its tests, scripts/test_egress_policy.py, which nvx-rs ported to src/host/network/policy/tests.rs; - scripts/nvx_tools/sandbox_lifecycle.py, the managed lifecycle, which the nvx-rs sandbox command replaces, all of it; - the launch profile of scripts/nvx_tools/sandbox.py: SandboxLaunch, SandboxLayer, SandboxMount, the workload identity, the reserved targets, and the checks of shares, which nvx::host::sandbox replaces. sandbox.py keeps what `nvx.py run` builds its own OpenVMM command line with, as #359 leaves that assembly in Python: the --mount values, the names, arguments, and kernel command-line tokens of the aggregate of several shares, the mount owners and access-policy options, and the kernel command-line budget. Its docstring says so. The unit tests of the deleted code go with it: the managed lifecycle, lock, state, and process-identity tests and the launch-profile tests of SandboxTests, the helpers that only they used, and ControlSessionTests of test_microvm_tests.py. What remains of SandboxTests tests the share helpers of run, as RunShareTests, with the --mount parser and mount kind tested directly rather than through SandboxMount. The Validate NVX action no longer runs the two deleted test modules, and the check that it runs every scripts/test_*.py module looks for one that still exists. nvx-rs comments and README sections that named the Python files now call them former, and keep why the ports match them: the state directories, the lifecycle lock, and the rule output that an older nvx.py shares with nvx-rs. doc/project-structure.md drops the deleted modules. Part of #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Open PR #579 (jay23606, fixes #486) reserves the guest paths that the container runtime of a layered sandbox creates or binds over: it creates /run and /run/nvx below the container root, and binds its time ABI state over /run/nvx/time. A live share at one of them would be shadowed, or would expose host data to the runtime's writes. #579 changes scripts/nvx_tools/sandbox.py, which #359 retires, so this ports its behavior to the launch profile that replaced it, as the maintainer asked while #579 is open: - nvx::host::sandbox::mount: RESERVED_MOUNT_TARGETS gains the /run/nvx/time tree, and RESERVED_EXACT_MOUNT_TARGETS /run and /run/nvx, with #579's reasons. validate_mount_target refuses them with the messages that the Python check gave: `sandbox mount target /run is reserved`, and `... overlaps the reserved /run/nvx/time tree` for the tree. Other paths below /run, such as /run/secrets, /run/nvx/other, or /run/nvx/timer, stay valid. - guest/common/nvx-init-agent refuses the same targets, as #579 does, so that a guest refuses them even from a host that would not. - The guest-agent test of aggregate children gains #579's rows for /run, /run/nvx, and /run/nvx/time/state. - doc/usage.md and doc/run.md list the new reserved targets, which #579 left undocumented. tests/fixtures/sandbox.json, which the retired Python launch profile recorded, held nine targets and four shares that #579 now refuses. Its records of those cases are re-evaluated with that Python code, taken from the commit before #359 retired it, with #579's change to sandbox.py applied: 7 targets at /run, one at /run/nvx, and one at /run/nvx/time become refusals, as do 4 shares at /run, and no other record changes. The Rust unit tests check the new refusals, and paths beside them that stay valid. Part of #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Open PR #576 (jay23606, refs #556) narrows the sandbox to one EROFS distro layer and the scratch disk: the runtime and custom layers go, and their MMIO windows and interrupts stay unassigned. #576 changes scripts/nvx_tools/sandbox.py, which #359 retires, so this ports its behavior to the launch profile that replaced it, as the maintainer asked while #576 is open, with the messages and checks of #576: - nvx::host::sandbox::layer: LAYER_ROLES, the roles that --layer accepts and a launch holds, is distro alone, so `--layer runtime,...` fails with `unsupported layer role 'runtime'; choose distro`, as does any other role. LayerRole keeps the retired Runtime and Custom roles, which from_name still reads, because the configuration of a sandbox that an older nvx.py provisioned may name them. - SandboxLaunch::new checks the duplicate roles first, over every role, and then requires exactly one layer, the distro layer: `a sandbox requires exactly one read-only distro layer`, which replaces `one to three read-only layers`. As in #576, the check holds for a saved launch as much as for parsed options, so `nvx.py sandbox start` of such a sandbox fails before OpenVMM starts. - guest/common/nvx-init-agent mounts and unmounts only the distro layer, and refuses a second layer: #576's change, verbatim. - The sandbox-blocks scenario attaches the distro layer and scratch, and its guest script checks for only vda and vdb, for no vdc, and that the cmdline assigns neither 0xd0004000 nor 0xd0005000, with #576's codes 30, 31, and 32. Its unit test expects two blocks. - doc/run.md and doc/usage.md describe one distro layer, as in #576. The golden records follow. In tests/fixtures/sandbox.json, the layer and launch cases are re-evaluated with the retired Python launch profile, from the commit before #359 retired it, with #576's and #579's changes to sandbox.py applied. 168 layer values of the retired roles, 146 of which parsed and 22 of which had an invalid UUID, become refusals of their role, which is checked first; the refusals of 28 values of unknown roles list only distro; and 136 launches with retired roles now fail with the duplicate roles or the distro-layer requirement, which come before their other checks. No other record changes. The launch harness builds a retired role's layer as a saved configuration names it, since --layer no longer parses it. In tests/fixtures/cli.json, by hand, for POSIX and Windows alike: - `validation: invalid layer role` lists only distro, and a new `validation: retired layer role` shows `--layer runtime,...` refused with the same usage error; - `dry run: every layer and option` takes the distro layer alone, so its OpenVMM command loses the runtime and custom blocks and their nvx_layer tokens; - `lifecycle: network and launch options` provisions the distro layer alone, so its config.json and its OpenVMM arguments lose the runtime layer. A new CLI test edits a provisioned sandbox's configuration to add a runtime layer, as an older nvx.py wrote it, and checks that start refuses it with status 1 and the message above, leaving no runtime state, and that deprovision still removes it. Part of #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
nvx.py now runs nvx-rs for its sandbox command and for the egress and artifact steps of its run command, and the microVM jobs drive both, in their scenarios and in the steps that call nvx.py sandbox directly. Without an executable, nvx.py would build one with Cargo in every microVM job, so CI builds it once per operating system and hands it over, as it hands over OpenVMM: - The NVX CLI jobs, which every microVM job of their operating system already waits for, build it with `--release --locked --features cli` before the CLI tests, at nvx-rs/target/release/, where nvx.py looks for it, so the tests run with it rather than building it. Linux builds a static x86_64-unknown-linux-musl executable, which runs on every self-hosted Linux runner whatever its C library, and Windows an executable with a static C runtime, which needs no Visual C++ redistributable on the runners. The Windows job already installs the pinned Rust release with the minimal profile. - After the tests pass, each job uploads its executable as a one-day artifact: nvx-rs-linux-musl-executable or nvx-rs-windows-msvc-executable. - run-nvx-microvm-tests.yml takes the artifact's name as a new required input, nvx-rs-artifact, which every microVM job, the debug-kernel ones included, sets for its operating system. It downloads the executable into nvx-rs/target/release/ and restores its executable permission on Linux, before any step runs nvx.py. The platform jobs run neither nvx.py sandbox nor nvx.py run, so they take nothing. The change classification needs no change: nvx-rs/, nvx.py, scripts/nvx_tools/, and these workflows are already test inputs. A new workflow test checks the builds, their targets and their order around the CLI tests, the artifact names and paths, that each microVM job takes the executable of its operating system and the platform jobs none, that the reusable workflow downloads it where nvx_rs.binary_path() looks, and that it does so before it runs nvx.py. The handoff test counts the microVM workflow's fifth download. doc/ci.md describes the handoff, and notes that the Copilot setup does not build nvx-rs, which nvx.py builds on first use. Part of #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
nvx.py now runs nvx-rs for its sandbox command and for the egress and artifact steps of its run command, and test-microvm drives the managed lifecycle through an nvx-rs test, so the documentation says where the executable comes from, what needs Cargo, and what the delegation keeps or changes: - doc/usage.md gains an nvx-rs section: NVX_RS, then nvx-rs/target/release/nvx-rs[.exe], then a Cargo build with the pinned release and its output on standard error, the failure that names these choices without Cargo, and that nvx.py does not rebuild an existing executable after a change. The sandbox section gives the exit statuses and says which usage errors argparse still reports, that abbreviations still work, and that nvx-rs's usage wraps at 80 columns; run says which steps nvx-rs performs; test-microvm describes the managed-lifecycle scenario's nvx-rs test and its need for Cargo. - README.md and doc/setup.md list rustup as a prerequisite of nvx.py run and nvx.py sandbox, whose nvx-rs nvx.py builds on first use until releases include it, and doc/run.md points the sandbox section at the nvx-rs section. - doc/contribute.md says that the control protocol, lifecycle, and egress logic belong to nvx-rs, which the microVM scenarios drive as commands, and that no Python client of them may return. - doc/project-structure.md lists scripts/nvx_tools/nvx_rs.py and the hypervisor test among nvx-rs's tests. - nvx-rs/README.md says how nvx.py finds and invokes the CLI, and describes tests/managed_lifecycle.rs. - .github/copilot-instructions.md tells cloud sessions that nvx.py builds nvx-rs on first use, how to rebuild it after a change, and that the managed-lifecycle scenario needs Cargo. Part of #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Pedro Henrique Penna (ppenna)
requested review from
Enrique Saurez (esaurez)
and
a balanced review from Copilot
October 17, 2026 08:13
This was referenced Oct 17, 2026
Contributor
There was a problem hiding this comment.
🔵 Needs a closer look
The cross-platform runtime and guest-build migration needs human review and complete CI validation, including the currently skipped real-binary integration checks.
1 open issue
What changed in this PR
Moves NVX runtime responsibilities into nvx-rs as part of #359, reducing duplicated Python logic while preserving the command-line contract.
Changes:
- Adds Rust CLI support, compatibility helpers, and cross-platform process handling.
- Integrates Rust guest binaries into builds, provenance, and packaging.
- Updates sandbox restrictions, CI executable handoff, tests, and documentation.
| File | Description |
|---|---|
| THIRD_PARTY_NOTICES.md | Documents Rust guest dependencies and licenses. |
| SOURCE-MANIFEST.json | Records Rust toolchain and guest sources. |
| scripts/test_tool_versions.py | Checks musl target installation. |
| scripts/test_guest_binaries.py | Tests source identity and staged-build validation. |
| scripts/setup/setup-linux-runner.sh | Explicitly selects the target toolchain. |
| scripts/setup/setup-linux-mshv.sh | Installs and verifies the musl target. |
| scripts/nvx_tools/ubuntu.py | Includes Rust sources in provenance. |
| scripts/nvx_tools/sandbox_lifecycle_tests.py | Replaces lifecycle imports and strengthens report checks. |
| scripts/nvx_tools/release.py | Validates Rust source metadata. |
| scripts/nvx_tools/microvm_test_scripts/sandbox-blocks.sh | Checks distro and scratch devices only. |
| scripts/nvx_tools/guest_binaries.py | Adds source-root support and staged-build inspection. |
| scripts/nvx_tools/ci.py | Tracks Rust guest inputs for CI selection. |
| scripts/nvx_tools/build.py | Installs Rust guest binaries. |
| scripts/nvx_tools/build_constants.py | Defines Rust build and packaging inputs. |
| scripts/nvx_tools/benchmark.py | Records Rust guest provenance. |
| scripts/nvx_tools/azurelinux.py | Includes Rust build inputs in provenance. |
| scripts/nvx_tools/archive.py | Excludes generated Rust build outputs. |
| README.md | Explains Rust binaries and first-use builds. |
| nvx-rs/src/protocol/tests.rs | Pins wire values and layered feature requirements. |
| nvx-rs/src/protocol/mod.rs | Documents shared Rust protocol definitions. |
| nvx-rs/src/protocol/features.rs | Defines layered sandbox feature requirements. |
| nvx-rs/src/host/sys/mod.rs | Exposes Unix interrupt handling. |
| nvx-rs/src/host/sys/interrupt.rs | Records Unix interrupts for CLI polling. |
| nvx-rs/src/host/session.rs | Exposes request and response primitives. |
| nvx-rs/src/host/sandbox/layer.rs | Implements distro-only layer parsing. |
| nvx-rs/src/host/python/uuid.rs | Preserves Python UUID parsing behavior. |
| nvx-rs/src/host/python/shell.rs | Implements Python-compatible command quoting. |
| nvx-rs/src/host/python/mod.rs | Organizes Python compatibility helpers. |
| nvx-rs/src/host/python/dumps.rs | Implements Python-compatible JSON output. |
| nvx-rs/src/host/process.rs | Adds CLI process waiting and termination. |
| nvx-rs/src/host/platform/windows.rs | Adds Windows process, interrupt, and lock helpers. |
| nvx-rs/src/host/platform/other.rs | Supplies unsupported-platform interrupt behavior. |
| nvx-rs/src/host/platform/linux.rs | Exposes Linux interrupt helpers. |
| nvx-rs/src/host/mod.rs | Exposes CLI and sandbox modules. |
| nvx-rs/src/host/cli/sandbox/identity.rs | Checks recorded OpenVMM process identities. |
| nvx-rs/src/host/cli/output.rs | Preserves CLI stream formatting. |
| nvx-rs/src/host/cli/egress.rs | Adds egress compilation commands. |
| nvx-rs/src/host/cli/deadline.rs | Adds interruptible deadline handling. |
| nvx-rs/src/host/cli/artifacts.rs | Locates artifacts and emits JSON paths. |
| nvx-rs/src/guest/time/mod.rs | Updates historical implementation comments. |
| nvx-rs/src/guest/time_probe/mod.rs | Updates historical implementation comments. |
| nvx-rs/src/guest/reseed.rs | Clarifies existing behavior in comments. |
| nvx-rs/src/bin/nvx-rs.rs | Adds the CLI entry point. |
| nvx-rs/Cargo.toml | Enables CLI features and dependencies. |
| guest/common/nvx-reseed.c | Removes the replaced C implementation. |
| guest/common/nvx-mmio-write.c | Removes the replaced C implementation. |
| guest/common/nvx-init-agent | Restricts layers and reserves runtime share targets. |
| guest/common/nvx-exit.c | Removes the replaced C implementation. |
| guest/common/nvx-console-pending.c | Removes the replaced C implementation. |
| docker/Dockerfile | Builds and installs shared Rust guest binaries. |
| doc/usage.md | Documents delegation and sandbox restrictions. |
| doc/setup.md | Documents Rust build prerequisites. |
| doc/run.md | Updates sandbox layers and reserved targets. |
| doc/project-structure.md | Describes the Rust-backed repository structure. |
| doc/guests/ubuntu-guest.md | Documents Rust guest installation and provenance. |
| doc/guests/azurelinux-guest.md | Documents Rust guest installation and provenance. |
| doc/contribute.md | Explains runtime ownership and test placement. |
| doc/ci.md | Documents guest caches and executable handoff. |
| doc/build.md | Documents Rust builds and packaging. |
| doc/benchmarks.md | Updates guest-binary provenance documentation. |
| aci_edge_sandboxes/src/openvmm/mod.rs | Documents shared lowering and tests its errors. |
| aci_edge_sandboxes/README.md | Explains egress lowering compatibility changes. |
| .github/workflows/run-nvx-microvm-tests.yml | Downloads the platform’s Rust CLI executable. |
| .github/workflows/copilot-setup-steps.yml | Tracks all Rust source changes. |
| .github/workflows/ci.yml | Builds and distributes platform CLI executables. |
| .github/copilot-instructions.md | Documents Rust prerequisites and rebuild guidance. |
| .github/actions/validate-nvx/action.yml | Removes retired Python test suites. |
| .github/actions/build-guest-artifacts/action.yml | Keys guest caches on Rust source identity. |
| .gitattributes | Marks frozen compatibility fixtures as generated. |
Review effort: Balanced
Three CLI tests run the real nvx-rs only when NVX_RS names it: the egress compilation of nvx.py run, the JSON that nvx.py reads from nvx-rs, and the options that nvx.py sandbox passes. The NVX CLI jobs built nvx-rs where nvx.py finds it, but set no NVX_RS, so those tests skipped and the executables that the jobs hand to the microVM jobs left unexercised by them (review of #597). Each Build nvx-rs step now exports NVX_RS for the rest of its job, naming the executable that it built: with `>> "${GITHUB_ENV}"` on Linux, and on Windows with `Out-File -Encoding utf8 -Append`, as setup-curl writes GITHUB_ENV under Windows PowerShell. The workflow test checks both, and doc/ci.md says so. Part of #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The adversarial executor runs each scenario with an allowlist of environment variables, which left out everything that nvx-rs needs now that the control protocol and the egress compilation live there. Of the catalogued scenarios, managed-lifecycle runs the nvx-rs test managed_lifecycle with Cargo, and l3-l4-egress-policy compiles its policy with nvx-rs and runs nvx.py run, which runs nvx-rs too. Under the executor, they ignored a prebuilt nvx-rs that NVX_RS named, and they could not find a Rust toolchain installed outside the default rustup and Cargo homes: without RUSTUP_HOME, rustup starts installing the pinned release into the home directory, within the scenario's timeout. The allowlist now passes NVX_RS, CARGO_HOME, and RUSTUP_HOME, which are paths, not secrets. It still drops RUSTUP_TOOLCHAIN, so that rust-toolchain.toml selects the release, and Cargo's other settings and credentials, such as CARGO_TARGET_DIR, RUSTFLAGS, and registry tokens. Tests check both, and that nvx.py finds NVX_RS in a process that has only the executor's environment. The rest of the executor's contract holds, and is now tested: - managed-lifecycle still preserves OpenVMM's report as managed-outcome.json, with its teardown fields, in the scenario's output directory, where the executor's outcome inventory requires it. A test checks the preserved report against that inventory. - The nvx-rs test records OpenVMM in the journal that NVX_ADVERSARIAL_OPENVMM_PID_JOURNAL names, as the Python scenarios do, but it runs in nvx-rs/, so the scenario now hands it an absolute path: a relative journal resolves where nvx.py runs, as for the Python scenarios. The test reads the variable as an OsString, so a path that is not UTF-8 is no longer skipped, and a new test that needs no hypervisor checks that its entries are exactly what the executor reads. doc/adversarial-testing.md describes what a target needs: the Rust toolchain, with Cargo on the executor's PATH, and nvx-rs and the managed-lifecycle test built at the staged commit before exposure, since a scenario's timeout includes any build, nvx.py never rebuilds an existing nvx-rs, and the target may have no network. doc/ci.md and doc/setup.md point there. The nvx-adversary agent is the strategist's prompt, which names no host prerequisites, so it is unchanged. Verified by driving the local executor on a Linux/MSHV host through the managed-control-lifecycle and l3-l4-network-boundary cases of workload-isolation, with source verification, provenance, and the baseline and post-campaign boots passing: once with NVX_RS naming an nvx-rs outside the checkout, which the scenario ran without building, and once without it, when the scenario built nvx-rs with Cargo. Every journal held the nvx-rs test's OpenVMM, and the inventory found managed-outcome.json with complete teardown in each repetition. On Windows, with HOME and USERPROFILE pointing at an empty directory, the executor's environment built nvx-rs and the managed_lifecycle test through CARGO_HOME and RUSTUP_HOME, with the release that rust-toolchain.toml pins despite a RUSTUP_TOOLCHAIN of 1.89; without those two variables, rustup began installing the release into the empty home. Part of #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Pedro Henrique Penna (ppenna)
added a commit
that referenced
this pull request
Oct 17, 2026
The executor validates and hashes the nvx-rs that NVX_RS names before the baseline boot, and nvx.py resolves a relative NVX_RS against its working directory: the executor's, at that point. The scenarios inherit NVX_RS unchanged but run in the repository root, so with a relative NVX_RS they ran another executable than the one validated, or found none. sanitized_environment() now makes NVX_RS absolute against the executor's working directory, so that the scenarios run the executable that the initialization checked. An empty NVX_RS, which nvx.py ignores, passes unchanged. A new test validates a relative NVX_RS in one working directory and resolves it in a scenario process in another; it fails without the fix. The environment test now expects the absolute path. Copilot's review of #598 flagged this. #597 added NVX_RS to the scenarios' environment, and the earlier commit of this series that validates the executor's nvx-rs made the mismatch matter. Closes #359. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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.

Part of #359 (PR 4 of 5).
Summary
nvx.pynow runs thenvx-rsCLI of #596 for its wholesandboxcommand and for the egress-policy and artifact steps ofrun, and the Python implementations thatnvx-rsreplaced are gone: the control-protocol client, the egress compiler, the managed lifecycle, and the sandbox launch profile.test-microvmkeeps its orchestration in Python but no longer speaks the control protocol: the egress scenarios compile withnvx-rs egress compile, the public sandbox scenarios runnvx.py sandbox, andmanaged-lifecycleruns a new ignorednvx-rshypervisor test. CI buildsnvx-rsonce per OS in the NVX CLI jobs and hands it to the microVM jobs, as it hands over OpenVMM. The PR also ports the open PRs #576 and #579 into the Rust launch profile.How
nvx.pyfindsnvx-rs(scripts/nvx_tools/nvx_rs.py; documented in thenvx-rssection ofdoc/usage.md):NVX_RSnames;nvx-rs/target/release/nvx-rs[.exe], wherecargo build --releaseputs it, and where PR E'snvx.py downloadshould install a release'sbin/nvx-rs[.exe](nvx_rs.binary_path()is the hook);cargo build --release --locked --features cli --manifest-path nvx-rs/Cargo.toml --target-dir nvx-rs/target, from the repository root, with Cargo's output on standard error so that it never mixes with the command's output. Without Cargo, aScriptErrornamesNVX_RS, the build command,nvx.py download, and rustup.nvx.py sandboxkeeps argparse as its front end, for--help(byte-identical at 80 and 120 columns), abbreviations, and the usage errors of unknown options, invalid choices, and missing values. It drops every converter and check thatnvx-rsperforms, rebuilds the canonical argv from the options the user gave (in full,--name=value, in command order), and runsnvx-rs --root REPO --prog "nvx.py sandbox" sandbox OPERATION ...with inherited stdio, passing its exit status through. The new global--progoption ofnvx-rsmakes its usage and errors namenvx.py sandbox. Ctrl-C reachesnvx-rs, which prints the onlyInterruptedand exits with 130, whichnvx.pyreturns unchanged.What moved where
scripts/nvx_tools/control_session.pynvx::host::sessionandnvx::host::cli::session(#596), whichnvx-rs sandboxandnvx-rs/tests/managed_lifecycle.rsusescripts/nvx_tools/egress_policy.pynvx::host::network::policyandnvx-rs egress compile, whichnvx.py runand the egress scenarios runscripts/nvx_tools/sandbox_lifecycle.py(all of it)nvx-rs sandbox(nvx::host::cli::sandbox);sandbox_lifecycle_tests.pyuses local state-file names and a process scan insteadscripts/nvx_tools/sandbox.py:SandboxLaunch,SandboxLayer,SandboxMount, the workload identity, the reserved targets, the share checksnvx::host::sandbox(#595).sandbox.pykeeps only the share helpers with whichnvx.py runbuilds its own OpenVMM commandcommand_sandboxofscripts/nvx.pyand its helpersscripts/nvx_tools/nvx_rs.pynvx.py run's artifact lookups and egress compilationnvx-rs --root REPO artifactsandnvx-rs egress compile PATH(JSON);run's OpenVMM command assembly stays in Pythonscripts/test_control_session.py,scripts/test_egress_policy.pynvx-rs/src/host/cli/session/tests.rsandnvx-rs/src/host/network/policy/tests.rsscripts/nvx_rs_fixtures.py(the fixture generator)nvx-rs/tests/fixtures, which a behavior change now updates by hand (see below)test_nvx_tools.pyandtest_microvm_tests.pyrunshare tests remain asRunShareTestsWhere each
managed-lifecycleassertion wentThe scenario still boots a guest whose init hands the boot to the managed agent; the ignored test
nvx-rs/tests/managed_lifecycle.rsnow drives it. CI runs the scenario for Alpine (default suite, every backend), Azure Linux (every backend), and Ubuntu (KVM extended).run_managed_lifecyclenvx.py sandbox)/tmpwith exactlyEMPTY=and an entry with spaces,=, and a snowmanmanaged-exec-config(--cwd,--environment)nvx.py sandbox execis a new session (sandbox-lifecycle)managed-exec-config(empty and exact environment files)managed-exec-config/run/nvx/workload-machine-idsandbox-lifecyclewarm statesleep 5with category timeout and status 124, guest still usablemanaged-exec-configtimeout outcomecwd-failed, errors 2, 20, 13, no output, a diagnostic naming the directory, nothing of the workload runsmanaged-exec-configrefused directoriessandbox-lifecyclestop/rootrefusal)managed-exec-configidentity probenvx-rs sandbox startmanaged-outcome.jsonsandbox-lifecyclestop now checks backend, network policy, and teardown too (new)Stack
Based on #596 (
e74e3c677), #595, #594, and #561; it contains their commits until they merge. This PR's commits:nvx-rs: name the calling program in usage messages(the--proghook)scripts: find, build, and run nvx-rs for nvx.pynvx.py: run the sandbox command with nvx-rsnvx.py: look up run's artifacts and egress rules with nvx-rstest: compile the microVM egress policies with nvx-rsnvx-rs: test the direct-mode managed lifecycle on a hypervisortest: run the managed lifecycle through nvx-rsscripts: delete the Python duplicates of nvx-rsnvx-rs: reserve /run, /run/nvx, and /run/nvx/time as share targets(fix(sandbox): reserve /run, /run/nvx, and the /run/nvx/time tree as share targets #579)nvx-rs: accept only the distro layer and the scratch disk(sandbox: accept only the distro layer and scratch #576)ci: hand the nvx-rs executable to the microVM jobsdoc: describe how nvx.py finds, builds, and runs nvx-rsci: name the nvx-rs build for the CLI tests that run it(review)adversarial: run nvx-rs scenarios with Cargo and NVX_RS: the adversarial executor's environment allowlist passesNVX_RS,CARGO_HOME, andRUSTUP_HOME(notRUSTUP_TOOLCHAIN),managed-lifecyclehands the nvx-rs test an absolute PID journal, and doc/adversarial-testing.md lists what a target needsCommits 1, 9, and 10 change code that #596 and #595 own: the CLI's usage plumbing, the launch profile, and the golden records. Each commit body explains why.
Contract notes
nvx.py sandboxoptions, stdout and stderr, exit codes (1, 2, 130, and the workload's or OpenVMM's status), the outcome reports, and the state-directory layout are unchanged; the golden scenarios ofnvx-rs/tests/cli.rsreplay them.nvx-rsreports, such as an invalid--layeror--workload-user, printnvx-rs's usage, which namesnvx.py sandboxbut wraps at 80 columns whatever the terminal's width. The usage errors of unknown options, invalid choices, and missing values still come from argparse, and so now come before the value errors of other options.nvx.pypasses them tonvx-rsin full.nvx.py runandnvx.py sandboxnow neednvx-rs. Until PR E ships it in releases, a downloaded checkout builds it with Cargo on first use; README.md and doc/setup.md say so.NVX_OPENVMM, the OpenVMM override ofnvx-rs, now also affectsnvx.py run's artifact lookup.managed-lifecyclescenario needs Cargo, astest-aci-edge-sandboxesdoes.--layer runtime,...orcustom,...fails withunsupported layer role 'runtime'; choose distro, and a sandbox whose saved configuration names a retired role failsstartwitha sandbox requires exactly one read-only distro layer./run,/run/nvx, and the/run/nvx/timetree are reserved share targets.sandbox.json's affected cases were re-evaluated with the retired Python launch profile from the commit before this PR, patched with sandbox: accept only the distro layer and scratch #576's and fix(sandbox): reserve /run, /run/nvx, and the /run/nvx/time tree as share targets #579's changes tosandbox.py(no other record changed);cli.jsonchanges three scenarios by hand and addsvalidation: retired layer role, on both platforms.#576 and #579
Both were still open when this PR was opened, so this PR implements their behavior, as the maintainer asked: in the Rust launch profile with their messages and check order, in
guest/common/nvx-init-agentverbatim, and, for #576, insandbox-blocks.sh, its scenario and unit test, and the docs. It also documents #579's reserved targets in doc/usage.md and doc/run.md. I commented on both PRs: #576 (comment) and #579 (comment).Validation
nvx.py verify; nvx-rs: fmt, clippy (all features, and lib without defaults), tests (all features and default), rustdoc with-D warnings, 1.89 check, the bundled-artifacts test,aarch64-apple-darwincheck; aci_edge_sandboxes: the commands of check-aci-edge-sandboxes andtest-agent-bundling; Ruff, Ruff format, Pyright (Linux and Windows), the Markdown link check, compileall, the validate-nvx unittest list,test_adversarial.py,test_hosts.pycheck-guest-binaries, and the validate-nvx list on Python 3.14, 3.12, and 3.10.test_adversarial.pyfails one subtest on 3.10 (test_contained_native_process_preserves_child_signal_status, sigkill), as it does onorigin/dev; it passes on 3.12, which CI usesnvx-adversarial-controllerlabel)test-microvmscenariossandbox-lifecycle,managed-exec-config,managed-lifecycle,l3-l4-egress-policy,l3-l4-egress-port-ranges,l3-l4-egress-protocols,ipv6-egress-policy,directional-network-policy,sandbox-blocks,filesystem-comma-paths: pass;managed-lifecyclefor Azure Linux and Ubuntu: pass;test-aci-edge-sandboxes: 18 passedworkload-isolation'smanaged-control-lifecycleandl3-l4-network-boundary: initialize (verify, provenance, baseline boot), both cases, and finalize (post-campaign boot) pass, once withNVX_RSnaming an nvx-rs outside the checkout and once building nvx-rs with Cargo under the executor's environment; every PID journal and outcome inventory completeHardware artifacts: OpenVMM at the pinned
5ab8a84and the kernel from #594's validation, the Ubuntu and Azure Linux images and distro layer from #561's, and an Alpine initramfs rebuilt natively from this branch, so the hosts ran the new guest agent;nvx-rswas the static musl build that CI hands to Linux runners, and on Windows the static-CRT build that CI makes.