Repository navigation
Add the nvx-rs CLI - #596
Open
Pedro Henrique Penna (ppenna) wants to merge 24 commits into
Open
Pedro Henrique Penna (ppenna) wants to merge 24 commits into
Pedro Henrique Penna (ppenna) wants to merge 24 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>
Pedro Henrique Penna (ppenna)
requested review from
Enrique Saurez (esaurez)
and
a balanced review from Copilot
October 17, 2026 04:43
Contributor
There was a problem hiding this comment.
🔵 Needs a closer look
Cross-platform lifecycle and guest-build changes need human review, and the Ctrl-C result-reporting issue remains unresolved.
1 open issue
What changed in this PR
Adds the nvx-rs CLI as groundwork for replacing Python runtime logic in #359. The stacked changes also migrate guest builds to Rust.
Changes:
- Adds artifact lookup, egress compilation, and sandbox commands with Python-compatible behavior.
- Adds process control, interrupt handling, and layered-sandbox feature requirements.
- Updates guest builds, provenance, tests, and documentation.
| File | Description |
|---|---|
| THIRD_PARTY_NOTICES.md | Adds Rust runtime and dependency notices. |
| SOURCE-MANIFEST.json | Records Rust guest inputs. |
| scripts/test_tool_versions.py | Checks musl target installation. |
| scripts/test_guest_binaries.py | Expands build and provenance tests. |
| scripts/setup/setup-linux-runner.sh | Explicitly selects target toolchain. |
| scripts/setup/setup-linux-mshv.sh | Installs and verifies musl target. |
| scripts/nvx.py | Adds guest source-identity command. |
| scripts/nvx_tools/ubuntu.py | Includes Rust inputs in provenance. |
| scripts/nvx_tools/release.py | Validates Rust source metadata. |
| scripts/nvx_tools/guest_binaries.py | Adds staged-build validation. |
| scripts/nvx_tools/ci.py | Tracks Rust guest test inputs. |
| scripts/nvx_tools/build.py | Installs shared Rust guest binaries. |
| scripts/nvx_tools/build_constants.py | Defines Rust build and packaging inputs. |
| scripts/nvx_tools/benchmark.py | Records Rust helper provenance. |
| scripts/nvx_tools/azurelinux.py | Includes Rust build inputs. |
| scripts/nvx_tools/archive.py | Excludes generated Rust targets. |
| README.md | Describes Rust guest binaries. |
| nvx-rs/src/protocol/tests.rs | Pins wire values and feature requirements. |
| nvx-rs/src/protocol/mod.rs | Updates shared-protocol documentation. |
| nvx-rs/src/protocol/features.rs | Defines layered-sandbox requirements. |
| nvx-rs/src/host/sys/mod.rs | Exposes Unix interrupt helpers. |
| nvx-rs/src/host/sys/interrupt.rs | Records Unix Ctrl-C signals. |
| nvx-rs/src/host/session.rs | Exposes request and response operations. |
| nvx-rs/src/host/sandbox/layer.rs | Implements sandbox layer parsing. |
| nvx-rs/src/host/python/value.rs | Implements Python value conversions. |
| nvx-rs/src/host/python/uuid.rs | Implements Python-compatible UUID parsing. |
| nvx-rs/src/host/python/text.rs | Preserves Python string semantics. |
| nvx-rs/src/host/python/shell.rs | Formats displayed command lines. |
| nvx-rs/src/host/python/mod.rs | Organizes Python compatibility helpers. |
| nvx-rs/src/host/python/dumps.rs | Produces Python-compatible JSON. |
| nvx-rs/src/host/process.rs | Adds child wait and termination helpers. |
| nvx-rs/src/host/platform/windows.rs | Adds waits, interrupts, and byte locks. |
| nvx-rs/src/host/platform/other.rs | Provides interrupt compatibility hooks. |
| nvx-rs/src/host/platform/linux.rs | Exposes Linux interrupt handling. |
| nvx-rs/src/host/mod.rs | Registers CLI and sandbox modules. |
| nvx-rs/src/host/cli/sandbox/identity.rs | Checks persisted process identities. |
| nvx-rs/src/host/cli/output.rs | Preserves Python stream formatting. |
| nvx-rs/src/host/cli/mod.rs | Dispatches commands and reports results. |
| nvx-rs/src/host/cli/egress.rs | Adds egress compilation command. |
| nvx-rs/src/host/cli/deadline.rs | Adds interruptible deadline helpers. |
| nvx-rs/src/host/cli/artifacts.rs | Discovers and prints artifact paths. |
| nvx-rs/src/guest/time/mod.rs | Updates historical C-port comments. |
| nvx-rs/src/guest/time_probe/mod.rs | Updates historical C-port comments. |
| nvx-rs/src/guest/reseed.rs | Clarifies comments without behavior changes. |
| nvx-rs/src/bin/nvx-rs.rs | Adds binary entry point. |
| nvx-rs/Cargo.toml | Declares CLI feature and dependencies. |
| guest/common/nvx-reseed.c | Removes replaced C implementation. |
| guest/common/nvx-mmio-write.c | Removes replaced C implementation. |
| guest/common/nvx-exit.c | Removes replaced C implementation. |
| guest/common/nvx-device-io.c | Removes replaced C implementation. |
| guest/common/nvx-console-pending.c | Removes replaced C implementation. |
| docker/Dockerfile | Shares Rust binaries across guest images. |
| doc/usage.md | Documents source-identity command. |
| doc/setup.md | Documents Rust guest prerequisites. |
| doc/project-structure.md | Describes CLI and Rust guest layout. |
| doc/guests/ubuntu-guest.md | Documents shared Rust binaries. |
| doc/guests/azurelinux-guest.md | Documents Rust binary provenance. |
| doc/contribute.md | Updates guest testing guidance. |
| doc/ci.md | Describes Rust build and cache inputs. |
| doc/build.md | Documents Rust builds and packaging. |
| doc/benchmarks.md | Updates helper provenance documentation. |
| aci_edge_sandboxes/src/openvmm/mod.rs | Updates egress documentation and tests. |
| aci_edge_sandboxes/README.md | Documents shared egress lowering changes. |
| .github/workflows/copilot-setup-steps.yml | Tracks all Rust crate changes. |
| .github/copilot-instructions.md | Documents musl setup prerequisites. |
| .github/actions/validate-nvx/action.yml | Removes retired C-driven test suites. |
| .github/actions/build-guest-artifacts/action.yml | Adds Rust-aware guest cache keys. |
| .gitattributes | Marks generated compatibility fixtures. |
Review effort: Balanced
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>
This was referenced Oct 17, 2026
Pedro Henrique Penna (ppenna)
added a commit
that referenced
this pull request
Oct 17, 2026
nvx-rs sandbox identifies the OpenVMM of a running sandbox by its process ID and start time, which identity.rs reads on Linux from /proc/PID/stat. Ported from the former sandbox_lifecycle.py, it counted every zombie as exited. But /proc reports a process as a zombie as soon as its main thread exits, while its other threads may still run and hold its descriptors, so exec and stop could reject a live VM as stale, and deprovision could remove the state of a VM that still runs. The library's platform::process_start_time, with which the process helpers wait for and kill OpenVMM, already counts such a process as running until its last thread exits, as Windows does, and so does the CLI's own Windows branch; only its Linux branch disagreed. linux_start_time now asks, for a zombie only, whether other threads remain, through the platform's other_threads_remain, which reads /proc/PID/task, and otherwise keeps its Python-compatible parsing and messages. other_threads_remain becomes pub(crate) for that. The stat-line test covers a zombie with and without other threads, and an error while listing them. A new Linux test, like process::tests::a_process_runs_until_its_last_thread_exits, makes the main thread of a Python process exit through pthread_exit while another thread sleeps: process_running holds until the process is killed. It fails without the fix. Copilot's review of #598 flagged this in code that #596 added, the nvx-rs CLI; the platform helper is already on dev. The change stays within the CLI's Linux identity check. Part of #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 3 of 5).
Summary
Adds the
nvx-rscommand-line interface: aclifeature, which implieshost, and annvx-rsbinary (required-features = ["cli"]). It ports the runtime steps thatnvx.pywill delegate to it in PR D, faithfully: the same options, wire formats, limits, messages, outputs, exit statuses, outcome reports, locks, and state directories.nvx-rs artifactsprints the artifact paths of Port the sandbox launch profile and artifact lookup to nvx-rs #595'sArtifactDir::paths()as JSON.nvx-rs egress compile PATHprints the native rules of Port egress policies to nvx-rs #594's policy compiler as JSON.nvx-rs sandbox [run|provision|start|exec|stop|deprovision]portscommand_sandboxofscripts/nvx.py,scripts/nvx_tools/sandbox_lifecycle.py, and the observable behavior ofcontrol_session.py, on Port the sandbox launch profile and artifact lookup to nvx-rs #595'snvx::host::sandboxand Port egress policies to nvx-rs #594's compiler, and reusesnvx::host::session,process, andplatform.startnow negotiates the guest's control features with a layered-sandbox required set (the nvx-rs: make the nvx-rs CLI the backend of nvx.py sandbox, egress, and artifact lookup #359 decision); see FEATURES.nvx.py(424 steps over both host kinds) are replayed bynvx-rs/tests/cli.rswithout Python, and the generator also runs every lifecycle withnvx.pyandnvx-rstaking turns both ways.CLI reference
Build:
cd nvx-rs && cargo build --release --features cli(target/release/nvx-rs[.exe]).Artifact root
--root PATH: an NVX repository checkout (build/,openvmm/target/release/) or an extracted release (guest/,bin/), told apart asArtifacts::from_dirdoes. PR D passes its repository root.--root: the release whosebin/holds the executable (<root>/bin/nvx-rs[.exe]), or the checkout whosenvx-rs/target/holds it at any depth, each with itsSOURCE-MANIFEST.json; otherwiseerror: cannot locate the NVX artifacts: pass --root ..., status 1.NVX_OPENVMM: an OpenVMM executable to run instead of the root's, made absolute against the working directory. It must be valid Unicode.artifactsPrints
json.dumps(value, indent=2, sort_keys=True)of:{ "guests": [ {"distribution": "Alpine Linux", "distro_layer": null, "distro_manifest": null, "initramfs": "ROOT/build/initramfs.cpio.gz", "name": "alpine", "package_manifest": "ROOT/build/initramfs.cpio.gz.packages.json"}, {"distribution": "Ubuntu Base", "distro_layer": "ROOT/build/ubuntu-distro.erofs", "distro_manifest": "ROOT/build/ubuntu-distro.erofs.manifest.json", "...": "..."}, {"distribution": "Azure Linux", "name": "azurelinux", "...": "..."} ], "kernel": "ROOT/build/vmlinux", "layout": "repository", "openvmm": "ROOT/openvmm/target/release/openvmm", "root": "ROOT" }layoutisrepositoryorrelease; every path is absolute and unchecked;openvmmisNVX_OPENVMMwhen set; guests are innvx.py's order (alpine, ubuntu, azurelinux).egress compile PATHPrints
{"allow": [RULE, ...], "deny": [RULE, ...]}in the same JSON layout, each list in the order in which OpenVMM receives--network-egress-allowand--network-egress-deny. A refused file printserror: <compile_policy_file's message>and exits 1.sandboxEvery option of the
sandboxsubparser ofscripts/nvx.py, with the same names, defaults, types, and choices (nvx-rs sandbox -hprints argparse's help withnvx-rs sandboxas the program).--opt valueand--opt=value; a value that starts with-needs=unless it looks like a negative number or holds a space; repeated options accumulate;--mount-deny,--mount-allow, and--mount-writeattach to the--mountbefore them; the operation defaults torun.run>> COMMAND(subprocess.list2cmdlineon Windows,shlex.joinelsewhere), then OpenVMM's own streams--dry-runprovisionstartexecstopdeprovision--outcome-report(run, exec): run passes--microvm-reportto OpenVMM; exec checks the destination before it runs and writes{"operation_id": HEX32, "outcome": {"category": ..., "operation": "exec", "status_code": ...}, "schema_version": 1}.State directory:
config.json(formats 1 to 6, the lowest that holds the sandbox),runtime.json(format 1:pid,start_time,control_endpoint),control.capability,control.sock(POSIX),openvmm.log,outcome.json, andlifecycle.lock(deprovisioned\ntombstone), all written as Python writes them; JSON isjson.dumps(value, indent=2, sort_keys=True) + "\n"byte for byte, mode 0600, through a temporary file and a rename.Exit statuses
error: MESSAGEon stderr: whatnvx.py'smain()prints for aScriptError,OSError,RuntimeError, orValueErrorPROG: error: MESSAGE, as argparse; unrecognized arguments are reported by the top-level parserInterruptedrun) or the workload's (exec), asSystemExit(status)Text ends lines as Python's
printdoes:\r\non Windows,\nelsewhere; workload output passes through unchanged.Test hooks (for PR D)
--root PATHandNVX_OPENVMM=PATH.nvx-fake-openvmm(--features testing) now serves the sandbox profile:--microvm-lifecycle managed(capability on stdin, control console,SANDBOX_AGENTfeatures, outcome report at stop) andone-shot(runs/bin/echo,/bin/sleep,/bin/true,/bin/false; others "not found" with 127; writes the outcome report if asked). Kernel command-line knobs:fake_exit_on_start=STATUS,fake_exit_status=STATUS,fake_guest_features=MASK,fake_legacy_guest=1,fake_boot_delay_ms,fake_crash_after_ms,fake_ignore_stop=1.NVX_OPENVMM=target/debug/nvx-fake-openvmm target/debug/nvx-rs --root .. sandbox run --layer distro,LAYER,UUID --scratch SCRATCH --entrypoint /bin/echo --arg hello.Stack
Based on #595 (
agents/fix-issue-359-launch-profile, 1fa32b8), which is based on #594 (agents/fix-issue-359-egress-policy, 78e77c3) and #561 (agents/fix-issue-358-build-switch, f1b8428). Until those merge, this PR's diff includes their commits. This PR's own commits:Each commit builds and passes clippy and the tests on Windows and Linux. Commit 1's lifecycle operations fail after their option checks until commit 2.
Changes to code that #594 and #595 own, kept additive: the private
pythonmodule gainsdumps,shell,value,float(), andlstat/lexists/fstat(cfgcli);scripts/nvx_rs_fixtures.pygains a CLI section and requires CPython 3.14.5+ for it. Base code gainsControlSession::send_request/read_response,process::Launchedwaits and kills, Windows byte locks and console interrupts, a Unix SIGINT handler, andGuestFeatures::LAYERED_SANDBOX.aci_edge_sandboxesandnvx::host::instanceare unchanged.Faithful-port notes
FEATURES
As decided in #359,
startsends FEATURES after its PING and requiresGuestFeatures::LAYERED_SANDBOX, notREQUIRED(the direct agent's set):EXEC_ENVIRONMENT: required, sinceexecsends a working directory and an environment in an extended EXEC.CANCEL: not required, deliberately. The CLI never sendsAPP_CANCEL: as withnvx.py, Ctrl-C ends anexecby closing the session, and the guest agent ends the workload when OpenVMM resets that session.WORKLOAD_ACCOUNT: never required (microVM: create requested workload accounts in the layered sandbox root, or stop advertising them #566).A guest that lacks a required feature, or predates the request (an unsupported-operation refusal counts as no features), is stopped and its runtime files removed as for a failed start, and start 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, naming the missing features withGuestFeatures::missing. The Rust agent of #561 and the C agent before it advertise bits 0, 2, and 4 in sandbox mode, and the real start on all three hypervisors negotiated successfully. This is the one intended behavior change;nvx.pydoes not ask.Other intended deviations
nvx.pywould end with a traceback (for example aconfig.jsonwithout a field that_startreads, or anOverflowError),nvx-rsprintserror: <str(exception)>, status 1.PYTHONIOENCODING/UTF-8 mode is set.starttreats a control endpoint served by a process other than the launched OpenVMM as not yet available (asnvx::host::sessiondoes), and starts OpenVMM without a console window on Windows (asnvx::host::processdoes).execrefuses more than the 1 MiB of workload output that the guest agent forwards (only a misbehaving guest sends more).nvx.py, a start whose OpenVMM exits at once waits until--timeoutfor the endpoint before reporting the exit; that is kept, not fixed.Port details worth reviewing
flock(LOCK_EX|LOCK_NB); WindowsCreateFileWwith read/write/delete sharing andFILE_FLAG_OPEN_REPARSE_POINT, thenLockFileof byte 0 asmsvcrt.locking(LK_NBLCK, 1). Standalone-regular-file checks, 25 ms retries within--timeout, hand-off waits, and the directory-removal protocol are ported, with Windows errors classed as CPython maps them./proc/PID/statstart time with zombies as exited; Windows creation time), so runtime records are valid across implementations.control_session.py's messages, per-operation deadlines, direction-specific timeout messages,ControlEndpointClosedversus reset, closing after a failed transfer, and exec's validation order. Waits run in 250 ms slices so Ctrl-C ends them.start: 32-byte non-zero capability on stdin through a pipe closed before OpenVMM starts;//./pipe/openvmm-microvm-<uuid4 hex>on Windows,<state>/control.sockelsewhere;_start's command line and network-option order; detached (own session /CREATE_NEW_PROCESS_GROUP), log appended; failed-start cleanup order and_startup_exit_message.run: OpenVMM gets 0.25 s on POSIX, then is killed, assubprocess.rundoes; on Windows the CLI waits, as Python's wait cannot be interrupted either. A recorded Ctrl-C decides how a command ends, asKeyboardInterruptdoes, also when the same Ctrl-C ended OpenVMM first.test_control_session.pymappinghost/cli/session/tests.rsunless noted)test_exec_streams_output_and_returns_bounded_statusexec_streams_output_and_returns_bounded_statustest_exec_rejects_unbounded_or_relative_argumentsexec_rejects_unbounded_or_relative_argumentstest_exec_encodes_extended_cwd_and_environment_exactlyexec_encodes_extended_cwd_and_environment_exactlytest_exec_distinguishes_omitted_and_empty_environmentexec_distinguishes_omitted_and_empty_environmenttest_exec_layers_only_a_supplied_environment_over_the_defaultsexec_layers_only_a_supplied_environment_over_the_defaultstest_exec_accepts_full_uint32_timeout_range_without_waitingexec_accepts_full_uint32_timeout_range_without_waitingtest_exec_rejects_invalid_timeout_and_execution_fields_before_sendingexec_rejects_invalid_timeout_and_execution_fields_before_sending(theTypeErrorcases have no CLI equivalent: values arrive parsed)test_exec_rejects_invalid_response_timeout_before_sendingexec_rejects_invalid_response_timeout_before_sendingtest_exec_rejects_unknown_exit_categoryexec_rejects_unknown_exit_category(alsocancelledand non-ASCII)test_exec_refusal_keeps_status_category_and_diagnosticexec_refusal_keeps_status_category_and_diagnostic(nvx.pyprints only the message)test_session_reset_is_not_a_closed_endpointsession_reset_is_not_a_closed_endpointtest_operations_bound_their_requests_by_their_deadlinesoperations_bound_their_requests_by_their_deadlinestest_failed_transfer_closes_the_sessionfailed_transfer_closes_the_sessionCapabilityPipeTests(3)nvx::host::process::spawn's (#561), which closes it before OpenVMM starts; every golden lifecycle start exercises it, since the fake reads exactly 32 bytes then EOF and refuses an all-zero capabilitytest_write_that_sends_nothing_reports_a_closed_endpoint(mock)a_closed_endpoint_ends_the_operation_with_its_own_errortest_read_reports_a_closed_endpointendpoints::read_reports_a_closed_endpoint(POSIX)test_reset_and_broken_pipe_report_a_closed_endpointendpoints::reset_and_broken_pipe_report_a_closed_endpoint(POSIX)test_request_to_a_stalled_endpoint_times_out(socket)request_to_a_stalled_endpoint_times_out_and_closes_the_sessiontest_write_that_moves_nothing_reports_a_closed_endpoint(mock)endpoints::write_reports_a_closed_endpoint(Windows)test_write_reports_a_closed_endpointendpoints::write_reports_a_closed_endpoint(Windows)test_read_reports_a_closed_endpoint_after_its_dataendpoints::read_reports_a_closed_endpoint_after_its_data(Windows)test_read_times_out_without_losing_later_datanvx::host::session::tests::timed_out_polls_keep_partial_records(buffering) andendpoints::read_times_out_and_closes_the_session(Windows)test_interrupted_write_is_cancelled_before_the_interruption_propagatesnvx::host::platformcancels a pipe write that times outtest_request_to_a_stalled_endpoint_times_out_and_closes_the_session(pipe)request_to_a_stalled_endpoint_times_out_and_closes_the_sessionAdded:
response_that_does_not_arrive_times_out,output_beyond_the_guest_limit_breaks_the_protocol,features_of_a_guest_that_predates_them_are_none,protocol_violations_keep_the_messages_of_python, andendpoints::endpoints_that_never_appear_time_out_with_their_pathon both hosts.Golden scenarios and interoperability
scripts/nvx_rs_fixtures.py(single--check) recordsnvx-rs/tests/fixtures/cli.jsonfromnvx.py sandboxrun as a command (subprocess,artifact_pathandopenvmm_binary_pathpatched to a temporary root whose OpenVMM is a copy ofnvx-fake-openvmm) and from the Python functions behindegress compileandartifacts: 24 usage errors and helps, 69–70command_sandboxand launch checks, 10–11 dry runs, 11 one-shot runs, lifecycles in every config format (format 3 on POSIX only, since Windows refuses caller-owned shares), and 8 refusal/failure scenarios; 215 steps on POSIX and 209 on Windows. Each step records status, stdout, stderr, and state files (config.jsonformats 1–6,runtime.json, entries and modes, outcome reports, and the command linestartpassed to OpenVMM), with the root, PIDs, start times, and random identifiers as placeholders. Theposixandwindowssections are recorded on each host kind. The generator then plays every scenario withnvx-rs, and every lifecycle withnvx.pyandnvx-rsalternating both ways (Python provisions and Rust starts, executes, stops, and deprovisions, and the reverse), and fails on any difference.nvx-rs/tests/cli.rsreplays the host's section without Python, plus the FEATURES rejection, root discovery, relativeNVX_OPENVMM, top-level usage errors, and Linux Ctrl-C duringrunandexec.Validation
nvx.py verify; nvx-rs fmt, Clippy (--all-targets --all-features,--lib --no-default-features), tests (--all-featuresand default), rustdoc-D warnings, MSRV 1.89,aarch64-apple-darwincheck, bundled-artifacts test; aci_edge_sandboxes fmt, Clippy ×2, tests ×2, doc, MSRV;test-agent-bundling --toolchain 1.95.0; ruff check/format; pyright Linux and Windows; markdown links; validate-nvx unittest list;nvx_rs_fixtures.py --check(CPython 3.14.7): all passtest-agent-bundling, validate-nvx unittests,nvx_rs_fixtures.py --check(CPython 3.14.7): all pass; every commit of this PR checked with fmt, Clippy ×2, andtest --all-featuresnvx-rs --features clion this branch, OpenVMM 5ab8a84 and Alpine initramfs from #594's worktree, Ubuntu distro layer and scratch template from #561's: one-shotrun(+--dry-run,--outcome-report,/bin/false→ 1);provision,start(FEATURES negotiated),execwith--cwd,--environment,--environment-file,--inherit-default-environment,--outcome-report,--exec-timeout-ms(124, timeout), missing program and cwd (125);stop,startagain,exec,stop,deprovision; anvx.py-provisioned sandbox started and exec'd bynvx-rs, exec'd and stopped bynvx.py, deprovisioned bynvx-rs: all as expected--hypervisor mshv: all as expected--hypervisor whp(named-pipe endpoint): all as expectedLeft to CI: the workload jobs (
test-microvm,test-openvmm, guest builds and determinism, benchmarks), which keep exercising the Python path until PR D; the guest-binary steps ofcheck-nvx-rs; and CI's own runs of the jobs above.