Repository navigation
sbx-mgr: characterize stop, delete and create core RPC paths (tests only) - #271
Conversation
b38ccc3 to
1062578
Compare
…nly) Pin the RPC-layer behavior of Stop, Delete and Create in service/grpc_core.rs so the planned lifecycle consolidation has a safety net: - Create, from a forwarded peer and for every boot mode: owner authorization and every network-policy authority (Local refused, unspecified and unknown values invalid, absent policy and Cluster accepted). - Create, Stop and Delete refuse missing credentials and unlisted peers without persisting anything. - Stop selects only a live runtime and Delete any row. An empty template filter means none. Absent sandboxes succeed with no ids. - A non-blocking stop or delete returns once admitted, with a durable cleanup job. x-space-skip-cluster-sync skips only the admission-time dirty ref; completion still marks the sandbox dirty. - A template-manager peer may clean up only template-owned sandboxes. Shutdown refuses cleanup. - Auto-lifecycle delete guards: a template filter or non-canonical id is refused, and a stale policy aborts with the CAS-lost metadata. - Pool-clone disposal through Delete: refused while publication owns the clone and, after quarantine, for template-manager or auto-lifecycle callers or while cleanup is busy; an operator delete re-arms it. Template lookup connects to the fixed template-manager socket, so every create request carries an auto-lifecycle policy with an unknown authority, the last check before lookup. Each case asserts code and message, so removing the refusal a case pins stops at that barrier with a refusal the case does not expect. Auto-lifecycle authority cases have no later barrier and are left until template resolution can be injected. The two earlier forwarded-peer create tests are folded into the new table; their auto-lifecycle case is among those deferred. Stop and delete reach node cleanup only through the cleanup executor's existing test dispatch, which waits on test-granted permits. Services are built from explicit options: a fixed node id, absent sockets, and runtime liveness reading the test-owned run directory. They run in a child process with the feature-flag provider environment cleared. Admitted stops and ordinary deletes stat the fixed sidecar pid path of the fresh sandbox id. The absent-sandbox case stats the fixed sandbox cache path of a fresh id. Every wait on cleanup, including correct runs and shutdown, is bounded by 30 seconds. No production code changes.
1062578 to
bf4c74f
Compare
Review — 4 findingsMedium
Low
It's tests-only. The only file is Verified against
The one that survived was the auto-lifecycle authority check (finding 1). Findings 2–4 are from reading. |
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:
I'll fold items 2–4 into the next sbx-mgr test change, which moves these tests onto the new seams. |
Adds characterization tests for the sandbox-manager
Stop,DeleteandCreateRPC handlers incrate::service::grpc_core, so the upcoming lifecycle consolidation (one state-write helper, splitting the service struct) has a safety net at the RPC layer. Tests only: the only file changed isservice/grpc_core/tests.rs, which was already wired in.Before this change, the RPC layer of
stopanddelete_with_auto_lifecycle_expectationhad no tests for its own branches. Nothing covered the blocking vs non-blocking split, the admission-time dirty ref and itsx-space-skip-cluster-syncgate, the empty template filter, the stop-only live-runtime selection, the shutdown refusal, the auto-lifecycle expectation guards, or any of delete's pool-clone disposal branches. The lower layers (target resolution, admission, cleanup completion) already have their own tests, so these tests assert only what the RPC layer adds: return values, persisted rows, cleanup episodes, durable cleanup jobs, node dirty refs, and the status codes and metadata that callers match on.Tests
forwarded_create_refuses_owner_and_network_policy_authority_before_template_lookupcreatefrom a forwarded (node-api) peer, for everyBootMode: asking for the template-manager owner →PermissionDenied; aLocalnetwork-policy authority →PermissionDenied; an unspecified authority →InvalidArgument"…is unspecified"; an unknown authority 99 →InvalidArgument"…99 is unknown". Two accepting controls: no network policy, and aClusterauthority, both pass the network-policy check and reach the barrier (below). No row is created. Replaces the two earlier forwarded-peer create tests and keeps their network-policy case.core_rpcs_refuse_unauthenticated_and_unlisted_peers_without_mutationcreate,stop,delete: no peer credentials →Unauthenticated. A node-ctl peer (not on the allow list) →PermissionDenied; for create this is pinned by the peer check's message, because the owner check refuses node-ctl too. No row, cleanup episode, cleanup job or dirty ref appears.stop_selects_only_a_live_runtime_and_both_treat_an_empty_template_filter_as_nonestopselects a sandbox only when its runtime is live;deleteselects it either way. An emptytemplate_filterbehaves like no filter. A different template selects nothing. An absent sandbox is an idempotentOkwith no ids. When a sandbox is selected, blocking cleanup completes (stopped, or the row is removed) and the sandbox has a dirty ref.nonblocking_cleanup_returns_once_admitted_and_honors_skip_node_publishstopping/deletingwith a durable cleanup job. The RPC adds an admission-time dirty ref, andx-space-skip-cluster-syncskips it; the test checks the ref at return time, before node cleanup is released. Releasing node cleanup completes the job, after which the sandbox has a dirty ref with or without the header: the header defers the dirty ref rather than suppressing it. No current caller sends this header to Stop or Delete: its only sender is node-ctl, which both RPCs refuse.template_manager_cleanup_requires_a_template_owned_sandboxPermissionDeniedand the row is untouched.cleanup_rpcs_refuse_after_shutdown_without_mutationUnavailableand leave the row untouched.auto_lifecycle_delete_requires_one_canonical_id_and_the_expected_policyInternalwhen given a template filter or a non-canonical (uppercase) id. A stale policy fingerprint returnsAbortedcarryingAUTO_LIFECYCLE_DELETE_CAS_LOST_METADATA(the reconciler matches on this key), and no receipt is created. The current policy deletes the row.delete_of_a_pool_clone_disposal_requires_an_operator_after_quarantineFailedPrecondition. After quarantine, a template-manager or auto-lifecycle delete →FailedPrecondition, and a held cleanup fence →Unavailable. In every refusal the state, episode and jobs are unchanged. An operator delete re-arms the disposal and returns: non-blocking leaves a pending cleanup job, blocking removes the row and episode.Create cases never reach template lookup
resolve_template_pathsconnects to the fixed template-manager socket, and a live template manager can pull artifacts and reconcile snapshots. These tests cannot redirect it without a production seam. So every create request is refused before that call, both on the correct path and under every mutation below.Each create request starts from a barrier: an auto-lifecycle policy with an unknown authority, 98. Create parses that authority last before template lookup, and it refuses with
InvalidArgument"policy authority proto value 98 is unknown", a message no case expects for its own refusal. Every case asserts both code and message, so reaching the barrier in place of the case's own refusal is a failure. Pre-lookup refusal order increate:authorize_peer,authorize_create_sandbox_owner,validate_create_request, network-policy authority parse plusrequire_local_authority_peer, auto-lifecycle authority parse plusrequire_local_authority_peer.authorize_peerauthorize_peerrequire_local_authority_peerrequire_local_authority_peerif it parses asLocal; barrier, with a different message, if it parses asClusterMutations
Each mutation was a scratch edit to
grpc_core.rs(M33 editscleanup.rs), run against the final test file and then reverted. All 33 made the listed test fail. For create mutations, the third column shows the refusal the failing case actually got.LocalPermissionDenied"authority=local requires a local operator peer…" in place of "…is unspecified"Clusterrequire_local_authority_peerInvalidArgumentin place ofPermissionDeniedPermissionDeniedin place of the barrierauthorize_create_sandbox_ownerInvalidArgumentin place ofPermissionDeniedInvalidArgumentin place ofUnauthenticatedrunning_only = falseNotFoundNotFoundcleanup.rs)falsefor the template-manager flag to both target resolution and admissionfalsefor the template-manager flag to both target resolution and admissioncleanup_request_permitcleanup_request_permitFor M15 and M16, target resolution and admission each refuse a template-manager peer on a non-template sandbox on their own. Removing the flag from only one layer is masked by the other, so these mutations remove it from both.
Coverage of the scoped functions
Measured with
cargo-llvm-cov 0.9.1on toolchain 1.98.0 over the fullcargo nextest run -p pplx-space-sbx-mgrsuite. The pinned toolchain'sllvm-cov exportsegfaults on these binaries, so as a workaround the export used the nightlyllvm-toolsllvm-covreader while profiles were merged with 1.98.0'sllvm-profdata. A line counts as covered by LLVM's line rule, restricted to each function's source span inservice/grpc_core.rs. "Before" uses the starting commit's test file. The tests that construct a service run in a child process (see Host isolation), and their profiles are collected.stopdelete_with_auto_lifecycle_expectationdeletefinish_unpersisted_delete_cleanupcreate(whole function)createthrough template lookupThe starting commit's auto-lifecycle Local case covered
create's auto-lifecyclerequire_local_authority_peercall and the line before it. This PR defers that case (see Follow-ups), so those two lines are no longer covered. The rest of create's uncovered pre-lookup lines are the lookup itself and lines reached only after every pre-lookup check passes.Still uncovered in stop and delete: the unpersisted stop/delete path (stop lines 638–657, delete lines 850–865), the stop event trigger (line 605), and two race-only arms in clone disposal: the template recheck under the fence (line 783) and a failed re-arm (line 788).
Host isolation
Environment: every test that constructs a service first runs itself in a child process via
crate::service::tests::enter_route_test_process. The child clearsEPPO_SDK_KEY,SPACE_FLAGD_ENABLED,SPACE_NODE_ID,SPACE_NODE_ROLEand the route-fence override, so service construction cannot start a feature-flag provider. Other environment variables are inherited.Service construction: services are built from fully explicit options, never
SandboxManagerServiceOptions::default(), whose node identity falls back to reading/space/etc/node-id. Instead:Construction then reads environment variables, and resolves the six peer account names (
space-nodeapi,space-tmplmgr,space-nodectl,space-sbxmgr,dev,deploy) through the host user database (getpwnam, which can read/etc/passwdvia NSS). Both are read-only, and it reads no SPACE host files or sockets.Create: no case and no listed mutation reaches
resolve_template_paths. The refusal that bars each is in the tables above.Node cleanup: stop and delete reach node cleanup only through the cleanup executor's existing
#[cfg(test)]dispatch, so the guest flush and node-manager calls are never reached. Each fixture's dispatch waits for permits the test grants. The runtime-liveness probe reads a test-owned temporary run directory. The executor task runs on the service's task tracker and is drained at the end of every case.Non-destructive reads of fixed paths:
/space/run/firecracker/sandboxes/<id>/space-netgtw-svc.pidfor the fresh sandbox id. Nothing exists there, and with no committed gateway pid nothing is probed.Successful stops and deletes notify the process-global dirty-outbox
Notify, which simply records a permit (no worker waits on it in tests).Waits: every wait on cleanup is bounded by 30 s. That covers blocking RPC calls, the non-blocking calls, the clone-disposal delete,
wait_cleanup_job, the wait for the completion dirty ref, and the fixture's shutdown drain. These bounds also cover correct runs: the executor wakes on notify with a one-second tick fallback, so the bound leaves room for a loaded CI runner. A regression that blocks on held node cleanup fails instead of hanging.Findings
Clone-disposal delete ignores
x-space-skip-cluster-syncfor its dirty ref. The ordinary delete path adds the admission-time dirty ref only when node publish is allowed (grpc_core.rs:901–903). The pool-clone disposal branch callsmark_sandbox_dirtyunconditionally (grpc_core.rs:819). To reproduce:Creatingrow with aDeletePoolClonecleanup episode, as ininsert_armed_pool_clone).crate::db::mark_quarantined_from_state.Delete { blocking: false }from root withx-space-skip-cluster-sync: true.The call returns the id, and
crate::db::list_node_dirty_refsalready contains it. Today this only marks the sandbox dirty earlier than cleanup completion would, which marks it dirty regardless. No caller is affected: no current caller sends this header to Delete. The tests do not assert this behavior either way.Follow-ups
resolve_template_pathsconnects to the fixed template-manager socket, so these create cases have no later pre-lookup barrier and are deferred:Localauthority from a forwarded peer (PermissionDenied);InvalidArgument);Clusterauto-lifecycle accepting control;create_with_id.admission.is_unpersisted_for_sandbox(..)is true, the cleanup goes throughrun_cleanup_after_persistence_failure. That path flushes the guest and connects to node-manager directly, bypassing the executor's test dispatch.check_cleanup_selection) and theAborted"clone disposal hold changed" result each require a concurrent writer between target resolution and the fenced re-arm. Testing them needs an interleaving hook.yield_now()until the dirty ref appears. It ends within microseconds on a correct run, but under a regression it spins one core until the 30 s bound. A short async sleep between checks would be gentler.Validation
cargo fmt --all --checkcargo clippy --locked --all-targets --all-features -p pplx-space-sbx-mgr -- -D warningscargo nextest run --locked -p pplx-space-sbx-mgr --retries 0 -E 'test(/grpc_core::tests/)'cargo nextest run --locked -p pplx-space-sbx-mgr --retries 0, 3 consecutive runs,SPACE_CI_REQUIRE_TEST_DEPS=1, repository Postgres setup script live#[ignore]test that needs root)grpc_core.rs, the cleanup, create-support, cache-cleanup, activation-support and watchdog modules,service/mod.rsandservice/tests.rsare identical on the current baseAI assistance: these tests and this description were written with Claude Code.
🤖 Generated with Claude Code