@redwoodjs/agent-ci 0.1.0
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/LICENSE +110 -0
- package/README.md +79 -0
- package/dist/cli.js +628 -0
- package/dist/config.js +63 -0
- package/dist/docker/container-config.js +178 -0
- package/dist/docker/container-config.test.js +156 -0
- package/dist/docker/service-containers.js +205 -0
- package/dist/docker/service-containers.test.js +236 -0
- package/dist/docker/shutdown.js +120 -0
- package/dist/docker/shutdown.test.js +148 -0
- package/dist/output/agent-mode.js +7 -0
- package/dist/output/agent-mode.test.js +36 -0
- package/dist/output/cleanup.js +218 -0
- package/dist/output/cleanup.test.js +241 -0
- package/dist/output/concurrency.js +57 -0
- package/dist/output/concurrency.test.js +88 -0
- package/dist/output/debug.js +36 -0
- package/dist/output/logger.js +57 -0
- package/dist/output/logger.test.js +82 -0
- package/dist/output/reporter.js +67 -0
- package/dist/output/run-state.js +126 -0
- package/dist/output/run-state.test.js +169 -0
- package/dist/output/state-renderer.js +149 -0
- package/dist/output/state-renderer.test.js +488 -0
- package/dist/output/tree-renderer.js +52 -0
- package/dist/output/tree-renderer.test.js +105 -0
- package/dist/output/working-directory.js +20 -0
- package/dist/runner/directory-setup.js +98 -0
- package/dist/runner/directory-setup.test.js +31 -0
- package/dist/runner/git-shim.js +92 -0
- package/dist/runner/git-shim.test.js +57 -0
- package/dist/runner/local-job.js +691 -0
- package/dist/runner/metadata.js +90 -0
- package/dist/runner/metadata.test.js +127 -0
- package/dist/runner/result-builder.js +119 -0
- package/dist/runner/result-builder.test.js +177 -0
- package/dist/runner/step-wrapper.js +82 -0
- package/dist/runner/step-wrapper.test.js +77 -0
- package/dist/runner/sync.js +80 -0
- package/dist/runner/workspace.js +66 -0
- package/dist/types.js +1 -0
- package/dist/workflow/job-scheduler.js +62 -0
- package/dist/workflow/job-scheduler.test.js +130 -0
- package/dist/workflow/workflow-parser.js +556 -0
- package/dist/workflow/workflow-parser.test.js +642 -0
- package/package.json +39 -0
- package/shim.sh +11 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// ─── Environment variables ────────────────────────────────────────────────────
|
|
2
|
+
/**
|
|
3
|
+
* Build the Env array for `docker.createContainer()`.
|
|
4
|
+
*/
|
|
5
|
+
export function buildContainerEnv(opts) {
|
|
6
|
+
const { containerName, registrationToken, repoUrl, dockerApiUrl, githubRepo, headSha, dtuHost, useDirectContainer, } = opts;
|
|
7
|
+
return [
|
|
8
|
+
`RUNNER_NAME=${containerName}`,
|
|
9
|
+
`RUNNER_TOKEN=${registrationToken}`,
|
|
10
|
+
`RUNNER_REPOSITORY_URL=${repoUrl}`,
|
|
11
|
+
`GITHUB_API_URL=${dockerApiUrl}`,
|
|
12
|
+
`GITHUB_SERVER_URL=${repoUrl}`,
|
|
13
|
+
`GITHUB_REPOSITORY=${githubRepo}`,
|
|
14
|
+
`AGENT_CI_LOCAL_SYNC=true`,
|
|
15
|
+
`AGENT_CI_HEAD_SHA=${headSha || "HEAD"}`,
|
|
16
|
+
`AGENT_CI_DTU_HOST=${dtuHost}`,
|
|
17
|
+
`ACTIONS_CACHE_URL=${dockerApiUrl}/`,
|
|
18
|
+
`ACTIONS_RESULTS_URL=${dockerApiUrl}/`,
|
|
19
|
+
`ACTIONS_RUNTIME_TOKEN=mock_cache_token_123`,
|
|
20
|
+
`RUNNER_TOOL_CACHE=/opt/hostedtoolcache`,
|
|
21
|
+
`PATH=/home/runner/externals/node24/bin:/home/runner/externals/node20/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin`,
|
|
22
|
+
// Force colour output in all child processes (pnpm, Node, etc.)
|
|
23
|
+
`FORCE_COLOR=1`,
|
|
24
|
+
// Custom containers may run as root and lack libicu — configure accordingly
|
|
25
|
+
...(useDirectContainer
|
|
26
|
+
? [`RUNNER_ALLOW_RUNASROOT=1`, `DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1`]
|
|
27
|
+
: []),
|
|
28
|
+
];
|
|
29
|
+
}
|
|
30
|
+
// ─── Bind mounts ──────────────────────────────────────────────────────────────
|
|
31
|
+
/**
|
|
32
|
+
* Build the Binds array for `docker.createContainer()`.
|
|
33
|
+
*/
|
|
34
|
+
export function buildContainerBinds(opts) {
|
|
35
|
+
const { hostWorkDir, shimsDir, signalsDir, diagDir, toolCacheDir, pnpmStoreDir, npmCacheDir, bunCacheDir, playwrightCacheDir, warmModulesDir, hostRunnerDir, useDirectContainer, } = opts;
|
|
36
|
+
const h = toHostPath;
|
|
37
|
+
return [
|
|
38
|
+
// When using a custom container, bind-mount the extracted runner
|
|
39
|
+
...(useDirectContainer ? [`${h(hostRunnerDir)}:/home/runner`] : []),
|
|
40
|
+
`${h(hostWorkDir)}:/home/runner/_work`,
|
|
41
|
+
"/var/run/docker.sock:/var/run/docker.sock",
|
|
42
|
+
`${h(shimsDir)}:/tmp/agent-ci-shims`,
|
|
43
|
+
// Pause-on-failure IPC: signal files (paused, retry, abort)
|
|
44
|
+
...(signalsDir ? [`${h(signalsDir)}:/tmp/agent-ci-signals`] : []),
|
|
45
|
+
`${h(diagDir)}:/home/runner/_diag`,
|
|
46
|
+
`${h(toolCacheDir)}:/opt/hostedtoolcache`,
|
|
47
|
+
// Package manager caches (persist across runs)
|
|
48
|
+
`${h(pnpmStoreDir)}:/home/runner/_work/.pnpm-store`,
|
|
49
|
+
`${h(npmCacheDir)}:/home/runner/.npm`,
|
|
50
|
+
`${h(bunCacheDir)}:/home/runner/.bun/install/cache`,
|
|
51
|
+
`${h(playwrightCacheDir)}:/home/runner/.cache/ms-playwright`,
|
|
52
|
+
// Warm node_modules: mounted outside the workspace so actions/checkout can
|
|
53
|
+
// delete the symlink without EBUSY. A symlink in the entrypoint points
|
|
54
|
+
// workspace/node_modules → /tmp/warm-modules.
|
|
55
|
+
`${h(warmModulesDir)}:/tmp/warm-modules`,
|
|
56
|
+
];
|
|
57
|
+
}
|
|
58
|
+
// ─── Container command ────────────────────────────────────────────────────────
|
|
59
|
+
/**
|
|
60
|
+
* Build the long entrypoint command string for the container.
|
|
61
|
+
*/
|
|
62
|
+
export function buildContainerCmd(opts) {
|
|
63
|
+
const { svcPortForwardSnippet, dtuPort, dtuHost, useDirectContainer, containerName } = opts;
|
|
64
|
+
// The runner connects directly to the DTU host (no in-container proxy needed).
|
|
65
|
+
// The DTU listens on 0.0.0.0 so it's reachable from the container network.
|
|
66
|
+
const dtuBaseUrl = `http://${dtuHost}:${dtuPort}`;
|
|
67
|
+
// For direct containers, credentials are pre-baked on the host and bind-mounted
|
|
68
|
+
// into /home/runner. For the default image, we write them inline in the
|
|
69
|
+
// entrypoint since /home/runner is baked into the image.
|
|
70
|
+
const credentialSnippet = useDirectContainer
|
|
71
|
+
? ""
|
|
72
|
+
: `echo '{"agentId":1,"agentName":"${containerName}","poolId":1,"poolName":"Default","serverUrl":"${dtuBaseUrl}","gitHubUrl":"${dtuBaseUrl}/'$GITHUB_REPOSITORY'","workFolder":"_work","ephemeral":true}' > /home/runner/.runner && echo '{"scheme":"OAuth","data":{"clientId":"00000000-0000-0000-0000-000000000000","authorizationUrl":"${dtuBaseUrl}/_apis/oauth2/token","oAuthEndpointUrl":"${dtuBaseUrl}/_apis/oauth2/token","requireFipsCryptography":"False"}}' > /home/runner/.credentials && echo '{"d":"CQpCI+sO2GD1N/JsHHI9zEhMlu5Fcc8mU4O2bO6iscOsagFjvEnTesJgydC/Go1HuOBlx+GT9EG2h7+juS0z2o5n8Mvt5BBxlK+tqoDOs8VfQ9CSUl3hqYRPeNdBfnA1w8ovLW0wqfPO08FWTLI0urYsnwjZ5BQrBM+D7zYeA0aCsKdo75bKmaEKnmqrtIEhb7hE45XQa32Yt0RPCPi8QcQAY2HLHbdWdZYDj6k/UuDvz9H/xlDzwYq6Yikk2RSMArFzaufxCGS9tBZNEACDPYgnZnEMXRcvsnZ9FYbq81KOSifCmq7Yocq+j3rY5zJCD+PIDY9QJwPxB4PGasRKAQ==","dp":"A0sY1oOz1+3uUMiy+I5xGuHGHOrEQPYspd1xGClBYYsa/Za0UDWS7V0Tn1cbRWfWtNe5vTpxcvwQd6UZBwrtHF6R2zyXFhE++PLPhCe0tH4C5FY9i9jUw9Vo8t44i/s5JUHU2B1mEptXFUA0GcVrLKS8toZSgqELSS2Q/YLRxoE=","dq":"GrLC9dPJ5n3VYw51ghCH7tybUN9/Oe4T8d9v4dLQ34RQEWHwRd4g3U3zkvuhpXFPloUTMmkxS7MF5pS1evrtzkay4QUTDv+28s0xRuAsw5qNTzuFygg8t93MvpvTVZ2TNApW6C7NFvkL9NbxAnU8+I61/3ow7i6a7oYJJ0hWAxE=","exponent":"AQAB","inverseQ":"8DVz9FSvEdt5W4B9OjgakZHwGfnhn2VLDUxrsR5ilC5tPC/IgA8C2xEfKQM1t+K/N3pAYHBYQ6EPgtW4kquBS/Sy102xbRI7GSCnUbRtTpWYPOaCn6EaxBNzwWzbp5vCbCGvFqlSu4+OBYRVe+iCj+gAnkmT/TKPhHHbTjJHvw==","modulus":"x0eoW2DD7xsW5YiorMN8pNHVvZk4ED1SHlA/bmVnRz5FjEDnQloMn0nBgIUHxoNArksknrp/FOVJv5sJHJTiRZkOp+ZmH7d3W3gmw63IxK2C5pV+6xfav9jR2+Wt/6FMYMgG2utBdF95oif1f2XREFovHoXkWms2l0CPLLHVPO44Hh9EEmBmjOeMJEZkulHJ44z9y8e+GZ2nYqO0ZiRWQcRObZ0vlRaGg6PPOl4ltay0BfNksMB3NDtlhkdVkAEFQxEaZZDK9NtkvNljXCioP3TyTAbqNUGsYCA5D+IHGZT9An99J9vUqTFP6TKjqUvy9WNiIzaUksCySA0a4SVBkQ==","p":"8fgAdmWy+sTzAN19fYkWMQqeC7t1BCQMo5z5knfVLg8TtwP9ZGqDtoe+r0bGv3UgVsvvDdP/QwRvRVP+5G9l999Y6b4VbSdUbrfPfOgjpPDmRTQzHDve5jh5xBENQoRXYm7PMgHGmjwuFsE/tKtSGTrvt2Z3qcYAo0IOqLLhYmE=","q":"0tXx4+P7gUWePf92UJLkzhNBClvdnmDbIt52Lui7YCARczbN/asCDJxcMy6Bh3qmIx/bNuOUrfzHkYZHfnRw8AGEK80qmiLLPI6jrUBOGRajmzemGQx0W8FWalEQfGdNIv9R2nsegDRoMq255Zo/qX60xQ6abpp0c6UNhVYSjTE="}' > /home/runner/.credentials_rsaparams && `;
|
|
73
|
+
// Timing helper: date +%s%3N gives epoch milliseconds
|
|
74
|
+
const T = (label) => `T1=$(date +%s%3N); echo "[agent-ci:boot] ${label}: $((T1-T0))ms"; T0=$T1`;
|
|
75
|
+
const cmdScript = [
|
|
76
|
+
`MAYBE_SUDO() { if command -v sudo >/dev/null 2>&1; then sudo -n "$@"; else "$@"; fi; }`,
|
|
77
|
+
`BOOT_T0=$(date +%s%3N); T0=$BOOT_T0`,
|
|
78
|
+
// chmod is done host-side in workspacePrepPromise — skip it here
|
|
79
|
+
`if [ -f /usr/bin/git ]; then MAYBE_SUDO mv /usr/bin/git /usr/bin/git.real 2>/dev/null; MAYBE_SUDO cp -p /tmp/agent-ci-shims/git /usr/bin/git 2>/dev/null; MAYBE_SUDO chmod +x /usr/bin/git 2>/dev/null; fi`,
|
|
80
|
+
T("git-shim"),
|
|
81
|
+
`${svcPortForwardSnippet}chmod 666 /var/run/docker.sock 2>/dev/null || true`,
|
|
82
|
+
T("docker-sock"),
|
|
83
|
+
`cd /home/runner`,
|
|
84
|
+
`${credentialSnippet}true`,
|
|
85
|
+
T("credentials"),
|
|
86
|
+
`REPO_NAME=$(basename $GITHUB_REPOSITORY)`,
|
|
87
|
+
`WORKSPACE_PATH=/home/runner/_work/$REPO_NAME/$REPO_NAME`,
|
|
88
|
+
`mkdir -p $WORKSPACE_PATH`,
|
|
89
|
+
`ln -sfn /tmp/warm-modules $WORKSPACE_PATH/node_modules`,
|
|
90
|
+
T("workspace-setup"),
|
|
91
|
+
`echo "[agent-ci:boot] total: $(($(date +%s%3N)-BOOT_T0))ms"`,
|
|
92
|
+
`echo "[agent-ci:boot] starting run.sh --once"`,
|
|
93
|
+
`./run.sh --once`,
|
|
94
|
+
].join(" && ");
|
|
95
|
+
return [...(useDirectContainer ? ["-c"] : ["bash", "-c"]), cmdScript];
|
|
96
|
+
}
|
|
97
|
+
// ─── DTU host resolution ──────────────────────────────────────────────────────
|
|
98
|
+
import fs from "fs";
|
|
99
|
+
import { execSync } from "child_process";
|
|
100
|
+
/**
|
|
101
|
+
* Resolve the DTU host address that nested Docker containers can reach.
|
|
102
|
+
* Inside Docker: use the container's own bridge IP.
|
|
103
|
+
* On host: use `host.docker.internal`.
|
|
104
|
+
*/
|
|
105
|
+
export function resolveDtuHost() {
|
|
106
|
+
const isInsideDocker = fs.existsSync("/.dockerenv");
|
|
107
|
+
if (!isInsideDocker) {
|
|
108
|
+
return "host.docker.internal";
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
const ip = execSync("hostname -I 2>/dev/null | awk '{print $1}'", {
|
|
112
|
+
encoding: "utf8",
|
|
113
|
+
}).trim();
|
|
114
|
+
if (ip) {
|
|
115
|
+
return ip;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
catch { }
|
|
119
|
+
return "172.17.0.1"; // fallback to bridge gateway
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Rewrite a DTU URL to be reachable from inside Docker containers.
|
|
123
|
+
*/
|
|
124
|
+
export function resolveDockerApiUrl(dtuUrl, dtuHost) {
|
|
125
|
+
return dtuUrl.replace("localhost", dtuHost).replace("127.0.0.1", dtuHost);
|
|
126
|
+
}
|
|
127
|
+
let _mountMappings = null;
|
|
128
|
+
/**
|
|
129
|
+
* When running inside a container with Docker-outside-of-Docker (shared socket),
|
|
130
|
+
* bind mount paths must use HOST paths, not container paths. This function
|
|
131
|
+
* inspects our own container's mounts to build a translation table.
|
|
132
|
+
*
|
|
133
|
+
* Returns [] when running on bare metal (no translation needed).
|
|
134
|
+
*/
|
|
135
|
+
function getMountMappings() {
|
|
136
|
+
if (_mountMappings !== null) {
|
|
137
|
+
return _mountMappings;
|
|
138
|
+
}
|
|
139
|
+
if (!fs.existsSync("/.dockerenv")) {
|
|
140
|
+
_mountMappings = [];
|
|
141
|
+
return _mountMappings;
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
const containerId = fs.readFileSync("/etc/hostname", "utf8").trim();
|
|
145
|
+
const json = execSync(`docker inspect ${containerId}`, {
|
|
146
|
+
encoding: "utf8",
|
|
147
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
148
|
+
});
|
|
149
|
+
const data = JSON.parse(json);
|
|
150
|
+
const mounts = data[0]?.Mounts || [];
|
|
151
|
+
_mountMappings = mounts
|
|
152
|
+
.filter((m) => m.Type === "bind")
|
|
153
|
+
.map((m) => ({
|
|
154
|
+
hostPath: m.Source,
|
|
155
|
+
containerPath: m.Destination,
|
|
156
|
+
}))
|
|
157
|
+
// Sort longest containerPath first for greedy matching
|
|
158
|
+
.sort((a, b) => b.containerPath.length - a.containerPath.length);
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
_mountMappings = [];
|
|
162
|
+
}
|
|
163
|
+
return _mountMappings;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Translate a local filesystem path to the corresponding Docker host path.
|
|
167
|
+
* Only applies when running inside a container (Docker-outside-of-Docker).
|
|
168
|
+
* Returns the path unchanged when running on bare metal.
|
|
169
|
+
*/
|
|
170
|
+
export function toHostPath(localPath) {
|
|
171
|
+
const mappings = getMountMappings();
|
|
172
|
+
for (const { containerPath, hostPath } of mappings) {
|
|
173
|
+
if (localPath === containerPath || localPath.startsWith(containerPath + "/")) {
|
|
174
|
+
return hostPath + localPath.slice(containerPath.length);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return localPath;
|
|
178
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
// ── buildContainerEnv ─────────────────────────────────────────────────────────
|
|
3
|
+
describe("buildContainerEnv", () => {
|
|
4
|
+
it("builds the standard env array", async () => {
|
|
5
|
+
const { buildContainerEnv } = await import("./container-config.js");
|
|
6
|
+
const env = buildContainerEnv({
|
|
7
|
+
containerName: "runner-1",
|
|
8
|
+
registrationToken: "tok",
|
|
9
|
+
repoUrl: "http://dtu:3000/org/repo",
|
|
10
|
+
dockerApiUrl: "http://dtu:3000",
|
|
11
|
+
githubRepo: "org/repo",
|
|
12
|
+
headSha: "abc123",
|
|
13
|
+
dtuHost: "host.docker.internal",
|
|
14
|
+
useDirectContainer: false,
|
|
15
|
+
});
|
|
16
|
+
expect(env).toContain("RUNNER_NAME=runner-1");
|
|
17
|
+
expect(env).toContain("GITHUB_REPOSITORY=org/repo");
|
|
18
|
+
expect(env).toContain("AGENT_CI_HEAD_SHA=abc123");
|
|
19
|
+
expect(env).toContain("FORCE_COLOR=1");
|
|
20
|
+
// Should NOT include root-mode vars for standard container
|
|
21
|
+
expect(env).not.toContain("RUNNER_ALLOW_RUNASROOT=1");
|
|
22
|
+
});
|
|
23
|
+
it("adds root-mode env vars for direct container injection", async () => {
|
|
24
|
+
const { buildContainerEnv } = await import("./container-config.js");
|
|
25
|
+
const env = buildContainerEnv({
|
|
26
|
+
containerName: "runner-1",
|
|
27
|
+
registrationToken: "tok",
|
|
28
|
+
repoUrl: "http://dtu:3000/org/repo",
|
|
29
|
+
dockerApiUrl: "http://dtu:3000",
|
|
30
|
+
githubRepo: "org/repo",
|
|
31
|
+
dtuHost: "host.docker.internal",
|
|
32
|
+
useDirectContainer: true,
|
|
33
|
+
});
|
|
34
|
+
expect(env).toContain("RUNNER_ALLOW_RUNASROOT=1");
|
|
35
|
+
expect(env).toContain("DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1");
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
// ── buildContainerBinds ───────────────────────────────────────────────────────
|
|
39
|
+
describe("buildContainerBinds", () => {
|
|
40
|
+
it("builds standard bind mounts", async () => {
|
|
41
|
+
const { buildContainerBinds } = await import("./container-config.js");
|
|
42
|
+
const binds = buildContainerBinds({
|
|
43
|
+
hostWorkDir: "/tmp/work",
|
|
44
|
+
shimsDir: "/tmp/shims",
|
|
45
|
+
diagDir: "/tmp/diag",
|
|
46
|
+
toolCacheDir: "/tmp/toolcache",
|
|
47
|
+
pnpmStoreDir: "/tmp/pnpm",
|
|
48
|
+
npmCacheDir: "/tmp/npm",
|
|
49
|
+
bunCacheDir: "/tmp/bun",
|
|
50
|
+
playwrightCacheDir: "/tmp/playwright",
|
|
51
|
+
warmModulesDir: "/tmp/warm",
|
|
52
|
+
hostRunnerDir: "/tmp/runner",
|
|
53
|
+
useDirectContainer: false,
|
|
54
|
+
});
|
|
55
|
+
expect(binds).toContain("/tmp/work:/home/runner/_work");
|
|
56
|
+
expect(binds).toContain("/var/run/docker.sock:/var/run/docker.sock");
|
|
57
|
+
expect(binds).toContain("/tmp/shims:/tmp/agent-ci-shims");
|
|
58
|
+
expect(binds).toContain("/tmp/warm:/tmp/warm-modules");
|
|
59
|
+
// Standard mode should NOT include runner home bind (but _work bind is expected)
|
|
60
|
+
expect(binds.some((b) => b.endsWith(":/home/runner"))).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
it("includes runner bind mount for direct container", async () => {
|
|
63
|
+
const { buildContainerBinds } = await import("./container-config.js");
|
|
64
|
+
const binds = buildContainerBinds({
|
|
65
|
+
hostWorkDir: "/tmp/work",
|
|
66
|
+
shimsDir: "/tmp/shims",
|
|
67
|
+
diagDir: "/tmp/diag",
|
|
68
|
+
toolCacheDir: "/tmp/toolcache",
|
|
69
|
+
pnpmStoreDir: "/tmp/pnpm",
|
|
70
|
+
npmCacheDir: "/tmp/npm",
|
|
71
|
+
bunCacheDir: "/tmp/bun",
|
|
72
|
+
playwrightCacheDir: "/tmp/playwright",
|
|
73
|
+
warmModulesDir: "/tmp/warm",
|
|
74
|
+
hostRunnerDir: "/tmp/runner",
|
|
75
|
+
useDirectContainer: true,
|
|
76
|
+
});
|
|
77
|
+
expect(binds).toContain("/tmp/runner:/home/runner");
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
// ── buildContainerCmd ─────────────────────────────────────────────────────────
|
|
81
|
+
describe("buildContainerCmd", () => {
|
|
82
|
+
it("starts with bash -c for standard containers", async () => {
|
|
83
|
+
const { buildContainerCmd } = await import("./container-config.js");
|
|
84
|
+
const cmd = buildContainerCmd({
|
|
85
|
+
svcPortForwardSnippet: "",
|
|
86
|
+
dtuPort: "3000",
|
|
87
|
+
dtuHost: "localhost",
|
|
88
|
+
useDirectContainer: false,
|
|
89
|
+
containerName: "test-runner",
|
|
90
|
+
});
|
|
91
|
+
expect(cmd[0]).toBe("bash");
|
|
92
|
+
expect(cmd[1]).toBe("-c");
|
|
93
|
+
expect(cmd[2]).toContain("MAYBE_SUDO");
|
|
94
|
+
expect(cmd[2]).toContain("run.sh --once");
|
|
95
|
+
});
|
|
96
|
+
it("starts with -c for direct containers", async () => {
|
|
97
|
+
const { buildContainerCmd } = await import("./container-config.js");
|
|
98
|
+
const cmd = buildContainerCmd({
|
|
99
|
+
svcPortForwardSnippet: "",
|
|
100
|
+
dtuPort: "3000",
|
|
101
|
+
dtuHost: "localhost",
|
|
102
|
+
useDirectContainer: true,
|
|
103
|
+
containerName: "test-runner",
|
|
104
|
+
});
|
|
105
|
+
expect(cmd[0]).toBe("-c");
|
|
106
|
+
expect(cmd).toHaveLength(2);
|
|
107
|
+
});
|
|
108
|
+
it("includes service port forwarding snippet", async () => {
|
|
109
|
+
const { buildContainerCmd } = await import("./container-config.js");
|
|
110
|
+
const cmd = buildContainerCmd({
|
|
111
|
+
svcPortForwardSnippet: "socat TCP-LISTEN:5432,fork TCP:svc-db:5432 & \nsleep 0.3 && ",
|
|
112
|
+
dtuPort: "3000",
|
|
113
|
+
dtuHost: "localhost",
|
|
114
|
+
useDirectContainer: false,
|
|
115
|
+
containerName: "test-runner",
|
|
116
|
+
});
|
|
117
|
+
expect(cmd[2]).toContain("socat TCP-LISTEN:5432");
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
// ── resolveDockerApiUrl ───────────────────────────────────────────────────────
|
|
121
|
+
describe("resolveDockerApiUrl", () => {
|
|
122
|
+
it("replaces localhost with the DTU host", async () => {
|
|
123
|
+
const { resolveDockerApiUrl } = await import("./container-config.js");
|
|
124
|
+
expect(resolveDockerApiUrl("http://localhost:3000", "172.17.0.2")).toBe("http://172.17.0.2:3000");
|
|
125
|
+
});
|
|
126
|
+
it("replaces 127.0.0.1 with the DTU host", async () => {
|
|
127
|
+
const { resolveDockerApiUrl } = await import("./container-config.js");
|
|
128
|
+
expect(resolveDockerApiUrl("http://127.0.0.1:3000", "host.docker.internal")).toBe("http://host.docker.internal:3000");
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
// ── signalsDir bind-mount ─────────────────────────────────────────────────────
|
|
132
|
+
describe("buildContainerBinds with signalsDir", () => {
|
|
133
|
+
const baseOpts = {
|
|
134
|
+
hostWorkDir: "/tmp/work",
|
|
135
|
+
shimsDir: "/tmp/shims",
|
|
136
|
+
diagDir: "/tmp/diag",
|
|
137
|
+
toolCacheDir: "/tmp/toolcache",
|
|
138
|
+
pnpmStoreDir: "/tmp/pnpm",
|
|
139
|
+
npmCacheDir: "/tmp/npm",
|
|
140
|
+
bunCacheDir: "/tmp/bun",
|
|
141
|
+
playwrightCacheDir: "/tmp/playwright",
|
|
142
|
+
warmModulesDir: "/tmp/warm",
|
|
143
|
+
hostRunnerDir: "/tmp/runner",
|
|
144
|
+
useDirectContainer: false,
|
|
145
|
+
};
|
|
146
|
+
it("includes signals bind-mount when signalsDir is provided", async () => {
|
|
147
|
+
const { buildContainerBinds } = await import("./container-config.js");
|
|
148
|
+
const binds = buildContainerBinds({ ...baseOpts, signalsDir: "/tmp/signals" });
|
|
149
|
+
expect(binds).toContain("/tmp/signals:/tmp/agent-ci-signals");
|
|
150
|
+
});
|
|
151
|
+
it("omits signals bind-mount when signalsDir is undefined", async () => {
|
|
152
|
+
const { buildContainerBinds } = await import("./container-config.js");
|
|
153
|
+
const binds = buildContainerBinds(baseOpts);
|
|
154
|
+
expect(binds.some((b) => b.includes("agent-ci-signals"))).toBe(false);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
2
|
+
/**
|
|
3
|
+
* Parse Docker-style health-check flags from the YAML `options:` string.
|
|
4
|
+
* GitHub Actions uses flags like `--health-cmd="..." --health-interval=5s`.
|
|
5
|
+
*/
|
|
6
|
+
export function parseHealthCheck(options) {
|
|
7
|
+
const cmdMatch = options.match(/--health-cmd[= ]"([^"]+)"/);
|
|
8
|
+
if (!cmdMatch) {
|
|
9
|
+
return undefined;
|
|
10
|
+
}
|
|
11
|
+
const intervalMatch = options.match(/--health-interval[= ](\d+)s/);
|
|
12
|
+
const timeoutMatch = options.match(/--health-timeout[= ](\d+)s/);
|
|
13
|
+
const retriesMatch = options.match(/--health-retries[= ](\d+)/);
|
|
14
|
+
return {
|
|
15
|
+
Test: ["CMD-SHELL", cmdMatch[1]],
|
|
16
|
+
Interval: parseInt(intervalMatch?.[1] ?? "10", 10) * 1000000000, // nanoseconds
|
|
17
|
+
Timeout: parseInt(timeoutMatch?.[1] ?? "5", 10) * 1000000000,
|
|
18
|
+
Retries: parseInt(retriesMatch?.[1] ?? "3", 10),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Wait for a container to report "healthy" (Docker HEALTHCHECK).
|
|
23
|
+
* Falls back to a simple ready check after `timeoutMs` milliseconds.
|
|
24
|
+
*/
|
|
25
|
+
async function waitForHealth(docker, containerId, timeoutMs = 60000, emit) {
|
|
26
|
+
const deadline = Date.now() + timeoutMs;
|
|
27
|
+
const start = Date.now();
|
|
28
|
+
let lastEmit = 0;
|
|
29
|
+
while (Date.now() < deadline) {
|
|
30
|
+
try {
|
|
31
|
+
const info = await docker.getContainer(containerId).inspect();
|
|
32
|
+
const health = info.State?.Health?.Status;
|
|
33
|
+
if (health === "healthy") {
|
|
34
|
+
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
|
35
|
+
emit?.(` ✓ healthy after ${elapsed}s`);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (health === "unhealthy") {
|
|
39
|
+
throw new Error(`Service container ${containerId} is unhealthy`);
|
|
40
|
+
}
|
|
41
|
+
// If no healthcheck defined, just wait for "running" state
|
|
42
|
+
if (!health && info.State?.Running) {
|
|
43
|
+
emit?.(` ✓ running (no healthcheck)`);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
if (err.message?.includes("unhealthy")) {
|
|
49
|
+
throw err;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const elapsed = Math.floor((Date.now() - start) / 1000);
|
|
53
|
+
if (elapsed > lastEmit) {
|
|
54
|
+
emit?.(` ⏳ ${elapsed}s / ${timeoutMs / 1000}s — waiting for healthy...`);
|
|
55
|
+
lastEmit = elapsed;
|
|
56
|
+
}
|
|
57
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
58
|
+
}
|
|
59
|
+
emit?.(` ⚠ Service health-check timed out after ${timeoutMs / 1000}s — proceeding anyway`);
|
|
60
|
+
}
|
|
61
|
+
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
62
|
+
/**
|
|
63
|
+
* Create a Docker network, start all service containers, wait for them to be
|
|
64
|
+
* healthy, and return context that `local-job.ts` threads into the runner
|
|
65
|
+
* container config.
|
|
66
|
+
*
|
|
67
|
+
* NOTE: Callers must ensure `pruneOrphanedDockerResources()` has been called
|
|
68
|
+
* *before* launching concurrent runners. Pruning inside this function would
|
|
69
|
+
* race with sibling runners that have already created their networks.
|
|
70
|
+
*/
|
|
71
|
+
export async function startServiceContainers(docker, services, runnerName, emit) {
|
|
72
|
+
const networkName = `agent-ci-net-${runnerName}`;
|
|
73
|
+
const containerIds = [];
|
|
74
|
+
const portForwards = [];
|
|
75
|
+
// 1. Create a bridge network
|
|
76
|
+
await docker.createNetwork({ Name: networkName, Driver: "bridge" });
|
|
77
|
+
emit?.(` 🔗 Created network ${networkName}`);
|
|
78
|
+
// 2. Start each service
|
|
79
|
+
for (const svc of services) {
|
|
80
|
+
const containerName = `${runnerName}-svc-${svc.name}`;
|
|
81
|
+
emit?.(` 🐳 Starting service: ${svc.name} (${svc.image})`);
|
|
82
|
+
// Build env array
|
|
83
|
+
const envArr = svc.env ? Object.entries(svc.env).map(([k, v]) => `${k}=${v}`) : [];
|
|
84
|
+
// Build health-check config from options string
|
|
85
|
+
const healthConfig = svc.options ? parseHealthCheck(svc.options) : undefined;
|
|
86
|
+
// Parse port mappings (e.g. "3306:3306")
|
|
87
|
+
const portBindings = {};
|
|
88
|
+
const exposedPorts = {};
|
|
89
|
+
for (const portMapping of svc.ports ?? []) {
|
|
90
|
+
const [hostPort, containerPort] = portMapping.split(":");
|
|
91
|
+
const key = `${containerPort}/tcp`;
|
|
92
|
+
exposedPorts[key] = {};
|
|
93
|
+
portBindings[key] = [{ HostPort: hostPort }];
|
|
94
|
+
}
|
|
95
|
+
// Pre-cleanup stale container
|
|
96
|
+
try {
|
|
97
|
+
await docker.getContainer(containerName).remove({ force: true });
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// doesn't exist — fine
|
|
101
|
+
}
|
|
102
|
+
// Pull the image if missing
|
|
103
|
+
try {
|
|
104
|
+
await docker.getImage(svc.image).inspect();
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
emit?.(` 📦 Pulling image ${svc.image}...`);
|
|
108
|
+
await new Promise((resolve, reject) => {
|
|
109
|
+
docker.pull(svc.image, (err, stream) => {
|
|
110
|
+
if (err) {
|
|
111
|
+
return reject(err);
|
|
112
|
+
}
|
|
113
|
+
docker.modem.followProgress(stream, (err) => {
|
|
114
|
+
if (err) {
|
|
115
|
+
reject(err);
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
resolve();
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
const container = await docker.createContainer({
|
|
125
|
+
Image: svc.image,
|
|
126
|
+
name: containerName,
|
|
127
|
+
Env: envArr,
|
|
128
|
+
ExposedPorts: exposedPorts,
|
|
129
|
+
Healthcheck: healthConfig,
|
|
130
|
+
HostConfig: {
|
|
131
|
+
NetworkMode: networkName,
|
|
132
|
+
PortBindings: portBindings,
|
|
133
|
+
},
|
|
134
|
+
// Add network aliases so the service is reachable by its short name (e.g. `mysql`)
|
|
135
|
+
// on the Docker bridge, matching how GitHub Actions exposes service containers.
|
|
136
|
+
NetworkingConfig: {
|
|
137
|
+
EndpointsConfig: {
|
|
138
|
+
[networkName]: {
|
|
139
|
+
Aliases: [svc.name],
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
await container.start();
|
|
145
|
+
containerIds.push(container.id);
|
|
146
|
+
emit?.(` ✓ Service ${svc.name} started (${container.id.substring(0, 12)}) — alias: ${svc.name}`);
|
|
147
|
+
// Build port-forward commands so localhost:<port> inside the runner reaches the service.
|
|
148
|
+
// Uses the service container's Docker-network hostname (its container name).
|
|
149
|
+
for (const portMapping of svc.ports ?? []) {
|
|
150
|
+
const [hostPort, containerPort] = portMapping.split(":");
|
|
151
|
+
const fwdPort = containerPort || hostPort;
|
|
152
|
+
// Python TCP forwarder (same pattern used for DTU forwarding in local-job.ts)
|
|
153
|
+
portForwards.push(`sudo -n python3 -c "
|
|
154
|
+
import socket,threading
|
|
155
|
+
def fwd(s,d):
|
|
156
|
+
try:
|
|
157
|
+
while True:
|
|
158
|
+
x=s.recv(65536)
|
|
159
|
+
if not x: break
|
|
160
|
+
d.sendall(x)
|
|
161
|
+
except: pass
|
|
162
|
+
finally: s.close();d.close()
|
|
163
|
+
def handle(c):
|
|
164
|
+
s=socket.socket();s.connect(('${containerName}',${fwdPort}));threading.Thread(target=fwd,args=(c,s),daemon=True).start();fwd(s,c)
|
|
165
|
+
srv=socket.socket();srv.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1);srv.bind(('127.0.0.1',${fwdPort}));srv.listen(32)
|
|
166
|
+
while True:
|
|
167
|
+
c,_=srv.accept();threading.Thread(target=handle,args=(c,),daemon=True).start()
|
|
168
|
+
" &`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// 3. Wait for all services to become healthy
|
|
172
|
+
for (let i = 0; i < containerIds.length; i++) {
|
|
173
|
+
const svc = services[i];
|
|
174
|
+
if (svc.options?.includes("--health-cmd")) {
|
|
175
|
+
emit?.(` ⏳ Waiting for ${svc.name} to become healthy (timeout: 60s)...`);
|
|
176
|
+
const t0 = Date.now();
|
|
177
|
+
await waitForHealth(docker, containerIds[i], 60000, emit);
|
|
178
|
+
const took = ((Date.now() - t0) / 1000).toFixed(1);
|
|
179
|
+
emit?.(` ✓ ${svc.name} healthy in ${took}s`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return { networkName, containerIds, portForwards };
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Stop and remove all service containers, then remove the shared network.
|
|
186
|
+
*/
|
|
187
|
+
export async function cleanupServiceContainers(docker, ctx, emit) {
|
|
188
|
+
for (const id of ctx.containerIds) {
|
|
189
|
+
try {
|
|
190
|
+
const c = docker.getContainer(id);
|
|
191
|
+
await c.stop({ t: 2 }).catch(() => { });
|
|
192
|
+
await c.remove({ force: true });
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
// already gone
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
await docker.getNetwork(ctx.networkName).remove();
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
// already gone
|
|
203
|
+
}
|
|
204
|
+
emit?.(` 🧹 Cleaned up service containers and network ${ctx.networkName}`);
|
|
205
|
+
}
|