| 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 | |
| 23 | import { spawnSync } from "node:child_process"; |
| 24 | import { copyFileSync, mkdirSync, readFileSync, statSync } from "node:fs"; |
| 25 | import { arch, platform } from "node:os"; |
| 26 | import { dirname, join } from "node:path"; |
| 27 | import { fileURLToPath } from "node:url"; |
| 28 | |
| 29 | export const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); |
| 30 | export const TARGET = "x86_64-unknown-linux-musl"; |
| 31 | /** Where the binary is written: the thin image's whole build context. */ |
| 32 | export const OUT_DIR = join(ROOT, "target", "runner-image"); |
| 33 | export 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 | */ |
| 40 | export const BUILDER_RUST = "1.99"; |
| 41 | const BUILDER_IMAGE = `g1t-runner-builder:${BUILDER_RUST}`; |
| 42 | const BUILDER_DOCKERFILE = `FROM rust:${BUILDER_RUST}-alpine\nRUN apk add --no-cache musl-dev\n`; |
| 43 | /** Docker volumes the builder keeps between builds. */ |
| 44 | const VOLUMES = { registry: "g1t-runner-cargo-registry", git: "g1t-runner-cargo-git", target: "g1t-runner-target" }; |
| 45 | |
| 46 | const run = (command, args, options = {}) => spawnSync(command, args, { stdio: "inherit", ...options }); |
| 47 | const quiet = (command, args) => spawnSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); |
| 48 | |
| 49 | /** Whether this machine can build the musl binary itself. */ |
| 50 | export 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 | |
| 57 | function 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 | |
| 66 | function 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. */ |
| 98 | export 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). */ |
| 114 | export 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 | |
| 131 | if (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 | } |