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", "kuber-system", 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 ManagementDependencies = { readNamespace(project: string): Promise; listDeployments(project: string): Promise; scaleDeployment( project: string, name: string, replicas: number, ): Promise; restartDeployment(project: string, name: string): Promise; waitForDeployment( project: string, name: string, timeoutMs?: number, ): Promise; fetchGraphObjects(project: string): Promise; planRollback(project: string, names?: string[]): Promise; rollbackDeployment( project: string, candidate: RollbackCandidate, ): Promise; listProjectResources(project: string): Promise; listDatabaseResources(project: string): Promise; listStorageResources(project: string): Promise; findStaleResources( project: string, desired: KubernetesObject[], ): Promise; applyResource(resource: KubernetesObject): Promise; deleteResource(identity: ResourceIdentity): Promise; reconcileDatabases( project: string, compose: ComposeSpecification, ): Promise>>; getDatabaseCredentials(username: string): Promise; reconcileStorage( project: string, compose: ComposeSpecification, ): 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: reconcilePostgresClaims, getDatabaseCredentials: getRoleCredentials, reconcileStorage: reconcileS3Claims, 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 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 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[], ): Promise { for (const resource of resources) { await dependencies.applyResource( labelExternal(workspace, resource, namespace), ); } } async function deleteResources( workspace: Workspace, resources: ResourceIdentity[], ): 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) { await dependencies.deleteResource(resource); } } 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[]): Promise { 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); } for (const name of selected) { await dependencies.scaleDeployment(workspace.project, name, 0); } return selected; }, async restart(workspace: Workspace, names?: string[]): Promise { const selected = await targets(workspace, names); for (const name of selected) { await dependencies.restartDeployment(workspace.project, name); } return selected; }, async rollback( workspace: Workspace, names?: string[], timeoutMs?: number, ): Promise { await assertSafe(workspace); const candidates = await dependencies.planRollback( workspace.project, names, ); for (const candidate of candidates) { await dependencies.rollbackDeployment(workspace.project, candidate); } for (const candidate of candidates) { await dependencies.waitForDeployment( workspace.project, candidate.name, timeoutMs, ); } 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, ) { await assertSafe(workspace, true); const environment = await dependencies.reconcileDatabases( workspace.project, compose, ); await ownExternalResources( workspace, DATABASE_NAMESPACE, await dependencies.listDatabaseResources(workspace.project), ); 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, ) { await assertSafe(workspace, true); const environment = await dependencies.reconcileStorage( workspace.project, compose, ); await ownExternalResources( workspace, STORAGE_NAMESPACE, await dependencies.listStorageResources(workspace.project), ); 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[], ): Promise { await assertSafe(workspace, true); const applied: KubernetesObject[] = []; for (const resource of sortResources( resources.map((item) => labelDesired(workspace, item)), )) { applied.push(await dependencies.applyResource(resource)); } return applied; }, async waitForResources( workspace: Workspace, deploymentTargets: string[], timeoutMs?: number, ): Promise { const selected = await targets(workspace, deploymentTargets); for (const name of selected) { await dependencies.waitForDeployment( workspace.project, name, timeoutMs, ); } }, deleteResources, planDown, async down(workspace: Workspace, full = false): Promise { const plan = await planDown(workspace, full); await deleteResources(workspace, plan.delete); return plan; }, }; }