@norskvideo/ctl-test-harness 0.1.34 → 0.1.35

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 CHANGED
@@ -27,13 +27,14 @@
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 { basename, dirname, isAbsolute, join, resolve } from "node:path";
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
39
  import { makeStoreDir } from "../temp-dir.js";
39
40
  const DEMO_PORT_BASE = 35000;
@@ -41,7 +42,6 @@ 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),
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-test-harness",
3
- "version": "0.1.34",
3
+ "version": "0.1.35",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
package/root-nuke.d.ts ADDED
@@ -0,0 +1,46 @@
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. The
7
+ * mount is the target's PARENT, so a target with no leaf ("/" or a trailing
8
+ * slash) means "delete the mount", and a relative path resolves against a cwd
9
+ * the container does not share. These are programming errors, not runtime
10
+ * conditions: throw loudly rather than run a root rm against a guess. */
11
+ export declare function assertNukeTarget(target: string): void;
12
+ /** Docker argv for the nuke. Two things matter here:
13
+ *
14
+ * 1. The PARENT is mounted and the leaf removed by name -- mounting the target
15
+ * itself would let the container empty it but never unlink it, leaving the
16
+ * dir behind and the caller's existence check true.
17
+ * 2. The leaf is passed as a positional ARGUMENT, never interpolated into the
18
+ * `sh -c` script. The prune feeds this arbitrary readdir names, and a
19
+ * directory called `x; rm -rf /base` would otherwise wipe every sibling --
20
+ * including a live sibling run -- from inside a root container. */
21
+ export declare function nukeArgv(target: string): string[];
22
+ /** Remove `target` as root. Returns whether the nuke actually ran: `spawnSync`
23
+ * reports a missing docker in its RESULT rather than throwing, and a caller
24
+ * that retries a doomed spawn forever (silently) is the failure mode to avoid.
25
+ * Throws only for an unsafe target -- see {@link assertNukeTarget}. */
26
+ export declare function nukePathAsRoot(target: string, spawn?: NukeSpawn): boolean;
27
+ export interface OwnerScanDeps {
28
+ readdir: (path: string) => string[];
29
+ isDir: (path: string) => boolean;
30
+ uidOf: (path: string) => number;
31
+ }
32
+ /** Paths under `dir` owned by a uid that is not `selfUid`, with the owning uid.
33
+ *
34
+ * The diagnostic that ends the guessing. Teardown deletes this evidence every
35
+ * run, so nobody has ever observed WHICH uid owns the litter -- and "root" and
36
+ * "a container's baked-in service account" EACCES a non-root runner
37
+ * identically while implying completely different causes. Bounded so a large
38
+ * tree cannot flood a CI log. */
39
+ export declare function foreignOwnedPaths(dir: string, selfUid: number, deps: OwnerScanDeps, limit?: number): Array<{
40
+ path: string;
41
+ uid: number;
42
+ }>;
43
+ /** Log which uids own what under `dir`, for a teardown whose non-root rm just
44
+ * failed. Without this the harness deletes the only evidence of the cause on
45
+ * every run, leaving "root-owned" an inference nobody has ever checked. */
46
+ export declare function reportForeignOwners(dir: string, log: (line: string) => void): void;
package/root-nuke.js ADDED
@@ -0,0 +1,129 @@
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 } 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. The
14
+ * mount is the target's PARENT, so a target with no leaf ("/" or a trailing
15
+ * slash) means "delete the mount", and a relative path resolves against a cwd
16
+ * the container does not share. These are programming errors, not runtime
17
+ * conditions: throw loudly rather than run a root rm against a guess. */
18
+ export function assertNukeTarget(target) {
19
+ if (!isAbsolute(target))
20
+ throw new Error(`refusing to nuke a relative path: ${target}`);
21
+ const leaf = basename(target);
22
+ if (leaf === "" || leaf === "." || leaf === "..")
23
+ throw new Error(`refusing to nuke a path with no leaf: ${target}`);
24
+ if (leaf === "*" || leaf.includes("*"))
25
+ throw new Error(`refusing to nuke a glob: ${target}`);
26
+ // dirname is what gets bind-mounted; "/" would hand the whole host to a root
27
+ // container. Every real target (a store under test-temp) is nested deeper.
28
+ if (dirname(target) === "/" || dirname(target) === target) {
29
+ throw new Error(`refusing to nuke a top-level path (its parent would be the mount): ${target}`);
30
+ }
31
+ }
32
+ /** Docker argv for the nuke. Two things matter here:
33
+ *
34
+ * 1. The PARENT is mounted and the leaf removed by name -- mounting the target
35
+ * itself would let the container empty it but never unlink it, leaving the
36
+ * dir behind and the caller's existence check true.
37
+ * 2. The leaf is passed as a positional ARGUMENT, never interpolated into the
38
+ * `sh -c` script. The prune feeds this arbitrary readdir names, and a
39
+ * directory called `x; rm -rf /base` would otherwise wipe every sibling --
40
+ * including a live sibling run -- from inside a root container. */
41
+ export function nukeArgv(target) {
42
+ return [
43
+ "run",
44
+ "--rm",
45
+ "--user",
46
+ "0:0",
47
+ "-v",
48
+ `${dirname(target)}:/base`,
49
+ "--entrypoint",
50
+ "sh",
51
+ NUKE_IMAGE,
52
+ "-c",
53
+ 'rm -rf -- "/base/$1"',
54
+ "sh",
55
+ basename(target),
56
+ ];
57
+ }
58
+ /** Remove `target` as root. Returns whether the nuke actually ran: `spawnSync`
59
+ * reports a missing docker in its RESULT rather than throwing, and a caller
60
+ * that retries a doomed spawn forever (silently) is the failure mode to avoid.
61
+ * Throws only for an unsafe target -- see {@link assertNukeTarget}. */
62
+ export function nukePathAsRoot(target, spawn = spawnSync) {
63
+ assertNukeTarget(target);
64
+ const r = spawn("docker", nukeArgv(target), { timeout: NUKE_TIMEOUT_MS });
65
+ return !r?.error;
66
+ }
67
+ /** Paths under `dir` owned by a uid that is not `selfUid`, with the owning uid.
68
+ *
69
+ * The diagnostic that ends the guessing. Teardown deletes this evidence every
70
+ * run, so nobody has ever observed WHICH uid owns the litter -- and "root" and
71
+ * "a container's baked-in service account" EACCES a non-root runner
72
+ * identically while implying completely different causes. Bounded so a large
73
+ * tree cannot flood a CI log. */
74
+ export function foreignOwnedPaths(dir, selfUid, deps, limit = 40) {
75
+ const found = [];
76
+ const stack = [dir];
77
+ while (stack.length > 0 && found.length < limit) {
78
+ const cur = stack.pop();
79
+ if (cur === undefined)
80
+ break;
81
+ let names;
82
+ try {
83
+ names = deps.readdir(cur);
84
+ }
85
+ catch {
86
+ continue;
87
+ }
88
+ for (const name of names) {
89
+ if (found.length >= limit)
90
+ break;
91
+ const p = `${cur}/${name}`;
92
+ try {
93
+ const uid = deps.uidOf(p);
94
+ if (uid !== selfUid)
95
+ found.push({ path: p, uid });
96
+ if (deps.isDir(p))
97
+ stack.push(p);
98
+ }
99
+ catch {
100
+ // vanished mid-scan, or unreadable -- diagnostics are best-effort
101
+ }
102
+ }
103
+ }
104
+ return found;
105
+ }
106
+ /** Log which uids own what under `dir`, for a teardown whose non-root rm just
107
+ * failed. Without this the harness deletes the only evidence of the cause on
108
+ * every run, leaving "root-owned" an inference nobody has ever checked. */
109
+ export function reportForeignOwners(dir, log) {
110
+ const selfUid = process.getuid?.();
111
+ if (selfUid === undefined)
112
+ return;
113
+ const found = foreignOwnedPaths(dir, selfUid, {
114
+ readdir: (p) => readdirSync(p),
115
+ isDir: (p) => lstatSync(p).isDirectory(),
116
+ uidOf: (p) => lstatSync(p).uid,
117
+ });
118
+ if (found.length === 0) {
119
+ log(`owners: nothing under ${dir} is owned by a uid other than ${selfUid}`);
120
+ return;
121
+ }
122
+ const byUid = new Map();
123
+ for (const f of found)
124
+ byUid.set(f.uid, (byUid.get(f.uid) ?? 0) + 1);
125
+ const summary = [...byUid.entries()].map(([uid, n]) => `uid ${uid} x${n}`).join(", ");
126
+ log(`owners: runner is uid ${selfUid}; foreign-owned under ${dir}: ${summary}`);
127
+ for (const f of found.slice(0, 10))
128
+ log(`owners: uid ${f.uid} ${f.path}`);
129
+ }
package/smoke.js CHANGED
@@ -17,15 +17,15 @@
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 { basename, dirname, join } from "node:path";
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
31
  import { makeStoreDir } from "./temp-dir.js";
@@ -33,7 +33,6 @@ 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),
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
- return mkdtempSync(join(base, prefix));
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
- // The 1h cutoff never races a live run — no test runs near that long — so this
26
- // can't delete a sibling run's active dir.
27
- function pruneStaleTempDirs(base) {
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
- const cutoff = Date.now() - 60 * 60 * 1000;
30
- for (const name of readdirSync(base)) {
31
- const p = join(base, name);
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
- if (statSync(p).mtimeMs < cutoff)
34
- rmSync(p, { recursive: true, force: true });
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
- // ignore — racing teardown of a sibling run, or a transient stat error
108
+ return undefined;
38
109
  }
39
- }
40
- }
41
- catch {
42
- // base doesn't exist yet — nothing to prune
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);
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-") {