@norskvideo/ctl-test-harness 0.1.57 → 0.1.59

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,5 @@
1
+ /** Called by startDaemon. Last writer wins: a port reused by a later daemon in
2
+ * the same process belongs to that daemon. */
3
+ export declare function rememberDaemonStore(port: number, storeDir: string): void;
4
+ export declare function forgetDaemonStore(port: number): void;
5
+ export declare function storeDirForDaemonPort(port: number): string | undefined;
@@ -0,0 +1,31 @@
1
+ // Which store dir the daemon on a given port was started with.
2
+ //
3
+ // The daemon's ingress secret lives in its store (`<storeDir>/proxy-secret`),
4
+ // and RFC 0003 D10b guards /api/* for loopback callers too — so anything
5
+ // driving a test daemon's API needs that file. Every caller already has the
6
+ // PORT; almost none of them has the store dir, because a test holds a harness
7
+ // that started the daemon for it. That gap is why `startCameraSource` shipped
8
+ // with a `proxySecret` option that nearly nobody passed, and why five product
9
+ // repos' smoke tiers were 307'd for days.
10
+ //
11
+ // `startDaemon` knows both, so it records the pairing here and the pump looks
12
+ // it up. Deliberately a process-global: the two are one-to-one within a test
13
+ // process (one daemon per port), and the alternative — threading a store dir
14
+ // through every helper signature and every call site — is the arrangement that
15
+ // already failed.
16
+ //
17
+ // A daemon this process did not start (the demo driver's `reuse` policy, a
18
+ // developer's own `norsk-ctl serve`) is absent, and callers that know their
19
+ // store read it themselves.
20
+ const storeByPort = new Map();
21
+ /** Called by startDaemon. Last writer wins: a port reused by a later daemon in
22
+ * the same process belongs to that daemon. */
23
+ export function rememberDaemonStore(port, storeDir) {
24
+ storeByPort.set(port, storeDir);
25
+ }
26
+ export function forgetDaemonStore(port) {
27
+ storeByPort.delete(port);
28
+ }
29
+ export function storeDirForDaemonPort(port) {
30
+ return storeByPort.get(port);
31
+ }
package/daemon.js CHANGED
@@ -12,6 +12,7 @@ import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
12
12
  import { createServer } from "node:net";
13
13
  import { join } from "node:path";
14
14
  import { cliCommand } from "./cli-command.js";
15
+ import { rememberDaemonStore } from "./daemon-stores.js";
15
16
  import { pollUntil } from "./poll.js";
16
17
  import { makeStoreDir, makeTempDir, TEST_TMP_BASE } from "./temp-dir.js";
17
18
  export const HEALTH_TIMEOUT_MS = 120_000;
@@ -146,6 +147,9 @@ export function seedDaemonConfig(storeDir) {
146
147
  }
