import { defineCommand } from "citty"; import { Listr } from "listr2"; import type { ComposeSpecification } from "../schema/docker.d"; import type { KuberResource } from "../types"; import { apiRequest, KuberApiError, type ApiRequestInit } from "../lib/api"; import { buildServices, getRepoRoot, resolveBuildImages, type ApiRequester, } from "../lib/build"; import { composeToKubernetes, type KubernetesResource } from "../lib/convert"; import { ctx } from "../lib/context"; import { getComposePostgresClaims } from "../lib/database"; import { getComposeS3Claims } from "../lib/storage"; import { enumerateWorkspace, type WorkspaceSnapshot } from "../lib/workspace"; type Workspace = { metadata: { name: string; uid: string; resourceVersion: string }; }; type ResourceIdentity = { apiVersion: string; kind: string; name: string; namespace?: string; uid: string; workspaceUid: string; }; type ResourcePlan = { desired: KubernetesResource[]; stale: ResourceIdentity[]; }; type UpContext = { compose?: ComposeSpecification; snapshot?: WorkspaceSnapshot; buildImages?: Record; serviceEnv?: Record>; resources?: KubernetesResource[]; plan?: ResourcePlan; }; export const WORKSPACE_ADOPTION_METHOD = "POST"; export function workspaceAdoptionRoute(project: string): string { return `/workspaces/${encodeURIComponent(project)}/adopt`; } export function mergeServiceEnv( current: Record> | undefined, next: Record>, ): Record> { const merged = { ...current }; for (const [service, environment] of Object.entries(next)) merged[service] = { ...merged[service], ...environment }; return merged; } export function getDeploymentNames(resources: KubernetesResource[]): string[] { return resources .filter((resource) => resource.kind === "Deployment") .map((resource) => resource.metadata?.name) .filter((name): name is string => Boolean(name)); } function workspaceInput( compose: ComposeSpecification, snapshot: WorkspaceSnapshot, ) { return { source: { uri: `cas://${snapshot.digest}`, digest: snapshot.digest, }, config: { compose }, }; } export async function ensureWorkspace( project: string, compose: ComposeSpecification, snapshot: WorkspaceSnapshot, request: ApiRequester = apiRequest, ): Promise { const path = `/workspaces/${encodeURIComponent(project)}`; const input = workspaceInput(compose, snapshot); let current: Workspace; try { current = await request(path); } catch (error) { if (!(error instanceof KuberApiError) || error.status !== 404) throw error; return request("/workspaces", { method: "POST", json: { id: project, ...input }, }); } return request(path, { method: "PUT", headers: { "if-match": `"${current.metadata.resourceVersion}"` }, json: input, }); } function operationEnvironment( response: Record, ): Record> { return Object.fromEntries( Object.entries(response).filter( ([key, value]) => key !== "operation" && key !== "operationId" && value !== null && typeof value === "object" && !Array.isArray(value), ), ) as Record>; } function adoptionHint(project: string, error: unknown): Error { const message = error instanceof Error ? error.message : String(error); if (!/namespace .* (?:external|different-workspace)/i.test(message)) return error instanceof Error ? error : new Error(message); return new Error( `${message}. Safe adoption requires ${WORKSPACE_ADOPTION_METHOD} ${workspaceAdoptionRoute(project)} with namespace UID and ownership preconditions.`, { cause: error }, ); } async function managementRequest( project: string, request: ApiRequester, path: string, init: ApiRequestInit, ): Promise { try { return await request(path, init); } catch (error) { throw adoptionHint(project, error); } } export async function reconcileResources( project: string, resources: KubernetesResource[], rolloutTimeoutMs: number, postApply: | ((resources: KubernetesResource[]) => void | Promise) | undefined, request: ApiRequester = apiRequest, ): Promise { const workspacePath = `/workspaces/${encodeURIComponent(project)}`; const plan = await managementRequest( project, request, `${workspacePath}/resources/plan`, { method: "POST", json: { resources } }, ); await managementRequest( project, request, `${workspacePath}/resources/apply`, { method: "POST", json: { resources: plan.desired }, }, ); await postApply?.(plan.desired); const deployments = getDeploymentNames(plan.desired); if (deployments.length > 0) { await managementRequest( project, request, `${workspacePath}/resources/wait`, { method: "POST", json: { deployments, timeoutMs: rolloutTimeoutMs }, }, ); } if (plan.stale.length > 0) { await managementRequest( project, request, `${workspacePath}/resources/delete`, { method: "POST", json: { resources: plan.stale }, }, ); } return plan; } export async function runUp( build: boolean, request: ApiRequester = apiRequest, ) { const { project, compose, cwd, config, hookContext: getHookContext } = ctx(); const workspacePath = `/workspaces/${encodeURIComponent(project)}`; const taskCtx = await new Listr( [ { title: "Read compose", task: async (taskCtx, task) => { taskCtx.compose = await compose(); if (build) await config.preBuild?.(taskCtx.compose, await getHookContext()); task.output = `${Object.keys(taskCtx.compose.services ?? {}).length} services`; }, }, { title: "Snapshot workspace", task: async (taskCtx, task) => { taskCtx.snapshot = await enumerateWorkspace(await getRepoRoot(cwd)); task.output = `${taskCtx.snapshot.manifest.files.length} files`; }, }, { title: "Build images", rendererOptions: { outputBar: 10, 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 }, ); taskCtx.buildImages = result.images; await config.postBuild?.(result, await getHookContext()); task.output = `Built ${result.built.length} image${result.built.length === 1 ? "" : "s"}`; }, }, { title: "Resolve images", enabled: async () => !build && Object.values((await compose()).services ?? {}).some( (service) => service.build, ), task: async (taskCtx, task) => { taskCtx.buildImages = await resolveBuildImages( project, taskCtx.compose!, { ...config, request, }, ); task.output = `${Object.keys(taskCtx.buildImages).length} images`; }, }, { title: "Update workspace", task: async (taskCtx, task) => { const workspace = await ensureWorkspace( project, taskCtx.compose!, taskCtx.snapshot!, request, ); const adopted = await request<{ resourcesAdopted: number }>( workspaceAdoptionRoute(project), { method: WORKSPACE_ADOPTION_METHOD, json: { workspaceUid: workspace.metadata.uid }, }, ); task.output = adopted.resourcesAdopted ? `Adopted ${adopted.resourcesAdopted} existing resources` : "Workspace ready"; }, }, { title: "Reconcile databases", enabled: async () => getComposePostgresClaims(await compose()).length > 0, task: async (taskCtx, task) => { const response = await managementRequest>( project, request, `${workspacePath}/databases`, { method: "POST", json: { compose: taskCtx.compose } }, ); taskCtx.serviceEnv = mergeServiceEnv( taskCtx.serviceEnv, operationEnvironment(response), ); task.output = `${Object.keys(taskCtx.serviceEnv).length} services`; }, }, { title: "Reconcile S3 storage", enabled: async () => getComposeS3Claims(await compose()).length > 0, task: async (taskCtx, task) => { const response = await managementRequest>( project, request, `${workspacePath}/storage`, { method: "POST", json: { compose: taskCtx.compose } }, ); taskCtx.serviceEnv = mergeServiceEnv( taskCtx.serviceEnv, operationEnvironment(response), ); task.output = `${Object.keys(taskCtx.serviceEnv).length} services`; }, }, { title: "Render manifests", task: async (taskCtx, task) => { taskCtx.resources = await composeToKubernetes( project, taskCtx.compose!, cwd, taskCtx.serviceEnv, taskCtx.buildImages, ); await config.postRender?.( taskCtx.resources as KuberResource[], await getHookContext(), ); task.output = `${taskCtx.resources.length} resources`; }, }, { title: "Reconcile resources", task: async (taskCtx, task) => { taskCtx.plan = await reconcileResources( project, taskCtx.resources!, config.rolloutTimeoutMs, config.postApply ? async (resources) => config.postApply?.( resources as KuberResource[], await getHookContext(), ) : undefined, request, ); task.output = `${taskCtx.plan.desired.length} applied, ${taskCtx.plan.stale.length} stale deleted`; }, }, ], { rendererOptions: { collapseErrors: false } }, ).run(); return taskCtx; } export const up = defineCommand({ meta: { name: "up", description: "Create and start deployments" }, args: { build: { type: "boolean", default: true, alias: "b", description: "Build images before starting deployments", negativeDescription: "Don't build an image, even if it's policy", }, }, async run({ args }) { await runUp(args.build); }, });