feat: add amd64 node support
This commit is contained in:
+194
-81
@@ -4,10 +4,19 @@ import { hostname, tmpdir } from "node:os";
|
||||
import type { Writable } from "node:stream";
|
||||
import type { ComposeSpecification, Service } from "../schema/docker.d";
|
||||
import { IMAGE_REGISTRY } from "../const";
|
||||
import { getComposeArch, withComposeArch } from "./arch";
|
||||
|
||||
const REMOTE_BUILDER = "kuber@astral";
|
||||
const REMOTE_BUILDER_ARM = "kuber@astral";
|
||||
const REMOTE_BUILDER_AMD = "kuber@astral-th";
|
||||
const REMOTE_BUILD_ROOT = "kuber-build";
|
||||
|
||||
const RemoteBuilder = {
|
||||
amd64: REMOTE_BUILDER_AMD,
|
||||
arm64: REMOTE_BUILDER_ARM,
|
||||
} as const;
|
||||
|
||||
type RemoteBuilder = (typeof RemoteBuilder)[keyof typeof RemoteBuilder];
|
||||
|
||||
type BuildPlan = {
|
||||
name: string;
|
||||
image: string;
|
||||
@@ -29,6 +38,22 @@ type SpawnResult = {
|
||||
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 {
|
||||
@@ -40,7 +65,11 @@ function shellQuote(value: string): string {
|
||||
}
|
||||
|
||||
function resolveBuildArgs(service: Service): string[] {
|
||||
if (!service.build || typeof service.build === "string" || !service.build.args) {
|
||||
if (
|
||||
!service.build ||
|
||||
typeof service.build === "string" ||
|
||||
!service.build.args
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -62,16 +91,21 @@ function resolveBuildPlan(
|
||||
if (!service.build) return;
|
||||
|
||||
const build = service.build;
|
||||
const contextInput = typeof build === "string" ? build : build.context ?? ".";
|
||||
const contextInput =
|
||||
typeof build === "string" ? build : (build.context ?? ".");
|
||||
|
||||
if (contextInput.includes("://")) {
|
||||
throw new Error(`Remote build context is not supported for service ${name}`);
|
||||
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}`);
|
||||
throw new Error(
|
||||
`Build context must stay inside the git repo for service ${name}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof build !== "string" && build.dockerfile_inline) {
|
||||
@@ -86,22 +120,35 @@ function resolveBuildPlan(
|
||||
? relative(repoRoot, dockerfilePath)
|
||||
: undefined;
|
||||
|
||||
if (dockerfileRelative?.startsWith("..") || isAbsolute(dockerfileRelative ?? "")) {
|
||||
throw new Error(`Dockerfile must stay inside the git repo for service ${name}`);
|
||||
if (
|
||||
dockerfileRelative?.startsWith("..") ||
|
||||
isAbsolute(dockerfileRelative ?? "")
|
||||
) {
|
||||
throw new Error(
|
||||
`Dockerfile must stay inside the git repo for service ${name}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
image: `${IMAGE_REGISTRY}/kuber/${project}-${name}:latest`,
|
||||
context: `${buildRoot}/${contextRelative === "" ? "." : contextRelative}`,
|
||||
dockerfile: dockerfileRelative ? `${buildRoot}/${dockerfileRelative}` : undefined,
|
||||
dockerfile: dockerfileRelative
|
||||
? `${buildRoot}/${dockerfileRelative}`
|
||||
: undefined,
|
||||
target: typeof build === "string" ? undefined : build.target,
|
||||
buildArgs: resolveBuildArgs(service),
|
||||
};
|
||||
}
|
||||
|
||||
function getRemoteBuilder(): RemoteBuilder {
|
||||
return getComposeArch() === "amd64"
|
||||
? RemoteBuilder.amd64
|
||||
: RemoteBuilder.arm64;
|
||||
}
|
||||
|
||||
function isOnRemoteBuilder(): boolean {
|
||||
return hostname() === REMOTE_BUILDER.split("@").at(-1);
|
||||
return hostname() === getRemoteBuilder().split("@").at(-1);
|
||||
}
|
||||
|
||||
async function pumpStream(
|
||||
@@ -144,24 +191,56 @@ async function runWithOutput(
|
||||
) {
|
||||
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) reporter.stream.write(`${line}\n`);
|
||||
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) reporter.stream.write(`${line}\n`);
|
||||
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(
|
||||
stderrLines.join("\n") || stdoutLines.join("\n") || `Command failed with exit code ${exitCode}`,
|
||||
summarizeCommandFailure(exitCode, stderrLines, stdoutLines),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -174,21 +253,23 @@ async function runWithOutput(
|
||||
|
||||
function ssh(script: string) {
|
||||
const remoteCommand = `bash -lc ${shellQuote(script)}`;
|
||||
return Bun.spawn(["ssh", REMOTE_BUILDER, remoteCommand], {
|
||||
return Bun.spawn(["ssh", getRemoteBuilder(), remoteCommand], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
}
|
||||
|
||||
function scp(localPath: string, remotePath: string) {
|
||||
return Bun.spawn(["scp", localPath, `${REMOTE_BUILDER}:${remotePath}`], {
|
||||
return Bun.spawn(["scp", localPath, `${getRemoteBuilder()}:${remotePath}`], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
}
|
||||
|
||||
async function getRepoRoot(cwd: string): Promise<string> {
|
||||
return Bun.$.cwd(cwd)`git rev-parse --show-toplevel`.text().then((e) => e.trim());
|
||||
return Bun.$.cwd(cwd)`git rev-parse --show-toplevel`
|
||||
.text()
|
||||
.then((e) => e.trim());
|
||||
}
|
||||
|
||||
async function getHeadSha(repoRoot: string): Promise<string> {
|
||||
@@ -243,13 +324,8 @@ async function getIgnoredDotenvFiles(repoRoot: string): Promise<string[]> {
|
||||
}
|
||||
|
||||
async function getRemoteHead(remoteRepo: string): Promise<string | undefined> {
|
||||
const result = Bun.spawn(
|
||||
[
|
||||
"ssh",
|
||||
REMOTE_BUILDER,
|
||||
`bash -lc ${shellQuote(`if [ -d ${shellQuote(`${remoteRepo}/.git`)} ]; then git -C ${shellQuote(remoteRepo)} rev-parse HEAD; fi`)}`,
|
||||
],
|
||||
{ stdout: "pipe", stderr: "pipe" },
|
||||
const result = ssh(
|
||||
`if [ -d ${shellQuote(`${remoteRepo}/.git`)} ]; then git -C ${shellQuote(remoteRepo)} rev-parse HEAD; fi`,
|
||||
);
|
||||
|
||||
const output = await runWithOutput(result);
|
||||
@@ -273,26 +349,35 @@ async function syncRemoteRepo(
|
||||
const remoteBundle = `${REMOTE_BUILD_ROOT}/${basename(repoRoot)}.bundle`;
|
||||
|
||||
await Bun.$.cwd(repoRoot)`git bundle create ${bundlePath} HEAD`;
|
||||
await runWithOutput(ssh(`mkdir -p ${shellQuote(REMOTE_BUILD_ROOT)}`), reporter);
|
||||
await runWithOutput(
|
||||
ssh(`mkdir -p ${shellQuote(REMOTE_BUILD_ROOT)}`),
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
@@ -302,12 +387,15 @@ async function syncRemoteRepo(
|
||||
const remoteDiff = `${REMOTE_BUILD_ROOT}/${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);
|
||||
await runWithOutput(
|
||||
ssh(
|
||||
[
|
||||
"set -euo pipefail",
|
||||
`git -C ${shellQuote(remoteRepo)} apply --allow-binary-replacement "$PWD/${remoteDiff}"`,
|
||||
].join("; "),
|
||||
),
|
||||
reporter,
|
||||
);
|
||||
}
|
||||
|
||||
const untrackedFiles = await getUntrackedFiles(repoRoot);
|
||||
@@ -325,22 +413,31 @@ async function syncRemoteRepo(
|
||||
const untrackedArchive = `${tempDir}/untracked.tar`;
|
||||
const remoteUntrackedArchive = `${REMOTE_BUILD_ROOT}/${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);
|
||||
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);
|
||||
await runWithOutput(
|
||||
ssh(
|
||||
[
|
||||
"set -euo pipefail",
|
||||
`git -C ${shellQuote(remoteRepo)} clean -fdX -- .env* '**/.env*'`,
|
||||
].join("; "),
|
||||
),
|
||||
reporter,
|
||||
);
|
||||
|
||||
if (ignoredDotenvFiles.length === 0) return;
|
||||
|
||||
@@ -358,12 +455,15 @@ async function syncRemoteRepo(
|
||||
const remoteDotenvArchive = `${REMOTE_BUILD_ROOT}/${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);
|
||||
await runWithOutput(
|
||||
ssh(
|
||||
[
|
||||
"set -euo pipefail",
|
||||
`tar -C ${shellQuote(remoteRepo)} -xf "$PWD/${remoteDotenvArchive}"`,
|
||||
].join("; "),
|
||||
),
|
||||
reporter,
|
||||
);
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -377,7 +477,14 @@ function getBuildPlans(
|
||||
remoteRepo: string,
|
||||
): BuildPlan[] {
|
||||
return Object.entries(compose.services ?? {}).flatMap(([name, service]) => {
|
||||
const plan = resolveBuildPlan(project, name, service, cwd, repoRoot, remoteRepo);
|
||||
const plan = resolveBuildPlan(
|
||||
project,
|
||||
name,
|
||||
service,
|
||||
cwd,
|
||||
repoRoot,
|
||||
remoteRepo,
|
||||
);
|
||||
return plan ? [plan] : [];
|
||||
});
|
||||
}
|
||||
@@ -431,23 +538,29 @@ export async function buildServices(
|
||||
cwd = process.cwd(),
|
||||
reporter?: BuildReporter,
|
||||
): Promise<number> {
|
||||
const repoRoot = await getRepoRoot(cwd);
|
||||
const localBuilder = isOnRemoteBuilder();
|
||||
const buildRoot = localBuilder
|
||||
? repoRoot
|
||||
: `${REMOTE_BUILD_ROOT}/${basename(repoRoot)}`;
|
||||
const plans = getBuildPlans(project, compose, cwd, repoRoot, buildRoot);
|
||||
return withComposeArch(compose, async () => {
|
||||
if (!Object.values(compose.services ?? {}).some((service) => service.build)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (plans.length === 0) return 0;
|
||||
const repoRoot = await getRepoRoot(cwd);
|
||||
const localBuilder = isOnRemoteBuilder();
|
||||
const buildRoot = localBuilder
|
||||
? repoRoot
|
||||
: `${REMOTE_BUILD_ROOT}/${basename(repoRoot)}`;
|
||||
const plans = getBuildPlans(project, compose, cwd, repoRoot, buildRoot);
|
||||
|
||||
if (!localBuilder) {
|
||||
await syncRemoteRepo(repoRoot, buildRoot, reporter);
|
||||
}
|
||||
if (plans.length === 0) return 0;
|
||||
|
||||
for (const plan of plans) {
|
||||
if (localBuilder) await buildLocal(plan, reporter);
|
||||
else await buildRemote(plan, reporter);
|
||||
}
|
||||
if (!localBuilder) {
|
||||
await syncRemoteRepo(repoRoot, buildRoot, reporter);
|
||||
}
|
||||
|
||||
return plans.length;
|
||||
for (const plan of plans) {
|
||||
if (localBuilder) await buildLocal(plan, reporter);
|
||||
else await buildRemote(plan, reporter);
|
||||
}
|
||||
|
||||
return plans.length;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user