Files
kuber/lib/scaffold.ts
T
2026-10-07 05:10:59 +00:00

336 lines
10 KiB
TypeScript

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<string> {
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<string[]> {
const results: string[] = [];
async function visit(path: string, depth: number): Promise<void> {
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<void> {
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<string> {
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<string> {
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;
}
}