@norskvideo/ctl-test-harness 0.1.40 → 0.1.41
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.d.ts +22 -0
- package/demo/run.js +26 -1
- package/package.json +1 -1
- package/root-nuke.d.ts +15 -1
- package/root-nuke.js +53 -0
package/demo/run.d.ts
CHANGED
|
@@ -217,6 +217,28 @@ export interface SessionOptions {
|
|
|
217
217
|
/** Containers teardown must also wait out — an overridden proxy's pair. */
|
|
218
218
|
extraContainers?: string[];
|
|
219
219
|
}
|
|
220
|
+
/** The host a demo's daemon-port URLs name.
|
|
221
|
+
*
|
|
222
|
+
* NORSK_TEST_HOST is a STUDIO-port setting: on a docker-outside-of-docker
|
|
223
|
+
* runner the test process sits in a container while the studio it launched
|
|
224
|
+
* published its port on the HOST, reachable only via the host-gateway alias.
|
|
225
|
+
* A PRIVATE daemon is the opposite case -- this process starts it, and its
|
|
226
|
+
* proxy, so its ports are on this process's own loopback. studio-state.ts
|
|
227
|
+
* states the invariant plainly ("daemon-port fetches are deliberately left on
|
|
228
|
+
* localhost"); letting the env reach them here aimed playout's visualiser gate
|
|
229
|
+
* at the host gateway, where nothing listens on the private proxy port, and
|
|
230
|
+
* `demo -- check` missed `video_compose advancing` on every poll for 180s
|
|
231
|
+
* while the compositor was advancing perfectly well (2026-09-03). It passed
|
|
232
|
+
* only where a runner's network happened to route back, which is what made it
|
|
233
|
+
* read as a flake rather than a wrong address.
|
|
234
|
+
*
|
|
235
|
+
* `reuse` keeps the alias: that daemon is a process this one did not start and
|
|
236
|
+
* may genuinely be the host sibling. An explicit --public-host always wins. */
|
|
237
|
+
export declare function demoUrlHost(opts: {
|
|
238
|
+
publicHost: string | undefined;
|
|
239
|
+
daemon: DemoDaemonPolicy;
|
|
240
|
+
env?: NodeJS.ProcessEnv;
|
|
241
|
+
}): string;
|
|
220
242
|
/** The pieces of a run that `runDemo`, `runExportCheck` and the dev-loop
|
|
221
243
|
* share: the daemon (private on its band, or the developer's), the dev
|
|
222
244
|
* backend on the driver's port, the registration, and the template (built
|
package/demo/run.js
CHANGED
|
@@ -311,6 +311,31 @@ export function defaultDemoDeps(cwd) {
|
|
|
311
311
|
log: (line) => console.log(`[demo] ${line}`),
|
|
312
312
|
};
|
|
313
313
|
}
|
|
314
|
+
/** The host a demo's daemon-port URLs name.
|
|
315
|
+
*
|
|
316
|
+
* NORSK_TEST_HOST is a STUDIO-port setting: on a docker-outside-of-docker
|
|
317
|
+
* runner the test process sits in a container while the studio it launched
|
|
318
|
+
* published its port on the HOST, reachable only via the host-gateway alias.
|
|
319
|
+
* A PRIVATE daemon is the opposite case -- this process starts it, and its
|
|
320
|
+
* proxy, so its ports are on this process's own loopback. studio-state.ts
|
|
321
|
+
* states the invariant plainly ("daemon-port fetches are deliberately left on
|
|
322
|
+
* localhost"); letting the env reach them here aimed playout's visualiser gate
|
|
323
|
+
* at the host gateway, where nothing listens on the private proxy port, and
|
|
324
|
+
* `demo -- check` missed `video_compose advancing` on every poll for 180s
|
|
325
|
+
* while the compositor was advancing perfectly well (2026-09-03). It passed
|
|
326
|
+
* only where a runner's network happened to route back, which is what made it
|
|
327
|
+
* read as a flake rather than a wrong address.
|
|
328
|
+
*
|
|
329
|
+
* `reuse` keeps the alias: that daemon is a process this one did not start and
|
|
330
|
+
* may genuinely be the host sibling. An explicit --public-host always wins. */
|
|
331
|
+
export function demoUrlHost(opts) {
|
|
332
|
+
if (opts.publicHost !== undefined)
|
|
333
|
+
return opts.publicHost;
|
|
334
|
+
const envHost = (opts.env ?? process.env).NORSK_TEST_HOST;
|
|
335
|
+
if (opts.daemon === "reuse" && envHost)
|
|
336
|
+
return envHost;
|
|
337
|
+
return "localhost";
|
|
338
|
+
}
|
|
314
339
|
/** The pieces of a run that `runDemo`, `runExportCheck` and the dev-loop
|
|
315
340
|
* share: the daemon (private on its band, or the developer's), the dev
|
|
316
341
|
* backend on the driver's port, the registration, and the template (built
|
|
@@ -346,7 +371,7 @@ export class DemoSession {
|
|
|
346
371
|
this.mode = opts.mode;
|
|
347
372
|
this.policy = opts.daemon;
|
|
348
373
|
this.publicHost = opts.publicHost;
|
|
349
|
-
const host = opts.publicHost
|
|
374
|
+
const host = demoUrlHost({ publicHost: opts.publicHost, daemon: opts.daemon });
|
|
350
375
|
this.host = host;
|
|
351
376
|
if (opts.daemon === "reuse") {
|
|
352
377
|
const daemonPort = opts.daemonPort ?? (Number(process.env.NORSK_CTL_PORT) || DEFAULT_DAEMON_PORT);
|
package/package.json
CHANGED
package/root-nuke.d.ts
CHANGED
|
@@ -1,8 +1,22 @@
|
|
|
1
|
-
/** Minimal shape of `spawnSync` this needs, so tests can inject.
|
|
1
|
+
/** Minimal shape of `spawnSync` this needs, so tests can inject. `stdout` is
|
|
2
|
+
* optional because only the container-scan calls read it. */
|
|
2
3
|
export type NukeSpawn = (cmd: string, argv: string[], opts?: unknown) => {
|
|
3
4
|
error?: unknown;
|
|
4
5
|
status?: number | null;
|
|
6
|
+
stdout?: string | Buffer | null;
|
|
5
7
|
};
|
|
8
|
+
/** Container ids whose bind-mount sources sit at or under `dir`.
|
|
9
|
+
*
|
|
10
|
+
* Matching on the mount SOURCE rather than a label is the point. The
|
|
11
|
+
* containers that strand a temp dir are exactly the ones no teardown ever
|
|
12
|
+
* tracked -- a run killed mid-flight (CI cancel, timeout, SIGKILL) never
|
|
13
|
+
* labels or records them -- so a label filter finds nothing. The mount is the
|
|
14
|
+
* one thing that is always true of a container holding the path open. */
|
|
15
|
+
export declare function containersHoldingMountsUnder(dir: string, spawn?: NukeSpawn): string[];
|
|
16
|
+
/** Remove the containers pinning `dir` so a nuke can actually unlink it.
|
|
17
|
+
* Returns the ids it tried to reap. Best-effort: a docker that cannot list is
|
|
18
|
+
* a docker that cannot nuke either, and the caller reports that separately. */
|
|
19
|
+
export declare function reapContainersUnder(dir: string, spawn?: NukeSpawn, log?: (line: string) => void): string[];
|
|
6
20
|
/** Refuse a target that would turn the nuke into something catastrophic.
|
|
7
21
|
*
|
|
8
22
|
* `allowedBase` is the containment: the target must sit strictly INSIDE it.
|
package/root-nuke.js
CHANGED
|
@@ -10,6 +10,52 @@ import { lstatSync, readdirSync } from "node:fs";
|
|
|
10
10
|
import { basename, dirname, isAbsolute, resolve, sep } from "node:path";
|
|
11
11
|
const NUKE_IMAGE = "alpine:3";
|
|
12
12
|
const NUKE_TIMEOUT_MS = 60_000;
|
|
13
|
+
const REAP_TIMEOUT_MS = 30_000;
|
|
14
|
+
function spawnOut(spawn, argv) {
|
|
15
|
+
const r = spawn("docker", argv, { timeout: REAP_TIMEOUT_MS, encoding: "utf8" });
|
|
16
|
+
if (r?.error || (r?.status ?? 0) !== 0)
|
|
17
|
+
return undefined;
|
|
18
|
+
return String(r.stdout ?? "");
|
|
19
|
+
}
|
|
20
|
+
function lines(out) {
|
|
21
|
+
return out
|
|
22
|
+
.split("\n")
|
|
23
|
+
.map((l) => l.trim())
|
|
24
|
+
.filter((l) => l.length > 0);
|
|
25
|
+
}
|
|
26
|
+
/** Container ids whose bind-mount sources sit at or under `dir`.
|
|
27
|
+
*
|
|
28
|
+
* Matching on the mount SOURCE rather than a label is the point. The
|
|
29
|
+
* containers that strand a temp dir are exactly the ones no teardown ever
|
|
30
|
+
* tracked -- a run killed mid-flight (CI cancel, timeout, SIGKILL) never
|
|
31
|
+
* labels or records them -- so a label filter finds nothing. The mount is the
|
|
32
|
+
* one thing that is always true of a container holding the path open. */
|
|
33
|
+
export function containersHoldingMountsUnder(dir, spawn = spawnSync) {
|
|
34
|
+
const prefix = dir.endsWith(sep) ? dir : dir + sep;
|
|
35
|
+
const listed = spawnOut(spawn, ["ps", "-aq"]);
|
|
36
|
+
if (listed === undefined)
|
|
37
|
+
return [];
|
|
38
|
+
const held = [];
|
|
39
|
+
for (const id of lines(listed)) {
|
|
40
|
+
const mounts = spawnOut(spawn, ["inspect", "-f", "{{range .Mounts}}{{println .Source}}{{end}}", id]);
|
|
41
|
+
if (mounts === undefined)
|
|
42
|
+
continue;
|
|
43
|
+
if (lines(mounts).some((src) => src === dir || src.startsWith(prefix)))
|
|
44
|
+
held.push(id);
|
|
45
|
+
}
|
|
46
|
+
return held;
|
|
47
|
+
}
|
|
48
|
+
/** Remove the containers pinning `dir` so a nuke can actually unlink it.
|
|
49
|
+
* Returns the ids it tried to reap. Best-effort: a docker that cannot list is
|
|
50
|
+
* a docker that cannot nuke either, and the caller reports that separately. */
|
|
51
|
+
export function reapContainersUnder(dir, spawn = spawnSync, log) {
|
|
52
|
+
const held = containersHoldingMountsUnder(dir, spawn);
|
|
53
|
+
if (held.length === 0)
|
|
54
|
+
return held;
|
|
55
|
+
log?.(`reaping ${held.length} container(s) pinning ${dir}: ${held.join(" ")}`);
|
|
56
|
+
spawn("docker", ["rm", "-f", ...held], { timeout: REAP_TIMEOUT_MS });
|
|
57
|
+
return held;
|
|
58
|
+
}
|
|
13
59
|
/** Refuse a target that would turn the nuke into something catastrophic.
|
|
14
60
|
*
|
|
15
61
|
* `allowedBase` is the containment: the target must sit strictly INSIDE it.
|
|
@@ -83,6 +129,13 @@ export function nukeArgv(target) {
|
|
|
83
129
|
* Throws only for an unsafe target -- see {@link assertNukeTarget}. */
|
|
84
130
|
export function nukePathAsRoot(target, allowedBase, spawn = spawnSync) {
|
|
85
131
|
assertNukeTarget(target, allowedBase);
|
|
132
|
+
// A container still bind-mounting something under `target` makes that path
|
|
133
|
+
// unlinkable even for root -- the kernel returns EBUSY, `rm -rf` reports it
|
|
134
|
+
// as a plain non-zero exit, and the nuke silently no-ops. The litter then
|
|
135
|
+
// outlives every later run and eventually wedges an innocent job's checkout
|
|
136
|
+
// (2026-09-03: a leaked guide-composition studio, five days up, took
|
|
137
|
+
// commentary's checks AND integration red). Clear the holders first.
|
|
138
|
+
reapContainersUnder(target, spawn);
|
|
86
139
|
const r = spawn("docker", nukeArgv(target), { timeout: NUKE_TIMEOUT_MS });
|
|
87
140
|
if (r?.error)
|
|
88
141
|
return false;
|