@norskvideo/ctl-test-harness 0.1.56 → 0.1.58

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/run.d.ts CHANGED
@@ -272,7 +272,11 @@ export declare class DemoSession {
272
272
  constructor(spec: DemoSpec, slug: string, cwd: string, deps: DemoDeps, opts: SessionOptions);
273
273
  get devUrl(): string;
274
274
  templateDir(name: string): string;
275
- /** What the real daemon's proxy demands on `/api/*`; absent on a private daemon. */
275
+ /** What this daemon's guard demands on `/api/*`. Read from the store, for
276
+ * EVERY policy: a private daemon arms the same guard and writes the same
277
+ * file, so the `reuse`-only read this replaced sent nothing on the policy
278
+ * that CI runs and had the pump 307'd (RFC 0003 D10b). Absent only until the
279
+ * daemon has issued one, or on a `test-unguarded` daemon. */
276
280
  proxySecret(): string | undefined;
277
281
  cli: (argv: string[], opts?: {
278
282
  output?: "json" | "yaml";
package/demo/run.js CHANGED
@@ -409,10 +409,12 @@ export class DemoSession {
409
409
  templateDir(name) {
410
410
  return join(this.storeDir, TEMPLATES_DIR, name);
411
411
  }
412
- /** What the real daemon's proxy demands on `/api/*`; absent on a private daemon. */
412
+ /** What this daemon's guard demands on `/api/*`. Read from the store, for
413
+ * EVERY policy: a private daemon arms the same guard and writes the same
414
+ * file, so the `reuse`-only read this replaced sent nothing on the policy
415
+ * that CI runs and had the pump 307'd (RFC 0003 D10b). Absent only until the
416
+ * daemon has issued one, or on a `test-unguarded` daemon. */
413
417
  proxySecret() {
414
- if (this.policy !== "reuse")
415
- return undefined;
416
418
  return this.deps.readFile(join(this.storeDir, PROXY_SECRET_FILE))?.trim() || undefined;
417
419
  }
418
420
  cli = async (argv, opts = {}) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-test-harness",
3
- "version": "0.1.56",
3
+ "version": "0.1.58",
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"
package/smoke.d.ts CHANGED
@@ -124,6 +124,9 @@ export interface SmokeDeps {
124
124
  instanceId: string;
125
125
  targets: readonly SrtPumpTarget[];
126
126
  timeoutMs?: number;
127
+ /** The daemon's ingress secret. Not optional in practice — see the
128
+ * `proxySecret` read in runProductSmoke. */
129
+ proxySecret?: string;
127
130
  }): Promise<SourceHandle[]>;
128
131
  stopSources(handles: SourceHandle[]): Promise<void>;
129
132
  readers: SmokeReaders;
@@ -150,6 +153,10 @@ export interface SmokeDeps {
150
153
  * is still up. Optional so an existing custom-deps consumer keeps working;
151
154
  * the default reads every container carrying the instance's label. */
152
155
  diagnose?(instanceId: string): string;
156
+ /** The ingress secret the daemon rooted at `storeDir` demands on `/api/*`.
157
+ * Optional so an existing custom-deps consumer keeps working; absent = read
158
+ * the store's `proxy-secret` file. */
159
+ proxySecret?(storeDir: string): string | undefined;
153
160
  /** GET a manifest-declared route through the daemon's product proxy — the
154
161
  * same URL ctl's product page links to. */
155
162
  fetchProductRoute(opts: {
package/smoke.js CHANGED
@@ -17,7 +17,7 @@
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 { existsSync, readFileSync, writeFileSync } from "node:fs";
20
+ import { existsSync, writeFileSync } from "node:fs";
21
21
  import { join } from "node:path";
22
22
  import { dumpInstanceContainerLogs, preserveEngineLogs } from "./container-logs.js";
23
23
  import { ensureRunnerOnNetwork, netReachMode, STUDIO_INTERNAL_PORT, studioBaseFrom, } from "./container-net.js";
@@ -26,7 +26,7 @@ import { hashSlot } from "./harness-config.js";
26
26
  import { ctlSupportsNoPublish, ctlSupportsPublishDebugPorts, runnerContainerUser } from "./launch.js";
27
27
  import { pollUntil } from "./poll.js";
28
28
  import { nukePathAsRoot, reportForeignOwners } from "./root-nuke.js";
29
- import { startSrtSources } from "./source-pump.js";
29
+ import { readProxySecret, startSrtSources } from "./source-pump.js";
30
30
  import { applyTestHost, fetchComponentState, fetchComponents, fetchStreamMappings, isSrtListenerState, } from "./studio-state.js";
31
31
  import { makeStoreDir, TEST_TMP_BASE } from "./temp-dir.js";
32
32
  // Below 32768: the band is a reservation, and Linux hands 32768-60999 out to
@@ -70,6 +70,7 @@ export const defaultSmokeDeps = {
70
70
  cleanup: (opts) => cleanupDaemon({ ...opts, stopProxy: async () => { }, proxy: false }),
71
71
  nukeStoreAsRoot: (storeDir) => nukePathAsRoot(storeDir, TEST_TMP_BASE),
72
72
  storeExists: (storeDir) => existsSync(storeDir),
73
+ proxySecret: (storeDir) => readProxySecret(storeDir),
73
74
  ensureNetwork: () => ensureRunnerOnNetwork(),
74
75
  supportsNoPublish: ctlSupportsNoPublish,
75
76
  supportsPublishDebugPorts: ctlSupportsPublishDebugPorts,
@@ -78,8 +79,7 @@ export const defaultSmokeDeps = {
78
79
  fetchProductRoute: async ({ daemonPort, product, route, storeDir }) => {
79
80
  // The daemon fronts the product proxy with the same auth guard as /api/*,
80
81
  // so the secret travels when the store has one (source-pump's rule).
81
- const secretFile = join(storeDir, "proxy-secret");
82
- const secret = existsSync(secretFile) ? readFileSync(secretFile, "utf8").trim() : "";
82
+ const secret = readProxySecret(storeDir);
83
83
  const url = `http://localhost:${daemonPort}/products/${encodeURIComponent(product)}${route.startsWith("/") ? route : `/${route}`}`;
84
84
  const res = await fetch(url, {
85
85
  headers: secret ? { "x-norsk-proxy": secret } : {},
@@ -360,11 +360,22 @@ export async function runProductSmoke(slug, spec, deps = defaultSmokeDeps) {
360
360
  }
361
361
  deps.log(`${slug}: not-yet phase passed for ${phased.length} observation(s)`);
362
362
  if (!vacuous) {
363
+ // The secret is read HERE, not at journey start: the daemon issues it in
364
+ // guard.configure() as the proxy comes up, so a read taken before `ready`
365
+ // finds no file and sends nothing. Sending nothing is not a soft failure
366
+ // — RFC 0003 D10b guards /api/* for loopback callers too, so the pump is
367
+ // 307'd. Said once, because a store with no secret is worth seeing in the
368
+ // log before the 307 it causes.
369
+ const secret = (deps.proxySecret ?? readProxySecret)(storeDir);
370
+ if (secret === undefined) {
371
+ deps.log(`${slug}: no proxy-secret in the store — the source pump will go unsecreted, and the guard 307s it`);
372
+ }
363
373
  handles = await deps.startSources({
364
374
  daemonPort: ports.daemonPort,
365
375
  instanceId,
366
376
  targets: spec.sources,
367
377
  timeoutMs: timeouts.observeMs,
378
+ ...(secret !== undefined ? { proxySecret: secret } : {}),
368
379
  });
369
380
  }
370
381
  for (const o of spec.observe) {
package/source-pump.d.ts CHANGED
@@ -1,3 +1,9 @@
1
+ /** The secret a daemon rooted at `storeDir` demands on `/api/*`, or undefined
2
+ * when the file is not there (a `test-unguarded` daemon, or a store that no
3
+ * daemon has armed yet). Absent is NOT the same as "no guard": every
4
+ * self-hosted daemon writes this file, so an unexpected undefined here is why
5
+ * a call 307s. */
6
+ export declare function readProxySecret(storeDir: string): string | undefined;
1
7
  export interface SourceHandle {
2
8
  name: string;
3
9
  stop(): Promise<void>;
@@ -36,15 +42,17 @@ export interface StartCameraSourceOptions {
36
42
  * (kept even when the fallback is used, so the handle is stable). Required
37
43
  * when the same asset must run multiple times within an instance. */
38
44
  name?: string;
39
- /** Sent as `x-norsk-proxy`. **Effectively required.** The daemon writes it
40
- * to `<stateDir>/proxy-secret` (`<storeDir>/proxy-secret` under
41
- * NORSK_CTL_STORE_DIR); read it from there and pass it.
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`.
42
50
  *
43
- * BREAKING for callers that omitted it (RFC 0003 D10b, ctl >= this
44
- * release): the daemon used to admit any loopback caller without a secret
45
- * while TLS was off, and that bypass is gone — a loopback caller is a host
46
- * admin, and a host admin can read the secret file. A harness or guide that
47
- * relied on it now fails with `POST /api/sources failed (307)`. */
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. */
48
56
  proxySecret?: string;
49
57
  timeoutMs?: number;
50
58
  /** Test seams. */
package/source-pump.js CHANGED
@@ -13,10 +13,29 @@
13
13
  //
14
14
  // PASS `proxySecret`. RFC 0003 D10b removed the daemon's loopback bypass, so
15
15
  // a call without it now 307s where it used to be admitted. See the field's
16
- // docblock for where to read the secret from.
17
- import { existsSync } from "node:fs";
18
- import { basename, extname } from "node:path";
16
+ // docblock for where to read the secret from. `readProxySecret` is that read;
17
+ // the smoke and demo drivers both go through it, so neither can drift from the
18
+ // file the daemon writes.
19
+ import { existsSync, readFileSync } from "node:fs";
20
+ import { basename, extname, join } from "node:path";
21
+ import { storeDirForDaemonPort } from "./daemon-stores.js";
19
22
  import { pollUntil } from "./poll.js";
23
+ /** Where every daemon the harness starts writes its ingress secret: the store
24
+ * root (the guard's stateDir is `<storeDir>` under NORSK_CTL_STORE_DIR). One
25
+ * reader for the whole harness — the three callers that spelled the filename
26
+ * themselves are what let the smoke driver quietly stop sending it. */
27
+ const PROXY_SECRET_FILE = "proxy-secret";
28
+ /** The secret a daemon rooted at `storeDir` demands on `/api/*`, or undefined
29
+ * when the file is not there (a `test-unguarded` daemon, or a store that no
30
+ * daemon has armed yet). Absent is NOT the same as "no guard": every
31
+ * self-hosted daemon writes this file, so an unexpected undefined here is why
32
+ * a call 307s. */
33
+ export function readProxySecret(storeDir) {
34
+ const path = join(storeDir, PROXY_SECRET_FILE);
35
+ if (!existsSync(path))
36
+ return undefined;
37
+ return readFileSync(path, "utf8").trim() || undefined;
38
+ }
20
39
  /** Mirrors the daemon's default source name (shared/src/source-name.ts) so a
21
40
  * caller can predict the identity it will poll and stop by. The harness
22
41
  * always SENDS the name it derived, so the two cannot drift. */
@@ -40,6 +59,30 @@ function assetFields(asset, fileExists) {
40
59
  }
41
60
  return { ...asset };
42
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
+ }
68
+ /** A 307/308 off `/api/*` is the daemon's guard, not a route that moved: the
69
+ * API itself never redirects. */
70
+ function isGuardRedirect(res) {
71
+ return res.status === 307 || res.status === 308;
72
+ }
73
+ function guardRedirectError(method, res, sentSecret) {
74
+ const where = res.headers.get("location");
75
+ return new Error(`${method} /api/sources was refused by the daemon's ingress guard (${res.status} -> ${where ?? "the ingress port"}). ` +
76
+ (sentSecret
77
+ ? "The x-norsk-proxy secret that was sent is not the one this daemon holds — it is re-issued on start, so a " +
78
+ "secret read before the daemon booted is stale (read it after `ready`)."
79
+ : "No x-norsk-proxy secret was sent, and RFC 0003 D10b removed the loopback bypass, so a local caller needs " +
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`.") +
83
+ " Following this redirect instead is what turns a missing secret into an unrelated-looking ConnectionRefused or " +
84
+ "a `Failed to parse JSON` on nginx's HTML.");
85
+ }
43
86
  export async function startCameraSource(opts) {
44
87
  if (opts.preset !== undefined && opts.asset !== undefined) {
45
88
  throw new Error("startCameraSource: pass either `preset` or `asset`, not both");
@@ -47,7 +90,12 @@ export async function startCameraSource(opts) {
47
90
  const asset = opts.asset ?? { preset: opts.preset ?? "camera1" };
48
91
  const name = opts.name ?? defaultSourceName(asset);
49
92
  const fetchImpl = opts.fetch ?? ((url, init) => fetch(url, init));
50
- 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 } : {};
51
99
  const base = `http://localhost:${opts.daemonPort}`;
52
100
  const body = {
53
101
  instanceId: opts.instanceId,
@@ -61,16 +109,32 @@ export async function startCameraSource(opts) {
61
109
  method: "POST",
62
110
  headers: { ...headers, "Content-Type": "application/json" },
63
111
  body: JSON.stringify(body),
112
+ // MANUAL. The guard answers an unsecreted /api/* with a 307 to the ingress
113
+ // port, and a followed redirect hides which call was refused: on a runner
114
+ // that cannot reach that port it surfaced as `ConnectionRefused` naming
115
+ // THIS url (bun reports the original), and on one that can, as nginx's
116
+ // HTML failing `r.json()` 120s later. Neither says "guard" or "secret",
117
+ // which is how a missing proxySecret stayed unread for days.
118
+ redirect: "manual",
64
119
  signal: AbortSignal.timeout(10_000),
65
120
  });
121
+ if (isGuardRedirect(startRes))
122
+ throw guardRedirectError("POST", startRes, opts.proxySecret);
66
123
  if (!startRes.ok) {
67
124
  throw new Error(`POST /api/sources failed (${startRes.status}): ${await startRes.text()}`);
68
125
  }
69
126
  await pollUntil(async () => {
70
127
  const r = await fetchImpl(`${base}/api/sources?instanceId=${encodeURIComponent(opts.instanceId)}`, {
71
128
  headers,
129
+ redirect: "manual",
72
130
  signal: AbortSignal.timeout(5000),
73
131
  });
132
+ // Thrown, not `return false`: a guarded-out poll is a permanent
133
+ // condition, and pollUntil carries the last error into its timeout
134
+ // message. Returning false spends the whole window and then says only
135
+ // that the source never ran.
136
+ if (isGuardRedirect(r))
137
+ throw guardRedirectError("GET", r, opts.proxySecret);
74
138
  if (!r.ok)
75
139
  return false;
76
140
  // GET /api/sources returns a bare array of SampleSource — not { sources: [...] }.
@@ -88,6 +152,10 @@ export async function startCameraSource(opts) {
88
152
  await fetchImpl(`${base}/api/sources/${encodeURIComponent(opts.instanceId)}/${encodeURIComponent(name)}`, {
89
153
  method: "DELETE",
90
154
  headers,
155
+ // Swallowed either way (stopAll sweeps the instance), but not chased:
156
+ // following the guard's redirect to a port this process may not reach
157
+ // spends the full 10s timeout on every source at teardown.
158
+ redirect: "manual",
91
159
  signal: AbortSignal.timeout(10_000),
92
160
  }).catch(() => { });
93
161
  },