Skip to content

cli: restore managed sandboxes from named snapshots (SL3.8a2) - #1487

Merged
Pedro Henrique Penna (ppenna) merged 1 commit into
feature/nvx-refactorfrom
agents/slim-sl3-8a2-snapshot-restore
Nov 12, 2026
Merged

Pedro Henrique Penna (ppenna) merged 1 commit into
feature/nvx-refactorfrom
agents/slim-sl3-8a2-snapshot-restore

Conversation

@ppenna

@ppenna Pedro Henrique Penna (ppenna) commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

Part of #1114 (SL3.8a2, the second half of SL3.8a, "snapshot restore starts a sandbox from a named snapshot"). Parent #1084, umbrella #1076.

Follows #1486 (SL3.8a1, merged as 99855f75e0e), which added the restored start that this command uses. This PR was stacked on it and now targets feature/nvx-refactor.

What and why

snapshot restore NAME --store-dir STORE --state-dir STATE creates a managed sandbox from a named snapshot that snapshot capture published:

  • It provisions the sandbox with the options of sandbox provision, such as the EROFS or GPT image, a scratch or RAM upper and its cap, the hostname and the workload user.

  • The sandbox's configuration records the snapshot, its store and the workload (--entrypoint and its --arg values).

  • It then starts the sandbox through SL3.8a1's restored start, which runs the workload until it exits with status 0. Each later sandbox start restores the snapshot again.

  • Refusals. The command does not take the options that only an execution or a cold boot takes, and leaves them unrecognized: --cwd, --environment, --environment-file, --inherit-default-environment, --exec-timeout-ms, --wait, --outcome-report, --cmdline and --dry-run.

    • It opens the snapshot under its lock first, so a missing snapshot provisions nothing.
    • It refuses a state directory in the snapshot store before it provisions anything.
    • A sandbox whose start fails stays provisioned, as after sandbox provision.
  • Workload. Each restore runs the workload as an exec request over the control protocol, as sandbox run runs its own, not from boot tokens. So the command checks it as exec does, before it provisions anything, and the sandbox's launch takes the default workload, as a run's does. The workload's arguments may thus contain whitespace and quotes, which sandbox provision refuses.

  • Wiring. lifecycle::provision takes the source that it records; only snapshot restore passes one. The snapshot subcommand parser moves from the capture module to snapshots.rs, which dispatches both commands.

  • Docs. doc/usage.md gains a snapshot restore section. The nvx-rs/README.md state table gains openvmm/ (the view) and restore-ready.sock.

Not in this PR, per #1114: run --restore-snapshot (SL3.8b), and everything that #1486 lists.

Takeover. This replays Enrique Saurez (@esaurez)'s first implementation (esaurez/slim-3.2.2-sl3.8a-private-20261108, e7d77fcdda), with his authorship.

