@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.
Files changed (47) hide show
  1. package/LICENSE +110 -0
  2. package/README.md +79 -0
  3. package/dist/cli.js +628 -0
  4. package/dist/config.js +63 -0
  5. package/dist/docker/container-config.js +178 -0
  6. package/dist/docker/container-config.test.js +156 -0
  7. package/dist/docker/service-containers.js +205 -0
  8. package/dist/docker/service-containers.test.js +236 -0
  9. package/dist/docker/shutdown.js +120 -0
  10. package/dist/docker/shutdown.test.js +148 -0
  11. package/dist/output/agent-mode.js +7 -0
  12. package/dist/output/agent-mode.test.js +36 -0
  13. package/dist/output/cleanup.js +218 -0
  14. package/dist/output/cleanup.test.js +241 -0
  15. package/dist/output/concurrency.js +57 -0
  16. package/dist/output/concurrency.test.js +88 -0
  17. package/dist/output/debug.js +36 -0
  18. package/dist/output/logger.js +57 -0
  19. package/dist/output/logger.test.js +82 -0
  20. package/dist/output/reporter.js +67 -0
  21. package/dist/output/run-state.js +126 -0
  22. package/dist/output/run-state.test.js +169 -0
  23. package/dist/output/state-renderer.js +149 -0
  24. package/dist/output/state-renderer.test.js +488 -0
  25. package/dist/output/tree-renderer.js +52 -0
  26. package/dist/output/tree-renderer.test.js +105 -0
  27. package/dist/output/working-directory.js +20 -0
  28. package/dist/runner/directory-setup.js +98 -0
  29. package/dist/runner/directory-setup.test.js +31 -0
  30. package/dist/runner/git-shim.js +92 -0
  31. package/dist/runner/git-shim.test.js +57 -0
  32. package/dist/runner/local-job.js +691 -0
  33. package/dist/runner/metadata.js +90 -0
  34. package/dist/runner/metadata.test.js +127 -0
  35. package/dist/runner/result-builder.js +119 -0
  36. package/dist/runner/result-builder.test.js +177 -0
  37. package/dist/runner/step-wrapper.js +82 -0
  38. package/dist/runner/step-wrapper.test.js +77 -0
  39. package/dist/runner/sync.js +80 -0
  40. package/dist/runner/workspace.js +66 -0
  41. package/dist/types.js +1 -0
  42. package/dist/workflow/job-scheduler.js +62 -0
  43. package/dist/workflow/job-scheduler.test.js +130 -0
  44. package/dist/workflow/workflow-parser.js +556 -0
  45. package/dist/workflow/workflow-parser.test.js +642 -0
  46. package/package.json +39 -0
  47. 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
+ }