import type { KubernetesObject, V1Deployment } from "@kubernetes/client-node"; import type { ComposeSpecification } from "../schema/docker.d"; import { LABELS } from "../const"; import { applyResource, getStaleResources, listManagedResources, sortResources, } from "../lib/apply"; import { DATABASE_NAMESPACE, getComposePostgresClaims, getRoleCredentials, listManagedDatabaseResources, reconcilePostgresClaims, type PostgresClaim, type RoleCredentials, } from "../lib/database"; import { buildNamespaceGraphs, fetchNamespaceObjects, type NamespaceGraph, } from "../lib/graph"; import { planRollback, rollbackDeployment, type RollbackCandidate, } from "../lib/rollback"; import { listManagedDeployments, restartDeployment, scaleDeployment, waitForDeploymentRollout, } from "../lib/shared"; import { getComposeS3Claims, getS3Credentials, listManagedStorageResources, reconcileS3Claims, STORAGE_NAMESPACE, type S3Claim, } from "../lib/storage"; export const WORKSPACE_UID_LABEL = "kuber.dev/workspace-uid"; export const WORKSPACE_PROJECT_LABEL = "kuber.dev/project"; export const RESERVED_NAMESPACES = new Set([ "default", "kube-system", "kube-public", "kube-node-lease", DATABASE_NAMESPACE, STORAGE_NAMESPACE, ]); export type Workspace = { project: string; uid: string; }; export type NamespaceRecord = { uid?: string; labels?: Record; }; export type NamespaceSafety = { project: string; status: "missing" | "owned" | "external" | "different-workspace"; namespaceUid?: string; }; export type ResourceIdentity = { apiVersion: string; kind: string; name: string; namespace?: string; uid: string; workspaceUid: string; }; export type ResourcePlan = { desired: KubernetesObject[]; stale: ResourceIdentity[]; }; export type DownPlan = { full: boolean; retained: ResourceIdentity[]; delete: ResourceIdentity[]; }; export type CredentialMetadata = { service: string; provider: "postgres" | "s3"; principal: string; resource: string; secretNamespace: string; secretName?: string; }; export type OperationProgressEmitter = (event: { resource: { apiVersion: string; kind: string; name: string; namespace?: string; }; phase: "apply" | "wait" | "delete"; state: "started" | "succeeded" | "failed" | "aborted"; }) => Promise; export type OperationExecution = { signal?: AbortSignal; emit?: OperationProgressEmitter; }; export type ManagementDependencies = { readNamespace(project: string): Promise; listDeployments(project: string): Promise; scaleDeployment( project: string, name: string, replicas: number, execution?: OperationExecution, ): Promise; restartDeployment( project: string, name: string, execution?: OperationExecution, ): Promise; waitForDeployment( project: string, name: string, timeoutMs?: number, execution?: OperationExecution, ): Promise; fetchGraphObjects(project: string): Promise; planRollback(project: string, names?: string[]): Promise; rollbackDeployment( project: string, candidate: RollbackCandidate, execution?: OperationExecution, ): Promise; listProjectResources(project: string): Promise; listDatabaseResources(project: string): Promise; listStorageResources(project: string): Promise; findStaleResources( project: string, desired: KubernetesObject[], ): Promise; applyResource( resource: KubernetesObject, execution?: OperationExecution, ): Promise; deleteResource( identity: ResourceIdentity, execution?: OperationExecution, ): Promise; reconcileDatabases( project: string, compose: ComposeSpecification, execution?: OperationExecution, ): Promise>>; getDatabaseCredentials(username: string): Promise; reconcileStorage( project: string, compose: ComposeSpecification, execution?: OperationExecution, ): Promise>>; getStorageCredentials(claim: S3Claim): Promise>; }; export type ManagementService = ReturnType; const defaultOperations: Omit< ManagementDependencies, "readNamespace" | "deleteResource" > = { listDeployments: async () => listManagedDeployments(), scaleDeployment: async (_project, name, replicas) => scaleDeployment(name, replicas), restartDeployment: async (_project, name) => restartDeployment(name), waitForDeployment: async (_project, name, timeoutMs) => waitForDeploymentRollout(name, timeoutMs), fetchGraphObjects: fetchNamespaceObjects, planRollback: async (_project, names) => planRollback(names), rollbackDeployment: async (_project, candidate) => rollbackDeployment(candidate), listProjectResources: listManagedResources, listDatabaseResources: listManagedDatabaseResources, listStorageResources: listManagedStorageResources, findStaleResources: getStaleResources, applyResource, reconcileDatabases: async (project, compose, execution) => reconcilePostgresClaims(project, compose, execution?.signal), getDatabaseCredentials: getRoleCredentials, reconcileStorage: async (project, compose, execution) => reconcileS3Claims(project, compose, execution?.signal), getStorageCredentials: getS3Credentials, }; export function managementDependencies( infrastructure: Pick< ManagementDependencies, "readNamespace" | "deleteResource" >, overrides: Partial = {}, ): ManagementDependencies { return { ...defaultOperations, ...infrastructure, ...overrides }; } function validateWorkspace(workspace: Workspace): void { if (!workspace.uid.trim()) throw new Error("Workspace UID is required"); if ( !workspace.project || workspace.project.length > 63 || !/^[a-z0-9](?:[-a-z0-9]*[a-z0-9])?$/.test(workspace.project) ) { throw new Error(`Invalid project namespace ${workspace.project}`); } if (RESERVED_NAMESPACES.has(workspace.project)) { throw new Error(`Namespace ${workspace.project} is reserved`); } } function throwIfExecutionAborted(execution?: OperationExecution): void { if (!execution?.signal?.aborted) return; throw new Error("Workspace operation execution was cancelled"); } function resourceName(resource: KubernetesObject): string { const name = resource.metadata?.name; if (!resource.apiVersion || !resource.kind || !name) { throw new Error("Resources require apiVersion, kind, and metadata.name"); } return name; } function resourceProgressIdentity( resource: Pick< ResourceIdentity, "apiVersion" | "kind" | "name" | "namespace" >, ) { return { apiVersion: resource.apiVersion, kind: resource.kind, name: resource.name, ...(resource.namespace && { namespace: resource.namespace }), }; } function assertResourceOwnership( workspace: Workspace, resource: KubernetesObject, ): void { const owner = resource.metadata?.labels?.[WORKSPACE_UID_LABEL]; if (owner !== workspace.uid) { throw new Error( `${resource.kind}/${resourceName(resource)} is not owned by workspace ${workspace.uid}`, ); } } function identity( workspace: Workspace, resource: KubernetesObject, ): ResourceIdentity { assertResourceOwnership(workspace, resource); const uid = resource.metadata?.uid; if (!uid) { throw new Error( `${resource.kind}/${resourceName(resource)} has no UID deletion precondition`, ); } return { apiVersion: resource.apiVersion!, kind: resource.kind!, name: resourceName(resource), namespace: resource.metadata?.namespace, uid, workspaceUid: workspace.uid, }; } function labelDesired( workspace: Workspace, resource: KubernetesObject, ): KubernetesObject { const name = resourceName(resource); const namespace = resource.kind === "Namespace" ? undefined : (resource.metadata?.namespace ?? workspace.project); if (resource.kind === "Namespace" && name !== workspace.project) { throw new Error( `Cannot manage namespace ${name} from project ${workspace.project}`, ); } if (namespace && namespace !== workspace.project) { throw new Error( `Cannot manage ${resource.kind}/${name} in namespace ${namespace}`, ); } const existingOwner = resource.metadata?.labels?.[WORKSPACE_UID_LABEL]; if (existingOwner && existingOwner !== workspace.uid) { throw new Error(`${resource.kind}/${name} belongs to another workspace`); } return { ...resource, metadata: { ...resource.metadata, name, namespace, labels: { ...resource.metadata?.labels, ...LABELS, [WORKSPACE_PROJECT_LABEL]: workspace.project, [WORKSPACE_UID_LABEL]: workspace.uid, }, }, }; } function labelExternal( workspace: Workspace, resource: KubernetesObject, namespace: string, ): KubernetesObject { const name = resourceName(resource); if (resource.metadata?.namespace !== namespace) { throw new Error( `${resource.kind}/${name} is outside expected namespace ${namespace}`, ); } const existingOwner = resource.metadata?.labels?.[WORKSPACE_UID_LABEL]; if (existingOwner && existingOwner !== workspace.uid) { throw new Error(`${resource.kind}/${name} belongs to another workspace`); } const { status: _status, ...body } = resource as KubernetesObject & { status?: unknown; }; return { ...body, metadata: { ...body.metadata, labels: { ...body.metadata?.labels, [WORKSPACE_PROJECT_LABEL]: workspace.project, [WORKSPACE_UID_LABEL]: workspace.uid, }, }, }; } function deploymentNames(deployments: V1Deployment[]): string[] { return deployments .map((deployment) => deployment.metadata?.name) .filter((name): name is string => Boolean(name)); } function selectTargets(all: string[], requested?: string[]): string[] { if (!requested) return all; const unique = [...new Set(requested)]; const available = new Set(all); for (const name of unique) { if (!available.has(name)) throw new Error(`No managed deployment named ${name}`); } return unique; } export function createManagementService(dependencies: ManagementDependencies) { async function namespaceSafety( workspace: Workspace, ): Promise { validateWorkspace(workspace); const namespace = await dependencies.readNamespace(workspace.project); if (!namespace) return { project: workspace.project, status: "missing" }; const managed = namespace.labels?.["app.kubernetes.io/managed-by"] === LABELS["app.kubernetes.io/managed-by"]; if (!managed) { return { project: workspace.project, status: "external", namespaceUid: namespace.uid, }; } if (namespace.labels?.[WORKSPACE_UID_LABEL] !== workspace.uid) { return { project: workspace.project, status: "different-workspace", namespaceUid: namespace.uid, }; } return { project: workspace.project, status: "owned", namespaceUid: namespace.uid, }; } async function assertSafe( workspace: Workspace, allowMissing = false, ): Promise { const safety = await namespaceSafety(workspace); if ( safety.status === "owned" || (allowMissing && safety.status === "missing") ) { return safety; } throw new Error( `Namespace ${workspace.project} is ${safety.status}; refusing workspace mutation`, ); } async function targets(workspace: Workspace, names?: string[]) { await assertSafe(workspace); return selectTargets( deploymentNames(await dependencies.listDeployments(workspace.project)), names, ); } async function ownExternalResources( workspace: Workspace, namespace: string, resources: KubernetesObject[], execution?: OperationExecution, ): Promise { for (const resource of resources) { throwIfExecutionAborted(execution); await dependencies.applyResource( labelExternal(workspace, resource, namespace), execution, ); } } async function deleteResources( workspace: Workspace, resources: ResourceIdentity[], execution?: OperationExecution, ): Promise { await assertSafe(workspace); for (const resource of resources) { if (!resource.uid || resource.workspaceUid !== workspace.uid) { throw new Error( `${resource.kind}/${resource.name} has an invalid workspace deletion identity`, ); } } for (const resource of resources) { const event = { resource: resourceProgressIdentity(resource), phase: "delete" as const, }; try { throwIfExecutionAborted(execution); await execution?.emit?.({ ...event, state: "started" }); await dependencies.deleteResource(resource, execution); await execution?.emit?.({ ...event, state: "succeeded" }); } catch (error) { await execution?.emit?.({ ...event, state: execution?.signal?.aborted ? "aborted" : "failed", }); throw error; } } } async function planDown( workspace: Workspace, full = false, ): Promise { const safety = await assertSafe(workspace); const projectResources = sortResources( await dependencies.listProjectResources(workspace.project), ); const retainedResources = full ? [] : projectResources.filter( (resource) => resource.kind === "Ingress" || resource.kind === "PersistentVolumeClaim", ); const deleteResources = full ? [...projectResources] : projectResources.filter( (resource) => !retainedResources.includes(resource), ); if (full) { deleteResources.push( ...(await dependencies.listDatabaseResources(workspace.project)), ...(await dependencies.listStorageResources(workspace.project)), ); if (!safety.namespaceUid) { throw new Error( `Namespace ${workspace.project} has no UID deletion precondition`, ); } deleteResources.push({ apiVersion: "v1", kind: "Namespace", metadata: { name: workspace.project, uid: safety.namespaceUid, labels: { ...LABELS, [WORKSPACE_PROJECT_LABEL]: workspace.project, [WORKSPACE_UID_LABEL]: workspace.uid, }, }, }); } return { full, retained: retainedResources.map((item) => identity(workspace, item)), delete: sortResources(deleteResources) .reverse() .map((item) => identity(workspace, item)), }; } return { namespaceSafety, async graphStatus( workspace: Workspace, options: { includeIdle?: boolean } = {}, ): Promise { await assertSafe(workspace); return buildNamespaceGraphs( await dependencies.fetchGraphObjects(workspace.project), options, ); }, async stop( workspace: Workspace, names?: string[], execution?: OperationExecution, ): Promise { throwIfExecutionAborted(execution); const selected = await targets(workspace, names); const selectedSet = new Set(selected); const hpas = (await dependencies.listProjectResources(workspace.project)) .filter( (resource) => resource.apiVersion === "autoscaling/v2" && resource.kind === "HorizontalPodAutoscaler" && resource.metadata?.name !== undefined && selectedSet.has(resource.metadata.name), ) .map((resource) => identity(workspace, resource)); if (hpas.length > 0) { await deleteResources(workspace, hpas, execution); } for (const name of selected) { throwIfExecutionAborted(execution); await dependencies.scaleDeployment( workspace.project, name, 0, execution, ); } return selected; }, async restart( workspace: Workspace, names?: string[], execution?: OperationExecution, ): Promise { const selected = await targets(workspace, names); for (const name of selected) { throwIfExecutionAborted(execution); await dependencies.restartDeployment( workspace.project, name, execution, ); } return selected; }, async rollback( workspace: Workspace, names?: string[], timeoutMs?: number, execution?: OperationExecution, ): Promise { throwIfExecutionAborted(execution); await assertSafe(workspace); const candidates = await dependencies.planRollback( workspace.project, names, ); for (const candidate of candidates) { throwIfExecutionAborted(execution); await dependencies.rollbackDeployment( workspace.project, candidate, execution, ); } for (const candidate of candidates) { throwIfExecutionAborted(execution); await dependencies.waitForDeployment( workspace.project, candidate.name, timeoutMs, execution, ); } return candidates; }, databaseCredentialsMetadata( compose: ComposeSpecification, ): CredentialMetadata[] { return getComposePostgresClaims(compose).map((claim: PostgresClaim) => ({ service: claim.service, provider: "postgres", principal: claim.username, resource: claim.database, secretNamespace: DATABASE_NAMESPACE, secretName: claim.secretName, })); }, async reconcileDatabases( workspace: Workspace, compose: ComposeSpecification, execution?: OperationExecution, ) { throwIfExecutionAborted(execution); await assertSafe(workspace, true); const environment = await dependencies.reconcileDatabases( workspace.project, compose, execution, ); await ownExternalResources( workspace, DATABASE_NAMESPACE, await dependencies.listDatabaseResources(workspace.project), execution, ); return environment; }, async getDatabaseCredentials(workspace: Workspace, username: string) { await assertSafe(workspace); const databases = await dependencies.listDatabaseResources( workspace.project, ); const database = databases.find( (resource) => resource.kind === "Database" && (resource as KubernetesObject & { spec?: { owner?: string } }).spec ?.owner === username, ); if (!database) { throw new Error( `Database role ${username} is not managed by this workspace`, ); } assertResourceOwnership(workspace, database); return dependencies.getDatabaseCredentials(username); }, storageCredentialsMetadata( compose: ComposeSpecification, ): CredentialMetadata[] { return getComposeS3Claims(compose).map((claim) => ({ service: claim.service, provider: "s3", principal: claim.key, resource: claim.bucket, secretNamespace: STORAGE_NAMESPACE, })); }, async reconcileStorage( workspace: Workspace, compose: ComposeSpecification, execution?: OperationExecution, ) { throwIfExecutionAborted(execution); await assertSafe(workspace, true); const environment = await dependencies.reconcileStorage( workspace.project, compose, execution, ); await ownExternalResources( workspace, STORAGE_NAMESPACE, await dependencies.listStorageResources(workspace.project), execution, ); return environment; }, async getStorageCredentials(workspace: Workspace, claim: S3Claim) { await assertSafe(workspace); const resources = await dependencies.listStorageResources( workspace.project, ); const key = resources.find( (resource) => resource.kind === "GarageKey" && resource.metadata?.name === claim.key, ); const bucket = resources.find( (resource) => resource.kind === "GarageBucket" && resource.metadata?.name === claim.bucket, ); if (!key || !bucket) { throw new Error( `S3 claim ${claim.key}/${claim.bucket} is not managed by this workspace`, ); } assertResourceOwnership(workspace, key); assertResourceOwnership(workspace, bucket); return dependencies.getStorageCredentials(claim); }, async planResources( workspace: Workspace, desired: KubernetesObject[], ): Promise { await assertSafe(workspace, true); const labeled = sortResources( desired.map((item) => labelDesired(workspace, item)), ); const stale = await dependencies.findStaleResources( workspace.project, labeled, ); return { desired: labeled, stale: sortResources(stale) .reverse() .map((item) => identity(workspace, item)), }; }, async applyResources( workspace: Workspace, resources: KubernetesObject[], execution?: OperationExecution, ): Promise { await assertSafe(workspace, true); const applied: KubernetesObject[] = []; for (const resource of sortResources( resources.map((item) => labelDesired(workspace, item)), )) { const event = { resource: resourceProgressIdentity({ apiVersion: resource.apiVersion!, kind: resource.kind!, name: resourceName(resource), namespace: resource.metadata?.namespace, }), phase: "apply" as const, }; try { throwIfExecutionAborted(execution); await execution?.emit?.({ ...event, state: "started" }); applied.push(await dependencies.applyResource(resource, execution)); await execution?.emit?.({ ...event, state: "succeeded" }); } catch (error) { await execution?.emit?.({ ...event, state: execution?.signal?.aborted ? "aborted" : "failed", }); throw error; } } return applied; }, async waitForResources( workspace: Workspace, deploymentTargets: string[], timeoutMs?: number, execution?: OperationExecution, ): Promise { const selected = await targets(workspace, deploymentTargets); for (const name of selected) { const event = { resource: { apiVersion: "apps/v1", kind: "Deployment", name, namespace: workspace.project, }, phase: "wait" as const, }; try { throwIfExecutionAborted(execution); await execution?.emit?.({ ...event, state: "started" }); await dependencies.waitForDeployment( workspace.project, name, timeoutMs, execution, ); await execution?.emit?.({ ...event, state: "succeeded" }); } catch (error) { await execution?.emit?.({ ...event, state: execution?.signal?.aborted ? "aborted" : "failed", }); throw error; } } }, deleteResources, planDown, async down( workspace: Workspace, full = false, execution?: OperationExecution, ): Promise { throwIfExecutionAborted(execution); const plan = await planDown(workspace, full); await deleteResources(workspace, plan.delete, execution); return plan; }, }; }