mandrel-platform 1.0.1 → 1.2.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.
@@ -0,0 +1,234 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * job-cleanup-hook.test.mjs — node:test suite for the ACTIONS_RUNNER_HOOK_JOB_STARTED
4
+ * hook shipped at `templates/runner/job-cleanup.sh` (Story #345).
5
+ *
6
+ * The hook runs INSIDE the job's clock on every job of every persistent
7
+ * self-hosted runner, so its cost is charged to `Set up runner` and a slow
8
+ * hook kills jobs against their own `timeout-minutes`. Issue #343 is exactly
9
+ * that failure: the hook enumerated the shared OS temp root, which had reached
10
+ * 841,690 entries on the swarm-os host, and `Set up runner` reached 5m29s.
11
+ *
12
+ * The load-bearing property this suite pins is therefore a NEGATIVE one — the
13
+ * hook must never read the shared temp root — and it is asserted two ways,
14
+ * because neither alone is sufficient:
15
+ *
16
+ * 1. Behaviourally: a decoy shared temp root is planted with entries that
17
+ * match the old sweep's patterns, and the hook must leave every one of
18
+ * them alone. This catches a sweep that still deletes there.
19
+ * 2. Structurally: the script text must contain no enumeration rooted at the
20
+ * shared temp root. This catches a sweep that reads the directory but
21
+ * happens to delete nothing — the exact shape of the #343 stall, which
22
+ * was pure cost with no observable effect.
23
+ *
24
+ * A wall-clock assertion was deliberately NOT used for (2): the cost is a
25
+ * function of host churn, so on a clean dev machine a full enumeration of a
26
+ * small decoy root is fast and would pass. Timing here would be a test that
27
+ * only fails on the machine that least needs it.
28
+ *
29
+ * The suite executes the real script with env fixtures, following the
30
+ * `scripts/resolve-diff-range.test.mjs` precedent for shell-under-test.
31
+ *
32
+ * Run: node --test scripts/job-cleanup-hook.test.mjs
33
+ */
34
+
35
+ import assert from "node:assert/strict";
36
+ import { execFileSync } from "node:child_process";
37
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, rmSync, chmodSync, existsSync } from "node:fs";
38
+ import { tmpdir } from "node:os";
39
+ import { fileURLToPath } from "node:url";
40
+ import { dirname, join } from "node:path";
41
+ import { test, after } from "node:test";
42
+
43
+ const HERE = dirname(fileURLToPath(import.meta.url));
44
+ const SCRIPT = join(HERE, "..", "templates", "runner", "job-cleanup.sh");
45
+
46
+ /** Sandboxes created by the suite, torn down in `after`. */
47
+ const SANDBOXES = [];
48
+
49
+ after(() => {
50
+ for (const dir of SANDBOXES) {
51
+ // Restore any permissions the unwritable-temp case removed, or the
52
+ // recursive delete cannot descend.
53
+ try {
54
+ chmodSync(join(dir, "runner", "_work", "_temp"), 0o755);
55
+ } catch {
56
+ /* not every sandbox has one */
57
+ }
58
+ rmSync(dir, { recursive: true, force: true });
59
+ }
60
+ });
61
+
62
+ /**
63
+ * Build a sandbox holding a synthetic runner root and a decoy shared temp root.
64
+ *
65
+ * @param {{ sharedEntries?: number }} [opts]
66
+ * @returns {{ root: string, runnerDir: string, runnerTmp: string, sharedTmp: string }}
67
+ */
68
+ function makeSandbox({ sharedEntries = 0 } = {}) {
69
+ const root = mkdtempSync(join(tmpdir(), "job-cleanup-test-"));
70
+ SANDBOXES.push(root);
71
+
72
+ const runnerDir = join(root, "runner");
73
+ const runnerTmp = join(runnerDir, "_work", "_temp");
74
+ const sharedTmp = join(root, "shared-tmp");
75
+ mkdirSync(runnerTmp, { recursive: true });
76
+ mkdirSync(join(runnerDir, "_work", "_tool"), { recursive: true });
77
+ mkdirSync(sharedTmp, { recursive: true });
78
+
79
+ // The decoy shared root carries entries matching BOTH the retired sweep's
80
+ // patterns and the current runner-scoped ones, so a sweep that kept the old
81
+ // root or reused the new globs against it is caught either way.
82
+ writeFileSync(join(sharedTmp, "gitleaks.tmp"), "decoy");
83
+ mkdirSync(join(sharedTmp, "gitleaks-8.30.1"), { recursive: true });
84
+ mkdirSync(join(sharedTmp, "gitleaks.AbCdEf"), { recursive: true });
85
+ mkdirSync(join(sharedTmp, "osv-scanner.AbCdEf"), { recursive: true });
86
+ for (let i = 0; i < sharedEntries; i += 1) {
87
+ writeFileSync(join(sharedTmp, `unrelated-${i}.tmp`), "x");
88
+ }
89
+
90
+ return { root, runnerDir, runnerTmp, sharedTmp };
91
+ }
92
+
93
+ /**
94
+ * Run the hook against a sandbox.
95
+ *
96
+ * @param {{ runnerDir: string, sharedTmp: string }} sandbox
97
+ * @returns {{ status: number, stdout: string }}
98
+ */
99
+ function runHook({ runnerDir, sharedTmp }) {
100
+ try {
101
+ const stdout = execFileSync("bash", [SCRIPT], {
102
+ encoding: "utf8",
103
+ env: { ...process.env, RUNNER_DIR: runnerDir, TMPDIR: sharedTmp },
104
+ timeout: 60_000,
105
+ });
106
+ return { status: 0, stdout };
107
+ } catch (err) {
108
+ return { status: err.status ?? 1, stdout: err.stdout ?? "" };
109
+ }
110
+ }
111
+
112
+ test("removes this runner's own stale tool-download and pnpm-shim leftovers", () => {
113
+ const sandbox = makeSandbox();
114
+ const { runnerTmp } = sandbox;
115
+
116
+ // Every artifact the platform's actions now extract into runner.temp.
117
+ const owned = [
118
+ join(runnerTmp, "pnpm"),
119
+ join(runnerTmp, "setup-pnpm"),
120
+ join(runnerTmp, "gitleaks.AbCdEf"),
121
+ join(runnerTmp, "osv-scanner.AbCdEf"),
122
+ join(runnerTmp, "semgrep.AbCdEf"),
123
+ ];
124
+ for (const dir of owned) {
125
+ mkdirSync(dir, { recursive: true });
126
+ writeFileSync(join(dir, "leftover"), "stale");
127
+ }
128
+ writeFileSync(join(runnerTmp, "gh-api-err.AbCdEf"), "stale");
129
+
130
+ const { status } = runHook(sandbox);
131
+ assert.equal(status, 0, "the hook must never fail a job");
132
+
133
+ for (const dir of owned) {
134
+ assert.equal(existsSync(dir), false, `runner-owned leftover not swept: ${dir}`);
135
+ }
136
+ assert.equal(existsSync(join(runnerTmp, "gh-api-err.AbCdEf")), false);
137
+ });
138
+
139
+ test("leaves the shared temp root untouched", () => {
140
+ // The decoy is deliberately SMALL. An earlier draft planted 5,000 entries to
141
+ // evoke the 841,690 seen on the swarm-os host, but that bought no signal: the
142
+ // assertion below is on effect (nothing deleted), which is count-independent,
143
+ // and the companion structural test — not a stopwatch — is what pins the
144
+ // cost property. Planting alone cost ~87s on a dev Mac whose endpoint
145
+ // security scans every write (~17ms/file), which would have made this the
146
+ // slowest test in the suite by two orders of magnitude, and a flaky one.
147
+ //
148
+ // What actually matters is pattern COVERAGE, and makeSandbox plants every
149
+ // shape — both the retired sweep's names and the current runner-scoped
150
+ // globs — so a sweep pointed back at the shared root is caught by the first
151
+ // entry it would match.
152
+ const sandbox = makeSandbox({ sharedEntries: 20 });
153
+ const { sharedTmp } = sandbox;
154
+
155
+ const before = readdirSync(sharedTmp).sort();
156
+ const { status } = runHook(sandbox);
157
+
158
+ assert.equal(status, 0);
159
+ assert.deepEqual(
160
+ readdirSync(sharedTmp).sort(),
161
+ before,
162
+ "the hook deleted from the shared temp root — it must only sweep runner-owned paths",
163
+ );
164
+ });
165
+
166
+ test("the script text contains no enumeration rooted at the shared temp root", () => {
167
+ const source = readFileSync(SCRIPT, "utf8");
168
+
169
+ // Assert the PROPERTY (the hook cannot reach the shared root at all), not
170
+ // one syntactic form of violating it. Pinning a specific `find "${TMP…"`
171
+ // shape would miss an unbraced `$TMPDIR`, an `ls | grep`, a `for f in
172
+ // "${TMP}"/…` loop, or a renamed intermediate variable — all of which
173
+ // reintroduce the unbounded read this Story removed.
174
+ //
175
+ // Every one of those must name the shared root to reach it, and the hook has
176
+ // no legitimate use for it: RUNNER_TMP derives from RUNNER_DIR. So the
177
+ // absence of the name is both necessary and sufficient, and it stays a true
178
+ // invariant rather than a regex chasing syntax.
179
+ //
180
+ // Comments are stripped first: the header documents the #343 incident by
181
+ // name, and that prose is the reason the next maintainer will not re-add the
182
+ // sweep. Asserting over raw text would force the fix to delete its own
183
+ // rationale.
184
+ const code = source
185
+ .split("\n")
186
+ .filter((line) => !/^\s*#/.test(line))
187
+ .join("\n");
188
+
189
+ assert.equal(
190
+ /TMPDIR/.test(code),
191
+ false,
192
+ "the hook references the shared temp root — its cost must scale with runner-owned state only",
193
+ );
194
+
195
+ // Second half of the property: no directory enumeration, anywhere. Naming
196
+ // the shared root is one way to reintroduce unbounded cost; a `find` rooted
197
+ // at an intermediate variable is another, and it would not have to mention
198
+ // TMPDIR on the same line. The hook has no legitimate need to enumerate —
199
+ // it addresses known paths under RUNNER_TMP directly — so the absence of
200
+ // `find` is a stronger and more durable invariant than any pattern match
201
+ // over its arguments.
202
+ assert.equal(
203
+ /\bfind\b/.test(code),
204
+ false,
205
+ "the hook enumerates a directory — address known runner-owned paths directly instead",
206
+ );
207
+
208
+ // The age gate existed solely to make deleting from the SHARED root safe.
209
+ // Runner-scoped paths are unreachable by a co-resident runner, so a
210
+ // surviving knob would be dead configuration the runbook still promises.
211
+ assert.equal(
212
+ source.includes("JOB_CLEANUP_STALE_MINUTES"),
213
+ false,
214
+ "the retired age-gate knob is still referenced",
215
+ );
216
+ });
217
+
218
+ test("exits 0 when the runner directory does not exist", () => {
219
+ const sandbox = makeSandbox();
220
+ const { status } = runHook({
221
+ runnerDir: join(sandbox.root, "no-such-runner"),
222
+ sharedTmp: sandbox.sharedTmp,
223
+ });
224
+ assert.equal(status, 0, "a missing runner root must degrade to a no-op, never fail the job");
225
+ });
226
+
227
+ test("exits 0 when the runner temp is unwritable", () => {
228
+ const sandbox = makeSandbox();
229
+ mkdirSync(join(sandbox.runnerTmp, "gitleaks.AbCdEf"), { recursive: true });
230
+ chmodSync(sandbox.runnerTmp, 0o500);
231
+
232
+ const { status } = runHook(sandbox);
233
+ assert.equal(status, 0, "an unwritable runner temp must not fail the job");
234
+ });