147
148
  export function startDaemon(storeDir, options) {
148
149
  const port = options?.port ?? 8333;
150
+ // Before the spawn, so anything reaching for this daemon's ingress secret can
151
+ // find its store by the port it will answer on (daemon-stores.ts).
152
+ rememberDaemonStore(port, storeDir);
149
153
  killProcessOnPort(port);
150
154
  if (options?.seedConfig !== false)
151
155
  seedDaemonConfig(storeDir);
package/demo/harness.js CHANGED
@@ -16,6 +16,7 @@
16
16
  import { netReachMode, STUDIO_INTERNAL_PORT, studioBaseFrom } from "../container-net.js";
17
17
  import { ctlSupportsPublishDebugPorts } from "../launch.js";
18
18
  import { pollUntil } from "../poll.js";
19
+ import { launchWithPortConflictRetry, reapInstance } from "../reap.js";
19
20
  import { DemoGateFailure } from "./gates.js";
20
21
  import { DemoSession, defaultDemoDeps, demoSlug, expandHome, resolveIngestPort, } from "./run.js";
21
22
  const STUDIO_HOST_PORT_PARAM = "STUDIO_HOST_PORT";
@@ -165,7 +166,16 @@ export class ProductHarness {
165
166
  // the reap step whether or not this process lives to teardown.
166
167
  if (this.launchedManifest)
167
168
  this.deps.writeFile(this.launchedManifest, `${this.launchedIds.join("\n")}\n`);
168
- await s.cliOk(launchArgv);
169
+ // A run cancelled before teardown leaves this id's compose stack up, still
170
+ // holding its host ports, and nothing that asks a live daemon can see it
171
+ // (the leaker's daemon is gone). Reap by label first, then retry once if the
172
+ // bind still loses the advisory-check race — see reap.ts.
173
+ reapInstance(instanceId);
174
+ await launchWithPortConflictRetry({
175
+ instanceId,
176
+ attempt: () => s.cliOk(launchArgv),
177
+ log: deps.log,
178
+ });
169
179
  await s.waitRunning(instanceId);
170
180
  const rows = await s.ingestPorts(instanceId);
171
181
  const templateParams = { declared, overrides: specParams };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-test-harness",
3
- "version": "0.1.57",
3
+ "version": "0.1.59",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -15,6 +15,10 @@
15
15
  "types": "./daemon.d.ts",
16
16
  "default": "./daemon.js"
17
17
  },
18
+ "./daemon-stores": {
19
+ "types": "./daemon-stores.d.ts",
20
+ "default": "./daemon-stores.js"
21
+ },
18
22
  "./image-surface": {
19
23
  "types": "./image-surface.d.ts",
20
24
  "default": "./image-surface.js"
@@ -23,6 +27,10 @@
23
27
  "types": "./launch.d.ts",
24
28
  "default": "./launch.js"
25
29
  },
30
+ "./reap": {
31
+ "types": "./reap.d.ts",
32
+ "default": "./reap.js"
33
+ },
26
34
  "./source-pump": {
27
35
  "types": "./source-pump.d.ts",
28
36
  "default": "./source-pump.js"
package/reap.d.ts ADDED
@@ -0,0 +1,37 @@
1
+ import { spawnSync } from "node:child_process";
2
+ /** True when `text` is a host-port binding conflict — the signal to reap and
3
+ * retry. Deliberately narrow: an unhealthy dependency is also a launch
4
+ * failure, and retrying that one just burns the clock. */
5
+ export declare function isPortConflict(text: string): boolean;
6
+ /** The conflicting host port, or null when the message carries no extractable
7
+ * one (still a conflict per {@link isPortConflict}, just not attributable to a
8
+ * port, so only the instance-scoped reap can run). */
9
+ export declare function extractConflictPort(text: string): number | null;
10
+ /** `docker ps` argv listing container ids for ONE instance id — the exact scope
11
+ * that spares co-running products. */
12
+ export declare function instancePsArgs(instanceId: string): string[];
13
+ /** `docker ps` argv listing norsk-ctl container ids publishing a given host
14
+ * port. Role-filtered, so a squatter outside norsk-ctl is never touched. */
15
+ export declare function portPsArgs(port: number): string[];
16
+ type Spawn = typeof spawnSync;
17
+ /** Reap every leftover container for one instance id (media, studio and source
18
+ * all carry the `norsk-ctl.instance` label). */
19
+ export declare function reapInstance(instanceId: string, spawn?: Spawn): string[];
20
+ /** Reap whatever norsk-ctl container holds a specific host port — the fallback
21
+ * when the holder belongs to a different instance, so the instance-id reap
22
+ * alone would not free it. */
23
+ export declare function reapPort(port: number, spawn?: Spawn): string[];
24
+ /**
25
+ * Run a launch, and on a host-port conflict reap the holder and retry ONCE.
26
+ *
27
+ * Once, not a loop: one retry clears a leaked holder or a lost race, and a
28
+ * second conflict means something is genuinely sitting on the port — where the
29
+ * launch's own message, naming the port, is more use than another attempt.
30
+ */
31
+ export declare function launchWithPortConflictRetry<T>(opts: {
32
+ instanceId: string;
33
+ attempt: () => Promise<T>;
34
+ spawn?: Spawn;
35
+ log?: (line: string) => void;
36
+ }): Promise<T>;
37
+ export {};
package/reap.js ADDED
@@ -0,0 +1,129 @@
1
+ // Orphan-container reaper for launches that lose a host port.
2
+ //
3
+ // ctl's port-availability check at validation is ADVISORY — the probe holds no
4
+ // reservation, and docker binds host ports at container START — so a port free
5
+ // when the launch was validated can be taken by the time compose binds it
6
+ // (`launch-service.ts`, HOST_PORT_CONFLICT). Its own message says to launch
7
+ // again. On a shared self-hosted runner that race is routine rather than
8
+ // exotic, in two flavours:
9
+ //
10
+ // * a CANCELLED run: `cancel-in-progress` SIGKILLs `bun test` before the
11
+ // harness can tear down, so a `compose up -d` stack outlives its (now dead)
12
+ // daemon and keeps holding the port. Nothing that asks a LIVE daemon can
13
+ // see it, because the leaker's daemon is gone — which is why this reaps off
14
+ // Docker by label rather than through the API.
15
+ // * co-running products: six product repos' CI can fire within seconds of
16
+ // each other on one Docker daemon, and a fixed host port (the MoQ preview's
17
+ // `preferred: 4443`) is picked by each of them independently.
18
+ //
19
+ // Scoping is the whole safety argument. Every norsk-ctl container carries
20
+ // `norsk-ctl.role`; per-instance ones also carry `norsk-ctl.instance=<id>`. We
21
+ // reap by the exact instance id, and by port only among containers that carry
22
+ // the role label — so a co-running product is untouched and a squatter outside
23
+ // norsk-ctl is never removed, only reported by the launch that then fails.
24
+ //
25
+ // The regex and argv builders are pure so tests/reap.test.ts covers them with
26
+ // zero Docker; only `reapInstance`/`reapPort` shell out.
27
+ //
28
+ // Lifted from norsk-ctl-product-playout's tests/integration/support/reap.ts,
29
+ // which is where this was worked out. It lived in one product while probe,
30
+ // commentary, reuters and funke had no equivalent and lost tests to the same
31
+ // race, so it belongs here instead.
32
+ import { spawnSync } from "node:child_process";
33
+ // A launch failure that means "the host port I need is already bound". Covers
34
+ // the daemon's own wrapper and the raw docker/kernel strings it quotes, since
35
+ // either can surface depending on where the bind loses the race.
36
+ const CONFLICT_PATTERNS = [
37
+ /was taken between validation and container start/i,
38
+ /port is already allocated/i,
39
+ /address already in use/i,
40
+ ];
41
+ // Same failure, with the offending port captured. Ordered most-specific first:
42
+ // the daemon wrapper names the port up front, the raw docker/kernel lines carry
43
+ // it in the bind address.
44
+ const PORT_PATTERNS = [
45
+ /host port (\d{2,5})\b/i,
46
+ /bind for [\d.:[\]]*?:(\d{2,5})\b[^:]*:\s*port is already allocated/i,
47
+ /:(\d{2,5}):\s*bind:\s*address already in use/i,
48
+ /port (\d{2,5}) .*already/i,
49
+ ];
50
+ /** True when `text` is a host-port binding conflict — the signal to reap and
51
+ * retry. Deliberately narrow: an unhealthy dependency is also a launch
52
+ * failure, and retrying that one just burns the clock. */
53
+ export function isPortConflict(text) {
54
+ return CONFLICT_PATTERNS.some((re) => re.test(text));
55
+ }
56
+ /** The conflicting host port, or null when the message carries no extractable
57
+ * one (still a conflict per {@link isPortConflict}, just not attributable to a
58
+ * port, so only the instance-scoped reap can run). */
59
+ export function extractConflictPort(text) {
60
+ for (const re of PORT_PATTERNS) {
61
+ const m = text.match(re);
62
+ if (m?.[1])
63
+ return Number(m[1]);
64
+ }
65
+ return null;
66
+ }
67
+ /** `docker ps` argv listing container ids for ONE instance id — the exact scope
68
+ * that spares co-running products. */
69
+ export function instancePsArgs(instanceId) {
70
+ return ["ps", "-aq", "--filter", `label=norsk-ctl.instance=${instanceId}`];
71
+ }
72
+ /** `docker ps` argv listing norsk-ctl container ids publishing a given host
73
+ * port. Role-filtered, so a squatter outside norsk-ctl is never touched. */
74
+ export function portPsArgs(port) {
75
+ return ["ps", "-aq", "--filter", "label=norsk-ctl.role", "--filter", `publish=${port}`];
76
+ }
77
+ /** Force-remove the containers `docker ps <psArgs>` lists. Best-effort and
78
+ * synchronous (it must finish before the relaunch); a Docker that is absent or
79
+ * errors leaves the launch to fail with its own clearer message. Returns the
80
+ * ids it removed, for logging. */
81
+ function reap(psArgs, spawn) {
82
+ try {
83
+ const ps = spawn("docker", psArgs, { encoding: "utf8", timeout: 15_000 });
84
+ const ids = (ps.stdout ?? "")
85
+ .split("\n")
86
+ .map((s) => s.trim())
87
+ .filter(Boolean);
88
+ if (ids.length > 0)
89
+ spawn("docker", ["rm", "-f", ...ids], { encoding: "utf8", timeout: 30_000 });
90
+ return ids;
91
+ }
92
+ catch {
93
+ return [];
94
+ }
95
+ }
96
+ /** Reap every leftover container for one instance id (media, studio and source
97
+ * all carry the `norsk-ctl.instance` label). */
98
+ export function reapInstance(instanceId, spawn = spawnSync) {
99
+ return reap(instancePsArgs(instanceId), spawn);
100
+ }
101
+ /** Reap whatever norsk-ctl container holds a specific host port — the fallback
102
+ * when the holder belongs to a different instance, so the instance-id reap
103
+ * alone would not free it. */
104
+ export function reapPort(port, spawn = spawnSync) {
105
+ return reap(portPsArgs(port), spawn);
106
+ }
107
+ /**
108
+ * Run a launch, and on a host-port conflict reap the holder and retry ONCE.
109
+ *
110
+ * Once, not a loop: one retry clears a leaked holder or a lost race, and a
111
+ * second conflict means something is genuinely sitting on the port — where the
112
+ * launch's own message, naming the port, is more use than another attempt.
113
+ */
114
+ export async function launchWithPortConflictRetry(opts) {
115
+ const spawn = opts.spawn ?? spawnSync;
116
+ try {
117
+ return await opts.attempt();
118
+ }
119
+ catch (e) {
120
+ const text = e instanceof Error ? e.message : String(e);
121
+ if (!isPortConflict(text))
122
+ throw e;
123
+ const port = extractConflictPort(text);
124
+ const reaped = [...reapInstance(opts.instanceId, spawn), ...(port !== null ? reapPort(port, spawn) : [])];
125
+ opts.log?.(`host port conflict on ${port !== null ? `:${port}` : "an unnamed port"} launching ${opts.instanceId}; ` +
126
+ `reaped ${reaped.length} container(s) and retrying once`);
127
+ return await opts.attempt();
128
+ }
129
+ }
package/smoke.js CHANGED
@@ -25,6 +25,7 @@ import { cleanupDaemon, requireLicenseFile, runCli, startDaemon, } from "./daemo
25
25
  import { hashSlot } from "./harness-config.js";
26
26
  import { ctlSupportsNoPublish, ctlSupportsPublishDebugPorts, runnerContainerUser } from "./launch.js";
27
27
  import { pollUntil } from "./poll.js";
28
+ import { launchWithPortConflictRetry, reapInstance } from "./reap.js";
28
29
  import { nukePathAsRoot, reportForeignOwners } from "./root-nuke.js";
29
30
  import { readProxySecret, startSrtSources } from "./source-pump.js";
30
31
  import { applyTestHost, fetchComponentState, fetchComponents, fetchStreamMappings, isSrtListenerState, } from "./studio-state.js";
@@ -339,7 +340,15 @@ export async function runProductSmoke(slug, spec, deps = defaultSmokeDeps) {
339
340
  launchArgv.push("--publish-debug-ports");
340
341
  }
341
342
  launched = true;
342
- await cliOk(launchArgv);
343
+ // Same advisory-check race as the demo tier, same remedy: reap this id's
344
+ // orphans from a run that was killed before teardown, then retry once if the
345
+ // bind loses anyway (reap.ts).
346
+ reapInstance(instanceId);
347
+ await launchWithPortConflictRetry({
348
+ instanceId,
349
+ attempt: () => cliOk(launchArgv),
350
+ log: deps.log,
351
+ });
343
352
  await pollUntil(async () => {
344
353
  const r = await cli(["instance", "list"], { output: "json" });
345
354
  if (r.exitCode !== 0)
package/source-pump.d.ts CHANGED
@@ -42,16 +42,17 @@ export interface StartCameraSourceOptions {
42
42
  * (kept even when the fallback is used, so the handle is stable). Required
43
43
  * when the same asset must run multiple times within an instance. */
44
44
  name?: string;
45
- /** Sent as `x-norsk-proxy`. **Effectively required.** `readProxySecret`
46
- * reads it out of the store the daemon was started with; pass what that
47
- * returns.
45
+ /** Sent as `x-norsk-proxy`. **Usually leave this out**: when the daemon on
46
+ * `daemonPort` was started by this process's harness, the secret is resolved
47
+ * from its store automatically. Pass it only for a daemon the harness did
48
+ * not start — the demo driver's `reuse` policy, or a developer's own
49
+ * `norsk-ctl serve`.
48
50
  *
49
- * BREAKING for callers that omitted it (RFC 0003 D10b, ctl >= this
50
- * release): the daemon used to admit any loopback caller without a secret
51
- * while TLS was off, and that bypass is gone — a loopback caller is a host
52
- * admin, and a host admin can read the secret file. A harness or guide that
53
- * relied on it now fails on the guard's 307, which `guardRedirectError`
54
- * names rather than following. */
51
+ * RFC 0003 D10b: the daemon used to admit any loopback caller without a
52
+ * secret while TLS was off, and that bypass is gone — a loopback caller is a
53
+ * host admin, and a host admin can read the secret file. When neither this
54
+ * option nor the registry supplies one, the call meets the guard's 307,
55
+ * which `guardRedirectError` names rather than following. */
55
56
  proxySecret?: string;
56
57
  timeoutMs?: number;
57
58
  /** Test seams. */
package/source-pump.js CHANGED
@@ -18,6 +18,7 @@
18
18
  // file the daemon writes.
19
19
  import { existsSync, readFileSync } from "node:fs";
20
20
  import { basename, extname, join } from "node:path";
21
+ import { storeDirForDaemonPort } from "./daemon-stores.js";
21
22
  import { pollUntil } from "./poll.js";
22
23
  /** Where every daemon the harness starts writes its ingress secret: the store
23
24
  * root (the guard's stateDir is `<storeDir>` under NORSK_CTL_STORE_DIR). One
@@ -58,6 +59,12 @@ function assetFields(asset, fileExists) {
58
59
  }
59
60
  return { ...asset };
60
61
  }
62
+ /** The secret of whichever daemon this process started on `port`, or undefined
63
+ * for a daemon it did not start. */
64
+ function secretForDaemonPort(port) {
65
+ const storeDir = storeDirForDaemonPort(port);
66
+ return storeDir === undefined ? undefined : readProxySecret(storeDir);
67
+ }
61
68
  /** A 307/308 off `/api/*` is the daemon's guard, not a route that moved: the
62
69
  * API itself never redirects. */
63
70
  function isGuardRedirect(res) {
@@ -70,7 +77,9 @@ function guardRedirectError(method, res, sentSecret) {
70
77
  ? "The x-norsk-proxy secret that was sent is not the one this daemon holds — it is re-issued on start, so a " +
71
78
  "secret read before the daemon booted is stale (read it after `ready`)."
72
79
  : "No x-norsk-proxy secret was sent, and RFC 0003 D10b removed the loopback bypass, so a local caller needs " +
73
- "one too. Read it with `readProxySecret(storeDir)` and pass it as `proxySecret`.") +
80
+ "one too. This process started no daemon on that port, so there was no store to read one from: either start " +
81
+ "it through the harness's startDaemon, or read the secret yourself with `readProxySecret(storeDir)` and pass " +
82
+ "it as `proxySecret`.") +
74
83
  " Following this redirect instead is what turns a missing secret into an unrelated-looking ConnectionRefused or " +
75
84
  "a `Failed to parse JSON` on nginx's HTML.");
76
85
  }
@@ -81,7 +90,12 @@ export async function startCameraSource(opts) {
81
90
  const asset = opts.asset ?? { preset: opts.preset ?? "camera1" };
82
91
  const name = opts.name ?? defaultSourceName(asset);
83
92
  const fetchImpl = opts.fetch ?? ((url, init) => fetch(url, init));
84
- const headers = opts.proxySecret ? { "x-norsk-proxy": opts.proxySecret } : {};
93
+ // Resolved here, not at the call site: every caller has the port and almost
94
+ // none has the store dir, which is exactly why the explicit option went
95
+ // unpassed. Read now rather than when the daemon was registered — the guard
96
+ // issues the secret as the proxy comes up, after startDaemon returns.
97
+ const secret = opts.proxySecret ?? secretForDaemonPort(opts.daemonPort);
98
+ const headers = secret ? { "x-norsk-proxy": secret } : {};
85
99
  const base = `http://localhost:${opts.daemonPort}`;
86
100
  const body = {
87
101
  instanceId: opts.instanceId,