Files
kuber/lib/build.ts
T

644 lines
17 KiB
TypeScript

import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
import { cp, mkdir, mkdtemp, rm } from "node:fs/promises";
import { hostname, tmpdir } from "node:os";
import { AsyncLocalStorage } from "node:async_hooks";
import type { Writable } from "node:stream";
import type { ComposeSpecification, Service } from "../schema/docker.d";
import { getComposeArch, withComposeArch } from "./arch";
import { DEFAULT_BUILDERS, DEFAULT_REGISTRY } from "./config";
export type BuildOptions = {
registry?: string;
builders?: {
amd64?: string;
arm64?: string;
remoteRoot?: string;
};
};
type BuildRuntime = {
registry: string;
builders: {
amd64: string;
arm64: string;
remoteRoot: string;
};
};
const buildRuntime = new AsyncLocalStorage<BuildRuntime>();
function getBuildRuntime(): BuildRuntime {
return (
buildRuntime.getStore() ?? {
registry: DEFAULT_REGISTRY,
builders: DEFAULT_BUILDERS,
}
);
}
type BuildPlan = {
name: string;
image: string;
context: string;
dockerfile?: string;
target?: string;
buildArgs: string[];
};
type ProgressReporter = (message: string) => void | Promise<void>;
type BuildReporter = {
progress?: ProgressReporter;
stream?: Writable;
};
export type BuildResult = {
built: string[];
changed: string[];
};
type SpawnResult = {
exitCode: number;
stdout: string;
stderr: string;
};
function summarizeCommandFailure(
exitCode: number,
stderrLines: string[],
stdoutLines: string[],
): string {
const lines = (stderrLines.length > 0 ? stderrLines : stdoutLines)
.map((line) => line.trimEnd())
.filter(Boolean);
if (lines.length === 0) return `Command failed with exit code ${exitCode}`;
const preview = lines.slice(0, 8).join("\n");
return lines.length > 8
? `${preview}\n... (${lines.length - 8} more lines)`
: preview;
}
function toReadableStream(
stream: number | ReadableStream<Uint8Array> | undefined,
): ReadableStream<Uint8Array> | undefined {
return typeof stream === "number" ? undefined : stream;
}
function shellQuote(value: string): string {
return `'${value.replaceAll("'", `'"'"'`)}'`;
}
function resolveBuildArgs(service: Service): string[] {
if (
!service.build ||
typeof service.build === "string" ||
!service.build.args
) {
return [];
}
if (Array.isArray(service.build.args)) return [...service.build.args];
return Object.entries(service.build.args)
.filter(([, value]) => value !== null)
.map(([key, value]) => `${key}=${String(value)}`);
}
function resolveBuildPlan(
project: string,
name: string,
service: Service,
cwd: string,
repoRoot: string,
buildRoot: string,
): BuildPlan | undefined {
if (!service.build) return;
const build = service.build;
const contextInput =
typeof build === "string" ? build : (build.context ?? ".");
if (contextInput.includes("://")) {
throw new Error(
`Remote build context is not supported for service ${name}`,
);
}
const contextPath = resolve(cwd, contextInput);
const contextRelative = relative(repoRoot, contextPath);
if (contextRelative.startsWith("..") || isAbsolute(contextRelative)) {
throw new Error(
`Build context must stay inside the git repo for service ${name}`,
);
}
if (typeof build !== "string" && build.dockerfile_inline) {
throw new Error(`dockerfile_inline is not supported for service ${name}`);
}
const dockerfilePath =
typeof build === "string" || !build.dockerfile
? undefined
: resolve(contextPath, build.dockerfile);
const dockerfileRelative = dockerfilePath
? relative(repoRoot, dockerfilePath)
: undefined;
if (
dockerfileRelative?.startsWith("..") ||
isAbsolute(dockerfileRelative ?? "")
) {
throw new Error(
`Dockerfile must stay inside the git repo for service ${name}`,
);
}
return {
name,
image: `${getBuildRuntime().registry}/kuber/${project}-${name}:latest`,
context: `${buildRoot}/${contextRelative === "" ? "." : contextRelative}`,
dockerfile: dockerfileRelative
? `${buildRoot}/${dockerfileRelative}`
: undefined,
target: typeof build === "string" ? undefined : build.target,
buildArgs: resolveBuildArgs(service),
};
}
function getRemoteBuilder(): string {
const builders = getBuildRuntime().builders;
return getComposeArch() === "amd64" ? builders.amd64 : builders.arm64;
}
function isOnRemoteBuilder(): boolean {
return hostname() === getRemoteBuilder().split("@").at(-1);
}
async function pumpStream(
stream: ReadableStream<Uint8Array> | null | undefined,
onLine: (line: string) => void | Promise<void>,
) {
if (!stream) return;
const reader = stream.getReader();
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let newline = buffer.indexOf("\n");
while (newline !== -1) {
const line = buffer.slice(0, newline).replace(/\r$/, "");
buffer = buffer.slice(newline + 1);
if (line) await onLine(line);
newline = buffer.indexOf("\n");
}
}
buffer += decoder.decode();
const line = buffer.replace(/\r$/, "");
if (line) await onLine(line);
} finally {
reader.releaseLock();
}
}
async function runWithOutput(
command: Bun.Subprocess,
reporter?: BuildReporter,
) {
const stdoutLines: string[] = [];
const stderrLines: string[] = [];
let streamBuffer = "";
let flushTimer: ReturnType<typeof setTimeout> | undefined;
function flushStreamBuffer() {
if (!reporter?.stream || streamBuffer.length === 0) return;
reporter.stream.write(streamBuffer);
streamBuffer = "";
}
function queueStreamLine(line: string) {
streamBuffer += `${line}\n`;
if (streamBuffer.length >= 8192) {
if (flushTimer) {
clearTimeout(flushTimer);
flushTimer = undefined;
}
flushStreamBuffer();
return;
}
if (flushTimer) return;
flushTimer = setTimeout(() => {
flushTimer = undefined;
flushStreamBuffer();
}, 33);
}
await Promise.all([
pumpStream(toReadableStream(command.stdout), async (line) => {
stdoutLines.push(line);
if (reporter?.stream) queueStreamLine(line);
else if (reporter?.progress) await reporter.progress(line);
}),
pumpStream(toReadableStream(command.stderr), async (line) => {
stderrLines.push(line);
if (reporter?.stream) queueStreamLine(line);
else if (reporter?.progress) await reporter.progress(line);
}),
]);
if (flushTimer) {
clearTimeout(flushTimer);
flushTimer = undefined;
}
flushStreamBuffer();
const exitCode = await command.exited;
if (exitCode !== 0) {
throw new Error(
summarizeCommandFailure(exitCode, stderrLines, stdoutLines),
);
}
return {
exitCode,
stdout: stdoutLines.join("\n"),
stderr: stderrLines.join("\n"),
} satisfies SpawnResult;
}
function ssh(script: string) {
const remoteCommand = `bash -lc ${shellQuote(script)}`;
return Bun.spawn(["ssh", getRemoteBuilder(), remoteCommand], {
stdout: "pipe",
stderr: "pipe",
});
}
function scp(localPath: string, remotePath: string) {
return Bun.spawn(["scp", localPath, `${getRemoteBuilder()}:${remotePath}`], {
stdout: "pipe",
stderr: "pipe",
});
}
async function getRemoteImageDigest(
image: string,
): Promise<string | undefined> {
const inspectCommand = [
"docker",
"buildx",
"imagetools",
"inspect",
image,
"--format",
"{{json .Image}}",
]
.map(shellQuote)
.join(" ");
try {
const result = await runWithOutput(
ssh(`set -euo pipefail; ${inspectCommand} | sha256sum`),
);
const digest = /^([a-f0-9]{64})\s/.exec(result.stdout)?.[1];
return digest ? `sha256:${digest}` : undefined;
} catch {
return;
}
}
export function imageDigestChanged(
before: string | undefined,
after: string | undefined,
): boolean {
return !before || !after || before !== after;
}
async function getRepoRoot(cwd: string): Promise<string> {
return Bun.$.cwd(cwd)`git rev-parse --show-toplevel`
.text()
.then((e) => e.trim());
}
async function getHeadSha(repoRoot: string): Promise<string> {
return Bun.$.cwd(repoRoot)`git rev-parse HEAD`.text().then((e) => e.trim());
}
async function getTrackedDiff(repoRoot: string): Promise<string> {
return Bun.$.cwd(repoRoot)`git diff --binary HEAD`.text();
}
async function getUntrackedFiles(repoRoot: string): Promise<string[]> {
const result = Bun.spawn(
["git", "ls-files", "--others", "--exclude-standard"],
{
cwd: repoRoot,
stdout: "pipe",
stderr: "pipe",
},
);
const output = await runWithOutput(result);
return output.stdout
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
}
async function getIgnoredDotenvFiles(repoRoot: string): Promise<string[]> {
const result = Bun.spawn(
[
"git",
"ls-files",
"--others",
"--ignored",
"--exclude-standard",
"--",
".env*",
"**/.env*",
],
{
cwd: repoRoot,
stdout: "pipe",
stderr: "pipe",
},
);
const output = await runWithOutput(result);
return output.stdout
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
}
async function getRemoteHead(remoteRepo: string): Promise<string | undefined> {
const result = ssh(
`if [ -d ${shellQuote(`${remoteRepo}/.git`)} ]; then git -C ${shellQuote(remoteRepo)} rev-parse HEAD; fi`,
);
const output = await runWithOutput(result);
const head = output.stdout.trim();
return head || undefined;
}
async function syncRemoteRepo(
repoRoot: string,
remoteRepo: string,
reporter?: BuildReporter,
) {
const headSha = await getHeadSha(repoRoot);
const remoteHead = await getRemoteHead(remoteRepo);
const tempDir = await mkdtemp(`${tmpdir()}/kuber-build-`);
try {
if (remoteHead !== headSha) {
await reporter?.progress?.("Transferring latest git bundle");
const bundlePath = `${tempDir}/repo.bundle`;
const remoteBundle = `${getBuildRuntime().builders.remoteRoot}/${basename(repoRoot)}.bundle`;
await Bun.$.cwd(repoRoot)`git bundle create ${bundlePath} HEAD`;
await runWithOutput(
ssh(`mkdir -p ${shellQuote(getBuildRuntime().builders.remoteRoot)}`),
reporter,
);
await runWithOutput(scp(bundlePath, remoteBundle), reporter);
await runWithOutput(
ssh(
[
"set -euo pipefail",
`mkdir -p ${shellQuote(remoteRepo)}`,
`if [ ! -d ${shellQuote(`${remoteRepo}/.git`)} ]; then git -C ${shellQuote(remoteRepo)} init; fi`,
`git -C ${shellQuote(remoteRepo)} fetch --force "$PWD/${remoteBundle}" HEAD`,
`git -C ${shellQuote(remoteRepo)} reset --hard FETCH_HEAD`,
`git -C ${shellQuote(remoteRepo)} clean -fd`,
].join("; "),
),
reporter,
);
} else {
await runWithOutput(
ssh(
[
"set -euo pipefail",
`git -C ${shellQuote(remoteRepo)} reset --hard HEAD`,
`git -C ${shellQuote(remoteRepo)} clean -fd`,
].join("; "),
),
reporter,
);
}
const diff = await getTrackedDiff(repoRoot);
if (diff.trim().length > 0) {
await reporter?.progress?.("Applying local git diff on remote builder");
const diffPath = `${tempDir}/repo.diff`;
const remoteDiff = `${getBuildRuntime().builders.remoteRoot}/${basename(repoRoot)}.diff`;
await Bun.write(diffPath, diff);
await runWithOutput(scp(diffPath, remoteDiff), reporter);
await runWithOutput(
ssh(
[
"set -euo pipefail",
`git -C ${shellQuote(remoteRepo)} apply --allow-binary-replacement "$PWD/${remoteDiff}"`,
].join("; "),
),
reporter,
);
}
const untrackedFiles = await getUntrackedFiles(repoRoot);
if (untrackedFiles.length > 0) {
await reporter?.progress?.("Syncing untracked files to remote builder");
const untrackedDir = `${tempDir}/untracked`;
for (const file of untrackedFiles) {
const source = resolve(repoRoot, file);
const target = resolve(untrackedDir, file);
await mkdir(dirname(target), { recursive: true });
await cp(source, target, { force: true, recursive: true });
}
const untrackedArchive = `${tempDir}/untracked.tar`;
const remoteUntrackedArchive = `${getBuildRuntime().builders.remoteRoot}/${basename(repoRoot)}-untracked.tar`;
await Bun.$`tar -C ${untrackedDir} -cf ${untrackedArchive} .`;
await runWithOutput(
scp(untrackedArchive, remoteUntrackedArchive),
reporter,
);
await runWithOutput(
ssh(
[
"set -euo pipefail",
`tar -C ${shellQuote(remoteRepo)} -xf "$PWD/${remoteUntrackedArchive}"`,
].join("; "),
),
reporter,
);
}
const ignoredDotenvFiles = await getIgnoredDotenvFiles(repoRoot);
await runWithOutput(
ssh(
[
"set -euo pipefail",
`git -C ${shellQuote(remoteRepo)} clean -fdX -- .env* '**/.env*'`,
].join("; "),
),
reporter,
);
if (ignoredDotenvFiles.length === 0) return;
await reporter?.progress?.("Syncing ignored .env* files to remote builder");
const dotenvDir = `${tempDir}/dotenv`;
for (const file of ignoredDotenvFiles) {
const source = resolve(repoRoot, file);
const target = resolve(dotenvDir, file);
await mkdir(dirname(target), { recursive: true });
await cp(source, target, { force: true });
}
const dotenvArchive = `${tempDir}/dotenv.tar`;
const remoteDotenvArchive = `${getBuildRuntime().builders.remoteRoot}/${basename(repoRoot)}-dotenv.tar`;
await Bun.$`tar -C ${dotenvDir} -cf ${dotenvArchive} .`;
await runWithOutput(scp(dotenvArchive, remoteDotenvArchive), reporter);
await runWithOutput(
ssh(
[
"set -euo pipefail",
`tar -C ${shellQuote(remoteRepo)} -xf "$PWD/${remoteDotenvArchive}"`,
].join("; "),
),
reporter,
);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
}
function getBuildPlans(
project: string,
compose: ComposeSpecification,
cwd: string,
repoRoot: string,
remoteRepo: string,
): BuildPlan[] {
return Object.entries(compose.services ?? {}).flatMap(([name, service]) => {
const plan = resolveBuildPlan(
project,
name,
service,
cwd,
repoRoot,
remoteRepo,
);
return plan ? [plan] : [];
});
}
async function buildRemote(plan: BuildPlan, reporter?: BuildReporter) {
await reporter?.progress?.(`Building ${plan.name}`);
const args = [
"docker",
"build",
"--push",
"--progress=plain",
"-t",
plan.image,
...(plan.dockerfile ? ["-f", plan.dockerfile] : []),
...(plan.target ? ["--target", plan.target] : []),
...plan.buildArgs.flatMap((arg) => ["--build-arg", arg]),
plan.context,
];
const command = args.map(shellQuote).join(" ");
await runWithOutput(ssh(`set -euo pipefail; ${command}`), reporter);
}
async function buildLocal(plan: BuildPlan, reporter?: BuildReporter) {
await reporter?.progress?.(`Building ${plan.name}`);
const command = Bun.spawn(
[
"docker",
"build",
"--push",
"--progress=plain",
"-t",
plan.image,
...(plan.dockerfile ? ["-f", plan.dockerfile] : []),
...(plan.target ? ["--target", plan.target] : []),
...plan.buildArgs.flatMap((arg) => ["--build-arg", arg]),
plan.context,
],
{
stdout: "pipe",
stderr: "pipe",
},
);
await runWithOutput(command, reporter);
}
export async function buildServices(
project: string,
compose: ComposeSpecification,
cwd = process.cwd(),
reporter?: BuildReporter,
options: BuildOptions = {},
): Promise<BuildResult> {
const runtime: BuildRuntime = {
registry: options.registry ?? DEFAULT_REGISTRY,
builders: {
amd64: options.builders?.amd64 ?? DEFAULT_BUILDERS.amd64,
arm64: options.builders?.arm64 ?? DEFAULT_BUILDERS.arm64,
remoteRoot: options.builders?.remoteRoot ?? DEFAULT_BUILDERS.remoteRoot,
},
};
return buildRuntime.run(runtime, () =>
withComposeArch(compose, async () => {
if (
!Object.values(compose.services ?? {}).some((service) => service.build)
) {
return { built: [], changed: [] };
}
const repoRoot = await getRepoRoot(cwd);
const localBuilder = isOnRemoteBuilder();
const buildRoot = localBuilder
? repoRoot
: `${getBuildRuntime().builders.remoteRoot}/${basename(repoRoot)}`;
const plans = getBuildPlans(project, compose, cwd, repoRoot, buildRoot);
if (plans.length === 0) return { built: [], changed: [] };
if (!localBuilder) {
await syncRemoteRepo(repoRoot, buildRoot, reporter);
}
const changed: string[] = [];
for (const plan of plans) {
const before = await getRemoteImageDigest(plan.image);
if (localBuilder) await buildLocal(plan, reporter);
else await buildRemote(plan, reporter);
const after = await getRemoteImageDigest(plan.image);
if (imageDigestChanged(before, after)) changed.push(plan.name);
}
return {
built: plans.map((plan) => plan.name),
changed,
};
}),
);
}