Repository navigation
api: render the admin and ECW sandbox state from one typed input - #1238
Conversation
|
Thanks for opening this pull request! A SPACE maintainer will take a look. CONTRIBUTING.md explains how we review outside contributions. |
The state a caller sees was decided in several places: the controller response overwrote `lifecycle` and `status` with the node's observed status, and the ECW translator then re-read that merged string, so no render could tell the stored lifecycle from the node's observation. `SandboxStateInput` now carries the row's stored lifecycle and the node's observed status separately, on `SandboxResponse` as a field that is never serialized. `SandboxResponse::set_state` is the one writer: it stores the input and renders `lifecycle` and `status` from it, and the cached and observed-only response constructors call it. One render per surface reads the input: - the admin response's `lifecycle` and `status` (`SandboxStateInput::reported_lifecycle`): the observed status, else the stored lifecycle, as before; - the ECW state (`ecw_sandbox_state`) and terminal classification (`ecw_terminal_state`), through `ecw_lifecycle`, which keeps today's observed-status-first precedence and the rule that every lifecycle ECW does not name reads as running. The existing entry points (`translate_sandbox_state`, `terminal_sandbox_error`, `translate_lifecycle_state`) keep their signatures and delegate, so their callers in the ECW connect, get, list, timeout and refresh handlers and the native timeout gate do not change. ECW's translation stays at the HTTP boundary and its pause mapping to SPACE suspend is untouched. The native sandbox state is not routed: it already renders from the stored lifecycle alone through `public_sandbox_projection::public_lifecycle_state`, and its row query reads the node's observed status only for `failure_code`. A Postgres-backed table test pins, for every stored lifecycle and every observation, what each caller-facing surface reports: the admin response's lifecycle and status, the ECW list, get and terminal gate, the ECW connect wake and touch-recovery decisions, and the native sandbox state and Ready condition. The stored axis is `StoredSandboxLifecycle::all_as_str()`, the placement crate's non-canonical rows and a padded terminal value. The observations are no record, a NULL status, every sandbox-manager label, and padded, mixed-case, unknown, empty and whitespace-only statuses. The table makes the current divergence visible (the ECW list reads the stored lifecycle, ECW get and connect read the node's status first, native reads only the stored lifecycle) so a later change to that precedence shows up as a table diff. The table was generated by running it against `main`'s code (`main` plus the test file), and the same snapshot passes unchanged with this change, so every output is identical. ECW unit tests that fake a state now set it through `set_state`.
ab5ac5c to
5baa765
Compare
zibi-pplx
left a comment
There was a problem hiding this comment.
Approving. The production constructors keep today's outputs, and the characterization table is a useful record of where the surfaces disagree. Two suggestions:
[medium] lifecycle and status are still writable alongside state_input. Both stay pub on SandboxResponse (crates/libs/api/src/models.rs). ECW now reads state_input. A future direct assignment to status would change the admin JSON while ECW keeps reading the unchanged state_input, even under today's precedence. The description acknowledges this ("a direct status write still compiles, and ECW ignores it"). No production caller makes such a write today, so this isn't blocking. But it's cheap to close now, before the follow-up: make the two fields private behind set_state, or render them from state_input at serialization with serialize_with or a custom Serialize.
[low] For the precedence follow-up. The 588-row snapshot will change in many rows at once. It would help reviewers if that PR listed which (stored, observed) classes it intends to change, so the snapshot diff can be checked against the list.
Summary
The sandbox state a caller sees was decided in several places. The controller response (
sandbox_to_cached_responseincrates/libs/api/src/controllers/sandbox/responses.rs) overwrote the row'slifecycleandstatuswith the node's observed status whenever an observed record existed. ECW's translator (crates/libs/api/src/routes/e2b/translate.rs) then re-read that merged string. No render could tell the stored lifecycle from the node's observation, so changing ECW's precedence meant editing the controller response, which also changes the admin API.This PR changes no output. It adds a characterization table and routes the admin and ECW renders through one typed input:
SandboxStateInput(crates/libs/api/src/models/sandbox_state.rs) carries the row's stored lifecycle and the node's observed status separately. It rides onSandboxResponseasstate_input, which is never serialized.SandboxResponse::set_state(crates/libs/api/src/models.rs) is the one writer. It stores the input and renderslifecycleandstatusfrom it.sandbox_to_cached_responseandobserved_only_sandbox_responsecall it.sandbox_to_responsekeeps its struct literal, and both fields come fromstate_input.reported_lifecycle().One render per surface reads the input:
lifecycleandstatus:SandboxStateInput::reported_lifecycle. Observed status first, else stored lifecycle, as before.ecw_sandbox_state.ecw_terminal_state.ecw_lifecycle. It keeps today's precedence (observed status, then stored lifecycle, thenstarted), trims and lowercases, and keeps the rule that every lifecycle ECW does not name reads as running. A later precedence in which the stored lifecycle wins is a change toecw_lifecyclealone. It also moves the native timeout gate (routes/public/timeout.rs), which readstranslate_sandbox_state. The ECW list never carries an observation (cached_sandbox_response), so a precedence in which the observation wins also needs the list to read it.The existing entry points keep their signatures and delegate:
translate_sandbox_stateterminal_sandbox_errortranslate_lifecycle_state(the stored-only read inshould_wake_on_connect)Their callers are unchanged: get, list, connect, touch recovery, timeout and refresh in
routes/e2b/lifecycle.rs, and the native timeout gate inroutes/public/timeout.rs.ECW translation stays at the HTTP boundary, and ECW
pausestill maps to SPACE suspend.Native
PublicSandbox.stateandconditionsare not routed. They do not render fromSandboxResponse:routes/public/sandboxes.rsreads its own row query and renders from the stored lifecycle alone, throughpublic_sandbox_projection::public_lifecycle_state. That query reads the node's observed status only forfailure_code. The table pins native outputs next to the others.The new table (
routes/e2b/state_render_tests.rsand its snapshot) covers 21 stored values × 28 observations, 588 rows.StoredSandboxLifecycle::all_as_str()(the 18 live and tombstone values), then the placement crate'stest_support::NON_CANONICAL_ROWS(bogus,Suspended), then a padded terminalStopped.For each row it records what every surface returns through Postgres:
lifecycle/status;Ready.It shows the divergence the later fix addresses. For example, stored
stoppedwith observedstartedis hidden from the ECW list,runningon ECW get, andstoppedon native. An empty observed status overrides the stored lifecycle in the admin response and ECW get. The table was generated by running it againstmain's code (mainplus the test file). The same snapshot passes unchanged at this head.Validation
At head
5baa765a(tree005dfea3), one9d36fd7.Unit and Postgres tests
cargo nextest run --locked -p pplx-space-api: 1563/1564 passed, 1 skipped (the#[ignore]d occupancy benchmark). With--features kubernetes: 2143/2144 passed, 1 skipped. Both Postgres and Redis setup scripts ran, and no fixture-backed test was skipped.routes::e2b::tests::detail_reports_authoritative_federated_ownership_not_metadata, fails identically on the base commit. Its inline JSON snapshot assumes serde_jsonpreserve_order, which these-pbuilds do not enable.mainplus the test file,INSTA_UPDATE=always cargo nextest run --locked -p pplx-space-api -E 'test(each_surface_renders_stored_lifecycle_and_observed_status)'wrote the snapshot. At this head, the same test withINSTA_UPDATE=nopasses against that snapshot.ecw_lifecycleprefer the stored lifecycle fails the table test. Reverted, not committed.routes/e2b/tests.rs: every fake that an ECW state read consumes goes throughset_state.native_sandbox_responsebuilds withset_state(SandboxStateInput::stored("started")).SandboxStateInput::new(Some(<row lifecycle>), Some("started"))for each row, which is whattracked_responsebuilds.set_stateinstead of writinglifecycle,statusorstate_inputdirectly. The literal inmetadata_uses_sandbox_metadatareads no state and is unchanged. No expectation changed.Static checks
cargo clippy --locked -p pplx-space-api -p pplx-space-api-svc --all-targets --features kubernetes -- -D warnings: clean. Withoutkubernetes: two warnings that are onmaintoo, both in lines this PR does not touch.cargo arch: no new finding for files this PR touches. Its advisory thatcontrollers/sandbox/mod.rsshrank (1086 → 1084) comes frommain; this PR changes one import line there without changing its line count.Kind at this tree (fresh cluster via
infra/space/kind/scripts/up.sh, 5 min 10 s; images built from this head's tree,005dfea3). Scenarios ran withscripts/tests/smoke-kind-typed.sh's environment andSPACE_SMOKE_KIND_COLD_RECEIPTSset as Kind CI does.ecw.cold_create_environment,ecw.command_round_trip,ecw.sandbox_info,ecw.sandbox_list_pagination,ecw.sandbox_delete,ecw.filesystem_round_trip,ecw.pause_connect,ecw.pause_connect_race,ecw.pause_timeout_requires_connect,ecw.access_idle_reconnect_preserves_workspace,ecw.refresh_keepalive,ecw.timeout_updateandecw.timeout_rearm.native_contract.*scenarios (--category native_contract, 6 min 49 s) andnative.m1_generated_sdk_conformance(15 s).Severity and risk
Severity: does not apply. It is a behaviour-preserving refactor.
Risk: API only. It changes no SQL, no wire shape and no stored data.
state_inputis#[serde(skip)], so admin JSON is byte-identical. The response constructors inresponses.rsset it throughset_stateor from the same render. A struct literal or a directstatuswrite still compiles, and ECW ignores it in favour ofstate_input; a default response reads asstartedin ECW, as it did before. The one hand-built response in production, the fork result ine2b.rs, uses onlyid. Rollback: revert the commit.Rollout
No migration and no flag. Old and new API pods render identical outputs, so a mixed-version rollout is indistinguishable to callers. No
sql!locator moves: no SQL changed.Not covered
scripts/tests/smoke-kind-typed.sh allsuite was not run at this head; only the 13 ECW and 15 native scenarios listed under Validation.SandboxStateInput. It already renders from the stored lifecycle alone, and its row query reads the observed status only forfailure_code(see Summary).responses.rs: [space] fence every placement writer against the provider-terminate path #559, [cluster-controller] re-place an evacuated stopped sandbox as stopped on another node, materialized by the shim without a VM #826, [api] record the activating client and label sandbox pods with it #1068models.rs: [api] record the activating client and label sandbox pods with it #1068 (it editsSandbox, notSandboxResponse)routes/e2b/tests.rs: [api] rate-limit activations per client key, atomically with the cluster bucket #1131, [api] record the activating client and label sandbox pods with it #1068, [space-api] preserve CommandIdle template default across ECW lifecycle endpoints #538, Accept client TLS interception exemptions on ECW creates #194translate.rs: [space-api] preserve CommandIdle template default across ECW lifecycle endpoints #538, Accept client TLS interception exemptions on ECW creates #194routes/e2b/lifecycle.rs(not modified): [api] rate-limit activations per client key, atomically with the cluster bucket #1131, [api] record the activating client and label sandbox pods with it #1068, [space] fence every placement writer against the provider-terminate path #559, [space-api] preserve CommandIdle template default across ECW lifecycle endpoints #538routes/public/timeout.rs(not modified): [space-api] preserve CommandIdle template default across ECW lifecycle endpoints #538controllers/sandbox/mod.rs: api: permalaunch space-api-timeout-lock-bypass #1178, api: cap each client's live sandboxes, counted from the pod reflector and a Redis pending set #1172, [api] rate-limit activations per client key, atomically with the cluster bucket #1131, [api] record the activating client and label sandbox pods with it #1068, api: refuse a cold create with a 503 when every eligible node's network slots are held #1061, [cluster-controller] give up on pod-less drain residents only when the node vouches #705, feat(api): list guest files matching a glob in one call (files:glob) #675, [space] fence every placement writer against the provider-terminate path #559, [space-api] preserve CommandIdle template default across ECW lifecycle endpoints #538, Bound reconnect recovery on unpublished local suspends #350AI assistance: An AI agent (Claude Code) wrote the code, the tests and this description, and ran the validation.