Split and size. 202 core lines in 5 counted files: snapshots.rs 172, sandbox/mod.rs 18, cli/mod.rs 6, lifecycle/mod.rs 3 and snapshots/capture.rs 3. SL3.8a measures 550 core lines across its two PRs (#1486 348, this PR 202; 549 in their combined diff), against its 500-line stop. Continuing past the stop was approved on Pedro Henrique Penna (@ppenna)'s behalf, up to the PR rules' hard limit of 600 core lines, because the command and the restored start are one coupled change. Its 8 counted files were not, since #1076 allows at most 7, so SL3.8a is split into SL3.8a1 and this PR, at 5 counted files each; #1486 explains why no single PR reaches 7. BOOT_CONTRACT_VERSION, CONTROL_CONTRACT_REVISION and the public API are unchanged.

Validation

The gates are those that local_gates.py selects for these files, run from this branch's action definitions at bf63b6aaf7f, which contains SL3.8a1, on 66fd43145ce.

Rebases. #1486 was rebased onto d4edeeb9ab9 (SL3.4b, #1490), and this PR onto it, as 12f0f38578f. This PR's change applied unchanged; git range-diff showed only context in sandbox/mod.rs, whose run now also serves SL3.4b's sandbox run. SL3.4b changed only the help text of the sandbox options, so snapshot restore takes the same options. This PR then followed #1486 onto 584c2ddde55 and f834bcb9672, cleanly but for one resolution: start/tests/restore.rs keeps both #1486's new test of its review and this PR's command test (below). Once #1486 merged as 99855f75e0e, whose tree equals its last head's, git rebase --onto replayed this PR onto it unchanged, as 8315693905c; the second review fix below then made its head fc50a29e9f5. At that head, on Windows, all passed: cargo fmt --check, the five Clippy variants with -D warnings, the lib tests of host::cli::sandbox, host::sandbox, host::cli::snapshots and host::restore*, the whole cli integration suite, and check_test_layout.py. On Linux (nvx-kvm-test), the same checks and the macOS check passed at 7ebdb1778ba, which has the command test, before the last rebases and the second review fix; CI runs them on Linux at the head. The table and the real-VM results below are from before the rebases, but for the restores of the second review fix.

Review fixes. Copilot's reviews found two gaps:

  • No test of the command's wiring from the provision to the restored start. snapshot_restore_records_its_source_and_starts_a_restore_of_it, in start/tests/restore.rs, runs snapshot restore through snapshots::parse and snapshots::run, with a store that holds a strict snapshot and an artifact root of fakes. It checks:

    • that the configuration records the snapshot's name and the workload, and that Config::source() returns the name, the store as paths::resolve resolves it, and the workload;
    • that the start is a restore: with --memory-mib 2176, which the snapshot refuses, it fails with Unsupported ("snapshot memory must be from boot"), a check that only a restore makes;
    • that it fails before it makes a view or links anything, so the snapshot stays free;
    • that the sandbox stays provisioned, until sandbox deprovision removes it.

    The test fails if sandbox::restore provisions without the snapshot, or if the start ignores the recorded source.

  • A workload with whitespace was refused. Provision's launch checked the workload as kernel-command-line tokens, which refuse whitespace and quotes, although each restore sends it as an exec request over NVXC. The launch of a restored sandbox now takes the default workload, as a run's does, and snapshot restore checks the workload with Session::exec_payload, as exec and run check theirs, before it provisions anything. The command test now restores /bin/echo 'a "restored" sandbox'; without the launch's default workload, it fails with "sandbox arguments must be nonempty and contain no whitespace, control characters, or quotes". a_restore_refuses_a_workload_that_is_not_a_valid_exec_request_before_it_provisions refuses a relative entrypoint and 65 arguments before anything is created, and fails without the check.

Real VMs at fc50a29e9f5, on KVM (nvx-kvm-test) and MSHV (nvx-mshv-test), after snapshot capture:

  • snapshot restore of an EROFS image with a RAM upper and /bin/echo 'a "restored" sandbox' prints a "restored" sandbox and records that workload. A later sandbox start restores again and prints it again.
  • A GPT restore runs /bin/sh -c 'printf "%s|%s\n" "restored sandbox" "$(id -u)"; test -d /run/nvx', which prints restored sandbox|65534, and a sandbox exec works afterwards.
  • A relative entrypoint fails with "managed exec entrypoint must be absolute", and nothing is provisioned. sandbox provision still refuses an argument with whitespace.
Check Result
Windows: cargo fmt --check; the five Clippy variants with -D warnings; cargo test --all-features, cargo test, and --lib --no-default-features build and test; bundling; cargo doc with -D warnings; MSRV 1.89; runtime adapter (protocol-only and host-only); public_api.py; check_test_layout.py; check_test_changes.py; check_markdown_links.py Passed
Windows: validate-nvx's "Validate NVX CLI on Windows" step: compileall, the 22 unittest modules with NVX_RS set to this branch's release nvx-rs (test_usage_documents_every_option_of_nvx_rs among them), test_adversarial.py, test_hosts.py, and the nvx.py --help checks Passed: 1834 tests OK (70 skipped), 84 OK (11 skipped), 4 OK
Linux (nvx-kvm-test): fmt, the five Clippy variants, cargo test, the --lib --no-default-features build and test, doc, MSRV, bundling, macOS check, public_api.py, guest-binary build and test, runtime adapter (protocol, host and guest), check_test_layout.py, check_markdown_links.py Passed
Linux (nvx-kvm-test): cargo test --all-features; check-guest-binaries Every target passes except guest tests whose isolated child processes fail on that host (Operation not supported): 28 guest:: tests of the lib, and 26 of the guest build on musl. Both sets are exactly those that fail at the base 66fd43145ce on that host.
Real VMs with snapshot capture, then snapshot restore, on KVM (nvx-kvm-test, Azure Linux 3, kernel 6.6), MSHV (nvx-mshv-test, kernel 6.18 mshv3) and WHP (Windows 11 developer machine) Passed on all three; see below
Cold sandbox lifecycle, KVM, MSHV and WHP: EROFS and GPT, scratch and RAM uppers Passed on all three
test-microvm --scenario sandbox-lifecycle --scenario sandbox-ram-upper --scenario managed-exec-config --scenario sandbox-proxy-only, KVM and MSHV Passed on both

The restore matrix on each backend:

  • Capture. snapshot capture base publishes exactly key.bin, snapshot.json and openvmm/.
  • Formats and uppers. snapshot restore passes with each image format (EROFS, GPT) and upper (scratch; RAM with caps 25 and 90).
    • The workload runs and its output is forwarded.
    • An exec shows lowerdir=/run/nvx/layers/distro, or /run/nvx/layers/p2 for GPT, and the scratch or tmpfs upper.
    • The view hard-links the snapshot's 3 files (link count 2), and deprovision drops them back to 1.
  • Restarts. A later start of a scratch sandbox refuses; a RAM sandbox restores again with a fresh upper.
  • Refusals. A failing workload fails the start ("the restored workload ended with Exit status 1") and ends the VM. A state directory in the store is refused, and nothing is provisioned.
  • Concurrent restores. One restore runs a 6 s workload, and two more start 1 s later. Each waits for the first one's lock, ending 6.2 to 6.7 s after the first began (KVM 6.26, 6.50, 6.70; MSHV 6.22, 6.44, 6.57; WHP 6.28, 6.65, 6.49).
  • Deprovision under the lock. While another restore holds the snapshot's lock, deprovision with --timeout 2 fails with "another command still holds the lock of the snapshot base" and leaves the view. With 60 s, it ends right after that restore (KVM 8.26 s vs 8.25 s, MSHV 8.24 vs 8.21, WHP 8.56 vs 8.50).
  • Killed restore. A restore whose command is killed once its VM is journaled never completes. The next command's recovery ends its VM (on WHP, the VM ends with its CLI), and deprovision then succeeds.

The real-VM runs used OpenVMM 14dc4116: from the release on Linux, and built at that pin for WHP. They used the release's kernel, an initramfs built from this branch, the release's Ubuntu EROFS layer, and a GPT image made from it. The public scenarios used an Ubuntu layer built from this branch's guest sources (build-distro-layer --guest ubuntu), since the release's layer predates #1460's probe.

--workload-user: a pre-existing limitation. A restore with --workload-user 1000:1000 fails in the guest (NVX-MANAGED-ERROR: stage=data status=71, guest exit 125) on KVM and MSHV. This is the layered root's existing rule, not this change:

  • The NVX Ubuntu image has no account with UID 1000. A layered root requires an existing account, since only the blockless root creates one (protocol::features::WORKLOAD_ACCOUNT).
  • A cold sandbox start with that image and identity fails the same way: at 66fd43145ce, this branch's base before the rebase, and at its head then, bf63b6aaf7f.
  • With identities that the image has, 1:1 (daemon) and 65534:65534 (nobody), both cold and restored starts work, and the restored workload runs as that user.

Left to CI: validate-nvx's Linux CLI job; the guest tests that need working isolated child processes, which fail on nvx-kvm-test at the base too; and MSHV's and WHP's own CI runs.

Changes to existing tests (tests-modified)

check_test_changes.py --base origin/feature/nvx-refactor, with #1486 merged, lists 2 changes and 6 added tests, none relocated. The added ones are the snapshot parser's test and its command helper, the refusals of a state directory in the snapshot store and of a workload that is not a valid exec request, and the command test above and its artifact_root helper. The changes:

  • The options helper of host::cli::snapshots::capture::tests now passes the arguments after capture, since the capture parser no longer sees the subcommand: the snapshot parser in snapshots.rs selects it.
  • capture_parser_refuses_restore_bad_names_and_invalid_options is renamed capture_parser_refuses_bad_names_and_invalid_options. restore is now a command, which the snapshot parser's tests cover. The capture parser keeps refusing an escaping name, a missing store, a zero timeout and an invalid processor count, and its --help.

@github-actions

github-actions Bot commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

This pull request changes existing tests

It modifies or removes 2 existing test asset(s), listed below. Review each with care: a change to a test can hide a regression that the test caught. See doc/contribute.md#changes-to-existing-tests.

Test code

Change What Before After
modified helper fn options in host::cli::snapshots::capture::tests nvx-rs/src/host/cli/snapshots/capture/tests.rs:166 nvx-rs/src/host/cli/snapshots/capture/tests.rs:166
modified test fn capture_parser_refuses_restore_bad_names_and_invalid_options in host::cli::snapshots::capture::tests (renamed to capture_parser_refuses_bad_names_and_invalid_options) nvx-rs/src/host/cli/snapshots/capture/tests.rs:301 nvx-rs/src/host/cli/snapshots/capture/tests.rs:300

Relocated unchanged: 0; added: 6. Compared the merge base 99855f75e0e0 with fc50a29e9f58.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

The tests do not exercise snapshot provisioning-to-restored-start wiring, so omitting the source during provisioning could silently cause a cold start without failing tests.

1 open issue
What changed in this PR

Adds snapshot restore to create managed sandboxes from named snapshots, using the restored-start lifecycle introduced in #1486.

Changes:

  • Dispatches snapshot capture and restore through a shared command parser.
  • Persists the snapshot source and workload during provisioning, then starts the sandbox from that snapshot.
  • Adds parser and refusal tests, and documents restore behavior and state files.
File Description
nvx-rs/​src/​host/​cli/​snapshots/​tests.rs Tests command selection, restore options, and state-directory refusal.
nvx-rs/​src/​host/​cli/​snapshots/​capture/​tests.rs Adapts capture tests to the relocated subcommand parser.
nvx-rs/​src/​host/​cli/​snapshots/​capture.rs Parses capture arguments after shared command dispatch.
nvx-rs/​src/​host/​cli/​snapshots.rs Adds restore parsing, snapshot checks, and command orchestration.
nvx-rs/​src/​host/​cli/​sandbox/​mod.rs Connects source-aware provisioning to sandbox startup.
nvx-rs/​src/​host/​cli/​sandbox/​lifecycle/​mod.rs Records the optional snapshot source during provisioning.
nvx-rs/​src/​host/​cli/​mod.rs Routes snapshot commands through the shared dispatcher.
nvx-rs/​README.md Documents restored sandboxes and their state files.
doc/​usage.md Explains restore usage, constraints, locking, and recovery.

Review effort: Balanced

Comment thread nvx-rs/src/host/cli/snapshots.rs Resolved

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Snapshot restore incorrectly rejects workload arguments containing whitespace by applying kernel-command-line validation instead of exec-request validation.

1 open issue
1 resolved since last review

Review effort: Balanced

Comment thread nvx-rs/src/host/cli/sandbox/mod.rs Resolved
`snapshot restore NAME --store-dir STORE --state-dir STATE` provisions
a managed sandbox with the options of `sandbox provision` that a
restore takes, and starts it from the named snapshot that `snapshot
capture` published. The sandbox's configuration records the snapshot,
its store, and its workload, the entrypoint and its arguments, so each
later `sandbox start` restores the snapshot again, as SL3.8a1's start
does. The command takes provision's `--layer-format` and
`--ram-upper-percent`, so it restores an EROFS or a GPT image, with a
scratch upper or a RAM upper and its cap, but none of the options that
only an execution or a cold boot takes, which it leaves unrecognized.

It opens the snapshot under its lock first, so a missing snapshot
provisions nothing, and refuses a state directory in the snapshot
store, whose files a start or deprovision could take for a view. A
sandbox whose start fails stays provisioned, as after `sandbox
provision`. No index of a snapshot's sandboxes or record of a restore
in progress exists.

Each restore runs the workload as an exec request, as `sandbox run`
runs its own, not from boot tokens, so the command checks it as `exec`
does, before it provisions anything, and the sandbox's launch takes the
default workload, as a run's does. Its arguments may thus contain
whitespace and quotes, which `sandbox provision` refuses.

`lifecycle::provision` takes the snapshot that it records, which only
`snapshot restore` passes. The `snapshot` subcommand parser moves from
the capture module to the snapshots module, which dispatches both
commands.

This is the second half of SL3.8a, from the first implementation on
esaurez/slim-3.2.2-sl3.8a-private-20261108 (e7d77fc).

Tests: the snapshot parser, which selects capture or a restore with the
options of a provision, provision's image format and RAM upper cap
among them, requires the snapshot, its store and the state directory,
and leaves the options of an execution or a cold boot unrecognized;
the capture parser, which now parses only the options after `capture`;
the refusal of a state directory in the store, and of a workload that
is not a valid exec request, a relative entrypoint or 65 arguments,
before anything is provisioned; and the command, with a store that
holds a strict snapshot, an artifact root of fakes, and a workload
whose argument holds whitespace and quotes: it records the snapshot,
the resolved store and the workload, and its start restores that
snapshot, as a memory size that the snapshot refuses shows, and fails
before it launches or links anything, which leaves the sandbox
provisioned.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟢 Approval recommended

The snapshot restore change has no active review issues.

No open issues 🎉

1 resolved since last review

Review effort: Balanced

@ppenna
Pedro Henrique Penna (ppenna) merged commit 64ad98a into feature/nvx-refactor Nov 12, 2026
48 checks passed
@ppenna
Pedro Henrique Penna (ppenna) deleted the agents/slim-sl3-8a2-snapshot-restore branch November 12, 2026 00:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feat-nvx-refactor tests-modified Changes existing tests, test data, or test wiring

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants