@norskvideo/ctl-test-harness 0.1.74 → 0.1.76
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/compose-advancing.js +2 -1
- package/container-net.d.ts +46 -2
- package/container-net.js +131 -10
- package/daemon.d.ts +4 -0
- package/daemon.js +5 -0
- package/demo/harness.js +1 -0
- package/demo/run.d.ts +6 -0
- package/demo/run.js +19 -1
- package/demo/spec.d.ts +2 -1
- package/package.json +1 -1
- package/smoke.d.ts +37 -0
- package/smoke.js +40 -4
- package/studio-state.js +9 -6
package/compose-advancing.js
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
// -> nodes[].mediaIn). Playout's scripts/ensure-sources.sh:52-64 as TypeScript
|
|
7
7
|
// (05-demo s7 step 2), pure over an injected fetch so it is unit-tested and
|
|
8
8
|
// reachable through whichever base the caller has: the daemon proxy's
|
|
9
|
-
// `/instance/<id>/visualiser`, or `<id>-media-1:6791/visualiser`
|
|
9
|
+
// `/instance/<id>/visualiser`, or `<id>-media-1:6791/visualiser` from the
|
|
10
|
+
// instance network `<id>_default` (media is on no other).
|
|
10
11
|
export const DEFAULT_COMPOSE_NODE = "video_compose";
|
|
11
12
|
const DEFAULT_SETTLE_MS = 6000;
|
|
12
13
|
/** The highest mediaIn across every node of that name; 0 when none. */
|
package/container-net.d.ts
CHANGED
|
@@ -18,7 +18,9 @@ export declare const authority: (e: Endpoint) => string;
|
|
|
18
18
|
export declare function productServiceHost(instanceId: string, service: string): string;
|
|
19
19
|
export interface ResolveOpts {
|
|
20
20
|
instanceId: string;
|
|
21
|
-
service: "studio"
|
|
21
|
+
/** A compose service name: "studio" and "media" for a Studio product, or a
|
|
22
|
+
* direct-SDK product's own control service (the smoke `surface`). */
|
|
23
|
+
service: string;
|
|
22
24
|
/** Port the listener binds INSIDE the container (studio 8000, media SRT its
|
|
23
25
|
* own configured port). Used in `direct` mode. */
|
|
24
26
|
internalPort: number;
|
|
@@ -116,11 +118,13 @@ export declare function ownContainerId(opts?: {
|
|
|
116
118
|
isDockerObject?: (id: string) => boolean;
|
|
117
119
|
}): string | null;
|
|
118
120
|
/** `docker network connect` is not idempotent — a second attach errors. Treat
|
|
119
|
-
* the "already attached" variants as success so setup can call this blindly.
|
|
121
|
+
* the "already attached" variants as success so setup can call this blindly.
|
|
122
|
+
* Twin of norsk-ctl backend/src/docker/container-ops.ts isAlreadyOnNetwork. */
|
|
120
123
|
export declare function isAlreadyOnNetworkError(stderr: string): boolean;
|
|
121
124
|
type RunResult = {
|
|
122
125
|
status: number | null;
|
|
123
126
|
stderr: string;
|
|
127
|
+
stdout?: string;
|
|
124
128
|
};
|
|
125
129
|
type Runner = (cmd: string, args: string[]) => RunResult;
|
|
126
130
|
/** Idempotently join the test container to norsk-net so `direct` service DNS
|
|
@@ -137,4 +141,44 @@ export declare function ensureRunnerOnNetwork(opts?: {
|
|
|
137
141
|
detect?: Parameters<typeof ownContainerId>[0];
|
|
138
142
|
run?: Runner;
|
|
139
143
|
}): "attached" | "already" | "not-in-container" | "failed";
|
|
144
|
+
/** The compose project is the instance id, so its network is `<id>_default`.
|
|
145
|
+
* Mirrors norsk-ctl's instanceNetworkName. */
|
|
146
|
+
export declare function instanceNetworkName(instanceId: string): string;
|
|
147
|
+
/** docker's wording for a disconnect that has nothing to do. Twin of norsk-ctl
|
|
148
|
+
* backend/src/docker/container-ops.ts isNotOnNetwork: change both together. */
|
|
149
|
+
export declare function isNotOnNetworkError(stderr: string): boolean;
|
|
150
|
+
export declare class InstanceNetworks {
|
|
151
|
+
private readonly opts;
|
|
152
|
+
private readonly ids;
|
|
153
|
+
private resolvedId;
|
|
154
|
+
constructor(opts?: {
|
|
155
|
+
/** Explicit id (tests). Omitted -> resolved once by `ownContainerId`. */
|
|
156
|
+
containerId?: string | null;
|
|
157
|
+
run?: Runner;
|
|
158
|
+
});
|
|
159
|
+
private containerId;
|
|
160
|
+
/** Join the runner to the instance's network. Connects on every call rather
|
|
161
|
+
* than trusting the record: an instance deleted and relaunched under the
|
|
162
|
+
* same id has a new network the runner is not on. One `docker network
|
|
163
|
+
* connect` costs the same as the inspect that checking the record would. */
|
|
164
|
+
join(instanceId: string): "attached" | "already" | "not-in-container" | "failed";
|
|
165
|
+
/** Leave a network joined here. Never throws: teardown carries on regardless.
|
|
166
|
+
* `force` leaves even if this process did not record the join (a later
|
|
167
|
+
* `demo down` in a fresh process). */
|
|
168
|
+
leave(instanceId: string, opts?: {
|
|
169
|
+
force?: boolean;
|
|
170
|
+
}): void;
|
|
171
|
+
/** The runner's IP on the instance network, for something media dials back
|
|
172
|
+
* (a capture server). Joins first. Null off-container or on failure. */
|
|
173
|
+
addressOn(instanceId: string): string | null;
|
|
174
|
+
leaveAll(): void;
|
|
175
|
+
joined(): string[];
|
|
176
|
+
}
|
|
177
|
+
/** The process-wide registry the address helpers join through. */
|
|
178
|
+
export declare function currentInstanceNetworks(): InstanceNetworks;
|
|
179
|
+
/** Swap the registry (tests); returns a restore function. */
|
|
180
|
+
export declare function useInstanceNetworks(next: InstanceNetworks): () => void;
|
|
181
|
+
/** Make `<id>-media-1` resolvable in direct mode. Publish mode reaches media by
|
|
182
|
+
* published port and needs nothing. */
|
|
183
|
+
export declare function reachInstanceMedia(instanceId: string, mode?: NetReachMode): void;
|
|
140
184
|
export {};
|
package/container-net.js
CHANGED
|
@@ -23,12 +23,14 @@
|
|
|
23
23
|
// makes for media HTTP — generalised to the studio-control + SRT-egress ports
|
|
24
24
|
// that are the actual colliders.
|
|
25
25
|
//
|
|
26
|
-
// THE ONE CONSTRAINT: container-DNS reachability needs the test container
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
26
|
+
// THE ONE CONSTRAINT: container-DNS reachability needs the test container on
|
|
27
|
+
// the target's network (bridge DNS is per-network): norsk-net for studio, the
|
|
28
|
+
// instance network `<id>_default` for media (see "Instance networks" below).
|
|
29
|
+
// On Docker Desktop / OrbStack the bridge is hidden inside a VM and is not
|
|
30
|
+
// routable from the host at all, so the direct path can't work there — those
|
|
31
|
+
// runs MUST keep publishing. Hence the mode switch: `direct` on the Linux DooD
|
|
32
|
+
// CI box, `publish` (the default) everywhere else. Nothing here changes
|
|
33
|
+
// behaviour until a caller opts a suite into `direct`.
|
|
32
34
|
import { spawnSync } from "node:child_process";
|
|
33
35
|
import { existsSync, readFileSync } from "node:fs";
|
|
34
36
|
import { hostname } from "node:os";
|
|
@@ -83,6 +85,7 @@ export function studioApiBase(o) {
|
|
|
83
85
|
* `<id>-media-1:8080`. Mirrors studio-state.mediaDirectUrl, generalised for the
|
|
84
86
|
* direct-pull capture path. */
|
|
85
87
|
export function mediaHttpBase(o) {
|
|
88
|
+
reachInstanceMedia(o.instanceId, o.mode);
|
|
86
89
|
const e = resolveEndpoint({
|
|
87
90
|
instanceId: o.instanceId,
|
|
88
91
|
service: "media",
|
|
@@ -100,6 +103,7 @@ export function mediaHttpBase(o) {
|
|
|
100
103
|
* and `publishPort` are the same band value today — going direct is what later
|
|
101
104
|
* lets that band be dropped entirely. */
|
|
102
105
|
export function srtEgressUrl(o) {
|
|
106
|
+
reachInstanceMedia(o.instanceId, o.mode);
|
|
103
107
|
const e = resolveEndpoint({
|
|
104
108
|
instanceId: o.instanceId,
|
|
105
109
|
service: "media",
|
|
@@ -127,8 +131,8 @@ export function studioBaseFrom(studio, publishHost, mode) {
|
|
|
127
131
|
});
|
|
128
132
|
}
|
|
129
133
|
// ── Runner network attach ───────────────────────────────────────────────────
|
|
130
|
-
// `direct` DNS only resolves if the test container is on norsk-net.
|
|
131
|
-
// helpers attach it idempotently; all pure/injectable so the logic is tested
|
|
134
|
+
// `direct` DNS for studio only resolves if the test container is on norsk-net.
|
|
135
|
+
// These helpers attach it idempotently; all pure/injectable so the logic is tested
|
|
132
136
|
// without touching docker.
|
|
133
137
|
/** The 64-hex container id from a /proc/self/cgroup dump, or null on a bare host.
|
|
134
138
|
* In a container the id also appears as the hostname, but cgroup is present in
|
|
@@ -180,13 +184,14 @@ export function ownContainerId(opts = {}) {
|
|
|
180
184
|
});
|
|
181
185
|
}
|
|
182
186
|
/** `docker network connect` is not idempotent — a second attach errors. Treat
|
|
183
|
-
* the "already attached" variants as success so setup can call this blindly.
|
|
187
|
+
* the "already attached" variants as success so setup can call this blindly.
|
|
188
|
+
* Twin of norsk-ctl backend/src/docker/container-ops.ts isAlreadyOnNetwork. */
|
|
184
189
|
export function isAlreadyOnNetworkError(stderr) {
|
|
185
190
|
return /already exists in network|is already connected|endpoint with name .* already exists/i.test(stderr);
|
|
186
191
|
}
|
|
187
192
|
const defaultRunner = (cmd, args) => {
|
|
188
193
|
const r = spawnSync(cmd, args, { encoding: "utf-8" });
|
|
189
|
-
return { status: r.status, stderr: r.stderr ?? "" };
|
|
194
|
+
return { status: r.status, stderr: r.stderr ?? "", stdout: r.stdout ?? "" };
|
|
190
195
|
};
|
|
191
196
|
/** Idempotently join the test container to norsk-net so `direct` service DNS
|
|
192
197
|
* resolves. Returns a verdict rather than throwing: `not-in-container` (nothing
|
|
@@ -205,3 +210,119 @@ export function ensureRunnerOnNetwork(opts = {}) {
|
|
|
205
210
|
return "already";
|
|
206
211
|
return "failed";
|
|
207
212
|
}
|
|
213
|
+
// ── Instance networks ───────────────────────────────────────────────────────
|
|
214
|
+
// ctl attaches media to its instance's own compose network only, not to
|
|
215
|
+
// norsk-net: Docker Desktop's port forwarder delivers published UDP to either IP
|
|
216
|
+
// of a dual-homed container, and an SRT listener's reply then leaves by the
|
|
217
|
+
// other interface and is dropped (norsk-ctl docs/product-template-format.md,
|
|
218
|
+
// Networking). So `<id>-media-1` resolves only from `<id>_default`, and direct
|
|
219
|
+
// mode joins the runner to that network whenever it addresses an instance's
|
|
220
|
+
// media.
|
|
221
|
+
//
|
|
222
|
+
// The runner must leave again BEFORE the instance is deleted: a foreign
|
|
223
|
+
// endpoint keeps `compose down` from removing the network, which then leaks.
|
|
224
|
+
// cleanupDaemon does that for every network joined here.
|
|
225
|
+
/** The compose project is the instance id, so its network is `<id>_default`.
|
|
226
|
+
* Mirrors norsk-ctl's instanceNetworkName. */
|
|
227
|
+
export function instanceNetworkName(instanceId) {
|
|
228
|
+
return `${instanceId}_default`;
|
|
229
|
+
}
|
|
230
|
+
/** docker's wording for a disconnect that has nothing to do. Twin of norsk-ctl
|
|
231
|
+
* backend/src/docker/container-ops.ts isNotOnNetwork: change both together. */
|
|
232
|
+
export function isNotOnNetworkError(stderr) {
|
|
233
|
+
return /is not connected to (the )?network|network .* not found|no such network/i.test(stderr);
|
|
234
|
+
}
|
|
235
|
+
export class InstanceNetworks {
|
|
236
|
+
opts;
|
|
237
|
+
ids = new Set();
|
|
238
|
+
resolvedId;
|
|
239
|
+
constructor(opts = {}) {
|
|
240
|
+
this.opts = opts;
|
|
241
|
+
}
|
|
242
|
+
containerId() {
|
|
243
|
+
if (this.opts.containerId !== undefined)
|
|
244
|
+
return this.opts.containerId;
|
|
245
|
+
if (this.resolvedId === undefined)
|
|
246
|
+
this.resolvedId = ownContainerId();
|
|
247
|
+
return this.resolvedId;
|
|
248
|
+
}
|
|
249
|
+
/** Join the runner to the instance's network. Connects on every call rather
|
|
250
|
+
* than trusting the record: an instance deleted and relaunched under the
|
|
251
|
+
* same id has a new network the runner is not on. One `docker network
|
|
252
|
+
* connect` costs the same as the inspect that checking the record would. */
|
|
253
|
+
join(instanceId) {
|
|
254
|
+
const verdict = ensureRunnerOnNetwork({
|
|
255
|
+
network: instanceNetworkName(instanceId),
|
|
256
|
+
containerId: this.containerId(),
|
|
257
|
+
...(this.opts.run ? { run: this.opts.run } : {}),
|
|
258
|
+
});
|
|
259
|
+
if (verdict === "attached" || verdict === "already")
|
|
260
|
+
this.ids.add(instanceId);
|
|
261
|
+
return verdict;
|
|
262
|
+
}
|
|
263
|
+
/** Leave a network joined here. Never throws: teardown carries on regardless.
|
|
264
|
+
* `force` leaves even if this process did not record the join (a later
|
|
265
|
+
* `demo down` in a fresh process). */
|
|
266
|
+
leave(instanceId, opts = {}) {
|
|
267
|
+
if (!this.ids.delete(instanceId) && !opts.force)
|
|
268
|
+
return;
|
|
269
|
+
const id = this.containerId();
|
|
270
|
+
if (!id)
|
|
271
|
+
return;
|
|
272
|
+
const r = (this.opts.run ?? defaultRunner)("docker", [
|
|
273
|
+
"network",
|
|
274
|
+
"disconnect",
|
|
275
|
+
instanceNetworkName(instanceId),
|
|
276
|
+
id,
|
|
277
|
+
]);
|
|
278
|
+
if (r.status !== 0 && !isNotOnNetworkError(r.stderr))
|
|
279
|
+
console.error(`leaving ${instanceNetworkName(instanceId)} failed: ${r.stderr.trim()}`);
|
|
280
|
+
}
|
|
281
|
+
/** The runner's IP on the instance network, for something media dials back
|
|
282
|
+
* (a capture server). Joins first. Null off-container or on failure. */
|
|
283
|
+
addressOn(instanceId) {
|
|
284
|
+
const verdict = this.join(instanceId);
|
|
285
|
+
if (verdict === "not-in-container" || verdict === "failed")
|
|
286
|
+
return null;
|
|
287
|
+
const id = this.containerId();
|
|
288
|
+
if (!id)
|
|
289
|
+
return null;
|
|
290
|
+
const net = instanceNetworkName(instanceId);
|
|
291
|
+
const r = (this.opts.run ?? defaultRunner)("docker", [
|
|
292
|
+
"inspect",
|
|
293
|
+
"-f",
|
|
294
|
+
`{{with index .NetworkSettings.Networks "${net}"}}{{.IPAddress}}{{end}}`,
|
|
295
|
+
id,
|
|
296
|
+
]);
|
|
297
|
+
const ip = (r.stdout ?? "").trim();
|
|
298
|
+
return r.status === 0 && ip ? ip : null;
|
|
299
|
+
}
|
|
300
|
+
leaveAll() {
|
|
301
|
+
for (const id of [...this.ids])
|
|
302
|
+
this.leave(id);
|
|
303
|
+
}
|
|
304
|
+
joined() {
|
|
305
|
+
return [...this.ids];
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
let instanceNetworks = new InstanceNetworks();
|
|
309
|
+
/** The process-wide registry the address helpers join through. */
|
|
310
|
+
export function currentInstanceNetworks() {
|
|
311
|
+
return instanceNetworks;
|
|
312
|
+
}
|
|
313
|
+
/** Swap the registry (tests); returns a restore function. */
|
|
314
|
+
export function useInstanceNetworks(next) {
|
|
315
|
+
const prev = instanceNetworks;
|
|
316
|
+
instanceNetworks = next;
|
|
317
|
+
return () => {
|
|
318
|
+
instanceNetworks = prev;
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
/** Make `<id>-media-1` resolvable in direct mode. Publish mode reaches media by
|
|
322
|
+
* published port and needs nothing. */
|
|
323
|
+
export function reachInstanceMedia(instanceId, mode = netReachMode()) {
|
|
324
|
+
if (mode !== "direct")
|
|
325
|
+
return;
|
|
326
|
+
if (instanceNetworks.join(instanceId) === "failed")
|
|
327
|
+
console.error(`could not join ${instanceNetworkName(instanceId)}: ${instanceId}-media-1 will not resolve`);
|
|
328
|
+
}
|
package/daemon.d.ts
CHANGED
|
@@ -140,6 +140,10 @@ export declare function cleanupDaemon(opts: {
|
|
|
140
140
|
containers: string[];
|
|
141
141
|
/** Injected for tests; defaults to the Docker-label reap. */
|
|
142
142
|
reapInstance?: (id: string) => string[];
|
|
143
|
+
/** Injected for tests; defaults to leaving the instance network the harness
|
|
144
|
+
* joined (container-net InstanceNetworks). It must precede the delete: a
|
|
145
|
+
* foreign endpoint keeps `compose down` from removing the network. */
|
|
146
|
+
leaveNetwork?: (id: string) => void;
|
|
143
147
|
deleteTimeoutMs?: number;
|
|
144
148
|
}): Promise<void>;
|
|
145
149
|
/** Tear down a direct-API test: stop proxy, remove instances, rm temp dir. */
|
package/daemon.js
CHANGED
|
@@ -13,6 +13,7 @@ import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
|
13
13
|
import { createServer } from "node:net";
|
|
14
14
|
import { join } from "node:path";
|
|
15
15
|
import { cliCommand } from "./cli-command.js";
|
|
16
|
+
import { currentInstanceNetworks } from "./container-net.js";
|
|
16
17
|
import { rememberDaemonStore } from "./daemon-stores.js";
|
|
17
18
|
import { pollUntil } from "./poll.js";
|
|
18
19
|
import { reapInstance } from "./reap.js";
|
|
@@ -339,7 +340,9 @@ export const INSTANCE_DELETE_TIMEOUT_MS = 20_000;
|
|
|
339
340
|
*/
|
|
340
341
|
export async function cleanupDaemon(opts) {
|
|
341
342
|
const deleteTimeoutMs = opts.deleteTimeoutMs ?? INSTANCE_DELETE_TIMEOUT_MS;
|
|
343
|
+
const leave = opts.leaveNetwork ?? ((id) => currentInstanceNetworks().leave(id));
|
|
342
344
|
for (const id of opts.instances ?? []) {
|
|
345
|
+
leave(id);
|
|
343
346
|
try {
|
|
344
347
|
if ((await settleWithin(opts.deleteInstance(id), deleteTimeoutMs)) === "timeout")
|
|
345
348
|
console.error(`cleanup: delete ${id} gave no answer in ${deleteTimeoutMs}ms`);
|
|
@@ -348,6 +351,8 @@ export async function cleanupDaemon(opts) {
|
|
|
348
351
|
console.error(`cleanup: delete ${id} failed: ${e}`);
|
|
349
352
|
}
|
|
350
353
|
}
|
|
354
|
+
if (!opts.leaveNetwork)
|
|
355
|
+
currentInstanceNetworks().leaveAll();
|
|
351
356
|
const reap = opts.reapInstance ?? reapInstance;
|
|
352
357
|
for (const id of opts.instances ?? []) {
|
|
353
358
|
const left = reap(id);
|
package/demo/harness.js
CHANGED
|
@@ -179,6 +179,7 @@ export class ProductHarness {
|
|
|
179
179
|
attempt: () => s.cliOk(launchArgv),
|
|
180
180
|
log: deps.log,
|
|
181
181
|
});
|
|
182
|
+
s.joinExtras(instanceId);
|
|
182
183
|
await s.waitRunning(instanceId);
|
|
183
184
|
const rows = await s.ingestPorts(instanceId);
|
|
184
185
|
const templateParams = { declared, overrides: specParams };
|
package/demo/run.d.ts
CHANGED
|
@@ -356,6 +356,12 @@ export declare class DemoSession {
|
|
|
356
356
|
/** name -> stringified default, from `template show`. */
|
|
357
357
|
declaredParams(templateName: string): Promise<Map<string, string | undefined>>;
|
|
358
358
|
startExtras(instanceId: string): void;
|
|
359
|
+
/** ctl puts media on its instance network only, so an extra media dials (a
|
|
360
|
+
* capture origin) must join that network too. It exists only once the
|
|
361
|
+
* launch has created the instance, so this runs after the launch, not with
|
|
362
|
+
* startExtras. Extras are removed before the instance is deleted, so this
|
|
363
|
+
* never holds the network open. */
|
|
364
|
+
joinExtras(instanceId: string): void;
|
|
359
365
|
stopExtras(): void;
|
|
360
366
|
waitRunning(instanceId: string): Promise<void>;
|
|
361
367
|
instanceListed(instanceId: string): Promise<boolean>;
|
package/demo/run.js
CHANGED
|
@@ -30,7 +30,7 @@ import { homedir } from "node:os";
|
|
|
30
30
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
31
31
|
import { parseManifestSeed, repoOf } from "@norskvideo/ctl-sdk/manifest-seed";
|
|
32
32
|
import { preserveInstanceContainerLogs } from "../container-logs.js";
|
|
33
|
-
import { DOCKER_NETWORK_NAME, ensureRunnerOnNetwork, netReachMode } from "../container-net.js";
|
|
33
|
+
import { currentInstanceNetworks, DOCKER_NETWORK_NAME, ensureRunnerOnNetwork, instanceNetworkName, isAlreadyOnNetworkError, netReachMode, } from "../container-net.js";
|
|
34
34
|
import { cleanupDaemon, ctlSupportsEphemeralPorts, PROXY_PORT_FILE, requireLicenseFile, runCli, startDaemon, } from "../daemon.js";
|
|
35
35
|
import { hashSlot } from "../harness-config.js";
|
|
36
36
|
import { ctlSupportsNoPublish, ctlSupportsPublishDebugPorts, runnerContainerUser } from "../launch.js";
|
|
@@ -565,6 +565,7 @@ export class DemoSession {
|
|
|
565
565
|
* the product publishes (plus the spec's built one), the product and its
|
|
566
566
|
* control-plane container all go first. Each step tolerates absence. */
|
|
567
567
|
async resetRegistration(instanceId) {
|
|
568
|
+
currentInstanceNetworks().leave(instanceId, { force: true });
|
|
568
569
|
await this.cli(["instance", "delete", instanceId, "--purge"]);
|
|
569
570
|
const listed = await this.cli(["template", "list"], { output: "json" });
|
|
570
571
|
const names = listed.exitCode === 0
|
|
@@ -695,6 +696,19 @@ export class DemoSession {
|
|
|
695
696
|
this.deps.log(`extra ${e.name}: ${e.image}`);
|
|
696
697
|
}
|
|
697
698
|
}
|
|
699
|
+
/** ctl puts media on its instance network only, so an extra media dials (a
|
|
700
|
+
* capture origin) must join that network too. It exists only once the
|
|
701
|
+
* launch has created the instance, so this runs after the launch, not with
|
|
702
|
+
* startExtras. Extras are removed before the instance is deleted, so this
|
|
703
|
+
* never holds the network open. */
|
|
704
|
+
joinExtras(instanceId) {
|
|
705
|
+
for (const name of this.extras) {
|
|
706
|
+
const c = this.deps.docker(["network", "connect", instanceNetworkName(instanceId), name]);
|
|
707
|
+
if (c.exitCode !== 0 && !isAlreadyOnNetworkError(c.stderr)) {
|
|
708
|
+
throw new Error(`extra '${name}' could not join ${instanceNetworkName(instanceId)}: ${c.stderr.trim()}`);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
698
712
|
stopExtras() {
|
|
699
713
|
for (const name of this.extras)
|
|
700
714
|
this.deps.docker(["rm", "-f", name]);
|
|
@@ -748,8 +762,11 @@ export class DemoSession {
|
|
|
748
762
|
this.preserveContainerLogs(opts.instances);
|
|
749
763
|
if (opts.handles.length)
|
|
750
764
|
await this.deps.stopSources(opts.handles).catch(() => { });
|
|
765
|
+
// Extras sit on the instance networks; they go before any instance does.
|
|
766
|
+
this.stopExtras();
|
|
751
767
|
if (this.policy === "reuse") {
|
|
752
768
|
for (const id of opts.instances) {
|
|
769
|
+
currentInstanceNetworks().leave(id);
|
|
753
770
|
const r = await this.cli(["instance", "delete", id, "--purge"]);
|
|
754
771
|
if (r.exitCode !== 0)
|
|
755
772
|
this.deps.log(`instance delete ${id}: ${(r.stderr || r.stdout).trim()}`);
|
|
@@ -836,6 +853,7 @@ export async function demoDown(product, deps = defaultDemoDeps(process.cwd())) {
|
|
|
836
853
|
const cli = (argv) => deps.cli(state.storeDir, ["--port", String(state.daemonPort), ...argv]);
|
|
837
854
|
if (state.daemon === "reuse") {
|
|
838
855
|
if (state.instanceId) {
|
|
856
|
+
currentInstanceNetworks().leave(state.instanceId, { force: true });
|
|
839
857
|
const r = await cli(["instance", "delete", state.instanceId, "--purge"]);
|
|
840
858
|
if (r.exitCode !== 0)
|
|
841
859
|
deps.log(`instance delete ${state.instanceId}: ${(r.stderr || r.stdout).trim()}`);
|
package/demo/spec.d.ts
CHANGED
|
@@ -93,7 +93,8 @@ export type DemoTemplate = {
|
|
|
93
93
|
};
|
|
94
94
|
/** A sidecar container the demo needs beside the instance (funke's ad-push
|
|
95
95
|
* capture origin): `docker run -d --name <name> --network norsk-net <args>
|
|
96
|
-
* <image> <command>`, started before the launch,
|
|
96
|
+
* <image> <command>`, started before the launch, joined to the instance
|
|
97
|
+
* network after it (media is on no other), removed at teardown. */
|
|
97
98
|
export interface DemoExtra {
|
|
98
99
|
name: string;
|
|
99
100
|
image: string;
|
package/package.json
CHANGED
package/smoke.d.ts
CHANGED
|
@@ -33,6 +33,25 @@ export interface SmokeContext {
|
|
|
33
33
|
output?: "json" | "yaml";
|
|
34
34
|
}): Promise<CliResult>;
|
|
35
35
|
fetchStudio(path: string): Promise<Response>;
|
|
36
|
+
/** The spec's `surface` (Studio's by default), reached the same two ways:
|
|
37
|
+
* the banded published port, or `<id>-<service>-1:<internalPort>` direct. */
|
|
38
|
+
surfaceUrl(path: string): string;
|
|
39
|
+
/** GET surfaceUrl(path) through the injected reader -- for observations. A
|
|
40
|
+
* prepare step that acts (POST, DELETE) uses fetch(ctx.surfaceUrl(...)). */
|
|
41
|
+
fetchSurface(path: string): Promise<Response>;
|
|
42
|
+
}
|
|
43
|
+
/** The instance service whose HTTP surface the journey publishes and reaches.
|
|
44
|
+
* Studio's by default; a direct-SDK product (no Studio, e.g. norsk-relay)
|
|
45
|
+
* names its own control API. */
|
|
46
|
+
export interface SmokeSurface {
|
|
47
|
+
service: string;
|
|
48
|
+
internalPort: number;
|
|
49
|
+
/** The host port the TEMPLATE publishes the surface on (a `--param` the spec
|
|
50
|
+
* sets). ctl emits `--host-ports` only for Studio and media -- one naming any
|
|
51
|
+
* other service is persisted but never wired -- so a direct-SDK product's
|
|
52
|
+
* surface is published by its own compose. Given, no `--host-ports` is
|
|
53
|
+
* passed and publish mode dials this port. */
|
|
54
|
+
hostPort?: number;
|
|
36
55
|
}
|
|
37
56
|
/** Set on any observation that holds with or without a source — a resilient
|
|
38
57
|
* switch that keeps a ladder fed from a slate, a control surface that is up
|
|
@@ -85,6 +104,14 @@ export interface SmokeSpec {
|
|
|
85
104
|
licenseFile?: string;
|
|
86
105
|
};
|
|
87
106
|
template: SmokeTemplate;
|
|
107
|
+
/** Default { service: "studio", internalPort: 8000 }. Published on the banded
|
|
108
|
+
* studioHostPort; STUDIO_HOST_PORT is passed instead only for Studio's. */
|
|
109
|
+
surface?: SmokeSurface;
|
|
110
|
+
/** Runs once the instance reports running, before the not-yet phase: what a
|
|
111
|
+
* product needs in place before its sources can be accepted (the relay
|
|
112
|
+
* refuses SRT for a stream nobody declared). Not an observation -- it may
|
|
113
|
+
* act, and a throw fails the journey. */
|
|
114
|
+
prepare?(ctx: SmokeContext): Promise<void>;
|
|
88
115
|
/** Extra `--param NAME=value`. INSTANCE_NAME is always passed; STUDIO_HOST_PORT
|
|
89
116
|
* when the template declares it (else the port is pinned with --host-ports). */
|
|
90
117
|
params?: string[];
|
|
@@ -206,5 +233,15 @@ export declare function assertManifestRoutesServed(opts: {
|
|
|
206
233
|
export declare function sourceDependent(o: SmokeObservation): boolean;
|
|
207
234
|
/** One sample of an observation — the predicate the three phases poll. */
|
|
208
235
|
export declare function observationSatisfied(o: SmokeObservation, ctx: SmokeContext, readers: SmokeReaders): Promise<boolean>;
|
|
236
|
+
/** A direct-SDK product's licence identity -- the sdk-client cert and key it
|
|
237
|
+
* answers the engine's V2 handshake with, base64 -- from what smoke.yml
|
|
238
|
+
* exports (repo secrets NORSK_SDK_CLIENT_CERT_B64 / NORSK_SDK_CLIENT_KEY_B64).
|
|
239
|
+
* Undefined when neither is set (a Studio product, or a local run without
|
|
240
|
+
* one); half a pair is a misconfigured repo, so it throws rather than
|
|
241
|
+
* launching an instance that restarts on "License handshake required". */
|
|
242
|
+
export declare function sdkClientIdentityFromEnv(env?: Record<string, string | undefined>): {
|
|
243
|
+
certB64: string;
|
|
244
|
+
keyB64: string;
|
|
245
|
+
} | undefined;
|
|
209
246
|
export declare function runProductSmoke(slug: string, spec: SmokeSpec, deps?: SmokeDeps): Promise<void>;
|
|
210
247
|
export {};
|
package/smoke.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
import { existsSync, writeFileSync } from "node:fs";
|
|
21
21
|
import { join } from "node:path";
|
|
22
22
|
import { dumpInstanceContainerLogs, preserveEngineLogs, preserveInstanceContainerLogs } from "./container-logs.js";
|
|
23
|
-
import { ensureRunnerOnNetwork, netReachMode, STUDIO_INTERNAL_PORT, studioBaseFrom, } from "./container-net.js";
|
|
23
|
+
import { authority, ensureRunnerOnNetwork, netReachMode, resolveEndpoint, STUDIO_INTERNAL_PORT, studioBaseFrom, } from "./container-net.js";
|
|
24
24
|
import { cleanupDaemon, ctlSupportsEphemeralPorts, PROXY_PORT_FILE, requireLicenseFile, runCli, startDaemon, } from "./daemon.js";
|
|
25
25
|
import { hashSlot } from "./harness-config.js";
|
|
26
26
|
import { ctlSupportsNoPublish, ctlSupportsPublishDebugPorts, runnerContainerUser } from "./launch.js";
|
|
@@ -191,6 +191,22 @@ function parseJson(r, what) {
|
|
|
191
191
|
throw new Error(`${what}: not JSON: ${r.stdout.slice(0, 200)}`);
|
|
192
192
|
}
|
|
193
193
|
}
|
|
194
|
+
/** A direct-SDK product's licence identity -- the sdk-client cert and key it
|
|
195
|
+
* answers the engine's V2 handshake with, base64 -- from what smoke.yml
|
|
196
|
+
* exports (repo secrets NORSK_SDK_CLIENT_CERT_B64 / NORSK_SDK_CLIENT_KEY_B64).
|
|
197
|
+
* Undefined when neither is set (a Studio product, or a local run without
|
|
198
|
+
* one); half a pair is a misconfigured repo, so it throws rather than
|
|
199
|
+
* launching an instance that restarts on "License handshake required". */
|
|
200
|
+
export function sdkClientIdentityFromEnv(env = process.env) {
|
|
201
|
+
const certB64 = env.NORSK_SDK_CLIENT_CERT_B64 ?? "";
|
|
202
|
+
const keyB64 = env.NORSK_SDK_CLIENT_KEY_B64 ?? "";
|
|
203
|
+
if (!certB64 && !keyB64)
|
|
204
|
+
return undefined;
|
|
205
|
+
if (!certB64 || !keyB64) {
|
|
206
|
+
throw new Error(`NORSK_SDK_CLIENT_${certB64 ? "KEY" : "CERT"}_B64 is empty but its pair is set -- set both repo secrets or neither`);
|
|
207
|
+
}
|
|
208
|
+
return { certB64, keyB64 };
|
|
209
|
+
}
|
|
194
210
|
export async function runProductSmoke(slug, spec, deps = defaultSmokeDeps) {
|
|
195
211
|
const ports = smokePorts(slug, spec.ports);
|
|
196
212
|
const storeDir = deps.storeDir(slug);
|
|
@@ -202,6 +218,8 @@ export async function runProductSmoke(slug, spec, deps = defaultSmokeDeps) {
|
|
|
202
218
|
};
|
|
203
219
|
const mode = netReachMode();
|
|
204
220
|
const studio = { instanceId, studioHostPort: ports.studioHostPort };
|
|
221
|
+
const surface = spec.surface ?? { service: "studio", internalPort: STUDIO_INTERNAL_PORT };
|
|
222
|
+
const studioSurface = surface.service === "studio" && surface.internalPort === STUDIO_INTERNAL_PORT;
|
|
205
223
|
const cli = async (argv, opts = {}) => {
|
|
206
224
|
const full = ["--port", String(ports.daemonPort), ...argv, ...(opts.output ? ["-o", opts.output] : [])];
|
|
207
225
|
return deps.cli(storeDir, full);
|
|
@@ -223,6 +241,18 @@ export async function runProductSmoke(slug, spec, deps = defaultSmokeDeps) {
|
|
|
223
241
|
studio,
|
|
224
242
|
cli,
|
|
225
243
|
fetchStudio: (path) => deps.readers.fetch(`${studioBaseFrom(studio, process.env.NORSK_TEST_HOST ?? "localhost", mode)}${path}`),
|
|
244
|
+
surfaceUrl: (path) => {
|
|
245
|
+
const e = resolveEndpoint({
|
|
246
|
+
instanceId,
|
|
247
|
+
service: surface.service,
|
|
248
|
+
internalPort: surface.internalPort,
|
|
249
|
+
publishHost: process.env.NORSK_TEST_HOST ?? "localhost",
|
|
250
|
+
publishPort: surface.hostPort ?? ports.studioHostPort,
|
|
251
|
+
mode,
|
|
252
|
+
});
|
|
253
|
+
return `http://${authority(e)}${path}`;
|
|
254
|
+
},
|
|
255
|
+
fetchSurface: (path) => deps.readers.fetch(ctx.surfaceUrl(path)),
|
|
226
256
|
};
|
|
227
257
|
let daemon = null;
|
|
228
258
|
let launched = false;
|
|
@@ -335,10 +365,12 @@ export async function runProductSmoke(slug, spec, deps = defaultSmokeDeps) {
|
|
|
335
365
|
const params = [`INSTANCE_NAME=${instanceId}`, ...(spec.params ?? [])];
|
|
336
366
|
const launchArgv = ["instance", "launch-template", instanceId, "--template", templateName];
|
|
337
367
|
const internalOnly = mode === "direct" && (await deps.supportsNoPublish());
|
|
338
|
-
if (declared.has(STUDIO_HOST_PORT_PARAM))
|
|
368
|
+
if (studioSurface && declared.has(STUDIO_HOST_PORT_PARAM)) {
|
|
339
369
|
params.push(`${STUDIO_HOST_PORT_PARAM}=${ports.studioHostPort}`);
|
|
340
|
-
|
|
341
|
-
|
|
370
|
+
}
|
|
371
|
+
else if (!internalOnly && surface.hostPort === undefined) {
|
|
372
|
+
launchArgv.push("--host-ports", `${ports.studioHostPort}:${surface.internalPort}@${surface.service}`);
|
|
373
|
+
}
|
|
342
374
|
for (const p of params)
|
|
343
375
|
launchArgv.push("--param", p);
|
|
344
376
|
if (spec.hardware)
|
|
@@ -376,6 +408,10 @@ export async function runProductSmoke(slug, spec, deps = defaultSmokeDeps) {
|
|
|
376
408
|
const inst = parseJson(r, "instance list").instances?.find((i) => i.id === instanceId);
|
|
377
409
|
return inst?.status === "running" || inst?.status === "healthy";
|
|
378
410
|
}, { timeoutMs: timeouts.healthyMs, intervalMs: 1000, label: `instance ${instanceId} never reported running` });
|
|
411
|
+
if (spec.prepare) {
|
|
412
|
+
await spec.prepare(ctx);
|
|
413
|
+
deps.log(`${slug}: prepare done`);
|
|
414
|
+
}
|
|
379
415
|
// Phases.
|
|
380
416
|
const vacuous = spec.sources.length === 0;
|
|
381
417
|
if (vacuous) {
|
package/studio-state.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// Studio's /live/api/components returns each entry as `{ componentId, componentType, capabilities }`
|
|
19
19
|
// — NOT `{ id, type }` (which is what the source-yaml schema uses internally).
|
|
20
20
|
// Verified empirically against a live Studio instance.
|
|
21
|
-
import { studioBaseFrom } from "./container-net.js";
|
|
21
|
+
import { reachInstanceMedia, studioBaseFrom } from "./container-net.js";
|
|
22
22
|
// The host the studio port is reachable on. Normally the product template
|
|
23
23
|
// publishes it on the loopback of the machine running the tests, so localhost is
|
|
24
24
|
// right. When the test process itself runs inside a container that launches the
|
|
@@ -55,11 +55,13 @@ export function applyTestHost(rawUrl) {
|
|
|
55
55
|
// proxies `/instance/<id>/media/*` here; whip-driver hits the same port direct.
|
|
56
56
|
const MEDIA_HTTP_PORT = 8080;
|
|
57
57
|
// Rewrite a daemon-proxy-routed media URL to a direct hit on the Norsk media
|
|
58
|
-
// container over
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
// directly by its compose service name:
|
|
58
|
+
// container over its instance network `<id>_default`, the only one media is
|
|
59
|
+
// on. Studio advertises playback URLs as
|
|
60
|
+
// `http://<publicHost>/instance/<id>/media/<native>` — that route is served ONLY
|
|
61
|
+
// by the daemon's nginx proxy. A harness that runs proxy-less can instead reach
|
|
62
|
+
// the media container directly by its compose service name:
|
|
63
|
+
// `http://<id>-media-1:8080/<native>`; in direct mode this joins the runner to
|
|
64
|
+
// `<id>_default` so that name resolves.
|
|
63
65
|
// The `/instance/<id>/media` prefix is the proxy's routing concern; Norsk's
|
|
64
66
|
// native path is what follows it. Falls back to applyTestHost for URLs that
|
|
65
67
|
// don't carry the proxy prefix. Mirrors whip-driver's dockerNetUrl.
|
|
@@ -70,6 +72,7 @@ export function mediaDirectUrl(rawUrl) {
|
|
|
70
72
|
if (m) {
|
|
71
73
|
const id = m[1];
|
|
72
74
|
const rest = m[2] ?? "/";
|
|
75
|
+
reachInstanceMedia(id);
|
|
73
76
|
return `http://${id}-media-1:${MEDIA_HTTP_PORT}${rest}${u.search}`;
|
|
74
77
|
}
|
|
75
78
|
}
|