Repository navigation
node-mgr: validate LoadSnapshot requests before staging host state - #269
Conversation
Add LoadSnapshot validation rows that carry a malformed rootfs rate limit on the two paths that stage host state: a plaintext load from the sandbox's checkpoint directory and an encrypted load from its snapshot volume. Both must be refused with the rate-limit InvalidArgument before any host work. A valid encrypted load with a well-formed limit pins that the limit itself is accepted and the load stops at the fixture's refused volume bind. This fails on the current code: the limit is parsed only in the shared load tail, after the plaintext path has inspected its checkpoint files and after the encrypted path's bind step (here refused with Cancelled).
LoadSnapshot parsed rootfs_rate_limit in finish_load_snapshot, the tail both load paths share, so a malformed limit was refused only after the plaintext path had copied vmstate into the jail, re-owned the source vmstate and memory files and bind-mounted memory, or after the encrypted path had re-owned the volume and bound it into the jail. Its comment only promised validation before the Firecracker load. Parse the limit at the top of load_snapshot_inner, beside the sandbox id check, and hand the parsed limiter to the tail. The refusal keeps its code and message (InvalidArgument, "rootfs_rate_limit.<bucket> is required"); it now also precedes the lifecycle permit and lease lookup. The tail takes the sandbox id and the parsed limiter instead of the request and derives the API socket from the jail root, as it already does for v.sock, which keeps its argument count unchanged.
The two rate-limit rows that stage nothing used the allocated sandbox. Dropping the parse then let them past the lease lookup into the shared load tail, which polls the Firecracker API socket under the node's fixed jail directory and would load a snapshot through it if one existed. Run them against the bound but unallocated sandbox instead: the fixed code still refuses them with the rate-limit InvalidArgument, and a regression stops at the lease check. Say what the checkpoint row can show: its source check reads checkpoint files under the fixed cache root first, so it pins the parse ahead of the path checks, not ahead of the staging step. The encrypted row pins that.
19a7689 to
c0f146f
Compare
Review — 4 findingsLow
I listed every host-side effect on the path in order: authorize, id validation, owned-work claim, gate and family lock, Verified against |
the-gigi-pplx
left a comment
There was a problem hiding this comment.
Approved. 4 non-blocking findings in the review comment above.
|
Thanks. On the four findings:
|
Problem
LoadSnapshotchecked the request'srootfs_rate_limitonly after it had staged host state. A request with a malformed limit (aDriveRateLimitmissing itsbandwidthoroperationsbucket) was refused withInvalidArgument. By the time it was refused, node-manager had already done the following:source_dirset): copiedvmstateinto the sandbox jail, re-owned to the jail uid the staged in-jail copy ofvmstate(stage_open_filereturns the in-jail file) and the sourcememoryfile, and bind-mountedmemoryinto the jail.snapshot_volume_mount_dirset): chowned the volume'svmstate,memoryand root to the jail uid, and bind-mounted the volume into the jail.The refusal does not undo these changes. The code comment only promised to validate "before the load".
Root cause
drive_rate_limiter(crates/libs/node/mgr/server/src/service/snapshots.rs:1204onf48eb2b) ran insidefinish_load_snapshot(:680, parse at:692). That is the shared tail of both load paths, and both paths call it only after their host staging:stage_plaintext_snapshot_inputs(copies vmstate into the jail) →reown(&vmstate)on the in-jail copy,reown(&memory)on the source file →bind_pinned_files(:647–:658).pin_encrypted_snapshot_bind(re-owns the volume) →bind_pinned_files(:602–:607).Note: the fix commit's message says "re-owned the source vmstate and memory files"; the accurate wording is the one above (the in-jail copy of vmstate and the source memory file).
Fix
The limit is now parsed at the top of
load_snapshot_inner, right aftervalidate_sandbox_id. The parse now also precedes the lifecycle permit, the lease lookup and the path checks (validate_snapshot_volume/load_snapshot_paths). The parsedRateLimitergoes tofinish_load_snapshot, which only applies it after the Firecracker load.finish_load_snapshotnow takes the sandbox id and the parsed limiter instead of the raw request. It derives the API socket path fromjail_root, as it already does forv.sock. Its argument count is unchanged, so notoo_many_argumentsallow is needed.Design note
Semantics
The order in which
LoadSnapshotchecks things (the execution fence and VM lock inload_snapshotcome first and are unchanged):validate_sandbox_idvalidate_sandbox_idLoadSnapshot)rootfs_rate_limitparsevalidate_snapshot_volume/load_snapshot_paths)rootfs_rate_limitparseWhat a caller gets back:
InvalidArgument rootfs_rate_limit.<bucket> is requiredsource_dirUnavailable/ path errorInvalidArgument(rate limit)Why this is correct
docs/rust/principles.md§1 (line 36, and the boundary rule at line 194): parse a wire value once at the boundary and pass the typed value inward. The limit is now parsed once intoRateLimiterbefore any work. The tail gets the typed value, so it can no longer re-parse or reach into the raw request.rollback_loaded_snapshot) only covers failures after the load. The staging steps have no undo on a validation refusal.check_execution, FailedPrecondition) and the VM-cgroup lock inload_snapshot. No CAS or fence semantics change.Alternatives considered
load_snapshot_pathsalso stats the checkpoint files, which reads host state, so it cannot move above the lease without splitting it (a refactor).#[allow(clippy::too_many_arguments)]and keepapi_socketas a parameter. Rejected in favour of deriving it fromjail_rootinside the tail. Both callers computed it the same way.Client-visible effects
InvalidArgument,rootfs_rate_limit.bandwidth is required/rootfs_rate_limit.operations is required).InvalidArgumentinstead ofUnavailableor the path error. sandbox-manager, the in-tree caller, always sends both buckets (drive_rate_limit_proto,crates/libs/sbx/mgr/server/src/service/start.rs), so it never hits this refusal.Risks
Rolling-deploy safety
LoadSnapshotRequestfields are unchanged.Tests
Rows added to the existing
load_snapshot_refuses_invalid_requests_before_staging_the_jailtable (service/snapshots/tests.rs), not a new function. The table runs through theLoadSnapshotRPC entry point againstrefuse_host_work: a pre-cancelled artifact-work owner, so the first blocking host step returnsCancelled.checkpoint load with a rate limit without bandwidth: plaintext with the sandbox's ownsource_dir. ExpectsInvalidArgument. This row pins "the parse precedes the path checks", not the staging barrier (see below).encrypted load with a rate limit without operations: the sandbox's own snapshot volume. ExpectsInvalidArgument. This is the row that proves the validation error comes before the barrier.valid encrypted load with a rate limit: the accepting control. A well-formed limit is accepted, and the load stops at the refused volume bind (Cancelled).rate limit without bandwidth/without operations) now run against the bound but unallocated sandbox. On the fixed code they still expect the rate-limitInvalidArgument. If the parse regresses, they stop at the lease lookup (Unavailable) instead of reaching the shared tail's poll of the fixed jail socket.valid encrypted loadcontrol. Every row still checks that the lease is undisturbed and that no lifecycle permit leaked.Limitation of the plaintext row.
load_snapshot_paths→validate_load_snapshot_sourcestatsvmstate/memoryunder the fixed cache root (SANDBOX_CACHE_DIRis a constant:/space/cache/firecracker/sandboxes/<id>/snapshot) before staging. That means a test cannot reach the plaintext staging barrier without creating files on a fixed host path, which is not allowed. So on main the plaintext row fails at that read-only stat of a fresh-UUID path (NotFoundhere; host-dependent elsewhere) rather than at the barrier. After the fix it is refused before the stat. If the files somehow existed, staging would still stop at the barrier, so the row fails closed either way. So this row pins "the parse precedes the path checks"; the encrypted row is the one that proves validation comes before the barrier. There is no "valid checkpoint load" control because that stat's outcome depends on the host. The table comment says so.Red (commit 1 on unchanged code; the UUIDs are fresh test-generated ids):
Mutation proof. In the fix commit, I replaced the hoisted
.map(drive_rate_limiter).transpose()?with.and_then(|limit| drive_rate_limiter(limit).ok()), so a malformed limit is silently dropped. Four rows fail, and the test finishes in about 10 ms:Host safety of that regression path: the unstaged rows stop at the lease lookup. The checkpoint row reaches the read-only stat of a fresh-UUID path under the fixed cache root. The encrypted row stops at the refused bind. No row polls or connects to a Firecracker socket, and nothing is written, chowned or bound. On the fixed code none of these steps is reached.
Validation
Every result below is for head
c0f146ff87684568379863784f7cb250d258c4ccon based29873e3efb82ab2d4d126dc3138d6f37b777a80. The Kind harness is the post-#154 one: SPACE Kind node imagespace-kind-node:15d575fd832acf75on the sandbox workers and builders, and one DaemonSet per node service. The cluster was created fresh withteardown.sh, then thesmoke-kind-e2e.shbring-up.ci/scripts/run-rust-checks.sh fmt(env contract, Rust error policy,cargo fmt --check)cargo clippy --workspace --locked --all-targets --all-features -- -D warnings, and the same scoped to-p pplx-space-node-mgrcargo nextest run -p pplx-space-node-mgr --retries 0×5, withSPACE_CI_REQUIRE_TEST_DEPS=1and a skip log#[ignore]root/btrfs-fixture tests were not selected; they are unrelated to this path.crates/libs/node/{mgr,runtime}in the synced build input are byte-identical to the head, including the head-only commentValidate before either path stages the jail, which is absent on the base. The release build of that input,space-nodemgr-svcsha256ec2fb94e…3d11749, is what runs on all four node-manager pods (worker and builder DaemonSets). Release binaries are stripped and this change adds no string literal, so no meaningful string in the binary is unique to the head. Instead, the same build with only this PR's two files reverted to the base recompiles justpplx-space-node-mgr/-svcand yieldscdbbbcc6…f3354c1, which differs from the deployed binary.ecw.pause_connect,ecw.pause_timeout_requires_connect,ecw.access_idle_reconnect_preserves_workspace,local.encrypted_resuspend_preserves_cow(BYOK encrypted load),local.suspend_restore_cross_node_from_object_storage,local.suspend_snapshot_rows_are_per_attempt,network_policy.v1_rules_survive_restoreecw.pause_connectapi_surfaces=("ecw",)).native.resident_pausealso passed but never calls LoadSnapshot, so it is not evidence for this change. The other native evidence is the native typed suite below.smoke-kind-typed.sh all(SPACE_SMOKE_KIND_COLD_RECEIPTSset)smoke-kind-e2e.sh(bring-up baseline, then again after the typed suite)No failures, so nothing to classify. The known pre-existing flakes did not occur.
Follow-ups
load_snapshot_paths(artifact names) andvalidate_snapshot_volumecould move ahead of the lifecycle permit, so invalid requests never queue for one. This would change which refusal wins for requests that are invalid in two ways, so it is left out of this PR.update_drive_rate_limiter. That is a pre-existing gap: replacingrootfs_limiter.as_ref()withNoneat eitherfinish_load_snapshotcall site currently passes every test.🤖 Generated with Claude Code