Repository navigation
api: close six admin API gaps the admin console works around - #1303
Merged
Merged
Conversation
|
Thanks for opening this pull request! A SPACE maintainer will take a look. CONTRIBUTING.md explains how we review outside contributions. |
ac-pplx
force-pushed
the
ac/admin-api-console-gaps
branch
from
November 2, 2026 17:16
5b01ed7 to
3d3ced4
Compare
ac-pplx
marked this pull request as ready for review
November 2, 2026 19:05
This was referenced Nov 2, 2026
Merged
Merged
Merged
ac-pplx
force-pushed
the
ac/admin-api-console-gaps
branch
from
November 3, 2026 05:20
a438c21 to
5f4d794
Compare
This was referenced Nov 3, 2026
nikhilbirmiwal-pplx
approved these changes
Nov 3, 2026
github-merge-queue
Bot
removed this pull request from the merge queue due to failed status checks
Nov 3, 2026
ac-pplx
force-pushed
the
ac/admin-api-console-gaps
branch
from
November 3, 2026 21:12
5f4d794 to
270e45e
Compare
DELETE /users/{user_id} ran a plain DELETE, so the foreign keys from
sandboxes.user_id and sandbox_archives.user_id surfaced as a 500. Map
those violations to 409 with a reason (user_owns_sandboxes or
user_owns_archived_sandboxes), and give the existing self-target 409
the reason self_mutation so callers can tell them apart. The field is
reason, not code, which the admin envelope uses for the status
category.
A deleted sandbox's row is kept for 7 days and still holds the key.
When only such rows remain, the 409 says so, with the reason
user_owns_retained_deleted_sandboxes and retained_until, the time the
last of them leaves retention, so the operator waits rather than
deleting sandboxes that are already deleted. The sandbox store now owns
that retention period, which tombstone retention reads.
GET /debug/sandbox/{sandbox_id} now returns resources.env with each
value null: the object keeps its shape, so diagnostics still show which
variables are set. A stored env that is not an object yields no names.
The route is now in the admin OpenAPI stub.
PUT /template-releases/{release_id}/tag moved a tag unconditionally. An
optional body field, expected_current_release_id, now names the release
the tag must point at (or null for a tag that must not exist yet). A
mismatch answers 412 with the current release in details. The check and
the move share one transaction and lock the tag row; a move that loses
to other writers creating and removing the tag answers a retryable 409.
The move's space.admin.audit entry records previous_release_id, the
release the tag pointed at before the move.
POST /system-templates/{template}/builds started a builder job on every
call. It now takes an optional Idempotency-Key, recorded in
public_idempotency_records. The builder starts a job per submission and
takes no caller identity to deduplicate on, so the key is reserved and
committed before dispatch, and the job is recorded in its place after.
No transaction is held across the builder call, and the dispatch runs
in its own task, so a client disconnect does not cancel it.
Within the record's 24-hour lifetime, the same key and body return the
original job and a different body answers 409. A replay while the first
request is still submitting answers a retryable 409. One whose
reservation is older than the dispatch can take, because its result
write failed or its process stopped, answers a non-retryable 409 naming
the reservation time, so the operator checks the template's releases.
No replay starts a second job under the key. A dispatch that fails
releases the key: the controller has already revoked the publication
grant of any build it may have started.
An admin create records the sandbox owner as the operation's actor_user_id, which the create executor runs as, so the operation did not say which administrator submitted it. The create payload gains an optional requested_by_user_id, and the admin operations API returns it as an optional field. It is read only from create_sandbox operations, and is null when unrecorded or for older operations. Older executors reject unknown payload keys, so the API writes the field only while SPACE_ADMIN_CREATE_REQUESTED_BY_ENABLED is on, to be enabled once every cluster-controller replica runs this release.
Fork operations recorded their source in target_id as the bare UUID, while every other sandbox operation records sbx_<uuid>. Readers now match both forms: the fork-source delete and snapshot-retention guard, capture leader election, the ForkFailed event, the admin operations target_id filter and the sandbox's lifecycle-events stream. The filter also always matches the string as typed, so a non-sandbox target whose name parses as a UUID still finds its own operations. A wake no longer treats a fork queued behind it as other work in the sandbox's queue. Older releases match a fork's source by the bare UUID alone, so new forks record sbx_<uuid> only while SPACE_FORK_PUBLIC_TARGET_ID_ENABLED is on, to be enabled once every API and cluster-controller replica runs this release. docs/configuration.md gives the steps for turning it off before a rollback.
…en it A failed dispatch released the build's Idempotency-Key, relying on the controller revoking the publication grant. That revocation is best effort and finds nothing once a build that published has consumed the grant, so a build accepted before its answer was lost could be repeated under the same key. The template controller now reports whether the builder may have taken the build. Only failures that prove it did not free the key: errors before the request was sent, a dial that sent nothing, and the builder's draining refusal. A timeout, a transport error after sending or a failed job-record write keeps the reservation pending; when the builder named its job, the reservation notes it, and replays report it in progress, then of unknown outcome with that job_id. The reservation is now taken in the spawned task with the dispatch, so a disconnect cannot interrupt either, and the task keeps the request's span.
The rollback steps for SPACE_FORK_PUBLIC_TARGET_ID_ENABLED offered the admin operations list as an alternative to the SQL check. That list returns at most 250 operations with no cursor, so it can hide an older queued sbx_ fork. The check is now the query returning no rows, or both lists being empty.
After a failure that did not prove the builder refused the build, the key's reservation stayed pending without a note, so replays asked for a retry for 15 minutes although nothing would complete the record. Every such failure now notes the reservation, with the job ID when the builder named one, and a noted reservation reads as of unknown outcome at once: replays answer the non-retryable 409. A reservation left without a note, by a failed note write, a failed completion write or a panic, still reads as unknown 15 minutes after it was taken.
The builder client's interceptor reads the node-api bearer token on every call, and one it cannot read fails the call with a bare UNAVAILABLE that cannot be told from the builder's own. The controller now reads the token before the call, so that failure provably sent nothing and frees the system build's Idempotency-Key. A token that goes missing between the two reads still counts as possibly accepted.
The rollback steps for SPACE_FORK_PUBLIC_TARGET_ID_ENABLED let two empty admin operation lists stand in for the SQL check. Forks move from running back to queued, so an sbx_ fork can move between the two reads and be missed by both. The query returning no rows is now the only check.
Generate the native-build fixtures' sign-in password per test instead of hashing a literal, and drop the sandbox target ids from two assertion messages so the tests no longer trip CodeQL's hard-coded-cryptographic-value and cleartext-logging queries.
ac-pplx
force-pushed
the
ac/admin-api-console-gaps
branch
from
November 3, 2026 21:47
270e45e to
19869fa
Compare
This was referenced Nov 3, 2026
ac-pplx
added a commit
that referenced
this pull request
Nov 5, 2026
Admin creates now always put the administrator in the create payload's requested_by_user_id. The SPACE_ADMIN_CREATE_REQUESTED_BY_ENABLED switch guarded controllers that rejected the key. Every space-cluster-controller replica must run #1303 (e083c94) or later before an API with this change serves admin creates.
ac-pplx
added a commit
that referenced
this pull request
Nov 5, 2026
Keep SPACE_ADMIN_CREATE_REQUESTED_BY_ENABLED instead of removing it, but turn it on by default, so admin creates record the operator in the create payload's requested_by_user_id. 0, false, no, off or disabled, in any case, turns it off; unset or any other value keeps it on. Removing the switch left no API-side way to withhold the field from a space-cluster-controller older than #1303 (e083c94), whose deny_unknown_fields then fails every admin create. Turn it off for an out-of-order deploy, where an API with this change serves admin creates before every controller replica runs #1303 or later, and before rolling a controller back below #1303, letting queued admin creates finish first. The requester test now pins the switch unset in its own test process, and a new test turns it off and checks that both admin operation reads report no requester.
ac-pplx
added a commit
that referenced
this pull request
Nov 5, 2026
Controllers before #1303 reject the field and the API does not check their version. The chart rolls the API and the controllers out together, so an upgrade from a release before #1303 turns the switch off for that upgrade and on again once every controller runs this release. A rollback below #1303 turns it off first.
GeekLord
pushed a commit
to GeekLord/SPACE
that referenced
this pull request
Nov 5, 2026
* api: always record who requested an admin create Admin creates now always put the administrator in the create payload's requested_by_user_id. The SPACE_ADMIN_CREATE_REQUESTED_BY_ENABLED switch guarded controllers that rejected the key. Every space-cluster-controller replica must run perplexityai#1303 (e083c94) or later before an API with this change serves admin creates. * api: record who requested an admin create by default Keep SPACE_ADMIN_CREATE_REQUESTED_BY_ENABLED instead of removing it, but turn it on by default, so admin creates record the operator in the create payload's requested_by_user_id. 0, false, no, off or disabled, in any case, turns it off; unset or any other value keeps it on. Removing the switch left no API-side way to withhold the field from a space-cluster-controller older than perplexityai#1303 (e083c94), whose deny_unknown_fields then fails every admin create. Turn it off for an out-of-order deploy, where an API with this change serves admin creates before every controller replica runs perplexityai#1303 or later, and before rolling a controller back below perplexityai#1303, letting queued admin creates finish first. The requester test now pins the switch unset in its own test process, and a new test turns it off and checks that both admin operation reads report no requester. * api: say when to turn off the admin create requester switch Controllers before perplexityai#1303 reject the field and the API does not check their version. The chart rolls the API and the controllers out together, so an upgrade from a release before perplexityai#1303 turns the switch off for that upgrade and on again once every controller runs this release. A rollback below perplexityai#1303 turns it off first. * api: order the requester switch's rollback steps after old replicas exit An API replica reads the switch from its environment, so one started with it on keeps sending the field until it exits, shutdown drain included. The rollback steps now wait for every such replica to exit before waiting for the queued admin creates, and only then roll the controller back.
This was referenced Nov 6, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This change closes six admin API gaps that the admin console currently works around in
ui/. It changes Rust code, docs and one OpenAPI stub snapshot, and adds no schema migration. It is one PR of eleven commits: one for each of the six changes below, three more for change 4's build key (when it stays reserved, how replays report it, and the bearer-token check), and two for change 2's rollback check.User delete answers 409, not 500, while the user owns sandboxes.
DELETE /users/{user_id}maps the foreign-key violations fromsandboxes.user_idandsandbox_archives.user_idto 409 with areason:user_owns_sandboxeswhile the user owns a sandbox that is not deleted;user_owns_retained_deleted_sandboxeswhen every such row is a deleted sandbox. Those rows are kept for 7 days after their last update and still hold the foreign key, mostly on Direct. The message says they are kept for retention until the time in theretained_untilfield and to retry after that, rather than asking the operator to delete sandboxes that are already deleted;user_owns_archived_sandboxeswhile the user owns archived sandboxes.The self-target 409 carries
reason: self_mutation, so callers can tell the cases apart. The field isreason, notcode, becausecodeis the status category elsewhere in/admin/api/v1. Onmain, both foreign keys surface asInternal. No other foreign key tousersrestricts a delete; the rest areSET NULLorCASCADE. The retention period lives in the sandbox store, which the cluster controller's tombstone retention reads.Fork operations can record their source as
sbx_<uuid>. Every other sandbox operation records that form. Every reader of a fork'starget_idmatches both forms:active_fork_source_operation_exists, which the sandbox delete, reconcile finalize and snapshot retention use;ForkFailedevent, which takes the canonical id fromClaimedPublicOperation::sandbox_id();/operations?target_id=filter. It always matches the string as given, and also both sandbox forms when that string parses as a sandbox id, so a template named by 32 hex digits still finds its owndelete_templateoperation;GET /v1/sandboxes/{id}/lifecycle-events, which carries the events of forks of the sandbox in either form, so what it streams does not depend on the switch or on a row's age.A wake no longer counts a later fork of the sandbox as other work in the sandbox's queue. New forks write
sbx_<uuid>only whileSPACE_FORK_PUBLIC_TARGET_ID_ENABLEDis on; see Rollout. Matching the bare form can be removed one release after the switch is on everywhere, plus the 7 days a finished operation is kept; the code records where.An admin create records who requested it.
actor_user_idkeeps its meaning: it is the owner, and the create executor runs as that user. The create payload gains an optionalrequested_by_user_id, andGET /operationsandGET /operations/{operation_id}return it as a nullable field, read only fromcreate_sandboxoperations. The API writes it only whileSPACE_ADMIN_CREATE_REQUESTED_BY_ENABLEDis on; see Rollout.System template builds accept an optional
Idempotency-Key.POST /system-templates/{template}/buildsrecords the key inpublic_idempotency_records. The builder starts a new job per submission and takes no caller identity to deduplicate on, so the key is reserved in its own committed transaction before dispatch, and the job is recorded in its place after. No transaction is held across the builder call. The reservation and the dispatch run together in a spawned task that keeps the request's tracing span, so a client disconnect interrupts neither. Within the record's 24-hour lifetime:job_idwithout reaching the builder;reserved_atindetails, andjob_idwhen the builder named one; its message says the build may have started and may still be running, and points to Template Releases and the build's status. The 15 minutes outlast the builder RPC attempts, but not necessarily the database work and builder discovery around them.The template controller reports whether the builder may have taken a failed build. The key is freed for a retry only when it certainly did not: the submission failed before its request was sent (validation, ownership, grants, no builder to send to, or a node-api bearer token that cannot be read, which the controller reads before the call), the dial sent nothing, or the builder refused it while draining, which build-manager decides before it enqueues. On any other failure, such as a timeout, a transport error after sending, or a failed job-record write after acceptance, the reservation stays for the rest of the record's life, noted as unresolved with the job ID when the builder named one. So no repeat under a key starts a second job. The native
:timeoutroute's response admission is unchanged.The release tag move gains a compare-and-set precondition.
PUT /template-releases/{release_id}/tagaccepts an optional body field,expected_current_release_id:null: the tag must not exist yet;A mismatch answers 412
failed_preconditionwithcurrent_release_idindetails. The check and the move run in one transaction under the tag row's lock. A move that loses to other writers creating and removing the tag answers 409 withretryable: true. Each move, and each refused precondition, emits aspace.admin.auditentry,action: template_release_tag, withprevious_release_id: the release the tag pointed at when the request ran, or empty when it did not exist.The debug route returns environment variable names, not values.
GET /debug/sandbox/{sandbox_id}keepsresources.envas an object with each valuenull,{ "NAME": null }. This is data minimisation: diagnostics still show which variables are set. The route is in the admin OpenAPI stub, with that shape documented. Nothing in this repository reads the values: neitherui/,space-admin-clinor the docs.The admin console PRs #1245, #1253, #1254 and #1278 work around these gaps in
ui/. #1245 and #1278 have merged with their workarounds, which a follow-up can drop; #1253 and #1254 are still open. After this merges, each can drop its workarounds. For the two changes behind a switch, that applies once the switch is on; for the tag precondition and the build key, once every API replica runs this release (see Rollout).Validation
Rebased onto
mainatc5fdf0c7, after #1307 merged. Three textual conflicts were resolved by keeping both sides: two adjacent new debug-route tests, and thecontrollersimports inroutes/admin/templates.rs. No behaviour changed. At this head,cargo nextest run -p pplx-space-apipasses 2,191 of 2,191, and fmt and clippy with-D warningsare clean.The results below are from
049cdf5b, before the rebase, onmainat17721a84:cargo nextest run --all-featuresforpplx-space-api,pplx-space-sandbox-store,pplx-space-snapshot-retentionandpplx-space-cluster-controller-server, with the Postgres setup frompplx-space-db-test-fixtures: 3451 passed, 0 failed, with no dependency skips.cargo clippy --all-features --all-targets -- -D warningson the API, sandbox-store, snapshot-retention and cluster-controller crates is clean, andcargo archpasses.Database-backed and route tests:
delete_user_answers_conflict_while_the_user_owns_sandboxes: live, deleted-only and archived owners, then 204, andself_mutationuser_sandbox_rows_reports_when_deleted_rows_leave_retentionan_active_fork_source_is_found_by_either_target_id_forma_wake_joins_an_earlier_wake_past_a_later_forkcapture_leader_election_spans_both_target_id_formsfork_operation_kind_persists_in_native_scope: the admin filter by either form, a 32-hex template name found as typed, and the lifecycle-events read finding a bare-UUID forkan_admin_create_reports_its_requester_apart_from_the_ownercreate_payload_without_a_requester_is_wire_compatible_with_old_shapea_response_reservation_answers_replays_without_repeating_work: reserve, replay, release, unknown outcome, complete, and a noted reservation that reads as unknown at once and can be neither completed nor releaseda_system_build_replay_with_its_idempotency_key_starts_one_job: replay, a different body, a failed record write, and a client that disconnects mid-dispatcha_failed_system_build_frees_its_key_only_if_the_builder_did_not_take_it: a builder that takes the build and answers an error, and an accepted build whose job record fails, both keep the key, and their replays answer the non-retryable 409 at once (the second names the job); a node-api bearer token that cannot be read, and a refused connection, free it, and the same key then buildsa_failed_attempt_may_have_been_accepted_unless_it_proves_otherwise: the controller's classification tablea_release_tag_move_honours_its_expected_current_release: preconditions, and the lost race answering a retryable 409the_debug_response_lists_env_names_without_valuesRed checks at this head, each fix reverted on its own, made its test fail: the fork reader fixes; dropping the typed filter string; awaiting the dispatch in the handler instead of a task; freeing the key when the record write fails; the old
Racedenvelope;user_owns_sandboxesfor deleted rows; treating every RPC failure as not accepted, and every failure as possibly accepted; skipping the bearer-token read before the call; and leaving a failure without a job unnoted.On a local Kind stack built from this head, through the admin API with
curl, with both builder nodes' build-manager stopped (SIGSTOP) behind their node-api: a keyed system build failed with node-api's own 503 after 15 s, an answer that does not prove nothing was taken. The reservation was noted unresolved, and a replay right after answered the non-retryable 409, as did one after the builder was resumed.template_build_jobsstayed empty.The cluster was removed afterwards.
Not validated: a mixed-version rollout with an older controller or API replica. That behaviour rests on the wire-compatibility test, the switches being off by default, and the Rollout steps.
Severity and risk
Severity: moderate. These are wrong statuses, missing provenance and missing concurrency controls on operator-only admin routes. No data is lost today.
Risk:
reason,retained_until,requested_by_user_id), an optional header and an optional body field. These are not:nullfor every env value. A script that read the values fromresources.envgetsnullinstead.DELETE /users/{user_id}answers 409 with areasonwhere it answered 500 for a user who owns sandboxes or archived sandboxes. A client that retried the 500 gets a 409 to act on instead.target_idfilter matches a sandbox's operations under both id forms, so filtering by either form returns operationsmaindid not.GET /v1/sandboxes/{id}/lifecycle-eventscarries the events of forks of the sandbox, in either id form, which it never did for forks recorded by the bare UUID. A client of the stream seesfork_sandboxoperations' events it did not see before. The route is hidden by default.mainit overwrites silently. A race with a writer that only creates the tag is resolved inside the transaction.Rollback: revert the commits. The switches can be turned off on their own; for the fork switch, follow the steps below first.
Rollout
The API and the cluster controller can roll out in either order. Both switches default to off, and with them off the payloads and
target_idvalues written are byte-identical to today's.SPACE_ADMIN_CREATE_REQUESTED_BY_ENABLED(API): older controllers reject unknown create-payload keys (deny_unknown_fields). Turn it on only once everyspace-cluster-controllerreplica runs this release. Turn it off, and let queued creates finish, before rolling a controller back. A new API reading an older operation reportsrequested_by_user_id: null. An older API never reads the field.SPACE_FORK_PUBLIC_TARGET_ID_ENABLED(API): older releases match a fork's source by the bare UUID alone, in the delete guard, snapshot retention and capture election, so they cannot see a fork recorded assbx_<uuid>. Turn it on only once everyspace-apiandspace-cluster-controllerreplica runs this release. Before rolling either service back:space-apireplica;SELECT operation_id, state, target_id FROM public_operations WHERE kind = 'fork_sandbox' AND state IN ('queued', 'running') AND target_id LIKE 'sbx\_%'returns no rows. The admin operations list cannot stand in for it: it returns at most 250 operations, and two separate reads can miss a fork that moves between queued and running;expected_current_release_idon the tag route: older API replicas reject unknown fields on that request with 422. Callers, the admin console included, should send it only once every API replica runs this release.Idempotency-Keyon system template builds: older API replicas ignore the header, so during a rolling deploy a retry that reaches one starts a second build, and nothing returns an error. Callers, the admin console included, should rely on the key only once every API replica runs this release.What an operator sees in the window after the fork switch is turned on: fork operations created before it keep the bare UUID in
target_id. They stay until finished operations are garbage-collected, 7 days after creation, so for up to 7 days the operation lists show both forms side by side.target_idfilter finds both forms whichever form it is given.GET /v1/operations/{id}returns each operation'starget_idas it was recorded.sandbox_idis the bare UUID either way.Both switches, and the fork switch's rollback steps, are documented in
docs/configuration.md.Not covered
space.admin.auditlog entry, not in the persistedadmin_audit_eventsrow thatGET /audit-eventsserves. That table has no column for per-request details. Storing it there needs a separate migration first, adding a nullabledetails jsonbcolumn toadmin_audit_events; then the audit middleware can take details from a response extension the handler sets. Perdb/README.md, that migration lands on its own, before the code that writes it.requested_by_user_idis recorded only for admin creates. Admin lifecycle actions and updates already record the admin asactor_user_id.StartBuildRequestcarries no caller identity, and build-manager creates a job per submission. Deduplicating in the builder would need a proto field and a build-manager change with its own rollout, so the key is enforced in the API.AI assistance: Claude Code drafted the code, tests and this description, and ran the validation above.