diff --git a/README.md b/README.md index 2599cdf..5285ec8 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,65 @@ The default login is scoped to the current user and the authenticated identity is available to every v2 command. `login`, `logout`, `whoami`, and global `maintenance` are the only commands that run without a loaded project configuration. +### API Keys + +Create API keys for automation or other non-interactive clients. Keys belong to a +user and carry explicit capabilities; use the smallest capability set the task +needs. The `--workspace` option further restricts Kubernetes access to one +workspace. Capabilities describe *what* the key may do, while workspace scope +describes *where* its Kubernetes access applies; neither replaces the other. + +Valid capabilities are `kubernetes:read`, `kubernetes:write`, +`kubernetes:exec`, `users:read`, `users:write`, `sessions:revoke`, and +`platform:adopt`. For example, a deployment key that only reads and updates +resources in the `website` workspace can be created with: + +```bash +kuber users keys create deployer --capabilities kubernetes:read,kubernetes:write --workspace website +``` + +The default expiry is 90 days. Set `--expires-days` to an integer from 1 to 365, +or explicitly use `none` for a key that does not expire: + +```bash +kuber users keys create deployer --capabilities kubernetes:read --expires-days 30 +kuber users keys create deployer --capabilities kubernetes:read --expires-days none +``` + +The raw token is printed once at creation. Copy it directly into a secret +manager; it cannot be retrieved later. List keys (optionally filtered by owner) +to find the key ID, then revoke a compromised or retired key: + +```bash +kuber users keys ls +kuber users keys ls deployer +kuber users keys revoke deployer +``` + +Revoking a key immediately prevents it from authenticating. Do not place API +tokens in source control, command-line arguments, or committed configuration. + +### CI Integration + +Create a dedicated automation user/key with only the capabilities required by +the CI job, and set the token as a masked repository or organization secret +(for example, `KUBER_API_TOKEN`). Expose the secret as an environment variable +for the job step. Commands that use the v2 API can then authenticate with that +token without an interactive `kuber login`: + +```yaml +- name: Deploy with kuber + env: + KUBER_API_TOKEN: ${{ secrets.KUBER_API_TOKEN }} + run: kuber up +``` + +Use the CI provider's secret store, never commit the token or print it in logs. +For example, a workspace-scoped key with `kubernetes:read,kubernetes:write` +allows deployment operations in that workspace, but does not grant user +administration, exec, or platform adoption. Add a capability only when the job +needs that operation, and choose `--workspace` to limit its Kubernetes scope. + ### Roles and Authorization The server grants capabilities through three roles: diff --git a/command/up.ts b/command/up.ts index 22a3a0e..b561329 100644 --- a/command/up.ts +++ b/command/up.ts @@ -1,5 +1,5 @@ import { defineCommand } from "citty"; -import { Listr, type ListrTaskWrapper } from "listr2"; +import { Listr, ListrErrorTypes, type ListrTaskWrapper } from "listr2"; import { randomUUID } from "node:crypto"; import { isDeepStrictEqual } from "node:util"; import type { ComposeSpecification } from "../schema/docker.d"; @@ -765,34 +765,103 @@ export async function runUp( }, { title: "Build images", - rendererOptions: { outputBar: 10, persistentOutput: true }, + rendererOptions: { bottomBar: Infinity, persistentOutput: true }, enabled: async () => build && Object.values((await compose()).services ?? {}).some( (service) => service.build, ), task: async (taskCtx, task) => { - const result = await buildServices( - project, - taskCtx.compose!, - cwd, - { - progress: (message) => { - task.output = message; - }, - stream: task.stdout(), - }, - { - ...config, - request, - snapshot: taskCtx.snapshot, - workspaceRoot: taskCtx.workspaceRoot, - signal: task.signal, - }, + const services = Object.entries(taskCtx.compose!.services ?? {}) + .filter(([, service]) => service.build) + .map(([name]) => name); + const children = new Map< + string, + ListrTaskWrapper + >(); + const pending = new Map void>(); + const completed = new Map>( + services.map((name) => [ + name, + new Promise((resolve) => pending.set(name, resolve)), + ]), ); + let result: Awaited> | undefined; + let operationFailure: Error | undefined; + let operation: Promise | undefined; + const start = () => { + operation ??= buildServices( + project, + taskCtx.compose!, + cwd, + { + progress: (message) => { + task.output = message; + }, + service: (name) => { + let stream: ReturnType["stdout"]> | undefined; + return { + progress: (message) => { + const child = children.get(name); + if (child && child.output !== message) child.output = message; + }, + get stream() { + const child = children.get(name); + if (child) stream ??= child.stdout(); + return stream; + }, + }; + }, + settled: (name, error) => { + pending.get(name)?.(error); + pending.delete(name); + }, + }, + { + ...config, + request, + snapshot: taskCtx.snapshot, + workspaceRoot: taskCtx.workspaceRoot, + signal: task.signal, + }, + ).then( + (value) => { + result = value; + }, + (error: unknown) => { + const failure = + error instanceof Error ? error : new Error(String(error)); + operationFailure = failure; + task.output = failure.stack ?? failure.message; + task.report(failure, ListrErrorTypes.HAS_FAILED); + for (const resolve of pending.values()) resolve(failure); + pending.clear(); + }, + ); + return operation; + }; + await task + .newListr( + services.map((name) => ({ + title: `Build ${name}`, + rendererOptions: { outputBar: 10, persistentOutput: true }, + task: async (_ctx, child) => { + children.set(name, child); + child.output = "Build queued"; + if (children.size === services.length) start(); + const error = await completed.get(name); + if (error) throw error; + child.output = "done"; + }, + })), + { concurrent: true, exitOnError: false }, + ) + .run(); + await operation; + if (!result) throw operationFailure ?? new Error("Build images failed"); taskCtx.buildImages = result.images; await config.postBuild?.(result, await getHookContext()); - task.output = `Built ${result.built.length} image${result.built.length === 1 ? "" : "s"}`; + task.title = `Built ${result.built.length} image${result.built.length === 1 ? "" : "s"}`; }, }, { diff --git a/command/users.ts b/command/users.ts index c4d987b..f1fcedb 100644 --- a/command/users.ts +++ b/command/users.ts @@ -61,7 +61,9 @@ function parseCapabilities( "platform:adopt", ]); if (!capabilities.length || capabilities.some((item) => !allowed.has(item))) - throw new Error("Provide at least one valid capability"); + throw new Error( + "Provide valid capabilities (for example: --capabilities kubernetes:read,kubernetes:write; choices: kubernetes:read, kubernetes:write, kubernetes:exec, users:read, users:write, sessions:revoke, platform:adopt)", + ); return [...new Set(capabilities)] as CreateApiKeyRequest["capabilities"]; } @@ -202,22 +204,37 @@ function renderApiKeys(keys: ApiKey[]): string { return toTable( keys.map((key) => ({ id: key.id, + username: key.username, capabilities: key.capabilities.join(","), workspace: key.workspace ?? "", - expires: key.expiresAt, + expires: key.expiresAt ?? "never", disabled: key.disabled ? "yes" : "no", })), ); } export async function listApiKeys( - username: string, + username?: string, request: UsersApiRequest = apiRequest, ): Promise { - const response = await request( - `/users/${encodeURIComponent(username)}/keys`, + const usernames = username + ? [username] + : (await request("/users")).items.map( + (user) => user.username, + ); + const results = await Promise.all( + usernames.map((name) => + request(`/users/${encodeURIComponent(name)}/keys`), + ), + ); + return renderApiKeys( + results + .flatMap((result) => result.items) + .sort( + (a, b) => + a.username.localeCompare(b.username) || a.id.localeCompare(b.id), + ), ); - return renderApiKeys(response.items); } export async function createApiKey( @@ -225,6 +242,11 @@ export async function createApiKey( body: CreateApiKeyRequest, request: UsersApiRequest = apiRequest, ): Promise { + const user = await request( + `/users/${encodeURIComponent(username)}`, + ); + if (user.disabled) + throw new Error(`Cannot create API key: user '${username}' is inactive`); return request( `/users/${encodeURIComponent(username)}/keys`, { method: "POST", json: body }, @@ -318,39 +340,62 @@ const keys = defineCommand({ meta: { name: "keys", description: "Manage user API keys" }, subCommands: { ls: defineCommand({ - meta: { name: "ls", description: "List a user's API keys" }, + meta: { + name: "ls", + description: "List all API keys (optional positional username filters the results)", + }, async run({ args }) { - console.log(await listApiKeys(requireUsername(args._[0]))); + console.log(await listApiKeys(args._[0])); }, }), create: defineCommand({ - meta: { name: "create", description: "Create an API key" }, + meta: { + name: "create", + description: + "Create an API key for a user (e.g. kuber users keys create alice --capabilities kubernetes:read --expires-days none)", + }, args: { + username: { + type: "positional", + required: true, + description: "Username to own the API key", + }, capabilities: { type: "string", required: true, - description: "Comma-separated capabilities", + description: + "Comma-separated capabilities, e.g. kubernetes:read,kubernetes:write (also kubernetes:exec, users:read, users:write, sessions:revoke, platform:adopt)", }, workspace: { type: "string", description: "Restrict the key to a workspace", }, - expiresDays: { + "expires-days": { type: "string", default: "90", - description: "Expiry in days (1-365)", + description: "Expiry in days (1-365), or none for a non-expiring key", }, }, async run({ args }) { - const days = Number(args.expiresDays); - if (!Number.isSafeInteger(days) || days < 1 || days > 365) - throw new Error("--expires-days must be an integer from 1 to 365"); - const key = await createApiKey(requireUsername(args._[0]), { + const days = + args["expires-days"] === "none" + ? undefined + : Number(args["expires-days"]); + if ( + days !== undefined && + (!Number.isSafeInteger(days) || days < 1 || days > 365) + ) + throw new Error( + "--expires-days must be an integer from 1 to 365, or none", + ); + const key = await createApiKey(requireUsername(args.username), { capabilities: parseCapabilities(args.capabilities), ...(args.workspace && { workspace: args.workspace }), - expiresAt: new Date( - Date.now() + days * 24 * 60 * 60 * 1000, - ).toISOString(), + ...(days !== undefined && { + expiresAt: new Date( + Date.now() + days * 24 * 60 * 60 * 1000, + ).toISOString(), + }), }); console.log( "Store this API key securely now. It will not be shown again:", diff --git a/lib/build.ts b/lib/build.ts index e654427..c4ab13c 100644 --- a/lib/build.ts +++ b/lib/build.ts @@ -179,6 +179,9 @@ type ProgressReporter = (message: string) => void | Promise; type BuildReporter = { progress?: ProgressReporter; stream?: Writable; + /** Per-image output and lifecycle hooks, including for queued images. */ + service?: (name: string) => BuildReporter | undefined; + settled?: (name: string, error?: unknown) => void; }; export type BuildResult = { @@ -197,6 +200,7 @@ type ImageResult = { image: string; digest: Sha256Digest; reference: string; + references?: Record; }; function posixRelative(root: string, path: string): string { @@ -405,17 +409,22 @@ export async function uploadWorkspaceSnapshot( async function reportBuildEvent( event: BuildEvent, reporter?: BuildReporter, - reportedStates?: Set, + reportedStates?: Set, service?: string, -): Promise { +): Promise { if (event.type === "status") { - if (!reportedStates?.has(event.status.state)) { - reportedStates?.add(event.status.state); + const phase = + event.status.phase ?? + (event.status.state === "succeeded" || event.status.state === "failed" + ? "done" + : event.status.state); + if (!reportedStates?.has(phase)) { + reportedStates?.add(phase); await reporter?.progress?.( - `${service ? `${service}: ` : ""}Build ${event.status.state}`, + `${service ? `${service}: ` : ""}Build ${phase}${event.status.state === "failed" ? `: ${event.status.error ?? "unknown error"}` : ""}`, ); } - return 0; + return; } if (reporter?.stream) reporter.stream.write( @@ -425,7 +434,6 @@ async function reportBuildEvent( await reporter?.progress?.( `${service ? `${service}: ` : ""}${event.message.trimEnd()}`, ); - return event.sequence; } function isTransientBuildPollError(error: unknown): boolean { @@ -482,16 +490,26 @@ async function requestBuildPoll( async function waitForBuild( id: string, request: ApiRequester, - reporter: BuildReporter | undefined, + reporters: Array<{ name: string; reporter?: BuildReporter }>, pollIntervalMs: number, sleep: (milliseconds: number) => Promise, initial: BuildStatus, signal?: AbortSignal, - service?: string, + prefixService = false, ): Promise { let status = initial; let sequence = 0; - const reportedStates = new Set(); + let failureLog = ""; + // Each physical build belongs only to its destination services. A shared + // reporter (the caller's global sink) receives each event just once. + const owners = new Map; + }>(); + for (const { name, reporter } of reporters) + if (!owners.has(reporter)) + owners.set(reporter, { name, reporter, states: new Set() }); for (;;) { const events = await requestBuildPoll( request, @@ -501,13 +519,32 @@ async function waitForBuild( sleep, signal, ); - for (const event of events) - sequence = Math.max( - sequence, - await reportBuildEvent(event, reporter, reportedStates, service), - ); - if (status.state === "succeeded" || status.state === "failed") + for (const event of events) { + if (event.type === "log" && event.sequence <= sequence) continue; + if (event.type === "log") + failureLog = (failureLog + event.message).slice(-8_192); + for (const { name, reporter, states } of owners.values()) + await reportBuildEvent( + event, + reporter, + states, + prefixService ? name : undefined, + ); + if (event.type === "log") sequence = event.sequence; + } + if (status.state === "succeeded" || status.state === "failed") { + const details = failureLog.trim(); + if ( + status.state === "failed" && + details && + !status.error?.includes(details) + ) + return { + ...status, + error: `${status.error ?? "BuildKit Job failed"}\n${details}`, + }; return status; + } status = await requestBuildPoll( request, `/builds/${encodeURIComponent(id)}/reconcile`, @@ -545,24 +582,21 @@ export async function resolveBuildImages( const index = next++; if (index >= services.length) return; const service = services[index]!; - options.signal?.throwIfAborted(); - try { - references[index] = ( - await request( - "/images/resolve", - { - method: "POST", - json: { project, service }, - signal: options.signal, - }, - ) - ).reference; - } catch (error) { - throw new Error( - `Cannot resolve a published image for service ${service}. Run kuber up to build it.`, - { cause: error }, - ); - } + options.signal?.throwIfAborted(); + try { + references[index] = ( + await request("/images/resolve", { + method: "POST", + json: { project, service }, + signal: options.signal, + }) + ).reference; + } catch (error) { + throw new Error( + `Cannot resolve a published image for service ${service}. Run kuber up to build it.`, + { cause: error }, + ); + } } }, ), @@ -619,32 +653,80 @@ export async function buildServices( ); const references: string[] = new Array(plans.length); + const reporters = new Map( + plans.map((plan) => [ + plan.name, + reporter?.service?.(plan.name) ?? reporter, + ]), + ); + const architecture = resolveComposeArch(compose); + const groups = new Map(); + plans.forEach((plan, index) => { + const key = JSON.stringify({ + workspace: snapshot.digest, + architecture, + context: plan.context, + dockerfile: plan.dockerfile, + target: plan.target, + buildArgs: plan.buildArgs, + }); + const group = groups.get(key) ?? []; + group.push(index); + groups.set(key, group); + }); + const batches = [...groups.values()]; let next = 0; let failure: unknown; let failed = false; const worker = async () => { - while (!failed && next < plans.length) { - const index = next++; - const plan = plans[index]!; + while (!failed && next < batches.length) { + const batch = batches[next++]!; + const groupPlans = batch.map((index) => plans[index]!); try { options.signal?.throwIfAborted(); - await reporter?.progress?.(`Building ${plan.name}`); - references[index] = await buildPlan(plan); + for (const plan of groupPlans) { + await reporters + .get(plan.name) + ?.progress?.( + `${reporter?.service ? "" : `${plan.name}: `}Build queued`, + ); + } + const result = await buildPlan(groupPlans); + batch.forEach((index) => { + const plan = plans[index]!; + const reference = + index === batch[0] + ? result.reference + : result.references?.[plan.name]; + if (!reference) + throw new Error( + `Build result missing image for service ${plan.name}`, + ); + references[index] = reference; + }); + for (const plan of groupPlans) reporter?.settled?.(plan.name); } catch (error) { if (!failed) failure = error; failed = true; + for (const plan of groupPlans) reporter?.settled?.(plan.name, error); } } }; - const buildPlan = async (plan: BuildPlan): Promise => { + const buildPlan = async (groupPlans: BuildPlan[]): Promise => { + const plan = groupPlans[0]!; const id = randomUUID(); const buildRequest: BuildRequest = { version: BUILD_PROTOCOL_VERSION, id, project, service: plan.name, + ...(groupPlans.length > 1 && { + destinations: groupPlans + .slice(1) + .map(({ name, image }) => ({ service: name, image })), + }), spec: { - architecture: resolveComposeArch(compose), + architecture, image: plan.image, context: plan.context, dockerfile: plan.dockerfile, @@ -665,26 +747,27 @@ export async function buildServices( const status = await waitForBuild( id, request, - reporter, + groupPlans.map(({ name }) => ({ name, reporter: reporters.get(name) })), options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS, options.sleep ?? ((milliseconds) => Bun.sleep(milliseconds)), initial, options.signal, - plan.name, + !reporter?.service, ); if (status.state !== "succeeded") throw new Error( `Build failed for service ${plan.name}: ${status.error ?? "unknown error"}`, ); - return ( - await request(`/builds/${encodeURIComponent(id)}/result`, { + return await request( + `/builds/${encodeURIComponent(id)}/result`, + { signal: options.signal, - }) - ).reference; + }, + ); }; await Promise.all( - Array.from({ length: Math.min(concurrency, plans.length) }, worker), + Array.from({ length: Math.min(concurrency, batches.length) }, worker), ); if (failed) throw failure; const images = Object.fromEntries( diff --git a/lib/database.ts b/lib/database.ts index d801f9f..bdaab42 100644 --- a/lib/database.ts +++ b/lib/database.ts @@ -4,6 +4,27 @@ import type { ComposeSpecification, Service } from "../schema/docker.d"; import { LABELS } from "../const"; import { deleteResource, applyResource } from "./apply"; +export class DatabaseReconciliationError extends Error { + constructor(phase: string, error: unknown, claim?: PostgresClaim) { + const context = claim + ? ` for database ${claim.database} (service ${claim.service}, role ${claim.username})` + : ""; + const reason = error instanceof Error ? error.message : String(error); + // Provider errors can contain credentials or entire request bodies. Only + // expose a short, recognisable operational reason, never a raw response. + const knownReason = /^(forbidden|not found|conflict|permission denied|connection refused|timed out|timeout|unauthorized|unprocessable entity|service unavailable)\b/i.exec(reason); + const status = error && typeof error === "object" && "code" in error && + typeof error.code === "number" && error.code >= 400 && error.code < 600 + ? ` (HTTP ${error.code})` + : ""; + const safeReason = `${knownReason ? knownReason[1] : "Kubernetes request failed"}${status}`; + super(`Database reconciliation failed during ${phase}${context}: ${safeReason}`, { + cause: error, + }); + this.name = "DatabaseReconciliationError"; + } +} + export const DATABASE_NAMESPACE = "database"; export const DATABASE_CLUSTER = "postgres"; export const DATABASE_HOST = `c.${DATABASE_NAMESPACE}.svc.cluster.local`; @@ -189,33 +210,37 @@ async function readObject( async function ensureRoleSecret( claim: PostgresClaim, ): Promise { - const existing = await readObject({ - apiVersion: "v1", - kind: "Secret", - metadata: { - name: claim.secretName, - namespace: DATABASE_NAMESPACE, - }, - }); + try { + const existing = await readObject({ + apiVersion: "v1", + kind: "Secret", + metadata: { + name: claim.secretName, + namespace: DATABASE_NAMESPACE, + }, + }); - const username = claim.username; - const password = decodeSecretValue(existing?.data?.password) ?? randomUUID(); + const username = claim.username; + const password = decodeSecretValue(existing?.data?.password) ?? randomUUID(); - await applyResource({ - apiVersion: "v1", - kind: "Secret", - metadata: { - name: claim.secretName, - namespace: DATABASE_NAMESPACE, - }, - type: existing?.type ?? "Opaque", - stringData: { - username, - password, - }, - } satisfies V1Secret); + await applyResource({ + apiVersion: "v1", + kind: "Secret", + metadata: { + name: claim.secretName, + namespace: DATABASE_NAMESPACE, + }, + type: existing?.type ?? "Opaque", + stringData: { + username, + password, + }, + } satisfies V1Secret); - return { username, password }; + return { username, password }; + } catch (error) { + throw new DatabaseReconciliationError("role secret setup", error, claim); + } } function toManagedRole(claim: PostgresClaim): ManagedRole { @@ -247,20 +272,27 @@ async function reconcileManagedRoles( ): Promise { if (claims.length === 0) return; - const cluster = await readObject< - KubernetesObject & { spec?: { managed?: { roles?: ManagedRole[] } } } - >({ - apiVersion: "postgresql.cnpg.io/v1", - kind: "Cluster", - metadata: { - name: DATABASE_CLUSTER, - namespace: DATABASE_NAMESPACE, - }, - }); + let cluster: KubernetesObject & { spec?: { managed?: { roles?: ManagedRole[] } } } | undefined; + try { + cluster = await readObject< + KubernetesObject & { spec?: { managed?: { roles?: ManagedRole[] } } } + >({ + apiVersion: "postgresql.cnpg.io/v1", + kind: "Cluster", + metadata: { + name: DATABASE_CLUSTER, + namespace: DATABASE_NAMESPACE, + }, + }); + } catch (error) { + throw new DatabaseReconciliationError("cluster lookup", error, claims[0]); + } if (!cluster) { - throw new Error( - `CNPG cluster ${DATABASE_CLUSTER} was not found in namespace ${DATABASE_NAMESPACE}.`, + throw new DatabaseReconciliationError( + `CNPG cluster ${DATABASE_NAMESPACE}/${DATABASE_CLUSTER} lookup`, + new Error("Not found"), + claims[0], ); } @@ -287,9 +319,7 @@ async function reconcileManagedRoles( }, }); } catch (error) { - throw new Error( - `Failed to reconcile managed roles on ${DATABASE_NAMESPACE}/${DATABASE_CLUSTER}: ${error instanceof Error ? error.message : String(error)}`, - ); + throw new DatabaseReconciliationError("managed role update", error, claims[0]); } } @@ -329,9 +359,7 @@ async function reconcileDatabases( }, }); } catch (error) { - throw new Error( - `Failed to reconcile database ${claim.database} owned by ${claim.username}: ${error instanceof Error ? error.message : String(error)}`, - ); + throw new DatabaseReconciliationError("database apply", error, claim); } } } diff --git a/server/app.ts b/server/app.ts index a1b3a2d..e4878e0 100644 --- a/server/app.ts +++ b/server/app.ts @@ -1,6 +1,7 @@ import type { KubernetesObject } from "@kubernetes/client-node"; import { randomUUID } from "node:crypto"; import type { ComposeSpecification } from "../schema/docker.d"; +import { DatabaseReconciliationError } from "../lib/database"; import type { Operation as PublicOperation } from "../shared/api"; import type { BuildRequest, Sha256Digest } from "../shared/build-protocol"; import { @@ -87,7 +88,6 @@ const MAX_LOGIN_FAILURES = 5; const DEFAULT_JSON_LIMIT = 1024 * 1024; const WORKSPACE_LEASE_TTL_MS = 30_000; const WORKSPACE_LEASE_RENEW_INTERVAL_MS = WORKSPACE_LEASE_TTL_MS / 3; -const DEFAULT_API_KEY_MS = 90 * 24 * 60 * 60 * 1000; const MAX_API_KEY_MS = 365 * 24 * 60 * 60 * 1000; export interface ApiWorkspaceStore extends WorkspaceStore { @@ -248,7 +248,7 @@ export async function authenticateRequest( if ( !apiKey || !tokenHashesEqual(tokenHash, apiKey.tokenHash) || - Date.parse(apiKey.expiresAt) <= now() + (apiKey.expiresAt !== undefined && Date.parse(apiKey.expiresAt) <= now()) ) return; const user = await options.store.getUser(apiKey.username); @@ -725,11 +725,17 @@ export function createApp( username: string, capabilities: readonly Capability[], workspace: string | undefined, + expiresAt: string | undefined, ): Promise { const parent = identity.apiKey; if (!parent) return; - let reason: "target_user" | "capabilities" | "workspace" | undefined; + let reason: + | "target_user" + | "capabilities" + | "workspace" + | "expiry" + | undefined; if (username !== parent.username) reason = "target_user"; else if ( !capabilities.every((capability) => @@ -739,6 +745,12 @@ export function createApp( reason = "capabilities"; else if (parent.workspace !== undefined && workspace !== parent.workspace) reason = "workspace"; + else if ( + parent.expiresAt !== undefined && + (expiresAt === undefined || + Date.parse(expiresAt) > Date.parse(parent.expiresAt)) + ) + reason = "expiry"; if (!reason) return; await audit(identity, request, "api_key.create", "denied", { @@ -746,12 +758,13 @@ export function createApp( username, capabilities, ...(workspace && { workspace }), + ...(expiresAt && { expiresAt }), }); throw new HttpError( 403, "Forbidden", "API_KEY_DELEGATION_FORBIDDEN", - "API key children must use the caller's user, capabilities, and workspace scope", + "API key children must use the caller's user, capabilities, workspace, and expiry scope", ); } @@ -1144,10 +1157,18 @@ export function createApp( const failed = await transitionOperationToFailure( operation.metadata.name, { - code: leaseLost ? "WORKSPACE_LEASE_LOST" : "OPERATION_FAILED", + code: leaseLost + ? "WORKSPACE_LEASE_LOST" + : action === "databases.reconcile" && + error instanceof DatabaseReconciliationError + ? "DATABASE_RECONCILE_FAILED" + : "OPERATION_FAILED", message: leaseLost ? "Workspace operation lease ownership was lost" - : message, + : action === "databases.reconcile" && + !(error instanceof DatabaseReconciliationError) + ? "Database reconciliation failed" + : message, }, ); const failure = failed.status.error; @@ -1572,7 +1593,7 @@ export function createApp( username: key.username, capabilities: key.capabilities, ...(key.workspace && { workspace: key.workspace }), - expiresAt: key.expiresAt, + ...(key.expiresAt !== undefined && { expiresAt: key.expiresAt }), disabled: Boolean(key.disabled), })), }); @@ -1580,13 +1601,9 @@ export function createApp( if (!keyId && request.method === "POST") { await requireCapability(identity, request, "users:write"); const body = await readJson(request); - const expiresAt = - body.expiresAt === undefined - ? new Date(now() + DEFAULT_API_KEY_MS).toISOString() - : typeof body.expiresAt === "string" - ? body.expiresAt - : ""; - const expires = new Date(expiresAt); + const expiresAt = body.expiresAt; + const expires = + typeof expiresAt === "string" ? new Date(expiresAt) : undefined; if ( !Array.isArray(body.capabilities) || body.capabilities.length === 0 || @@ -1596,16 +1613,19 @@ export function createApp( (typeof body.workspace !== "string" || !/^[a-z0-9](?:[-a-z0-9]*[a-z0-9])?$/.test(body.workspace) || body.workspace.length > 63)) || - !Number.isFinite(expires.getTime()) || - expires.toISOString() !== expiresAt || - expires.getTime() <= now() || - expires.getTime() > now() + MAX_API_KEY_MS + (expiresAt !== undefined && + (typeof expiresAt !== "string" || + !expires || + !Number.isFinite(expires.getTime()) || + expires.toISOString() !== expiresAt || + expires.getTime() <= now() || + expires.getTime() > now() + MAX_API_KEY_MS)) ) throw new HttpError( 400, "Invalid API key", "API_KEY_INVALID", - "Capabilities and an expiry no more than 365 days away are required", + "Valid capabilities and an optional expiry no more than 365 days away are required", ); await requireApiKeyDelegation( identity, @@ -1615,6 +1635,7 @@ export function createApp( typeof body.workspace === "string" && body.workspace ? body.workspace : undefined, + expiresAt as string | undefined, ); const token = createToken(); const key: ApiKeyRecord = { @@ -1624,7 +1645,7 @@ export function createApp( capabilities: body.capabilities, ...(typeof body.workspace === "string" && body.workspace && { workspace: body.workspace }), - expiresAt, + ...(typeof expiresAt === "string" && { expiresAt }), }; if (!(await options.store.getUser(username))) throw new HttpError( @@ -1639,7 +1660,7 @@ export function createApp( keyId: key.id, capabilities: key.capabilities, ...(key.workspace && { workspace: key.workspace }), - expiresAt: key.expiresAt, + ...(key.expiresAt !== undefined && { expiresAt: key.expiresAt }), }); return response( { @@ -1647,7 +1668,7 @@ export function createApp( username, capabilities: key.capabilities, ...(key.workspace && { workspace: key.workspace }), - expiresAt: key.expiresAt, + ...(key.expiresAt !== undefined && { expiresAt: key.expiresAt }), disabled: false, token, }, diff --git a/server/auth.ts b/server/auth.ts index cba7756..0c7293f 100644 --- a/server/auth.ts +++ b/server/auth.ts @@ -36,7 +36,7 @@ export type ApiKeyRecord = { username: string; capabilities: Capability[]; workspace?: string; - expiresAt: string; + expiresAt?: string; disabled?: boolean; }; @@ -129,12 +129,14 @@ export function normalizeApiKey(key: NewApiKey): ApiKeyRecord { ) { throw new Error("API key workspace scope is invalid"); } - const expiresAt = new Date(key.expiresAt); - if ( - !Number.isFinite(expiresAt.getTime()) || - expiresAt.toISOString() !== key.expiresAt - ) { - throw new Error("API key expiration must be an ISO timestamp"); + if (key.expiresAt !== undefined) { + const expiresAt = new Date(key.expiresAt); + if ( + !Number.isFinite(expiresAt.getTime()) || + expiresAt.toISOString() !== key.expiresAt + ) { + throw new Error("API key expiration must be an ISO timestamp"); + } } return { ...key, @@ -279,7 +281,12 @@ export class MemoryAuthStore implements AuthStore { async getApiKey(tokenHash: string): Promise { const key = this.apiKeysByTokenHash.get(tokenHash); - if (!key || key.disabled || Date.parse(key.expiresAt) <= Date.now()) return; + if ( + !key || + key.disabled || + (key.expiresAt !== undefined && Date.parse(key.expiresAt) <= Date.now()) + ) + return; const user = this.users.get(key.username); if (!user || user.disabled) return; return key; @@ -309,7 +316,7 @@ export class MemoryAuthStore implements AuthStore { async deleteExpiredApiKeys(now = Date.now()): Promise { const expired = [...this.apiKeys.values()].filter( - (key) => Date.parse(key.expiresAt) <= now, + (key) => key.expiresAt !== undefined && Date.parse(key.expiresAt) <= now, ); for (const key of expired) this.deleteApiKey(key.id); return expired.length; diff --git a/server/build-controller.ts b/server/build-controller.ts index 884e1c7..333855f 100644 --- a/server/build-controller.ts +++ b/server/build-controller.ts @@ -38,7 +38,13 @@ export interface BuildCas extends MaterializeCas { put(data: Uint8Array, expected?: Sha256Digest): Promise; } -export type JobPhase = "queued" | "running" | "succeeded" | "failed"; +export type JobPhase = + | "queued" + | "creating" + | "starting" + | "running" + | "succeeded" + | "failed"; export interface BuildJobObservation { phase: JobPhase; @@ -187,7 +193,41 @@ function validateRequest(request: BuildRequest): void { throw new BuildValidationError("Build arguments must be strings"); } assertSha256Digest(request.spec.workspace); + if ( + [ + request.spec.image, + ...(Array.isArray(request.destinations) + ? request.destinations.map((destination) => destination?.image) + : []), + ].some((image) => typeof image === "string" && /[,"\r\n]/.test(image)) + ) + throw new BuildValidationError( + "Build image cannot contain BuildKit output separators", + ); parseImageReference(request.spec.image); + if (request.destinations !== undefined) { + if (!Array.isArray(request.destinations)) + throw new BuildValidationError("Build destinations must be an array"); + const services = new Set([request.service]); + const images = new Set([request.spec.image]); + for (const destination of request.destinations) { + if ( + !destination || + typeof destination.service !== "string" || + !/^[a-z0-9](?:[-a-z0-9]*[a-z0-9])?$/.test(destination.service) || + destination.service.length > 63 || + services.has(destination.service) || + typeof destination.image !== "string" || + images.has(destination.image) + ) + throw new BuildValidationError( + "Build destinations must have unique services and images", + ); + parseImageReference(destination.image); + services.add(destination.service); + images.add(destination.image); + } + } } function recordStatus(record: BuildRecord): BuildStatus { @@ -195,6 +235,7 @@ function recordStatus(record: BuildRecord): BuildStatus { version, id, state, + phase, createdAt, startedAt, finishedAt, @@ -205,6 +246,7 @@ function recordStatus(record: BuildRecord): BuildStatus { version, id, state, + ...(phase && { phase }), createdAt, ...(startedAt && { startedAt }), ...(finishedAt && { finishedAt }), @@ -381,8 +423,32 @@ export class BuildController { async submitBuild(request: BuildRequest): Promise { request = clone(request); + if ( + request.destinations !== undefined && + !Array.isArray(request.destinations) + ) + throw new BuildValidationError("Build destinations must be an array"); + if ( + request.destinations?.some( + (destination) => + !destination || + typeof destination.service !== "string" || + typeof destination.image !== "string", + ) + ) + throw new BuildValidationError("Invalid build destination"); if (this.options.imageName) request.spec.image = this.options.imageName(request); + if (this.options.imageName && request.destinations) + request.destinations = request.destinations.map((destination) => ({ + service: destination.service, + image: this.options.imageName!({ + ...request, + service: destination.service, + spec: { ...request.spec, image: destination.image }, + destinations: undefined, + }), + })); validateRequest(request); const imageKey = `${request.project}\0${request.service}\0${request.spec.image}`; const previous = @@ -417,6 +483,7 @@ export class BuildController { version: BUILD_PROTOCOL_VERSION, id: request.id, state: "queued", + phase: "queued", createdAt, }; const record: BuildRecord = { @@ -492,6 +559,16 @@ export class BuildController { const pushImage = this.options.pushImage ? this.options.pushImage(request) : request.spec.image; + const pushImages = request.destinations?.map((destination) => + this.options.pushImage + ? this.options.pushImage({ + ...request, + service: destination.service, + spec: { ...request.spec, image: destination.image }, + destinations: undefined, + }) + : destination.image, + ); const job = createBuildJob({ name: jobName, namespace: this.options.namespace, @@ -503,6 +580,7 @@ export class BuildController { ), cacheImage, pushImage, + pushImages, pushRegistryInsecure: this.options.pushRegistryInsecure, cacheRegistryInsecure: this.options.cacheRegistryInsecure ?? @@ -683,14 +761,23 @@ export class BuildController { record.spec.jobName, ); if (!observation) return recordStatus(record); - if (observation.phase === "running" && record.status.state === "queued") { + if ( + (observation.phase === "queued" || + observation.phase === "creating" || + observation.phase === "starting" || + observation.phase === "running") && + (record.status.state === "queued" || observation.phase === "running") + ) { await requireLeaseOwnership(); record = await this.setState( record, - "running", - { - startedAt: observation.startedAt ?? this.now().toISOString(), - }, + observation.phase === "running" ? "running" : "queued", + observation.phase === "running" + ? { + phase: "running", + startedAt: observation.startedAt ?? this.now().toISOString(), + } + : { phase: observation.phase }, reconcileLease, ); } else if (observation.phase === "failed") { @@ -702,6 +789,7 @@ export class BuildController { startedAt: record.status.startedAt ?? observation.startedAt, finishedAt: observation.finishedAt ?? this.now().toISOString(), error: observation.error ?? "BuildKit Job failed", + phase: "done", }, reconcileLease, ); @@ -710,6 +798,14 @@ export class BuildController { try { digest = await this.digestResolver(record.spec.request.spec.image); assertSha256Digest(digest); + for (const destination of record.spec.request.destinations ?? []) { + const aliasDigest = await this.digestResolver(destination.image); + assertSha256Digest(aliasDigest); + if (aliasDigest !== digest) + throw new Error( + `Pushed image ${destination.image} has a different digest`, + ); + } } catch (error) { await requireLeaseOwnership(); record = await this.setState( @@ -718,6 +814,7 @@ export class BuildController { { finishedAt: this.now().toISOString(), error: `Unable to resolve pushed image digest: ${error instanceof Error ? error.message : String(error)}`, + phase: "done", }, reconcileLease, ); @@ -731,6 +828,7 @@ export class BuildController { startedAt: record.status.startedAt ?? observation.startedAt, finishedAt: observation.finishedAt ?? this.now().toISOString(), digest, + phase: "done", }, reconcileLease, ); @@ -783,6 +881,7 @@ export class BuildController { const next = await this.setState(record, "failed", { finishedAt: this.now().toISOString(), error: "Build cancelled", + phase: "done", cancelled: true, }); return recordStatus(next); @@ -799,9 +898,12 @@ export class BuildController { }); } - async getBuildResult( - id: string, - ): Promise<{ image: string; digest: Sha256Digest; reference: string }> { + async getBuildResult(id: string): Promise<{ + image: string; + digest: Sha256Digest; + reference: string; + references?: Record; + }> { const record = await this.requireBuild(id); if (record.status.state !== "succeeded" || !record.status.digest) throw new BuildConflictError("Build has no immutable image result"); @@ -811,6 +913,17 @@ export class BuildController { image, digest: record.status.digest, reference: `${image}@${record.status.digest}`, + ...(record.spec.request.destinations?.length && { + references: Object.fromEntries( + record.spec.request.destinations.map((destination) => { + const alias = parseImageReference(destination.image); + return [ + destination.service, + `${alias.registry}/${alias.repository}@${record.status.digest}`, + ]; + }), + ), + }), }; } @@ -857,7 +970,8 @@ export class BuildController { values: Partial, reconcileLease?: BuildReconciliationLease, ): Promise { - if (record.status.state === state && state === "running") return record; + if (record.status.state === state && record.status.phase === values.phase) + return record; return this.updateBuild( record, (next) => { @@ -878,6 +992,7 @@ export class BuildController { await this.setState(current, "failed", { finishedAt: this.now().toISOString(), error: message, + phase: "done", }); } diff --git a/server/build-job.ts b/server/build-job.ts index fa29200..17af4c8 100644 --- a/server/build-job.ts +++ b/server/build-job.ts @@ -8,6 +8,7 @@ export type BuildJobOptions = { workspaceSubPath?: string; cacheImage: string; pushImage?: string; + pushImages?: string[]; pushRegistryInsecure?: boolean; cacheRegistryInsecure?: boolean; buildkitImage?: string; @@ -72,6 +73,9 @@ export function createBuildJob(options: BuildJobOptions): KubernetesJob { ? `${workspace}/${dockerfilePath}` : `${context}/Dockerfile`; const outputImage = options.pushImage ?? options.spec.image; + const outputImages = [outputImage, ...(options.pushImages ?? [])]; + if (outputImages.some((image) => !image || /[,"\r\n]/.test(image))) + throw new Error("Invalid BuildKit output image name"); const importCacheInsecure = options.cacheRegistryInsecure ? ",registry.insecure=true" : ""; @@ -92,7 +96,7 @@ export function createBuildJob(options: BuildJobOptions): KubernetesJob { ...options.spec.buildArgs.map((arg) => `--opt=build-arg:${arg}`), `--import-cache=type=registry,ref=${options.cacheImage}${importCacheInsecure}`, `--export-cache=type=registry,ref=${options.cacheImage},mode=max${exportCacheInsecure}`, - `--output=type=image,name=${outputImage},push=true${outputInsecure}`, + `--output=type=image,${outputImages.length === 1 ? `name=${outputImage}` : `"name=${outputImages.join(",")}"`},push=true${outputInsecure}`, ]; const labels = { "app.kubernetes.io/name": "kuber-buildkit", diff --git a/server/build-kubernetes.ts b/server/build-kubernetes.ts index 42154bc..be81221 100644 --- a/server/build-kubernetes.ts +++ b/server/build-kubernetes.ts @@ -887,18 +887,42 @@ export class KubernetesBuildOperations implements BuildKubernetesOperations { (condition) => condition.type === "Complete" && condition.status === "True", ); - const phase = failed - ? "failed" - : complete - ? "succeeded" - : (job.status?.active ?? 0) > 0 + let phase: BuildJobObservation["phase"] = "queued"; + let podError: string | undefined; + let containerStartedAt: string | undefined; + if (!failed && !complete) { + const pods = await this.core.listNamespacedPod({ + namespace, + labelSelector: `job-name=${name}`, + }); + const pod = pods.items + .sort( + (a, b) => + (a.metadata?.creationTimestamp?.getTime() ?? 0) - + (b.metadata?.creationTimestamp?.getTime() ?? 0), + ) + .at(-1); + const container = pod?.status?.containerStatuses?.find( + (entry) => entry.name === "buildkit", + ); + phase = + pod?.status?.phase === "Running" && container?.state?.running ? "running" - : "queued"; + : !pod || !pod.spec?.nodeName + ? "creating" + : "starting"; + podError = + container?.state?.terminated?.message ?? + container?.state?.waiting?.message; + containerStartedAt = container?.state?.running?.startedAt?.toISOString(); + } else phase = failed ? "failed" : "succeeded"; return { phase, - startedAt: job.status?.startTime?.toISOString(), + startedAt: phase === "running" ? containerStartedAt : undefined, finishedAt: job.status?.completionTime?.toISOString(), - ...(failed?.message && { error: failed.message }), + ...((failed?.message || podError) && { + error: failed?.message ?? podError, + }), }; } diff --git a/server/kubernetes-store.ts b/server/kubernetes-store.ts index 44ca185..e196dfb 100644 --- a/server/kubernetes-store.ts +++ b/server/kubernetes-store.ts @@ -191,15 +191,22 @@ function parseApiKey(secret: SecretObject): ApiKeyRecord | undefined { "username", "capabilities", "workspace", - "expiresAt", "disabled", ]; - if (!secret.data || !hasOnlyKeys(secret.data, keys)) return; + if ( + !secret.data || + !hasOnlyKeys( + secret.data, + secret.data.expiresAt === undefined ? keys : [...keys, "expiresAt"], + ) + ) + return; const id = decode(secret.data.id); const tokenHash = decode(secret.data.tokenHash); const username = decode(secret.data.username); const capabilities = parseCapabilities(secret.data.capabilities); - const workspace = decode(secret.data.workspace); + const workspace = + secret.data.workspace === "" ? "" : decode(secret.data.workspace); const expiresAt = decode(secret.data.expiresAt); const disabled = decode(secret.data.disabled); if ( @@ -207,7 +214,7 @@ function parseApiKey(secret: SecretObject): ApiKeyRecord | undefined { !tokenHash || !username || !capabilities || - !expiresAt || + (secret.data.expiresAt !== undefined && !expiresAt) || (workspace !== "" && workspace === undefined) || (disabled !== "true" && disabled !== "false") || secret.metadata?.name !== objectName("api-key", tokenHash) @@ -220,7 +227,7 @@ function parseApiKey(secret: SecretObject): ApiKeyRecord | undefined { username, capabilities, ...(workspace && { workspace }), - expiresAt, + ...(expiresAt !== undefined && { expiresAt }), disabled: disabled === "true", }); } catch { @@ -459,7 +466,12 @@ export class KubernetesAuthStore implements AuthStore { const key = (await this.listSecrets("api-key")) .map(parseApiKey) .find((item): item is ApiKeyRecord => item?.tokenHash === tokenHash); - if (!key || key.disabled || Date.parse(key.expiresAt) <= Date.now()) return; + if ( + !key || + key.disabled || + (key.expiresAt !== undefined && Date.parse(key.expiresAt) <= Date.now()) + ) + return; const user = await this.getUser(key.username); if (!user || user.disabled) return; return key; @@ -478,7 +490,9 @@ export class KubernetesAuthStore implements AuthStore { username: normalized.username, capabilities: JSON.stringify(normalized.capabilities), workspace: normalized.workspace ?? "", - expiresAt: normalized.expiresAt, + ...(normalized.expiresAt !== undefined && { + expiresAt: normalized.expiresAt, + }), disabled: String(Boolean(normalized.disabled)), }, ); @@ -505,7 +519,9 @@ export class KubernetesAuthStore implements AuthStore { .map(parseApiKey) .filter( (key): key is ApiKeyRecord => - key !== undefined && Date.parse(key.expiresAt) <= now, + key !== undefined && + key.expiresAt !== undefined && + Date.parse(key.expiresAt) <= now, ); for (const key of expired) await this.deleteSecret(objectName("api-key", key.tokenHash)); diff --git a/server/operation-store.ts b/server/operation-store.ts index 066b50a..c0ab76e 100644 --- a/server/operation-store.ts +++ b/server/operation-store.ts @@ -237,7 +237,9 @@ export function sanitizeOperationError( ): OperationError { const message = action === "databases.reconcile" - ? "Database reconciliation failed" + ? error.code === "DATABASE_RECONCILE_FAILED" + ? redactString(error.message) + : "Database reconciliation failed" : action === "storage.reconcile" ? "Storage reconciliation failed" : redactString(error.message); diff --git a/server/registry.ts b/server/registry.ts index f8aa8d3..9b5a021 100644 --- a/server/registry.ts +++ b/server/registry.ts @@ -43,7 +43,9 @@ export type ParsedImageReference = { function validateRepository(repository: string, image: string): void { if ( !repository || - repository.split("/").some((part) => !part || part === "." || part === "..") + repository + .split("/") + .some((part) => !/^[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*$/.test(part)) ) throw new Error(`Invalid image reference: ${image}`); } @@ -63,29 +65,26 @@ export function parseImageReference(image: string): ParsedImageReference { throw new Error(`Invalid image reference: ${image}`); const at = repositoryAndReference.lastIndexOf("@"); + let digest: Sha256Digest | undefined; if (at !== -1) { const value = repositoryAndReference.slice(at + 1); assertSha256Digest(value); + digest = value; repositoryAndReference = repositoryAndReference.slice(0, at); - validateRepository(repositoryAndReference, image); - return { - registry, - repository: repositoryAndReference, - reference: value, - digest: value, - }; } const lastSlash = repositoryAndReference.lastIndexOf("/"); const colon = repositoryAndReference.lastIndexOf(":"); - const reference = - colon > lastSlash ? repositoryAndReference.slice(colon + 1) : "latest"; + const tag = + colon > lastSlash ? repositoryAndReference.slice(colon + 1) : undefined; const repository = - colon > lastSlash + tag !== undefined ? repositoryAndReference.slice(0, colon) : repositoryAndReference; validateRepository(repository, image); - if (!reference) throw new Error(`Invalid image reference: ${image}`); - return { registry, repository, reference }; + if (tag !== undefined && !/^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/.test(tag)) + throw new Error(`Invalid image reference: ${image}`); + if (digest) return { registry, repository, reference: digest, digest }; + return { registry, repository, reference: tag ?? "latest" }; } function bearerParameters( diff --git a/shared/api.ts b/shared/api.ts index 0fabe5d..7074004 100644 --- a/shared/api.ts +++ b/shared/api.ts @@ -244,7 +244,8 @@ export type ApiKey = { username: string; capabilities: Capability[]; workspace?: string; - expiresAt: string; + /** Absent for a key that never expires. */ + expiresAt?: string; disabled: boolean; }; export type CreateApiKeyRequest = { diff --git a/shared/build-protocol.ts b/shared/build-protocol.ts index 0a2cd75..b7e8fa7 100644 --- a/shared/build-protocol.ts +++ b/shared/build-protocol.ts @@ -32,14 +32,24 @@ export type BuildRequest = { project: string; service: string; spec: BuildSpec; + /** Additional service destinations for the same build artifact. */ + destinations?: Array<{ service: string; image: string }>; }; export type BuildState = "queued" | "running" | "succeeded" | "failed"; +export type BuildPhase = + | "queued" + | "creating" + | "starting" + | "running" + | "done"; export type BuildStatus = { version: typeof BUILD_PROTOCOL_VERSION; id: string; state: BuildState; + /** Optional lifecycle detail; absent on older persisted builds and servers. */ + phase?: BuildPhase; createdAt: string; startedAt?: string; finishedAt?: string; diff --git a/tests/command/administration.test.ts b/tests/command/administration.test.ts index b023b45..db2cf44 100644 --- a/tests/command/administration.test.ts +++ b/tests/command/administration.test.ts @@ -12,6 +12,7 @@ import { revokeUserSessions, setUserDisabled, updateUser, + users, } from "../../command/users"; import type { ApiRequestInit } from "../../lib/api"; @@ -130,7 +131,10 @@ describe("user administration commands", () => { await createApiKey( "alice", { capabilities: ["kubernetes:write"] }, - requestReturning(key, calls), + async (path: string, init?: ApiRequestInit): Promise => { + calls.push({ path, init }); + return (init ? key : user) as T; + }, ); expect( await revokeApiKey( @@ -148,6 +152,7 @@ describe("user administration commands", () => { ); expect(calls).toEqual([ { path: "/users/alice%2Fexample/keys", init: undefined }, + { path: "/users/alice", init: undefined }, { path: "/users/alice/keys", init: { method: "POST", json: { capabilities: ["kubernetes:write"] } }, @@ -158,6 +163,157 @@ describe("user administration commands", () => { }, ]); }); + + test("lists all users' keys without a username and filters with one", async () => { + const calls: Call[] = []; + const request = async ( + path: string, + init?: ApiRequestInit, + ): Promise => { + calls.push({ path, init }); + if (path === "/users") + return { items: [user, { ...user, username: "bob" }] } as T; + return { + items: [ + { + id: `${path.includes("bob") ? "bob" : "alice"}-key`, + username: path.includes("bob") ? "bob" : "alice", + capabilities: ["kubernetes:read"], + disabled: false, + }, + ], + } as T; + }; + const all = await listApiKeys(undefined, request); + expect(all).toContain("alice-key"); + expect(all).toContain("bob-key"); + expect(all).toContain("never"); + expect(calls.map((call) => call.path)).toEqual([ + "/users", + "/users/alice/keys", + "/users/bob/keys", + ]); + calls.length = 0; + expect(await listApiKeys("alice", request)).not.toContain("bob-key"); + expect(calls.map((call) => call.path)).toEqual(["/users/alice/keys"]); + }); + + test("key creation requires an existing active user", async () => { + const calls: Call[] = []; + const body = { capabilities: ["kubernetes:read" as const] }; + await expect( + createApiKey("missing", body, async (path) => { + calls.push({ path }); + throw new Error("User 'missing' not found"); + }), + ).rejects.toThrow("User 'missing' not found"); + expect(calls).toEqual([{ path: "/users/missing" }]); + await expect( + createApiKey( + "alice", + body, + requestReturning({ ...user, disabled: true }, calls), + ), + ).rejects.toThrow("user 'alice' is inactive"); + expect(calls.at(-1)?.path).toBe("/users/alice"); + }); + + test("creates non-expiring and finite API keys with the requested POST bodies", async () => { + const calls: Call[] = []; + const key = { + id: "key-identifier-123", + username: "alice", + capabilities: ["kubernetes:read"] as const, + disabled: false, + token: "shown-once-token", + }; + const request = async ( + path: string, + init?: ApiRequestInit, + ): Promise => { + calls.push({ path, init }); + return (init ? key : user) as T; + }; + + await createApiKey( + "alice", + { capabilities: ["kubernetes:read"], workspace: "team/shop" }, + request, + ); + const expiresAt = "2026-12-01T00:00:00.000Z"; + await createApiKey( + "alice", + { + capabilities: ["kubernetes:read"], + workspace: "team/shop", + expiresAt, + }, + request, + ); + + expect(calls).toEqual([ + { path: "/users/alice", init: undefined }, + { + path: "/users/alice/keys", + init: { + method: "POST", + json: { capabilities: ["kubernetes:read"], workspace: "team/shop" }, + }, + }, + { path: "/users/alice", init: undefined }, + { + path: "/users/alice/keys", + init: { + method: "POST", + json: { + capabilities: ["kubernetes:read"], + workspace: "team/shop", + expiresAt, + }, + }, + }, + ]); + expect(calls[1]?.init?.json).not.toHaveProperty("expiresAt"); + }); + + test("key command help declares username, capability examples, and non-expiry", async () => { + const commands = (users.subCommands as Record)?.keys; + if (!commands || typeof commands === "function") + throw new Error("Missing keys command"); + const subCommands = await Promise.resolve(commands.subCommands); + const create = subCommands?.create; + const ls = subCommands?.ls; + if ( + !create || + typeof create === "function" || + !ls || + typeof ls === "function" + ) + throw new Error("Missing key subcommands"); + expect(create.args?.username).toMatchObject({ + type: "positional", + required: true, + }); + expect(create.args?.capabilities?.description).toContain( + "kubernetes:read,kubernetes:write", + ); + expect(create.args?.["expires-days"]?.description).toContain("none"); + expect(ls.meta?.description).toContain("optional positional username"); + await expect( + create.run?.({ + args: { username: "alice", capabilities: "invalid", "expires-days": "90" }, + } as never), + ).rejects.toThrow("kubernetes:read,kubernetes:write"); + await expect( + create.run?.({ + args: { + username: "alice", + capabilities: "kubernetes:read", + "expires-days": "NaN", + }, + } as never), + ).rejects.toThrow("1 to 365, or none"); + }); }); const operation = { diff --git a/tests/command/up-api.test.ts b/tests/command/up-api.test.ts index b6dbb75..2f0a7be 100644 --- a/tests/command/up-api.test.ts +++ b/tests/command/up-api.test.ts @@ -1,8 +1,9 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; import { Listr } from "listr2"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { Writable } from "node:stream"; import type { ApiRequestInit, ApiRequestOptions } from "../../lib/api"; import { KuberApiError } from "../../lib/api"; import type { ApiRequester } from "../../lib/build"; @@ -25,6 +26,104 @@ const snapshot = { }; describe("up API pipeline", () => { + test("attaches each build log to its started image child", async () => { + const root = await mkdtemp(join(tmpdir(), "kuber-up-api-")); + const previousCwd = process.cwd(); + const originalRun = Listr.prototype.run; + const lines = new Map(); + const acquired: string[] = []; + const runSpy = spyOn(Listr.prototype, "run").mockImplementation(function (this: Listr) { + if (this.tasks[0]?.title === "Build web") { + for (const entry of this.tasks) { + const name = entry.title!.replace(/^Build /, ""); + const executable = entry as unknown as { taskFn: typeof entry.task.task }; + const originalTask = executable.taskFn; + executable.taskFn = async (ctx, child) => { + const output: string[] = []; + lines.set(name, output); + child.stdout = () => { + acquired.push(name); + return new Writable({ write(chunk, _encoding, done) { + output.push(String(chunk)); + done(); + } }); + }; + return originalTask(ctx, child); + }; + } + } + return originalRun.call(this); + }); + try { + await writeFile(join(root, "compose.yml"), + "services:\n web:\n build: .\n worker:\n build: .\n admin:\n build:\n context: .\n args:\n ROLE: admin\n"); + await writeFile(join(root, ".kuberrc.ts"), 'export default { project: "shop" };\n'); + process.chdir(root); + const trust = await resolveTrustIdentity("shop", root); + const builds = new Map(); + const request: ApiRequester = async (path: string, init?: ApiRequestInit) => { + if (path === "/snapshots/negotiate") return { ready: true } as T; + if (path === "/builds") { + const build = init!.json as { id: string; service: string }; + builds.set(build.id, build.service); + return { state: "queued" } as T; + } + const service = builds.get(path.split("/")[2]!)!; + if (path.includes("/events")) + return path.includes("after=0") + ? [{ type: "log", sequence: 1, message: `${service} log\n` }] as T + : [] as T; + if (path.endsWith("/reconcile")) + return { state: service === "admin" ? "failed" : "succeeded", error: "admin failed" } as T; + if (path.endsWith("/result")) + return { reference: "image:web", references: { worker: "image:worker" } } as T; + throw new Error(path); + }; + await expect(provideContext(() => runUp(true, request, { trust }))).rejects.toThrow("admin failed"); + expect(builds.size).toBe(2); + expect(acquired.sort()).toEqual(["admin", "web", "worker"]); + expect(lines.get("web")).toEqual(["web log\n"]); + expect(lines.get("worker")).toEqual(["web log\n"]); + expect(lines.get("admin")).toEqual(["admin log\n"]); + } finally { + runSpy.mockRestore(); + process.chdir(previousCwd); + await rm(root, { recursive: true, force: true }); + } + }); + + test("renders a failing image under its child and sends the stack to the parent bottom bar", async () => { + const root = await mkdtemp(join(tmpdir(), "kuber-up-api-")); + const previousCwd = process.cwd(); + const rendered: string[] = []; + const writes = spyOn(process.stdout, "write").mockImplementation(((chunk: string | Uint8Array) => { + rendered.push(String(chunk)); + return true; + }) as typeof process.stdout.write); + try { + await writeFile(join(root, "compose.yml"), "services:\n client:\n build: .\n server:\n build: .\n"); + await writeFile(join(root, ".kuberrc.ts"), 'export default { project: "shop" };\n'); + process.chdir(root); + const trust = await resolveTrustIdentity("shop", root); + const request: ApiRequester = async (path: string, init?: ApiRequestInit) => { + if (path === "/snapshots/negotiate") return { ready: true } as T; + if (path === "/builds") return { id: (init!.json as { id: string }).id, state: "failed", error: "buildkit failed\nstack detail" } as T; + if (path.includes("/events")) return [] as T; + throw new Error(path); + }; + await expect(provideContext(() => runUp(true, request, { trust }))).rejects.toThrow(); + const output = rendered.join(""); + expect(output).toContain("Build client"); + expect(output).toContain("Build server"); + expect(output).toContain("buildkit failed"); + expect(output).toContain("stack detail"); + } finally { + writes.mockRestore(); + process.chdir(previousCwd); + await rm(root, { recursive: true, force: true }); + } + }); + test("forwards the build timeout through the trusted requester", async () => { const root = await mkdtemp(join(tmpdir(), "kuber-up-api-")); const previousCwd = process.cwd(); @@ -108,6 +207,14 @@ describe("up API pipeline", () => { const previousCwd = process.cwd(); const order: string[] = []; const started: string[] = []; + let rootTasks: Listr["tasks"] | undefined; + let backingTasks: Listr["tasks"] | undefined; + const originalRun = Listr.prototype.run; + const runSpy = spyOn(Listr.prototype, "run").mockImplementation(function (this: Listr) { + if (this.tasks[0]?.title === "Read compose") rootTasks = this.tasks; + if (this.tasks[0]?.title === "Reconcile databases") backingTasks = this.tasks; + return originalRun.call(this); + }); let bothReady!: () => void; const bothStarted = new Promise((resolve) => { bothReady = resolve; @@ -158,6 +265,18 @@ describe("up API pipeline", () => { const run = provideContext(() => runUp(false, request, { trust })); try { await bothStarted; + const titles = rootTasks!.map(({ title }) => title); + expect(titles.indexOf("Reconcile backing services")).toBeLessThan( + titles.indexOf("Render manifests"), + ); + expect(titles.indexOf("Render manifests")).toBeLessThan( + titles.indexOf("Reconcile resources"), + ); + expect(backingTasks!.map(({ title }) => title)).toEqual([ + "Reconcile databases", + "Reconcile S3 storage", + ]); + expect(rootTasks!.filter(({ title }) => title === "Reconcile databases")).toEqual([]); expect(started).toEqual(["database", "storage"]); expect(order.indexOf("/workspaces/shop/adopt")).toBeLessThan( order.indexOf("/workspaces/shop/databases"), @@ -184,6 +303,47 @@ describe("up API pipeline", () => { await run.catch(() => {}); } } finally { + runSpy.mockRestore(); + process.chdir(previousCwd); + await rm(root, { recursive: true, force: true }); + } + }); + + test("shows database API problem details on the database task without starting resources", async () => { + const root = await mkdtemp(join(tmpdir(), "kuber-up-api-")); + const previousCwd = process.cwd(); + const rendered: string[] = []; + const writes = spyOn(process.stdout, "write").mockImplementation(((chunk: string | Uint8Array) => { + rendered.push(String(chunk)); + return true; + }) as typeof process.stdout.write); + const errors = spyOn(process.stderr, "write").mockImplementation(((chunk: string | Uint8Array) => { + rendered.push(String(chunk)); + return true; + }) as typeof process.stderr.write); + const calls: string[] = []; + try { + await writeFile(join(root, "compose.yml"), "services:\n web:\n image: nginx\n volumes:\n - postgresql:web_db\n"); + await writeFile(join(root, ".kuberrc.ts"), 'export default { project: "shop" };\n'); + process.chdir(root); + const trust = await resolveTrustIdentity("shop", root); + const detail = "Database reconciliation failed during database apply for database web_db (service web, role web): Forbidden"; + const request: ApiRequester = async (path: string) => { + calls.push(path); + if (path === "/workspaces/shop") throw new KuberApiError("missing", 404); + if (path === "/workspaces") return { metadata: { name: "shop", uid: "workspace", resourceVersion: "1" } } as T; + if (path.endsWith("/adopt")) return { resourcesAdopted: 0 } as T; + if (path.endsWith("/databases")) throw new KuberApiError(detail, 500, { + title: "Operation failed", status: 500, code: "DATABASE_RECONCILE_FAILED", detail, + }); + throw new Error(`Unexpected request: ${path}`); + }; + await expect(provideContext(() => runUp(false, request, { trust }))).rejects.toThrow(detail); + expect(rendered.join("")).toContain("database apply for database web_db"); + expect(calls).not.toContain("/workspaces/shop/resources/plan"); + } finally { + errors.mockRestore(); + writes.mockRestore(); process.chdir(previousCwd); await rm(root, { recursive: true, force: true }); } diff --git a/tests/lib/build-api.test.ts b/tests/lib/build-api.test.ts index f6cfaa4..17f5939 100644 --- a/tests/lib/build-api.test.ts +++ b/tests/lib/build-api.test.ts @@ -582,6 +582,301 @@ describe("authenticated build API pipeline", () => { expect(calls.some(({ path }) => path.endsWith("/result"))).toBe(true); }); + test("builds equivalent services once and attributes events and image results to each", async () => { + const snapshot = emptySnapshot(); + const submitted: BuildRequest[] = []; + const messages = new Map(); + const settled: string[] = []; + const request: ApiRequester = async ( + path: string, + init?: ApiRequestInit, + ) => { + if (path === "/snapshots/negotiate") return { ready: true } as T; + if (path === "/builds") { + submitted.push(init!.json as BuildRequest); + return { state: "queued" } as T; + } + if (path.includes("/events")) + return [ + { type: "status", status: { state: "running", phase: "running" } }, + { type: "log", sequence: 1, message: "shared log\n" }, + ] as T; + if (path.endsWith("/reconcile")) return { state: "succeeded" } as T; + if (path.endsWith("/result")) + return { + reference: "registry/kuber/shop-web@sha256:abc", + references: { worker: "registry/kuber/shop-worker@sha256:abc" }, + } as T; + throw new Error(path); + }; + const result = await buildServices( + "shop", + { + services: { web: { build: "." }, worker: { build: { context: "." } } }, + }, + process.cwd(), + { + service: (name) => { + const output: string[] = []; + messages.set(name, output); + return { + progress: (message) => { + output.push(message); + }, + }; + }, + settled: (name) => { + settled.push(name); + }, + }, + { request, snapshot, sleep: async () => {}, pollIntervalMs: 0 }, + ); + expect(submitted).toHaveLength(1); + expect(submitted[0]?.destinations).toEqual([ + { + service: "worker", + image: "registry.neko-piranha.ts.net/kuber/shop-worker:latest", + }, + ]); + expect(result).toEqual({ + built: ["web", "worker"], + changed: ["web", "worker"], + images: { + web: "registry/kuber/shop-web@sha256:abc", + worker: "registry/kuber/shop-worker@sha256:abc", + }, + }); + expect(settled).toEqual(["web", "worker"]); + for (const name of ["web", "worker"]) + expect(messages.get(name)?.join(" ")).toContain("shared log"); + }); + + test("routes overlapping build logs only to their destination children", async () => { + const snapshot = emptySnapshot(); + const submissions = new Map(); + const output = new Map(); + let started = 0; + let release!: () => void; + const bothStarted = new Promise((resolve) => { release = resolve; }); + const request: ApiRequester = async (path: string, init?: ApiRequestInit) => { + if (path === "/snapshots/negotiate") return { ready: true } as T; + if (path === "/builds") { + const build = init!.json as BuildRequest; + submissions.set(build.id, build); + if (++started === 2) release(); + return { state: "queued" } as T; + } + const id = path.split("/")[2]!; + const build = submissions.get(id)!; + if (path.includes("/events")) + return path.includes("after=0") + ? [ + { type: "status", status: { state: "running", phase: "running" } }, + { type: "log", sequence: 1, message: `${build.service} log\n` }, + ] as T + : [] as T; + if (path.endsWith("/reconcile")) { + await bothStarted; + return { state: "succeeded" } as T; + } + if (path.endsWith("/result")) + return { + reference: `image:${build.service}`, + references: { worker: "image:worker" }, + } as T; + throw new Error(path); + }; + const result = await buildServices( + "shop", + { services: { + web: { build: "." }, + worker: { build: "." }, + admin: { build: { context: ".", args: { ROLE: "admin" } } }, + } }, + process.cwd(), + { service: (name) => { + const lines: string[] = []; + output.set(name, lines); + return { + progress: (message) => { lines.push(message); }, + stream: new Writable({ write(chunk, _encoding, done) { + lines.push(String(chunk)); + done(); + } }), + }; + } }, + { request, snapshot, buildConcurrency: 2, pollIntervalMs: 0, sleep: async () => {} }, + ); + expect(submissions.size).toBe(2); + expect([...submissions.values()].find(({ service }) => service === "web")?.destinations?.map(({ service }) => service)).toEqual(["worker"]); + expect(result.images).toEqual({ web: "image:web", worker: "image:worker", admin: "image:admin" }); + for (const name of ["web", "worker"]) { + expect(output.get(name)?.filter((line) => line === "web log\n")).toHaveLength(1); + expect(output.get(name)?.join("")).not.toContain("admin log"); + expect(output.get(name)).toContain("Build running"); + } + expect(output.get("admin")?.filter((line) => line === "admin log\n")).toHaveLength(1); + expect(output.get("admin")?.join("")).not.toContain("web log"); + }); + + test("reports a grouped build once to a shared reporter", async () => { + const output: string[] = []; + const request: ApiRequester = async (path: string) => { + if (path === "/snapshots/negotiate") return { ready: true } as T; + if (path === "/builds") return { state: "queued" } as T; + if (path.includes("/events")) + return path.includes("after=0") + ? [{ type: "log", sequence: 1, message: "one physical build\n" }] as T + : [] as T; + if (path.endsWith("/reconcile")) return { state: "succeeded" } as T; + if (path.endsWith("/result")) + return { reference: "image:web", references: { worker: "image:worker" } } as T; + throw new Error(path); + }; + await buildServices( + "shop", + { services: { web: { build: "." }, worker: { build: "." } } }, + process.cwd(), + { stream: new Writable({ write(chunk, _encoding, done) { + output.push(String(chunk)); + done(); + } }) }, + { request, snapshot: emptySnapshot(), sleep: async () => {}, pollIntervalMs: 0 }, + ); + expect(output).toEqual(["[web] one physical build\n"]); + }); + + test("does not merge builds differing in context, Dockerfile, target, or build arguments", async () => { + const snapshot = emptySnapshot(); + const submitted: BuildRequest[] = []; + const request: ApiRequester = async ( + path: string, + init?: ApiRequestInit, + ) => { + if (path === "/snapshots/negotiate") return { ready: true } as T; + if (path === "/builds") { + submitted.push(init!.json as BuildRequest); + return { state: "queued" } as T; + } + if (path.includes("/events")) return [] as T; + if (path.endsWith("/reconcile")) return { state: "succeeded" } as T; + if (path.endsWith("/result")) + return { reference: "image@sha256:abc" } as T; + throw new Error(path); + }; + await buildServices( + "shop", + { + services: { + base: { build: "." }, + context: { build: { context: "nested" } }, + dockerfile: { build: { context: ".", dockerfile: "Otherfile" } }, + target: { build: { context: ".", target: "test" } }, + args: { build: { context: ".", args: { MODE: "test" } } }, + }, + }, + process.cwd(), + undefined, + { request, snapshot }, + ); + expect(submitted).toHaveLength(5); + expect(submitted.every((build) => !build.destinations)).toBe(true); + }); + + test("marks every service failed when a shared BuildKit job fails", async () => { + const snapshot = emptySnapshot(); + const settled: Array<[string, unknown]> = []; + let submissions = 0; + const request: ApiRequester = async (path: string) => { + if (path === "/snapshots/negotiate") return { ready: true } as T; + if (path === "/builds") { + submissions++; + return { state: "queued" } as T; + } + if (path.includes("/events")) + return [{ type: "log", sequence: 1, message: "build failed\n" }] as T; + if (path.endsWith("/reconcile")) + return { state: "failed", error: "export failed" } as T; + throw new Error(path); + }; + await expect( + buildServices( + "shop", + { + services: { + web: { build: "." }, + worker: { build: "." }, + }, + }, + process.cwd(), + { + settled: (name, error) => { + settled.push([name, error]); + }, + }, + { request, snapshot, sleep: async () => {}, pollIntervalMs: 0 }, + ), + ).rejects.toThrow("export failed\nbuild failed"); + expect(submissions).toBe(1); + expect(settled.map(([name]) => name)).toEqual(["web", "worker"]); + expect(settled.every(([, error]) => error instanceof Error)).toBe(true); + }); + + test("cancels a shared build once and does not start queued groups", async () => { + const snapshot = emptySnapshot(); + const controller = new AbortController(); + const reason = new DOMException("Cancelled", "AbortError"); + const started: BuildRequest[] = []; + const settled: string[] = []; + let ready!: () => void; + const submitted = new Promise((resolve) => { + ready = resolve; + }); + const request: ApiRequester = async ( + path: string, + init?: ApiRequestInit, + ) => { + if (path === "/snapshots/negotiate") return { ready: true } as T; + if (path === "/builds") { + started.push(init!.json as BuildRequest); + ready(); + return await new Promise((_resolve, reject) => { + init?.signal?.addEventListener( + "abort", + () => reject(init.signal!.reason), + { once: true }, + ); + }); + } + throw new Error(path); + }; + const result = buildServices( + "shop", + { + services: { + web: { build: "." }, + worker: { build: "." }, + different: { build: { context: ".", args: { MODE: "different" } } }, + }, + }, + process.cwd(), + { + settled: (name) => { + settled.push(name); + }, + }, + { request, snapshot, buildConcurrency: 1, signal: controller.signal }, + ); + await submitted; + controller.abort(reason); + await expect(result).rejects.toBe(reason); + expect(started).toHaveLength(1); + expect(started[0]!.destinations?.map(({ service }) => service)).toEqual([ + "worker", + ]); + expect(settled).toEqual(["web", "worker"]); + }); + test("bounds overlapping builds, attributes logs, and returns images in compose order", async () => { const root = await mkdtemp(join(tmpdir(), "kuber-build-api-")); directories.push(root); @@ -631,7 +926,8 @@ describe("authenticated build API pipeline", () => { await gates.get(service); return { id, state: "succeeded" } as T; } - if (path.endsWith("/result")) return { reference: `image:${service}` } as T; + if (path.endsWith("/result")) + return { reference: `image:${service}` } as T; throw new Error(`Unexpected request ${path}`); }; const run = buildServices( @@ -640,7 +936,7 @@ describe("authenticated build API pipeline", () => { services: Object.fromEntries( ["one", "two", "three", "four"].map((name) => [ name, - { build: "." }, + { build: { context: ".", args: { SERVICE: name } } }, ]), ), }, @@ -683,6 +979,126 @@ describe("authenticated build API pipeline", () => { } }); + test("keeps per-image logs and lifecycle updates separate, including a failed build", async () => { + const snapshot = emptySnapshot(); + const outputs = new Map(); + const ids = new Map(); + const settled: Array<[string, unknown]> = []; + const statuses = ["creating", "starting", "running", "done"] as const; + let polls = 0; + const request: ApiRequester = async ( + path: string, + init?: ApiRequestInit, + ) => { + if (path === "/snapshots/negotiate") return { ready: true } as T; + if (path === "/builds") { + const build = init!.json as BuildRequest; + ids.set(build.id, build.service); + return { state: "queued" } as T; + } + const id = path.split("/")[2]!; + const service = ids.get(id)!; + if (path.includes("/events")) { + const phase = statuses[Math.min(polls, 3)]!; + return [ + { + type: "status", + status: { + state: + phase === "done" + ? service === "server" + ? "failed" + : "succeeded" + : phase === "running" + ? "running" + : "queued", + phase, + error: + service === "server" && phase === "done" + ? "stack trace" + : undefined, + }, + }, + { type: "log", sequence: polls + 1, message: `${service} log\n` }, + ] as T; + } + if (path.endsWith("/reconcile")) { + const phase = statuses[Math.min(polls++, 3)]!; + return { + state: + phase === "done" + ? service === "server" + ? "failed" + : "succeeded" + : phase === "running" + ? "running" + : "queued", + phase, + error: "stack trace", + } as T; + } + if (path.endsWith("/result")) + return { reference: `image:${service}` } as T; + throw new Error(path); + }; + await expect( + buildServices( + "shop", + { + services: { + client: { build: "." }, + server: { build: { context: ".", args: { SERVICE: "server" } } }, + }, + }, + process.cwd(), + { + service: (name) => { + const output: string[] = []; + outputs.set(name, output); + return { + progress: (message) => { + output.push(message); + }, + stream: new Writable({ + write(chunk, _encoding, done) { + output.push(String(chunk)); + done(); + }, + }), + }; + }, + settled: (name, error) => { + settled.push([name, error]); + }, + }, + { + request, + snapshot, + sleep: async () => {}, + pollIntervalMs: 0, + buildConcurrency: 1, + }, + ), + ).rejects.toThrow( + "Build failed for service server: stack trace\nserver log", + ); + expect(outputs.get("client")).toEqual( + expect.arrayContaining([ + "Build queued", + "Build creating", + "Build starting", + "Build running", + "Build done", + "client log\n", + ]), + ); + expect(outputs.get("client")?.join("")).not.toContain("server log"); + expect(outputs.get("server")?.join("")).toContain("server log"); + expect(settled.map(([name]) => name)).toEqual(["client", "server"]); + expect(settled[1]?.[1]).toBeInstanceOf(Error); + expect(ids.size).toBe(2); // Different build arguments require separate jobs. + }); + test("drains active builds and does not start queued services after failure", async () => { const root = await mkdtemp(join(tmpdir(), "kuber-build-api-")); directories.push(root); @@ -722,16 +1138,19 @@ describe("authenticated build API pipeline", () => { "shop", { services: { - one: { build: "." }, - two: { build: "." }, - three: { build: "." }, + one: { build: { context: ".", args: { SERVICE: "one" } } }, + two: { build: { context: ".", args: { SERVICE: "two" } } }, + three: { build: { context: ".", args: { SERVICE: "three" } } }, }, }, root, undefined, { request, snapshot, buildConcurrency: 2 }, ); - const outcome = run.then(() => undefined, (error: unknown) => error); + const outcome = run.then( + () => undefined, + (error: unknown) => error, + ); await bothStarted; fail(new Error("first build failed")); await Promise.resolve(); @@ -776,9 +1195,9 @@ describe("authenticated build API pipeline", () => { "shop", { services: { - one: { build: "." }, - two: { build: "." }, - three: { build: "." }, + one: { build: { context: ".", args: { SERVICE: "one" } } }, + two: { build: { context: ".", args: { SERVICE: "two" } } }, + three: { build: { context: ".", args: { SERVICE: "three" } } }, }, }, root, @@ -927,7 +1346,11 @@ describe("authenticated build API pipeline", () => { } const images = await run; expect(Object.keys(images)).toEqual(services); - expect(images).toEqual(Object.fromEntries(services.map((service) => [service, `image:${service}`]))); + expect(images).toEqual( + Object.fromEntries( + services.map((service) => [service, `image:${service}`]), + ), + ); expect(started).toEqual(services); } finally { for (const release of pending) release(); diff --git a/tests/lib/database.test.ts b/tests/lib/database.test.ts index a04685b..090245f 100644 --- a/tests/lib/database.test.ts +++ b/tests/lib/database.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; import type { ComposeSpecification, Service } from "../../schema/docker.d"; import { + DatabaseReconciliationError, buildDatabaseUrl, buildPostgresEnvironment, getComposePostgresClaims, @@ -152,6 +153,38 @@ describe("managed PostgreSQL claims", () => { ]); }); + test("reports database apply phase and claim without exposing provider credentials", async () => { + const claim = { + service: "app", + username: "app_role", + database: "app_db", + secretName: "postgres-app_role", + }; + spyOn(objectApi, "read").mockImplementation(async (resource) => { + if (resource.kind === "Secret") { + return { ...resource, data: { password: Buffer.from("private-value").toString("base64") } } as never; + } + return { ...resource, spec: { managed: { roles: [] } } } as never; + }); + spyOn(objectApi, "patch").mockImplementation(async (resource) => { + if (resource.kind === "Database") { + throw Object.assign(new Error("Forbidden: password=private-value"), { code: 403 }); + } + return resource as never; + }); + let failure: unknown; + try { + await reconcilePostgresClaim("project", claim); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(DatabaseReconciliationError); + expect((failure as Error).message).toBe( + "Database reconciliation failed during database apply for database app_db (service app, role app_role): Forbidden (HTTP 403)", + ); + expect((failure as Error).message).not.toContain("private-value"); + }); + test("reconciles a CNPG cluster returned with managed fields without sending them back", async () => { const claim = { service: "app", diff --git a/tests/server/api-keys.test.ts b/tests/server/api-keys.test.ts index c41e8be..7895ee9 100644 --- a/tests/server/api-keys.test.ts +++ b/tests/server/api-keys.test.ts @@ -136,9 +136,27 @@ describe("API keys", () => { }); expect(differentWorkspace.status).toBe(403); + const noExpiry = await create({ + capabilities: ["kubernetes:read"], + workspace: "shop", + }); + expect(noExpiry.status).toBe(403); + expect((await noExpiry.json()) as { code: string }).toHaveProperty( + "code", + "API_KEY_DELEGATION_FORBIDDEN", + ); + + const laterExpiry = await create({ + capabilities: ["kubernetes:read"], + workspace: "shop", + expiresAt: "2026-10-06T00:00:00.000Z", + }); + expect(laterExpiry.status).toBe(403); + const subset = await create({ capabilities: ["kubernetes:read"], workspace: "shop", + expiresAt: "2026-10-05T00:00:00.000Z", }); expect(subset.status).toBe(201); expect( @@ -168,7 +186,7 @@ describe("API keys", () => { event.spec.action === "api_key.create" && event.spec.outcome === "denied", ), - ).toHaveLength(4); + ).toHaveLength(6); expect(JSON.stringify(denied)).not.toContain("delegation-parent"); await store.createApiKey({ @@ -184,7 +202,10 @@ describe("API keys", () => { { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ capabilities: ["kubernetes:read"] }), + body: JSON.stringify({ + capabilities: ["kubernetes:read"], + expiresAt: "2026-09-20T00:00:00.000Z", + }), }, "unscoped-delegation", ), @@ -193,6 +214,33 @@ describe("API keys", () => { expect(await unscopedSubset.json()).not.toHaveProperty("workspace"); }); + test("allows a non-expiring parent to delegate a non-expiring child", async () => { + const { app, store } = await setup(); + await store.createApiKey({ + id: "key_nonexpiring_parent", + tokenHash: hashToken("nonexpiring-parent"), + username: "ci", + capabilities: ["users:write", "kubernetes:read"], + }); + const created = await app( + request( + "/api/v2/users/ci/keys", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ capabilities: ["kubernetes:read"] }), + }, + "nonexpiring-parent", + ), + ); + expect(created.status).toBe(201); + const child = (await created.json()) as { token: string }; + expect(child).not.toHaveProperty("expiresAt"); + expect((await app(request("/api/v2/me", {}, child.token))).status).toBe( + 200, + ); + }); + test("rejects expired keys and expiry longer than 365 days", async () => { const { app, store } = await setup(); await store.createApiKey({ @@ -221,7 +269,7 @@ describe("API keys", () => { ).toBe(400); }); - test("uses the default expiry, cleans up expired keys, and does not log keys out", async () => { + test("preserves non-expiring keys while cleaning up finite keys and sessions", async () => { const { app, store } = await setup(); const created = await app( request("/api/v2/users/ci/keys", { @@ -230,17 +278,44 @@ describe("API keys", () => { body: JSON.stringify({ capabilities: ["kubernetes:read"] }), }), ); - const key = (await created.json()) as { token: string; expiresAt: string }; - expect(key.expiresAt).toBe("2026-12-04T00:00:00.000Z"); + expect(created.status).toBe(201); + const key = (await created.json()) as { token: string }; + expect(key).not.toHaveProperty("expiresAt"); + const listed = await app(request("/api/v2/users/ci/keys")); + const { items } = (await listed.json()) as { items: object[] }; + expect(items).toHaveLength(1); + expect(items[0]).not.toHaveProperty("expiresAt"); + const finite = await app( + request("/api/v2/users/ci/keys", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + capabilities: ["kubernetes:read"], + expiresAt: "2026-10-05T00:00:00.000Z", + }), + }), + ); + expect(finite.status).toBe(201); + const finiteKey = (await finite.json()) as { + token: string; + expiresAt: string; + }; + expect(finiteKey.expiresAt).toBe("2026-10-05T00:00:00.000Z"); expect( (await app(request("/api/v2/logout", { method: "POST" }, key.token))) .status, ).toBe(204); expect((await app(request("/api/v2/me", {}, key.token))).status).toBe(200); - expect(await cleanupExpiredSessions(store, Date.parse(key.expiresAt))).toBe( - 2, + expect((await app(request("/api/v2/me", {}, finiteKey.token))).status).toBe( + 200, ); - expect((await app(request("/api/v2/me", {}, key.token))).status).toBe(401); + expect( + await cleanupExpiredSessions(store, Date.parse(finiteKey.expiresAt)), + ).toBe(2); + expect((await app(request("/api/v2/me", {}, finiteKey.token))).status).toBe( + 401, + ); + expect((await app(request("/api/v2/me", {}, key.token))).status).toBe(200); }); test("denies a workspace-scoped key outside its workspace", async () => { diff --git a/tests/server/app.test.ts b/tests/server/app.test.ts index 976beb8..b25c99e 100644 --- a/tests/server/app.test.ts +++ b/tests/server/app.test.ts @@ -238,9 +238,7 @@ describe("operation response safety", () => { }); test("redacts database and storage reconciliation results immediately", async () => { - const workspaceStore = new MemoryWorkspaceStore({ - uid: () => "workspace-uid", - }); + const workspaceStore = new MemoryWorkspaceStore({ uid: () => "workspace-uid" }); await workspaceStore.create({ id: "demo", source: { uri: "oci://example/demo", digest: "sha256:abc" }, @@ -381,6 +379,143 @@ describe("operation response safety", () => { }); }); +describe("API key route expiry", () => { + test("omitted expiry creates a non-expiring key that authenticates and survives cleanup", async () => { + const store = new MemoryAuthStore(); + await store.putUser({ + username: "admin", + passwordHash: "hash", + roles: ["admin"], + }); + await store.putUser({ + username: "ci", + passwordHash: "hash", + roles: ["viewer"], + }); + await store.putSession({ + tokenHash: hashToken("admin-token"), + username: "admin", + authVersion: 1, + expiresAt: "2027-01-01T00:00:00.000Z", + }); + let time = Date.parse("2026-10-05T00:00:00.000Z"); + const app = createApp({ store, now: () => time }); + const created = await app( + request( + "/api/v2/users/ci/keys", + { + method: "POST", + body: JSON.stringify({ capabilities: ["kubernetes:read"] }), + }, + "admin-token", + ), + ); + expect(created.status).toBe(201); + const key = (await created.json()) as { + id: string; + token: string; + expiresAt?: string; + }; + expect(key).not.toHaveProperty("expiresAt"); + expect((await store.listApiKeys("ci"))[0]).not.toHaveProperty("expiresAt"); + const listed = await app( + request("/api/v2/users/ci/keys", {}, "admin-token"), + ); + const list = (await listed.json()) as { items: Record[] }; + expect(list.items).toEqual([expect.objectContaining({ id: key.id })]); + expect(list.items[0]).not.toHaveProperty("expiresAt"); + time = Date.parse("2028-01-01T00:00:00.000Z"); + expect(await cleanupExpiredSessions(store, time)).toBe(1); + expect((await app(request("/api/v2/me", {}, key.token))).status).toBe(200); + }); + + test("explicit finite expiry is enforced and malformed expiry is rejected", async () => { + const store = new MemoryAuthStore(); + await store.putUser({ + username: "admin", + passwordHash: "hash", + roles: ["admin"], + }); + await store.putUser({ + username: "ci", + passwordHash: "hash", + roles: ["viewer"], + }); + await store.putSession({ + tokenHash: hashToken("admin-token"), + username: "admin", + authVersion: 1, + expiresAt: "2027-01-01T00:00:00.000Z", + }); + let time = Date.parse("2026-10-05T00:00:00.000Z"); + const app = createApp({ store, now: () => time }); + const create = (expiresAt: unknown) => + app( + request( + "/api/v2/users/ci/keys", + { + method: "POST", + body: JSON.stringify({ + capabilities: ["kubernetes:read"], + expiresAt, + }), + }, + "admin-token", + ), + ); + const expiry = "2026-10-06T00:00:00.000Z"; + const created = await create(expiry); + expect(created.status).toBe(201); + const key = (await created.json()) as { token: string; expiresAt: string }; + expect(key.expiresAt).toBe(expiry); + expect((await app(request("/api/v2/me", {}, key.token))).status).toBe(200); + for (const invalid of [ + null, + 0, + "", + "2026-10-05T00:00:00.000Z", + "2027-10-06T00:00:00.000Z", + ]) + expect((await create(invalid)).status).toBe(400); + time = Date.parse(expiry); + expect((await app(request("/api/v2/me", {}, key.token))).status).toBe(401); + }); + + test("a finite parent API key cannot delegate a non-expiring child", async () => { + const store = new MemoryAuthStore(); + await store.putUser({ + username: "ci", + passwordHash: "hash", + roles: ["viewer"], + }); + await store.createApiKey({ + id: "finite-parent-key-1", + tokenHash: hashToken("parent-token"), + username: "ci", + capabilities: ["users:write", "kubernetes:read"], + expiresAt: "2027-01-01T00:00:00.000Z", + }); + const app = createApp({ + store, + now: () => Date.parse("2026-10-05T00:00:00.000Z"), + }); + const created = await app( + request( + "/api/v2/users/ci/keys", + { + method: "POST", + body: JSON.stringify({ capabilities: ["kubernetes:read"] }), + }, + "parent-token", + ), + ); + expect(created.status).toBe(403); + expect((await created.json()) as { code: string }).toMatchObject({ + code: "API_KEY_DELEGATION_FORBIDDEN", + }); + }); +}); + async function authenticatedStore(role: "viewer" | "operator" | "admin") { const store = new MemoryAuthStore(); await store.putUser({ username: role, passwordHash: "hash", roles: [role] }); @@ -1078,7 +1213,9 @@ describe("kuber v2 HTTP routes", () => { }); test("a stalled observation-only wait does not block mutation and remains idempotent", async () => { - const workspaceStore = new MemoryWorkspaceStore({ uid: () => "workspace-uid" }); + const workspaceStore = new MemoryWorkspaceStore({ + uid: () => "workspace-uid", + }); await workspaceStore.create({ id: "demo", source: { uri: "oci://example/demo", digest: "sha256:abc" }, @@ -1620,6 +1757,44 @@ describe("kuber v2 HTTP routes", () => { ).toBe("failed"); }); + test("returns safe database phase and claim details on first and repeated failures", async () => { + const workspaceStore = new MemoryWorkspaceStore({ uid: () => "workspace-uid" }); + await workspaceStore.create({ + id: "demo", + source: { uri: "oci://example/demo", digest: "sha256:abc" }, + }); + const operationStore = new MemoryOperationStore(undefined, () => "db-failure"); + const { DatabaseReconciliationError } = await import("../../lib/database"); + const app = createApp({ + store: await authenticatedStore("operator"), + workspaceStore, + operationStore, + management: { + reconcileDatabases: async () => { + throw new DatabaseReconciliationError( + "database apply", + new Error("Forbidden: DB_PASSWORD=private-value"), + { service: "web", username: "web_role", database: "web_db", secretName: "web" }, + ); + }, + } as unknown as ManagementService, + }); + const reconcile = () => app(request( + "/api/v2/workspaces/demo/databases", + { method: "POST", headers: { "idempotency-key": "db-failure" }, body: JSON.stringify({ compose: {} }) }, + "token", + )); + for (const result of [await reconcile(), await reconcile()]) { + expect(result.status).toBe(500); + const problem = await result.json() as { code: string; detail: string }; + expect(problem.code).toBe("DATABASE_RECONCILE_FAILED"); + expect(problem.detail).toContain("database apply for database web_db (service web, role web_role): Forbidden"); + expect(JSON.stringify(problem)).not.toContain("private-value"); + } + expect((await operationStore.get("operation-db-failure"))?.status.error?.message) + .toContain("database apply for database web_db"); + }); + test("routes workspace adoption and keeps platform adoption admin-only", async () => { const workspaceStore = new MemoryWorkspaceStore({ uid: () => "workspace-uid", diff --git a/tests/server/auth.test.ts b/tests/server/auth.test.ts index bbfad32..05d4628 100644 --- a/tests/server/auth.test.ts +++ b/tests/server/auth.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { MemoryAuthStore, hashToken, + normalizeApiKey, tokenHashesEqual, } from "../../server/auth"; import { @@ -23,6 +24,54 @@ describe("authorization", () => { }); describe("memory auth store", () => { + test("keeps non-expiring keys active and preserves finite expiry validation", async () => { + const store = new MemoryAuthStore(); + await store.putUser({ + username: "alice", + passwordHash: "hash", + roles: ["viewer"], + }); + const key = { + id: "never-expiring-key-123", + tokenHash: hashToken("never"), + username: "alice", + capabilities: ["kubernetes:read" as const], + }; + await store.createApiKey(key); + expect(await store.getApiKey(key.tokenHash)).toMatchObject(key); + expect((await store.listApiKeys("alice"))[0]?.expiresAt).toBeUndefined(); + await store.createApiKey({ + ...key, + id: "finite-expiry-key-123", + tokenHash: hashToken("finite"), + expiresAt: "2020-01-01T00:00:00.000Z", + }); + expect(await store.deleteExpiredApiKeys()).toBe(1); + expect(await store.getApiKey(key.tokenHash)).toMatchObject(key); + expect( + normalizeApiKey({ ...key, expiresAt: undefined }).expiresAt, + ).toBeUndefined(); + for (const expiresAt of ["none", "not-a-date", "2026-09-03", ""]) { + expect(() => normalizeApiKey({ ...key, expiresAt })).toThrow( + "ISO timestamp", + ); + } + await expect( + store.createApiKey({ + ...key, + id: "duplicate-key-id-123", + tokenHash: hashToken("duplicate"), + }), + ).resolves.toBeUndefined(); + expect(await store.revokeApiKey("bob", key.id)).toBe(false); + expect(await store.revokeApiKey("alice", key.id)).toBe(true); + expect(await store.getApiKey(key.tokenHash)).toBeUndefined(); + await expect( + store.createApiKey({ ...key, username: "missing" }), + ).rejects.toThrow("not active"); + await store.updateUser("alice", { disabled: true }); + await expect(store.createApiKey({ ...key })).rejects.toThrow("not active"); + }); test("maintains the API-key hash index across key mutations", async () => { const store = new MemoryAuthStore(); await store.putUser({ diff --git a/tests/server/build-controller.test.ts b/tests/server/build-controller.test.ts index d9099f7..0a9fb99 100644 --- a/tests/server/build-controller.test.ts +++ b/tests/server/build-controller.test.ts @@ -167,7 +167,10 @@ async function fixture( }; } -async function internalFixture(maxLogBytes = 1024) { +async function internalFixture( + maxLogBytes = 1024, + resolveDigest?: (image: string) => Promise, +) { const root = await mkdtemp(join(tmpdir(), "kuber-controller-internal-")); roots.push(root); const cas = new FilesystemCas(join(root, "cas")); @@ -205,7 +208,7 @@ async function internalFixture(maxLogBytes = 1024) { pushRegistryInsecure: true, maxLogBytes, now: () => new Date(Date.UTC(2026, 8, 2, 0, 0, now++)), - resolveDigest: async () => `sha256:${"f".repeat(64)}`, + resolveDigest: resolveDigest ?? (async () => `sha256:${"f".repeat(64)}`), }); const request: BuildRequest = { version: BUILD_PROTOCOL_VERSION, @@ -233,6 +236,86 @@ async function internalFixture(maxLogBytes = 1024) { } describe("build controller", () => { + test("publishes multiple service destinations in one Job and resolves every canonical reference", async () => { + const resolved: string[] = []; + const { controller, request, kubernetes } = await internalFixture( + 1024, + async (image) => { + resolved.push(image); + return `sha256:${"f".repeat(64)}`; + }, + ); + request.destinations = [ + { service: "worker", image: "external.example/other:latest" }, + ]; + await controller.submitBuild(request); + expect(kubernetes.jobs).toHaveLength(1); + expect( + (kubernetes.jobs[0]!.spec as any).template.spec.containers[0].args, + ).toContain( + '--output=type=image,"name=cncf-distribution-svc.registry.svc.cluster.local:5000/kuber/demo-web:latest,cncf-distribution-svc.registry.svc.cluster.local:5000/kuber/demo-worker:latest",push=true,registry.insecure=true', + ); + kubernetes.observation = { phase: "succeeded" }; + expect(await controller.reconcileBuild(request.id)).toMatchObject({ + state: "succeeded", + }); + expect(resolved).toEqual([ + "registry.neko-piranha.ts.net/kuber/demo-web:latest", + "registry.neko-piranha.ts.net/kuber/demo-worker:latest", + ]); + expect(await controller.getBuildResult(request.id)).toMatchObject({ + reference: `registry.neko-piranha.ts.net/kuber/demo-web@sha256:${"f".repeat(64)}`, + references: { + worker: `registry.neko-piranha.ts.net/kuber/demo-worker@sha256:${"f".repeat(64)}`, + }, + }); + }); + + test("fails the shared job if an alias resolves to a different digest", async () => { + const { controller, request, kubernetes } = await internalFixture( + 1024, + async (image) => + `sha256:${(image.includes("worker") ? "e" : "f").repeat(64)}`, + ); + request.destinations = [ + { service: "worker", image: "registry.test/worker:latest" }, + ]; + await controller.submitBuild(request); + kubernetes.observation = { phase: "succeeded" }; + expect(await controller.reconcileBuild(request.id)).toMatchObject({ + state: "failed", + error: expect.stringContaining("different digest"), + }); + await expect(controller.getBuildResult(request.id)).rejects.toBeInstanceOf( + BuildConflictError, + ); + }); + + test("rejects duplicate alias destinations and unsafe exporter names", async () => { + const { controller, request, kubernetes } = await fixture(); + request.destinations = [ + { service: "web", image: "registry.test/demo/other:latest" }, + ]; + await expect(controller.submitBuild(request)).rejects.toBeInstanceOf( + BuildValidationError, + ); + request.destinations = [ + { service: "worker", image: "registry.test/demo/web:latest" }, + ]; + await expect(controller.submitBuild(request)).rejects.toBeInstanceOf( + BuildValidationError, + ); + request.destinations = [ + { + service: "worker", + image: "registry.test/demo/worker:latest,push=false", + }, + ]; + await expect(controller.submitBuild(request)).rejects.toBeInstanceOf( + BuildValidationError, + ); + expect(kubernetes.jobs).toHaveLength(0); + }); test("negotiates snapshots and resumes verified blob uploads", async () => { const { controller, cas } = await fixture(); const content = Buffer.from("resumable"); @@ -365,6 +448,40 @@ describe("build controller", () => { expect(await controller.reconcileBuild(replacement.id)).toEqual(status); }); + test("persists creating and starting phases without claiming the build has started", async () => { + const { controller, kubernetes, request } = await fixture(); + expect(await controller.submitBuild(request)).toMatchObject({ + state: "queued", + phase: "queued", + }); + for (const phase of ["creating", "starting"] as const) { + kubernetes.observation = { phase }; + expect(await controller.reconcileBuild(request.id)).toMatchObject({ + state: "queued", + phase, + }); + } + kubernetes.observation = { phase: "running" }; + expect(await controller.reconcileBuild(request.id)).toMatchObject({ + state: "running", + phase: "running", + }); + kubernetes.observation = { + phase: "failed", + error: "buildkit crashed\nstack line", + }; + expect(await controller.reconcileBuild(request.id)).toMatchObject({ + state: "failed", + phase: "done", + error: "buildkit crashed\nstack line", + }); + expect( + (await controller.getBuildEvents(request.id)) + .filter((event) => event.type === "status") + .map((event) => event.type === "status" && event.status.phase), + ).toEqual(["queued", "creating", "starting", "running", "done"]); + }); + test("concurrent controllers converge on one same-ID record and Job", async () => { const first = await fixture(); const second = new BuildController({ diff --git a/tests/server/build-job.test.ts b/tests/server/build-job.test.ts index 5c62c5b..99431b7 100644 --- a/tests/server/build-job.test.ts +++ b/tests/server/build-job.test.ts @@ -15,6 +15,20 @@ function spec(architecture: "arm64" | "amd64"): BuildSpec { } describe("BuildKit Job generation", () => { + test("quotes multiple image names as one BuildKit image exporter", () => { + const job = createBuildJob({ + name: "build-multi", + namespace: "default", + spec: spec("amd64"), + workspaceClaimName: "workspace", + cacheImage: "registry.example.com/cache/demo-web", + pushImage: "registry.example.com/kuber/demo-web:latest", + pushImages: ["registry.example.com/kuber/demo-worker:latest"], + }); + expect((job.spec as any).template.spec.containers[0].args).toContain( + '--output=type=image,"name=registry.example.com/kuber/demo-web:latest,registry.example.com/kuber/demo-worker:latest",push=true', + ); + }); test.each(["arm64", "amd64"] as const)( "generates a rootless %s job", (architecture) => { diff --git a/tests/server/build-kubernetes.test.ts b/tests/server/build-kubernetes.test.ts index 1a57b11..0ae568a 100644 --- a/tests/server/build-kubernetes.test.ts +++ b/tests/server/build-kubernetes.test.ts @@ -6,6 +6,7 @@ import { type BuildRecord, } from "../../server/build-store"; import { + KubernetesBuildOperations, KubernetesBuildStore, type BuildObjectApi, } from "../../server/build-kubernetes"; @@ -19,6 +20,50 @@ const namespace = "kuber-test"; const imageKey = "project\0service\0registry.test/app:latest"; const workspace = `sha256:${"a".repeat(64)}` as Sha256Digest; +test("observes the build container lifecycle rather than treating an active Job as running", async () => { + const job = { + status: { active: 1, startTime: new Date("2026-01-01T00:00:00Z") }, + }; + let pods: any[] = []; + const operations = new KubernetesBuildOperations( + { readNamespacedJob: async () => job } as any, + { listNamespacedPod: async () => ({ items: pods }) } as any, + ); + expect((await operations.getJob("builds", "job"))?.phase).toBe("creating"); + pods = [{ spec: {}, status: { phase: "Pending" } }]; + expect((await operations.getJob("builds", "job"))?.phase).toBe("creating"); + pods = [ + { + spec: { nodeName: "node" }, + status: { + phase: "Pending", + containerStatuses: [ + { + name: "buildkit", + state: { waiting: { reason: "ContainerCreating" } }, + }, + ], + }, + }, + ]; + expect((await operations.getJob("builds", "job"))?.phase).toBe("starting"); + pods[0].status.phase = "Running"; + expect((await operations.getJob("builds", "job"))?.phase).toBe("starting"); + pods[0].status.containerStatuses[0].state = { + running: { startedAt: new Date() }, + }; + expect(await operations.getJob("builds", "job")).toMatchObject({ + phase: "running", + }); + (job.status as any).conditions = [ + { type: "Failed", status: "True", message: "crashed" }, + ]; + expect(await operations.getJob("builds", "job")).toMatchObject({ + phase: "failed", + error: "crashed", + }); +}); + function validLabelValue(value: string): boolean { return ( value.length <= 63 && @@ -221,11 +266,12 @@ describe("KubernetesBuildStore", () => { new Date(Date.UTC(2026, 0, 1, 0, 0, i)).toISOString(), ); record.status.state = i === 0 ? "queued" : "failed"; - if (i === 1) record.status.reconcileLease = { - holder: "worker", - token: "active-lease", - expiresAt: new Date(Date.now() + 60_000).toISOString(), - }; + if (i === 1) + record.status.reconcileLease = { + holder: "worker", + token: "active-lease", + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }; fake.maps.set(name("build", record.metadata.name), configMap(record)); } const old = build( @@ -253,7 +299,9 @@ describe("KubernetesBuildStore", () => { expect(fake.maps.has(name("build", "protected-lock"))).toBe(true); expect(fake.maps.has(name("build", "history-2"))).toBe(false); expect( - [...fake.maps.values()].filter((value) => value.metadata.labels?.["kuber.astrxl.dev/type"] === "build"), + [...fake.maps.values()].filter( + (value) => value.metadata.labels?.["kuber.astrxl.dev/type"] === "build", + ), ).toHaveLength(50); expect(fake.deletes.length).toBeGreaterThan(0); expect( diff --git a/tests/server/kubernetes-store.test.ts b/tests/server/kubernetes-store.test.ts index ef0174e..6889bae 100644 --- a/tests/server/kubernetes-store.test.ts +++ b/tests/server/kubernetes-store.test.ts @@ -26,7 +26,7 @@ function encode(value: string): string { } function secret( - recordType: "user" | "session", + recordType: "user" | "session" | "api-key", name: string, values: Record, ): StoredSecret { @@ -111,6 +111,65 @@ function setup(): { } describe("KubernetesAuthStore", () => { + test("persists absent expiry, reads finite legacy keys, and rejects malformed expiries", async () => { + const { fake, store } = setup(); + await store.putUser({ + username: "alice", + passwordHash: "hash", + roles: ["viewer"], + }); + const never = { + id: "never-expiring-key-123", + tokenHash: hashToken("never"), + username: "alice", + capabilities: ["kubernetes:read" as const], + }; + await store.createApiKey(never); + expect(fake.patches.at(-1)?.stringData).not.toHaveProperty("expiresAt"); + expect(await store.getApiKey(never.tokenHash)).toMatchObject(never); + const finite = { + ...never, + id: "finite-expiry-key-123", + tokenHash: hashToken("finite"), + expiresAt: "2020-01-01T00:00:00.000Z", + }; + await store.createApiKey(finite); + expect( + (await store.listApiKeys("alice")).map((key) => key.expiresAt), + ).toEqual([finite.expiresAt, undefined]); + expect(await store.deleteExpiredApiKeys()).toBe(1); + expect(await store.getApiKey(never.tokenHash)).toMatchObject(never); + const active = { + ...finite, + id: "active-finite-key-123", + tokenHash: hashToken("active-finite"), + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }; + await store.createApiKey(active); + expect(await store.getApiKey(active.tokenHash)).toMatchObject(active); + const name = objectName("api-key", never.tokenHash); + for (const expiry of ["none", "", "2026-09-03"]) { + fake.secrets.set( + name, + secret("api-key", name, { + id: never.id, + tokenHash: never.tokenHash, + username: never.username, + capabilities: JSON.stringify(never.capabilities), + workspace: "", + disabled: "false", + expiresAt: expiry, + }), + ); + expect(await store.getApiKey(never.tokenHash)).toBeUndefined(); + } + expect(await store.revokeApiKey("bob", finite.id)).toBe(false); + await expect( + store.createApiKey({ ...never, username: "missing" }), + ).rejects.toThrow("not active"); + await store.updateUser("alice", { disabled: true }); + await expect(store.createApiKey(never)).rejects.toThrow("not active"); + }); test("validates the session and user from the store on every request", async () => { const { fake, store } = setup(); const tokenHash = hashToken("fresh"); diff --git a/tests/server/registry.test.ts b/tests/server/registry.test.ts index 6c32476..a0ec0da 100644 --- a/tests/server/registry.test.ts +++ b/tests/server/registry.test.ts @@ -25,6 +25,59 @@ describe("OCI registry digest resolution", () => { ).toThrow("Invalid image reference"); }); + test("accepts distribution tags and digest-pinned references", () => { + for (const tag of [ + "latest", + "v1.2.3", + "Release_2026-10", + "_build", + "a".repeat(128), + ]) { + expect(parseImageReference(`localhost:5000/team/my_image:${tag}`)).toEqual({ + registry: "localhost:5000", + repository: "team/my_image", + reference: tag, + }); + } + const digest = `sha256:${"a".repeat(64)}` as const; + expect( + parseImageReference(`registry.example.com/team/my-image:Release_1@${digest}`), + ).toEqual({ + registry: "registry.example.com", + repository: "team/my-image", + reference: digest, + digest, + }); + expect( + parseImageReference(`registry.example.com/team/my-image@${digest}`).digest, + ).toBe(digest); + }); + + test("rejects malformed tags and uppercase repository names", () => { + for (const tag of [ + "bad+tag", + ".leading", + "-leading", + "bad:tag", + "bad@tag", + "bad/tag", + "é", + "a".repeat(129), + "", + ]) { + expect(() => + parseImageReference(`registry.example.com/team/image:${tag}`), + ).toThrow(); + } + expect(() => + parseImageReference("registry.example.com/Team/image:Release_1"), + ).toThrow("Invalid image reference"); + const digest = `sha256:${"a".repeat(64)}` as const; + expect(() => + parseImageReference(`registry.example.com/team/image:bad+tag@${digest}`), + ).toThrow("Invalid image reference"); + }); + test("resolves an anonymous manifest using the advertised digest", async () => { const expected = `sha256:${"b".repeat(64)}` as const; const calls: Array<{ url: string; authorization: string | null }> = [];