diff --git a/.github/workflows/ci-check-docs.yml b/.github/workflows/ci-check-docs.yml index 15c130116..6aff0f403 100644 --- a/.github/workflows/ci-check-docs.yml +++ b/.github/workflows/ci-check-docs.yml @@ -21,10 +21,13 @@ on: - "libs/typescript/agent/src/**" # Cua-Bot - "libs/cuabot/src/**" + # Cyclops CS Backend (vendored OpenAPI spec from trycua/cloud) + - "scripts/docs-generators/specs/cyclops-cs.swagger.json" # Documentation files themselves - "docs/content/docs/cua-driver/reference/**" - "docs/content/docs/cua/reference/**" - "docs/content/docs/cuabot/reference/**" + - "docs/content/docs/reference/cyclops-cs/**" # Generator scripts - "scripts/docs-generators/**" @@ -89,6 +92,10 @@ jobs: if echo "$CHANGED_FILES" | grep -q "^libs/cuabot/src/"; then GENERATORS="$GENERATORS cuabot" fi + # Cyclops CS: vendored spec, generator, or its output page changed + if echo "$CHANGED_FILES" | grep -q "^scripts/docs-generators/specs/cyclops-cs\.swagger\.json\|^scripts/docs-generators/cyclops-cs\.ts\|^docs/content/docs/reference/cyclops-cs/"; then + GENERATORS="$GENERATORS cyclops-cs" + fi # Individual generator script changes — only trigger their own generator if echo "$CHANGED_FILES" | grep -q "^scripts/docs-generators/cua-driver\.ts"; then diff --git a/.github/workflows/sync-cyclops-cs-spec.yml b/.github/workflows/sync-cyclops-cs-spec.yml new file mode 100644 index 000000000..cfe6e3cf5 --- /dev/null +++ b/.github/workflows/sync-cyclops-cs-spec.yml @@ -0,0 +1,70 @@ +name: "Sync: Cyclops CS spec" + +# Refreshes the vendored Cyclops CS backend OpenAPI spec from its source of +# truth in trycua/cloud and regenerates the reference MDX. Opens a PR when the +# vendored copy or generated page drifts. +# +# Triggers: +# - repository_dispatch: trycua/cloud pings this when swagger.json changes +# (see trycua/cloud .github/workflows/notify-cyclops-cs-spec-change.yml) +# - schedule: daily self-heal in case a ping is missed +# - workflow_dispatch: manual run +# +# Required secret: +# CLOUD_REPO_TOKEN — a PAT or GitHub App token with read access to the +# private trycua/cloud repo contents. The built-in GITHUB_TOKEN cannot read +# another private repo, so this must be provisioned in repo/org settings. + +on: + repository_dispatch: + types: [cyclops-cs-spec-changed] + schedule: + - cron: "0 7 * * *" + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + sync: + name: Sync vendored spec and regenerate docs + runs-on: ubuntu-latest + steps: + - name: Checkout cua + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Install Node dependencies + run: pnpm install + working-directory: docs + + - name: Sync spec and regenerate + env: + CYCLOPS_CS_SPEC_SOURCE: "https://api.github.com/repos/trycua/cloud/contents/cyclops-cs/backend/docs/swagger.json?ref=main" + GITHUB_TOKEN: ${{ secrets.CLOUD_REPO_TOKEN }} + run: npx tsx scripts/docs-generators/sync-cyclops-cs-spec.ts + + - name: Open PR on drift + uses: peter-evans/create-pull-request@v6 + with: + branch: docs/cyclops-cs-spec-sync + title: "docs(cyclops-cs): sync backend API reference from trycua/cloud" + commit-message: "docs(cyclops-cs): sync backend API reference from trycua/cloud" + body: | + Automated sync of the Cyclops CS backend OpenAPI spec from + `trycua/cloud` (`cyclops-cs/backend/docs/swagger.json`) and the + regenerated reference page. + + Generated by `.github/workflows/sync-cyclops-cs-spec.yml`. Do not + edit the vendored spec or `http-api.mdx` by hand. + add-paths: | + scripts/docs-generators/specs/cyclops-cs.swagger.json + docs/content/docs/reference/cyclops-cs/http-api.mdx diff --git a/docs/content/docs/reference/cyclops-cs/http-api.mdx b/docs/content/docs/reference/cyclops-cs/http-api.mdx new file mode 100644 index 000000000..3ff63cba3 --- /dev/null +++ b/docs/content/docs/reference/cyclops-cs/http-api.mdx @@ -0,0 +1,704 @@ +--- +title: HTTP API +description: REST API reference for the Cyclops CS backend, v0.1. +--- + +{/* + AUTO-GENERATED FILE - DO NOT EDIT DIRECTLY + Generated by: npx tsx scripts/docs-generators/cyclops-cs.ts + Source: trycua/cloud cyclops-cs/backend/docs/swagger.json (vendored) + Spec version: 0.1 +*/} + +Reference for the **Cyclops CS Backend API** HTTP surface, version `0.1`. Generated from the backend's committed OpenAPI (Swagger 2.0) description. + +Backend sidecar for the cyclops-cs SPA — Keycloak-authenticated key management, authenticated reverse proxies (gateway / k8s / orch), and deprecated batch/label endpoints that now return 410 Gone. The /api/gateway proxy is the sole ingress to pool orchestrators (CUA-527); per-pool Tailscale Ingresses have been removed. + +## Endpoints + +The backend exposes 29 endpoints across 9 groups. + +| Method | Path | Summary | +| ------ | ---- | ------- | +| `DELETE` | `/api/batch/{pool}/{id}` | Deprecated batch cancellation route | +| `GET` | `/api/batch/{pool}/{id}/results` | Deprecated batch results route | +| `GET` | `/api/batch/{pool}/{id}/status` | Deprecated batch status route | +| `DELETE` | `/api/batch/{pool}/lanes` | Deprecated batch lane release route | +| `POST` | `/api/batch/{pool}/lanes` | Deprecated batch lane acquisition route | +| `POST` | `/api/batch/{pool}/submit` | Deprecated batch submission route | +| `DELETE` | `/api/label/{pool}/{label}` | Deprecated label cancellation route | +| `POST` | `/api/label/{pool}/{label}/batch` | Deprecated label batch submission route | +| `GET` | `/api/label/{pool}/{label}/results` | Deprecated label results route | +| `GET` | `/api/label/{pool}/{label}/status` | Deprecated label status route | +| `GET` | `/api/config` | Per-user feature flags | +| `GET` | `/api/gateway/{name}/{path}` | Reverse-proxy to the per-pool orchestrator (sole ingress path — CUA-527) | +| `GET` | `/api/svc/{namespace}/{service}/{path}` | Authenticated reverse proxy to a K8s Service in a namespace the caller owns | +| `GET` | `/healthz` | Liveness/readiness probe | +| `GET` | `/api/keys` | List the calling user's API keys | +| `POST` | `/api/keys` | Create a new API key | +| `DELETE` | `/api/keys/{id}` | Revoke an API key by Keycloak client UUID | +| `GET` | `/api/namespaces` | List the calling user's namespaces | +| `POST` | `/api/namespaces` | Create a namespace for the calling user | +| `DELETE` | `/api/namespaces/{name}` | Delete a namespace owned by the calling user | +| `GET` | `/api/k8s/{path}` | Authenticated proxy to the in-pod kubectl-proxy sidecar | +| `GET` | `/api/orch/{namespace}/{service}/{path}` | SPA-authenticated proxy to a per-namespace orchestrator service | +| `GET` | `/api/pool-templates` | List the calling user's pool templates | +| `POST` | `/api/pool-templates` | Save a pool config as a reusable template | +| `DELETE` | `/api/pool-templates/{name}` | Delete one of the calling user's pool templates | +| `GET` | `/api/pool-templates/{name}` | Get one of the calling user's pool templates | +| `GET` | `/api/user-keys` | List the calling user's API keys | +| `POST` | `/api/user-keys` | Create a per-user API key | +| `DELETE` | `/api/user-keys/{id}` | Revoke a per-user API key | + +## Batch + +### `DELETE /api/batch/{pool}/{id}` + +**Deprecated.** Deprecated batch cancellation route + +Route is deprecated and unavailable. Returns 410 Gone for every request. The orchestrator-backed batch surface is retired; callers must migrate to the replacement flow. + +**Parameters:** + +| Name | In | Type | Required | Description | +| ---- | -- | ---- | -------- | ----------- | +| `pool` | path | `string` | Yes | Pool name | +| `id` | path | `string` | Yes | Batch ID | + +**Responses:** + +| Status | Description | Schema | +| ------ | ----------- | ------ | +| `410` | Gone | object (map of `string`) | + +### `GET /api/batch/{pool}/{id}/results` + +**Deprecated.** Deprecated batch results route + +Route is deprecated and unavailable. Returns 410 Gone for every request. The orchestrator-backed batch surface is retired; callers must migrate to the replacement flow. + +**Parameters:** + +| Name | In | Type | Required | Description | +| ---- | -- | ---- | -------- | ----------- | +| `pool` | path | `string` | Yes | Pool name | +| `id` | path | `string` | Yes | Batch ID | + +**Responses:** + +| Status | Description | Schema | +| ------ | ----------- | ------ | +| `410` | Gone | object (map of `string`) | + +### `GET /api/batch/{pool}/{id}/status` + +**Deprecated.** Deprecated batch status route + +Route is deprecated and unavailable. Returns 410 Gone for every request. The orchestrator-backed batch surface is retired; callers must migrate to the replacement flow. + +**Parameters:** + +| Name | In | Type | Required | Description | +| ---- | -- | ---- | -------- | ----------- | +| `pool` | path | `string` | Yes | Pool name | +| `id` | path | `string` | Yes | Batch ID | + +**Responses:** + +| Status | Description | Schema | +| ------ | ----------- | ------ | +| `410` | Gone | object (map of `string`) | + +### `DELETE /api/batch/{pool}/lanes` + +**Deprecated.** Deprecated batch lane release route + +Route is deprecated and unavailable. Returns 410 Gone for every request. The orchestrator-backed batch surface is retired; callers must migrate to the replacement flow. + +**Parameters:** + +| Name | In | Type | Required | Description | +| ---- | -- | ---- | -------- | ----------- | +| `pool` | path | `string` | Yes | Pool name | + +**Responses:** + +| Status | Description | Schema | +| ------ | ----------- | ------ | +| `410` | Gone | object (map of `string`) | + +### `POST /api/batch/{pool}/lanes` + +**Deprecated.** Deprecated batch lane acquisition route + +Route is deprecated and unavailable. Returns 410 Gone for every request. The orchestrator-backed batch surface is retired; callers must migrate to the replacement flow. + +**Parameters:** + +| Name | In | Type | Required | Description | +| ---- | -- | ---- | -------- | ----------- | +| `pool` | path | `string` | Yes | Pool name | + +**Responses:** + +| Status | Description | Schema | +| ------ | ----------- | ------ | +| `410` | Gone | object (map of `string`) | + +### `POST /api/batch/{pool}/submit` + +**Deprecated.** Deprecated batch submission route + +Route is deprecated and unavailable. Returns 410 Gone for every request. The orchestrator-backed batch surface is retired; callers must migrate to the replacement flow. + +**Parameters:** + +| Name | In | Type | Required | Description | +| ---- | -- | ---- | -------- | ----------- | +| `pool` | path | `string` | Yes | Pool name | + +**Responses:** + +| Status | Description | Schema | +| ------ | ----------- | ------ | +| `410` | Gone | object (map of `string`) | + +### `DELETE /api/label/{pool}/{label}` + +**Deprecated.** Deprecated label cancellation route + +Route is deprecated and unavailable. Returns 410 Gone for every request. The orchestrator-backed batch surface is retired; callers must migrate to the replacement flow. + +**Parameters:** + +| Name | In | Type | Required | Description | +| ---- | -- | ---- | -------- | ----------- | +| `pool` | path | `string` | Yes | Pool name | +| `label` | path | `string` | Yes | Batch label | + +**Responses:** + +| Status | Description | Schema | +| ------ | ----------- | ------ | +| `410` | Gone | object (map of `string`) | + +### `POST /api/label/{pool}/{label}/batch` + +**Deprecated.** Deprecated label batch submission route + +Route is deprecated and unavailable. Returns 410 Gone for every request. The orchestrator-backed batch surface is retired; callers must migrate to the replacement flow. + +**Parameters:** + +| Name | In | Type | Required | Description | +| ---- | -- | ---- | -------- | ----------- | +| `pool` | path | `string` | Yes | Pool name | +| `label` | path | `string` | Yes | Batch label | + +**Responses:** + +| Status | Description | Schema | +| ------ | ----------- | ------ | +| `410` | Gone | object (map of `string`) | + +### `GET /api/label/{pool}/{label}/results` + +**Deprecated.** Deprecated label results route + +Route is deprecated and unavailable. Returns 410 Gone for every request. The orchestrator-backed batch surface is retired; callers must migrate to the replacement flow. + +**Parameters:** + +| Name | In | Type | Required | Description | +| ---- | -- | ---- | -------- | ----------- | +| `pool` | path | `string` | Yes | Pool name | +| `label` | path | `string` | Yes | Batch label | + +**Responses:** + +| Status | Description | Schema | +| ------ | ----------- | ------ | +| `410` | Gone | object (map of `string`) | + +### `GET /api/label/{pool}/{label}/status` + +**Deprecated.** Deprecated label status route + +Route is deprecated and unavailable. Returns 410 Gone for every request. The orchestrator-backed batch surface is retired; callers must migrate to the replacement flow. + +**Parameters:** + +| Name | In | Type | Required | Description | +| ---- | -- | ---- | -------- | ----------- | +| `pool` | path | `string` | Yes | Pool name | +| `label` | path | `string` | Yes | Batch label | + +**Responses:** + +| Status | Description | Schema | +| ------ | ----------- | ------ | +| `410` | Gone | object (map of `string`) | + +## Config + +### `GET /api/config` + +**Per-user feature flags** + +Returns OPA-evaluated feature flags for the authenticated SPA user. `admin` is true when the caller's JWT sub appears in input.flags.admin_subs. + +**Responses:** + +| Status | Description | Schema | +| ------ | ----------- | ------ | +| `200` | OK | `handlers.ConfigResponse` | +| `401` | Unauthorized | `handlers.ErrorResponse` | + +## Gateway + +### `GET /api/gateway/{name}/{path}` + +**Reverse-proxy to the per-pool orchestrator (sole ingress path — CUA-527)** + +The single authorised path to reach a pool orchestrator. Per-pool orchestrators no longer have a direct Tailscale Ingress; all traffic MUST flow through here. Validates a Bearer JWT issued via client_credentials to a per-key Keycloak client, verifies via OPA that the token's `namespace` claim equals the pool name (preventing cross-pool access), then proxies to <name>-orchestrator.<namespace>.svc.cluster.local. The request body and method are passed through unchanged; the response is streamed back as-is. + +**Parameters:** + +| Name | In | Type | Required | Description | +| ---- | -- | ---- | -------- | ----------- | +| `name` | path | `string` | Yes | Pool name (DNS-1123 label) | +| `path` | path | `string` | Yes | Upstream path (proxied verbatim, no validation) | + +**Responses:** + +| Status | Description | Schema | +| ------ | ----------- | ------ | +| `200` | Upstream response | `string` | +| `400` | Bad Request | `handlers.ErrorResponse` | +| `401` | Unauthorized | `handlers.ErrorResponse` | +| `403` | Forbidden | `handlers.ErrorResponse` | +| `502` | Bad Gateway | `handlers.ErrorResponse` | + +### `GET /api/svc/{namespace}/{service}/{path}` + +**Authenticated reverse proxy to a K8s Service in a namespace the caller owns** + +Proxies to {service}.{namespace}.svc.cluster.local:80. Per-key tokens are bound to their `namespace` claim; all other principals (SPA, user keys, oauth2-proxy browser sessions) must hold RBAC in {namespace}, verified via an impersonated RoleBinding probe. Strips Authorization before forwarding. + +**Parameters:** + +| Name | In | Type | Required | Description | +| ---- | -- | ---- | -------- | ----------- | +| `namespace` | path | `string` | Yes | K8s namespace (caller must own it, or it must match a per-key token's namespace claim) | +| `service` | path | `string` | Yes | K8s Service name (DNS-1123 label) | +| `path` | path | `string` | Yes | Upstream path (proxied verbatim) | + +**Responses:** + +| Status | Description | Schema | +| ------ | ----------- | ------ | +| `200` | Upstream response | `string` | +| `400` | Bad Request | `handlers.ErrorResponse` | +| `401` | Unauthorized | `handlers.ErrorResponse` | +| `403` | Forbidden | `handlers.ErrorResponse` | +| `502` | Bad Gateway | `handlers.ErrorResponse` | + +## Health + +### `GET /healthz` + +**Liveness/readiness probe** + +**Responses:** + +| Status | Description | Schema | +| ------ | ----------- | ------ | +| `200` | OK | `handlers.HealthResponse` | + +## Keys + +### `GET /api/keys` + +**List the calling user's API keys** + +**Responses:** + +| Status | Description | Schema | +| ------ | ----------- | ------ | +| `200` | OK | `handlers.ListKeysResponse` | +| `401` | Unauthorized | `handlers.ErrorResponse` | + +### `POST /api/keys` + +**Create a new API key** + +Creates a Keycloak service-account client owned by the calling user. The returned `client_secret` is shown exactly once. + +**Request body:** `handlers.CreateKeyRequest` (required) — Key parameters + +**Responses:** + +| Status | Description | Schema | +| ------ | ----------- | ------ | +| `201` | Created | `handlers.CreateKeyResponse` | +| `400` | Bad Request | `handlers.ErrorResponse` | +| `401` | Unauthorized | `handlers.ErrorResponse` | +| `403` | Forbidden | `handlers.ErrorResponse` | + +### `DELETE /api/keys/{id}` + +**Revoke an API key by Keycloak client UUID** + +**Parameters:** + +| Name | In | Type | Required | Description | +| ---- | -- | ---- | -------- | ----------- | +| `id` | path | `string` | Yes | Keycloak client UUID | + +**Responses:** + +| Status | Description | Schema | +| ------ | ----------- | ------ | +| `204` | No Content | — | +| `401` | Unauthorized | `handlers.ErrorResponse` | +| `403` | Forbidden | `handlers.ErrorResponse` | + +## Namespaces + +### `GET /api/namespaces` + +**List the calling user's namespaces** + +Returns namespaces owned by the caller's Capsule Tenant. The list is scoped by a capsule.clastix.io/tenant=<tenant> label selector built from the authenticated subject, so it stays fail-closed even when Capsule Proxy isn't filtering. + +**Responses:** + +| Status | Description | Schema | +| ------ | ----------- | ------ | +| `200` | OK | array of `handlers.NamespaceResponse` | +| `401` | Unauthorized | `handlers.ErrorResponse` | +| `502` | Bad Gateway | `handlers.ErrorResponse` | + +### `POST /api/namespaces` + +**Create a namespace for the calling user** + +Creates a K8s namespace via impersonation. Capsule's webhook intercepts the creation and assigns it to the user's Tenant. + +**Request body:** `handlers.CreateNamespaceRequest` (required) — Namespace parameters + +**Responses:** + +| Status | Description | Schema | +| ------ | ----------- | ------ | +| `201` | Created | `handlers.NamespaceResponse` | +| `400` | Bad Request | `handlers.ErrorResponse` | +| `401` | Unauthorized | `handlers.ErrorResponse` | +| `409` | Conflict | `handlers.ErrorResponse` | +| `502` | Bad Gateway | `handlers.ErrorResponse` | + +### `DELETE /api/namespaces/{name}` + +**Delete a namespace owned by the calling user** + +Deletes a K8s namespace via impersonation. Capsule blocks deletion if the namespace doesn't belong to the user's Tenant. + +**Parameters:** + +| Name | In | Type | Required | Description | +| ---- | -- | ---- | -------- | ----------- | +| `name` | path | `string` | Yes | Namespace name | + +**Responses:** + +| Status | Description | Schema | +| ------ | ----------- | ------ | +| `204` | No Content | — | +| `400` | Bad Request | `handlers.ErrorResponse` | +| `401` | Unauthorized | `handlers.ErrorResponse` | +| `403` | Forbidden | `handlers.ErrorResponse` | +| `404` | Not Found | `handlers.ErrorResponse` | +| `502` | Bad Gateway | `handlers.ErrorResponse` | + +## Passthrough + +### `GET /api/k8s/{path}` + +**Authenticated proxy to the in-pod kubectl-proxy sidecar** + +Forwards requests to http://127.0.0.1:8001 (the kubectl-proxy sidecar) so the SPA can read K8s resources via the pod ServiceAccount. SPA-only; OPA-gated. EventList responses are filtered by the caller's OPA visible_events policy via auth.K8sEventFilterMiddleware (mounted in main.go's route chain). + +**Parameters:** + +| Name | In | Type | Required | Description | +| ---- | -- | ---- | -------- | ----------- | +| `path` | path | `string` | Yes | K8s API path | + +**Responses:** + +| Status | Description | Schema | +| ------ | ----------- | ------ | +| `200` | K8s API response | `string` | +| `401` | Unauthorized | `handlers.ErrorResponse` | +| `403` | Forbidden | `handlers.ErrorResponse` | +| `502` | Bad Gateway | `handlers.ErrorResponse` | + +### `GET /api/orch/{namespace}/{service}/{path}` + +**SPA-authenticated proxy to a per-namespace orchestrator service** + +Resolves <service>.<namespace>.svc.cluster.local at request time (in-cluster DNS). The caller must hold RBAC in {namespace} (verified via an impersonated RoleBinding probe); OPA additionally validates that namespace and service look like DNS-1123 labels. + +**Parameters:** + +| Name | In | Type | Required | Description | +| ---- | -- | ---- | -------- | ----------- | +| `namespace` | path | `string` | Yes | Namespace (DNS-1123 label) | +| `service` | path | `string` | Yes | Service name (DNS-1123 label) | +| `path` | path | `string` | Yes | Upstream path | + +**Responses:** + +| Status | Description | Schema | +| ------ | ----------- | ------ | +| `200` | Upstream response | `string` | +| `400` | Bad Request | `handlers.ErrorResponse` | +| `401` | Unauthorized | `handlers.ErrorResponse` | +| `403` | Forbidden | `handlers.ErrorResponse` | +| `502` | Bad Gateway | `handlers.ErrorResponse` | + +## Pool Templates + +### `GET /api/pool-templates` + +**List the calling user's pool templates** + +Returns every pool template owned by the calling user, newest first. + +**Responses:** + +| Status | Description | Schema | +| ------ | ----------- | ------ | +| `200` | OK | array of `handlers.PoolTemplateResponse` | +| `401` | Unauthorized | `handlers.ErrorResponse` | +| `503` | Service Unavailable | `handlers.ErrorResponse` | + +### `POST /api/pool-templates` + +**Save a pool config as a reusable template** + +Stores the given pool config under a name owned by the calling user. Re-saving an existing name overwrites the config while preserving the original creation time. Use GET /api/pool-templates to list them and seed a new pool. + +**Request body:** `handlers.CreatePoolTemplateRequest` (required) — Template name + pool config + +**Responses:** + +| Status | Description | Schema | +| ------ | ----------- | ------ | +| `201` | Created | `handlers.PoolTemplateResponse` | +| `400` | Bad Request | `handlers.ErrorResponse` | +| `401` | Unauthorized | `handlers.ErrorResponse` | +| `503` | Service Unavailable | `handlers.ErrorResponse` | + +### `DELETE /api/pool-templates/{name}` + +**Delete one of the calling user's pool templates** + +**Parameters:** + +| Name | In | Type | Required | Description | +| ---- | -- | ---- | -------- | ----------- | +| `name` | path | `string` | Yes | Template name | + +**Responses:** + +| Status | Description | Schema | +| ------ | ----------- | ------ | +| `204` | No Content | — | +| `401` | Unauthorized | `handlers.ErrorResponse` | +| `404` | Not Found | `handlers.ErrorResponse` | +| `503` | Service Unavailable | `handlers.ErrorResponse` | + +### `GET /api/pool-templates/{name}` + +**Get one of the calling user's pool templates** + +**Parameters:** + +| Name | In | Type | Required | Description | +| ---- | -- | ---- | -------- | ----------- | +| `name` | path | `string` | Yes | Template name | + +**Responses:** + +| Status | Description | Schema | +| ------ | ----------- | ------ | +| `200` | OK | `handlers.PoolTemplateResponse` | +| `401` | Unauthorized | `handlers.ErrorResponse` | +| `404` | Not Found | `handlers.ErrorResponse` | +| `503` | Service Unavailable | `handlers.ErrorResponse` | + +## User Keys + +### `GET /api/user-keys` + +**List the calling user's API keys** + +**Responses:** + +| Status | Description | Schema | +| ------ | ----------- | ------ | +| `200` | OK | `handlers.ListUserKeysResponse` | +| `401` | Unauthorized | `handlers.ErrorResponse` | + +### `POST /api/user-keys` + +**Create a per-user API key** + +Creates a Keycloak service-account client that acts on behalf of the calling user. The client_secret is returned exactly once; it cannot be retrieved later. + +**Request body:** `handlers.CreateUserKeyRequest` (required) — Key parameters + +**Responses:** + +| Status | Description | Schema | +| ------ | ----------- | ------ | +| `201` | Created | `handlers.CreateUserKeyResponse` | +| `400` | Bad Request | `handlers.ErrorResponse` | +| `401` | Unauthorized | `handlers.ErrorResponse` | + +### `DELETE /api/user-keys/{id}` + +**Revoke a per-user API key** + +**Parameters:** + +| Name | In | Type | Required | Description | +| ---- | -- | ---- | -------- | ----------- | +| `id` | path | `string` | Yes | Keycloak client UUID | + +**Responses:** + +| Status | Description | Schema | +| ------ | ----------- | ------ | +| `204` | No Content | — | +| `401` | Unauthorized | `handlers.ErrorResponse` | +| `403` | Forbidden | `handlers.ErrorResponse` | + +## Models + +Schema definitions referenced by the request bodies and responses above. + +### `handlers.ConfigResponse` + +| Property | Type | Required | Description | +| -------- | ---- | -------- | ----------- | +| `admin` | `boolean` | No | Admin is true when the caller is in input.flags.admin_subs (OPA-evaluated). Non-admins get the customer view: infra-only nav (Nodes, Operator events) is hidden in the SPA and the corresponding kubectl-proxy paths are denied server-side by authz.rego. | + +### `handlers.CreateKeyRequest` + +| Property | Type | Required | Description | +| -------- | ---- | -------- | ----------- | +| `name` | `string` | No | — | +| `namespace` | `string` | No | — | + +### `handlers.CreateKeyResponse` + +| Property | Type | Required | Description | +| -------- | ---- | -------- | ----------- | +| `client_id` | `string` | No | — | +| `client_secret` | `string` | No | — | +| `name` | `string` | No | — | +| `namespace` | `string` | No | — | +| `token_url` | `string` | No | — | + +### `handlers.CreateNamespaceRequest` + +| Property | Type | Required | Description | +| -------- | ---- | -------- | ----------- | +| `name` | `string` | No | — | + +### `handlers.CreatePoolTemplateRequest` + +| Property | Type | Required | Description | +| -------- | ---- | -------- | ----------- | +| `config` | `object` | No | — | +| `name` | `string` | No | — | + +### `handlers.CreateUserKeyRequest` + +| Property | Type | Required | Description | +| -------- | ---- | -------- | ----------- | +| `name` | `string` | No | — | +| `scope` | array of `string` | No | — | + +### `handlers.CreateUserKeyResponse` + +| Property | Type | Required | Description | +| -------- | ---- | -------- | ----------- | +| `client_id` | `string` | No | — | +| `client_secret` | `string` | No | — | +| `name` | `string` | No | — | +| `scope` | array of `string` | No | — | +| `token_url` | `string` | No | — | + +### `handlers.ErrorResponse` + +| Property | Type | Required | Description | +| -------- | ---- | -------- | ----------- | +| `error` | `string` | No | — | + +### `handlers.HealthResponse` + +| Property | Type | Required | Description | +| -------- | ---- | -------- | ----------- | +| `ok` | `boolean` | No | — | + +### `handlers.ListKeysResponse` + +| Property | Type | Required | Description | +| -------- | ---- | -------- | ----------- | +| `keys` | array of `keycloak.KeyClient` | No | — | + +### `handlers.ListUserKeysResponse` + +| Property | Type | Required | Description | +| -------- | ---- | -------- | ----------- | +| `keys` | array of `handlers.UserKeyResponse` | No | — | + +### `handlers.NamespaceResponse` + +| Property | Type | Required | Description | +| -------- | ---- | -------- | ----------- | +| `createdAt` | `string` | No | — | +| `labels` | object (map of `string`) | No | — | +| `name` | `string` | No | — | +| `status` | `string` | No | — | + +### `handlers.PoolTemplateResponse` + +| Property | Type | Required | Description | +| -------- | ---- | -------- | ----------- | +| `config` | `object` | No | — | +| `createdAt` | `string` | No | — | +| `name` | `string` | No | — | +| `user` | `string` | No | — | + +### `handlers.UserKeyResponse` + +| Property | Type | Required | Description | +| -------- | ---- | -------- | ----------- | +| `client_id` | `string` | No | — | +| `id` | `string` | No | — | +| `name` | `string` | No | — | +| `scope` | array of `string` | No | — | + +### `keycloak.KeyClient` + +| Property | Type | Required | Description | +| -------- | ---- | -------- | ----------- | +| `client_id` | `string` | No | — | +| `id` | `string` | No | — | +| `name` | `string` | No | — | +| `namespace` | `string` | No | — | +| `owner_sub` | `string` | No | — | diff --git a/docs/content/docs/reference/cyclops-cs/meta.json b/docs/content/docs/reference/cyclops-cs/meta.json new file mode 100644 index 000000000..a904e966e --- /dev/null +++ b/docs/content/docs/reference/cyclops-cs/meta.json @@ -0,0 +1 @@ +{ "title": "Cyclops CS Backend", "pages": ["http-api"] } diff --git a/docs/content/docs/reference/meta.json b/docs/content/docs/reference/meta.json index 0ccc7d0d6..c184b6d01 100644 --- a/docs/content/docs/reference/meta.json +++ b/docs/content/docs/reference/meta.json @@ -1 +1 @@ -{ "title": "Reference", "icon": "BookText", "pages": ["index", "cua-driver", "sandbox-sdk", "docs-code-mcp"] } +{ "title": "Reference", "icon": "BookText", "pages": ["index", "cua-driver", "sandbox-sdk", "cyclops-cs", "docs-code-mcp"] } diff --git a/scripts/docs-generators/config.json b/scripts/docs-generators/config.json index 069564f41..f302f0469 100644 --- a/scripts/docs-generators/config.json +++ b/scripts/docs-generators/config.json @@ -60,6 +60,26 @@ ], "enabled": true }, + "cyclops-cs": { + "name": "Cyclops CS Backend", + "language": "go", + "sourcePath": "scripts/docs-generators/specs", + "docsOutputPath": "docs/content/docs/reference/cyclops-cs", + "generatorScript": "scripts/docs-generators/cyclops-cs.ts", + "watchPaths": ["scripts/docs-generators/specs/cyclops-cs.swagger.json"], + "buildCommand": null, + "buildDirectory": ".", + "extractionMethod": "openapi-spec", + "outputs": [ + { + "type": "api", + "outputFile": "http-api.mdx", + "extractCommand": null + } + ], + "enabled": true, + "notes": "Source lives in trycua/cloud (cyclops-cs/backend/docs/swagger.json). The Swagger 2.0 spec is vendored to specs/cyclops-cs.swagger.json and refreshed by .github/workflows/sync-cyclops-cs-spec.yml; the generator reads only the vendored copy so it runs hermetically here." + }, "cua-cli": { "name": "Cua CLI", "language": "typescript", diff --git a/scripts/docs-generators/cyclops-cs.ts b/scripts/docs-generators/cyclops-cs.ts new file mode 100644 index 000000000..9ef00fd31 --- /dev/null +++ b/scripts/docs-generators/cyclops-cs.ts @@ -0,0 +1,432 @@ +#!/usr/bin/env npx tsx + +/** + * Cyclops CS Backend Documentation Generator + * + * Generates MDX documentation for the Cyclops CS backend HTTP API from its + * committed OpenAPI/Swagger 2.0 description. + * + * Unlike the in-tree generators (cua-driver, lume), the Cyclops CS backend + * lives in a different repository (trycua/cloud). Its spec is therefore + * *vendored* into this repo under `specs/cyclops-cs.swagger.json` and kept in + * sync by the routine in `.github/workflows/sync-cyclops-cs-spec.yml`. This + * generator only ever reads the vendored copy, so it runs hermetically here. + * + * Usage: + * npx tsx scripts/docs-generators/cyclops-cs.ts # Generate docs + * npx tsx scripts/docs-generators/cyclops-cs.ts --check # Check for drift (CI mode) + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +// ============================================================================ +// Paths +// ============================================================================ + +const ROOT_DIR = path.resolve(__dirname, '../..'); +const SPEC_PATH = path.join(__dirname, 'specs', 'cyclops-cs.swagger.json'); +const DOCS_OUTPUT_DIR = path.join(ROOT_DIR, 'docs/content/docs/reference/cyclops-cs'); +const OUTPUT_FILE = 'http-api.mdx'; + +// ============================================================================ +// Minimal Swagger 2.0 types (only what this generator consumes) +// ============================================================================ + +interface Schema { + $ref?: string; + type?: string; + format?: string; + items?: Schema; + additionalProperties?: Schema | boolean; + enum?: unknown[]; + properties?: Record; + required?: string[]; + description?: string; +} + +interface Parameter { + name: string; + in: 'body' | 'path' | 'query' | 'header' | 'formData'; + required?: boolean; + type?: string; + format?: string; + items?: Schema; + enum?: unknown[]; + description?: string; + schema?: Schema; +} + +interface Response { + description?: string; + schema?: Schema; +} + +interface Operation { + summary?: string; + description?: string; + tags?: string[]; + deprecated?: boolean; + parameters?: Parameter[]; + responses?: Record; +} + +interface Swagger { + swagger?: string; + info?: { title?: string; version?: string; description?: string }; + basePath?: string; + host?: string; + paths?: Record>; + definitions?: Record; +} + +const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'] as const; + +// ============================================================================ +// MDX escaping (mirrors scripts/docs-generators/cua-driver.ts) +// ============================================================================ + +function escapeMdxText(value: string): string { + return value + .split(/(`[^`]*`)/g) + .map((segment) => { + if (segment.startsWith('`') && segment.endsWith('`')) { + return segment; + } + return segment + .replace(/\{/g, '{') + .replace(/\}/g, '}') + .replace(//g, '>'); + }) + .join(''); +} + +function escapeTableCell(value: string): string { + return escapeMdxText(value.replace(/\s*\n\s*/g, ' ').trim()).replace(/\|/g, '\\|'); +} + +// ============================================================================ +// Schema / type rendering +// ============================================================================ + +function refName(ref: string): string { + const parts = ref.split('/'); + return parts[parts.length - 1]; +} + +/** Render a schema as a short, MDX-safe inline-code type expression. */ +function schemaToText(schema: Schema | undefined): string { + if (!schema) return '—'; + + if (schema.$ref) { + return `\`${refName(schema.$ref)}\``; + } + + if (schema.type === 'array') { + return `array of ${schemaToText(schema.items)}`; + } + + if (schema.type === 'object' || (!schema.type && schema.additionalProperties)) { + if (schema.additionalProperties && typeof schema.additionalProperties === 'object') { + return `object (map of ${schemaToText(schema.additionalProperties)})`; + } + return '`object`'; + } + + if (schema.type) { + return schema.format ? `\`${schema.type}\` (${schema.format})` : `\`${schema.type}\``; + } + + return '—'; +} + +/** Render a non-body parameter's type. */ +function paramTypeText(param: Parameter): string { + if (param.type === 'array') { + const itemType = param.items?.type ?? 'string'; + return `array of \`${itemType}\``; + } + if (param.type) { + return param.format ? `\`${param.type}\` (${param.format})` : `\`${param.type}\``; + } + return '—'; +} + +function enumNote(values: unknown[] | undefined): string { + if (!values || values.length === 0) return ''; + const rendered = values.map((v) => `\`${String(v)}\``).join(', '); + return ` One of: ${rendered}.`; +} + +// ============================================================================ +// MDX assembly +// ============================================================================ + +function titleCase(tag: string): string { + return tag + .split(/[-_\s]+/) + .map((w) => (w ? w[0].toUpperCase() + w.slice(1) : w)) + .join(' '); +} + +function endpointHeading(method: string, route: string): string { + // Path templates contain `{...}` which MDX parses as expressions, so the + // method + path always live inside a code span (matches the existing + // `### \`cua-driver list-tools\`` convention in cua-driver docs). + return `### \`${method.toUpperCase()} ${route}\``; +} + +function generateOperation(method: string, route: string, op: Operation): string[] { + const lines: string[] = []; + lines.push(endpointHeading(method, route)); + lines.push(''); + + if (op.summary) { + const summary = op.deprecated ? `**Deprecated.** ${op.summary}` : `**${op.summary}**`; + lines.push(escapeMdxText(summary)); + lines.push(''); + } else if (op.deprecated) { + lines.push('**Deprecated.**'); + lines.push(''); + } + + if (op.description && op.description.trim() && op.description.trim() !== op.summary?.trim()) { + lines.push(escapeMdxText(op.description.trim())); + lines.push(''); + } + + const params = op.parameters ?? []; + const bodyParam = params.find((p) => p.in === 'body'); + const otherParams = params.filter((p) => p.in !== 'body'); + + if (otherParams.length > 0) { + lines.push('**Parameters:**'); + lines.push(''); + lines.push('| Name | In | Type | Required | Description |'); + lines.push('| ---- | -- | ---- | -------- | ----------- |'); + for (const p of otherParams) { + // Swagger 2.0 requires every `in: path` parameter to be required; enforce + // it here so the table is correct even when an upstream spec omits the flag. + const required = p.required || p.in === 'path' ? 'Yes' : 'No'; + const description = `${(p.description ?? '').trim()}${enumNote(p.enum)}`.trim() || '—'; + lines.push( + `| \`${p.name}\` | ${p.in} | ${paramTypeText(p)} | ${required} | ${escapeTableCell(description)} |` + ); + } + lines.push(''); + } + + if (bodyParam) { + const required = bodyParam.required ? ' (required)' : ''; + const body = schemaToText(bodyParam.schema); + const desc = bodyParam.description ? ` — ${escapeMdxText(bodyParam.description.trim())}` : ''; + lines.push(`**Request body:** ${body}${required}${desc}`); + lines.push(''); + } + + if (op.responses && Object.keys(op.responses).length > 0) { + lines.push('**Responses:**'); + lines.push(''); + lines.push('| Status | Description | Schema |'); + lines.push('| ------ | ----------- | ------ |'); + const codes = Object.keys(op.responses).sort((a, b) => a.localeCompare(b)); + for (const code of codes) { + const r = op.responses[code]; + const description = escapeTableCell(r.description ?? ''); + const schema = r.schema ? schemaToText(r.schema) : '—'; + lines.push(`| \`${code}\` | ${description || '—'} | ${schema} |`); + } + lines.push(''); + } + + return lines; +} + +function generateModel(name: string, schema: Schema): string[] { + const lines: string[] = []; + lines.push(`### \`${name}\``); + lines.push(''); + + if (schema.description && schema.description.trim()) { + lines.push(escapeMdxText(schema.description.trim())); + lines.push(''); + } + + const properties = schema.properties ?? {}; + const propNames = Object.keys(properties).sort((a, b) => a.localeCompare(b)); + const requiredSet = new Set(schema.required ?? []); + + if (propNames.length === 0) { + lines.push('_No documented properties._'); + lines.push(''); + return lines; + } + + lines.push('| Property | Type | Required | Description |'); + lines.push('| -------- | ---- | -------- | ----------- |'); + for (const prop of propNames) { + const p = properties[prop]; + const required = requiredSet.has(prop) ? 'Yes' : 'No'; + const description = `${(p.description ?? '').trim()}${enumNote(p.enum)}`.trim() || '—'; + lines.push( + `| \`${prop}\` | ${schemaToText(p)} | ${required} | ${escapeTableCell(description)} |` + ); + } + lines.push(''); + return lines; +} + +export function generateHttpApiMdx(spec: Swagger): string { + const lines: string[] = []; + const title = spec.info?.title ?? 'Cyclops CS Backend API'; + const version = spec.info?.version ?? ''; + const basePath = spec.basePath && spec.basePath !== '/' ? spec.basePath : ''; + + // Frontmatter — must be the very first thing in the file. + lines.push('---'); + lines.push('title: HTTP API'); + lines.push(`description: REST API reference for the Cyclops CS backend${version ? `, v${version}` : ''}.`); + lines.push('---'); + lines.push(''); + lines.push(`{/* + AUTO-GENERATED FILE - DO NOT EDIT DIRECTLY + Generated by: npx tsx scripts/docs-generators/cyclops-cs.ts + Source: trycua/cloud cyclops-cs/backend/docs/swagger.json (vendored) + Spec version: ${version || 'unknown'} +*/}`); + lines.push(''); + + // Intro + lines.push( + `Reference for the **${escapeMdxText(title)}** HTTP surface${version ? `, version \`${version}\`` : ''}. ` + + `Generated from the backend's committed OpenAPI (Swagger 2.0) description.` + ); + lines.push(''); + if (spec.info?.description && spec.info.description.trim()) { + lines.push(escapeMdxText(spec.info.description.trim())); + lines.push(''); + } + if (basePath) { + lines.push(`All paths are relative to the base path \`${basePath}\`.`); + lines.push(''); + } + + // Collect operations grouped by tag. + const paths = spec.paths ?? {}; + const byTag = new Map>(); + for (const route of Object.keys(paths)) { + const item = paths[route]; + for (const method of HTTP_METHODS) { + const op = item[method]; + if (!op) continue; + const tag = op.tags && op.tags.length > 0 ? op.tags[0] : 'Other'; + if (!byTag.has(tag)) byTag.set(tag, []); + byTag.get(tag)!.push({ method, route, op }); + } + } + + const tags = Array.from(byTag.keys()).sort((a, b) => { + if (a === 'Other') return 1; + if (b === 'Other') return -1; + return a.localeCompare(b); + }); + + // Endpoint overview table. + const totalEndpoints = Array.from(byTag.values()).reduce((n, arr) => n + arr.length, 0); + lines.push('## Endpoints'); + lines.push(''); + lines.push(`The backend exposes ${totalEndpoints} endpoints across ${tags.length} groups.`); + lines.push(''); + lines.push('| Method | Path | Summary |'); + lines.push('| ------ | ---- | ------- |'); + for (const tag of tags) { + const entries = byTag + .get(tag)! + .slice() + .sort((a, b) => a.route.localeCompare(b.route) || a.method.localeCompare(b.method)); + for (const { method, route, op } of entries) { + const summary = escapeTableCell(op.summary ?? ''); + lines.push(`| \`${method.toUpperCase()}\` | \`${route}\` | ${summary || '—'} |`); + } + } + lines.push(''); + + // Detailed sections per tag. + for (const tag of tags) { + lines.push(`## ${escapeMdxText(titleCase(tag))}`); + lines.push(''); + const entries = byTag + .get(tag)! + .slice() + .sort((a, b) => a.route.localeCompare(b.route) || a.method.localeCompare(b.method)); + for (const { method, route, op } of entries) { + lines.push(...generateOperation(method, route, op)); + } + } + + // Models. + const definitions = spec.definitions ?? {}; + const modelNames = Object.keys(definitions).sort((a, b) => a.localeCompare(b)); + if (modelNames.length > 0) { + lines.push('## Models'); + lines.push(''); + lines.push( + 'Schema definitions referenced by the request bodies and responses above.' + ); + lines.push(''); + for (const name of modelNames) { + lines.push(...generateModel(name, definitions[name])); + } + } + + // Normalise trailing whitespace and guarantee a single trailing newline. + while (lines.length > 0 && lines[lines.length - 1].trim() === '') { + lines.pop(); + } + return lines.join('\n') + '\n'; +} + +// ============================================================================ +// Main +// ============================================================================ + +function main(): void { + const args = process.argv.slice(2); + const checkOnly = args.includes('--check') || args.includes('--check-only'); + + console.log('Cyclops CS Backend Documentation Generator'); + console.log('==========================================\n'); + + if (!fs.existsSync(SPEC_PATH)) { + console.error(`Vendored spec not found: ${path.relative(ROOT_DIR, SPEC_PATH)}`); + console.error('Run the spec sync routine before generating.'); + process.exit(1); + } + + const spec: Swagger = JSON.parse(fs.readFileSync(SPEC_PATH, 'utf-8')); + const mdx = generateHttpApiMdx(spec); + const outPath = path.join(DOCS_OUTPUT_DIR, OUTPUT_FILE); + + if (checkOnly) { + console.log('Checking for documentation drift...'); + if (!fs.existsSync(outPath)) { + console.error(`${OUTPUT_FILE} does not exist`); + process.exit(1); + } + const existing = fs.readFileSync(outPath, 'utf-8'); + if (existing !== mdx) { + console.error(`${OUTPUT_FILE} is out of sync with the vendored spec`); + console.error("\nRun 'npx tsx scripts/docs-generators/cyclops-cs.ts' to update documentation"); + process.exit(1); + } + console.log(`${OUTPUT_FILE} is up to date`); + return; + } + + fs.mkdirSync(DOCS_OUTPUT_DIR, { recursive: true }); + fs.writeFileSync(outPath, mdx); + console.log(`Generated ${path.relative(ROOT_DIR, outPath)}`); +} + +main(); diff --git a/scripts/docs-generators/specs/cyclops-cs.swagger.json b/scripts/docs-generators/specs/cyclops-cs.swagger.json new file mode 100644 index 000000000..d4491d626 --- /dev/null +++ b/scripts/docs-generators/specs/cyclops-cs.swagger.json @@ -0,0 +1,1562 @@ +{ + "swagger": "2.0", + "info": { + "description": "Backend sidecar for the cyclops-cs SPA — Keycloak-authenticated key management, authenticated reverse proxies (gateway / k8s / orch), and deprecated batch/label endpoints that now return 410 Gone. The /api/gateway proxy is the sole ingress to pool orchestrators (CUA-527); per-pool Tailscale Ingresses have been removed.", + "title": "Cyclops CS Backend API", + "contact": {}, + "version": "0.1" + }, + "basePath": "/", + "paths": { + "/api/batch/{pool}/lanes": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Route is deprecated and unavailable. Returns 410 Gone for every request. The orchestrator-backed batch surface is retired; callers must migrate to the replacement flow.", + "produces": [ + "application/json" + ], + "tags": [ + "batch" + ], + "summary": "Deprecated batch lane acquisition route", + "deprecated": true, + "parameters": [ + { + "type": "string", + "description": "Pool name", + "name": "pool", + "in": "path", + "required": true + } + ], + "responses": { + "410": { + "description": "Gone", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Route is deprecated and unavailable. Returns 410 Gone for every request. The orchestrator-backed batch surface is retired; callers must migrate to the replacement flow.", + "produces": [ + "application/json" + ], + "tags": [ + "batch" + ], + "summary": "Deprecated batch lane release route", + "deprecated": true, + "parameters": [ + { + "type": "string", + "description": "Pool name", + "name": "pool", + "in": "path", + "required": true + } + ], + "responses": { + "410": { + "description": "Gone", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/batch/{pool}/submit": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Route is deprecated and unavailable. Returns 410 Gone for every request. The orchestrator-backed batch surface is retired; callers must migrate to the replacement flow.", + "produces": [ + "application/json" + ], + "tags": [ + "batch" + ], + "summary": "Deprecated batch submission route", + "deprecated": true, + "parameters": [ + { + "type": "string", + "description": "Pool name", + "name": "pool", + "in": "path", + "required": true + } + ], + "responses": { + "410": { + "description": "Gone", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/batch/{pool}/{id}": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Route is deprecated and unavailable. Returns 410 Gone for every request. The orchestrator-backed batch surface is retired; callers must migrate to the replacement flow.", + "produces": [ + "application/json" + ], + "tags": [ + "batch" + ], + "summary": "Deprecated batch cancellation route", + "deprecated": true, + "parameters": [ + { + "type": "string", + "description": "Pool name", + "name": "pool", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Batch ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "410": { + "description": "Gone", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/batch/{pool}/{id}/results": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Route is deprecated and unavailable. Returns 410 Gone for every request. The orchestrator-backed batch surface is retired; callers must migrate to the replacement flow.", + "produces": [ + "application/json" + ], + "tags": [ + "batch" + ], + "summary": "Deprecated batch results route", + "deprecated": true, + "parameters": [ + { + "type": "string", + "description": "Pool name", + "name": "pool", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Batch ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "410": { + "description": "Gone", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/batch/{pool}/{id}/status": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Route is deprecated and unavailable. Returns 410 Gone for every request. The orchestrator-backed batch surface is retired; callers must migrate to the replacement flow.", + "produces": [ + "application/json" + ], + "tags": [ + "batch" + ], + "summary": "Deprecated batch status route", + "deprecated": true, + "parameters": [ + { + "type": "string", + "description": "Pool name", + "name": "pool", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Batch ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "410": { + "description": "Gone", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/config": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns OPA-evaluated feature flags for the authenticated SPA user. `admin` is true when the caller's JWT sub appears in input.flags.admin_subs.", + "produces": [ + "application/json" + ], + "tags": [ + "config" + ], + "summary": "Per-user feature flags", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.ConfigResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/api/gateway/{name}/{path}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "The single authorised path to reach a pool orchestrator. Per-pool orchestrators no longer have a direct Tailscale Ingress; all traffic MUST flow through here. Validates a Bearer JWT issued via client_credentials to a per-key Keycloak client, verifies via OPA that the token's `namespace` claim equals the pool name (preventing cross-pool access), then proxies to -orchestrator..svc.cluster.local. The request body and method are passed through unchanged; the response is streamed back as-is.", + "tags": [ + "gateway" + ], + "summary": "Reverse-proxy to the per-pool orchestrator (sole ingress path — CUA-527)", + "parameters": [ + { + "type": "string", + "description": "Pool name (DNS-1123 label)", + "name": "name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Upstream path (proxied verbatim, no validation)", + "name": "path", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Upstream response", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "502": { + "description": "Bad Gateway", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/api/k8s/{path}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Forwards requests to http://127.0.0.1:8001 (the kubectl-proxy sidecar) so the SPA can read K8s resources via the pod ServiceAccount. SPA-only; OPA-gated. EventList responses are filtered by the caller's OPA visible_events policy via auth.K8sEventFilterMiddleware (mounted in main.go's route chain).", + "tags": [ + "passthrough" + ], + "summary": "Authenticated proxy to the in-pod kubectl-proxy sidecar", + "parameters": [ + { + "type": "string", + "description": "K8s API path", + "name": "path", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "K8s API response", + "schema": { + "type": "string" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "502": { + "description": "Bad Gateway", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/api/keys": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "keys" + ], + "summary": "List the calling user's API keys", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.ListKeysResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Creates a Keycloak service-account client owned by the calling user. The returned `client_secret` is shown exactly once.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "keys" + ], + "summary": "Create a new API key", + "parameters": [ + { + "description": "Key parameters", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.CreateKeyRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handlers.CreateKeyResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/api/keys/{id}": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "keys" + ], + "summary": "Revoke an API key by Keycloak client UUID", + "parameters": [ + { + "type": "string", + "description": "Keycloak client UUID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/api/label/{pool}/{label}": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Route is deprecated and unavailable. Returns 410 Gone for every request. The orchestrator-backed batch surface is retired; callers must migrate to the replacement flow.", + "produces": [ + "application/json" + ], + "tags": [ + "batch" + ], + "summary": "Deprecated label cancellation route", + "deprecated": true, + "parameters": [ + { + "type": "string", + "description": "Pool name", + "name": "pool", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Batch label", + "name": "label", + "in": "path", + "required": true + } + ], + "responses": { + "410": { + "description": "Gone", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/label/{pool}/{label}/batch": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Route is deprecated and unavailable. Returns 410 Gone for every request. The orchestrator-backed batch surface is retired; callers must migrate to the replacement flow.", + "produces": [ + "application/json" + ], + "tags": [ + "batch" + ], + "summary": "Deprecated label batch submission route", + "deprecated": true, + "parameters": [ + { + "type": "string", + "description": "Pool name", + "name": "pool", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Batch label", + "name": "label", + "in": "path", + "required": true + } + ], + "responses": { + "410": { + "description": "Gone", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/label/{pool}/{label}/results": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Route is deprecated and unavailable. Returns 410 Gone for every request. The orchestrator-backed batch surface is retired; callers must migrate to the replacement flow.", + "produces": [ + "application/json" + ], + "tags": [ + "batch" + ], + "summary": "Deprecated label results route", + "deprecated": true, + "parameters": [ + { + "type": "string", + "description": "Pool name", + "name": "pool", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Batch label", + "name": "label", + "in": "path", + "required": true + } + ], + "responses": { + "410": { + "description": "Gone", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/label/{pool}/{label}/status": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Route is deprecated and unavailable. Returns 410 Gone for every request. The orchestrator-backed batch surface is retired; callers must migrate to the replacement flow.", + "produces": [ + "application/json" + ], + "tags": [ + "batch" + ], + "summary": "Deprecated label status route", + "deprecated": true, + "parameters": [ + { + "type": "string", + "description": "Pool name", + "name": "pool", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Batch label", + "name": "label", + "in": "path", + "required": true + } + ], + "responses": { + "410": { + "description": "Gone", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/namespaces": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns namespaces owned by the caller's Capsule Tenant. The list is scoped by a capsule.clastix.io/tenant= label selector built from the authenticated subject, so it stays fail-closed even when Capsule Proxy isn't filtering.", + "produces": [ + "application/json" + ], + "tags": [ + "namespaces" + ], + "summary": "List the calling user's namespaces", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.NamespaceResponse" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "502": { + "description": "Bad Gateway", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Creates a K8s namespace via impersonation. Capsule's webhook intercepts the creation and assigns it to the user's Tenant.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "namespaces" + ], + "summary": "Create a namespace for the calling user", + "parameters": [ + { + "description": "Namespace parameters", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.CreateNamespaceRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handlers.NamespaceResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "502": { + "description": "Bad Gateway", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/api/namespaces/{name}": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Deletes a K8s namespace via impersonation. Capsule blocks deletion if the namespace doesn't belong to the user's Tenant.", + "tags": [ + "namespaces" + ], + "summary": "Delete a namespace owned by the calling user", + "parameters": [ + { + "type": "string", + "description": "Namespace name", + "name": "name", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "502": { + "description": "Bad Gateway", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/api/orch/{namespace}/{service}/{path}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Resolves ..svc.cluster.local at request time (in-cluster DNS). The caller must hold RBAC in {namespace} (verified via an impersonated RoleBinding probe); OPA additionally validates that namespace and service look like DNS-1123 labels.", + "tags": [ + "passthrough" + ], + "summary": "SPA-authenticated proxy to a per-namespace orchestrator service", + "parameters": [ + { + "type": "string", + "description": "Namespace (DNS-1123 label)", + "name": "namespace", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Service name (DNS-1123 label)", + "name": "service", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Upstream path", + "name": "path", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Upstream response", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "502": { + "description": "Bad Gateway", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/api/pool-templates": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns every pool template owned by the calling user, newest first.", + "produces": [ + "application/json" + ], + "tags": [ + "pool-templates" + ], + "summary": "List the calling user's pool templates", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.PoolTemplateResponse" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Stores the given pool config under a name owned by the calling user. Re-saving an existing name overwrites the config while preserving the original creation time. Use GET /api/pool-templates to list them and seed a new pool.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "pool-templates" + ], + "summary": "Save a pool config as a reusable template", + "parameters": [ + { + "description": "Template name + pool config", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.CreatePoolTemplateRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handlers.PoolTemplateResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/api/pool-templates/{name}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "pool-templates" + ], + "summary": "Get one of the calling user's pool templates", + "parameters": [ + { + "type": "string", + "description": "Template name", + "name": "name", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.PoolTemplateResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "pool-templates" + ], + "summary": "Delete one of the calling user's pool templates", + "parameters": [ + { + "type": "string", + "description": "Template name", + "name": "name", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/api/svc/{namespace}/{service}/{path}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Proxies to {service}.{namespace}.svc.cluster.local:80. Per-key tokens are bound to their `namespace` claim; all other principals (SPA, user keys, oauth2-proxy browser sessions) must hold RBAC in {namespace}, verified via an impersonated RoleBinding probe. Strips Authorization before forwarding.", + "tags": [ + "gateway" + ], + "summary": "Authenticated reverse proxy to a K8s Service in a namespace the caller owns", + "parameters": [ + { + "type": "string", + "description": "K8s namespace (caller must own it, or it must match a per-key token's namespace claim)", + "name": "namespace", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "K8s Service name (DNS-1123 label)", + "name": "service", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Upstream path (proxied verbatim)", + "name": "path", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Upstream response", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "502": { + "description": "Bad Gateway", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/api/user-keys": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "user-keys" + ], + "summary": "List the calling user's API keys", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.ListUserKeysResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Creates a Keycloak service-account client that acts on behalf of the calling user. The client_secret is returned exactly once; it cannot be retrieved later.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user-keys" + ], + "summary": "Create a per-user API key", + "parameters": [ + { + "description": "Key parameters", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.CreateUserKeyRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handlers.CreateUserKeyResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/api/user-keys/{id}": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "user-keys" + ], + "summary": "Revoke a per-user API key", + "parameters": [ + { + "type": "string", + "description": "Keycloak client UUID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/healthz": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "health" + ], + "summary": "Liveness/readiness probe", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.HealthResponse" + } + } + } + } + } + }, + "definitions": { + "handlers.ConfigResponse": { + "type": "object", + "properties": { + "admin": { + "description": "Admin is true when the caller is in input.flags.admin_subs (OPA-evaluated).\nNon-admins get the customer view: infra-only nav (Nodes, Operator events)\nis hidden in the SPA and the corresponding kubectl-proxy paths are denied\nserver-side by authz.rego.", + "type": "boolean" + } + } + }, + "handlers.CreateKeyRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "ci-prod" + }, + "namespace": { + "type": "string", + "example": "test-pool" + } + } + }, + "handlers.CreateKeyResponse": { + "type": "object", + "properties": { + "client_id": { + "type": "string" + }, + "client_secret": { + "type": "string" + }, + "name": { + "type": "string" + }, + "namespace": { + "type": "string" + }, + "token_url": { + "type": "string" + } + } + }, + "handlers.CreateNamespaceRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "my-workspace" + } + } + }, + "handlers.CreatePoolTemplateRequest": { + "type": "object", + "properties": { + "config": { + "type": "object" + }, + "name": { + "type": "string", + "example": "gpu-large" + } + } + }, + "handlers.CreateUserKeyRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "my-ci-key" + }, + "scope": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "[\"ns1\"", + "\"ns2\"]" + ] + } + } + }, + "handlers.CreateUserKeyResponse": { + "type": "object", + "properties": { + "client_id": { + "type": "string" + }, + "client_secret": { + "type": "string" + }, + "name": { + "type": "string" + }, + "scope": { + "type": "array", + "items": { + "type": "string" + } + }, + "token_url": { + "type": "string" + } + } + }, + "handlers.ErrorResponse": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + }, + "handlers.HealthResponse": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + } + } + }, + "handlers.ListKeysResponse": { + "type": "object", + "properties": { + "keys": { + "type": "array", + "items": { + "$ref": "#/definitions/keycloak.KeyClient" + } + } + } + }, + "handlers.ListUserKeysResponse": { + "type": "object", + "properties": { + "keys": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.UserKeyResponse" + } + } + } + }, + "handlers.NamespaceResponse": { + "type": "object", + "properties": { + "createdAt": { + "type": "string" + }, + "labels": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "name": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, + "handlers.PoolTemplateResponse": { + "type": "object", + "properties": { + "config": { + "type": "object" + }, + "createdAt": { + "type": "string" + }, + "name": { + "type": "string" + }, + "user": { + "type": "string" + } + } + }, + "handlers.UserKeyResponse": { + "type": "object", + "properties": { + "client_id": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "scope": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "keycloak.KeyClient": { + "type": "object", + "properties": { + "client_id": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "namespace": { + "type": "string" + }, + "owner_sub": { + "type": "string" + } + } + } + }, + "securityDefinitions": { + "BearerAuth": { + "description": "Keycloak access token. For /api/keys and /api/{k8s,orch} the token is the SPA's user JWT (azp=cyclops-cs-spa). /api/gateway/{name} and the deprecated /api/batch/{pool} + /api/label/{pool} routes still run through the same OPA checks; per-key tokens must carry a `namespace` claim matching \"pool-{name}\" or \"pool-{pool}\" respectively.", + "type": "apiKey", + "name": "Authorization", + "in": "header" + } + } +} diff --git a/scripts/docs-generators/sync-cyclops-cs-spec.ts b/scripts/docs-generators/sync-cyclops-cs-spec.ts new file mode 100644 index 000000000..a0125c318 --- /dev/null +++ b/scripts/docs-generators/sync-cyclops-cs-spec.ts @@ -0,0 +1,123 @@ +#!/usr/bin/env npx tsx + +/** + * Cyclops CS spec sync + * + * Refreshes the vendored Cyclops CS backend OpenAPI spec from its source of + * truth in trycua/cloud (`cyclops-cs/backend/docs/swagger.json`) and + * regenerates the reference MDX. This is the "sync whenever the JSON changes" + * half of the routine; the page-vs-spec half is enforced by `docs:check`. + * + * Source resolution (first match wins): + * 1. `--from ` CLI argument + * 2. `CYCLOPS_CS_SPEC_SOURCE` environment variable + * 3. local sibling checkout: ../cloud/cyclops-cs/backend/docs/swagger.json + * + * A `--from` value starting with http(s):// is fetched over the network; an + * optional `GITHUB_TOKEN` is sent as a Bearer token (needed for the private + * trycua/cloud repo via the raw contents API). + * + * Usage: + * npx tsx scripts/docs-generators/sync-cyclops-cs-spec.ts + * npx tsx scripts/docs-generators/sync-cyclops-cs-spec.ts --from ../cloud/cyclops-cs/backend/docs/swagger.json + * npx tsx scripts/docs-generators/sync-cyclops-cs-spec.ts --from https://api.github.com/repos/trycua/cloud/contents/cyclops-cs/backend/docs/swagger.json?ref=main + * npx tsx scripts/docs-generators/sync-cyclops-cs-spec.ts --check # fail if the vendored copy is stale + */ + +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; + +const ROOT_DIR = path.resolve(__dirname, '../..'); +const VENDORED_SPEC = path.join(__dirname, 'specs', 'cyclops-cs.swagger.json'); +const GENERATOR = path.join(__dirname, 'cyclops-cs.ts'); +const DEFAULT_LOCAL = path.resolve(ROOT_DIR, '../cloud/cyclops-cs/backend/docs/swagger.json'); + +function resolveSource(args: string[]): string { + const i = args.indexOf('--from'); + if (i !== -1) { + const value = args[i + 1]; + if (!value || value.startsWith('--')) { + throw new Error('`--from` requires a path or URL value.'); + } + return value; + } + if (process.env.CYCLOPS_CS_SPEC_SOURCE) return process.env.CYCLOPS_CS_SPEC_SOURCE; + return DEFAULT_LOCAL; +} + +async function readSource(source: string): Promise { + if (/^https?:\/\//.test(source)) { + const headers: Record = { Accept: 'application/vnd.github.raw+json' }; + if (process.env.GITHUB_TOKEN) headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`; + // Abort a stalled connection so a CI sync job can't hang indefinitely. + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 30_000); + let res: Response; + try { + res = await fetch(source, { headers, signal: controller.signal }); + } finally { + clearTimeout(timer); + } + if (!res.ok) { + throw new Error(`Failed to fetch spec from ${source}: ${res.status} ${res.statusText}`); + } + return await res.text(); + } + const abs = path.isAbsolute(source) ? source : path.resolve(process.cwd(), source); + if (!fs.existsSync(abs)) { + throw new Error(`Spec source not found: ${abs}`); + } + return fs.readFileSync(abs, 'utf-8'); +} + +/** Stable, formatted JSON so byte-for-byte diffs are meaningful. */ +function normalize(raw: string): string { + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object' || !('paths' in parsed)) { + throw new Error('Source does not look like an OpenAPI/Swagger document (no `paths`).'); + } + return JSON.stringify(parsed, null, 2) + '\n'; +} + +async function main(): Promise { + const args = process.argv.slice(2); + const checkOnly = args.includes('--check'); + const source = resolveSource(args); + + console.log('Cyclops CS spec sync'); + console.log('====================\n'); + console.log(`Source: ${source}`); + + const incoming = normalize(await readSource(source)); + const current = fs.existsSync(VENDORED_SPEC) ? fs.readFileSync(VENDORED_SPEC, 'utf-8') : ''; + const changed = incoming !== current; + + if (checkOnly) { + if (changed) { + console.error('\nVendored spec is stale relative to the source.'); + console.error('Run: npx tsx scripts/docs-generators/sync-cyclops-cs-spec.ts'); + process.exit(1); + } + console.log('\nVendored spec is up to date.'); + return; + } + + if (!changed) { + console.log('\nVendored spec already current — nothing to do.'); + return; + } + + fs.mkdirSync(path.dirname(VENDORED_SPEC), { recursive: true }); + fs.writeFileSync(VENDORED_SPEC, incoming); + console.log(`Updated ${path.relative(ROOT_DIR, VENDORED_SPEC)}`); + + console.log('\nRegenerating reference docs...'); + const result = spawnSync('npx', ['tsx', GENERATOR], { cwd: ROOT_DIR, stdio: 'inherit' }); + if (result.status !== 0) process.exit(result.status ?? 1); +} + +main().catch((error) => { + console.error('Error:', error instanceof Error ? error.message : error); + process.exit(1); +});