@norskvideo/ctl-test-harness 0.1.34 → 0.1.36
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/demo/run.js +5 -18
- package/package.json +1 -1
- package/root-nuke.d.ts +54 -0
- package/root-nuke.js +146 -0
- package/smoke.js +5 -19
- package/temp-dir.d.ts +14 -0
- package/temp-dir.js +104 -16
package/demo/run.js
CHANGED
|
@@ -27,21 +27,21 @@
|
|
|
27
27
|
import { spawn, spawnSync } from "node:child_process";
|
|
28
28
|
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, writeFileSync, } from "node:fs";
|
|
29
29
|
import { homedir } from "node:os";
|
|
30
|
-
import {
|
|
30
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
31
31
|
import { parseManifestSeed, repoOf } from "@norskvideo/ctl-sdk/manifest-seed";
|
|
32
32
|
import { DOCKER_NETWORK_NAME, ensureRunnerOnNetwork, netReachMode } from "../container-net.js";
|
|
33
33
|
import { cleanupDaemon, requireLicenseFile, runCli, startDaemon, } from "../daemon.js";
|
|
34
34
|
import { hashSlot } from "../harness-config.js";
|
|
35
35
|
import { ctlSupportsNoPublish, runnerContainerUser } from "../launch.js";
|
|
36
36
|
import { pollUntil } from "../poll.js";
|
|
37
|
+
import { nukePathAsRoot, reportForeignOwners } from "../root-nuke.js";
|
|
37
38
|
import { startSrtSources } from "../source-pump.js";
|
|
38
|
-
import { makeStoreDir } from "../temp-dir.js";
|
|
39
|
+
import { makeStoreDir, TEST_TMP_BASE } from "../temp-dir.js";
|
|
39
40
|
const DEMO_PORT_BASE = 35000;
|
|
40
41
|
const DEMO_BAND_WIDTH = 20;
|
|
41
42
|
const DEMO_BANDS = 50;
|
|
42
43
|
const DEFAULT_DAEMON_PORT = 8333;
|
|
43
44
|
const DEFAULT_PROXY_PORT = 443;
|
|
44
|
-
const NUKE_IMAGE = "alpine:3";
|
|
45
45
|
const DEFAULT_DEV_READY_PATH = "/manifest.json";
|
|
46
46
|
const SEED_FILE = "manifest.seed.json";
|
|
47
47
|
const TEMPLATES_DIR = "product-templates";
|
|
@@ -290,21 +290,7 @@ export function defaultDemoDeps(cwd) {
|
|
|
290
290
|
await h.stop();
|
|
291
291
|
},
|
|
292
292
|
cleanup: (opts) => cleanupDaemon({ ...opts, stopProxy: async () => { }, proxy: false }),
|
|
293
|
-
nukeStoreAsRoot: (storeDir) =>
|
|
294
|
-
spawnSync("docker", [
|
|
295
|
-
"run",
|
|
296
|
-
"--rm",
|
|
297
|
-
"--user",
|
|
298
|
-
"0:0",
|
|
299
|
-
"-v",
|
|
300
|
-
`${dirname(storeDir)}:/base`,
|
|
301
|
-
"--entrypoint",
|
|
302
|
-
"sh",
|
|
303
|
-
NUKE_IMAGE,
|
|
304
|
-
"-c",
|
|
305
|
-
`rm -rf /base/${basename(storeDir)}`,
|
|
306
|
-
]);
|
|
307
|
-
},
|
|
293
|
+
nukeStoreAsRoot: (storeDir) => nukePathAsRoot(storeDir, TEST_TMP_BASE),
|
|
308
294
|
storeExists: (storeDir) => existsSync(storeDir),
|
|
309
295
|
ensureNetwork: () => ensureRunnerOnNetwork(),
|
|
310
296
|
supportsNoPublish: ctlSupportsNoPublish,
|
|
@@ -658,6 +644,7 @@ export class DemoSession {
|
|
|
658
644
|
}
|
|
659
645
|
catch (e) {
|
|
660
646
|
this.deps.log(`cleanup could not remove the store itself (${e instanceof Error ? e.message : String(e)}); nuking as root`);
|
|
647
|
+
reportForeignOwners(this.storeDir, (l) => this.deps.log(l));
|
|
661
648
|
}
|
|
662
649
|
this.stopExtras();
|
|
663
650
|
this.dev?.kill();
|
package/package.json
CHANGED
package/root-nuke.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** Minimal shape of `spawnSync` this needs, so tests can inject. */
|
|
2
|
+
export type NukeSpawn = (cmd: string, argv: string[], opts?: unknown) => {
|
|
3
|
+
error?: unknown;
|
|
4
|
+
status?: number | null;
|
|
5
|
+
};
|
|
6
|
+
/** Refuse a target that would turn the nuke into something catastrophic.
|
|
7
|
+
*
|
|
8
|
+
* `allowedBase` is the containment: the target must sit strictly INSIDE it.
|
|
9
|
+
* Nothing else here is sufficient on its own, because the worst case passes
|
|
10
|
+
* every structural check. Under `--daemon reuse` the harness's storeDir is the
|
|
11
|
+
* developer's real `~/.norsk-ctl` -- absolute, ordinary leaf, no glob,
|
|
12
|
+
* non-root parent -- and nuking it mounts $HOME into a root container and
|
|
13
|
+
* removes the daemon's entire state: config, products, templates, instances.
|
|
14
|
+
* Today only an early return in teardown keeps the nuke away from it, and a
|
|
15
|
+
* guard that depends on a `return` staying put is not a guard.
|
|
16
|
+
*
|
|
17
|
+
* The base itself is refused too: it is shared by concurrently running tiers,
|
|
18
|
+
* so removing it would delete sibling runs' live stores. */
|
|
19
|
+
export declare function assertNukeTarget(target: string, allowedBase: string): void;
|
|
20
|
+
/** Docker argv for the nuke. Two things matter here:
|
|
21
|
+
*
|
|
22
|
+
* 1. The PARENT is mounted and the leaf removed by name -- mounting the target
|
|
23
|
+
* itself would let the container empty it but never unlink it, leaving the
|
|
24
|
+
* dir behind and the caller's existence check true.
|
|
25
|
+
* 2. The leaf is passed as a positional ARGUMENT, never interpolated into the
|
|
26
|
+
* `sh -c` script. The prune feeds this arbitrary readdir names, and a
|
|
27
|
+
* directory called `x; rm -rf /base` would otherwise wipe every sibling --
|
|
28
|
+
* including a live sibling run -- from inside a root container. */
|
|
29
|
+
export declare function nukeArgv(target: string): string[];
|
|
30
|
+
/** Remove `target` as root. Returns whether the nuke actually ran: `spawnSync`
|
|
31
|
+
* reports a missing docker in its RESULT rather than throwing, and a caller
|
|
32
|
+
* that retries a doomed spawn forever (silently) is the failure mode to avoid.
|
|
33
|
+
* Throws only for an unsafe target -- see {@link assertNukeTarget}. */
|
|
34
|
+
export declare function nukePathAsRoot(target: string, allowedBase: string, spawn?: NukeSpawn): boolean;
|
|
35
|
+
export interface OwnerScanDeps {
|
|
36
|
+
readdir: (path: string) => string[];
|
|
37
|
+
isDir: (path: string) => boolean;
|
|
38
|
+
uidOf: (path: string) => number;
|
|
39
|
+
}
|
|
40
|
+
/** Paths under `dir` owned by a uid that is not `selfUid`, with the owning uid.
|
|
41
|
+
*
|
|
42
|
+
* The diagnostic that ends the guessing. Teardown deletes this evidence every
|
|
43
|
+
* run, so nobody has ever observed WHICH uid owns the litter -- and "root" and
|
|
44
|
+
* "a container's baked-in service account" EACCES a non-root runner
|
|
45
|
+
* identically while implying completely different causes. Bounded so a large
|
|
46
|
+
* tree cannot flood a CI log. */
|
|
47
|
+
export declare function foreignOwnedPaths(dir: string, selfUid: number, deps: OwnerScanDeps, limit?: number): Array<{
|
|
48
|
+
path: string;
|
|
49
|
+
uid: number;
|
|
50
|
+
}>;
|
|
51
|
+
/** Log which uids own what under `dir`, for a teardown whose non-root rm just
|
|
52
|
+
* failed. Without this the harness deletes the only evidence of the cause on
|
|
53
|
+
* every run, leaving "root-owned" an inference nobody has ever checked. */
|
|
54
|
+
export declare function reportForeignOwners(dir: string, log: (line: string) => void): void;
|
package/root-nuke.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Removing a path a container left owned by someone the runner is not. A
|
|
2
|
+
// launched instance bind-mounts host dirs into containers; unless the launch
|
|
3
|
+
// delivers a container user, whatever those containers write is owned by the
|
|
4
|
+
// image's own uid (root, or a baked-in service account) and the non-root runner
|
|
5
|
+
// cannot remove it. This is the tolerance half -- a throwaway container,
|
|
6
|
+
// running as root, deletes the path from the inside. launch.ts is the
|
|
7
|
+
// prevention half.
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
9
|
+
import { lstatSync, readdirSync } from "node:fs";
|
|
10
|
+
import { basename, dirname, isAbsolute, resolve, sep } from "node:path";
|
|
11
|
+
const NUKE_IMAGE = "alpine:3";
|
|
12
|
+
const NUKE_TIMEOUT_MS = 60_000;
|
|
13
|
+
/** Refuse a target that would turn the nuke into something catastrophic.
|
|
14
|
+
*
|
|
15
|
+
* `allowedBase` is the containment: the target must sit strictly INSIDE it.
|
|
16
|
+
* Nothing else here is sufficient on its own, because the worst case passes
|
|
17
|
+
* every structural check. Under `--daemon reuse` the harness's storeDir is the
|
|
18
|
+
* developer's real `~/.norsk-ctl` -- absolute, ordinary leaf, no glob,
|
|
19
|
+
* non-root parent -- and nuking it mounts $HOME into a root container and
|
|
20
|
+
* removes the daemon's entire state: config, products, templates, instances.
|
|
21
|
+
* Today only an early return in teardown keeps the nuke away from it, and a
|
|
22
|
+
* guard that depends on a `return` staying put is not a guard.
|
|
23
|
+
*
|
|
24
|
+
* The base itself is refused too: it is shared by concurrently running tiers,
|
|
25
|
+
* so removing it would delete sibling runs' live stores. */
|
|
26
|
+
export function assertNukeTarget(target, allowedBase) {
|
|
27
|
+
if (!isAbsolute(target))
|
|
28
|
+
throw new Error(`refusing to nuke a relative path: ${target}`);
|
|
29
|
+
const leaf = basename(target);
|
|
30
|
+
if (leaf === "" || leaf === "." || leaf === "..")
|
|
31
|
+
throw new Error(`refusing to nuke a path with no leaf: ${target}`);
|
|
32
|
+
if (leaf.includes("*"))
|
|
33
|
+
throw new Error(`refusing to nuke a glob: ${target}`);
|
|
34
|
+
// dirname is what gets bind-mounted; "/" would hand the whole host to a root
|
|
35
|
+
// container. Every real target (a store under the temp base) is nested deeper.
|
|
36
|
+
if (dirname(target) === "/" || dirname(target) === target) {
|
|
37
|
+
throw new Error(`refusing to nuke a top-level path (its parent would be the mount): ${target}`);
|
|
38
|
+
}
|
|
39
|
+
// resolve() first so `..` cannot walk out, and compare with a trailing
|
|
40
|
+
// separator so `/x/test-temp-evil` does not pass as inside `/x/test-temp`.
|
|
41
|
+
const t = resolve(target);
|
|
42
|
+
const base = resolve(allowedBase);
|
|
43
|
+
if (t === base)
|
|
44
|
+
throw new Error(`refusing to nuke the temp base itself (concurrent runs live here): ${t}`);
|
|
45
|
+
if (!t.startsWith(base.endsWith(sep) ? base : base + sep)) {
|
|
46
|
+
throw new Error(`refusing to nuke ${t}: outside the test temp base ${base}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** Docker argv for the nuke. Two things matter here:
|
|
50
|
+
*
|
|
51
|
+
* 1. The PARENT is mounted and the leaf removed by name -- mounting the target
|
|
52
|
+
* itself would let the container empty it but never unlink it, leaving the
|
|
53
|
+
* dir behind and the caller's existence check true.
|
|
54
|
+
* 2. The leaf is passed as a positional ARGUMENT, never interpolated into the
|
|
55
|
+
* `sh -c` script. The prune feeds this arbitrary readdir names, and a
|
|
56
|
+
* directory called `x; rm -rf /base` would otherwise wipe every sibling --
|
|
57
|
+
* including a live sibling run -- from inside a root container. */
|
|
58
|
+
export function nukeArgv(target) {
|
|
59
|
+
return [
|
|
60
|
+
"run",
|
|
61
|
+
"--rm",
|
|
62
|
+
"--user",
|
|
63
|
+
"0:0",
|
|
64
|
+
"-v",
|
|
65
|
+
`${dirname(target)}:/base`,
|
|
66
|
+
"--entrypoint",
|
|
67
|
+
"sh",
|
|
68
|
+
NUKE_IMAGE,
|
|
69
|
+
"-c",
|
|
70
|
+
'rm -rf -- "/base/$1"',
|
|
71
|
+
"sh",
|
|
72
|
+
basename(target),
|
|
73
|
+
];
|
|
74
|
+
}
|
|
75
|
+
/** Remove `target` as root. Returns whether the nuke actually ran: `spawnSync`
|
|
76
|
+
* reports a missing docker in its RESULT rather than throwing, and a caller
|
|
77
|
+
* that retries a doomed spawn forever (silently) is the failure mode to avoid.
|
|
78
|
+
* Throws only for an unsafe target -- see {@link assertNukeTarget}. */
|
|
79
|
+
export function nukePathAsRoot(target, allowedBase, spawn = spawnSync) {
|
|
80
|
+
assertNukeTarget(target, allowedBase);
|
|
81
|
+
const r = spawn("docker", nukeArgv(target), { timeout: NUKE_TIMEOUT_MS });
|
|
82
|
+
return !r?.error;
|
|
83
|
+
}
|
|
84
|
+
/** Paths under `dir` owned by a uid that is not `selfUid`, with the owning uid.
|
|
85
|
+
*
|
|
86
|
+
* The diagnostic that ends the guessing. Teardown deletes this evidence every
|
|
87
|
+
* run, so nobody has ever observed WHICH uid owns the litter -- and "root" and
|
|
88
|
+
* "a container's baked-in service account" EACCES a non-root runner
|
|
89
|
+
* identically while implying completely different causes. Bounded so a large
|
|
90
|
+
* tree cannot flood a CI log. */
|
|
91
|
+
export function foreignOwnedPaths(dir, selfUid, deps, limit = 40) {
|
|
92
|
+
const found = [];
|
|
93
|
+
const stack = [dir];
|
|
94
|
+
while (stack.length > 0 && found.length < limit) {
|
|
95
|
+
const cur = stack.pop();
|
|
96
|
+
if (cur === undefined)
|
|
97
|
+
break;
|
|
98
|
+
let names;
|
|
99
|
+
try {
|
|
100
|
+
names = deps.readdir(cur);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
for (const name of names) {
|
|
106
|
+
if (found.length >= limit)
|
|
107
|
+
break;
|
|
108
|
+
const p = `${cur}/${name}`;
|
|
109
|
+
try {
|
|
110
|
+
const uid = deps.uidOf(p);
|
|
111
|
+
if (uid !== selfUid)
|
|
112
|
+
found.push({ path: p, uid });
|
|
113
|
+
if (deps.isDir(p))
|
|
114
|
+
stack.push(p);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// vanished mid-scan, or unreadable -- diagnostics are best-effort
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return found;
|
|
122
|
+
}
|
|
123
|
+
/** Log which uids own what under `dir`, for a teardown whose non-root rm just
|
|
124
|
+
* failed. Without this the harness deletes the only evidence of the cause on
|
|
125
|
+
* every run, leaving "root-owned" an inference nobody has ever checked. */
|
|
126
|
+
export function reportForeignOwners(dir, log) {
|
|
127
|
+
const selfUid = process.getuid?.();
|
|
128
|
+
if (selfUid === undefined)
|
|
129
|
+
return;
|
|
130
|
+
const found = foreignOwnedPaths(dir, selfUid, {
|
|
131
|
+
readdir: (p) => readdirSync(p),
|
|
132
|
+
isDir: (p) => lstatSync(p).isDirectory(),
|
|
133
|
+
uidOf: (p) => lstatSync(p).uid,
|
|
134
|
+
});
|
|
135
|
+
if (found.length === 0) {
|
|
136
|
+
log(`owners: nothing under ${dir} is owned by a uid other than ${selfUid}`);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const byUid = new Map();
|
|
140
|
+
for (const f of found)
|
|
141
|
+
byUid.set(f.uid, (byUid.get(f.uid) ?? 0) + 1);
|
|
142
|
+
const summary = [...byUid.entries()].map(([uid, n]) => `uid ${uid} x${n}`).join(", ");
|
|
143
|
+
log(`owners: runner is uid ${selfUid}; foreign-owned under ${dir}: ${summary}`);
|
|
144
|
+
for (const f of found.slice(0, 10))
|
|
145
|
+
log(`owners: uid ${f.uid} ${f.path}`);
|
|
146
|
+
}
|
package/smoke.js
CHANGED
|
@@ -17,23 +17,22 @@
|
|
|
17
17
|
// studio-state, container-net). Argv, not Command: @norskvideo/ctl-commands is
|
|
18
18
|
// not a dependency of this package (daemon.ts's cleanupDaemon has the same
|
|
19
19
|
// rule); a product wanting typed commands bridges with toArgv in its own spec.
|
|
20
|
-
import { spawnSync } from "node:child_process";
|
|
21
20
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
22
|
-
import {
|
|
21
|
+
import { join } from "node:path";
|
|
23
22
|
import { dumpInstanceContainerLogs } from "./container-logs.js";
|
|
24
23
|
import { ensureRunnerOnNetwork, netReachMode, STUDIO_INTERNAL_PORT, studioBaseFrom, } from "./container-net.js";
|
|
25
24
|
import { cleanupDaemon, requireLicenseFile, runCli, startDaemon, } from "./daemon.js";
|
|
26
25
|
import { hashSlot } from "./harness-config.js";
|
|
27
26
|
import { ctlSupportsNoPublish, runnerContainerUser } from "./launch.js";
|
|
28
27
|
import { pollUntil } from "./poll.js";
|
|
28
|
+
import { nukePathAsRoot, reportForeignOwners } from "./root-nuke.js";
|
|
29
29
|
import { startSrtSources } from "./source-pump.js";
|
|
30
30
|
import { applyTestHost, fetchComponentState, fetchComponents, fetchStreamMappings, isSrtListenerState, } from "./studio-state.js";
|
|
31
|
-
import { makeStoreDir } from "./temp-dir.js";
|
|
31
|
+
import { makeStoreDir, TEST_TMP_BASE } from "./temp-dir.js";
|
|
32
32
|
const SMOKE_PORT_BASE = 33000;
|
|
33
33
|
const SMOKE_BAND_WIDTH = 20;
|
|
34
34
|
const SMOKE_BANDS = 50;
|
|
35
35
|
const STUDIO_HOST_PORT_PARAM = "STUDIO_HOST_PORT";
|
|
36
|
-
const NUKE_IMAGE = "alpine:3";
|
|
37
36
|
export function smokePorts(slug, overrides = {}) {
|
|
38
37
|
const daemonPort = SMOKE_PORT_BASE + hashSlot(`smoke:${slug}`, SMOKE_BANDS) * SMOKE_BAND_WIDTH;
|
|
39
38
|
return {
|
|
@@ -63,21 +62,7 @@ export const defaultSmokeDeps = {
|
|
|
63
62
|
fetch: (url) => fetch(url, { signal: AbortSignal.timeout(5000) }),
|
|
64
63
|
},
|
|
65
64
|
cleanup: (opts) => cleanupDaemon({ ...opts, stopProxy: async () => { }, proxy: false }),
|
|
66
|
-
nukeStoreAsRoot: (storeDir) =>
|
|
67
|
-
spawnSync("docker", [
|
|
68
|
-
"run",
|
|
69
|
-
"--rm",
|
|
70
|
-
"--user",
|
|
71
|
-
"0:0",
|
|
72
|
-
"-v",
|
|
73
|
-
`${dirname(storeDir)}:/base`,
|
|
74
|
-
"--entrypoint",
|
|
75
|
-
"sh",
|
|
76
|
-
NUKE_IMAGE,
|
|
77
|
-
"-c",
|
|
78
|
-
`rm -rf /base/${basename(storeDir)}`,
|
|
79
|
-
]);
|
|
80
|
-
},
|
|
65
|
+
nukeStoreAsRoot: (storeDir) => nukePathAsRoot(storeDir, TEST_TMP_BASE),
|
|
81
66
|
storeExists: (storeDir) => existsSync(storeDir),
|
|
82
67
|
ensureNetwork: () => ensureRunnerOnNetwork(),
|
|
83
68
|
supportsNoPublish: ctlSupportsNoPublish,
|
|
@@ -418,6 +403,7 @@ export async function runProductSmoke(slug, spec, deps = defaultSmokeDeps) {
|
|
|
418
403
|
}
|
|
419
404
|
catch (e) {
|
|
420
405
|
deps.log(`${slug}: cleanup could not remove the store itself (${e instanceof Error ? e.message : String(e)}); nuking as root`);
|
|
406
|
+
reportForeignOwners(storeDir, (l) => deps.log(l));
|
|
421
407
|
}
|
|
422
408
|
deps.nukeStoreAsRoot(storeDir);
|
|
423
409
|
if (journeyError !== undefined)
|
package/temp-dir.d.ts
CHANGED
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
/** Create a uniquely-named temp dir under an explicit base. */
|
|
2
2
|
export declare function makeTempDirUnder(base: string, prefix: string): string;
|
|
3
3
|
export declare const TEST_TMP_BASE: string;
|
|
4
|
+
/** Injection seam for {@link pruneStaleTempDirs}. */
|
|
5
|
+
export interface PruneDeps {
|
|
6
|
+
now: () => number;
|
|
7
|
+
readdir: (base: string) => string[];
|
|
8
|
+
mtimeMs: (path: string) => number;
|
|
9
|
+
/** The pid that created this dir, if it recorded one. */
|
|
10
|
+
ownerPid: (path: string) => number | undefined;
|
|
11
|
+
pidAlive: (pid: number) => boolean;
|
|
12
|
+
rm: (path: string) => void;
|
|
13
|
+
/** Returns whether the nuke could actually run (docker present). */
|
|
14
|
+
nukeAsRoot: (path: string) => boolean;
|
|
15
|
+
log: (line: string) => void;
|
|
16
|
+
}
|
|
17
|
+
export declare function pruneStaleTempDirsWith(base: string, deps: PruneDeps): void;
|
|
4
18
|
/** Create a uniquely-named temp dir under the consumer-local test base. */
|
|
5
19
|
export declare function makeTempDir(prefix?: string): string;
|
|
6
20
|
export declare function makeStoreDir(prefix?: string): string;
|
package/temp-dir.js
CHANGED
|
@@ -11,36 +11,124 @@
|
|
|
11
11
|
// forwards /tmp events too, which is why that env never surfaced it.
|
|
12
12
|
// Native-Linux Docker is unaffected (real kernel inotify). A repo-local
|
|
13
13
|
// `test-temp/` is on the shared filesystem so events forward.
|
|
14
|
-
import { mkdirSync, mkdtempSync, readdirSync, rmSync, statSync } from "node:fs";
|
|
14
|
+
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
15
15
|
import { join } from "node:path";
|
|
16
|
+
import { nukePathAsRoot } from "./root-nuke.js";
|
|
17
|
+
const OWNER_FILE = ".harness-owner";
|
|
16
18
|
/** Create a uniquely-named temp dir under an explicit base. */
|
|
17
19
|
export function makeTempDirUnder(base, prefix) {
|
|
18
20
|
mkdirSync(base, { recursive: true });
|
|
19
21
|
pruneStaleTempDirs(base);
|
|
20
|
-
|
|
22
|
+
const dir = mkdtempSync(join(base, prefix));
|
|
23
|
+
// Records who owns this dir so a later prune can tell a live session from
|
|
24
|
+
// litter -- see pruneStaleTempDirsWith. Best-effort: an unstamped dir simply
|
|
25
|
+
// falls back to the age test.
|
|
26
|
+
try {
|
|
27
|
+
writeFileSync(join(dir, OWNER_FILE), `${process.pid}\n`);
|
|
28
|
+
}
|
|
29
|
+
catch { }
|
|
30
|
+
return dir;
|
|
21
31
|
}
|
|
22
32
|
export const TEST_TMP_BASE = process.env.NORSK_CTL_TEST_TMP ?? join(process.cwd(), "test-temp");
|
|
33
|
+
const STALE_AFTER_MS = 60 * 60 * 1000;
|
|
34
|
+
function isPermissionError(e) {
|
|
35
|
+
const code = e?.code;
|
|
36
|
+
return code === "EACCES" || code === "EPERM";
|
|
37
|
+
}
|
|
23
38
|
// Best-effort cleanup of dirs orphaned by a crashed run whose teardown never got
|
|
24
39
|
// to rm them (a repo-local base isn't OS-reclaimed like /tmp, so prune here).
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
|
|
40
|
+
//
|
|
41
|
+
// A plain rm is NOT enough: a job killed mid-run (CI cancel, timeout, SIGKILL)
|
|
42
|
+
// never reaches its teardown, so its containers' foreign-owned bind-mount dirs
|
|
43
|
+
// survive and EACCES every later non-root rm. Swallowing that let the litter
|
|
44
|
+
// accumulate forever on a shared runner -- the mess `clean: false` exists to
|
|
45
|
+
// route around. So escalate to a root container.
|
|
46
|
+
//
|
|
47
|
+
// Age alone is NOT a safe staleness test once that escalation exists. A
|
|
48
|
+
// directory's mtime only moves when its direct entries change, and `dev-loop up`
|
|
49
|
+
// holds a daemon indefinitely against a makeStoreDir store -- so an idle but
|
|
50
|
+
// very much LIVE store looks stale, and the escalation would guarantee its
|
|
51
|
+
// deletion. Every dir records its creating pid; a dir whose owner is still
|
|
52
|
+
// running is never touched. Skipping wrongly just leaves litter, deleting
|
|
53
|
+
// wrongly destroys a running session, so the tie breaks toward skipping.
|
|
54
|
+
export function pruneStaleTempDirsWith(base, deps) {
|
|
55
|
+
let names;
|
|
28
56
|
try {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
57
|
+
names = deps.readdir(base);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return; // base doesn't exist yet -- nothing to prune
|
|
61
|
+
}
|
|
62
|
+
const cutoff = deps.now() - STALE_AFTER_MS;
|
|
63
|
+
let nukeUnavailable = false;
|
|
64
|
+
for (const name of names) {
|
|
65
|
+
if (name === OWNER_FILE)
|
|
66
|
+
continue;
|
|
67
|
+
const p = join(base, name);
|
|
68
|
+
try {
|
|
69
|
+
if (deps.mtimeMs(p) >= cutoff)
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
continue; // racing a sibling run's teardown, or a transient stat error
|
|
74
|
+
}
|
|
75
|
+
const owner = deps.ownerPid(p);
|
|
76
|
+
if (owner !== undefined && deps.pidAlive(owner))
|
|
77
|
+
continue;
|
|
78
|
+
try {
|
|
79
|
+
deps.rm(p);
|
|
80
|
+
}
|
|
81
|
+
catch (e) {
|
|
82
|
+
// Only a permission error means "someone else owns this"; EBUSY or a
|
|
83
|
+
// teardown race does not justify spinning up a root container.
|
|
84
|
+
if (!isPermissionError(e) || nukeUnavailable)
|
|
85
|
+
continue;
|
|
86
|
+
if (!deps.nukeAsRoot(p)) {
|
|
87
|
+
nukeUnavailable = true;
|
|
88
|
+
deps.log(`temp-dir: cannot root-nuke ${p} (docker unavailable); leaving stale dirs in ${base}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// The latch is here rather than in the pure function so it spans calls: prune
|
|
94
|
+
// runs on every makeTempDir, and re-spawning a doomed docker each time would be
|
|
95
|
+
// a silent per-test cost.
|
|
96
|
+
let nukeKnownUnavailable = false;
|
|
97
|
+
function pruneStaleTempDirs(base) {
|
|
98
|
+
pruneStaleTempDirsWith(base, {
|
|
99
|
+
now: () => Date.now(),
|
|
100
|
+
readdir: (b) => readdirSync(b),
|
|
101
|
+
mtimeMs: (p) => statSync(p).mtimeMs,
|
|
102
|
+
ownerPid: (p) => {
|
|
32
103
|
try {
|
|
33
|
-
|
|
34
|
-
|
|
104
|
+
const raw = Number.parseInt(readFileSync(join(p, OWNER_FILE), "utf8").trim(), 10);
|
|
105
|
+
return Number.isFinite(raw) ? raw : undefined;
|
|
35
106
|
}
|
|
36
107
|
catch {
|
|
37
|
-
|
|
108
|
+
return undefined;
|
|
38
109
|
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
110
|
+
},
|
|
111
|
+
pidAlive: (pid) => {
|
|
112
|
+
try {
|
|
113
|
+
process.kill(pid, 0);
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
catch (e) {
|
|
117
|
+
// EPERM means it exists but belongs to someone else -- still alive.
|
|
118
|
+
return e.code === "EPERM";
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
rm: (p) => rmSync(p, { recursive: true, force: true }),
|
|
122
|
+
nukeAsRoot: (p) => {
|
|
123
|
+
if (nukeKnownUnavailable)
|
|
124
|
+
return false;
|
|
125
|
+
const ran = nukePathAsRoot(p, base);
|
|
126
|
+
if (!ran)
|
|
127
|
+
nukeKnownUnavailable = true;
|
|
128
|
+
return ran;
|
|
129
|
+
},
|
|
130
|
+
log: (line) => console.warn(line),
|
|
131
|
+
});
|
|
44
132
|
}
|
|
45
133
|
/** Create a uniquely-named temp dir under the consumer-local test base. */
|
|
46
134
|
export function makeTempDir(prefix = "norsk-ctl-") {
|