@norskvideo/ctl-test-harness 0.1.69 → 0.1.71

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/daemon.d.ts CHANGED
@@ -31,6 +31,9 @@ export interface DaemonProcess {
31
31
  exited: Promise<number | null>;
32
32
  }
33
33
  export interface StartDaemonOptions {
34
+ /** The port the daemon binds. 0 lets the kernel choose — only against a ctl
35
+ * that publishes it (see {@link ctlSupportsEphemeralPorts}); the daemon's
36
+ * `running` says which it got. */
34
37
  port?: number;
35
38
  env?: Record<string, string>;
36
39
  /** Spawn in its own process group so a caller can group-kill the whole
@@ -41,7 +44,42 @@ export interface StartDaemonOptions {
41
44
  * from a virgin store, so they turn this off. Default true. */
42
45
  seedConfig?: boolean;
43
46
  readyTimeoutMs?: number;
47
+ /** The CLI to run `serve` from. Default: the resolved norsk-ctl (cli-command.ts). */
48
+ command?: readonly string[];
44
49
  }
50
+ /** What a started daemon turned out to be answering on. */
51
+ export interface RunningDaemon {
52
+ daemonPort: number;
53
+ /** Its proxy's host port, when the daemon published one (`proxy.port`). An
54
+ * older ctl publishes none. */
55
+ proxyPort?: number;
56
+ }
57
+ export interface StartedDaemon {
58
+ daemon: DaemonProcess;
59
+ ready: Promise<void>;
60
+ /** Resolves once ready, with the ports the daemon actually answers on. */
61
+ running: Promise<RunningDaemon>;
62
+ }
63
+ /** Files a daemon publishes into its store once it knows them (ctl's
64
+ * backend/src/lib/daemon-port.ts). */
65
+ export declare const DAEMON_PORT_FILE = "daemon.port";
66
+ export declare const PROXY_PORT_FILE = "proxy.port";
67
+ /** A port a daemon published into its store, or undefined when it has not. */
68
+ export declare function readPortFile(storeDir: string, name: string): number | undefined;
69
+ /** The named daemon capability (ctl's shared/src/daemon-capabilities.ts):
70
+ * NORSK_CTL_PORT=0 and proxyPort 0 run on system-assigned ports, published as
71
+ * daemon.port and proxy.port. */
72
+ export declare const EPHEMERAL_PORTS_CAPABILITY = "ephemeral-ports";
73
+ /** Whether `command` advertises the ephemeral-ports capability, asked of the
74
+ * binary itself — the daemon does not exist yet when the answer is needed.
75
+ * A ctl that predates `norsk-ctl capabilities` fails the command, which reads
76
+ * as no. NORSK_CTL_PORT points at nothing: an older ctl probes the daemon for
77
+ * product commands when it does not know the first word, and the developer's
78
+ * daemon has no part in this answer. */
79
+ export declare function probeEphemeralPorts(command: readonly string[]): Promise<boolean>;
80
+ /** Whether the resolved norsk-ctl runs its daemon and proxy on system-assigned
81
+ * ports. Memoised: one spawn per process. */
82
+ export declare function ctlSupportsEphemeralPorts(): Promise<boolean>;
45
83
  /** Root the instance working directory under the store dir (itself under
46
84
  * TEST_TMP_BASE) rather than the daemon default ~/norsk-runtime. The launcher
47
85
  * bind-mounts the workdir as /data and then mounts the stored template's
@@ -71,10 +109,7 @@ export declare function daemonSpawnEnv(o: {
71
109
  inherited: Record<string, string | undefined>;
72
110
  overrides?: Record<string, string>;
73
111
  }): Record<string, string | undefined>;
74
- export declare function startDaemon(storeDir: string, options?: StartDaemonOptions): {
75
- daemon: DaemonProcess;
76
- ready: Promise<void>;
77
- };
112
+ export declare function startDaemon(storeDir: string, options?: StartDaemonOptions): StartedDaemon;
78
113
  /**
79
114
  * Tear down a daemon-based test: delete instances, stop proxy, stop daemon, kill
80
115
  * process, rm store. The teardown actions are injected as pre-bound thunks
package/daemon.js CHANGED
@@ -120,6 +120,65 @@ function daemonHandle(proc) {
120
120
  exited,
121
121
  };
122
122
  }
123
+ /** Files a daemon publishes into its store once it knows them (ctl's
124
+ * backend/src/lib/daemon-port.ts). */
125
+ export const DAEMON_PORT_FILE = "daemon.port";
126
+ export const PROXY_PORT_FILE = "proxy.port";
127
+ /** A port a daemon published into its store, or undefined when it has not. */
128
+ export function readPortFile(storeDir, name) {
129
+ try {
130
+ const port = Number(readFileSync(join(storeDir, name), "utf-8").trim());
131
+ return Number.isInteger(port) && port > 0 ? port : undefined;
132
+ }
133
+ catch {
134
+ return undefined;
135
+ }
136
+ }
137
+ /** The named daemon capability (ctl's shared/src/daemon-capabilities.ts):
138
+ * NORSK_CTL_PORT=0 and proxyPort 0 run on system-assigned ports, published as
139
+ * daemon.port and proxy.port. */
140
+ export const EPHEMERAL_PORTS_CAPABILITY = "ephemeral-ports";
141
+ /** Whether `command` advertises the ephemeral-ports capability, asked of the
142
+ * binary itself — the daemon does not exist yet when the answer is needed.
143
+ * A ctl that predates `norsk-ctl capabilities` fails the command, which reads
144
+ * as no. NORSK_CTL_PORT points at nothing: an older ctl probes the daemon for
145
+ * product commands when it does not know the first word, and the developer's
146
+ * daemon has no part in this answer. */
147
+ export function probeEphemeralPorts(command) {
148
+ return new Promise((resolve) => {
149
+ const [cmd, ...args] = command;
150
+ const proc = spawn(cmd, [...args, "capabilities"], {
151
+ env: { ...process.env, NORSK_CTL_PORT: "1" },
152
+ stdio: ["ignore", "pipe", "ignore"],
153
+ });
154
+ const out = [];
155
+ const timer = setTimeout(() => proc.kill("SIGKILL"), 30_000);
156
+ proc.stdout?.on("data", (d) => out.push(d));
157
+ proc.once("error", () => {
158
+ clearTimeout(timer);
159
+ resolve(false);
160
+ });
161
+ proc.once("close", (code) => {
162
+ clearTimeout(timer);
163
+ if (code !== 0)
164
+ return resolve(false);
165
+ try {
166
+ const body = JSON.parse(Buffer.concat(out).toString());
167
+ resolve(Array.isArray(body.daemonCapabilities) && body.daemonCapabilities.includes(EPHEMERAL_PORTS_CAPABILITY));
168
+ }
169
+ catch {
170
+ resolve(false);
171
+ }
172
+ });
173
+ });
174
+ }
175
+ let ephemeralSupport;
176
+ /** Whether the resolved norsk-ctl runs its daemon and proxy on system-assigned
177
+ * ports. Memoised: one spawn per process. */
178
+ export function ctlSupportsEphemeralPorts() {
179
+ ephemeralSupport ??= probeEphemeralPorts(cliCommand);
180
+ return ephemeralSupport;
181
+ }
123
182
  /** Root the instance working directory under the store dir (itself under
124
183
  * TEST_TMP_BASE) rather than the daemon default ~/norsk-runtime. The launcher
125
184
  * bind-mounts the workdir as /data and then mounts the stored template's
@@ -173,13 +232,19 @@ export function daemonSpawnEnv(o) {
173
232
  }
174
233
  export function startDaemon(storeDir, options) {
175
234
  const port = options?.port ?? 8333;
176
- // Before the spawn, so anything reaching for this daemon's ingress secret can
177
- // find its store by the port it will answer on (daemon-stores.ts).
178
- rememberDaemonStore(port, storeDir);
179
- killProcessOnPort(port);
235
+ const command = options?.command ?? cliCommand;
236
+ // Whatever a previous daemon on this store published is not this one's.
237
+ for (const f of [DAEMON_PORT_FILE, PROXY_PORT_FILE])
238
+ rmSync(join(storeDir, f), { force: true });
239
+ if (port !== 0) {
240
+ // Before the spawn, so anything reaching for this daemon's ingress secret
241
+ // can find its store by the port it will answer on (daemon-stores.ts).
242
+ rememberDaemonStore(port, storeDir);
243
+ killProcessOnPort(port);
244
+ }
180
245
  if (options?.seedConfig !== false)
181
246
  seedDaemonConfig(storeDir);
182
- const [cmd, ...cmdArgs] = cliCommand;
247
+ const [cmd, ...cmdArgs] = command;
183
248
  const proc = spawn(cmd, [...cmdArgs, "serve"], {
184
249
  stdio: "inherit",
185
250
  detached: options?.detached === true,
@@ -191,20 +256,33 @@ export function startDaemon(storeDir, options) {
191
256
  }),
192
257
  });
193
258
  const spawnFailed = new Promise((_, reject) => {
194
- proc.once("error", (e) => reject(new Error(cliUnavailableMessage(cliCommand, e.message))));
259
+ proc.once("error", (e) => reject(new Error(cliUnavailableMessage(command, e.message))));
195
260
  });
196
- const ready = Promise.race([
261
+ const timeoutMs = options?.readyTimeoutMs ?? DAEMON_READY_TIMEOUT_MS;
262
+ const bound = port !== 0
263
+ ? Promise.resolve(port)
264
+ : (async () => {
265
+ let published;
266
+ await pollUntil(async () => {
267
+ published = readPortFile(storeDir, DAEMON_PORT_FILE);
268
+ return published !== undefined;
269
+ }, { timeoutMs, intervalMs: 200, label: `Daemon published no ${DAEMON_PORT_FILE} in ${storeDir}` });
270
+ const p = published;
271
+ rememberDaemonStore(p, storeDir);
272
+ return p;
273
+ })();
274
+ const running = Promise.race([
197
275
  spawnFailed,
198
- pollUntil(async () => {
199
- const res = await fetch(`http://localhost:${port}/api/ready`, { signal: AbortSignal.timeout(1000) });
200
- return res.ok;
201
- }, {
202
- timeoutMs: options?.readyTimeoutMs ?? DAEMON_READY_TIMEOUT_MS,
203
- intervalMs: 500,
204
- label: "Daemon did not become ready",
276
+ bound.then(async (daemonPort) => {
277
+ await pollUntil(async () => {
278
+ const res = await fetch(`http://localhost:${daemonPort}/api/ready`, { signal: AbortSignal.timeout(1000) });
279
+ return res.ok;
280
+ }, { timeoutMs, intervalMs: 500, label: "Daemon did not become ready" });
281
+ const proxyPort = readPortFile(storeDir, PROXY_PORT_FILE);
282
+ return { daemonPort, ...(proxyPort !== undefined ? { proxyPort } : {}) };
205
283
  }),
206
284
  ]);
207
- return { daemon: daemonHandle(proc), ready };
285
+ return { daemon: daemonHandle(proc), ready: running.then(() => undefined), running };
208
286
  }
209
287
  function runningContainers(names) {
210
288
  const result = spawnSync("docker", ["ps", "--format", "{{.Names}}"], { encoding: "utf-8" });
@@ -238,7 +316,12 @@ export async function cleanupDaemon(opts) {
238
316
  console.error(`cleanup: delete ${id} failed: ${e}`);
239
317
  }
240
318
  }
241
- if (opts.proxy)
319
+ // Stop the proxy unless the caller says it never ran one (`proxy: false`).
320
+ // It used to need `proxy: true`, and three callers passing a real stopProxy
321
+ // omitted it — so `shutdown --daemon-only` left their proxy pair running.
322
+ // On the shared ar-arm-vm runner a leftover pair then answered for the next
323
+ // job's proxy (2026-10-02, norsk-ctl Checks 37048720935).
324
+ if (opts.proxy ?? true)
242
325
  try {
243
326
  await opts.stopProxy();
244
327
  }
package/demo/cli.d.ts CHANGED
@@ -24,7 +24,10 @@ export interface ResolvedSpecView {
24
24
  slug: string;
25
25
  instanceId: string;
26
26
  mode: string;
27
+ /** The band. With `portsAssignedAtStart` the daemon's and proxy's read 0:
28
+ * the running daemon is given them, and the plan cannot know them. */
27
29
  ports: ReturnType<typeof demoPorts>;
