@norskvideo/ctl-test-harness 0.1.35 → 0.1.37
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/dev-loop.js +12 -1
- package/demo/run.js +15 -4
- package/package.json +1 -1
- package/root-nuke.d.ts +23 -10
- package/root-nuke.js +39 -15
- package/smoke.js +2 -2
- package/temp-dir.js +1 -1
package/demo/dev-loop.js
CHANGED
|
@@ -199,7 +199,18 @@ export async function devLoopDown(product, deps = defaultDemoDeps(process.cwd())
|
|
|
199
199
|
}
|
|
200
200
|
if (state.devPid !== undefined)
|
|
201
201
|
deps.killPid?.(state.devPid);
|
|
202
|
-
|
|
202
|
+
// Never let the nuke strand the state record: it sits one line before
|
|
203
|
+
// state.remove, and the containment guard throws for a store outside THIS
|
|
204
|
+
// process's temp base -- which is a cross-process fact (the `up` shell's
|
|
205
|
+
// NORSK_CTL_TEST_TMP need not be the `down` shell's), not a bug in the run
|
|
206
|
+
// being torn down. Refusing to delete it is right; failing to forget it
|
|
207
|
+
// means `down` breaks identically on every retry.
|
|
208
|
+
try {
|
|
209
|
+
deps.nukeStoreAsRoot(state.storeDir);
|
|
210
|
+
}
|
|
211
|
+
catch (e) {
|
|
212
|
+
deps.log(`nuke skipped: ${e instanceof Error ? e.message : String(e)}`);
|
|
213
|
+
}
|
|
203
214
|
deps.state.remove(product, "dev-loop");
|
|
204
215
|
deps.log(`${product} dev-loop torn down (the workdir ${state.workdir ?? ""} is untouched)`);
|
|
205
216
|
}
|
package/demo/run.js
CHANGED
|
@@ -36,7 +36,7 @@ import { ctlSupportsNoPublish, runnerContainerUser } from "../launch.js";
|
|
|
36
36
|
import { pollUntil } from "../poll.js";
|
|
37
37
|
import { nukePathAsRoot, reportForeignOwners } from "../root-nuke.js";
|
|
38
38
|
import { startSrtSources } from "../source-pump.js";
|
|
39
|
-
import { makeStoreDir } from "../temp-dir.js";
|
|
39
|
+
import { makeStoreDir, TEST_TMP_BASE } from "../temp-dir.js";
|
|
40
40
|
const DEMO_PORT_BASE = 35000;
|
|
41
41
|
const DEMO_BAND_WIDTH = 20;
|
|
42
42
|
const DEMO_BANDS = 50;
|
|
@@ -290,7 +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) => nukePathAsRoot(storeDir),
|
|
293
|
+
nukeStoreAsRoot: (storeDir) => nukePathAsRoot(storeDir, TEST_TMP_BASE),
|
|
294
294
|
storeExists: (storeDir) => existsSync(storeDir),
|
|
295
295
|
ensureNetwork: () => ensureRunnerOnNetwork(),
|
|
296
296
|
supportsNoPublish: ctlSupportsNoPublish,
|
|
@@ -733,8 +733,19 @@ export async function demoDown(product, deps = defaultDemoDeps(process.cwd())) {
|
|
|
733
733
|
deps.docker(["rm", "-f", name]);
|
|
734
734
|
if (state.devPid !== undefined)
|
|
735
735
|
deps.killPid?.(state.devPid);
|
|
736
|
-
|
|
737
|
-
|
|
736
|
+
// Never let the nuke strand the state record: it sits one line before
|
|
737
|
+
// state.remove, and the containment guard throws for a store outside THIS
|
|
738
|
+
// process's temp base -- which is a cross-process fact (the `up` shell's
|
|
739
|
+
// NORSK_CTL_TEST_TMP need not be the `down` shell's), not a bug in the run
|
|
740
|
+
// being torn down. Refusing to delete it is right; failing to forget it
|
|
741
|
+
// means `down` breaks identically on every retry.
|
|
742
|
+
try {
|
|
743
|
+
if (state.daemon !== "reuse")
|
|
744
|
+
deps.nukeStoreAsRoot(state.storeDir);
|
|
745
|
+
}
|
|
746
|
+
catch (e) {
|
|
747
|
+
deps.log(`nuke skipped: ${e instanceof Error ? e.message : String(e)}`);
|
|
748
|
+
}
|
|
738
749
|
deps.state.remove(product);
|
|
739
750
|
deps.log(`${product} demo torn down${state.daemon === "reuse" ? " (your daemon and its registration are untouched)" : ""}`);
|
|
740
751
|
}
|
package/package.json
CHANGED
package/root-nuke.d.ts
CHANGED
|
@@ -3,12 +3,20 @@ export type NukeSpawn = (cmd: string, argv: string[], opts?: unknown) => {
|
|
|
3
3
|
error?: unknown;
|
|
4
4
|
status?: number | null;
|
|
5
5
|
};
|
|
6
|
-
/** Refuse a target that would turn the nuke into something catastrophic.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
|
|
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;
|
|
12
20
|
/** Docker argv for the nuke. Two things matter here:
|
|
13
21
|
*
|
|
14
22
|
* 1. The PARENT is mounted and the leaf removed by name -- mounting the target
|
|
@@ -19,11 +27,16 @@ export declare function assertNukeTarget(target: string): void;
|
|
|
19
27
|
* directory called `x; rm -rf /base` would otherwise wipe every sibling --
|
|
20
28
|
* including a live sibling run -- from inside a root container. */
|
|
21
29
|
export declare function nukeArgv(target: string): string[];
|
|
22
|
-
/** Remove `target` as root. Returns whether the nuke actually
|
|
23
|
-
*
|
|
24
|
-
*
|
|
30
|
+
/** Remove `target` as root. Returns whether the nuke actually SUCCEEDED.
|
|
31
|
+
*
|
|
32
|
+
* Both halves matter. `spawnSync` reports a missing docker in its RESULT
|
|
33
|
+
* rather than throwing, and a docker that is present but failing -- daemon
|
|
34
|
+
* stopped, image unpullable, mount refused -- reports only a non-zero status
|
|
35
|
+
* with no `error` at all. Reading `error` alone calls that a success, so a
|
|
36
|
+
* caller latching on the return value never latches and re-spawns a doomed
|
|
37
|
+
* 60-second docker on every attempt, forever and silently.
|
|
25
38
|
* Throws only for an unsafe target -- see {@link assertNukeTarget}. */
|
|
26
|
-
export declare function nukePathAsRoot(target: string, spawn?: NukeSpawn): boolean;
|
|
39
|
+
export declare function nukePathAsRoot(target: string, allowedBase: string, spawn?: NukeSpawn): boolean;
|
|
27
40
|
export interface OwnerScanDeps {
|
|
28
41
|
readdir: (path: string) => string[];
|
|
29
42
|
isDir: (path: string) => boolean;
|
package/root-nuke.js
CHANGED
|
@@ -7,27 +7,44 @@
|
|
|
7
7
|
// prevention half.
|
|
8
8
|
import { spawnSync } from "node:child_process";
|
|
9
9
|
import { lstatSync, readdirSync } from "node:fs";
|
|
10
|
-
import { basename, dirname, isAbsolute } from "node:path";
|
|
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
|
-
/** Refuse a target that would turn the nuke into something catastrophic.
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
|
|
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) {
|
|
19
27
|
if (!isAbsolute(target))
|
|
20
28
|
throw new Error(`refusing to nuke a relative path: ${target}`);
|
|
21
29
|
const leaf = basename(target);
|
|
22
30
|
if (leaf === "" || leaf === "." || leaf === "..")
|
|
23
31
|
throw new Error(`refusing to nuke a path with no leaf: ${target}`);
|
|
24
|
-
if (leaf
|
|
32
|
+
if (leaf.includes("*"))
|
|
25
33
|
throw new Error(`refusing to nuke a glob: ${target}`);
|
|
26
34
|
// dirname is what gets bind-mounted; "/" would hand the whole host to a root
|
|
27
|
-
// container. Every real target (a store under
|
|
35
|
+
// container. Every real target (a store under the temp base) is nested deeper.
|
|
28
36
|
if (dirname(target) === "/" || dirname(target) === target) {
|
|
29
37
|
throw new Error(`refusing to nuke a top-level path (its parent would be the mount): ${target}`);
|
|
30
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
|
+
}
|
|
31
48
|
}
|
|
32
49
|
/** Docker argv for the nuke. Two things matter here:
|
|
33
50
|
*
|
|
@@ -55,14 +72,21 @@ export function nukeArgv(target) {
|
|
|
55
72
|
basename(target),
|
|
56
73
|
];
|
|
57
74
|
}
|
|
58
|
-
/** Remove `target` as root. Returns whether the nuke actually
|
|
59
|
-
*
|
|
60
|
-
*
|
|
75
|
+
/** Remove `target` as root. Returns whether the nuke actually SUCCEEDED.
|
|
76
|
+
*
|
|
77
|
+
* Both halves matter. `spawnSync` reports a missing docker in its RESULT
|
|
78
|
+
* rather than throwing, and a docker that is present but failing -- daemon
|
|
79
|
+
* stopped, image unpullable, mount refused -- reports only a non-zero status
|
|
80
|
+
* with no `error` at all. Reading `error` alone calls that a success, so a
|
|
81
|
+
* caller latching on the return value never latches and re-spawns a doomed
|
|
82
|
+
* 60-second docker on every attempt, forever and silently.
|
|
61
83
|
* Throws only for an unsafe target -- see {@link assertNukeTarget}. */
|
|
62
|
-
export function nukePathAsRoot(target, spawn = spawnSync) {
|
|
63
|
-
assertNukeTarget(target);
|
|
84
|
+
export function nukePathAsRoot(target, allowedBase, spawn = spawnSync) {
|
|
85
|
+
assertNukeTarget(target, allowedBase);
|
|
64
86
|
const r = spawn("docker", nukeArgv(target), { timeout: NUKE_TIMEOUT_MS });
|
|
65
|
-
|
|
87
|
+
if (r?.error)
|
|
88
|
+
return false;
|
|
89
|
+
return r?.status === 0 || r?.status === undefined;
|
|
66
90
|
}
|
|
67
91
|
/** Paths under `dir` owned by a uid that is not `selfUid`, with the owning uid.
|
|
68
92
|
*
|
package/smoke.js
CHANGED
|
@@ -28,7 +28,7 @@ import { pollUntil } from "./poll.js";
|
|
|
28
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;
|
|
@@ -62,7 +62,7 @@ export const defaultSmokeDeps = {
|
|
|
62
62
|
fetch: (url) => fetch(url, { signal: AbortSignal.timeout(5000) }),
|
|
63
63
|
},
|
|
64
64
|
cleanup: (opts) => cleanupDaemon({ ...opts, stopProxy: async () => { }, proxy: false }),
|
|
65
|
-
nukeStoreAsRoot: (storeDir) => nukePathAsRoot(storeDir),
|
|
65
|
+
nukeStoreAsRoot: (storeDir) => nukePathAsRoot(storeDir, TEST_TMP_BASE),
|
|
66
66
|
storeExists: (storeDir) => existsSync(storeDir),
|
|
67
67
|
ensureNetwork: () => ensureRunnerOnNetwork(),
|
|
68
68
|
supportsNoPublish: ctlSupportsNoPublish,
|
package/temp-dir.js
CHANGED