feat(gitlabapi): add the six namespace service-condition endpoints
What
The six service-condition endpoints on the GitLab API surface, per S33 Service conditions:
POST /api/gitlab/v1/namespaces/{id}/block POST .../unblock
POST /api/gitlab/v1/namespaces/{id}/disable POST .../enable
POST /api/gitlab/v1/namespaces/{id}/suspend POST .../unsuspendEach action writes exactly one lifecycle column on namespaces through the new NamespaceStore.ApplyCondition/LiftCondition (a single UPDATE ... RETURNING; apply stamps through COALESCE(column, NOW()) so a replay keeps the original instant, lift assigns NULL unconditionally so a no-op lift stays a 200). Conditions apply in every lifecycle state, including soft-deleted and purged: the platform repairs namespaces through this surface, so there is no gating predicate in the WHERE clause, and the response reports the ADR-007 precedence winner rather than the condition just written.
Notable for reviewers
- Six literal routes, not an
{action}wildcard. The action table registers six literal patterns, so an unknown segment matches no route (fallback envelope 404), the irreversibledelete/purgecannot be named at all, and every operation carries its own ServeMux-stampedr.Pattern: one metrics/log series per endpoint, with scanner junk accruing to the fallback series.TestNamespaceCondition_RouteRegistration_StampsOnePatternPerActionpins the pattern per action andTestNamespaceCondition_RouteTable_MatchesTheContractpins the table against the OpenAPI document and the three store constants (an added undocumented action fails both). - Tolerant-strict body policy. The operations declare no fields; no body or
{}answers200, while malformed JSON, an unknown field, a non-object (including a barenull, whichencoding/jsonsilently ignores into a struct target; the decode target is a pointer for exactly that reason), and trailing data answer400. An over-cap body answers413on both framing paths. - Audit record. Every successful write logs one INFO record (namespace id, condition, direction). The row keeps no history and a lift NULLs the only evidence a block existed; this record is the trace until S21's audit stream lands.
- Shared-helper hoists.
pathNamespaceIDandwriteNamespaceStoreErrormoved out of the resolution handler to be shared with these routes; behavior-preserving, statuses and messages unchanged.
Spec coverage
Acceptance criteria
| # | Criterion | Tests |
|---|---|---|
| AC-1 | Valid slug and unused anchor returns 201 with the resource |
Provisioning endpoint, already landed: TestCreateNamespace_ValidRequest_Returns201 |
| AC-2 | Same-anchor replay returns 200, row unaltered |
Already landed: TestCreateNamespace_ExactReplay_Returns200, TestNamespaceStore_Create |
| AC-3 | Replay with a disagreeing body returns 409 |
Already landed: TestCreateNamespace_StoreConflicts_Return409, TestNamespaceStore_Create_ReplayMismatch |
| AC-4 | Brand-List Validator unreachable: 503, or replay |
Already landed: TestCreateNamespace_BrandListUnreachable_Returns503, TestCreateNamespace_ExactReplay_OutranksSlugRefusal |
| AC-5 | Slug policy failure returns 422, or replay |
Already landed: TestCreateNamespace_InvalidSlug_Returns422, TestCreateNamespace_BrandListReserved_Returns422 |
| AC-6 | Slug taken by another namespace returns 409 |
Already landed: TestNamespaceStore_Create_SlugTaken |
| AC-7 | Resolution returns anchor, slug, derived status; 404/400 |
Already landed: TestResolveNamespace_EveryLifecycleState_Returns200, TestResolveNamespace_UnknownUUID_Returns404, TestResolveNamespace_NonCanonicalID_Returns400 |
| AC-8 | Each of the six condition endpoints sets or clears exactly its own column; response and a subsequent read report the derived status with ADR-007 precedence | TestNamespaceCondition_EachAction_Returns200WithUpdatedResource, TestNamespaceCondition_Replay_Returns200, TestNamespaceStore_ApplyCondition, TestNamespaceStore_ApplyCondition_ReplayKeepsTheTimestamp, TestNamespaceStore_LiftCondition, TestNamespaceStore_LiftCondition_NotInEffectIsANoOp, TestNamespaceStore_Conditions_ReturnEveryLifecycleColumn, TestNamespaceConditionIntegration |
| AC-9 | Suspended namespace: client reads succeed, pushes rejected | Serviceability gating, already landed in internal/namespace and the protocol suites. Not re-tested here. |
| AC-10 | Management-surface requests rejected by the same predicate table | Serviceability gating, already landed. Not re-tested here. |
| AC-11 | Batch verifications 204/400 |
Not implemented yet: the verifications endpoint is a later MR. |
| AC-12 | Resolution and condition endpoints work in every state, including soft-deleted and purged | TestNamespaceCondition_EveryLifecycleState_Returns200, TestNamespaceStore_Conditions_ApplyInEveryLifecycleState, TestNamespaceConditionIntegration, and (resolution half, landed) TestResolveNamespace_EveryLifecycleState_Returns200 |
| AC-13 | The OpenAPI document defines every endpoint, schema, and response, and validates in CI | TestConditions_ResponsesMatchOpenAPIContract, TestContract_ConditionPaths_DeclareTheCanonicalIDPattern, plus the landed TestContract_DocumentValidates and the lint:openapi redocly job |
Error cases
| Endpoint | Condition | Tests |
|---|---|---|
| All | Missing or invalid service credential: 401, bodiless |
Owned by the S08 bootstrap-token middleware (internal/auth). Not tested here. |
| All | Malformed JSON body: 400 bad_request |
TestNamespaceCondition_RequestBody (conditions declare no body; see the provisional reading below), and the landed TestCreateNamespace_MalformedBody_Returns400 |
| Provision | Slug policy failure 422; slug taken 409; replay mismatch 409; validator unreachable 503 |
Already landed (see AC-3 to AC-6). |
| Resolution, conditions, verifications | :uuid is not a canonical UUID: 400 bad_request |
TestNamespaceCondition_MalformedID_Returns400, TestNamespaceCondition_NonCanonicalID_Returns400, TestContract_ConditionPaths_DeclareTheCanonicalIDPattern |
| Resolution, conditions, verifications | Unknown namespace UUID: 404 not_found |
TestNamespaceCondition_UnknownNamespace_Returns404, TestNamespaceStore_Conditions_UnknownNamespace, TestNamespaceConditionIntegration |
| Conditions | Unknown <action> segment: 404 not_found |
TestNamespaceCondition_UnknownAction_Returns404, TestNamespaceStore_Conditions_Guards (the store's own refusal of a condition value outside the three constants) |
| Verifications | Unknown or foreign id, empty or oversized batch: 400 |
Not implemented yet: the verifications endpoint is a later MR. |
| Conditions | Store fault: 500 with a static message, cause logged once |
TestNamespaceCondition_StoreFailure_Returns500, TestNamespaceCondition_CanceledRequest_WritesNothing |
Security considerations
| # | Concern | Tests |
|---|---|---|
| S-1 | Internet-facing but never customer-facing; bootstrap-token stub is the only guard | Owned by the S08 middleware and the central chain. Not tested here. |
| S-2 | Verifications response deliberately opaque | Not implemented yet: the verifications endpoint is a later MR. |
| S-3 | No organization data leaves the registry | TestNamespaceCondition_EachAction_Returns200WithUpdatedResource and TestNamespaceCondition_EveryLifecycleState_Returns200 compare the whole response body, so the billing anchor cannot appear; TestNamespaceConditionIntegration checks the same against a stored row |
| S-4 | Untrusted input crosses into SQL | TestNamespaceCondition_MalformedID_Returns400 and TestNamespaceCondition_NonCanonicalID_Returns400 assert the raw segment never reaches the store; TestNamespaceCondition_UnknownAction_Returns404 and TestNamespaceStore_Conditions_Guards keep the action off any column it does not name |
Testing
No e2e scenario is affected: the management/GitLab API has no e2e catalog under docs/testing/, and this MR ships its own unit, contract, and integration coverage (store suites run against real PostgreSQL).
Not here
- The canceled-request 500 path leaves the status unstamped, so wrappers report it as a 200 in logs, metrics, and spans; that is shared
response.gobehavior with the landed resolution/provisioning routes and gets its own fix MR. - Decode read-failures are classified as content 400s without a log; that classification lives in the shared decode path being hoisted to
internal/transportin !1149 (merged), and moves with it. - This surface is authenticated but not authorized; #356 (closed) owns applying service authz to the GitLab API surface.
decodeEmptyRequestBodyfolds intotransport.DecodeJSONBodyonce !1149 (merged) lands.
Closes #194 (closed)