Repository navigation
api: page and filter the admin sandbox list, count spaced versions, and share one sandbox ID parser - #1321
Conversation
|
Thanks for opening this pull request! A SPACE maintainer will take a look. CONTRIBUTING.md explains how we review outside contributions. |
Review — 8 findingsHigh
Low
Verified against What I ran:
What I traced by reading:
Not done: a Kind run, or a rerun of the timing measurements. |
Review — follow-up, 1 new findingThe head is still High
Not a finding
What I ran, on the rebased scratch copy with
|
… ID form
GET /admin/api/v1/sandboxes takes an opaque keyset cursor (the response is
then {sandboxes, next_cursor}) and filters by lifecycle, template, node,
assignment, user, creation time and name or ID prefix. Without a cursor it
answers the same bare array as before. The node-reported rows stay on the
first page of the unfiltered list only; the org-scoped list's test moves
with the handler into sandbox_list.
Every admin sandbox route and the debug route resolve sbx_<uuid>, a dashed
or undashed UUID and the sandbox pod name through one parser, and answer
400 for any other form.
GET /admin/api/v1/sandboxes/spaced-versions counts non-deleted sandboxes by
template, reported spaced version and lifecycle.
The spaced-version count now tallies the full list as the console does: every row whose status is not deleted, by the template, spaced version, status and resource type the list response shows, including the node-reported rows when no org_id is given. The admin filters, cursor and count move into the sandbox controller's admin_list module, which pages through the ECW list's page walk and full-row select; a page stops at the list byte budget, and the unpaged array keeps its limit with no budget. Malformed query parameters on the list and the count answer the admin JSON error, and with cursor an unknown parameter answers 400. A prefix with a NUL answers 400, created_before rounds up to the microsecond Postgres keeps, and the sandbox ID parser ignores surrounding whitespace.
The node_id and assigned filters read sandbox_direct_route_identities in a
new join, which no owning read covers yet; they go until the Direct route
has one. The console lists a node's sandboxes with GET /nodes/{id}/sandboxes.
The unpaged array runs a plain list statement again, without the page
walk's size estimate it never used, and the admin page module moves under
list_page so the sandbox controller's module list does not grow.
The count test gains a stored-deleted row its node still reports started.
pplx-space-placement is a store, and pplx-space-api is not one of its store_users, so the normal dependency the lifecycle filter added fails the required layers.store_edges check. The filter and the count now use AdminStoredLifecycle, a SandboxLifecycle state or one of the two tombstones, parsed with the same case and whitespace normalization. A test pins it to StoredSandboxLifecycle::all through the existing test-support dependency.
a12ff59 to
eb9be9f
Compare
CodeQL's rust/cleartext-logging flags assertion messages that format sandbox IDs, ID forms and response bodies. Label each table case with a static name or index instead of printing the value. Every check is unchanged.
Review — re-review at
|
the-gigi-pplx
left a comment
There was a problem hiding this comment.
Approved. Both earlier blockers are fixed; the remaining findings in the review comment above are non-blocking.
|
Thanks. Merging this as approved; the lows go into #1385, which edits the same files, as separate commits:
One I'll leave as is: the duplicated filter |
The admin /commands route mirrors a public, org-scoped route, as the terminal-session and snapshot routes do, so it now lives with them in sandboxes::guest_access and resolves its sandbox through the same helper. Its behaviour is unchanged. This keeps admin/sandboxes.rs within archcheck's 1,000-line limit, which the rebase onto #1321 had pushed it past, and the error-body note in admin/mod.rs names the mirror routes.
The admin /commands route mirrors a public, org-scoped route, as the terminal-session and snapshot routes do, so it now lives with them in sandboxes::guest_access and resolves its sandbox through the same helper. Its behaviour is unchanged. This keeps admin/sandboxes.rs within archcheck's 1,000-line limit, which the rebase onto #1321 had pushed it past, and the error-body note in admin/mod.rs names the mirror routes.
The admin /commands route mirrors a public, org-scoped route, as the terminal-session and snapshot routes do, so it now lives with them in sandboxes::guest_access and resolves its sandbox through the same helper. Its behaviour is unchanged. This keeps admin/sandboxes.rs within archcheck's 1,000-line limit, which the rebase onto #1321 had pushed it past, and the error-body note in admin/mod.rs names the mirror routes.
…erplexityai#1302) * api: let operators manage keys, terminals and snapshots across orgs Managing-org admins can create, list and revoke API keys for any organization, open terminal sessions in any sandbox, list any sandbox's snapshots, and roll back through :force-rollback, which now carries the public :rollback body. Each runs under the owning organization. Admin audit events record the organization and resource a handler acted on, including reads of another organization's snapshots. Key auth records last_used_at at most once per five minutes, revocation keeps the row with revoked_at, and org branding values are validated. * api: choose a key's user at creation and gate admin rollback Admin key creation takes an optional user_id, defaulting to the org's oldest user, and stores it as created_by, so every release resolves the key's actor with the existing lookup; the auth change is reverted. :force-rollback refuses unless rollback is enabled for the sandbox's org, and shares the public body parser. Audit targets are attached to refusals once the resource resolves, and to admin sandbox create and update. The admin terminal route has its own idempotency scope. Branding is checked only where a patch changes it, revoke is one statement, and the admin OpenAPI entries are edited in place. * api: keep own-org keys acting as the caller and scope admin commands Without user_id, a key for the caller's own organization acts as the caller, as before; a key for another organization keeps the oldest-user default. A chosen user deleted before the insert answers 409 instead of 500. The admin commands route records its idempotency keys under its own path template, as the admin terminal route does, so each route keeps its own idempotency records. * api: resolve the admin terminal and snapshot routes' sandbox IDs with the shared parser The admin terminal-session and snapshot-list routes accepted only an sbx_ ID, while every other admin sandbox route takes the forms parse_admin_sandbox_id accepts: a dashed or undashed UUID in either case, or the sandbox pod name. They now resolve through it too, and record the canonical sbx_ ID in the session, the idempotency fingerprint and the audit target. The OpenAPI entries describe the accepted forms. A test sends each accepted form to the terminal, commands, :force-rollback and snapshot routes under one idempotency key, and checks that every form replays the first request and is audited against the canonical ID, and that refused forms answer 400. * api: require user_id for another organization's key A key created for an organization other than the caller's, without user_id, answered with a key acting as that organization's oldest user, a choice the operator never made. It now answers 400 "user_id is required for another organization's key". A key for the caller's own organization without user_id still acts as the caller. The oldest-user lookup and the 409 for an organization with no users are gone with the default; the handler doc, the OpenAPI entry and the tests follow. * api: say why only :force-rollback checks the hidden-operation gate Checkpoint and enable_snapshots are hidden on the public API too, but the admin surface ran them ungated before this change, and they only add snapshots. Rollback is gated because this route could not run it until it carried the :rollback body, and it rolls the sandbox back to a snapshot. * api: move the admin commands route beside the other mirror routes The admin /commands route mirrors a public, org-scoped route, as the terminal-session and snapshot routes do, so it now lives with them in sandboxes::guest_access and resolves its sandbox through the same helper. Its behaviour is unchanged. This keeps admin/sandboxes.rs within archcheck's 1,000-line limit, which the rebase onto perplexityai#1321 had pushed it past, and the error-body note in admin/mod.rs names the mirror routes. * api: seed a snapshot in the admin ID-form test and correct a key test comment The ID-form test compared each form's snapshot page, but no snapshot was seeded, so every form answered an empty page and the comparison could not fail. It now seeds one snapshot and compares the first snapshot's id across forms, then checks that it is the seeded one, so every form must resolve the same sandbox. The chosen-user key test's comment said choosing either user differs from picking the organization's first user, but the seeded member is the oldest user. It now says what the test distinguishes: a key that chooses the tenant admin acts as that admin, not as the oldest user. * api: keep a revoked key's last use, and test the deleted-user 409 through the router A key revoked after its lookup read it could still have its last_used_at stamped, because the recording UPDATE's subquery did not check revoked_at. It now does, and a test revokes a key between its lookup and the recording of its use. The deleted-user 409 test called create_error directly, so it still passed with the handler reverted to support::db_error. It now creates the key through the admin router, with a test-database trigger that deletes the chosen user as the key row is inserted. The admin /commands route's doc comment now says that it does not apply the public route's lifecycle admission or implicit wake.
Summary
The admin sandbox list (
GET /admin/api/v1/sandboxes) returned at most the newest 5,000 rows, filtered only byorg_id. The admin console could therefore reach an older sandbox only by exact lookup, and filtered everything else in the browser. Admin sandbox routes also took different identifier forms. Merging this:Pages the list by cursor.
created_atdescending, then the ID.cursorreturns{sandboxes, next_cursor}. An emptycursorasks for the first page, andnext_cursorisnullon the last page.created_atand ID). It only marks a position in a list the caller can already read in full.(created_at, id)position. Paging is not a snapshot: concurrent changes can affect membership.created_atis the inserting transaction'snow(), so a transaction that began before a page boundary and commits later lands on a later page. Keep the filters unchanged between pages.cursorthe route answers the bare array as before: a plain list statement with no byte budget and no size estimate, because it has no cursor to continue from.Adds server-side filters that combine with
org_id:lifecycle: comma-separated values of the stored lifecycle, includingdeletedandcleanup_required. No response field shows it. The response'slifecycleandstatusreport the node's observed status when there is one, so a row can show another value. Filtering on what the response shows would mean filtering on free-form node status text joined from observed state, so the filter keeps to the validated stored vocabulary.template: the row's own template. A row without one does not match, although the response'stemplateand the count fall back to the template its node reports.active_node_id(the direct route's current node, else the row's) means reading the Direct-only route table, which no owning read covers yet, sonode_idandassignedare left out. The console can list a node's sandboxes with the existingGET /nodes/{id}/sandboxes. The two filters can follow once the Direct route has an owning read.user_id.created_afterandcreated_before: RFC 3339, both exclusive. Postgres keeps microseconds, so acreated_beforeinside a microsecond rounds up, and a row created earlier in that microsecond still matches.prefix: a case-sensitive name prefix, or an ID prefix with optionalsbx_and dashes where a UUID has them. A NUL character answers 400.Every filter,
org_idincluded, leaves out the node-reported rows: they belong to no org, and none of the filters can be applied to them.Malformed values, typed ones (
limit,org_id,user_id) included, answer 400 with the admin JSON{"error": …}rather than axum's plain-text rejection. Withcursor, an unknown query parameter answers 400, so a misspelt filter cannot return unfiltered pages. Withoutcursor, unknown parameters are still ignored for existing callers.Gives the admin sandbox routes one ID parser.
GETandPATCH /sandboxes/{id},POST /sandboxes/{id}:force-{action},POST /sandboxes/{id}/commandsandGET /debug/sandbox/{id}.sbx_<uuid>, a bare UUID, and an undashed UUID (hex in either case).space-sbx-<undashed uuid>, with or without its-g<generation>suffix, and ignores surrounding whitespace.sbx_followed by any spellinguuidparses. The debug route took a dashed, undashed, braced orurn:uuid:UUID withoutsbx_.urn:uuid:spellings,SBX_and thesandbox:<uuid>log tag. The console'sparseSandboxRefalso accepts those last two and normalizes them before sending, so it needs no change.sbx_ID, so one sandbox named two ways is one idempotency target. The request-leveladmin_audit_events.pathstill records the path as sent.Adds
GET /admin/api/v1/sandboxes/spaced-versions, with an optionalorg_id. It answers{counts: [{template, spaced_version, status, resource_type, count}]}: the console's tally of the full list, computed in one statement.statusis notdeleted, grouped by the fields the list response shows for that row:template: the row's template, else the node's, else the template the node's inventory holds for the image it runs, else the pool's;spaced_version: what the node last reported,nullbefore a report;status: the node's observed status, else the stored lifecycle. It is the same value as the response'sstatusandlifecycle. Acleanup_requiredrow whose node still reportsstartedtherefore counts asstarted, as it does in the console;resource_type:sandbox,pool_slotorobserved_local.org_idit includes the node-reported rows with no sandbox row, as the unscoped list does.status == "started"bytemplateandspaced_version, and get its listed and node-reported totals fromresource_type.The filters, page statements and count live in the sandbox controller's new
list_page::adminmodule, and the route keeps HTTP parsing only. The admin candidate statement is one fixed statement with bound nullable filters. A paged request composes it into the ECW list's existing page walk (list_page::page_rows_cte) and full-row select (org::scoped_page_sql, throughsandbox_query), which interpolate SQL fragments. That composition predates this PR, which reuses it so both lists resolve rows and stop at the byte budget the same way.ListPagedecides whether another page follows. The unpaged array runs a separate fixed statement with the same filters, oversandbox_query's shared columns and joins, asfetch_sandboxdoes.The list handler moved from
sandboxes.rstoroutes/admin/sandbox_list.rs. That takessandboxes.rsunder the file-size limit and out ofarchitecture-baseline.toml. The org-scoped list test from #1248 called the moved handler. Its two cases, anorg_idlist leaving out the node-reported rows and the unscoped list keeping them, are now part ofthe_list_pages_by_cursor_and_keeps_the_unpaged_array, which covers both the bare array and the paged walk.The admin OpenAPI stub documents the new parameters and their semantics, the count route,
GET /sandboxes/{sandbox_id}and the ID forms.pplx-space-apigains no production dependency onpplx-space-placement, whichmain's layer rules forbid. Thelifecyclefilter validates against an API-local list of the stored lifecycles, theSandboxLifecyclevalues plus thedeletedandcleanup_requiredtombstones, with the same normalization. A unit test pins that list toStoredSandboxLifecycle::all()through the existing test-support dev-dependency.Cost and indexes
No migration. Measured on a migrated test database holding 300,000 synthetic sandboxes, 205,000 observed rows, 5,000 rowless pool slots, and a template inventory on 50 nodes:
org_id, and 25–35 ms for aprefix. A page of 5,000 takes 216 ms and stops at the byte budget after 4,072 rows.main's statement on the same data set.Earlier
EXPLAIN ANALYZEruns of the filter predicates on the same data set:idx_sandboxes_created_atin list order: no filter,cursor,org_id,lifecycle,user_id,created_after/created_before, and a commontemplate.template;prefix: its name half has no index, and theORkeeps the ID half off the primary key;An index for
templateor name would serve one filter each and add write cost to the busiest table. These timings seemed acceptable for an operator route, so this PR adds none. If one is wanted, it can land as its own migration first.This unblocks the console work that depends on it:
Validation
mainat c5fdf0c (after api: expose node, template build, quarantine and snapshot-storage state to the admin console #1307 merged). At this head,cargo nextest run -p pplx-space-api --all-features: 2,194 pass, 1 skipped (the profile's standing skip). api: expose node, template build, quarantine and snapshot-storage state to the admin console #1307's debug-route test now passes thesbx_<uuid>string the route takes.controllers::sandbox::list_page::admin::tests):created_atand rows created between pages;list_responsesover every listed row). The seeds include an inventory-resolved template, a node template, acleanup_requiredrow and a stored-deletedrow whose nodes still report themstarted(both counted, as in the console), deleted rows, other orgs, a rowless pool slot and a rowless observed-local row.routes::admin::sandbox_list::tests):cursor, and the cursor walk with it, for anorg_idlist and the unscoped one, withnext_cursor: nullon the last page;node_id, refused withcursorand ignored without it;created_beforeinside a microsecond;routes::admin::sandbox_id::testscovers the accepted and refused forms, plus the pod builder's own names under thekubernetesfeature.deletedrows, or thecreated_beforerounding;sbx_-only parser on the commands route.cargo clippy -p pplx-space-api --all-targets --all-features -- -D warningsandcargo fmtare clean at this head.cargo archat this head againstmain: no required findings, as onmain, and nothing added:routes/admin/sandboxes.rsgrowth finding goes, with its baseline entry;routes/admin/docs.rs, already over the file-size limit onmain(1,018 code lines), grows to 1,054 with this route documentation.infra/space/kind/scripts/up.sh, Kubernetes substrate), ata12ff590, before the rebase. The rebase changed only a test call site and the lifecycle filter's parse type. Driven through/admin/api/v1with synthetic orgs, users,seedsandboxes and a two-slot warm pool. 43 of 43 checks pass:node_idwithcursoranswers 400 as an unknown parameter. Withoutcursorit is ignored, and the bare array equals the unfiltered one;GET /sandboxes/{id}andGET /debug/sandbox/{id}, including the live pod name;observed_local.Severity and risk
Severity: low. Nothing behaves incorrectly today, but operators could not page or filter the sandbox list server-side, count spaced versions beyond 5,000 rows, or look a sandbox up by the identifiers other tools print.
Risk:
main's speed: 105–110 ms for 5,000 rows, against 103–113 ms.urn:uuid:UUIDs, on the debug route and aftersbx_.{"error": "invalid_sandbox_id"}, instead of axum's plain-text path rejection.Rollout
The API, console and other services roll out separately. While old and new API replicas run together:
GET /admin/api/v1/sandboxes/spaced-versionsfalls intoGET /sandboxes/{sandbox_id}and answers 400 "sandbox id must be an sbx_ prefixed UUID". The console should treat that 400 on this route as "not available" and fall back to its sampled tally.cursorand answers the bare array, so it never applies a filter. The console should send filters only withcursor. A bare array in reply means paging, and with it filtering, is unavailable: fall back to client-side filtering of the sampled list.Stack
Independent of the other admin API PRs. The
node_idandassignedfilters can follow once the Direct route has an owning read. Open #1302 adds admin routes (terminal sessions, snapshots, keys) that parse onlysbx_IDs. Whichever of #1302 and this PR merges second moves those routes ontoroutes::admin::sandbox_id::parse_admin_sandbox_id.AI assistance: Claude Code drafted the code, tests and this description; a human reviews before merge.