30
+ portsAssignedAtStart?: true;
28
31
  dev: DemoSpec["dev"];
29
32
  image?: string;
30
33
  template: {
@@ -67,6 +70,7 @@ export declare function resolvedSpecView(spec: DemoSpec, opts: {
67
70
  mode: string;
68
71
  slug?: string;
69
72
  publicHost?: string;
73
+ ephemeralPorts?: boolean;
70
74
  }): ResolvedSpecView;
71
75
  /** Everything `demoMain` touches, injectable so the dispatch is unit-tested. */
72
76
  export interface DemoCliIo {
@@ -83,6 +87,9 @@ export interface DemoCliIo {
83
87
  devLoopDown(product: string): Promise<void>;
84
88
  /** Open the multiplexer on the rendered layout; returns its exit code. */
85
89
  launchUi(ui: DemoUi, layout: PaneLayout): number;
90
+ /** Does the resolved ctl run its daemon and proxy on system-assigned ports
91
+ * (what `demo` then uses)? Absent = no. */
92
+ supportsEphemeralPorts?(): Promise<boolean>;
86
93
  /** For the standalone dirs (NORSK_DIR, STUDIO_DIR, TO, HOME). */
87
94
  env?: Record<string, string | undefined>;
88
95
  stdout(line: string): void;
package/demo/cli.js CHANGED
@@ -24,6 +24,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
24
24
  // TypeScript module whose default export is `defineDemo({...})`. This runs
25
25
  // under bun only: the spec is TypeScript and so is every product's dev shell.
26
26
  import { resolve } from "node:path";
27
+ import { ctlSupportsEphemeralPorts } from "../daemon.js";
27
28
  import { devLoopDown, runDevLoop } from "./dev-loop.js";
28
29
  import { runDemo } from "./harness.js";
29
30
  import { demoPaneList, devLoopPaneList, launchLayout, renderLayout, standaloneDirs, } from "./panes.js";
@@ -152,7 +153,9 @@ export function parseDemoArgs(argv) {
152
153
  export function resolvedSpecView(spec, opts) {
153
154
  const slug = opts.slug ?? demoSlug(spec.product);
154
155
  const instanceId = `demo-${slug}`;
155
- const ports = demoPorts(slug);
156
+ const band = demoPorts(slug);
157
+ const ports = opts.ephemeralPorts ? { ...band, daemonPort: 0, proxyPort: 0 } : band;
158
+ const proxyPort = opts.ephemeralPorts ? "<assigned>" : String(ports.proxyPort);
156
159
  const host = opts.publicHost ?? "localhost";
157
160
  const pinned = spec.launch?.params?.STUDIO_HOST_PORT;
158
161
  const studioHostPort = pinned !== undefined ? Number(pinned) : ports.studioHostPort;
@@ -162,7 +165,7 @@ export function resolvedSpecView(spec, opts) {
162
165
  if ("control" in ref)
163
166
  return `http://${host}:${ports.backendPort}${ref.control}`;
164
167
  if ("proxy" in ref)
165
- return `http://${host}:${ports.proxyPort}${ref.proxy.replaceAll("{id}", instanceId)}`;
168
+ return `http://${host}:${proxyPort}${ref.proxy.replaceAll("{id}", instanceId)}`;
166
169
  return `http://${host}:${studioHostPort}${ref.studio}`;
167
170
  };
168
171
  const t = spec.template;
@@ -175,6 +178,7 @@ export function resolvedSpecView(spec, opts) {
175
178
  instanceId,
176
179
  mode: opts.mode,
177
180
  ports,
181
+ ...(opts.ephemeralPorts ? { portsAssignedAtStart: true } : {}),
178
182
  dev: spec.dev,
179
183
  ...(spec.image !== undefined ? { image: spec.image } : {}),
180
184
  template,
@@ -259,7 +263,8 @@ export async function demoMain(argv, io) {
259
263
  }
260
264
  switch (args.action) {
261
265
  case "spec": {
262
- const view = resolvedSpecView(spec, { mode, ...publicHost });
266
+ const ephemeralPorts = (await io.supportsEphemeralPorts?.()) === true;
267
+ const view = resolvedSpecView(spec, { mode, ...publicHost, ephemeralPorts });
263
268
  io.stdout(args.json ? JSON.stringify(view, null, 2) : renderView(view));
264
269
  return 0;
265
270
  }
@@ -299,7 +304,7 @@ export async function demoMain(argv, io) {
299
304
  function renderView(v) {
300
305
  const lines = [
301
306
  `${v.product} (${v.mode}) -> instance ${v.instanceId}`,
302
- ` daemon :${v.ports.daemonPort} dev backend :${v.ports.backendPort} proxy :${v.ports.proxyPort} studio :${v.ports.studioHostPort}`,
307
+ ` daemon :${v.portsAssignedAtStart ? "assigned" : v.ports.daemonPort} dev backend :${v.ports.backendPort} proxy :${v.portsAssignedAtStart ? "assigned" : v.ports.proxyPort} studio :${v.ports.studioHostPort}`,
303
308
  ` template ${"name" in v.template ? v.template.name : `build ${v.template.build.name}`}`,
304
309
  ];
305
310
  if (v.image)
@@ -338,6 +343,7 @@ export function defaultDemoCliIo() {
338
343
  runDevLoop: (spec, opts) => runDevLoop(spec, opts),
339
344
  devLoopDown: (product) => devLoopDown(product),
340
345
  launchUi: (ui, layout) => launchLayout(ui, renderLayout(layout, ui), layout),
346
+ supportsEphemeralPorts: ctlSupportsEphemeralPorts,
341
347
  stdout: (line) => console.log(line),
342
348
  stderr: (line) => console.error(line),
343
349
  abort: controller.signal,
package/demo/dev-loop.js CHANGED
@@ -121,6 +121,8 @@ export async function runDevLoop(spec, opts, deps = defaultDemoDeps(opts.cwd)) {
121
121
  const common = {
122
122
  mode: "dev",
123
123
  daemon: "private",
124
+ // The loop's daemon is the driver's own; `refresh` finds it by the record.
125
+ ephemeralPorts: true,
124
126
  ...(opts.timeouts !== undefined ? { timeouts: opts.timeouts } : {}),
125
127
  ...(opts.publicHost !== undefined ? { publicHost: opts.publicHost } : {}),
126
128
  };
@@ -129,7 +131,11 @@ export async function runDevLoop(spec, opts, deps = defaultDemoDeps(opts.cwd)) {
129
131
  if (!state || !(await deps.daemonAnswers(state.daemonPort)) || !state.templateName) {
130
132
  throw new Error(`dev-loop '${spec.product}' is not up — run \`dev-loop up\` first (it holds; refresh from another pane)`);
131
133
  }
132
- const s = new DemoSession(spec, slug, opts.cwd, deps, { ...common, storeDir: state.storeDir });
134
+ const s = new DemoSession(spec, slug, opts.cwd, deps, {
135
+ ...common,
136
+ storeDir: state.storeDir,
137
+ daemonPort: state.daemonPort,
138
+ });
133
139
  const workdir = state.workdir ?? dirs.workdir;
134
140
  const studioPort = await exportLoop(s, state.templateName, workdir, true);
135
141
  for (const line of describeLoop(s, { ...dirs, workdir }, studioPort))
package/demo/harness.d.ts CHANGED
@@ -29,6 +29,11 @@ export interface ProductHarnessOptions {
29
29
  daemonEnv?: Record<string, string>;
30
30
  /** Containers teardown must also wait out — an overridden proxy's pair. */
31
31
  extraContainers?: string[];
32
+ /** Run the private daemon and its proxy on system-assigned ports where the
33
+ * resolved ctl can. Off by default: turn it on only once every port this
34
+ * harness's caller uses is read from `ports` (or a launch), never from its
35
+ * own `demoPorts(slug)` copy — the daemon is not on the band any more. */
36
+ ephemeralPorts?: boolean;
32
37
  /** File every launched instance id is written to, one per line, BEFORE the
33
38
  * launch — what the convention integration.yml's reap step reads after a
34
39
  * cancelled run, when teardown never ran. Default `$NORSK_LAUNCHED_MANIFEST`;
package/demo/harness.js CHANGED
@@ -54,6 +54,7 @@ export class ProductHarness {
54
54
  ...(opts.proxy !== undefined ? { proxy: opts.proxy } : {}),
55
55
  ...(opts.daemonEnv !== undefined ? { daemonEnv: opts.daemonEnv } : {}),
56
56
  ...(opts.extraContainers !== undefined ? { extraContainers: opts.extraContainers } : {}),
57
+ ...(opts.ephemeralPorts !== undefined ? { ephemeralPorts: opts.ephemeralPorts } : {}),
57
58
  });
58
59
  }
59
60
  /** Every CLI step this fixture ran, as a reader would type it, with its output. */
@@ -265,7 +266,7 @@ export class ProductHarness {
265
266
  if (n > EXTRA_STUDIO_PORTS) {
266
267
  throw new Error(`launch ${n + 1}: the band holds ${EXTRA_STUDIO_PORTS + 1} studio host ports — pass params.${STUDIO_HOST_PORT_PARAM}`);
267
268
  }
268
- return this.ports.proxyPort + n;
269
+ return this.session.band.proxyPort + n;
269
270
  }
270
271
  }
271
272
  function urlResolver(o) {
@@ -366,6 +367,8 @@ export async function runDemo(spec, opts, deps = defaultDemoDeps(opts.cwd)) {
366
367
  ...(opts.daemonPort !== undefined ? { daemonPort: opts.daemonPort } : {}),
367
368
  ...(opts.timeouts !== undefined ? { timeouts: opts.timeouts } : {}),
368
369
  ...(opts.publicHost !== undefined ? { publicHost: opts.publicHost } : {}),
370
+ // The driver owns every port of this run, so none can be left behind.
371
+ ephemeralPorts: true,
369
372
  });
370
373
  if (opts.action === "up") {
371
374
  await refuseIfUp(spec, h.session, deps);
package/demo/run.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { type ManifestSeed } from "@norskvideo/ctl-sdk/manifest-seed";
2
2
  import { ensureRunnerOnNetwork } from "../container-net.js";
3
- import { type DaemonProcess, type StartDaemonOptions } from "../daemon.js";
3
+ import { type DaemonProcess, type RunningDaemon, type StartDaemonOptions } from "../daemon.js";
4
4
  import { type BaseHarnessPorts } from "../harness-config.js";
5
5
  import { type SourceHandle, type SrtPumpTarget } from "../source-pump.js";
6
6
  import type { DemoDaemonPolicy, DemoIngest, DemoMode, DemoSpec, DemoTemplate } from "./spec.js";
@@ -53,7 +53,11 @@ export interface DemoDeps {
53
53
  startDaemon(storeDir: string, options: StartDaemonOptions): {
54
54
  daemon: DaemonProcess;
55
55
  ready: Promise<void>;
56
+ running?: Promise<RunningDaemon>;
56
57
  };
58
+ /** Does the resolved ctl run its daemon and proxy on system-assigned ports?
59
+ * Optional so an existing custom-deps consumer keeps working; absent = no. */
60
+ supportsEphemeralPorts?(): Promise<boolean>;
57
61
  /** Does a daemon answer /api/ready on this port? (A stale state file otherwise.) */
58
62
  daemonAnswers(port: number): Promise<boolean>;
59
63
  cli(storeDir: string, argv: string[]): Promise<CliResult>;
@@ -219,6 +223,11 @@ export interface SessionOptions {
219
223
  daemonEnv?: Record<string, string>;
220
224
  /** Containers teardown must also wait out — an overridden proxy's pair. */
221
225
  extraContainers?: string[];
226
+ /** Private: run the daemon and its proxy on system-assigned ports where the
227
+ * resolved ctl can (the band otherwise). Only for a caller that reads every
228
+ * port from this session: one holding its own copy of the band would be
229
+ * left pointing at nothing. */
230
+ ephemeralPorts?: boolean;
222
231
  }
223
232
  /** The host a demo's daemon-port URLs name.
224
233
  *
@@ -285,13 +294,19 @@ export declare class DemoSession {
285
294
  readonly deps: DemoDeps;
286
295
  readonly mode: DemoMode;
287
296
  readonly policy: DemoDaemonPolicy;
297
+ /** The ports this run uses. The daemon's and the proxy's are the band's
298
+ * until a daemon with system-assigned ports says otherwise (attachDaemon). */
288
299
  readonly ports: DemoPorts;
300
+ /** The slug's band, as allocated: where every port this session does not
301
+ * learn from the daemon comes from. */
302
+ readonly band: DemoPorts;
289
303
  readonly storeDir: string;
290
304
  /** The host every printed URL names. */
291
305
  readonly host: string;
292
306
  readonly publicHost: string | undefined;
293
- /** Scheme + authority of the daemon's proxy, for `proxy` URLs. */
294
- readonly proxyBase: string;
307
+ private readonly proxyScheme;
308
+ private readonly proxyHost;
309
+ private readonly ephemeralPorts;
295
310
  daemon: DaemonProcess | null;
296
311
  dev: ProcessHandle | null;
297
312
  extras: string[];
@@ -302,6 +317,8 @@ export declare class DemoSession {
302
317
  private readonly daemonEnv;
303
318
  private readonly extraContainers;
304
319
  constructor(spec: DemoSpec, slug: string, cwd: string, deps: DemoDeps, opts: SessionOptions);
320
+ /** Scheme + authority of the daemon's proxy, for `proxy` URLs. */
321
+ get proxyBase(): string;
305
322
  get devUrl(): string;
306
323
  templateDir(name: string): string;
307
324
  /** What this daemon's guard demands on `/api/*`. Read from the store, for
package/demo/run.js CHANGED
@@ -31,7 +31,7 @@ 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
33
  import { DOCKER_NETWORK_NAME, ensureRunnerOnNetwork, netReachMode } from "../container-net.js";
34
- import { cleanupDaemon, requireLicenseFile, runCli, startDaemon, } from "../daemon.js";
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";
37
37
  import { pollUntil } from "../poll.js";
@@ -253,6 +253,7 @@ export function defaultDemoDeps(cwd) {
253
253
  },
254
254
  fileExists: (path) => existsSync(path),
255
255
  startDaemon,
256
+ supportsEphemeralPorts: ctlSupportsEphemeralPorts,
256
257
  daemonAnswers: async (port) => {
257
258
  try {
258
259
  const r = await fetch(`http://localhost:${port}/api/ready`, { signal: AbortSignal.timeout(1000) });
@@ -392,13 +393,19 @@ export class DemoSession {
392
393
  deps;
393
394
  mode;
394
395
  policy;
396
+ /** The ports this run uses. The daemon's and the proxy's are the band's
397
+ * until a daemon with system-assigned ports says otherwise (attachDaemon). */
395
398
  ports;
399
+ /** The slug's band, as allocated: where every port this session does not
400
+ * learn from the daemon comes from. */
401
+ band;
396
402
  storeDir;
397
403
  /** The host every printed URL names. */
398
404
  host;
399
405
  publicHost;
400
- /** Scheme + authority of the daemon's proxy, for `proxy` URLs. */
401
- proxyBase;
406
+ proxyScheme;
407
+ proxyHost;
408
+ ephemeralPorts;
402
409
  daemon = null;
403
410
  dev = null;
404
411
  extras = [];
@@ -420,19 +427,27 @@ export class DemoSession {
420
427
  this.host = host;
421
428
  // The proxy is a container even when the daemon is ours; see proxyUrlHost.
422
429
  const proxyHost = proxyUrlHost({ publicHost: opts.publicHost, inContainer: runningInContainer() });
430
+ this.proxyHost = proxyHost;
431
+ this.band = demoPorts(slug);
432
+ this.ephemeralPorts = opts.ephemeralPorts === true;
423
433
  if (opts.daemon === "reuse") {
424
434
  const daemonPort = opts.daemonPort ?? (Number(process.env.NORSK_CTL_PORT) || DEFAULT_DAEMON_PORT);
425
435
  this.storeDir = deps.realStoreDir();
426
436
  const proxy = parseProxyConfig(deps.readFile(join(this.storeDir, "config.yaml")));
427
- this.ports = demoPorts(slug, { daemonPort, proxyPort: proxy.port });
428
- this.proxyBase = `${proxy.scheme}://${proxyHost}:${proxy.port}`;
437
+ // A running daemon publishes its proxy's host port, which is the only
438
+ // answer when docker assigned it (proxyPort 0).
439
+ const published = Number(deps.readFile(join(this.storeDir, PROXY_PORT_FILE))?.trim());
440
+ const proxyPort = Number.isInteger(published) && published > 0 ? published : proxy.port;
441
+ this.ports = demoPorts(slug, { daemonPort, proxyPort });
442
+ this.proxyScheme = proxy.scheme;
429
443
  }
430
444
  else {
431
- this.ports = demoPorts(slug);
445
+ // A recorded run's daemon is wherever it was when the run began.
446
+ this.ports = demoPorts(slug, opts.storeDir && opts.daemonPort !== undefined ? { daemonPort: opts.daemonPort } : {});
432
447
  this.storeDir = opts.storeDir ?? deps.storeDir(slug);
433
448
  // The private daemon is initialised without a cert source, so its proxy
434
449
  // speaks plain http (seen live: https:// gave 000, http:// served the page).
435
- this.proxyBase = `http://${proxyHost}:${this.ports.proxyPort}`;
450
+ this.proxyScheme = "http";
436
451
  }
437
452
  this.proxyAuth = opts.proxy;
438
453
  this.daemonEnv = opts.daemonEnv ?? {};
@@ -443,6 +458,10 @@ export class DemoSession {
443
458
  readyMs: opts.timeouts?.readyMs ?? 180_000,
444
459
  };
445
460
  }
461
+ /** Scheme + authority of the daemon's proxy, for `proxy` URLs. */
462
+ get proxyBase() {
463
+ return `${this.proxyScheme}://${this.proxyHost}:${this.ports.proxyPort}`;
464
+ }
446
465
  get devUrl() {
447
466
  return `http://localhost:${this.ports.backendPort}`;
448
467
  }
@@ -483,6 +502,13 @@ export class DemoSession {
483
502
  }
484
503
  return;
485
504
  }
505
+ // Ports nobody chose, where the ctl can: on a host docker daemon shared by
506
+ // every runner, the band is what two jobs collide on.
507
+ const assigned = this.ephemeralPorts && (await this.deps.supportsEphemeralPorts?.()) === true;
508
+ if (assigned) {
509
+ this.ports.daemonPort = 0;
510
+ this.ports.proxyPort = 0;
511
+ }
486
512
  await this.cliOk([
487
513
  "init",
488
514
  "--network-mode",
@@ -503,16 +529,22 @@ export class DemoSession {
503
529
  "--no-start-server",
504
530
  ...(this.publicHost ? ["--public-host", this.publicHost] : []),
505
531
  ]);
506
- // The instance ports the daemon allocates come from this band too, so two
507
- // products' fixtures on one box never share one (what every product
508
- // harness passed by hand).
509
532
  const started = this.deps.startDaemon(this.storeDir, {
510
533
  port: this.ports.daemonPort,
511
534
  seedConfig: false,
512
- env: { NORSK_CTL_INSTANCE_PORT_BASE: String(this.ports.instancePortBase), ...this.daemonEnv },
535
+ env: { ...this.daemonEnv },
513
536
  });
514
537
  this.daemon = started.daemon;
515
538
  await started.ready;
539
+ if (assigned) {
540
+ const running = await started.running;
541
+ if (running?.proxyPort === undefined) {
542
+ throw new Error(`the daemon came up without publishing ${PROXY_PORT_FILE} in ${this.storeDir} — did its proxy fail to start? (see the daemon log)`);
543
+ }
544
+ this.ports.daemonPort = running.daemonPort;
545
+ this.ports.proxyPort = running.proxyPort;
546
+ this.deps.log(`daemon :${this.ports.daemonPort}, proxy :${this.ports.proxyPort} (system-assigned)`);
547
+ }
516
548
  if (netReachMode() === "direct" && this.deps.ensureNetwork() === "failed") {
517
549
  throw new Error("could not join the runner to norsk-net for direct reach");
518
550
  }
@@ -755,6 +787,7 @@ export async function runExportCheck(spec, opts, deps = defaultDemoDeps(opts.cwd
755
787
  const s = new DemoSession(spec, slug, opts.cwd, deps, {
756
788
  mode: "dev",
757
789
  daemon: "private",
790
+ ephemeralPorts: true,
758
791
  ...(opts.timeouts !== undefined ? { timeouts: opts.timeouts } : {}),
759
792
  });
760
793
  const exportDir = join(s.storeDir, "export");
@@ -14,5 +14,8 @@ export interface BaseHarnessPorts {
14
14
  daemonPort: number;
15
15
  backendPort: number;
16
16
  studioHostPort: number;
17
+ /** Still allocated because product harnesses read it; nothing in ctl ever
18
+ * has (NORSK_CTL_INSTANCE_PORT_BASE was passed and ignored), and the
19
+ * harness no longer passes it. */
17
20
  instancePortBase: number;
18
21
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-test-harness",
3
- "version": "0.1.69",
3
+ "version": "0.1.71",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
package/smoke.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { ensureRunnerOnNetwork, type StudioTarget } from "./container-net.js";
2
- import { type DaemonProcess, type StartDaemonOptions } from "./daemon.js";
2
+ import { type DaemonProcess, type RunningDaemon, type StartDaemonOptions } from "./daemon.js";
3
3
  import { type BaseHarnessPorts } from "./harness-config.js";
4
4
  import { type SourceHandle, type SrtPumpTarget } from "./source-pump.js";
5
5
  import { type ComponentsResponse, type StreamMappingEntry } from "./studio-state.js";
@@ -99,7 +99,9 @@ export interface SmokeSpec {
99
99
  observeMs?: number;
100
100
  clearedMs?: number;
101
101
  };
102
- /** Override the banded ports (see smokePorts). */
102
+ /** Override the banded ports (see smokePorts). Against a ctl that assigns
103
+ * its own daemon and proxy ports (supportsEphemeralPorts) those two come
104
+ * from the daemon instead, whatever is given here. */
103
105
  ports?: Partial<SmokePorts>;
104
106
  }
105
107
  /** What the observation kinds read. Injected so the phase rule is tested
@@ -117,7 +119,12 @@ export interface SmokeDeps {
117
119
  startDaemon(storeDir: string, options: StartDaemonOptions): {
118
120
  daemon: DaemonProcess;
119
121
  ready: Promise<void>;
122
+ running?: Promise<RunningDaemon>;
120
123
  };
124
+ /** Does the resolved ctl run its daemon and proxy on system-assigned ports?
125
+ * Optional so an existing custom-deps consumer keeps working; absent = no,
126
+ * and the band decides both, as before. */
127
+ supportsEphemeralPorts?(): Promise<boolean>;
121
128
  cli(storeDir: string, argv: string[]): Promise<CliResult>;
122
129
  startSources(opts: {
123
130
  daemonPort: number;
package/smoke.js CHANGED
@@ -21,7 +21,7 @@ import { existsSync, writeFileSync } from "node:fs";
21
21
  import { join } from "node:path";
22
22
  import { dumpInstanceContainerLogs, preserveEngineLogs, preserveInstanceContainerLogs } from "./container-logs.js";
23
23
  import { ensureRunnerOnNetwork, netReachMode, STUDIO_INTERNAL_PORT, studioBaseFrom, } from "./container-net.js";
24
- import { cleanupDaemon, requireLicenseFile, runCli, startDaemon, } from "./daemon.js";
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";
27
27
  import { pollUntil } from "./poll.js";
@@ -56,6 +56,7 @@ export const defaultSmokeDeps = {
56
56
  storeDir: (slug) => makeStoreDir(`norsk-smoke-${slug}-`),
57
57
  writeFile: (path, contents) => writeFileSync(path, contents),
58
58
  startDaemon,
59
+ supportsEphemeralPorts: ctlSupportsEphemeralPorts,
59
60
  cli: (storeDir, argv) => runCli(storeDir, ...argv),
60
61
  startSources: startSrtSources,
61
62
  stopSources: async (handles) => {
@@ -213,7 +214,10 @@ export async function runProductSmoke(slug, spec, deps = defaultSmokeDeps) {
213
214
  return r;
214
215
  };
215
216
  const ctx = {
216
- daemonPort: ports.daemonPort,
217
+ // A getter: with system-assigned ports the daemon's is known only once it is up.
218
+ get daemonPort() {
219
+ return ports.daemonPort;
220
+ },
217
221
  instanceId,
218
222
  storeDir,
219
223
  studio,
@@ -228,7 +232,14 @@ export async function runProductSmoke(slug, spec, deps = defaultSmokeDeps) {
228
232
  try {
229
233
  // The same zero point as the guides: `init` on a virgin store, not a seeded
230
234
  // config.yaml. No http redirect (port 80 needs root) and a banded proxy port
231
- // (the default :9443 would meet a co-located integration run).
235
+ // (the default :9443 would meet a co-located integration run) — or, where
236
+ // the ctl can, ports nobody chose: the band is shared by every job on a
237
+ // host docker daemon, and only the system's own allocation is not.
238
+ const assigned = (await deps.supportsEphemeralPorts?.()) === true;
239
+ if (assigned) {
240
+ ports.daemonPort = 0;
241
+ ports.proxyPort = 0;
242
+ }
232
243
  await cliOk([
233
244
  "init",
234
245
  "--network-mode",
@@ -243,6 +254,15 @@ export async function runProductSmoke(slug, spec, deps = defaultSmokeDeps) {
243
254
  const started = deps.startDaemon(storeDir, { port: ports.daemonPort, seedConfig: false });
244
255
  daemon = started.daemon;
245
256
  await started.ready;
257
+ if (assigned) {
258
+ const running = await started.running;
259
+ if (running?.proxyPort === undefined) {
260
+ throw new Error(`the daemon came up without publishing ${PROXY_PORT_FILE} in ${storeDir} — did its proxy fail to start? (see the daemon log)`);
261
+ }
262
+ ports.daemonPort = running.daemonPort;
263
+ ports.proxyPort = running.proxyPort;
264
+ deps.log(`${slug}: daemon :${ports.daemonPort}, proxy :${ports.proxyPort} (system-assigned)`);
265
+ }
246
266
  if (mode === "direct") {
247
267
  // Say which verdict it was. "not-in-container" used to be the SILENT
248
268
  // outcome on every docker-outside-of-docker runner, because the cgroup-only