From 71b48e4f6ca9cf7a1f79ba63f15f3fc829de16c3 Mon Sep 17 00:00:00 2001 From: Pavel Tiunov Date: Mon, 17 Aug 2026 10:08:06 -0700 Subject: [PATCH] feat(cube-cli): add `cube spec`, and regenerate the Platform API reference (#11571) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: regenerate the Platform API reference from the public OpenAPI spec `api-reference/api.yaml` and the `docs.json` nav are generated from cubejs-enterprise's `open-api-spec-public-v3.1.yaml` by `scripts/extract-api.mjs`. Re-run it so the reference covers the deployment settings endpoints added in cubedevinc/cubejs-enterprise#claude/deployment-settings-api-soozam: GET /api/v1/deployments/{deploymentId}/settings PUT /api/v1/deployments/{deploymentId}/settings The regeneration also picks up spec changes that landed upstream without a docs sync — the staging-environment branch route, the three dbt-sync status/result routes, and the removal of the workbook ai-widget-thread route — because the extractor rewrites both files wholesale from the spec. Nothing here is hand-edited. `api-reference/introduction.mdx` is unchanged: its table is per-tag and the new routes fall under the existing Deployments tag. * docs: re-extract after the upstream summary/shaderConfig fixes, redirect the dropped page - Re-ran `scripts/extract-api.mjs` against the updated public spec. The two `/settings` operations now carry short summaries ("Get deployment settings" / "Update deployment settings") with the prose moved to `description`, so Mintlify stops deriving a sentence-long page title and a ~150-char slug from them. `shaderConfig` and its model enums are gone from the public spec, so they no longer appear in the reference either. - Added a redirect for `/api-reference/workbooks/update-published-dashboard-ai-widget-thread`, which this sync removes from the nav — it was a published page, so without the redirect the URL would start 404ing. The extractor rewrites only the `openapi`-backed nav groups, so a hand-added redirect survives regeneration. `extract-api.mjs --check` now reports the reference up to date. * feat(cube-cli): add `cube spec` for discovering the API at runtime Fetches `/api/v1/spec` — the OpenAPI document the API now serves about itself — so neither a person nor an agent has to guess an endpoint's parameters, and the contract comes from the build being talked to rather than a pinned copy. cube spec # index every operation cube spec settings # filter by method/path/summary/operationId cube spec "deployments/{deploymentId}/settings" --json Bare and filtered runs print a METHOD/PATH/SUMMARY table, which is what a human scanning for an endpoint wants. `--json` prints OpenAPI instead: unfiltered, the whole document, so it can be piped into a generator or a validator; filtered, a still-valid document holding only the matching operations plus the transitive closure of the schemas they reference. That closure is the point of the filter rather than a size optimization. An agent asking "what does this endpoint take?" needs the request body's schema resolved — a bare `$ref` would force it to pull the whole document anyway. On the current API that turns a 238KB answer into a 9KB one that still carries all 19 properties of `UpdateDeploymentInput`, its nested `CspsConfig`, and every enum they reach. The closure runs to a fixpoint over a visited set because the schemas are mutually recursive in places; a naive walk would not terminate. Operations are emitted in verb order rather than the server's key order, and non-operation keys on a path item (`parameters`, `summary`, extensions) are skipped. A pattern that matches nothing exits non-zero with a message rather than printing an empty table, since it almost always means a typo. Tested: unit tests cover operation extraction (verb ordering, non-operation keys ignored), matching across all four fields, and the filtered document (only reachable schemas, self-referential schema terminates, `openapi`/`info`/ `securitySchemes` preserved). Also exercised end to end against the real 106-path document served by a local stub. Docs: `reference/cli.mdx` gains a "Discovering the API" section, and `reference/control-plane-api.mdx` documents the `/api/v1/spec` endpoint. * fix(cube-cli): keep path-level parameters and refuse dangling refs in `cube spec` Two ways the filtered document could be silently wrong. Neither fires against today's spec — it has no path-level `parameters` and no non-schema component refs, both verified — but the output is meant to be consumed unattended by agents and generators, so a quiet truncation is the worst failure mode available. - **Path-level `parameters` were dropped.** OpenAPI lets a path item declare parameters that apply to every operation under it, and only the method keys were copied. For a command whose whole pitch is "an endpoint's full parameter list", losing parameters is the one thing it must not do. They now come along, and their refs are seeded into the schema closure. The test fixture already had this shape and asserted nothing about it; it does now. - **Only `#/components/schemas/` refs were followed.** An operation referencing `#/components/parameters/*`, `responses/*` or `requestBodies/*` would produce a document that still carries the `$ref` but not its target — a validator rejects it, an agent resolves it to nothing. The document is now walked for any `#/components/` ref whose target is missing and the command bails, the same way `extract-api.mjs` hard-fails on dangling refs upstream. Also shortens the README row to match the terse verb lists around it; the explanation already lives in `cli.mdx`'s "Discovering the API" section. Tested: 12 unit tests pass (2 new — path-level parameters survive filtering, dangling refs rejected while a resolvable non-schema ref is accepted), fmt and clippy clean, and re-verified end to end against the real 106-path document. * feat(cube-cli): deployment settings, available versions, and version switching `cube deployments versions ` lists the Cube versions a deployment can switch to; `cube deployments settings ` reads the whole settings payload; `cube deployments update` gains `--release-channel` / `--release-channel-version` so applying a listed version doesn't need a hand-written `--data` body. The version is validated server-side against the list and the container image is resolved from it, so the CLI has nothing to pin and no way to get the two out of step. Docs: the new endpoint on the Control Plane API page, a version-switching section in the CLI reference, and the regenerated API reference — which also picks up the `/api/v1/spec` endpoint's nav group, retitled from the auto-cleaned "Open Api Spec". * fix(cube-cli): send version changes to the settings endpoint `PUT /api/v1/deployments/{id}` responds with the four-field deployment summary, which carries no version — so `cube deployments update --release-channel-version` printed a body in which the thing that changed was invisible. Route those writes to `/settings`, whose response is the full settings payload. Also trim the README's deployments row back to the terse verb list its neighbours use; the version-format detail already lives in the CLI reference. * refactor(cube-cli): send every deployment update to PUT /api/v1/deployments/:id The separate settings write endpoint is gone — `PUT /:id` now takes the full settings body and responds with the full settings payload — so `update` no longer needs to pick a route based on which flags were passed. Docs follow the same consolidation. * docs(cli): use an operationId in the cube spec filtering example `deployments/{deploymentId}` is a substring of 56 paths in the spec, so as a `cube spec` pattern it emits most of the Deployments surface — the opposite of the narrowing the paragraph below it describes. `updateDeployment` matches the one operation (the matcher concatenates method, path, summary and operationId and does a `contains`). * fix(api-gateway): require an isDevToken claim for the playground bypass `signedWithPlaygroundAuthSecret` unlocks developer affordances — meta members hidden by `public: false` / access policies, generated SQL, pre-aggregation and refresh-key debug info on load responses, request ids in errors. It was set purely from the token having verified against `playgroundAuthSecret`. In Cube Cloud that secret signs every token a deployment mints, including ones handed to end users and to external BI tools, so the signature alone was never evidence that the bearer is a developer. Require an explicit `isDevToken` claim as well; the control plane adds it only for the developer-facing console surfaces and only for users with deployment-manage permissions. devMode is unaffected: every affordance already ORs the flag with `getEnv('devMode')`, so a local `cube dev` server keeps full visibility. The one place reading the flag alone is the `isPlayground` field on Load Request logs, which now marks only developer traffic. Authentication is also unchanged — the claim gates what a playground-signed token may see, not whether it verifies, so `/cubejs-system/v1/*` still accepts any playground-signed token. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018rhtueaPpiVVNkvA5rWv8X * refactor(api-gateway): key the playground bypass on a dev-token scope Reads the grant from `scope` — the array the runtime already uses for per-token grants — instead of a bespoke top-level `isDevToken` claim, so a dev token is described the same way as every other capability a token carries. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018rhtueaPpiVVNkvA5rWv8X * docs: drop the /api/v1/spec section from the control plane API reference The endpoint stays; only its reference section goes. `cube spec` on the CLI page still documents the discovery workflow, and nothing linked to the removed anchor. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018rhtueaPpiVVNkvA5rWv8X --------- Co-authored-by: Claude --- docs-mintlify/api-reference/api.yaml | 706 ++++++++++++++++-- docs-mintlify/api-reference/introduction.mdx | 1 + docs-mintlify/docs.json | 22 +- docs-mintlify/reference/cli.mdx | 79 +- docs-mintlify/reference/control-plane-api.mdx | 56 +- docs-mintlify/scripts/extract-api.mjs | 3 + packages/cubejs-api-gateway/src/gateway.ts | 15 +- packages/cubejs-api-gateway/test/auth.test.ts | 63 ++ rust/cube-cli/README.md | 3 +- rust/cube-cli/src/commands/deployments.rs | 48 ++ rust/cube-cli/src/commands/mod.rs | 1 + rust/cube-cli/src/commands/spec.rs | 440 +++++++++++ rust/cube-cli/src/main.rs | 4 + 13 files changed, 1384 insertions(+), 57 deletions(-) create mode 100644 rust/cube-cli/src/commands/spec.rs diff --git a/docs-mintlify/api-reference/api.yaml b/docs-mintlify/api-reference/api.yaml index 26d800ede091d..f7b62151099cc 100644 --- a/docs-mintlify/api-reference/api.yaml +++ b/docs-mintlify/api-reference/api.yaml @@ -44,6 +44,7 @@ tags: - name: Embed - name: Embed Tenants - name: Dashboard Embed Access + - name: OpenAPI Spec paths: /api/v1/app-config: get: @@ -184,11 +185,22 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/Deployment' + $ref: '#/components/schemas/DeploymentSettings' description: '' - summary: Update deployment + summary: Update a deployment tags: - Deployments + x-mint: + content: >- + Update any subset of a deployment’s settings — everything the console’s Settings pages own + — and read the full settings back. Fields you omit are left alone, and `templateVariables` + is merged key-by-key rather than replaced, so setting one configuration flag never clears + the others. `releaseChannelVersion` must be one of the versions `GET + /:deploymentId/versions` lists for the target channel (in any of the forms it reports) — + anything else is a 400, and the container image is resolved from the version rather than + sent. Choosing a version that is not the channel’s latest also sets + `releaseChannelVersionHold` unless you send it explicitly, so auto-upgrade does not undo + the pin. /api/v1/deployments/{deploymentId}/build-status: get: operationId: buildStatus @@ -285,6 +297,77 @@ paths: summary: Start a dbt sync for a deployment tags: - dbt Sync + /api/v1/deployments/{deploymentId}/dbt-sync/{syncJobId}: + delete: + operationId: cancelDbtSync + parameters: + - in: path + name: deploymentId + required: true + schema: + type: integer + - in: path + name: syncJobId + required: true + schema: + type: string + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/DbtSyncCancelResponse' + description: '' + summary: Cancel a running dbt sync + tags: + - dbt Sync + get: + operationId: getDbtSyncStatus + parameters: + - in: path + name: deploymentId + required: true + schema: + type: integer + - in: path + name: syncJobId + required: true + schema: + type: string + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/DbtSyncStatusResponse' + description: '' + summary: Get the status of a dbt sync + tags: + - dbt Sync + /api/v1/deployments/{deploymentId}/dbt-sync/{syncJobId}/result: + get: + operationId: getDbtSyncResult + parameters: + - in: path + name: deploymentId + required: true + schema: + type: integer + - in: path + name: syncJobId + required: true + schema: + type: string + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/DbtSyncResultResponse' + description: '' + summary: Get the result of a completed dbt sync + tags: + - dbt Sync /api/v1/deployments/{deploymentId}/env-vars: get: operationId: getEnvVariables @@ -337,7 +420,9 @@ paths: content: >- Upserts deployment environment variables by name; variables not included keep their existing values. Passing the `[ENCRYPTED]` placeholder (the masked read value) is rejected - — omit variables you do not intend to change. + — omit variables you do not intend to change. New variable names must be POSIX identifiers + (`^[A-Za-z_][A-Za-z0-9_]*$`); names already stored on the deployment may be resent + unchanged so a legacy name can still be updated or removed. /api/v1/deployments/{deploymentId}/environments: get: operationId: getDeploymentEnvironments @@ -1575,6 +1660,32 @@ paths: summary: Refresh report tags: - Reports + /api/v1/deployments/{deploymentId}/settings: + get: + operationId: getDeploymentSettings + parameters: + - in: path + name: deploymentId + required: true + schema: + type: integer + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/DeploymentSettings' + description: '' + summary: Get deployment settings + tags: + - Deployments + x-mint: + content: >- + Every setting of a deployment in one payload — the full contents of the console Settings + pages (general, configuration flags, deploy, launchpad, Cube Store private storage), plus + the read-only values displayed alongside them. Secrets are excluded; read environment + variables through `/env-vars` instead. Writes go to `PUT /:deploymentId`, which accepts + the same fields and returns this payload. /api/v1/deployments/{deploymentId}/shared-workspace: get: operationId: sharedWorkspaceObjects @@ -1673,6 +1784,32 @@ paths: summary: Deployment token tags: - Deployments + /api/v1/deployments/{deploymentId}/versions: + get: + operationId: listDeploymentVersions + parameters: + - in: path + name: deploymentId + required: true + schema: + type: integer + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/DeploymentVersionsResponse' + description: '' + summary: List available Cube versions + tags: + - Deployments + x-mint: + content: >- + Every Cube version this deployment can be switched to — the same set the console’s version + picker offers: the head of each release channel, plus the older versions the tenant has + run before (its release history). Pass a listed `releaseChannelVersion` to `PUT + /:deploymentId`; anything else is rejected. The container image is resolved server-side + and is not part of the write surface. /api/v1/deployments/{deploymentId}/workbooks: get: operationId: getWorkbooks @@ -1920,37 +2057,6 @@ paths: summary: Update workbook dashboard tags: - Workbooks - /api/v1/deployments/{deploymentId}/workbooks/{workbookId}/dashboard/ai-widget-thread: - post: - operationId: updatePublishedDashboardAiWidgetThread - parameters: - - in: path - name: deploymentId - required: true - schema: - type: number - - in: path - name: workbookId - required: true - schema: - type: number - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/UpdatePublishedAiWidgetThreadInput' - description: UpdatePublishedAiWidgetThreadInput - required: false - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/Dashboard' - description: '' - summary: Update published dashboard AI widget thread - tags: - - Workbooks /api/v1/deployments/{deploymentId}/workbooks/{workbookId}/duplicate: post: operationId: duplicateWorkbook @@ -3329,6 +3435,25 @@ paths: summary: List regions tags: - Regions + /api/v1/spec: + get: + operationId: getSpec + responses: + '200': + content: + application/json: + schema: + additionalProperties: true + type: object + description: The OpenAPI 3.1 document + summary: Get the OpenAPI specification + tags: + - OpenAPI Spec + x-mint: + content: >- + The full OpenAPI 3.1 document for this API, as served by the build handling the request — + every path, parameter, request body and schema. Intended for runtime discovery by clients + and agents; `cube spec` wraps it with filtering. /api/v1/tenant/settings: get: operationId: getTenantSettings @@ -3747,6 +3872,39 @@ paths: summary: Create a branch (optionally entering dev mode on it) tags: - Data Model + /build/api/v1/deployments/{deploymentId}/branches/staging-environment: + put: + operationId: setBranchStagingEnvironment + parameters: + - in: path + name: deploymentId + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/SetBranchStagingEnvironmentRequest' + description: SetBranchStagingEnvironmentRequest + required: false + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/SetBranchStagingEnvironmentResponse' + description: '' + summary: Enable or disable a branch's staging environment + tags: + - Data Model + x-mint: + content: >- + Enabling a branch keeps its staging environment always active and accessible at + `/dev-mode/{branchName}/cubejs-api/v1`; disabled (the default) it is only + active while the branch is viewed in Cube Cloud. Enabled branches are the ones listed by + `GET /api/v1/deployments/{deploymentId}/environments?type=staging`. Only shared branches + qualify — personal dev branches and the deploy branch are rejected. /build/api/v1/deployments/{deploymentId}/commit: post: operationId: commitChanges @@ -4243,6 +4401,10 @@ components: oneOf: - $ref: '#/components/schemas/SheetsUiSettings' - type: 'null' + timezoneSettings: + oneOf: + - $ref: '#/components/schemas/TimezoneSettings' + - type: 'null' required: - applyThemeGlobally type: object @@ -4308,6 +4470,10 @@ components: properties: id: type: integer + isStagingEnvironmentEnabled: + oneOf: + - type: boolean + - type: 'null' lastHash: oneOf: - type: string @@ -5015,6 +5181,60 @@ components: - type: string - type: 'null' type: object + CspsConfig: + properties: + awsRoleArn: + oneOf: + - type: string + - type: 'null' + azureClientId: + oneOf: + - type: string + - type: 'null' + azureContainer: + oneOf: + - type: string + - type: 'null' + azureStorageAccount: + oneOf: + - type: string + - type: 'null' + azureTenantId: + oneOf: + - type: string + - type: 'null' + enabled: + type: boolean + gcpServiceAccountEmail: + oneOf: + - type: string + - type: 'null' + gcpWorkloadIdentityProvider: + oneOf: + - type: string + - type: 'null' + gcsBucket: + oneOf: + - type: string + - type: 'null' + s3Bucket: + oneOf: + - type: string + - type: 'null' + s3Region: + oneOf: + - type: string + - type: 'null' + s3Sse: + oneOf: + - type: string + - type: 'null' + storageProvider: + type: string + required: + - enabled + - storageProvider + type: object CspsConfigInput: properties: awsRoleArn: @@ -5707,8 +5927,10 @@ components: - TEXT - FILTER - AI - - TABS_CONTAINER - TIME_GRAIN + - SPACER + - DIVIDER + - CONTAINER type: string DashboardWidgetInput: properties: @@ -5718,6 +5940,7 @@ components: additionalProperties: true - type: 'null' id: + maxLength: 128 type: string position: $ref: '#/components/schemas/DashboardWidgetPositionInput' @@ -5743,8 +5966,10 @@ components: - TEXT - FILTER - AI - - TABS_CONTAINER - TIME_GRAIN + - SPACER + - DIVIDER + - CONTAINER type: string DashboardWidgetPosition: properties: @@ -5812,6 +6037,53 @@ components: required: - success type: object + DbtSyncCancelResponse: + properties: + message: + type: string + required: + - message + type: object + DbtSyncGeneratedFile: + properties: + path: + description: Path of the generated file within the data model project. + type: string + required: + - path + type: object + DbtSyncManifestStats: + properties: + macros: + type: integer + models: + type: integer + sources: + type: integer + required: + - models + - sources + - macros + type: object + DbtSyncProgress: + properties: + message: + oneOf: + - type: string + description: Human-readable description of what the sync is doing right now. + - type: 'null' + percentComplete: + description: >- + Rough completion percentage derived from the stage. Progress reporting only — do not + gate on it; gate on the status reaching COMPLETED or FAILED. + type: integer + stage: + description: The stage the sync is currently in, from the same set of values as the sync status. + type: string + required: + - stage + - percentComplete + type: object DbtSyncResponse: properties: branchName: @@ -5825,6 +6097,61 @@ components: - workflowId - branchName type: object + DbtSyncResultResponse: + properties: + completedAt: + description: When the sync finished, as an ISO 8601 timestamp. + type: string + cubeCount: + description: How many cubes the sync generated from the dbt manifest. + type: integer + durationMs: + description: End-to-end sync duration in milliseconds. + type: integer + generatedFiles: + description: The cube files this sync generated and committed to the branch it created. + items: + $ref: '#/components/schemas/DbtSyncGeneratedFile' + type: array + manifestStats: + $ref: '#/components/schemas/DbtSyncManifestStats' + syncJobId: + type: string + required: + - syncJobId + - generatedFiles + - manifestStats + - cubeCount + - completedAt + - durationMs + type: object + DbtSyncStatusResponse: + properties: + error: + oneOf: + - type: string + description: Why the sync stopped. Present only when the status is FAILED. + - type: 'null' + progress: + $ref: '#/components/schemas/DbtSyncProgress' + status: + description: >- + One of INITIALIZING, CREATING_SANDBOX, UPLOADING_PROJECT, INSTALLING_DEPENDENCIES, + RUNNING_DBT_DEPS, COMPILING_DBT, PROCESSING_MANIFEST, WRITING_CUBE_FILES, FINALIZING, + COMPLETED, FAILED. COMPLETED and FAILED are the only terminal values: poll until one of + them, then read the result. Treat any unrecognized value as still running. + type: string + syncJobId: + type: string + updatedAt: + description: When this status was last updated, as an ISO 8601 timestamp. + type: string + required: + - syncJobId + - status + - progress + - updatedAt + type: object Deployment: properties: creationStep: @@ -6031,6 +6358,156 @@ components: required: - items type: object + DeploymentSettings: + properties: + autoDomain: + oneOf: + - type: string + - type: 'null' + cloudProvider: + $ref: '#/components/schemas/DeploymentSettingsCloudProvider' + creationMethod: + oneOf: + - $ref: '#/components/schemas/DeploymentSettingsCreationMethod' + - type: 'null' + creationStep: + oneOf: + - $ref: '#/components/schemas/CreationStep' + - type: 'null' + cspsConfig: + oneOf: + - $ref: '#/components/schemas/CspsConfig' + - type: 'null' + cubeImageCloud: + oneOf: + - type: string + - type: 'null' + cubeImageVersion: + oneOf: + - type: string + - type: 'null' + customDomain: + oneOf: + - type: string + - type: 'null' + defaultLaunchpadViewGroup: + oneOf: + - type: string + - type: 'null' + deployBranchMergeAllowed: + type: boolean + deployBranchName: + type: string + deployBranchReadOnly: + type: boolean + deployMode: + oneOf: + - $ref: '#/components/schemas/DeploymentSettingsDeployMode' + - type: 'null' + deployProjectRoot: + type: string + deploymentUrl: + oneOf: + - type: string + - type: 'null' + id: + type: integer + isCold: + oneOf: + - type: boolean + - type: 'null' + isCubeSqlEnabled: + type: boolean + isManaged: + oneOf: + - type: boolean + - type: 'null' + isSupportPreAggregations: + type: boolean + launchpadTabs: + oneOf: + - items: + type: string + type: array + - type: 'null' + name: + type: string + region: + oneOf: + - type: string + - type: 'null' + releaseChannel: + $ref: '#/components/schemas/DeploymentSettingsReleaseChannel' + releaseChannelVersion: + oneOf: + - type: string + - type: 'null' + releaseChannelVersionHold: + oneOf: + - type: boolean + - type: 'null' + repoType: + oneOf: + - $ref: '#/components/schemas/DeploymentSettingsRepoType' + - type: 'null' + targetPlatform: + oneOf: + - type: string + - type: 'null' + template: + $ref: '#/components/schemas/DeploymentSettingsTemplate' + templateVariables: + oneOf: + - type: object + additionalProperties: true + - type: 'null' + required: + - id + - name + - template + - cloudProvider + - releaseChannel + - deployBranchName + - deployBranchReadOnly + - deployBranchMergeAllowed + - deployProjectRoot + - isCubeSqlEnabled + - isSupportPreAggregations + type: object + DeploymentSettingsCloudProvider: + enum: + - cubecloud + - aws + - gcp + type: string + DeploymentSettingsCreationMethod: + enum: + - upload + - cubecloud + - github + - ssh + type: string + DeploymentSettingsDeployMode: + enum: + - git + - cli + type: string + DeploymentSettingsReleaseChannel: + enum: + - latest + - release + type: string + DeploymentSettingsRepoType: + enum: + - git + - deployRepo + type: string + DeploymentSettingsTemplate: + enum: + - single + - simpleCluster + - multiCluster + type: string DeploymentTokenResponse: properties: cubeApiToken: @@ -6038,6 +6515,39 @@ components: required: - cubeApiToken type: object + DeploymentVersion: + properties: + isCurrent: + type: boolean + isLatestInChannel: + type: boolean + releaseChannel: + $ref: '#/components/schemas/DeploymentVersionReleaseChannel' + releaseChannelVersion: + type: string + version: + type: string + required: + - releaseChannelVersion + - version + - releaseChannel + - isLatestInChannel + - isCurrent + type: object + DeploymentVersionReleaseChannel: + enum: + - latest + - release + type: string + DeploymentVersionsResponse: + properties: + data: + items: + $ref: '#/components/schemas/DeploymentVersion' + type: array + required: + - data + type: object DeploymentsListResponse: properties: count: @@ -6238,6 +6748,10 @@ components: oneOf: - type: boolean - type: 'null' + timezone: + oneOf: + - type: string + - type: 'null' type: object EmbedTenant: properties: @@ -7661,6 +8175,10 @@ components: - type: 'null' id: type: integer + isSourceReport: + oneOf: + - type: boolean + - type: 'null' kind: oneOf: - $ref: '#/components/schemas/ReportSnapshotDtoKind' @@ -7839,6 +8357,28 @@ components: - name - actions type: object + SetBranchStagingEnvironmentRequest: + properties: + branchId: + oneOf: + - type: integer + - type: 'null' + branchName: + oneOf: + - type: string + - type: 'null' + enabled: + type: boolean + required: + - enabled + type: object + SetBranchStagingEnvironmentResponse: + properties: + data: + $ref: '#/components/schemas/BranchResponse' + required: + - data + type: object SetEnvVariablesInput: properties: env_variables: @@ -7871,6 +8411,15 @@ components: oneOf: - type: string - type: 'null' + ref: + oneOf: + - type: string + description: >- + Git ref in the dbt repository to sync from — a branch or a tag, NOT a commit SHA. + Overrides the branch saved on the deployment’s dbt git integration for this sync + only, so CI can sync the ref under review (e.g. a pull request’s head branch) + instead of the tracked branch. The generated Cube branch is named after it. + - type: 'null' type: object StartDevModeRequest: properties: @@ -7972,6 +8521,21 @@ components: - url - format type: object + TimezoneSettings: + properties: + allowUserOverride: + oneOf: + - type: boolean + - type: 'null' + enabled: + oneOf: + - type: boolean + - type: 'null' + timezone: + oneOf: + - type: string + - type: 'null' + type: object UpdateDashboardEmbeddingInput: properties: allowEmbed: @@ -7999,6 +8563,10 @@ components: type: object UpdateDeploymentInput: properties: + cloudProvider: + oneOf: + - $ref: '#/components/schemas/UpdateDeploymentInputCloudProvider' + - type: 'null' creationMethod: oneOf: - $ref: '#/components/schemas/UpdateDeploymentInputCreationMethod' @@ -8011,6 +8579,10 @@ components: oneOf: - type: string - type: 'null' + defaultLaunchpadViewGroup: + oneOf: + - type: string + - type: 'null' deployBranchMergeAllowed: oneOf: - type: boolean @@ -8031,11 +8603,48 @@ components: oneOf: - type: string - type: 'null' + launchpadTabs: + oneOf: + - items: + type: string + type: array + - type: 'null' name: oneOf: - type: string - type: 'null' + region: + oneOf: + - type: string + - type: 'null' + releaseChannel: + oneOf: + - $ref: '#/components/schemas/UpdateDeploymentInputReleaseChannel' + - type: 'null' + releaseChannelVersion: + oneOf: + - type: string + - type: 'null' + releaseChannelVersionHold: + oneOf: + - type: boolean + - type: 'null' + template: + oneOf: + - $ref: '#/components/schemas/UpdateDeploymentInputTemplate' + - type: 'null' + templateVariables: + oneOf: + - type: object + additionalProperties: true + - type: 'null' type: object + UpdateDeploymentInputCloudProvider: + enum: + - cubecloud + - aws + - gcp + type: string UpdateDeploymentInputCreationMethod: enum: - upload @@ -8048,6 +8657,17 @@ components: - git - cli type: string + UpdateDeploymentInputReleaseChannel: + enum: + - latest + - release + type: string + UpdateDeploymentInputTemplate: + enum: + - single + - simpleCluster + - multiCluster + type: string UpdateEmbedAccessInput: properties: action: @@ -8236,20 +8856,6 @@ components: maxLength: 128 - type: 'null' type: object - UpdatePublishedAiWidgetThreadInput: - properties: - checksum: - oneOf: - - type: string - - type: 'null' - threadId: - type: string - widgetId: - type: string - required: - - widgetId - - threadId - type: object UpdateReportInput: properties: endResultCell: diff --git a/docs-mintlify/api-reference/introduction.mdx b/docs-mintlify/api-reference/introduction.mdx index f30073ea71ba9..3c416dff09162 100644 --- a/docs-mintlify/api-reference/introduction.mdx +++ b/docs-mintlify/api-reference/introduction.mdx @@ -95,6 +95,7 @@ Resources by entity: | [Embed](/api-reference/embed/get-an-embeddable-dashboard) | `/api/v1/embed` | v1 | | [Embed Tenants](/api-reference/embed-tenants/list-embed-tenants) | `/api/v1/embed-tenants` | v1 | | [Dashboard Embed Access](/api-reference/dashboard-embed-access/list-a-dashboards-embed-access) | `/api/v1/deployments/{deploymentId}/workbooks/{workbookId}/embed-access` | v1 | +| [OpenAPI Spec](/api-reference/openapi-spec/get-the-openapi-specification) | `/api/v1/spec` | v1 | {/* AUTOGEN:platform-endpoints END */} | [Users (SCIM)](/api-reference/scim-users/list-users) | `/scim/v2/Users` | SCIM 2.0 | | [Groups (SCIM)](/api-reference/scim-groups/list-groups) | `/scim/v2/Groups` | SCIM 2.0 | diff --git a/docs-mintlify/docs.json b/docs-mintlify/docs.json index 0195b5120c9d9..a1598b5ece19a 100644 --- a/docs-mintlify/docs.json +++ b/docs-mintlify/docs.json @@ -762,7 +762,9 @@ "POST /api/v1/deployments/{deploymentId}/creation-step/reset", "GET /api/v1/deployments/{deploymentId}/logs", "GET /api/v1/deployments/{deploymentId}/pods", - "POST /api/v1/deployments/{deploymentId}/token" + "GET /api/v1/deployments/{deploymentId}/settings", + "POST /api/v1/deployments/{deploymentId}/token", + "GET /api/v1/deployments/{deploymentId}/versions" ] }, { @@ -803,6 +805,7 @@ "pages": [ "GET /build/api/v1/deployments/{deploymentId}/branches", "POST /build/api/v1/deployments/{deploymentId}/branches", + "PUT /build/api/v1/deployments/{deploymentId}/branches/staging-environment", "POST /build/api/v1/deployments/{deploymentId}/commit", "GET /build/api/v1/deployments/{deploymentId}/data-model/files", "PUT /build/api/v1/deployments/{deploymentId}/data-model/files", @@ -846,7 +849,10 @@ "group": "dbt Sync", "openapi": "/api-reference/api.yaml", "pages": [ - "POST /api/v1/deployments/{deploymentId}/dbt-sync" + "POST /api/v1/deployments/{deploymentId}/dbt-sync", + "GET /api/v1/deployments/{deploymentId}/dbt-sync/{syncJobId}", + "DELETE /api/v1/deployments/{deploymentId}/dbt-sync/{syncJobId}", + "GET /api/v1/deployments/{deploymentId}/dbt-sync/{syncJobId}/result" ] }, { @@ -885,7 +891,6 @@ "PUT /api/v1/deployments/{deploymentId}/workbooks/{workbookId}", "DELETE /api/v1/deployments/{deploymentId}/workbooks/{workbookId}", "PUT /api/v1/deployments/{deploymentId}/workbooks/{workbookId}/dashboard", - "POST /api/v1/deployments/{deploymentId}/workbooks/{workbookId}/dashboard/ai-widget-thread", "POST /api/v1/deployments/{deploymentId}/workbooks/{workbookId}/duplicate", "POST /api/v1/deployments/{deploymentId}/workbooks/{workbookId}/publish" ] @@ -1025,6 +1030,13 @@ "PUT /api/v1/deployments/{deploymentId}/workbooks/{workbookId}/embed-access" ] }, + { + "group": "OpenAPI Spec", + "openapi": "/api-reference/api.yaml", + "pages": [ + "GET /api/v1/spec" + ] + }, { "group": "Users", "openapi": "/api-reference/scim.yaml", @@ -1238,6 +1250,10 @@ { "source": "/docs/integrations/power-bi/ntlm", "destination": "/reference/core-data-apis/dax-api/ntlm" + }, + { + "source": "/api-reference/workbooks/update-published-dashboard-ai-widget-thread", + "destination": "/api-reference/workbooks/update-workbook-dashboard" } ] } diff --git a/docs-mintlify/reference/cli.mdx b/docs-mintlify/reference/cli.mdx index 047ab4f288817..8f991fcae6872 100644 --- a/docs-mintlify/reference/cli.mdx +++ b/docs-mintlify/reference/cli.mdx @@ -146,7 +146,7 @@ Run `cube --help` for the full options of any command. | Command | Description | | --- | --- | | `login`, `logout`, `whoami`, `context` | Authentication and saved contexts | -| `deployments` | List, get, create, update, delete deployments; `token`, `build-status`, `advance-step`, `reset-step` | +| `deployments` | List, get, create, update, delete deployments; `settings`, `versions`, `token`, `build-status`, `advance-step`, `reset-step` | | `deploy` | Upload a local project directory and build it | | `logs` | Tail deployment pod logs (`--pod`, `-c/--container`, `--source production\|dev`) | | `regions` | List available deployment regions | @@ -159,6 +159,7 @@ Run `cube --help` for the full options of any command. | `tenant`, `notifications`, `integrations`, `oidc`, `api-keys` | Account administration | | `embed` | Embed sessions, tokens, embed tenants; `enable-dashboard`/`disable-dashboard` toggle signed embedding for a dashboard | | `agents`, `app`, `meta`, `scim` | Agents, app config, model metadata, SCIM v2 | +| `spec` | Show the API's OpenAPI specification — see [Discovering the API](#discovering-the-api) | | `api` | Raw authenticated API request (escape hatch): `cube api GET /api/v1/... -q key=value -d '{...}'` | | `update` | Update the CLI to the latest release | | `completion` | Generate shell completions | @@ -166,6 +167,81 @@ Run `cube --help` for the full options of any command. List commands print tables by default; pass `--json` anywhere for raw JSON output, suitable for piping to `jq`. +## Changing the Cube version + +`cube deployments versions` lists the Cube versions a deployment can switch to +— the head of each [update channel][ref-update-channels], plus the older +versions your account has run before: + +```bash +cube deployments versions DEPLOYMENT_ID +``` + +``` +VERSION CHANNEL LATEST CURRENT PASS AS +1.7.20 latest true true cubejs/cube:v1.7.20 +1.6.69 latest false false cubejs/cube:v1.6.69 +``` + +Apply one with `update`. Any of `1.7.20`, `v1.7.20` or `cubejs/cube:v1.7.20` is +accepted; a version that is not on the list is rejected. The container image is +resolved from the version, so there is nothing else to set: + +```bash +cube deployments update DEPLOYMENT_ID --release-channel-version 1.7.20 +cube deployments update DEPLOYMENT_ID --release-channel release # move to a channel's latest +``` + +`cube deployments settings DEPLOYMENT_ID` reads back every setting, including +the version and channel currently in effect. + +## Discovering the API + +`cube spec` prints the OpenAPI specification of the API you are logged into, so +neither you nor an AI agent has to guess an endpoint's parameters. It reads +`/api/v1/spec` from the deployment itself, which means the contract you get is +the one that build actually serves. + +With no arguments it lists every operation: + +```bash +cube spec +``` + +``` +METHOD PATH SUMMARY +GET /api/v1/deployments Get deployments +PUT /api/v1/deployments/{deploymentId} Update a deployment +... +``` + +Pass a pattern to narrow it down. The match is case-insensitive and covers the +method, path, summary, and operation id: + +```bash +cube spec settings +``` + +Add `--json` to get OpenAPI instead of a table. Unfiltered, that is the entire +document — pipe it into a code generator or a validator. Filtered, it is a +smaller but still valid document containing just the matching operations plus +every schema they reference, transitively: + +```bash +cube spec updateDeployment --json +``` + +That last form is the one to reach for when you want an endpoint's full +parameter list: the request body's schema is included rather than left as a +`$ref` pointing into a document you would then have to fetch in full. + + + +Point an agent at `cube spec --json` and it can construct a correct +request without any hardcoded knowledge of the API. + + + ## Data model Git workflow Edit the data model through branches without touching production: @@ -224,3 +300,4 @@ or explicitly with `CUBE_NO_TELEMETRY=1` (or the legacy `CUBEJS_TELEMETRY=false` [ref-api-keys]: /admin/account-billing/api-keys [ref-rest-api]: /reference/core-data-apis/rest-api/index [ref-staging-env]: /admin/deployment/environments#staging-environments +[ref-update-channels]: /admin/deployment#update-channels diff --git a/docs-mintlify/reference/control-plane-api.mdx b/docs-mintlify/reference/control-plane-api.mdx index 66b9683849104..0122386052f57 100644 --- a/docs-mintlify/reference/control-plane-api.mdx +++ b/docs-mintlify/reference/control-plane-api.mdx @@ -273,6 +273,59 @@ Example response: The same operation is available in the [CLI][ref-cli] as `cube data-model enable-branch` / `cube data-model disable-branch`. +### `/api/v1/deployments/{deployment_id}/versions` + +Send a `GET` request to list the Cube versions a deployment can be switched +to — the same set the Cube Cloud UI's version picker offers: the head of each +[update channel][ref-update-channels], plus the older versions your account has +run before. + +Example request: + +```bash +curl \ + -H "Authorization: Bearer YOUR_API_KEY" \ + "https://YOUR_CUBE_CLOUD_HOST/api/v1/deployments/123/versions" +``` + +Example response: + +```json +{ + "data": [ + { + "releaseChannelVersion": "cubejs/cube:v1.7.20", + "version": "1.7.20", + "releaseChannel": "latest", + "isLatestInChannel": true, + "isCurrent": true + }, + { + "releaseChannelVersion": "cubejs/cube:v1.6.69", + "version": "1.6.69", + "releaseChannel": "latest", + "isLatestInChannel": false, + "isCurrent": false + } + ] +} +``` + +To change the version, send a listed value as `releaseChannelVersion` to +`PUT /api/v1/deployments/{deployment_id}`. Any of `1.7.20`, `v1.7.20` or +`cubejs/cube:v1.7.20` is accepted; a version that is not on the list is +rejected with a `400`. The container image is resolved from the version +server-side and cannot be set directly. + + +Leaving `releaseChannelVersion` out and sending only `releaseChannel` moves the +deployment to that channel's latest version. + + +The same operation is available in the [CLI][ref-cli] as +`cube deployments versions`, with `cube deployments update ID +--release-channel-version 1.7.20` to apply one. + ### `/api/v1/audit-logs/export` Send a `GET` request to export [audit log][ref-audit-log] events as a CSV @@ -327,4 +380,5 @@ curl \ [ref-cli]: /reference/cli [ref-api-keys]: /admin/account-billing/api-keys [ref-security-context]: /docs/data-modeling/access-control/context -[ref-audit-log]: /admin/monitoring/audit-log \ No newline at end of file +[ref-audit-log]: /admin/monitoring/audit-log +[ref-update-channels]: /admin/deployment#update-channels \ No newline at end of file diff --git a/docs-mintlify/scripts/extract-api.mjs b/docs-mintlify/scripts/extract-api.mjs index 77926d22af232..a685e7f0c8706 100644 --- a/docs-mintlify/scripts/extract-api.mjs +++ b/docs-mintlify/scripts/extract-api.mjs @@ -175,6 +175,8 @@ const TAG_MAP = { 'Deployments Build Public': 'Deployment Creation', 'Uploads Public': 'Data Model Uploads', 'Git Hub Deployment Public': 'GitHub Connection', + // Auto-cleaning title-cases the controller name into "Open Api Spec". + 'Open Api Spec Public': 'OpenAPI Spec', }; // Preferred nav order. Tags not listed here are appended alphabetically, so the // docs stay complete even when the upstream spec adds new areas. @@ -186,6 +188,7 @@ const TAG_ORDER = [ 'User Attributes', 'User Attribute Values', 'Resource Policies', 'Tenant Settings', 'OAuth Integrations', 'User OAuth Tokens', 'OIDC Token Configs', 'App Theme', 'AI Engineer', 'Embed', 'Embed Tenants', 'Dashboard Embed Access', + 'OpenAPI Spec', ]; // Mintlify renders the OpenAPI operation `description` as a plain-text node — it diff --git a/packages/cubejs-api-gateway/src/gateway.ts b/packages/cubejs-api-gateway/src/gateway.ts index 8a4ef7874a034..e595cc3e61c1b 100644 --- a/packages/cubejs-api-gateway/src/gateway.ts +++ b/packages/cubejs-api-gateway/src/gateway.ts @@ -130,6 +130,18 @@ function systemAsyncHandler(handler: (req: Request & { context: ExtendedRequestC }; } +const DEV_TOKEN_SCOPE = 'dev-token'; + +function hasDevTokenScope(securityContext: unknown): boolean { + if (typeof securityContext !== 'object' || securityContext === null) { + return false; + } + + const { scope } = >securityContext; + + return Array.isArray(scope) && scope.includes(DEV_TOKEN_SCOPE); +} + // Prepared CheckAuthFn, default or from config: always async type PreparedCheckAuthFn = (ctx: any, authorization?: string) => Promise<{ securityContext: any; @@ -2615,7 +2627,8 @@ class ApiGateway { if (auth) { try { req.securityContext = await checkAuthFn(auth); - req.signedWithPlaygroundAuthSecret = Boolean(internalOptions?.isPlaygroundCheckAuth); + req.signedWithPlaygroundAuthSecret = + Boolean(internalOptions?.isPlaygroundCheckAuth) && hasDevTokenScope(req.securityContext); } catch (e: any) { if (this.enforceSecurityChecks) { throw new CubejsHandlerError(403, 'Forbidden', 'Invalid token', e); diff --git a/packages/cubejs-api-gateway/test/auth.test.ts b/packages/cubejs-api-gateway/test/auth.test.ts index 2758183f5d8dd..049137a5e5c2c 100644 --- a/packages/cubejs-api-gateway/test/auth.test.ts +++ b/packages/cubejs-api-gateway/test/auth.test.ts @@ -280,6 +280,69 @@ describe('test authorization', () => { expectSecurityContext(handlerMock.mock.calls[0][0].context.authInfo); }); + describe('signedWithPlaygroundAuthSecret requires the dev-token scope', () => { + const playgroundAuthSecret = 'playgroundSecret'; + const loggerMock = jest.fn(() => { + // + }); + + // The playground secret signs every token a Cube Cloud deployment mints, + // including ones handed to end users and external BI tools, so the + // signature alone must not unlock the developer affordances gated on this + // flag (hidden meta members, generated SQL, pre-aggregation debug info). + const flagFor = async (payload: Record) => { + const seen: boolean[] = []; + const handlerMock = jest.fn((req, res) => { + seen.push(req.context.signedWithPlaygroundAuthSecret); + res.status(200).end(); + }); + + const { app } = createApiGateway(handlerMock, loggerMock, { playgroundAuthSecret }); + + await request(app) + .get('/test-auth-fake') + .set('Authorization', `Authorization: ${generateAuthToken(payload, {}, playgroundAuthSecret)}`) + .expect(200); + + return seen[0]; + }; + + test('is false for a playground-signed token with no scope at all', async () => { + expect(await flagFor({ uid: 5 })).toBe(false); + }); + + test('is false for a playground-signed token scoped to something else', async () => { + expect(await flagFor({ uid: 5, scope: ['sql-runner', 'agents-config'] })).toBe(false); + }); + + test('is true for a playground-signed token carrying the dev-token scope', async () => { + expect(await flagFor({ uid: 5, scope: ['dev-token'] })).toBe(true); + // Alongside the service scopes it is minted with in practice. + expect(await flagFor({ uid: 5, scope: ['sql-runner', 'dev-token'] })).toBe(true); + }); + + test('is false when scope is not an array of scope names', async () => { + expect(await flagFor({ uid: 5, scope: 'dev-token' })).toBe(false); + expect(await flagFor({ uid: 5, scope: { 'dev-token': true } })).toBe(false); + }); + + test('is false for a token signed with the main api secret, scope or not', async () => { + const handlerMock = jest.fn((req, res) => { + expect(req.context.signedWithPlaygroundAuthSecret).toBe(false); + res.status(200).end(); + }); + + const { app } = createApiGateway(handlerMock, loggerMock, { playgroundAuthSecret }); + + await request(app) + .get('/test-auth-fake') + .set('Authorization', `Authorization: ${generateAuthToken({ uid: 5, scope: ['dev-token'] }, {})}`) + .expect(200); + + expect(handlerMock.mock.calls.length).toEqual(1); + }); + }); + test('default authorization with JWT token and securityContext in u', async () => { const loggerMock = jest.fn(() => { // diff --git a/rust/cube-cli/README.md b/rust/cube-cli/README.md index 1a010a36e3a6f..df7843f5577b8 100644 --- a/rust/cube-cli/README.md +++ b/rust/cube-cli/README.md @@ -128,7 +128,7 @@ Every endpoint of the Console Server public API is covered: | Group | Endpoints | |---|---| -| `deployments` | list, get, create (`--bootstrap` scaffolds + builds a serving deployment), update, delete, token, advance-step, reset-step | +| `deployments` | list, get, create (`--bootstrap` scaffolds + builds a serving deployment), update (`--release-channel`, `--release-channel-version`), settings, versions, delete, token, advance-step, reset-step | | `regions` | list available deployment regions | | `logs` | tail deployment pod logs (`--pod`, `-c/--container`; defaults to the Cube API container) | | `github` (`gh`) | status, installations, repos, branches, connect (import a repo into a deployment + first build) | @@ -152,6 +152,7 @@ Every endpoint of the Console Server public API is covered: | `app` | config, theme | | `meta` | POST /api/v1/meta/ | | `scim` | Users/Groups CRUD + patch, resource-types, schemas, service-provider-config | +| `spec` | the API's own OpenAPI document from `/api/v1/spec`: bare lists every operation, `` filters on method/path/summary/operationId, `--json` prints OpenAPI (filtered = matching operations + the transitive schema closure) | | `api` | raw escape hatch: `cube api GET /api/v1/... -q key=value -d '{...}'` | Conventions: diff --git a/rust/cube-cli/src/commands/deployments.rs b/rust/cube-cli/src/commands/deployments.rs index e7b8899740c23..5cc9b08feeef8 100644 --- a/rust/cube-cli/src/commands/deployments.rs +++ b/rust/cube-cli/src/commands/deployments.rs @@ -69,10 +69,26 @@ enum Cmd { /// Name #[arg(long)] name: Option, + /// Release channel: latest, release + #[arg(long)] + release_channel: Option, + /// Cube version to run — see `cube deployments versions ` + #[arg(long)] + release_channel_version: Option, /// Request body as JSON (inline, @file, or - for stdin) #[arg(long, short = 'd')] data: Option, }, + /// Show every setting of a deployment + Settings { + /// Deployment id + deployment: i64, + }, + /// List the Cube versions a deployment can switch to + Versions { + /// Deployment id + deployment: i64, + }, /// Delete a deployment #[command(alias = "rm")] Delete { @@ -181,10 +197,14 @@ pub async fn command(args: Args, ctx: &Ctx) -> Result<()> { Cmd::Update { deployment, name, + release_channel, + release_channel_version, data, } => { let mut body = util::parse_data(data.as_deref())?; util::set(&mut body, "name", &name); + util::set(&mut body, "releaseChannel", &release_channel); + util::set(&mut body, "releaseChannelVersion", &release_channel_version); let res = api .put( &format!("/api/v1/deployments/{deployment}"), @@ -193,6 +213,34 @@ pub async fn command(args: Args, ctx: &Ctx) -> Result<()> { .await?; output::print_json(&res); } + Cmd::Settings { deployment } => { + let res = api + .get( + &format!("/api/v1/deployments/{deployment}/settings"), + &Vec::new(), + ) + .await?; + output::print_json(&res); + } + Cmd::Versions { deployment } => { + let res = api + .get( + &format!("/api/v1/deployments/{deployment}/versions"), + &Vec::new(), + ) + .await?; + output::print_list( + ctx.json, + &res, + &[ + ("VERSION", "version"), + ("CHANNEL", "releaseChannel"), + ("LATEST", "isLatestInChannel"), + ("CURRENT", "isCurrent"), + ("PASS AS", "releaseChannelVersion"), + ], + ); + } Cmd::Delete { deployment } => { let res = api .delete(&format!("/api/v1/deployments/{deployment}"), None) diff --git a/rust/cube-cli/src/commands/mod.rs b/rust/cube-cli/src/commands/mod.rs index 96e17a55de8bb..001e7b4375a06 100644 --- a/rust/cube-cli/src/commands/mod.rs +++ b/rust/cube-cli/src/commands/mod.rs @@ -24,6 +24,7 @@ pub mod policies; pub mod regions; pub mod reports; pub mod scim; +pub mod spec; pub mod tenant; pub mod update; pub mod users; diff --git a/rust/cube-cli/src/commands/spec.rs b/rust/cube-cli/src/commands/spec.rs new file mode 100644 index 0000000000000..73d129c30b7bf --- /dev/null +++ b/rust/cube-cli/src/commands/spec.rs @@ -0,0 +1,440 @@ +use std::collections::BTreeSet; + +use anyhow::{bail, Result}; +use serde_json::{json, Map, Value}; + +use crate::{output, Ctx}; + +/// The HTTP verbs an OpenAPI path item can hold. Anything else under a path +/// (`parameters`, `summary`, `$ref`, extensions) is not an operation. +const METHODS: [&str; 8] = [ + "get", "put", "post", "patch", "delete", "options", "head", "trace", +]; + +const SCHEMA_REF_PREFIX: &str = "#/components/schemas/"; +const COMPONENT_REF_PREFIX: &str = "#/components/"; + +/// Fetch the API's own OpenAPI document, so every endpoint, parameter and +/// schema can be discovered at runtime rather than guessed. +/// +/// Without `--json` this prints an index of operations, which is what a human +/// scanning for an endpoint wants. With `--json` it prints OpenAPI: the whole +/// document when unfiltered, or — when a pattern is given — a valid but much +/// smaller document containing only the matching operations plus the transitive +/// closure of the schemas they reference. That closure is the point of the +/// filter: an agent asking "what does this endpoint take?" needs the request +/// body's schema resolved, not a dangling `$ref` that forces it to pull the +/// whole spec anyway. +#[derive(clap::Args)] +pub struct Args { + /// Show only operations whose method, path, summary or operationId contains + /// this text (case-insensitive) + pattern: Option, +} + +/// One operation, flattened out of the nested `paths` → method structure. +struct Operation<'a> { + path: &'a str, + method: &'a str, + op: &'a Value, +} + +impl Operation<'_> { + fn summary(&self) -> &str { + self.op + .get("summary") + .and_then(Value::as_str) + .unwrap_or_default() + } + + fn operation_id(&self) -> &str { + self.op + .get("operationId") + .and_then(Value::as_str) + .unwrap_or_default() + } + + fn matches(&self, needle: &str) -> bool { + let haystack = format!( + "{} {} {} {}", + self.method, + self.path, + self.summary(), + self.operation_id() + ) + .to_lowercase(); + haystack.contains(needle) + } +} + +fn operations(spec: &Value) -> Vec> { + let Some(paths) = spec.get("paths").and_then(Value::as_object) else { + return Vec::new(); + }; + let mut out = Vec::new(); + for (path, item) in paths { + let Some(item) = item.as_object() else { + continue; + }; + // Iterate METHODS rather than the object's keys so operations always + // come out in verb order, not whatever order the server serialized. + for method in METHODS { + if let Some(op) = item.get(method) { + out.push(Operation { path, method, op }); + } + } + } + out +} + +/// Every `#/components/schemas/...` name referenced anywhere inside `value`. +fn collect_refs(value: &Value, out: &mut BTreeSet) { + match value { + Value::Object(map) => { + for (key, child) in map { + if key == "$ref" { + if let Some(name) = child + .as_str() + .and_then(|r| r.strip_prefix(SCHEMA_REF_PREFIX)) + { + out.insert(name.to_string()); + } + } + collect_refs(child, out); + } + } + Value::Array(items) => { + for item in items { + collect_refs(item, out); + } + } + _ => {} + } +} + +/// Expand a set of schema names to include everything they reference, however +/// deeply. Schemas are mutually recursive in places, so this runs to a fixpoint +/// over a visited set rather than recursing through the graph. +fn schema_closure(spec: &Value, seeds: BTreeSet) -> BTreeSet { + let all = spec + .get("components") + .and_then(|c| c.get("schemas")) + .and_then(Value::as_object); + let Some(all) = all else { + return BTreeSet::new(); + }; + + let mut resolved: BTreeSet = BTreeSet::new(); + let mut pending: Vec = seeds.into_iter().collect(); + while let Some(name) = pending.pop() { + if !resolved.insert(name.clone()) { + continue; + } + if let Some(schema) = all.get(&name) { + let mut refs = BTreeSet::new(); + collect_refs(schema, &mut refs); + pending.extend(refs.into_iter().filter(|r| !resolved.contains(r))); + } + } + resolved +} + +/// Build a valid OpenAPI document holding only `matched`, carrying over the +/// document-level fields a client needs to make sense of it (version, servers, +/// security) plus just the schemas those operations reach. +fn filtered_document(spec: &Value, matched: &[&Operation<'_>]) -> Value { + let mut paths = Map::new(); + let mut seeds = BTreeSet::new(); + for op in matched { + collect_refs(op.op, &mut seeds); + let item = paths + .entry(op.path.to_string()) + .or_insert_with(|| json!({})) + .as_object_mut() + .expect("path item is an object"); + item.insert(op.method.to_string(), op.op.clone()); + + // A path item may declare `parameters` that apply to every operation + // under it. Dropping them would silently shrink the parameter list — + // the one thing this command exists to report — so they come along and + // their refs are seeded too. + if let Some(shared) = spec + .get("paths") + .and_then(|p| p.get(op.path)) + .and_then(|i| i.get("parameters")) + { + collect_refs(shared, &mut seeds); + item.insert("parameters".into(), shared.clone()); + } + } + + let names = schema_closure(spec, seeds); + let mut schemas = Map::new(); + if let Some(all) = spec + .get("components") + .and_then(|c| c.get("schemas")) + .and_then(Value::as_object) + { + for name in &names { + if let Some(schema) = all.get(name) { + schemas.insert(name.clone(), schema.clone()); + } + } + } + + let mut components = Map::new(); + components.insert("schemas".into(), Value::Object(schemas)); + // Security schemes are tiny and tell the reader how to authenticate the + // operations it just asked about, so they ride along. + if let Some(security_schemes) = spec + .get("components") + .and_then(|c| c.get("securitySchemes")) + { + components.insert("securitySchemes".into(), security_schemes.clone()); + } + + let mut doc = Map::new(); + for key in ["openapi", "info", "servers", "security"] { + if let Some(value) = spec.get(key) { + doc.insert(key.to_string(), value.clone()); + } + } + doc.insert("paths".into(), Value::Object(paths)); + doc.insert("components".into(), Value::Object(components)); + Value::Object(doc) +} + +/// Every `#/components//` ref in `value`, as (bucket, name). +fn collect_component_refs(value: &Value, out: &mut BTreeSet<(String, String)>) { + match value { + Value::Object(map) => { + for (key, child) in map { + if key == "$ref" { + if let Some(rest) = child + .as_str() + .and_then(|r| r.strip_prefix(COMPONENT_REF_PREFIX)) + { + if let Some((bucket, name)) = rest.split_once('/') { + out.insert((bucket.to_string(), name.to_string())); + } + } + } + collect_component_refs(child, out); + } + } + Value::Array(items) => { + for item in items { + collect_component_refs(item, out); + } + } + _ => {} + } +} + +/// Refuse to emit a document whose `$ref`s don't resolve inside it. +/// +/// The closure only follows `#/components/schemas/`, which is everything the +/// current spec uses. If an operation ever references another bucket +/// (`parameters`, `responses`, `requestBodies`, …), the filtered document would +/// still carry the `$ref` but not its target: a validator rejects it and an +/// agent resolves it to nothing. Since this output is meant to be consumed +/// unattended, fail loudly rather than hand back something quietly wrong. +fn check_no_dangling_refs(doc: &Value) -> Result<()> { + let mut refs = BTreeSet::new(); + collect_component_refs(doc, &mut refs); + + let dangling: Vec = refs + .into_iter() + .filter(|(bucket, name)| { + doc.get("components") + .and_then(|c| c.get(bucket)) + .and_then(|b| b.get(name)) + .is_none() + }) + .map(|(bucket, name)| format!("#/components/{bucket}/{name}")) + .collect(); + + if !dangling.is_empty() { + bail!( + "internal error building the filtered spec: unresolved {} — \ + re-run without a pattern to get the whole document", + dangling.join(", ") + ); + } + Ok(()) +} + +pub async fn command(args: Args, ctx: &Ctx) -> Result<()> { + let spec = ctx.api()?.get("/api/v1/spec", &Vec::new()).await?; + + // Unfiltered JSON is the raw document — no reshaping, so it can be piped + // straight into a generator or a validator. + let Some(pattern) = args.pattern.as_deref() else { + if ctx.json { + output::print_json(&spec); + } else { + print_index(&operations(&spec).iter().collect::>()); + } + return Ok(()); + }; + + let needle = pattern.to_lowercase(); + let all = operations(&spec); + let matched: Vec<&Operation<'_>> = all.iter().filter(|op| op.matches(&needle)).collect(); + + if matched.is_empty() { + bail!("no operation matches `{pattern}` — run `cube spec` to list them all"); + } + + if ctx.json { + let doc = filtered_document(&spec, &matched); + check_no_dangling_refs(&doc)?; + output::print_json(&doc); + } else { + print_index(&matched); + } + Ok(()) +} + +fn print_index(operations: &[&Operation<'_>]) { + let rows = operations + .iter() + .map(|op| { + vec![ + op.method.to_uppercase(), + op.path.to_string(), + op.summary().to_string(), + ] + }) + .collect(); + output::table(&["METHOD", "PATH", "SUMMARY"], rows); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn spec() -> Value { + json!({ + "openapi": "3.1.0", + "info": { "title": "t", "version": "1" }, + "paths": { + "/api/v1/deployments/{id}/settings": { + "get": { "operationId": "getSettings", "summary": "Get deployment settings" }, + "put": { + "operationId": "updateSettings", + "summary": "Update deployment settings", + "requestBody": { "content": { "application/json": { + "schema": { "$ref": "#/components/schemas/UpdateDeploymentInput" } } } } + }, + "parameters": [{ "name": "id", "in": "path" }] + }, + "/api/v1/regions": { "get": { "operationId": "listRegions", "summary": "List regions" } } + }, + "components": { + "securitySchemes": { "bearerAuth": { "type": "http" } }, + "schemas": { + "UpdateDeploymentInput": { "properties": { + "cspsConfig": { "$ref": "#/components/schemas/CspsConfig" } } }, + "CspsConfig": { "properties": { + "self": { "$ref": "#/components/schemas/CspsConfig" } } }, + "Unrelated": { "type": "object" } + } + } + }) + } + + #[test] + fn lists_operations_in_verb_order_ignoring_non_operation_keys() { + let spec = spec(); + let ops = operations(&spec); + assert_eq!(ops.len(), 3); + // `parameters` on the path item is not an operation. + assert!(ops.iter().all(|o| METHODS.contains(&o.method))); + let settings: Vec<&str> = ops + .iter() + .filter(|o| o.path.ends_with("/settings")) + .map(|o| o.method) + .collect(); + assert_eq!(settings, vec!["get", "put"]); + } + + #[test] + fn matches_on_path_summary_and_operation_id() { + let spec = spec(); + let ops = operations(&spec); + assert_eq!(ops.iter().filter(|o| o.matches("settings")).count(), 2); + assert_eq!(ops.iter().filter(|o| o.matches("listregions")).count(), 1); + // Case-insensitive, and the method is part of the haystack. + assert_eq!(ops.iter().filter(|o| o.matches("put")).count(), 1); + assert_eq!(ops.iter().filter(|o| o.matches("nope")).count(), 0); + } + + #[test] + fn filtered_document_carries_the_reachable_schemas_only() { + let spec = spec(); + let ops = operations(&spec); + let matched: Vec<&Operation<'_>> = ops.iter().filter(|o| o.matches("settings")).collect(); + let doc = filtered_document(&spec, &matched); + + let paths = doc["paths"].as_object().unwrap(); + assert_eq!(paths.len(), 1); + let item = paths["/api/v1/deployments/{id}/settings"] + .as_object() + .unwrap(); + assert!(item.contains_key("get") && item.contains_key("put")); + + let schemas = doc["components"]["schemas"].as_object().unwrap(); + // Reached through the request body, and through CspsConfig's self-ref + // (which must terminate rather than spin). + assert!(schemas.contains_key("UpdateDeploymentInput")); + assert!(schemas.contains_key("CspsConfig")); + assert!(!schemas.contains_key("Unrelated")); + // Still a usable document: version, info and auth survive. + assert_eq!(doc["openapi"], "3.1.0"); + assert!(doc["info"].is_object()); + assert!(doc["components"]["securitySchemes"]["bearerAuth"].is_object()); + // Nothing points outside the document it just built. + check_no_dangling_refs(&doc).unwrap(); + } + + #[test] + fn filtered_document_keeps_path_level_parameters() { + let spec = spec(); + let ops = operations(&spec); + let matched: Vec<&Operation<'_>> = ops.iter().filter(|o| o.matches("settings")).collect(); + let doc = filtered_document(&spec, &matched); + + // `id` is declared once on the path item, not per operation. Losing it + // would understate the endpoint's parameters — the exact thing this + // command is supposed to report. + let params = doc["paths"]["/api/v1/deployments/{id}/settings"]["parameters"] + .as_array() + .expect("path-level parameters survive filtering"); + assert_eq!(params.len(), 1); + assert_eq!(params[0]["name"], "id"); + } + + #[test] + fn dangling_component_refs_are_rejected() { + // A ref into a bucket the closure doesn't follow: the document keeps the + // `$ref` but not its target, so it must be refused rather than emitted. + let doc = json!({ + "openapi": "3.1.0", + "paths": { "/x": { "get": { + "parameters": [{ "$ref": "#/components/parameters/Missing" }] } } }, + "components": { "schemas": {} } + }); + let err = check_no_dangling_refs(&doc).unwrap_err().to_string(); + assert!(err.contains("#/components/parameters/Missing"), "{err}"); + + // A resolvable ref in a non-schema bucket is fine. + let ok = json!({ + "openapi": "3.1.0", + "paths": { "/x": { "get": { + "parameters": [{ "$ref": "#/components/parameters/Present" }] } } }, + "components": { "parameters": { "Present": { "name": "p", "in": "query" } } } + }); + check_no_dangling_refs(&ok).unwrap(); + } +} diff --git a/rust/cube-cli/src/main.rs b/rust/cube-cli/src/main.rs index 7df4dd0b12b5d..a6bd3181cddc5 100644 --- a/rust/cube-cli/src/main.rs +++ b/rust/cube-cli/src/main.rs @@ -190,6 +190,8 @@ enum Command { /// SCIM v2 user and group provisioning Scim(commands::scim::Args), + /// Show this API's OpenAPI specification (endpoints, parameters, schemas) + Spec(commands::spec::Args), /// Make an authenticated raw API request (escape hatch) Api(commands::api::Args), /// Update the CLI to the latest release @@ -239,6 +241,7 @@ impl Command { App(_) => "app", Meta(_) => "meta", Scim(_) => "scim", + Spec(_) => "spec", Api(_) => "api", Update(_) => "update", Completion(_) => "completion", @@ -335,6 +338,7 @@ async fn run(global: GlobalArgs, command: Command) -> Result<()> { App(args) => commands::app::command(args, &ctx).await, Meta(args) => commands::meta::command(args, &ctx).await, Scim(args) => commands::scim::command(args, &ctx).await, + Spec(args) => commands::spec::command(args, &ctx).await, Api(args) => commands::api::command(args, &ctx).await, Update(args) => commands::update::command(args, &ctx).await, Completion(args) => commands::completion::command(args),