@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.
@@ -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` on norsk-net.
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. */
@@ -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" | "media";
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 joined
27
- // to norsk-net (bridge DNS is per-network). On Docker Desktop / OrbStack the
28
- // bridge is hidden inside a VM and is not routable from the host at all, so the
29
- // direct path can't work there — those runs MUST keep publishing. Hence the mode
30
- // switch: `direct` on the Linux DooD CI box, `publish` (the default) everywhere
31
- // else. Nothing here changes behaviour until a caller opts a suite into `direct`.
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. These
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, removed at teardown. */
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-test-harness",
3
- "version": "0.1.74",
3
+ "version": "0.1.76",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
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
- else if (!internalOnly)
341
- launchArgv.push("--host-ports", `${ports.studioHostPort}:${STUDIO_INTERNAL_PORT}@studio`);
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 the daemon's `norsk-net` bridge. Studio advertises playback
59
- // URLs as `http://<publicHost>/instance/<id>/media/<native>` — that route is
60
- // served ONLY by the daemon's nginx proxy. A harness that runs proxy-less (and
61
- // whose runner has joined norsk-net) can instead reach the media container
62
- // directly by its compose service name: `http://<id>-media-1:8080/<native>`.
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
  }