644 lines
17 KiB
TypeScript
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,
|
|
};
|
|
}),
|
|
);
|
|
}
|