diff --git a/README.md b/README.md index faa3626..7a63af2 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Bun/TypeScript executable for humans, automation, and coding agents. The current MVP implements local token authentication, adaptive structured output, and discovery of the public operationId-driven command registry. Generated API -commands are discoverable but not yet executable unless `akua --help` says so. +commands are discovery-only unless listed as executable by `akua --help`. The canonical executable is `akua`; there is no `cnap` compatibility binary. @@ -139,6 +139,26 @@ akua auth status akua auth logout ``` +## Executable capacity overlays + +Five reviewed public API overlays are executable in addition to the +discovery-only generated registry: + +```sh +akua clusters get --id [--workspace ] +akua compute-configs list --view full [--workspace ] +akua compute list-instance-types --config +akua machines list --cluster-id --view full [--workspace ] +akua machines create --cluster-id --compute-config-id --instance-type --idempotency-key [--workspace ] --yes +``` + +They use the fixed production API, validate canonical resource IDs and bounded +names locally, and authenticate with `AKUA_API_TOKEN` before the protected local +config. Machine creation targets only canonical `POST /v1/machines`, submits +once, and requires explicit `--yes` plus a caller-visible idempotency key. All +other generated mutations remain unavailable; registry discovery does not imply +execution support. + `AKUA_API_TOKEN` takes precedence over a stored token. Login writes `~/.config/akua/config.json`; the directory is forced to `0700` and the file to `0600`. Login replaces only `token` and preserves unknown config keys. Logout diff --git a/docs/architecture.md b/docs/architecture.md index dde632b..bda78d3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Akua Cloud CLI Architecture -Status: greenfield scaffold with local auth/config MVP. +Status: local auth/config MVP with bounded executable capacity overlays. ## Decisions And Non-Goals @@ -128,12 +128,16 @@ segment becomes the action, and single-segment operationIds fall back to the HTTP method as the action. The generator assumes OpenAPI operationIds are unique; it does not currently enforce uniqueness itself. -The generator deliberately produces a registry, not hand-written API coverage. -Execution is stubbed until the API client and request/body binding layer lands. -The next implementation step should add a small CLI overlay file for exceptions -that cannot be inferred safely from OpenAPI alone, such as preferred aliases, -default list fields, destructive-command confirmation labels, and resource- -specific next steps. +The generator deliberately produces a discovery registry, not generic API +execution. A small hand-written capacity overlay executes only reviewed +contracts: `clusters get`, `compute-configs list --view full`, +`compute list-instance-types --config `, +`machines list --cluster-id --view full`, and canonical +`machines create --cluster-id --compute-config-id +--instance-type --idempotency-key [--workspace +] --yes`. All other generated rows are discovery-only. The create +overlay binds a closed three-field body to `POST /v1/machines`; it does not +enable the legacy untyped `compute.createMachine` `node_claim` contract. Generation tasks: @@ -239,6 +243,11 @@ akua # registry status home view akua auth login --token # save a local API token akua auth status # show effective auth source akua auth logout # remove the saved local API token +akua clusters get --id # executable reviewed API overlay +akua compute-configs list --view full # executable reviewed API overlay +akua compute list-instance-types --config +akua machines list --cluster-id --view full +akua machines create --cluster-id --compute-config-id --instance-type --idempotency-key [--workspace ] --yes akua commands # first 20 generated public commands akua commands --resource workspaces # resource filter akua commands --operation-id workspaces.list diff --git a/src/bin/akua.ts b/src/bin/akua.ts index faa2931..b44cd11 100644 --- a/src/bin/akua.ts +++ b/src/bin/akua.ts @@ -1,6 +1,7 @@ #!/usr/bin/env bun import { authView } from "../commands/auth"; import { agentOsView } from "../commands/agent-os"; +import { capacityView } from "../commands/capacity"; import { buildHomeView } from "../commands/home"; import { commandRegistry } from "../generated/commands.gen"; import { AkuaCliError, commandNotImplemented, usageError } from "../runtime/errors"; @@ -52,6 +53,10 @@ async function route(argv: readonly string[], env: Record arg.startsWith("-")); if (unknownFlag) { throw usageError(`Unknown flag: ${flagName(unknownFlag)}`); @@ -101,6 +106,11 @@ function helpView(): RenderEnvelope { " akua auth status Show local authentication status", " akua auth logout Remove the saved local API token", " akua agent-os load-hcloud-provider --workspace --token-file [--expected-ssh-key-fingerprint [--expected-ssh-key-name ]]", + " akua clusters get --id [--workspace ]", + " akua compute-configs list --view full [--workspace ]", + " akua compute list-instance-types --config ", + " akua machines list --cluster-id --view full [--workspace ]", + " akua machines create --cluster-id --compute-config-id --instance-type --idempotency-key [--workspace ] --yes", " akua commands List generated public OpenAPI command registry", " akua --help Show help", " akua --version Show version", @@ -112,6 +122,17 @@ function helpView(): RenderEnvelope { }; } +function isCapacityOverlay(argv: readonly string[]): boolean { + const command = `${argv[0] ?? ""} ${argv[1] ?? ""}`; + return ( + command === "clusters get" || + command === "compute-configs list" || + command === "compute list-instance-types" || + command === "machines list" || + command === "machines create" + ); +} + function stripGlobalFlags(argv: readonly string[]): string[] { const stripped: string[] = []; for (let index = 0; index < argv.length; index += 1) { diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 18a78be..a3782d4 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -63,6 +63,23 @@ export async function readProtectedCallerToken(env: Record): Promise { + if (hasEnvToken(env)) { + return env.AKUA_API_TOKEN as string; + } + + const token = (await readConfig(resolveConfigPath(env))).token; + if (typeof token !== "string" || token === "") { + throw new AkuaCliError({ + type: "authentication_error", + code: "AKUA_PUBLIC_API_AUTH_REQUIRED", + message: "An Akua API token is required.", + exitCode: 3, + }); + } + return token; +} + async function loginView(argv: readonly string[], env: Record): Promise { const token = parseLoginFlags(argv); const configPath = resolveConfigPath(env); diff --git a/src/commands/capacity.ts b/src/commands/capacity.ts new file mode 100644 index 0000000..7692d3e --- /dev/null +++ b/src/commands/capacity.ts @@ -0,0 +1,201 @@ +import { usageError } from "../runtime/errors"; +import { PublicApiClient, type ApiFetch } from "../runtime/public-api-client"; +import type { RenderEnvelope } from "../runtime/render"; +import { readPublicApiToken } from "./auth"; + +export interface CapacityDependencies { + readToken(env: Record): Promise; + fetch: ApiFetch; +} + +const productionDependencies: CapacityDependencies = { + readToken: readPublicApiToken, + fetch, +}; + +interface ParsedCommand { + command: string; + path?: string; + workspace?: string; + create?: { + cluster_id: string; + compute_config_id: string; + instance_type: string; + idempotency_key: string; + }; +} + +export async function capacityView( + argv: readonly string[], + env: Record, + dependencies: CapacityDependencies = productionDependencies, +): Promise { + const parsed = parseCommand(argv); + const token = await dependencies.readToken(env); + const client = new PublicApiClient(token, dependencies.fetch); + if (parsed.create !== undefined) { + const { idempotency_key, ...body } = parsed.create; + const operation = await client.createMachine(body, idempotency_key, parsed.workspace); + return { + command: parsed.command, + observations: ["Machine creation accepted. No automatic retry was attempted."], + data: { ...operation, idempotency_key }, + }; + } + const data = await client.get(parsed.path as string, parsed.workspace); + return { command: parsed.command, data }; +} + +function parseCommand(argv: readonly string[]): ParsedCommand { + const operation = `${argv[0] ?? ""} ${argv[1] ?? ""}`; + const flags = parseFlags(argv.slice(2)); + + if (operation === "clusters get") { + allowOnly(flags, ["--id", "--workspace"]); + const id = requiredCanonicalId(flags, "--id", "clu"); + const workspace = optionalCanonicalId(flags, "--workspace", "ws"); + return { command: "akua clusters get", path: `/v1/clusters/${id}`, workspace }; + } + + if (operation === "compute-configs list") { + allowOnly(flags, ["--view", "--workspace"]); + requireExactValue(flags, "--view", "full"); + const workspace = optionalCanonicalId(flags, "--workspace", "ws"); + return { command: "akua compute-configs list", path: "/v1/compute_configs?view=full", workspace }; + } + + if (operation === "compute list-instance-types") { + allowOnly(flags, ["--config"]); + const config = requiredBoundedValue(flags, "--config", 54); + return { + command: "akua compute list-instance-types", + path: `/v1/compute/instance_types?config=${encodeURIComponent(config)}`, + }; + } + + if (operation === "machines list") { + allowOnly(flags, ["--cluster-id", "--view", "--workspace"]); + const cluster = requiredCanonicalId(flags, "--cluster-id", "clu"); + requireExactValue(flags, "--view", "full"); + const workspace = optionalCanonicalId(flags, "--workspace", "ws"); + return { + command: "akua machines list", + path: `/v1/machines?cluster_id=${cluster}&view=full`, + workspace, + }; + } + + if (operation === "machines create") { + allowOnly(flags, ["--cluster-id", "--compute-config-id", "--instance-type", "--idempotency-key", "--workspace", "--yes"]); + const cluster_id = requiredCanonicalId(flags, "--cluster-id", "clu"); + const compute_config_id = requiredOpaquePublicId(flags, "--compute-config-id"); + const instance_type = requiredBoundedValue(flags, "--instance-type", 120); + const idempotency_key = requiredBoundedValue(flags, "--idempotency-key", 64); + const workspace = optionalCanonicalId(flags, "--workspace", "ws"); + if (flags.get("--yes") !== true) { + throw usageError("machines create requires explicit --yes confirmation."); + } + return { + command: "akua machines create", + workspace, + create: { cluster_id, compute_config_id, instance_type, idempotency_key }, + }; + } + + throw usageError("Unknown capacity command."); +} + +function parseFlags(argv: readonly string[]): Map { + const flags = new Map(); + for (let index = 0; index < argv.length; index += 1) { + const raw = argv[index]; + if (!raw.startsWith("--")) { + throw usageError("Unexpected capacity command argument."); + } + const equals = raw.indexOf("="); + const name = equals === -1 ? raw : raw.slice(0, equals); + if (flags.has(name)) { + throw usageError(`Flag ${name} may be specified only once.`); + } + if (name === "--yes") { + if (equals !== -1) { + throw usageError("--yes does not accept a value."); + } + flags.set(name, true); + continue; + } + const value = equals === -1 ? argv[index + 1] : raw.slice(equals + 1); + if (value === undefined || value === "" || value.startsWith("--")) { + throw usageError(`Missing value for ${name}.`); + } + flags.set(name, value); + if (equals === -1) { + index += 1; + } + } + return flags; +} + +function allowOnly(flags: ReadonlyMap, allowed: readonly string[]): void { + for (const name of flags.keys()) { + if (!allowed.includes(name)) { + throw usageError(`Unknown flag: ${name}`); + } + } +} + +function requiredCanonicalId(flags: ReadonlyMap, name: string, prefix: string): string { + const value = requiredValue(flags, name); + if (!new RegExp(`^${prefix}_[a-z0-9]{32}$`).test(value)) { + throw usageError(`${name} must be a canonical ${prefix}_ ID.`); + } + return value; +} + +function optionalCanonicalId( + flags: ReadonlyMap, + name: string, + prefix: string, +): string | undefined { + if (!flags.has(name)) { + return undefined; + } + return requiredCanonicalId(flags, name, prefix); +} + +function requiredValue(flags: ReadonlyMap, name: string): string { + const value = flags.get(name); + if (typeof value !== "string" || value === "") { + throw usageError(`Missing required ${name} flag.`); + } + return value; +} + +function requiredBoundedValue( + flags: ReadonlyMap, + name: string, + maxLength: number, +): string { + const value = requiredValue(flags, name); + if (value.length > maxLength || /[\u0000-\u001f\u007f]/.test(value)) { + throw usageError(`${name} must be at most ${maxLength} characters and contain no control characters.`); + } + return value; +} + +function requiredOpaquePublicId(flags: ReadonlyMap, name: string): string { + const value = requiredValue(flags, name); + // Public IDs have an opaque 32-character payload and a resource prefix. The + // OpenAPI contract does not assign a literal prefix to compute configs. + if (value.length > 54 || !/^[a-z][a-z0-9]{0,20}_[a-z0-9]{32}$/.test(value)) { + throw usageError(`${name} must be a prefixed opaque public ID.`); + } + return value; +} + +function requireExactValue(flags: ReadonlyMap, name: string, expected: string): void { + const value = requiredValue(flags, name); + if (value !== expected) { + throw usageError(`${name} must be ${expected}.`); + } +} diff --git a/src/runtime/public-api-client.ts b/src/runtime/public-api-client.ts new file mode 100644 index 0000000..8e8b642 --- /dev/null +++ b/src/runtime/public-api-client.ts @@ -0,0 +1,239 @@ +import { AkuaCliError } from "./errors"; + +const PublicApiBase = "https://api.akua.dev"; +const DefaultRequestTimeoutMs = 30_000; +const DefaultMaxResponseBytes = 1_048_576; + +export type ApiFetch = (input: string | URL | Request, init?: RequestInit) => Promise; + +export interface PublicApiClientOptions { + requestTimeoutMs?: number; + maxResponseBytes?: number; +} + +class UnknownCreateOutcomeError extends Error {} + +export class PublicApiClient { + private readonly requestTimeoutMs: number; + private readonly maxResponseBytes: number; + + constructor( + private readonly token: string, + private readonly apiFetch: ApiFetch = fetch, + options: PublicApiClientOptions = {}, + ) { + this.requestTimeoutMs = options.requestTimeoutMs ?? DefaultRequestTimeoutMs; + this.maxResponseBytes = options.maxResponseBytes ?? DefaultMaxResponseBytes; + } + + async get(path: string, workspace?: string): Promise { + return this.request(path, { method: "GET" }, workspace); + } + + async createMachine( + body: { cluster_id: string; instance_type: string; compute_config_id: string }, + idempotencyKey: string, + workspace?: string, + ): Promise<{ operation_id: string }> { + try { + const value = await this.request( + "/v1/machines", + { + method: "POST", + headers: { "content-type": "application/json", "idempotency-key": idempotencyKey }, + body: JSON.stringify(body), + }, + workspace, + 202, + ); + return validateOperationEnvelope(value); + } catch (error) { + if (error instanceof UnknownCreateOutcomeError) { + throw new AkuaCliError({ + type: "unknown_outcome", + code: "AKUA_MACHINE_CREATE_OUTCOME_UNKNOWN", + message: "Machine creation outcome is unknown. The request was not retried.", + exitCode: 1, + nextSteps: [ + { + command: "akua machines create ... --idempotency-key --yes", + description: "Check operation status first; replay only with the exact caller-supplied idempotency key.", + }, + ], + }); + } + throw error; + } + } + + private async request( + path: string, + init: RequestInit, + workspace?: string, + expectedStatus?: number, + ): Promise { + const headers = new Headers(init.headers); + headers.set("authorization", `Bearer ${this.token}`); + if (workspace !== undefined) { + headers.set("akua-context", workspace); + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.requestTimeoutMs); + let response: Response; + try { + response = await this.apiFetch(`${PublicApiBase}${path}`, { + ...init, + headers, + signal: controller.signal, + }); + } catch { + clearTimeout(timeout); + if (init.method === "POST") { + throw new UnknownCreateOutcomeError(); + } + throw transportError(); + } + + let body: unknown; + try { + const text = await readBoundedText(response, this.maxResponseBytes); + body = JSON.parse(text); + } catch { + clearTimeout(timeout); + if (init.method === "POST" && response.ok) { + throw new UnknownCreateOutcomeError(); + } + if (!response.ok) { + throw apiError(response); + } + throw invalidResponse(response.status, "The Akua API returned an invalid or oversized JSON response."); + } finally { + clearTimeout(timeout); + } + + if (!response.ok) { + throw apiError(response, body, this.token); + } + if (expectedStatus !== undefined && response.status !== expectedStatus) { + if (init.method === "POST") { + throw new UnknownCreateOutcomeError(); + } + throw invalidResponse(response.status, "The Akua API returned an unexpected success status."); + } + return body; + } +} + +async function readBoundedText(response: Response, maxBytes: number): Promise { + const declaredLength = response.headers.get("content-length"); + if (declaredLength !== null && /^\d+$/.test(declaredLength) && Number(declaredLength) > maxBytes) { + throw new Error("response too large"); + } + if (response.body === null) { + return ""; + } + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > maxBytes) { + await reader.cancel(); + throw new Error("response too large"); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); +} + +function apiError(response: Response, body?: unknown, token?: string): AkuaCliError { + const entry = firstApiErrorEntry(body); + return new AkuaCliError({ + type: "api_error", + code: entry === undefined ? "AKUA_PUBLIC_API_ERROR" : `AKUA_PUBLIC_API_${entry.code}`, + message: entry === undefined + ? "The Akua API rejected the request." + : redactToken(entry.message, token), + path: entry?.path?.map((part) => redactToken(part, token)), + status: response.status, + requestId: response.headers.get("x-request-id") ?? undefined, + retryAfter: response.headers.get("retry-after"), + }); +} + +function firstApiErrorEntry(value: unknown): { code: number; message: string; path?: string[] } | undefined { + if (!isRecord(value) || value.success !== false || !Array.isArray(value.errors) || !isRecord(value.result)) { + return undefined; + } + const entry = value.errors[0]; + if (!isRecord(entry) || !Number.isSafeInteger(entry.code) || typeof entry.message !== "string") { + return undefined; + } + if (entry.message.length < 1 || entry.message.length > 1_000 || /[\u0000-\u001f\u007f]/.test(entry.message)) { + return undefined; + } + let path: string[] | undefined; + if (entry.path !== undefined) { + if (!Array.isArray(entry.path) || entry.path.length > 32 || entry.path.some((part) => + typeof part !== "string" || part.length < 1 || part.length > 200 || /[\u0000-\u001f\u007f]/.test(part) + )) { + return undefined; + } + path = entry.path as string[]; + } + return { code: entry.code as number, message: entry.message, path }; +} + +function redactToken(message: string, token: string | undefined): string { + return token === undefined || token === "" ? message : message.split(token).join("[REDACTED]"); +} + +function validateOperationEnvelope(value: unknown): { operation_id: string } { + if ( + !isRecord(value) || + Object.keys(value).length !== 1 || + typeof value.operation_id !== "string" || + value.operation_id.length < 1 || + value.operation_id.length > 53 || + /[\u0000-\u001f\u007f]/.test(value.operation_id) + ) { + throw new UnknownCreateOutcomeError(); + } + return { operation_id: value.operation_id }; +} + +function transportError(): AkuaCliError { + return new AkuaCliError({ + type: "transport_error", + code: "AKUA_PUBLIC_API_TRANSPORT_ERROR", + message: "The Akua API request could not be completed.", + exitCode: 1, + }); +} + +function invalidResponse(status: number, message: string): AkuaCliError { + return new AkuaCliError({ + type: "invalid_response", + code: "AKUA_PUBLIC_API_INVALID_RESPONSE", + message, + status, + exitCode: 1, + }); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/test/capacity-commands.test.ts b/test/capacity-commands.test.ts new file mode 100644 index 0000000..73e50bc --- /dev/null +++ b/test/capacity-commands.test.ts @@ -0,0 +1,283 @@ +import { describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { readPublicApiToken } from "../src/commands/auth"; +import { capacityView, type CapacityDependencies } from "../src/commands/capacity"; +import { AkuaCliError } from "../src/runtime/errors"; +import { PublicApiClient, type ApiFetch } from "../src/runtime/public-api-client"; + +const TOKEN = "sentinel-public-api-token"; +const CLUSTER = "clu_j572abc123def456j572abc123def456"; +const WORKSPACE = "ws_j572abc123def456j572abc123def456"; +const CONFIG = "cc_j572abc123def456j572abc123def456"; +const RAW_UUID = "123e4567-e89b-12d3-a456-426614174000"; + +describe("capacity command overlays", () => { + test("clusters get binds the canonical path and exact workspace header", async () => { + const fixture = apiFixture({ + id: CLUSTER, + html_url: `https://akua.dev/clusters/${CLUSTER}`, + name: "production", + workspace_id: WORKSPACE, + state: "active", + provider: "managed_kaas", + reconciling: false, + created_at: 1, + updated_at: 2, + etag: "3", + }); + const result = await capacityView(["clusters", "get", "--id", CLUSTER, "--workspace", WORKSPACE], {}, deps(fixture.fetch)); + + expect(result.data).toMatchObject({ id: CLUSTER, workspace_id: WORKSPACE }); + expect(fixture.requests).toHaveLength(1); + expect(fixture.requests[0]).toMatchObject({ + url: `https://api.akua.dev/v1/clusters/${CLUSTER}`, + method: "GET", + headers: { authorization: `Bearer ${TOKEN}`, "akua-context": WORKSPACE }, + }); + expect(JSON.stringify(result)).not.toContain(TOKEN); + }); + + test("compute config and machine lists force full view and preserve ownership", async () => { + const configs = apiFixture({ + data: [{ id: CONFIG, name: "hcloud-fsn1", provider: "hcloud", provider_config: { provider: "hcloud", region: "fsn1", image: "ubuntu", machine_type_filter: null }, secret_id: null, created_at: 1 }], + has_more: false, + next_cursor: null, + }); + const configResult = await capacityView(["compute-configs", "list", "--view", "full", "--workspace", WORKSPACE], {}, deps(configs.fetch)); + expect(configResult.data).toMatchObject({ data: [{ id: CONFIG }] }); + expect(configs.requests[0].url).toBe("https://api.akua.dev/v1/compute_configs?view=full"); + + const machines = apiFixture({ data: [], has_more: false, next_cursor: null }); + await capacityView(["machines", "list", "--cluster-id", CLUSTER, "--view", "full", "--workspace", WORKSPACE], {}, deps(machines.fetch)); + expect(machines.requests[0].url).toBe(`https://api.akua.dev/v1/machines?cluster_id=${CLUSTER}&view=full`); + }); + + test("instance types bind the explicit config name and return full comparison fields", async () => { + const fixture = apiFixture([{ name: "cpx31", arch: "amd64", cpu: 4, memory_mib: 8192, storage_mib: 163840, price_per_hour: 0.0208, available: true, zone: "fsn1", capacity_type: "on-demand" }]); + const configName = "hcloud fsn1/prod"; + const result = await capacityView(["compute", "list-instance-types", "--config", configName], {}, deps(fixture.fetch)); + expect(result.data).toEqual([{ name: "cpx31", arch: "amd64", cpu: 4, memory_mib: 8192, storage_mib: 163840, price_per_hour: 0.0208, available: true, zone: "fsn1", capacity_type: "on-demand" }]); + expect(fixture.requests[0].url).toBe("https://api.akua.dev/v1/compute/instance_types?config=hcloud%20fsn1%2Fprod"); + }); + + test("machine create submits the canonical closed request exactly once", async () => { + const fixture = apiFixture({ operation_id: "op_create_123" }, 202); + const result = await capacityView([ + "machines", "create", + "--cluster-id", CLUSTER, + "--compute-config-id", CONFIG, + "--instance-type", "cpx31", + "--idempotency-key", "captain-stable-key", + "--workspace", WORKSPACE, + "--yes", + ], {}, deps(fixture.fetch)); + + expect(result.data).toEqual({ operation_id: "op_create_123", idempotency_key: "captain-stable-key" }); + expect(fixture.requests).toHaveLength(1); + expect(fixture.requests[0]).toEqual({ + url: "https://api.akua.dev/v1/machines", + method: "POST", + headers: { + "akua-context": WORKSPACE, + authorization: `Bearer ${TOKEN}`, + "content-type": "application/json", + "idempotency-key": "captain-stable-key", + }, + body: JSON.stringify({ cluster_id: CLUSTER, compute_config_id: CONFIG, instance_type: "cpx31" }), + }); + expect(JSON.stringify(result)).not.toContain(TOKEN); + }); + + test("ambiguous machine-create outcomes are not retried and preserve same-key guidance", async () => { + const cases: ApiFetch[] = [ + async () => { throw new Error(`socket closed with ${TOKEN}`); }, + async () => new Response("{truncated", { status: 202 }), + async () => new Response(JSON.stringify({ wrong: "shape" }), { status: 202 }), + async () => new Response(JSON.stringify({ operation_id: "already-created" }), { status: 200 }), + ]; + for (const fetchCase of cases) { + let attempts = 0; + const fetch: ApiFetch = async (...args) => { + attempts += 1; + return fetchCase(...args); + }; + const error = await createMachine(fetch).catch((value) => value); + expect(attempts).toBe(1); + expect(error).toMatchObject({ + code: "AKUA_MACHINE_CREATE_OUTCOME_UNKNOWN", + message: expect.stringContaining("unknown"), + nextSteps: [{ + command: expect.stringContaining(""), + description: expect.stringContaining("exact caller-supplied idempotency key"), + }], + }); + expect(JSON.stringify(error.toPayload())).not.toContain(TOKEN); + } + }); + + test("create timeout is outcome unknown after exactly one bounded attempt", async () => { + let attempts = 0; + const fetch: ApiFetch = (_input, init) => new Promise((_resolve, reject) => { + attempts += 1; + init?.signal?.addEventListener("abort", () => reject(new DOMException("timed out", "AbortError")), { once: true }); + }); + const client = new PublicApiClient(TOKEN, fetch, { requestTimeoutMs: 5 }); + const error = await client.createMachine( + { cluster_id: CLUSTER, compute_config_id: CONFIG, instance_type: "cpx31" }, + "captain-stable-key", + ).catch((value) => value); + expect(attempts).toBe(1); + expect(error.code).toBe("AKUA_MACHINE_CREATE_OUTCOME_UNKNOWN"); + expect(JSON.stringify(error.toPayload())).not.toContain(TOKEN); + }); + + test("valid provider errors project the first stable entry and redact tokens", async () => { + const fixture = apiFixture({ + success: false, + errors: [{ code: 7002, message: `Resource ${TOKEN} not found`, path: ["body", `compute_config_id.${TOKEN}`], metadata: { ignored: "value" } }], + result: {}, + }, 404, { "x-request-id": "req_safe", "retry-after": "9" }); + const error = await capacityView(["compute", "list-instance-types", "--config", CONFIG], {}, deps(fixture.fetch)).catch((value) => value); + expect(error).toMatchObject({ + code: "AKUA_PUBLIC_API_7002", + status: 404, + path: ["body", "compute_config_id.[REDACTED]"], + requestId: "req_safe", + retryAfter: "9", + }); + expect(error.message).toBe("Resource [REDACTED] not found"); + expect(JSON.stringify(error.toPayload())).not.toContain(TOKEN); + + let createAttempts = 0; + const createError = await createMachine(async () => { + createAttempts += 1; + return new Response(JSON.stringify({ + success: false, + errors: [{ code: 7009, message: "Capacity conflict", path: ["body", "instance_type"] }], + result: {}, + }), { status: 409 }); + }).catch((value) => value); + expect(createAttempts).toBe(1); + expect(createError).toMatchObject({ code: "AKUA_PUBLIC_API_7009", status: 409 }); + expect(createError.code).not.toBe("AKUA_MACHINE_CREATE_OUTCOME_UNKNOWN"); + }); + + test("responses are byte bounded", async () => { + const fetch: ApiFetch = async () => new Response(JSON.stringify({ data: "x".repeat(33) })); + const client = new PublicApiClient(TOKEN, fetch, { maxResponseBytes: 32 }); + const error = await client.get("/v1/clusters/test").catch((value) => value); + expect(error).toMatchObject({ code: "AKUA_PUBLIC_API_INVALID_RESPONSE" }); + if (!(error instanceof AkuaCliError)) throw new Error("Expected a structured CLI error."); + expect(JSON.stringify(error.toPayload())).not.toContain(TOKEN); + }); + + test("invalid IDs, unknown flags, and create without yes fail before auth or fetch", async () => { + let authCalls = 0; + let fetchCalls = 0; + const dependencies: CapacityDependencies = { + readToken: async () => { authCalls += 1; return TOKEN; }, + fetch: async () => { fetchCalls += 1; throw new Error("must not fetch"); }, + }; + for (const argv of [ + ["clusters", "get", "--id", "clu_j572abc123def456"], + ["compute", "list-instance-types", "--config", "bad\nconfig"], + ["compute", "list-instance-types", "--config", "x".repeat(55)], + ["machines", "create", "--cluster-id", CLUSTER, "--compute-config-id", RAW_UUID, "--instance-type", "cpx31", "--idempotency-key", "captain-key", "--yes"], + ["machines", "list", "--cluster-id", CLUSTER, "--bogus"], + ["machines", "create", "--cluster-id", CLUSTER, "--compute-config-id", CONFIG, "--instance-type", "cpx31", "--idempotency-key", "captain-key"], + ]) { + await expect(capacityView(argv, {}, dependencies)).rejects.toMatchObject({ exitCode: 2 }); + } + expect(authCalls).toBe(0); + expect(fetchCalls).toBe(0); + }); + + test("public auth prefers the ephemeral environment token over protected config", async () => { + const home = await mkdtemp(join(process.cwd(), ".tmp-capacity-auth-")); + try { + const configDir = join(home, ".config", "akua"); + await mkdir(configDir, { recursive: true, mode: 0o700 }); + await writeFile(join(configDir, "config.json"), JSON.stringify({ token: "stored-token" }), { mode: 0o600 }); + + await expect(readPublicApiToken({ HOME: home })).resolves.toBe("stored-token"); + await expect(readPublicApiToken({ HOME: home, AKUA_API_TOKEN: TOKEN })).resolves.toBe(TOKEN); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); + + test("transport failures and CLI validation never expose the token sentinel", async () => { + const failingFetch: ApiFetch = async () => { + throw new Error(`transport included ${TOKEN}`); + }; + const error = await capacityView(["clusters", "get", "--id", CLUSTER], {}, deps(failingFetch)).catch((value) => value); + expect(String(error)).not.toContain(TOKEN); + + const cli = await runAkua(["clusters", "get", "--id", "not-a-cluster", "--json"], { + AKUA_API_TOKEN: TOKEN, + }); + expect(cli.exitCode).toBe(2); + expect(cli.stdout).toContain("canonical clu_ ID"); + expect(`${cli.stdout}${cli.stderr}`).not.toContain(TOKEN); + expect(cli.stdout).not.toContain("AKUA_COMMAND_NOT_IMPLEMENTED"); + }); + + test("CLI help distinguishes the executable capacity overlays", async () => { + const cli = await runAkua(["--help", "--json"]); + expect(cli.exitCode).toBe(0); + expect(cli.stdout).toContain("akua clusters get"); + expect(cli.stdout).toContain("akua compute-configs list --view full"); + expect(cli.stdout).toContain("akua compute list-instance-types"); + expect(cli.stdout).toContain("akua machines list"); + expect(cli.stdout).toContain("akua machines create --cluster-id --compute-config-id --instance-type --idempotency-key [--workspace ] --yes"); + expect(cli.stdout).not.toContain("submission unavailable"); + }); +}); + +interface CapturedRequest { url: string; method: string; headers: Record; body?: string } + +function apiFixture(body: unknown, status = 200, responseHeaders: Record = {}) { + const requests: CapturedRequest[] = []; + const fetch: ApiFetch = async (input, init) => { + requests.push({ url: String(input), method: init?.method ?? "GET", headers: Object.fromEntries(new Headers(init?.headers).entries()), body: typeof init?.body === "string" ? init.body : undefined }); + return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json", ...responseHeaders } }); + }; + return { fetch, requests }; +} + +function deps(fetch: ApiFetch): CapacityDependencies { + return { readToken: async () => TOKEN, fetch }; +} + +function createMachine(fetch: ApiFetch) { + return capacityView([ + "machines", "create", + "--cluster-id", CLUSTER, + "--compute-config-id", CONFIG, + "--instance-type", "cpx31", + "--idempotency-key", "captain-stable-key", + "--yes", + ], {}, deps(fetch)); +} + +async function runAkua(args: readonly string[], env: Record = {}) { + const childEnv = { ...process.env, ...env }; + delete childEnv.AKUA_OUTPUT; + if (!("AKUA_API_TOKEN" in env)) { + delete childEnv.AKUA_API_TOKEN; + } + const proc = Bun.spawn({ + cmd: ["bun", "src/bin/akua.ts", ...args], + stdout: "pipe", + stderr: "pipe", + env: childEnv, + }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + return { stdout, stderr, exitCode }; +} diff --git a/test/capacity-openapi.test.ts b/test/capacity-openapi.test.ts new file mode 100644 index 0000000..5f35b1a --- /dev/null +++ b/test/capacity-openapi.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from "bun:test"; + +type JsonObject = Record; + +const spec = (await Bun.file(new URL("../openapi/public.json", import.meta.url)).json()) as JsonObject; + +describe("capacity overlay OpenAPI contracts", () => { + test("reviewed read overlays retain their exact operations and bindings", () => { + expect(operation("/v1/clusters/{id}", "get")).toMatchObject({ + operationId: "clusters.get", + "x-platform-visibility": "PUBLIC", + parameters: [ + { name: "id", in: "path", required: true }, + { name: "akua-context", in: "header", required: false }, + ], + }); + const configs = operation("/v1/compute_configs", "get"); + expect(configs.operationId).toBe("computeConfigs.list"); + expect(bindings(configs)).toEqual([ + ["cursor", "query", false], + ["limit", "query", false], + ["view", "query", false], + ["akua-context", "header", false], + ]); + expect(configs.parameters[2].schema.enum).toEqual(["basic", "full"]); + + const instanceTypes = operation("/v1/compute/instance_types", "get"); + expect(instanceTypes.operationId).toBe("compute.listInstanceTypes"); + expect(bindings(instanceTypes)).toEqual([["config", "query", false]]); + + const machines = operation("/v1/machines", "get"); + expect(machines.operationId).toBe("machines.list"); + expect(bindings(machines)).toEqual([ + ["cursor", "query", false], + ["limit", "query", false], + ["cluster_id", "query", false], + ["state", "query", false], + ["view", "query", false], + ["akua-context", "header", false], + ]); + expect(machines.parameters[4].schema.enum).toEqual(["basic", "full"]); + }); + + test("machine creation remains the canonical closed idempotent contract", () => { + const create = operation("/v1/machines", "post"); + expect(create).toMatchObject({ + operationId: "machines.create", + "x-platform-visibility": "PUBLIC", + }); + expect(bindings(create)).toEqual([ + ["akua-context", "header", false], + ["idempotency-key", "header", false], + ]); + expect(create.responses["202"].content["application/json"].schema.$ref).toBe( + "#/components/schemas/OperationEnvelope", + ); + + const body = create.requestBody.content["application/json"].schema; + expect(body).toMatchObject({ + type: "object", + required: ["cluster_id", "instance_type", "compute_config_id"], + additionalProperties: false, + }); + expect(Object.keys(body.properties).sort()).toEqual([ + "cluster_id", + "compute_config_id", + "instance_type", + "name", + "node_claim", + ]); + }); +}); + +function operation(path: string, method: string): JsonObject { + const value = spec.paths?.[path]?.[method]; + expect(value).toBeDefined(); + return value as JsonObject; +} + +function bindings(value: JsonObject): Array<[string, string, boolean]> { + return value.parameters.map((parameter: JsonObject) => [parameter.name, parameter.in, parameter.required]); +}