@norskvideo/ctl-test-harness 0.1.56 → 0.1.57
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 +5 -1
- package/demo/run.js +5 -3
- package/package.json +1 -1
- package/smoke.d.ts +7 -0
- package/smoke.js +15 -4
- package/source-pump.d.ts +11 -4
- package/source-pump.js +57 -3
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
|
|
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
|
|
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
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,
|
|
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
|
|
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,16 @@ 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.**
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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.
|
|
42
48
|
*
|
|
43
49
|
* BREAKING for callers that omitted it (RFC 0003 D10b, ctl >= this
|
|
44
50
|
* release): the daemon used to admit any loopback caller without a secret
|
|
45
51
|
* while TLS was off, and that bypass is gone — a loopback caller is a host
|
|
46
52
|
* admin, and a host admin can read the secret file. A harness or guide that
|
|
47
|
-
* relied on it now fails
|
|
53
|
+
* relied on it now fails on the guard's 307, which `guardRedirectError`
|
|
54
|
+
* names rather than following. */
|
|
48
55
|
proxySecret?: string;
|
|
49
56
|
timeoutMs?: number;
|
|
50
57
|
/** Test seams. */
|
package/source-pump.js
CHANGED
|
@@ -13,10 +13,28 @@
|
|
|
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
|
-
|
|
18
|
-
|
|
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";
|
|
19
21
|
import { pollUntil } from "./poll.js";
|
|
22
|
+
/** Where every daemon the harness starts writes its ingress secret: the store
|
|
23
|
+
* root (the guard's stateDir is `<storeDir>` under NORSK_CTL_STORE_DIR). One
|
|
24
|
+
* reader for the whole harness — the three callers that spelled the filename
|
|
25
|
+
* themselves are what let the smoke driver quietly stop sending it. */
|
|
26
|
+
const PROXY_SECRET_FILE = "proxy-secret";
|
|
27
|
+
/** The secret a daemon rooted at `storeDir` demands on `/api/*`, or undefined
|
|
28
|
+
* when the file is not there (a `test-unguarded` daemon, or a store that no
|
|
29
|
+
* daemon has armed yet). Absent is NOT the same as "no guard": every
|
|
30
|
+
* self-hosted daemon writes this file, so an unexpected undefined here is why
|
|
31
|
+
* a call 307s. */
|
|
32
|
+
export function readProxySecret(storeDir) {
|
|
33
|
+
const path = join(storeDir, PROXY_SECRET_FILE);
|
|
34
|
+
if (!existsSync(path))
|
|
35
|
+
return undefined;
|
|
36
|
+
return readFileSync(path, "utf8").trim() || undefined;
|
|
37
|
+
}
|
|
20
38
|
/** Mirrors the daemon's default source name (shared/src/source-name.ts) so a
|
|
21
39
|
* caller can predict the identity it will poll and stop by. The harness
|
|
22
40
|
* always SENDS the name it derived, so the two cannot drift. */
|
|
@@ -40,6 +58,22 @@ function assetFields(asset, fileExists) {
|
|
|
40
58
|
}
|
|
41
59
|
return { ...asset };
|
|
42
60
|
}
|
|
61
|
+
/** A 307/308 off `/api/*` is the daemon's guard, not a route that moved: the
|
|
62
|
+
* API itself never redirects. */
|
|
63
|
+
function isGuardRedirect(res) {
|
|
64
|
+
return res.status === 307 || res.status === 308;
|
|
65
|
+
}
|
|
66
|
+
function guardRedirectError(method, res, sentSecret) {
|
|
67
|
+
const where = res.headers.get("location");
|
|
68
|
+
return new Error(`${method} /api/sources was refused by the daemon's ingress guard (${res.status} -> ${where ?? "the ingress port"}). ` +
|
|
69
|
+
(sentSecret
|
|
70
|
+
? "The x-norsk-proxy secret that was sent is not the one this daemon holds — it is re-issued on start, so a " +
|
|
71
|
+
"secret read before the daemon booted is stale (read it after `ready`)."
|
|
72
|
+
: "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`.") +
|
|
74
|
+
" Following this redirect instead is what turns a missing secret into an unrelated-looking ConnectionRefused or " +
|
|
75
|
+
"a `Failed to parse JSON` on nginx's HTML.");
|
|
76
|
+
}
|
|
43
77
|
export async function startCameraSource(opts) {
|
|
44
78
|
if (opts.preset !== undefined && opts.asset !== undefined) {
|
|
45
79
|
throw new Error("startCameraSource: pass either `preset` or `asset`, not both");
|
|
@@ -61,16 +95,32 @@ export async function startCameraSource(opts) {
|
|
|
61
95
|
method: "POST",
|
|
62
96
|
headers: { ...headers, "Content-Type": "application/json" },
|
|
63
97
|
body: JSON.stringify(body),
|
|
98
|
+
// MANUAL. The guard answers an unsecreted /api/* with a 307 to the ingress
|
|
99
|
+
// port, and a followed redirect hides which call was refused: on a runner
|
|
100
|
+
// that cannot reach that port it surfaced as `ConnectionRefused` naming
|
|
101
|
+
// THIS url (bun reports the original), and on one that can, as nginx's
|
|
102
|
+
// HTML failing `r.json()` 120s later. Neither says "guard" or "secret",
|
|
103
|
+
// which is how a missing proxySecret stayed unread for days.
|
|
104
|
+
redirect: "manual",
|
|
64
105
|
signal: AbortSignal.timeout(10_000),
|
|
65
106
|
});
|
|
107
|
+
if (isGuardRedirect(startRes))
|
|
108
|
+
throw guardRedirectError("POST", startRes, opts.proxySecret);
|
|
66
109
|
if (!startRes.ok) {
|
|
67
110
|
throw new Error(`POST /api/sources failed (${startRes.status}): ${await startRes.text()}`);
|
|
68
111
|
}
|
|
69
112
|
await pollUntil(async () => {
|
|
70
113
|
const r = await fetchImpl(`${base}/api/sources?instanceId=${encodeURIComponent(opts.instanceId)}`, {
|
|
71
114
|
headers,
|
|
115
|
+
redirect: "manual",
|
|
72
116
|
signal: AbortSignal.timeout(5000),
|
|
73
117
|
});
|
|
118
|
+
// Thrown, not `return false`: a guarded-out poll is a permanent
|
|
119
|
+
// condition, and pollUntil carries the last error into its timeout
|
|
120
|
+
// message. Returning false spends the whole window and then says only
|
|
121
|
+
// that the source never ran.
|
|
122
|
+
if (isGuardRedirect(r))
|
|
123
|
+
throw guardRedirectError("GET", r, opts.proxySecret);
|
|
74
124
|
if (!r.ok)
|
|
75
125
|
return false;
|
|
76
126
|
// GET /api/sources returns a bare array of SampleSource — not { sources: [...] }.
|
|
@@ -88,6 +138,10 @@ export async function startCameraSource(opts) {
|
|
|
88
138
|
await fetchImpl(`${base}/api/sources/${encodeURIComponent(opts.instanceId)}/${encodeURIComponent(name)}`, {
|
|
89
139
|
method: "DELETE",
|
|
90
140
|
headers,
|
|
141
|
+
// Swallowed either way (stopAll sweeps the instance), but not chased:
|
|
142
|
+
// following the guard's redirect to a port this process may not reach
|
|
143
|
+
// spends the full 10s timeout on every source at teardown.
|
|
144
|
+
redirect: "manual",
|
|
91
145
|
signal: AbortSignal.timeout(10_000),
|
|
92
146
|
}).catch(() => { });
|
|
93
147
|
},
|