@boardwalk-labs/runner 0.1.8 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.d.ts +3 -1
- package/dist/bin.js +33 -68
- package/dist/daemon/container.d.ts +46 -0
- package/dist/daemon/container.js +145 -0
- package/dist/daemon/index.d.ts +2 -0
- package/dist/daemon/index.js +2 -0
- package/dist/daemon/start.d.ts +52 -0
- package/dist/daemon/start.js +152 -0
- package/package.json +1 -1
package/dist/bin.d.ts
CHANGED
package/dist/bin.js
CHANGED
|
@@ -14,14 +14,12 @@
|
|
|
14
14
|
//
|
|
15
15
|
// Corporate proxies: launch with NODE_USE_ENV_PROXY=1 and HTTPS_PROXY set — Node's fetch (the
|
|
16
16
|
// daemon) and the spawned run processes both honor it.
|
|
17
|
-
import { spawn as nodeSpawn } from "node:child_process";
|
|
18
17
|
import { readFileSync } from "node:fs";
|
|
19
|
-
import { fileURLToPath } from "node:url";
|
|
20
18
|
import * as path from "node:path";
|
|
21
19
|
import * as os from "node:os";
|
|
22
20
|
import { createLogger } from "./runtime/support/index.js";
|
|
23
21
|
import { runnerOsSchema, runnerArchSchema } from "./contract.js";
|
|
24
|
-
import { PoolClient, defaultIdentityDir, loadIdentity, removeIdentity, saveIdentity,
|
|
22
|
+
import { PoolClient, defaultIdentityDir, loadIdentity, removeIdentity, saveIdentity, startRunner, } from "./daemon/index.js";
|
|
25
23
|
const log = createLogger("boardwalk-runner");
|
|
26
24
|
function flag(name) {
|
|
27
25
|
const i = process.argv.indexOf(`--${name}`);
|
|
@@ -48,47 +46,6 @@ function machineArch() {
|
|
|
48
46
|
const parsed = runnerArchSchema.safeParse(process.arch);
|
|
49
47
|
return parsed.success ? parsed.data : undefined;
|
|
50
48
|
}
|
|
51
|
-
/** Real spawner: one Node process per run, executing the SAME runtime a hosted worker boots.
|
|
52
|
-
* Env is exactly the platform contract + the claim's resolved vars, over a minimal base
|
|
53
|
-
* (PATH + proxy knobs from the daemon's own environment; HOME = the run's workspace). */
|
|
54
|
-
function realSpawn(opts) {
|
|
55
|
-
const base = {};
|
|
56
|
-
for (const key of [
|
|
57
|
-
"PATH",
|
|
58
|
-
"LANG",
|
|
59
|
-
"NODE_USE_ENV_PROXY",
|
|
60
|
-
"HTTPS_PROXY",
|
|
61
|
-
"https_proxy",
|
|
62
|
-
"HTTP_PROXY",
|
|
63
|
-
"http_proxy",
|
|
64
|
-
"NO_PROXY",
|
|
65
|
-
"no_proxy",
|
|
66
|
-
"BOARDWALK_RUNNER_DEBUG",
|
|
67
|
-
]) {
|
|
68
|
-
const v = process.env[key];
|
|
69
|
-
if (v !== undefined)
|
|
70
|
-
base[key] = v;
|
|
71
|
-
}
|
|
72
|
-
const child = nodeSpawn(process.execPath, [opts.entry], {
|
|
73
|
-
cwd: opts.cwd,
|
|
74
|
-
env: { ...base, HOME: opts.cwd, TMPDIR: path.join(opts.cwd, "..", "tmp"), ...opts.env },
|
|
75
|
-
stdio: "inherit",
|
|
76
|
-
});
|
|
77
|
-
const exit = new Promise((resolve) => {
|
|
78
|
-
child.on("exit", (code, signal) => {
|
|
79
|
-
resolve(code ?? (signal !== null ? 143 : 1));
|
|
80
|
-
});
|
|
81
|
-
child.on("error", () => {
|
|
82
|
-
resolve(1);
|
|
83
|
-
});
|
|
84
|
-
});
|
|
85
|
-
return {
|
|
86
|
-
wait: () => exit,
|
|
87
|
-
kill: () => {
|
|
88
|
-
child.kill("SIGTERM");
|
|
89
|
-
},
|
|
90
|
-
};
|
|
91
|
-
}
|
|
92
49
|
async function cmdRegister() {
|
|
93
50
|
const baseUrl = requireFlag("url");
|
|
94
51
|
const token = requireFlag("token");
|
|
@@ -114,6 +71,23 @@ async function cmdRegister() {
|
|
|
114
71
|
log.info("registered", { runnerId: res.runner_id, pool: res.pool, identity: file });
|
|
115
72
|
process.stdout.write(`Registered runner ${res.runner_id} in pool '${res.pool}'.\nStart it with: boardwalk-runner start --url ${baseUrl} --pool ${res.pool}\n`);
|
|
116
73
|
}
|
|
74
|
+
/** Build the isolation config from CLI flags: containerized by default; `--host` is the escape hatch. */
|
|
75
|
+
export function isolationFromFlags() {
|
|
76
|
+
if (hasFlag("host"))
|
|
77
|
+
return { mode: "host" };
|
|
78
|
+
const image = flag("image");
|
|
79
|
+
const network = flag("network");
|
|
80
|
+
const mounts = (flag("mount") ?? "")
|
|
81
|
+
.split(",")
|
|
82
|
+
.map((m) => m.trim())
|
|
83
|
+
.filter((m) => m.length > 0);
|
|
84
|
+
return {
|
|
85
|
+
mode: "container",
|
|
86
|
+
...(image !== undefined ? { image } : {}),
|
|
87
|
+
...(network !== undefined ? { network } : {}),
|
|
88
|
+
...(mounts.length > 0 ? { mounts } : {}),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
117
91
|
async function cmdStart() {
|
|
118
92
|
const baseUrl = requireFlag("url");
|
|
119
93
|
const pool = flag("pool") ?? "default";
|
|
@@ -123,32 +97,15 @@ async function cmdStart() {
|
|
|
123
97
|
process.stderr.write(`No saved identity for ${baseUrl} pool '${pool}'. Run boardwalk-runner register first.\n`);
|
|
124
98
|
process.exit(1);
|
|
125
99
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
workDir,
|
|
133
|
-
runnerId: identity.runner_id,
|
|
134
|
-
spawn: realSpawn,
|
|
100
|
+
// The daemon lifecycle + isolation decision live in the shared startRunner — this binary and the
|
|
101
|
+
// main CLI both call it, so their behavior can't drift.
|
|
102
|
+
await startRunner({
|
|
103
|
+
baseUrl,
|
|
104
|
+
identity,
|
|
105
|
+
isolation: isolationFromFlags(),
|
|
106
|
+
workDir: flag("work-dir") ?? path.join(identityDir, "work"),
|
|
135
107
|
...(hasFlag("once") ? { once: true } : {}),
|
|
136
108
|
});
|
|
137
|
-
let interrupts = 0;
|
|
138
|
-
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
139
|
-
process.on(signal, () => {
|
|
140
|
-
interrupts += 1;
|
|
141
|
-
if (interrupts === 1) {
|
|
142
|
-
process.stderr.write("\nDraining: finishing the current run, claiming nothing new. Ctrl-C again to force-quit.\n");
|
|
143
|
-
daemon.drain();
|
|
144
|
-
}
|
|
145
|
-
else {
|
|
146
|
-
process.exit(130);
|
|
147
|
-
}
|
|
148
|
-
});
|
|
149
|
-
}
|
|
150
|
-
process.stdout.write(`Runner ${identity.name} online in pool '${pool}'. Waiting for runs...\n`);
|
|
151
|
-
await daemon.done;
|
|
152
109
|
}
|
|
153
110
|
async function cmdDeregister() {
|
|
154
111
|
const baseUrl = requireFlag("url");
|
|
@@ -183,8 +140,16 @@ const USAGE = `boardwalk-runner <register|start|deregister> [flags]
|
|
|
183
140
|
|
|
184
141
|
register --url <control-plane> --token <bwkreg_…> [--name] [--labels a,b] [--identity-dir]
|
|
185
142
|
start --url <control-plane> [--pool default] [--work-dir] [--once] [--identity-dir]
|
|
143
|
+
[--host] [--image <ref>] [--network <mode>] [--mount host:container[:ro],…]
|
|
186
144
|
deregister --url <control-plane> [--pool default] [--identity-dir]
|
|
187
145
|
|
|
146
|
+
Isolation (start): runs are CONTAINERIZED by default (docker/podman) — each run sees only its
|
|
147
|
+
workspace + the machine's network, not your home dir, creds, or the rest of the machine.
|
|
148
|
+
--host raw process mode: full machine access (trusted workflows only / no runtime)
|
|
149
|
+
--image <ref> runner image to run (default: the version-pinned ghcr.io image)
|
|
150
|
+
--network <mode> container network (default: host — preserves LAN/VPN/localhost reach)
|
|
151
|
+
--mount a:b[:ro] extra host paths to expose to the run (comma-separated)
|
|
152
|
+
|
|
188
153
|
--verbose debug-level daemon logs (poll cycles, heartbeats)
|
|
189
154
|
--debug --verbose, plus debug logging inside each spawned run process
|
|
190
155
|
`;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
2
|
+
import type { RunProcessHandle } from "./daemon.js";
|
|
3
|
+
export interface ContainerSpawnConfig {
|
|
4
|
+
/** Container runtime binary: `docker` or `podman`. */
|
|
5
|
+
runtime: string;
|
|
6
|
+
/** Fully-qualified runner image ref (e.g. `ghcr.io/boardwalk-labs/runner:0.1.9`). */
|
|
7
|
+
image: string;
|
|
8
|
+
/** Docker network mode. Default `host` — preserves the machine's LAN/VPN/localhost reach. */
|
|
9
|
+
network?: string;
|
|
10
|
+
/** Extra host bind mounts (`hostPath:containerPath[:ro]`) the user opted into for this fleet. */
|
|
11
|
+
mounts?: readonly string[];
|
|
12
|
+
/** Run the container as this `uid:gid` — the invoking host user — so writes to the bind-mounted
|
|
13
|
+
* workspace match host ownership instead of the image's `node` (uid 1000). Set on Linux; omit on
|
|
14
|
+
* Docker Desktop (macOS/Windows), which maps ownership through its file sharing. */
|
|
15
|
+
user?: string;
|
|
16
|
+
/** Test seam. */
|
|
17
|
+
spawn?: typeof nodeSpawn;
|
|
18
|
+
}
|
|
19
|
+
/** The env the container receives: the claim's env + platform contract, with the in-container
|
|
20
|
+
* filesystem coordinates overridden (the host paths are meaningless inside the container). */
|
|
21
|
+
export declare function containerEnv(runEnv: Record<string, string>): Record<string, string>;
|
|
22
|
+
/** Derive the run id from the per-run workspace path (`<workDir>/runs/<runId>/workspace`). Used only
|
|
23
|
+
* for a human-readable container name; a fallback keeps a non-standard cwd from throwing. */
|
|
24
|
+
export declare function runIdFromCwd(cwd: string): string;
|
|
25
|
+
/**
|
|
26
|
+
* Build the `docker run` argv. PURE + exported so the isolation guarantees are unit-asserted:
|
|
27
|
+
* - the ONLY bind mount is the per-run workspace (+ any explicit user mounts) — the identity dir,
|
|
28
|
+
* the user's home, and the rest of the host FS are never mounted;
|
|
29
|
+
* - per-run credentials are passed by NAME (`-e BOARDWALK_RUN_TOKEN`), so their VALUES come from the
|
|
30
|
+
* docker client's env and never appear in the argv (which `ps` exposes);
|
|
31
|
+
* - `--rm` (no leftover container), `--init` (proper signal handling / zombie reaping).
|
|
32
|
+
*/
|
|
33
|
+
export declare function buildContainerArgs(cfg: ContainerSpawnConfig, opts: {
|
|
34
|
+
env: Record<string, string>;
|
|
35
|
+
cwd: string;
|
|
36
|
+
}): string[];
|
|
37
|
+
/** A `RunSpawner` that runs each run in a throwaway container. */
|
|
38
|
+
export declare function createContainerSpawner(cfg: ContainerSpawnConfig): (opts: {
|
|
39
|
+
entry: string;
|
|
40
|
+
env: Record<string, string>;
|
|
41
|
+
cwd: string;
|
|
42
|
+
}) => RunProcessHandle;
|
|
43
|
+
/** Probe that a container runtime is installed AND its daemon is reachable. Returns the runtime
|
|
44
|
+
* binary name on success, or null (so `start` can hard-fail with a clear message rather than
|
|
45
|
+
* failing every run later). `docker info` / `podman info` exits non-zero when the daemon is down. */
|
|
46
|
+
export declare function detectContainerRuntime(candidates?: readonly string[], run?: (bin: string) => Promise<void>): Promise<string | null>;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Container run spawner — the DEFAULT isolation model for a self-hosted runner. Each run executes
|
|
3
|
+
// inside a throwaway container (Docker/Podman) instead of as a same-UID child process, so the run
|
|
4
|
+
// (and the LLM-decided `agent()` tool calls inside it) sees ONLY its own workspace, never the
|
|
5
|
+
// user's home dir, the runner's identity file, SSH/cloud creds, or the rest of the machine. The
|
|
6
|
+
// machine's network IS preserved (`--network host`) so a run still reaches the org's LAN, VPN, and
|
|
7
|
+
// localhost services — the reason to self-host. Full host access stays possible, but only as an
|
|
8
|
+
// EXPLICIT mount, never the silent default. `--host` (a separate spawner) is the escape hatch.
|
|
9
|
+
//
|
|
10
|
+
// Security-critical surface: `buildContainerArgs` is a pure function so the argv can be asserted
|
|
11
|
+
// exhaustively — the identity dir is NEVER bind-mounted, and per-run credentials ride the docker
|
|
12
|
+
// client's ENV (name-only `-e KEY`), never the argv (which `ps` would expose).
|
|
13
|
+
import { spawn as nodeSpawn, execFile } from "node:child_process";
|
|
14
|
+
import { promisify } from "node:util";
|
|
15
|
+
import * as path from "node:path";
|
|
16
|
+
const exec = promisify(execFile);
|
|
17
|
+
/** Env keys forwarded from the daemon's OWN environment into the container (proxy + locale only —
|
|
18
|
+
* NOT the machine's PATH/HOME, which would leak host layout; the image provides its own). */
|
|
19
|
+
const FORWARDED_ENV_KEYS = [
|
|
20
|
+
"LANG",
|
|
21
|
+
"NODE_USE_ENV_PROXY",
|
|
22
|
+
"HTTPS_PROXY",
|
|
23
|
+
"https_proxy",
|
|
24
|
+
"HTTP_PROXY",
|
|
25
|
+
"http_proxy",
|
|
26
|
+
"NO_PROXY",
|
|
27
|
+
"no_proxy",
|
|
28
|
+
"BOARDWALK_RUNNER_DEBUG",
|
|
29
|
+
];
|
|
30
|
+
/** The env the container receives: the claim's env + platform contract, with the in-container
|
|
31
|
+
* filesystem coordinates overridden (the host paths are meaningless inside the container). */
|
|
32
|
+
export function containerEnv(runEnv) {
|
|
33
|
+
const env = {
|
|
34
|
+
// Forward the daemon's proxy/locale knobs so egress + a corporate proxy still work.
|
|
35
|
+
...forwardedEnv(),
|
|
36
|
+
// The claim's resolved non-secret vars + platform contract (RUN_ID, BOARDWALK_RUN_TOKEN, …).
|
|
37
|
+
...runEnv,
|
|
38
|
+
// In-container coordinates — the bind mount lands the workspace at /workspace.
|
|
39
|
+
WORKSPACE_ROOT: "/workspace",
|
|
40
|
+
HOME: "/workspace",
|
|
41
|
+
TMPDIR: "/tmp",
|
|
42
|
+
};
|
|
43
|
+
return env;
|
|
44
|
+
}
|
|
45
|
+
function forwardedEnv() {
|
|
46
|
+
const out = {};
|
|
47
|
+
for (const key of FORWARDED_ENV_KEYS) {
|
|
48
|
+
const v = process.env[key];
|
|
49
|
+
if (v !== undefined)
|
|
50
|
+
out[key] = v;
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
/** Derive the run id from the per-run workspace path (`<workDir>/runs/<runId>/workspace`). Used only
|
|
55
|
+
* for a human-readable container name; a fallback keeps a non-standard cwd from throwing. */
|
|
56
|
+
export function runIdFromCwd(cwd) {
|
|
57
|
+
const runId = path.basename(path.dirname(cwd));
|
|
58
|
+
return /^[A-Za-z0-9_-]{1,64}$/.test(runId) ? runId : "run";
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Build the `docker run` argv. PURE + exported so the isolation guarantees are unit-asserted:
|
|
62
|
+
* - the ONLY bind mount is the per-run workspace (+ any explicit user mounts) — the identity dir,
|
|
63
|
+
* the user's home, and the rest of the host FS are never mounted;
|
|
64
|
+
* - per-run credentials are passed by NAME (`-e BOARDWALK_RUN_TOKEN`), so their VALUES come from the
|
|
65
|
+
* docker client's env and never appear in the argv (which `ps` exposes);
|
|
66
|
+
* - `--rm` (no leftover container), `--init` (proper signal handling / zombie reaping).
|
|
67
|
+
*/
|
|
68
|
+
export function buildContainerArgs(cfg, opts) {
|
|
69
|
+
const args = [
|
|
70
|
+
"run",
|
|
71
|
+
"--rm",
|
|
72
|
+
"--init",
|
|
73
|
+
"--name",
|
|
74
|
+
`bw-run-${runIdFromCwd(opts.cwd)}`,
|
|
75
|
+
"--network",
|
|
76
|
+
cfg.network ?? "host",
|
|
77
|
+
// The per-run workspace — the ONLY host path the run can see by default.
|
|
78
|
+
"-v",
|
|
79
|
+
`${opts.cwd}:/workspace`,
|
|
80
|
+
"-w",
|
|
81
|
+
"/workspace",
|
|
82
|
+
];
|
|
83
|
+
// Run as the invoking host user (Linux) so the bind-mounted workspace is writable — the image's
|
|
84
|
+
// `node` (uid 1000) otherwise can't write a host dir it doesn't own.
|
|
85
|
+
if (cfg.user !== undefined) {
|
|
86
|
+
args.push("--user", cfg.user);
|
|
87
|
+
}
|
|
88
|
+
// Explicit, user-opted-in host mounts (fleet config) — the honest way to say "I grant this path".
|
|
89
|
+
for (const m of cfg.mounts ?? []) {
|
|
90
|
+
args.push("-v", m);
|
|
91
|
+
}
|
|
92
|
+
// Env by NAME only — values are read from the docker client's environment, never the argv.
|
|
93
|
+
for (const key of Object.keys(containerEnv(opts.env))) {
|
|
94
|
+
args.push("-e", key);
|
|
95
|
+
}
|
|
96
|
+
args.push(cfg.image);
|
|
97
|
+
return args;
|
|
98
|
+
}
|
|
99
|
+
/** A `RunSpawner` that runs each run in a throwaway container. */
|
|
100
|
+
export function createContainerSpawner(cfg) {
|
|
101
|
+
const spawnImpl = cfg.spawn ?? nodeSpawn;
|
|
102
|
+
return (opts) => {
|
|
103
|
+
const args = buildContainerArgs(cfg, opts);
|
|
104
|
+
const child = spawnImpl(cfg.runtime, args, {
|
|
105
|
+
// The docker client reads the name-only `-e KEY` values from ITS environment — set them here so
|
|
106
|
+
// they never touch the argv. The daemon's own secrets are NOT here (only forwarded + claim env).
|
|
107
|
+
env: { ...process.env, ...containerEnv(opts.env) },
|
|
108
|
+
stdio: "inherit",
|
|
109
|
+
});
|
|
110
|
+
const exit = new Promise((resolve) => {
|
|
111
|
+
child.on("exit", (code, signal) => {
|
|
112
|
+
resolve(code ?? (signal !== null ? 143 : 1));
|
|
113
|
+
});
|
|
114
|
+
child.on("error", () => {
|
|
115
|
+
resolve(1);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
return {
|
|
119
|
+
wait: () => exit,
|
|
120
|
+
kill: () => {
|
|
121
|
+
// `docker run` proxies SIGTERM to the container; also best-effort `docker kill` by name in
|
|
122
|
+
// case signal proxying is off, so a lost lease / drain actually stops the container.
|
|
123
|
+
child.kill("SIGTERM");
|
|
124
|
+
void exec(cfg.runtime, ["kill", `bw-run-${runIdFromCwd(opts.cwd)}`]).catch(() => undefined);
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
/** Probe that a container runtime is installed AND its daemon is reachable. Returns the runtime
|
|
130
|
+
* binary name on success, or null (so `start` can hard-fail with a clear message rather than
|
|
131
|
+
* failing every run later). `docker info` / `podman info` exits non-zero when the daemon is down. */
|
|
132
|
+
export async function detectContainerRuntime(candidates = ["docker", "podman"], run = async (bin) => {
|
|
133
|
+
await exec(bin, ["info"], { timeout: 10_000 });
|
|
134
|
+
}) {
|
|
135
|
+
for (const bin of candidates) {
|
|
136
|
+
try {
|
|
137
|
+
await run(bin);
|
|
138
|
+
return bin;
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
// not installed, or the daemon isn't running — try the next candidate
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
package/dist/daemon/index.d.ts
CHANGED
package/dist/daemon/index.js
CHANGED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { type RunProcessHandle, type RunSpawner } from "./daemon.js";
|
|
2
|
+
import { detectContainerRuntime } from "./container.js";
|
|
3
|
+
import type { RunnerIdentity } from "./identity.js";
|
|
4
|
+
export type IsolationMode = "container" | "host";
|
|
5
|
+
export interface IsolationConfig {
|
|
6
|
+
/** `container` (default) = throwaway container per run; `host` = raw process, full machine access. */
|
|
7
|
+
mode: IsolationMode;
|
|
8
|
+
/** Container image ref (container mode). Defaults to the version-pinned ghcr.io/boardwalk-labs image. */
|
|
9
|
+
image?: string;
|
|
10
|
+
/** Container network mode (container mode). Defaults to `host` — preserves LAN/VPN/localhost reach. */
|
|
11
|
+
network?: string;
|
|
12
|
+
/** Extra host bind mounts to expose to the run (container mode). */
|
|
13
|
+
mounts?: readonly string[];
|
|
14
|
+
}
|
|
15
|
+
/** This package's version (the runner image tag is pinned to it). Read from package.json so a
|
|
16
|
+
* release bump is one file. */
|
|
17
|
+
export declare function packageVersion(): string;
|
|
18
|
+
/** Default runner image ref — the version-pinned public image (base + this runtime). */
|
|
19
|
+
export declare function defaultImage(): string;
|
|
20
|
+
/** Raw-process spawner (the `--host` escape hatch): one Node process per run, full machine access.
|
|
21
|
+
* Env = the platform contract + the claim's resolved vars over a minimal base (PATH + proxy knobs;
|
|
22
|
+
* HOME = the run's workspace). */
|
|
23
|
+
export declare function processSpawn(opts: {
|
|
24
|
+
entry: string;
|
|
25
|
+
env: Record<string, string>;
|
|
26
|
+
cwd: string;
|
|
27
|
+
}): RunProcessHandle;
|
|
28
|
+
/** Error thrown when container isolation is requested but no runtime is available — the caller
|
|
29
|
+
* surfaces the message and exits (never silently drop to the unisolated path). */
|
|
30
|
+
export declare class NoContainerRuntimeError extends Error {
|
|
31
|
+
constructor();
|
|
32
|
+
}
|
|
33
|
+
/** Pick the run spawner from the isolation config. Container is default; `host` is the escape hatch;
|
|
34
|
+
* container-with-no-runtime throws {@link NoContainerRuntimeError}. */
|
|
35
|
+
export declare function resolveSpawner(iso: IsolationConfig, log: (line: string) => void, detect?: typeof detectContainerRuntime): Promise<RunSpawner>;
|
|
36
|
+
export interface StartRunnerOptions {
|
|
37
|
+
baseUrl: string;
|
|
38
|
+
identity: RunnerIdentity;
|
|
39
|
+
isolation: IsolationConfig;
|
|
40
|
+
workDir: string;
|
|
41
|
+
once?: boolean;
|
|
42
|
+
/** Progress line sink (stdout by default). */
|
|
43
|
+
log?: (line: string) => void;
|
|
44
|
+
/** Wire SIGINT/SIGTERM → drain (Ctrl-C finishes the current run; a second forces quit). Default on. */
|
|
45
|
+
handleSignals?: boolean;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Run the daemon to completion: resolve the spawner (isolation), poll → claim → execute → heartbeat,
|
|
49
|
+
* and drain cleanly on Ctrl-C. Both entry points call this after obtaining an identity, so there is
|
|
50
|
+
* exactly one implementation of the run loop + isolation decision.
|
|
51
|
+
*/
|
|
52
|
+
export declare function startRunner(opts: StartRunnerOptions): Promise<void>;
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// startRunner — the ONE implementation of "run this machine as a runner". Both entry points
|
|
3
|
+
// (`boardwalk-runner start` and the CLI's `boardwalk runner start`) obtain an identity their own
|
|
4
|
+
// way (two-step token vs one-step management-API enrollment) and then call THIS. There is no second
|
|
5
|
+
// copy of the daemon lifecycle, the isolation decision, or the drain handling to drift out of sync.
|
|
6
|
+
//
|
|
7
|
+
// Isolation is a `RunSpawner` choice: `container` (default) runs each run in a throwaway container;
|
|
8
|
+
// `host` (the `--host` escape hatch) runs it as a raw child process with full machine access. A
|
|
9
|
+
// future macOS/Windows native sandbox is simply a third RunSpawner behind this same seam.
|
|
10
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
import { PoolClient } from "./pool_client.js";
|
|
15
|
+
import { startDaemon } from "./daemon.js";
|
|
16
|
+
import { createContainerSpawner, detectContainerRuntime } from "./container.js";
|
|
17
|
+
/** This package's version (the runner image tag is pinned to it). Read from package.json so a
|
|
18
|
+
* release bump is one file. */
|
|
19
|
+
export function packageVersion() {
|
|
20
|
+
try {
|
|
21
|
+
const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
|
|
22
|
+
if (typeof pkg === "object" && pkg !== null && "version" in pkg) {
|
|
23
|
+
const v = pkg.version;
|
|
24
|
+
if (typeof v === "string")
|
|
25
|
+
return v;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// fall through
|
|
30
|
+
}
|
|
31
|
+
return "0.0.0";
|
|
32
|
+
}
|
|
33
|
+
/** Default runner image ref — the version-pinned public image (base + this runtime). */
|
|
34
|
+
export function defaultImage() {
|
|
35
|
+
return `ghcr.io/boardwalk-labs/runner:${packageVersion()}`;
|
|
36
|
+
}
|
|
37
|
+
/** Raw-process spawner (the `--host` escape hatch): one Node process per run, full machine access.
|
|
38
|
+
* Env = the platform contract + the claim's resolved vars over a minimal base (PATH + proxy knobs;
|
|
39
|
+
* HOME = the run's workspace). */
|
|
40
|
+
export function processSpawn(opts) {
|
|
41
|
+
const base = {};
|
|
42
|
+
for (const key of [
|
|
43
|
+
"PATH",
|
|
44
|
+
"LANG",
|
|
45
|
+
"NODE_USE_ENV_PROXY",
|
|
46
|
+
"HTTPS_PROXY",
|
|
47
|
+
"https_proxy",
|
|
48
|
+
"HTTP_PROXY",
|
|
49
|
+
"http_proxy",
|
|
50
|
+
"NO_PROXY",
|
|
51
|
+
"no_proxy",
|
|
52
|
+
"BOARDWALK_RUNNER_DEBUG",
|
|
53
|
+
]) {
|
|
54
|
+
const v = process.env[key];
|
|
55
|
+
if (v !== undefined)
|
|
56
|
+
base[key] = v;
|
|
57
|
+
}
|
|
58
|
+
const child = nodeSpawn(process.execPath, [opts.entry], {
|
|
59
|
+
cwd: opts.cwd,
|
|
60
|
+
env: { ...base, HOME: opts.cwd, TMPDIR: path.join(opts.cwd, "..", "tmp"), ...opts.env },
|
|
61
|
+
stdio: "inherit",
|
|
62
|
+
});
|
|
63
|
+
const exit = new Promise((resolve) => {
|
|
64
|
+
child.on("exit", (code, signal) => {
|
|
65
|
+
resolve(code ?? (signal !== null ? 143 : 1));
|
|
66
|
+
});
|
|
67
|
+
child.on("error", () => {
|
|
68
|
+
resolve(1);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
return {
|
|
72
|
+
wait: () => exit,
|
|
73
|
+
kill: () => {
|
|
74
|
+
child.kill("SIGTERM");
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/** Error thrown when container isolation is requested but no runtime is available — the caller
|
|
79
|
+
* surfaces the message and exits (never silently drop to the unisolated path). */
|
|
80
|
+
export class NoContainerRuntimeError extends Error {
|
|
81
|
+
constructor() {
|
|
82
|
+
super("No container runtime found (looked for docker, podman). Self-hosted runs are containerized " +
|
|
83
|
+
"by default for isolation.\n" +
|
|
84
|
+
" • Install Docker or Podman and make sure it's running, or\n" +
|
|
85
|
+
" • pass --host to run without isolation (full machine access; trusted workflows only).");
|
|
86
|
+
this.name = "NoContainerRuntimeError";
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** Pick the run spawner from the isolation config. Container is default; `host` is the escape hatch;
|
|
90
|
+
* container-with-no-runtime throws {@link NoContainerRuntimeError}. */
|
|
91
|
+
export async function resolveSpawner(iso, log, detect = detectContainerRuntime) {
|
|
92
|
+
if (iso.mode === "host") {
|
|
93
|
+
log("Running WITHOUT isolation (--host): runs get full access to this machine as your user. " +
|
|
94
|
+
"Only run workflows you trust.\n");
|
|
95
|
+
return processSpawn;
|
|
96
|
+
}
|
|
97
|
+
const runtime = await detect();
|
|
98
|
+
if (runtime === null)
|
|
99
|
+
throw new NoContainerRuntimeError();
|
|
100
|
+
const image = iso.image ?? defaultImage();
|
|
101
|
+
// Linux: run as the invoking user so the bind-mounted workspace is writable. Docker Desktop
|
|
102
|
+
// (macOS/Windows) maps ownership via file sharing, so leave the image's user in place there.
|
|
103
|
+
const user = process.platform === "linux" && process.getuid !== undefined && process.getgid !== undefined
|
|
104
|
+
? `${String(process.getuid())}:${String(process.getgid())}`
|
|
105
|
+
: undefined;
|
|
106
|
+
log(`Isolation: ${runtime} container (${image}); workspace-only + ${iso.network ?? "host"} network.\n`);
|
|
107
|
+
return createContainerSpawner({
|
|
108
|
+
runtime,
|
|
109
|
+
image,
|
|
110
|
+
...(iso.network !== undefined ? { network: iso.network } : {}),
|
|
111
|
+
...(iso.mounts !== undefined && iso.mounts.length > 0 ? { mounts: iso.mounts } : {}),
|
|
112
|
+
...(user !== undefined ? { user } : {}),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Run the daemon to completion: resolve the spawner (isolation), poll → claim → execute → heartbeat,
|
|
117
|
+
* and drain cleanly on Ctrl-C. Both entry points call this after obtaining an identity, so there is
|
|
118
|
+
* exactly one implementation of the run loop + isolation decision.
|
|
119
|
+
*/
|
|
120
|
+
export async function startRunner(opts) {
|
|
121
|
+
const log = opts.log ?? ((line) => void process.stdout.write(line));
|
|
122
|
+
const spawn = await resolveSpawner(opts.isolation, log);
|
|
123
|
+
// Process mode imports this entry directly; container mode ignores it (the image's entrypoint runs
|
|
124
|
+
// the runtime), but the daemon still passes it through the spawner interface.
|
|
125
|
+
const runtimeEntry = fileURLToPath(new URL("../runtime/main.js", import.meta.url));
|
|
126
|
+
const client = new PoolClient({ baseUrl: opts.baseUrl, runnerToken: opts.identity.runner_token });
|
|
127
|
+
const daemon = startDaemon({
|
|
128
|
+
client,
|
|
129
|
+
runtimeEntry,
|
|
130
|
+
workDir: opts.workDir,
|
|
131
|
+
runnerId: opts.identity.runner_id,
|
|
132
|
+
spawn,
|
|
133
|
+
...(opts.once === true ? { once: true } : {}),
|
|
134
|
+
});
|
|
135
|
+
if (opts.handleSignals !== false) {
|
|
136
|
+
let interrupts = 0;
|
|
137
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
138
|
+
process.on(signal, () => {
|
|
139
|
+
interrupts += 1;
|
|
140
|
+
if (interrupts === 1) {
|
|
141
|
+
log("\nDraining: finishing the current run, claiming nothing new. Ctrl-C again to force-quit.\n");
|
|
142
|
+
daemon.drain();
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
process.exit(130);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
log(`Runner ${opts.identity.name} online in pool '${opts.identity.pool}'. Waiting for runs...\n`);
|
|
151
|
+
await daemon.done;
|
|
152
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@boardwalk-labs/runner",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"description": "Boardwalk self-hosted runner: execute cloud-scheduled runs on your own machines. Currently: the canonical runner contract types.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|