import { open, readFile, readdir, realpath, rename, rm, stat, } from "node:fs/promises"; import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path"; import { randomUUID } from "node:crypto"; import { YAML } from "bun"; import type { Service } from "../schema/docker.d"; import { validateBuildpackUri } from "../shared/build-protocol"; import { readCompose, resolveComposeFile } from "./yaml"; import { renderDockerfileTemplate, templateIds, type DockerfileTemplateId, } from "./scaffold-templates"; export const managedHeader = "managedBy: kuber # See https://npmx.dev/@dmgnr/kuber for documentation."; const manifests = new Set([ "package.json", "bun.lock", "bun.lockb", "pnpm-lock.yaml", "pyproject.toml", "requirements.txt", "go.mod", "Cargo.toml", ]); const excluded = new Set([ ".git", "node_modules", ".next", "dist", "build", "target", ".venv", "vendor", ]); const serviceNamePattern = /^[a-z][a-z0-9-]*$/; export type Source = | { kind: "image"; image: string } | { kind: "auto"; uri?: string } | { kind: "template"; template: DockerfileTemplateId }; export type AppOptions = { name: string; path: string; source: Source; postgres?: string; s3?: string; cpu?: string; memory?: string; replicas?: number; }; export function projectName(name: string): string { const value = name .toLowerCase() .replace(/[^a-z0-9-]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 63) .replace(/-+$/, ""); if (!value) throw new Error("Project name must contain letters or numbers"); return value; } export function validateAppPath(path: string): string { const normalized = path.replaceAll("\\", "/").replace(/\/$/, "") || "."; if ( isAbsolute(normalized) || normalized.split("/").some((part) => part === ".." || part === "") ) throw new Error("App path must be relative and stay within the project"); return normalized; } export async function checkedAppPath( root: string, path: string, ): Promise { const normalized = validateAppPath(path); const absolute = resolve(root, normalized); const [realRoot, realTarget, info] = await Promise.all([ realpath(root), realpath(absolute), stat(absolute), ]); if ( !info.isDirectory() || (realTarget !== realRoot && !realTarget.startsWith(`${realRoot}${sep}`)) ) throw new Error("App path must be a directory inside the project"); return normalized; } export async function detectAppPaths(root: string): Promise { const results: string[] = []; async function visit(path: string, depth: number): Promise { const entries = await readdir(join(root, path), { withFileTypes: true }); if (entries.some((entry) => entry.isFile() && manifests.has(entry.name))) results.push(path); if (depth >= 3) return; for (const entry of entries) { if ( entry.isDirectory() && !excluded.has(entry.name) && !entry.name.startsWith(".") ) await visit( path === "." ? entry.name : `${path}/${entry.name}`, depth + 1, ); } } await visit(".", 0); return results; } export function serviceForApp(app: AppOptions): Service { if (!serviceNamePattern.test(app.name) || app.name.length > 63) throw new Error( "Service name must be lowercase letters, digits and hyphens, starting with a letter (max 63)", ); const path = validateAppPath(app.path); const service: Service = {}; if (app.source.kind === "image") { if (!app.source.image.trim()) throw new Error("Image must not be empty"); service.image = app.source.image.trim(); } else { if (app.source.kind === "auto" && app.source.uri?.trim()) validateBuildpackUri(app.source.uri.trim()); if ( app.source.kind === "template" && !templateIds.includes(app.source.template) ) throw new Error(`Unknown Dockerfile template: ${app.source.template}`); service.build = app.source.kind === "auto" ? app.source.uri?.trim() ? `auto:${app.source.uri.trim()}` : "auto" : path; if (app.source.kind === "auto" && path !== ".") service["x-kuber-build-context"] = path; } const volumes: string[] = []; for (const [claim, value] of [ ["postgresql", app.postgres], ["s3", app.s3], ] as const) { if (value) { if (!/^[a-z0-9][a-z0-9-]*(?:\/[a-z0-9][a-z0-9-]*)?$/.test(value)) throw new Error(`Invalid ${claim} claim: ${value}`); volumes.push(`${claim}:${value}`); } } if (volumes.length) service.volumes = volumes; if ( app.cpu !== undefined || app.memory !== undefined || app.replicas !== undefined ) { if ( app.cpu !== undefined && (!/^\d+(?:\.\d+)?$/.test(app.cpu) || Number(app.cpu) <= 0) ) throw new Error("CPU must be a positive number of cores"); if ( app.memory !== undefined && (!/^\d+(?:\.\d+)?(?:[kKmMgGtT]|[KMGT]i|[KMGT][bB])?$/.test(app.memory) || Number.parseFloat(app.memory) <= 0) ) throw new Error("Memory must be a positive quantity (e.g. 512m)"); if ( app.replicas !== undefined && (!Number.isSafeInteger(app.replicas) || app.replicas < 1) ) throw new Error("Replicas must be a positive integer"); service.deploy = { ...(app.replicas !== undefined ? { replicas: app.replicas } : {}), ...(app.cpu !== undefined || app.memory !== undefined ? { resources: { limits: { ...(app.cpu !== undefined ? { cpus: app.cpu } : {}), ...(app.memory !== undefined ? { memory: app.memory } : {}), }, }, } : {}), }; } return service; } function serviceBlock(name: string, service: Service): string { const text = YAML.stringify({ [name]: service }, null, 2).trimEnd(); return `${text .split("\n") .map((line) => ` ${line}`) .join("\n")}\n`; } async function writeExclusive(path: string, content: string): Promise { const file = await open(path, "wx"); try { await file.writeFile(content); } catch (error) { await file.close(); await rm(path, { force: true }); throw error; } await file.close(); } /** Existing YAML is kept byte-for-byte; only the new service block is inserted. */ export async function addAppToCompose( root: string, app: AppOptions, composeFile?: string, ): Promise { const path = composeFile ?? (await resolveComposeFile(root)); if (!path) throw new Error("Compose file cannot be found. Run kuber init first."); await checkedAppPath(root, app.path); const service = serviceForApp(app); const generated: string[] = []; const lock = await open(`${path}.kuber.lock`, "wx"); try { const original = await readFile(path, "utf8"); const compose = await readCompose(path); if (Object.hasOwn(compose.services ?? {}, app.name)) throw new Error(`Service ${app.name} already exists`); const source = app.source; if (source.kind === "template") { const templates = renderDockerfileTemplate(source.template); for (const [file, content] of [ ["Dockerfile", templates.dockerfile], [".dockerignore", templates.dockerignore], ] as const) { const target = join(root, app.path, file); await writeExclusive(target, content); generated.push(target); } } const lines = original.split("\n"); const serviceIndex = lines.findIndex((line) => /^services:\s*(?:#.*)?$/.test(line), ); const inlineServicesIndex = lines.findIndex((line) => /^services:\s*\{\}\s*(?:#.*)?$/.test(line), ); if (serviceIndex < 0 && Object.hasOwn(compose, "services")) if (inlineServicesIndex < 0) throw new Error( "Cannot safely insert a service into this Compose file", ); let updated: string; if (inlineServicesIndex >= 0) { const line = lines[inlineServicesIndex]!; const match = /^(services:\s*)\{\}(\s*(?:#.*)?)$/.exec(line)!; lines[inlineServicesIndex] = `${match[1]!.trimEnd()}${match[2]!.trimEnd()}`; const prefix = lines.slice(0, inlineServicesIndex + 1).join("\n") + "\n"; updated = prefix + serviceBlock(app.name, service) + lines.slice(inlineServicesIndex + 1).join("\n"); } else if (serviceIndex < 0) updated = `${original.trimEnd()}\nservices:\n${serviceBlock(app.name, service)}`; else { let end = serviceIndex + 1; while (end < lines.length && !/^[^\s#][^:]*:/.test(lines[end]!)) end++; const prefix = lines.slice(0, end).join("\n").replace(/\n*$/, "\n"); updated = prefix + serviceBlock(app.name, service) + lines.slice(end).join("\n"); } if ((await readFile(path, "utf8")) !== original) throw new Error("Compose file changed while adding the service"); const temporary = join( dirname(path), `.${basename(path)}.${randomUUID()}.tmp`, ); try { await writeExclusive(temporary, updated); await rename(temporary, path); } finally { await rm(temporary, { force: true }); } return path; } catch (error) { await Promise.all(generated.map((file) => rm(file, { force: true }))); throw error; } finally { await lock.close(); await rm(`${path}.kuber.lock`, { force: true }); } } export async function createCompose( root: string, project: string, app: AppOptions, ): Promise { if (await resolveComposeFile(root)) throw new Error("A Compose file already exists; use kuber add app instead"); await checkedAppPath(root, app.path); const service = serviceForApp(app); const path = join(root, "compose.yml"); const content = `${managedHeader}\nname: ${JSON.stringify(project)}\nservices:\n${serviceBlock(app.name, service)}`; const generated: string[] = []; try { if (app.source.kind === "template") { const template = renderDockerfileTemplate(app.source.template); for (const [file, text] of [ ["Dockerfile", template.dockerfile], [".dockerignore", template.dockerignore], ] as const) { const target = join(root, app.path, file); await writeExclusive(target, text); generated.push(target); } } await writeExclusive(path, content); return path; } catch (error) { await Promise.all(generated.map((file) => rm(file, { force: true }))); throw error; } }