@norskvideo/ctl-test-harness 0.1.69 → 0.1.70
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 +39 -4
- package/daemon.js +93 -15
- package/demo/cli.d.ts +7 -0
- package/demo/cli.js +10 -4
- package/demo/dev-loop.js +7 -1
- package/demo/harness.d.ts +5 -0
- package/demo/harness.js +4 -1
- package/demo/run.d.ts +20 -3
- package/demo/run.js +44 -11
- package/harness-config.d.ts +3 -0
- package/package.json +1 -1
- package/smoke.d.ts +9 -2
- package/smoke.js +23 -3
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
|
-
|
|
177
|
-
//
|
|
178
|
-
|
|
179
|
-
|
|
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] =
|
|
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(
|
|
259
|
+
proc.once("error", (e) => reject(new Error(cliUnavailableMessage(command, e.message))));
|
|
195
260
|
});
|
|
196
|
-
const
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
timeoutMs:
|
|
203
|
-
|
|
204
|
-
|
|
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" });
|
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
|
|
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}:${
|
|
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
|
|
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, {
|
|
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.
|
|
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
|
-
|
|
294
|
-
readonly
|
|
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
|
-
|
|
401
|
-
|
|
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
|
-
|
|
428
|
-
|
|
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
|
-
|
|
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.
|
|
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: {
|
|
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");
|
package/harness-config.d.ts
CHANGED
|
@@ -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
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
|
-
|
|
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
|