336 lines
10 KiB
TypeScript
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;
|
|
}
|
|
}
|