flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/scripts/build-runner.mjs

140 lines6,680 bytesCodeBlame
1#!/usr/bin/env node
2// Builds the g1t runner (crates/runner) as one static Linux binary, the
3// only file the runner's image adds to its base (services/runner/Dockerfile).
4// Built outside the image, so a change to the runner is a Cargo build and
5// a one-file image, not a Docker build of Rust.
6//
7// The target is x86_64-unknown-linux-musl: statically linked, so it runs
8// on any x86-64 Linux, whatever its libc (the sandbox's Debian, or a
9// self-hosted runner's machine). Where it is built:
10//
11// - natively, on x86-64 Linux with the musl target and musl-gcc
12// (`rustup target add x86_64-unknown-linux-musl`, `apt-get install
13// musl-tools`), which a CI machine can have;
14// - otherwise in a small builder container (rust on Alpine, whose own
15// target is musl), with Docker volumes for Cargo's registry and target
16// directory, so a second build is incremental. This is how it builds
17// on Windows and macOS, where no musl cross-linker is at hand.
18//
19// node scripts/build-runner.mjs # -> target/runner-image/g1t-runner
20// node scripts/build-runner.mjs --docker # the builder container, wherever
21// node scripts/build-runner.mjs --native # this machine's Cargo, or fail
22
23import { spawnSync } from "node:child_process";
24import { copyFileSync, mkdirSync, readFileSync, statSync } from "node:fs";
25import { arch, platform } from "node:os";
26import { dirname, join } from "node:path";
27import { fileURLToPath } from "node:url";
28
29export const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
30export const TARGET = "x86_64-unknown-linux-musl";
31/** Where the binary is written: the thin image's whole build context. */
32export const OUT_DIR = join(ROOT, "target", "runner-image");
33export const BINARY = join(OUT_DIR, "g1t-runner");
34
35/**
36 * The builder container's image: Rust on Alpine, the same Rust release as
37 * rust-toolchain users get, plus musl's headers for crates with C in them
38 * (ring). Built once per Rust version and kept by Docker.
39 */
40export const BUILDER_RUST = "1.99";
41const BUILDER_IMAGE = `g1t-runner-builder:${BUILDER_RUST}`;
42const BUILDER_DOCKERFILE = `FROM rust:${BUILDER_RUST}-alpine\nRUN apk add --no-cache musl-dev\n`;
43/** Docker volumes the builder keeps between builds. */
44const VOLUMES = { registry: "g1t-runner-cargo-registry", git: "g1t-runner-cargo-git", target: "g1t-runner-target" };
45
46const run = (command, args, options = {}) => spawnSync(command, args, { stdio: "inherit", ...options });
47const quiet = (command, args) => spawnSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
48
49/** Whether this machine can build the musl binary itself. */
50export function canBuildNatively() {
51 if (platform() !== "linux" || arch() !== "x64") return false;
52 const targets = quiet("rustup", ["target", "list", "--installed"]);
53 if (targets.status !== 0 || !targets.stdout.split("\n").includes(TARGET)) return false;
54 return quiet("musl-gcc", ["--version"]).status === 0 || quiet("sh", ["-c", "command -v musl-gcc"]).status === 0;
55}
56
57function native() {
58 const built = run("cargo", ["build", "--release", "--locked", "--package", "g1t-runner", "--target", TARGET], {
59 cwd: ROOT,
60 env: { ...process.env, CC_x86_64_unknown_linux_musl: "musl-gcc" },
61 });
62 if (built.status !== 0) throw new Error("cargo build of the runner failed");
63 return join(ROOT, "target", TARGET, "release", "g1t-runner");
64}
65
66function inDocker() {
67 const have = quiet("docker", ["image", "inspect", BUILDER_IMAGE, "--format", "{{.Id}}"]);
68 if (have.status !== 0) {
69 console.error(`building ${BUILDER_IMAGE}`);
70 const made = run("docker", ["build", "--platform", "linux/amd64", "-t", BUILDER_IMAGE, "-"], { input: BUILDER_DOCKERFILE, stdio: ["pipe", "inherit", "inherit"] });
71 if (made.status !== 0) throw new Error(`could not build ${BUILDER_IMAGE}`);
72 }
73 mkdirSync(OUT_DIR, { recursive: true });
74 // The source is mounted read-only; Cargo writes only to its volumes, and
75 // the binary is copied out to target/runner-image.
76 const script = [
77 "set -e",
78 "cargo build --release --locked --package g1t-runner",
79 "cp /target/release/g1t-runner /out/g1t-runner",
80 ].join("\n");
81 const built = run("docker", [
82 "run", "--rm", "--platform", "linux/amd64",
83 "-v", `${ROOT}:/src:ro`,
84 "-v", `${OUT_DIR}:/out`,
85 "-v", `${VOLUMES.registry}:/usr/local/cargo/registry`,
86 "-v", `${VOLUMES.git}:/usr/local/cargo/git`,
87 "-v", `${VOLUMES.target}:/target`,
88 "-e", "CARGO_TARGET_DIR=/target",
89 "-e", "CARGO_TERM_COLOR=never",
90 "-w", "/src",
91 BUILDER_IMAGE, "sh", "-c", script,
92 ]);
93 if (built.status !== 0) throw new Error("cargo build of the runner in the builder container failed");
94 return BINARY;
95}
96
97/** Whether a file is a statically linked x86-64 ELF executable. */
98export function isStaticElf(path) {
99 const bytes = readFileSync(path);
100 if (bytes.length < 64 || bytes.readUInt32BE(0) !== 0x7f454c46) return false;
101 // 64-bit, little-endian, x86-64.
102 if (bytes[4] !== 2 || bytes[5] !== 1 || bytes.readUInt16LE(18) !== 0x3e) return false;
103 // No PT_INTERP program header: nothing to load it but the kernel.
104 const phoff = Number(bytes.readBigUInt64LE(32));
105 const phentsize = bytes.readUInt16LE(54);
106 const phnum = bytes.readUInt16LE(56);
107 for (let i = 0; i < phnum; i++) {
108 if (bytes.readUInt32LE(phoff + i * phentsize) === 3) return false;
109 }
110 return true;
111}
112
113/** Builds the binary; returns its path (always target/runner-image/g1t-runner). */
114export function buildRunner({ mode = "auto" } = {}) {
115 const started = Date.now();
116 const useNative = mode === "native" || (mode === "auto" && canBuildNatively());
117 if (mode === "native" && !canBuildNatively()) {
118 throw new Error(`no native musl build here: needs x86-64 Linux, rustup target ${TARGET} and musl-gcc`);
119 }
120 const built = useNative ? native() : inDocker();
121 if (built !== BINARY) {
122 mkdirSync(OUT_DIR, { recursive: true });
123 copyFileSync(built, BINARY);
124 }
125 if (!isStaticElf(BINARY)) throw new Error(`${BINARY} is not a static x86-64 Linux binary`);
126 const size = statSync(BINARY).size;
127 console.error(`g1t-runner: ${(size / 1048576).toFixed(1)} MB, static, built ${useNative ? "natively" : "in the builder container"} in ${((Date.now() - started) / 1000).toFixed(1)}s`);
128 return BINARY;
129}
130
131if (process.argv[1]?.replaceAll("\\", "/").endsWith("scripts/build-runner.mjs")) {
132 const args = process.argv.slice(2);
133 const mode = args.includes("--docker") ? "docker" : args.includes("--native") ? "native" : "auto";
134 try {
135 console.log(buildRunner({ mode }));
136 } catch (error) {
137 console.error(String(error.message ?? error));
138 process.exit(1);
139 }
140}