feat: add amd64 node support

This commit is contained in:
2026-08-28 21:19:50 +07:00 Unverified
parent 23b4ccaa80
commit a1c3298f02
10 changed files with 856 additions and 177 deletions
+194 -81
View File
@@ -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;
});
}