@norskvideo/ctl-test-harness 0.1.16 → 0.1.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/demo/cli.d.ts +74 -0
- package/demo/cli.js +232 -0
- package/demo/index.d.ts +6 -0
- package/demo/index.js +3 -0
- package/demo/run.d.ts +157 -0
- package/demo/run.js +650 -0
- package/demo/spec.d.ts +122 -0
- package/demo/spec.js +146 -0
- package/package.json +10 -2
- package/smoke.d.ts +12 -3
- package/smoke.js +3 -2
package/demo/run.js
ADDED
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
// The demo driver (fleet review 05-demo s4, s7 step 1): runs a DemoSpec on a
|
|
2
|
+
// PRIVATE daemon — a makeStoreDir store on a banded port, exactly as the
|
|
3
|
+
// product harnesses and the smoke tier do — registers the product from source
|
|
4
|
+
// (`--mode dev`: the dev backend on a driver-chosen port, `product add
|
|
5
|
+
// --dev-url`), launches the template, resolves every source's ingest port from
|
|
6
|
+
// the instance, pumps, gates on `ready` while the sources run, runs `after`,
|
|
7
|
+
// prints `open`, then either holds (`up`, released by Ctrl-C or `demo down`)
|
|
8
|
+
// or tears down and exits (`check`, what CI runs). Teardown is a full
|
|
9
|
+
// `shutdown` (instances, the proxy containers the private daemon created,
|
|
10
|
+
// the daemon) because a demo runs on a developer's box, not a throwaway runner.
|
|
11
|
+
//
|
|
12
|
+
// Every side effect sits behind DemoDeps so the sequencing is unit-tested with
|
|
13
|
+
// fakes; defaultDemoDeps wires the real harness. Argv, not Command: this
|
|
14
|
+
// package does not depend on @norskvideo/ctl-commands (smoke.ts, daemon.ts).
|
|
15
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
16
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, writeFileSync, } from "node:fs";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
19
|
+
import { ensureRunnerOnNetwork, netReachMode, STUDIO_INTERNAL_PORT, studioBaseFrom } from "../container-net.js";
|
|
20
|
+
import { cleanupDaemon, requireLicenseFile, runCli, startDaemon, } from "../daemon.js";
|
|
21
|
+
import { hashSlot } from "../harness-config.js";
|
|
22
|
+
import { ctlSupportsNoPublish, runnerContainerUser } from "../launch.js";
|
|
23
|
+
import { pollUntil } from "../poll.js";
|
|
24
|
+
import { startSrtSources } from "../source-pump.js";
|
|
25
|
+
import { makeStoreDir } from "../temp-dir.js";
|
|
26
|
+
const DEMO_PORT_BASE = 35000;
|
|
27
|
+
const DEMO_BAND_WIDTH = 20;
|
|
28
|
+
const DEMO_BANDS = 50;
|
|
29
|
+
const STUDIO_HOST_PORT_PARAM = "STUDIO_HOST_PORT";
|
|
30
|
+
const NUKE_IMAGE = "alpine:3";
|
|
31
|
+
const DEFAULT_DEV_READY_PATH = "/manifest.json";
|
|
32
|
+
/** Per-slug port band above the smoke tier's (33000-34000), so a demo and a
|
|
33
|
+
* smoke run of the same product never meet. */
|
|
34
|
+
export function demoPorts(slug, overrides = {}) {
|
|
35
|
+
const daemonPort = DEMO_PORT_BASE + hashSlot(`demo:${slug}`, DEMO_BANDS) * DEMO_BAND_WIDTH;
|
|
36
|
+
return {
|
|
37
|
+
daemonPort,
|
|
38
|
+
backendPort: daemonPort + 1,
|
|
39
|
+
studioHostPort: daemonPort + 2,
|
|
40
|
+
proxyPort: daemonPort + 3,
|
|
41
|
+
instancePortBase: daemonPort + 10,
|
|
42
|
+
...overrides,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
export function demoSlug(product) {
|
|
46
|
+
return product.replace(/^norsk-/, "").replace(/[^a-z0-9-]/gi, "-") || "product";
|
|
47
|
+
}
|
|
48
|
+
export function expandHome(path) {
|
|
49
|
+
if (path === "~")
|
|
50
|
+
return homedir();
|
|
51
|
+
if (path.startsWith("~/"))
|
|
52
|
+
return join(homedir(), path.slice(2));
|
|
53
|
+
return path;
|
|
54
|
+
}
|
|
55
|
+
const NO_PARAMS = { declared: new Map(), overrides: {} };
|
|
56
|
+
function effectiveParam(t, name) {
|
|
57
|
+
return t.overrides[name] ?? t.declared.get(name);
|
|
58
|
+
}
|
|
59
|
+
function asPort(value) {
|
|
60
|
+
const n = Number(value);
|
|
61
|
+
return value !== undefined && value !== "" && Number.isInteger(n) && n > 0 && n < 65536 ? n : undefined;
|
|
62
|
+
}
|
|
63
|
+
function describeIngest(rows, t) {
|
|
64
|
+
const offered = rows.length === 0
|
|
65
|
+
? "the instance offers no ingest ports"
|
|
66
|
+
: `the instance offers: ${rows
|
|
67
|
+
.map((r) => {
|
|
68
|
+
const by = r.param ? `param ${r.param}` : r.label ? `label ${r.label}` : r.origin;
|
|
69
|
+
return `${r.port}/${r.proto} (${by})`;
|
|
70
|
+
})
|
|
71
|
+
.join(", ")}`;
|
|
72
|
+
const numeric = [...t.declared.keys()]
|
|
73
|
+
.filter((name) => asPort(effectiveParam(t, name)) !== undefined)
|
|
74
|
+
.map((name) => `${name}=${effectiveParam(t, name)}`);
|
|
75
|
+
return numeric.length ? `${offered}; template parameters: ${numeric.join(", ")}` : offered;
|
|
76
|
+
}
|
|
77
|
+
/** Ports come from the instance and its template, not the spec (05-demo s4).
|
|
78
|
+
* A `param` is first an allocated port the instance reports, else an
|
|
79
|
+
* ordinary template parameter whose effective value (the spec's override,
|
|
80
|
+
* else the template default) is the container port — the `${P}:${P}/udp`
|
|
81
|
+
* idiom every product's SRT ingest uses. A bare number is accepted only when
|
|
82
|
+
* no parameter carries it and the instance offers it as the conventional
|
|
83
|
+
* default, so the demo follows the template when the template moves. */
|
|
84
|
+
export function resolveIngestPort(ingest, rows, t = NO_PARAMS) {
|
|
85
|
+
if ("param" in ingest) {
|
|
86
|
+
const row = rows.find((r) => r.param === ingest.param);
|
|
87
|
+
if (row)
|
|
88
|
+
return row.port;
|
|
89
|
+
if (t.declared.has(ingest.param)) {
|
|
90
|
+
const value = effectiveParam(t, ingest.param);
|
|
91
|
+
const port = asPort(value);
|
|
92
|
+
if (port === undefined)
|
|
93
|
+
throw new Error(`param '${ingest.param}' is '${String(value)}', not a port`);
|
|
94
|
+
return port;
|
|
95
|
+
}
|
|
96
|
+
throw new Error(`no ingest port for param '${ingest.param}'; ${describeIngest(rows, t)}`);
|
|
97
|
+
}
|
|
98
|
+
if ("label" in ingest) {
|
|
99
|
+
const row = rows.find((r) => r.label === ingest.label);
|
|
100
|
+
if (!row)
|
|
101
|
+
throw new Error(`no ingest port labelled '${ingest.label}'; ${describeIngest(rows, t)}`);
|
|
102
|
+
return row.port;
|
|
103
|
+
}
|
|
104
|
+
const byParam = [...t.declared.keys()].find((name) => asPort(effectiveParam(t, name)) === ingest.port);
|
|
105
|
+
if (byParam !== undefined) {
|
|
106
|
+
throw new Error(`restated port ${ingest.port}: it is the template's parameter ${byParam} — name it as ingest: { param: "${byParam}" } so the demo follows the template`);
|
|
107
|
+
}
|
|
108
|
+
const row = rows.find((r) => r.port === ingest.port);
|
|
109
|
+
if (!row)
|
|
110
|
+
throw new Error(`no listener on port ${ingest.port}; ${describeIngest(rows, t)}`);
|
|
111
|
+
if (row.origin !== "default") {
|
|
112
|
+
const name = row.param ? `param: "${row.param}"` : row.label ? `label: "${row.label}"` : null;
|
|
113
|
+
throw new Error(`restated port ${ingest.port}: the template ${row.origin === "allocated" ? "allocated" : "bound"} it${name ? ` — name it as ingest: { ${name} } so the demo follows the template` : ""}`);
|
|
114
|
+
}
|
|
115
|
+
return row.port;
|
|
116
|
+
}
|
|
117
|
+
function parseJson(r, what) {
|
|
118
|
+
try {
|
|
119
|
+
return JSON.parse(r.stdout);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
throw new Error(`${what}: not JSON: ${r.stdout.slice(0, 200)}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function walkSymlinks(dir, out) {
|
|
126
|
+
let names;
|
|
127
|
+
try {
|
|
128
|
+
names = readdirSync(dir);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
for (const name of names) {
|
|
134
|
+
const full = join(dir, name);
|
|
135
|
+
const st = lstatSync(full);
|
|
136
|
+
if (st.isSymbolicLink()) {
|
|
137
|
+
const target = readlinkSync(full);
|
|
138
|
+
const abs = isAbsolute(target) ? target : resolve(dirname(full), target);
|
|
139
|
+
if (!existsSync(abs))
|
|
140
|
+
out.push(`${full} -> ${target}`);
|
|
141
|
+
}
|
|
142
|
+
else if (st.isDirectory()) {
|
|
143
|
+
walkSymlinks(full, out);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
export function findBrokenSymlinks(dir) {
|
|
148
|
+
const out = [];
|
|
149
|
+
walkSymlinks(dir, out);
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
/** `<cwd>/test-temp/demo/<product>.json` — beside the store dirs, inside the
|
|
153
|
+
* consumer's repo, where `down` from another shell can find it. */
|
|
154
|
+
export function fileStateStore(cwd) {
|
|
155
|
+
const dir = join(cwd, "test-temp", "demo");
|
|
156
|
+
const path = (product) => join(dir, `${product}.json`);
|
|
157
|
+
return {
|
|
158
|
+
read: (product) => {
|
|
159
|
+
try {
|
|
160
|
+
return JSON.parse(readFileSync(path(product), "utf8"));
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
write: (state) => {
|
|
167
|
+
mkdirSync(dir, { recursive: true });
|
|
168
|
+
writeFileSync(path(state.product), `${JSON.stringify(state, null, 2)}\n`);
|
|
169
|
+
},
|
|
170
|
+
remove: (product) => rmSync(path(product), { force: true }),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
export function defaultDemoDeps(cwd) {
|
|
174
|
+
return {
|
|
175
|
+
licenseFile: () => requireLicenseFile({ missing: "throw" }),
|
|
176
|
+
storeDir: (slug) => makeStoreDir(`norsk-demo-${slug}-`),
|
|
177
|
+
writeFile: (path, contents) => writeFileSync(path, contents),
|
|
178
|
+
fileExists: (path) => existsSync(path),
|
|
179
|
+
startDaemon,
|
|
180
|
+
daemonAnswers: async (port) => {
|
|
181
|
+
try {
|
|
182
|
+
const r = await fetch(`http://localhost:${port}/api/ready`, { signal: AbortSignal.timeout(1000) });
|
|
183
|
+
return r.ok;
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
cli: (storeDir, argv) => runCli(storeDir, ...argv),
|
|
190
|
+
startProcess: (command, opts) => {
|
|
191
|
+
const [cmd, ...args] = command;
|
|
192
|
+
const proc = spawn(cmd, args, { cwd: opts.cwd, stdio: "inherit", env: { ...process.env, ...opts.env } });
|
|
193
|
+
const exited = new Promise((res) => proc.once("exit", (code) => res(code)));
|
|
194
|
+
return {
|
|
195
|
+
pid: proc.pid,
|
|
196
|
+
kill: () => {
|
|
197
|
+
try {
|
|
198
|
+
proc.kill("SIGTERM");
|
|
199
|
+
}
|
|
200
|
+
catch { }
|
|
201
|
+
},
|
|
202
|
+
exited,
|
|
203
|
+
};
|
|
204
|
+
},
|
|
205
|
+
fetch: (url, init) => fetch(url, { signal: AbortSignal.timeout(5000), ...init }),
|
|
206
|
+
startSources: startSrtSources,
|
|
207
|
+
stopSources: async (handles) => {
|
|
208
|
+
for (const h of handles)
|
|
209
|
+
await h.stop();
|
|
210
|
+
},
|
|
211
|
+
cleanup: (opts) => cleanupDaemon({ ...opts, stopProxy: async () => { }, proxy: false }),
|
|
212
|
+
nukeStoreAsRoot: (storeDir) => {
|
|
213
|
+
spawnSync("docker", [
|
|
214
|
+
"run",
|
|
215
|
+
"--rm",
|
|
216
|
+
"--user",
|
|
217
|
+
"0:0",
|
|
218
|
+
"-v",
|
|
219
|
+
`${dirname(storeDir)}:/base`,
|
|
220
|
+
"--entrypoint",
|
|
221
|
+
"sh",
|
|
222
|
+
NUKE_IMAGE,
|
|
223
|
+
"-c",
|
|
224
|
+
`rm -rf /base/${basename(storeDir)}`,
|
|
225
|
+
]);
|
|
226
|
+
},
|
|
227
|
+
storeExists: (storeDir) => existsSync(storeDir),
|
|
228
|
+
ensureNetwork: () => ensureRunnerOnNetwork(),
|
|
229
|
+
supportsNoPublish: ctlSupportsNoPublish,
|
|
230
|
+
containerUser: runnerContainerUser,
|
|
231
|
+
brokenSymlinks: findBrokenSymlinks,
|
|
232
|
+
state: fileStateStore(cwd),
|
|
233
|
+
hold: (abort) => new Promise((res) => {
|
|
234
|
+
if (abort?.aborted)
|
|
235
|
+
return res();
|
|
236
|
+
abort?.addEventListener("abort", () => res(), { once: true });
|
|
237
|
+
if (!abort) {
|
|
238
|
+
const release = () => res();
|
|
239
|
+
process.once("SIGINT", release);
|
|
240
|
+
process.once("SIGTERM", release);
|
|
241
|
+
}
|
|
242
|
+
}),
|
|
243
|
+
killPid: (pid) => {
|
|
244
|
+
try {
|
|
245
|
+
process.kill(pid, "SIGTERM");
|
|
246
|
+
}
|
|
247
|
+
catch { }
|
|
248
|
+
},
|
|
249
|
+
log: (line) => console.log(`[demo] ${line}`),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
/** The pieces of a run that both `runDemo` and `runExportCheck` share: the
|
|
253
|
+
* private daemon on its band, the dev backend on the driver's port, the
|
|
254
|
+
* registration, and the template (built or chosen). */
|
|
255
|
+
class DemoSession {
|
|
256
|
+
spec;
|
|
257
|
+
slug;
|
|
258
|
+
cwd;
|
|
259
|
+
deps;
|
|
260
|
+
ports;
|
|
261
|
+
storeDir;
|
|
262
|
+
daemon = null;
|
|
263
|
+
dev = null;
|
|
264
|
+
timeouts;
|
|
265
|
+
constructor(spec, slug, cwd, deps, timeouts) {
|
|
266
|
+
this.spec = spec;
|
|
267
|
+
this.slug = slug;
|
|
268
|
+
this.cwd = cwd;
|
|
269
|
+
this.deps = deps;
|
|
270
|
+
this.ports = demoPorts(slug);
|
|
271
|
+
this.storeDir = deps.storeDir(slug);
|
|
272
|
+
this.timeouts = {
|
|
273
|
+
devReadyMs: timeouts?.devReadyMs ?? 120_000,
|
|
274
|
+
healthyMs: timeouts?.healthyMs ?? 180_000,
|
|
275
|
+
readyMs: timeouts?.readyMs ?? 180_000,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
get devUrl() {
|
|
279
|
+
return `http://localhost:${this.ports.backendPort}`;
|
|
280
|
+
}
|
|
281
|
+
cli = async (argv, opts = {}) => this.deps.cli(this.storeDir, [
|
|
282
|
+
"--port",
|
|
283
|
+
String(this.ports.daemonPort),
|
|
284
|
+
...argv,
|
|
285
|
+
...(opts.output ? ["-o", opts.output] : []),
|
|
286
|
+
]);
|
|
287
|
+
cliOk = async (argv, opts = {}) => {
|
|
288
|
+
const r = await this.cli(argv, opts);
|
|
289
|
+
if (r.exitCode !== 0) {
|
|
290
|
+
throw new Error(`norsk-ctl ${argv.join(" ")} failed (exit ${r.exitCode}):\n${r.stderr || r.stdout}`);
|
|
291
|
+
}
|
|
292
|
+
return r;
|
|
293
|
+
};
|
|
294
|
+
async startDaemon() {
|
|
295
|
+
await this.cliOk([
|
|
296
|
+
"init",
|
|
297
|
+
"--network-mode",
|
|
298
|
+
"docker",
|
|
299
|
+
"--working-directory",
|
|
300
|
+
join(this.storeDir, "norsk-runtime"),
|
|
301
|
+
"--proxy-port",
|
|
302
|
+
String(this.ports.proxyPort),
|
|
303
|
+
"--no-http-redirect",
|
|
304
|
+
"--no-start-server",
|
|
305
|
+
]);
|
|
306
|
+
const started = this.deps.startDaemon(this.storeDir, { port: this.ports.daemonPort, seedConfig: false });
|
|
307
|
+
this.daemon = started.daemon;
|
|
308
|
+
await started.ready;
|
|
309
|
+
if (netReachMode() === "direct" && this.deps.ensureNetwork() === "failed") {
|
|
310
|
+
throw new Error("could not join the runner to norsk-net for direct reach");
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
async startDev() {
|
|
314
|
+
this.dev = this.deps.startProcess(this.spec.dev.command, {
|
|
315
|
+
cwd: this.cwd,
|
|
316
|
+
env: { PORT: String(this.ports.backendPort) },
|
|
317
|
+
});
|
|
318
|
+
const url = `${this.devUrl}${this.spec.dev.readyPath ?? DEFAULT_DEV_READY_PATH}`;
|
|
319
|
+
await pollUntil(async () => {
|
|
320
|
+
const r = await this.deps.fetch(url);
|
|
321
|
+
return r.ok;
|
|
322
|
+
}, { timeoutMs: this.timeouts.devReadyMs, intervalMs: 1000, label: `dev backend did not answer at ${url}` });
|
|
323
|
+
}
|
|
324
|
+
async register(opts) {
|
|
325
|
+
const argv = ["product", "add", "--dev-url", this.devUrl];
|
|
326
|
+
if (opts.licence)
|
|
327
|
+
argv.push("--license-file", this.deps.licenseFile());
|
|
328
|
+
const added = parseJson(await this.cliOk(argv, { output: "json" }), "product add");
|
|
329
|
+
if (added.name !== this.spec.product) {
|
|
330
|
+
throw new Error(`product add registered '${added.name}', expected '${this.spec.product}'`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
/** The template to launch: built from the spec's input, or a name the
|
|
334
|
+
* product publishes (its first default when the spec names none). */
|
|
335
|
+
async resolveTemplate() {
|
|
336
|
+
const t = this.spec.template;
|
|
337
|
+
if (t && "build" in t) {
|
|
338
|
+
const name = t.build.name ?? `${this.spec.product}-demo`;
|
|
339
|
+
let inputPath;
|
|
340
|
+
if (typeof t.build.input === "string") {
|
|
341
|
+
inputPath = resolve(this.cwd, expandHome(t.build.input));
|
|
342
|
+
if (!this.deps.fileExists(inputPath))
|
|
343
|
+
throw new Error(`template input not found: ${inputPath}`);
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
inputPath = join(this.storeDir, `demo-${name}-input.json`);
|
|
347
|
+
this.deps.writeFile(inputPath, JSON.stringify(t.build.input));
|
|
348
|
+
}
|
|
349
|
+
await this.cliOk(["template", "build", name, "--product", this.spec.product, "--input", inputPath, "--replace"]);
|
|
350
|
+
return name;
|
|
351
|
+
}
|
|
352
|
+
const listed = parseJson(await this.cliOk(["template", "list"], { output: "json" }), "template list");
|
|
353
|
+
const published = (listed.productTemplates ?? []).filter((x) => x.source?.productName === this.spec.product);
|
|
354
|
+
const names = published.map((x) => x.name);
|
|
355
|
+
if (t) {
|
|
356
|
+
if (!names.includes(t.name)) {
|
|
357
|
+
throw new Error(`template '${t.name}' is not one ${this.spec.product} publishes; it publishes: ${names.join(", ") || "(none)"}`);
|
|
358
|
+
}
|
|
359
|
+
return t.name;
|
|
360
|
+
}
|
|
361
|
+
const first = names[0];
|
|
362
|
+
if (!first)
|
|
363
|
+
throw new Error(`${this.spec.product} publishes no default template; name one with template: { build }`);
|
|
364
|
+
return first;
|
|
365
|
+
}
|
|
366
|
+
/** name -> stringified default, from `template show`. */
|
|
367
|
+
async declaredParams(templateName) {
|
|
368
|
+
const shown = await this.cli(["template", "show", templateName], { output: "json" });
|
|
369
|
+
if (shown.exitCode !== 0)
|
|
370
|
+
return new Map();
|
|
371
|
+
const parameters = parseJson(shown, "template show").parameters ?? [];
|
|
372
|
+
return new Map(parameters.map((p) => [p.name, p.default]));
|
|
373
|
+
}
|
|
374
|
+
async waitRunning(instanceId) {
|
|
375
|
+
await pollUntil(async () => {
|
|
376
|
+
const r = await this.cli(["instance", "list"], { output: "json" });
|
|
377
|
+
if (r.exitCode !== 0)
|
|
378
|
+
return false;
|
|
379
|
+
const inst = parseJson(r, "instance list").instances?.find((i) => i.id === instanceId);
|
|
380
|
+
return inst?.status === "running" || inst?.status === "healthy";
|
|
381
|
+
}, { timeoutMs: this.timeouts.healthyMs, intervalMs: 1000, label: `instance ${instanceId} never reported running` });
|
|
382
|
+
}
|
|
383
|
+
async ingestPorts(instanceId) {
|
|
384
|
+
const r = await this.cliOk(["instance", "describe", instanceId], { output: "json" });
|
|
385
|
+
return parseJson(r, "instance describe").ingestPorts ?? [];
|
|
386
|
+
}
|
|
387
|
+
gateTimeout(ms) {
|
|
388
|
+
return ms ?? this.timeouts.readyMs;
|
|
389
|
+
}
|
|
390
|
+
/** Tear down in reverse: sources, instance, daemon (with its proxy), dev backend, store. */
|
|
391
|
+
async teardown(opts) {
|
|
392
|
+
if (opts.handles.length)
|
|
393
|
+
await this.deps.stopSources(opts.handles).catch(() => { });
|
|
394
|
+
try {
|
|
395
|
+
await this.deps.cleanup({
|
|
396
|
+
deleteInstance: (id) => this.cli(["instance", "delete", id, "--purge"]),
|
|
397
|
+
stopDaemon: () => this.cli(["shutdown"]),
|
|
398
|
+
instances: opts.instances,
|
|
399
|
+
daemon: this.daemon,
|
|
400
|
+
storeDir: this.storeDir,
|
|
401
|
+
containers: opts.instances.flatMap((id) => [`norsk-inst-${id}-studio`, `norsk-inst-${id}-media`]),
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
catch (e) {
|
|
405
|
+
this.deps.log(`cleanup could not remove the store itself (${e instanceof Error ? e.message : String(e)}); nuking as root`);
|
|
406
|
+
}
|
|
407
|
+
this.dev?.kill();
|
|
408
|
+
this.deps.nukeStoreAsRoot(this.storeDir);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
function urlResolver(o) {
|
|
412
|
+
const host = process.env.NORSK_TEST_HOST ?? "localhost";
|
|
413
|
+
const mode = netReachMode();
|
|
414
|
+
return (ref) => {
|
|
415
|
+
if ("url" in ref)
|
|
416
|
+
return ref.url;
|
|
417
|
+
if ("control" in ref)
|
|
418
|
+
return `http://localhost:${o.ports.backendPort}${ref.control}`;
|
|
419
|
+
if ("proxy" in ref)
|
|
420
|
+
return `https://${host}:${o.ports.proxyPort}${ref.proxy.replaceAll("{id}", o.instanceId)}`;
|
|
421
|
+
return `${studioBaseFrom({ instanceId: o.instanceId, studioHostPort: o.studioHostPort }, host, mode)}${ref.studio}`;
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
async function gateHolds(gate, ctx) {
|
|
425
|
+
if ("custom" in gate)
|
|
426
|
+
return gate.custom(ctx);
|
|
427
|
+
const r = await ctx.fetch(ctx.url(gate.http));
|
|
428
|
+
if (r.status !== (gate.status ?? 200))
|
|
429
|
+
return false;
|
|
430
|
+
if (gate.bodyIncludes === undefined)
|
|
431
|
+
return true;
|
|
432
|
+
return (await r.text()).includes(gate.bodyIncludes);
|
|
433
|
+
}
|
|
434
|
+
function gateName(gate, ctx) {
|
|
435
|
+
if ("custom" in gate)
|
|
436
|
+
return gate.label ?? "custom gate";
|
|
437
|
+
return `${ctx.url(gate.http)} -> ${gate.status ?? 200}${gate.bodyIncludes ? ` containing '${gate.bodyIncludes}'` : ""}`;
|
|
438
|
+
}
|
|
439
|
+
async function resolveOpen(spec, ctx) {
|
|
440
|
+
const out = [];
|
|
441
|
+
for (const o of spec.open ?? []) {
|
|
442
|
+
const url = ctx.url(o.url);
|
|
443
|
+
if (!o.pick) {
|
|
444
|
+
out.push({ name: o.name, value: url });
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
try {
|
|
448
|
+
const body = await (await ctx.fetch(url)).text();
|
|
449
|
+
const m = body.match(new RegExp(o.pick));
|
|
450
|
+
out.push({ name: o.name, value: m ? m[0] : `<no match for /${o.pick}/ at ${url}>` });
|
|
451
|
+
}
|
|
452
|
+
catch (e) {
|
|
453
|
+
out.push({ name: o.name, value: `<unavailable: ${e instanceof Error ? e.message : String(e)}>` });
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return out;
|
|
457
|
+
}
|
|
458
|
+
export async function runDemo(spec, opts, deps = defaultDemoDeps(opts.cwd)) {
|
|
459
|
+
const slug = opts.slug ?? demoSlug(spec.product);
|
|
460
|
+
const instanceId = `demo-${slug}`;
|
|
461
|
+
if (opts.action === "up") {
|
|
462
|
+
const prev = deps.state.read(spec.product);
|
|
463
|
+
if (prev) {
|
|
464
|
+
if (await deps.daemonAnswers(prev.daemonPort)) {
|
|
465
|
+
throw new Error(`demo '${spec.product}' is already up (daemon :${prev.daemonPort}, store ${prev.storeDir}) — run \`demo down\` first`);
|
|
466
|
+
}
|
|
467
|
+
deps.log(`forgetting a stale record of a run on :${prev.daemonPort} (nothing answers there)`);
|
|
468
|
+
deps.state.remove(spec.product);
|
|
469
|
+
}
|
|
470
|
+
for (const p of spec.prerequisites ?? []) {
|
|
471
|
+
const path = expandHome(p.path);
|
|
472
|
+
if (!deps.fileExists(path))
|
|
473
|
+
throw new Error(`prerequisite missing: ${path}\n make it with: ${p.hint}`);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
const s = new DemoSession(spec, slug, opts.cwd, deps, opts.timeouts);
|
|
477
|
+
let launched = false;
|
|
478
|
+
let handles = [];
|
|
479
|
+
let recorded = false;
|
|
480
|
+
let result;
|
|
481
|
+
let journeyError;
|
|
482
|
+
try {
|
|
483
|
+
await s.startDaemon();
|
|
484
|
+
await s.startDev();
|
|
485
|
+
await s.register({ licence: true });
|
|
486
|
+
const templateName = await s.resolveTemplate();
|
|
487
|
+
const declared = await s.declaredParams(templateName);
|
|
488
|
+
const specParams = spec.launch?.params ?? {};
|
|
489
|
+
const studioHostPort = specParams[STUDIO_HOST_PORT_PARAM] !== undefined
|
|
490
|
+
? Number(specParams[STUDIO_HOST_PORT_PARAM])
|
|
491
|
+
: s.ports.studioHostPort;
|
|
492
|
+
const params = [`INSTANCE_NAME=${instanceId}`, ...Object.entries(specParams).map(([k, v]) => `${k}=${v}`)];
|
|
493
|
+
const launchArgv = ["instance", "launch-template", instanceId, "--template", templateName];
|
|
494
|
+
const internalOnly = netReachMode() === "direct" && (await deps.supportsNoPublish());
|
|
495
|
+
if (declared.has(STUDIO_HOST_PORT_PARAM)) {
|
|
496
|
+
if (specParams[STUDIO_HOST_PORT_PARAM] === undefined)
|
|
497
|
+
params.push(`${STUDIO_HOST_PORT_PARAM}=${studioHostPort}`);
|
|
498
|
+
}
|
|
499
|
+
else if (!internalOnly) {
|
|
500
|
+
launchArgv.push("--host-ports", `${studioHostPort}:${STUDIO_INTERNAL_PORT}@studio`);
|
|
501
|
+
}
|
|
502
|
+
for (const p of params)
|
|
503
|
+
launchArgv.push("--param", p);
|
|
504
|
+
if (spec.launch?.hardware)
|
|
505
|
+
launchArgv.push("--hardware", spec.launch.hardware);
|
|
506
|
+
if (opts.action === "up" && spec.launch?.workingDirectory) {
|
|
507
|
+
launchArgv.push("--working-directory", expandHome(spec.launch.workingDirectory));
|
|
508
|
+
}
|
|
509
|
+
const user = deps.containerUser();
|
|
510
|
+
if (user)
|
|
511
|
+
launchArgv.push("--container-user", user);
|
|
512
|
+
if (internalOnly)
|
|
513
|
+
launchArgv.push("--internal-only");
|
|
514
|
+
launched = true;
|
|
515
|
+
await s.cliOk(launchArgv);
|
|
516
|
+
await s.waitRunning(instanceId);
|
|
517
|
+
const rows = await s.ingestPorts(instanceId);
|
|
518
|
+
const templateParams = { declared, overrides: specParams };
|
|
519
|
+
const targets = (spec.sources ?? []).map((src) => ({
|
|
520
|
+
port: resolveIngestPort(src.ingest, rows, templateParams),
|
|
521
|
+
name: src.name,
|
|
522
|
+
asset: src.asset ?? { preset: "camera1" },
|
|
523
|
+
...(src.streamId !== undefined ? { streamId: src.streamId } : {}),
|
|
524
|
+
}));
|
|
525
|
+
const ctx = {
|
|
526
|
+
action: opts.action,
|
|
527
|
+
product: spec.product,
|
|
528
|
+
instanceId,
|
|
529
|
+
daemonPort: s.ports.daemonPort,
|
|
530
|
+
storeDir: s.storeDir,
|
|
531
|
+
url: urlResolver({ instanceId, ports: s.ports, studioHostPort }),
|
|
532
|
+
cli: s.cli,
|
|
533
|
+
fetch: deps.fetch,
|
|
534
|
+
log: deps.log,
|
|
535
|
+
};
|
|
536
|
+
// Sources first: a gate such as "the switcher is composing" or "the probe
|
|
537
|
+
// is analysing" can only hold with something on the wire.
|
|
538
|
+
if (targets.length) {
|
|
539
|
+
handles = await deps.startSources({ daemonPort: s.ports.daemonPort, instanceId, targets });
|
|
540
|
+
deps.log(`sources: ${targets.map((t) => `${t.name} -> :${t.port}`).join(", ")}`);
|
|
541
|
+
}
|
|
542
|
+
for (const gate of spec.ready ?? []) {
|
|
543
|
+
const name = gateName(gate, ctx);
|
|
544
|
+
await pollUntil(() => gateHolds(gate, ctx), {
|
|
545
|
+
timeoutMs: s.gateTimeout(gate.timeoutMs),
|
|
546
|
+
intervalMs: 1000,
|
|
547
|
+
label: `ready gate never held: ${name}`,
|
|
548
|
+
});
|
|
549
|
+
deps.log(`ready: ${name}`);
|
|
550
|
+
}
|
|
551
|
+
if (spec.after)
|
|
552
|
+
await spec.after(ctx);
|
|
553
|
+
const open = await resolveOpen(spec, ctx);
|
|
554
|
+
const width = Math.max(0, ...open.map((o) => o.name.length));
|
|
555
|
+
deps.log(`${spec.product} demo is ${opts.action === "up" ? "UP" : "up (check)"} — instance ${instanceId}`);
|
|
556
|
+
for (const o of open)
|
|
557
|
+
deps.log(` ${o.name.padEnd(width)} ${o.value}`);
|
|
558
|
+
result = { instanceId, daemonPort: s.ports.daemonPort, storeDir: s.storeDir, open };
|
|
559
|
+
if (opts.action === "up") {
|
|
560
|
+
deps.state.write({
|
|
561
|
+
product: spec.product,
|
|
562
|
+
storeDir: s.storeDir,
|
|
563
|
+
daemonPort: s.ports.daemonPort,
|
|
564
|
+
instanceId,
|
|
565
|
+
...(s.dev?.pid !== undefined ? { devPid: s.dev.pid } : {}),
|
|
566
|
+
});
|
|
567
|
+
recorded = true;
|
|
568
|
+
deps.log("holding — Ctrl-C (or `demo down` from another shell) tears down");
|
|
569
|
+
await deps.hold(opts.abort);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
catch (e) {
|
|
573
|
+
journeyError = e;
|
|
574
|
+
}
|
|
575
|
+
await s.teardown({ instances: launched ? [instanceId] : [], handles });
|
|
576
|
+
if (recorded)
|
|
577
|
+
deps.state.remove(spec.product);
|
|
578
|
+
if (journeyError !== undefined)
|
|
579
|
+
throw journeyError;
|
|
580
|
+
if (deps.storeExists(s.storeDir))
|
|
581
|
+
throw new Error(`store ${s.storeDir} still present after the root-container nuke`);
|
|
582
|
+
return result;
|
|
583
|
+
}
|
|
584
|
+
/** `demo check --mode standalone --export-only` (05-demo s4): build the
|
|
585
|
+
* template, export the standalone workdir with the spec's live-source links,
|
|
586
|
+
* assert every symlink resolves. No instance, no licence — the daemon's own
|
|
587
|
+
* preflight still wants a Docker socket, but nothing is launched. This is the
|
|
588
|
+
* path that rotted (05-demo s1) and it is cheap enough to gate every push. */
|
|
589
|
+
export async function runExportCheck(spec, opts, deps = defaultDemoDeps(opts.cwd)) {
|
|
590
|
+
const slug = opts.slug ?? demoSlug(spec.product);
|
|
591
|
+
const s = new DemoSession(spec, slug, opts.cwd, deps, opts.timeouts);
|
|
592
|
+
const exportDir = join(s.storeDir, "export");
|
|
593
|
+
let journeyError;
|
|
594
|
+
try {
|
|
595
|
+
await s.startDaemon();
|
|
596
|
+
await s.startDev();
|
|
597
|
+
await s.register({ licence: false });
|
|
598
|
+
const templateName = await s.resolveTemplate();
|
|
599
|
+
const argv = ["template", "export-workdir", templateName, "--to", exportDir];
|
|
600
|
+
for (const [k, v] of Object.entries(spec.standalone?.params ?? {}))
|
|
601
|
+
argv.push("--param", `${k}=${v}`);
|
|
602
|
+
for (const [pkg, path] of Object.entries(spec.standalone?.links ?? {})) {
|
|
603
|
+
argv.push("--link-component", `${pkg}=${resolve(opts.cwd, path)}`);
|
|
604
|
+
}
|
|
605
|
+
if (spec.standalone?.dashboards)
|
|
606
|
+
argv.push("--link-dashboards", resolve(opts.cwd, spec.standalone.dashboards));
|
|
607
|
+
await s.cliOk(argv);
|
|
608
|
+
const broken = deps.brokenSymlinks(exportDir);
|
|
609
|
+
if (broken.length) {
|
|
610
|
+
throw new Error(`exported workdir has ${broken.length} broken symlink(s):\n ${broken.join("\n ")}`);
|
|
611
|
+
}
|
|
612
|
+
deps.log(`export ok: ${templateName} -> ${exportDir}, every symlink resolves`);
|
|
613
|
+
}
|
|
614
|
+
catch (e) {
|
|
615
|
+
journeyError = e;
|
|
616
|
+
}
|
|
617
|
+
await s.teardown({ instances: [], handles: [] });
|
|
618
|
+
if (journeyError !== undefined)
|
|
619
|
+
throw journeyError;
|
|
620
|
+
return { exportDir };
|
|
621
|
+
}
|
|
622
|
+
/** `demo down`: tear down the run `up` recorded, from any shell. */
|
|
623
|
+
export async function demoDown(product, deps = defaultDemoDeps(process.cwd())) {
|
|
624
|
+
const state = deps.state.read(product);
|
|
625
|
+
if (!state) {
|
|
626
|
+
deps.log(`nothing recorded as up for ${product}`);
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
const cli = (argv) => deps.cli(state.storeDir, ["--port", String(state.daemonPort), ...argv]);
|
|
630
|
+
try {
|
|
631
|
+
await deps.cleanup({
|
|
632
|
+
deleteInstance: (id) => cli(["instance", "delete", id, "--purge"]),
|
|
633
|
+
stopDaemon: () => cli(["shutdown"]),
|
|
634
|
+
instances: state.instanceId ? [state.instanceId] : [],
|
|
635
|
+
daemon: null,
|
|
636
|
+
storeDir: state.storeDir,
|
|
637
|
+
containers: state.instanceId
|
|
638
|
+
? [`norsk-inst-${state.instanceId}-studio`, `norsk-inst-${state.instanceId}-media`]
|
|
639
|
+
: [],
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
catch (e) {
|
|
643
|
+
deps.log(`cleanup: ${e instanceof Error ? e.message : String(e)}; nuking the store as root`);
|
|
644
|
+
}
|
|
645
|
+
if (state.devPid !== undefined)
|
|
646
|
+
deps.killPid?.(state.devPid);
|
|
647
|
+
deps.nukeStoreAsRoot(state.storeDir);
|
|
648
|
+
deps.state.remove(product);
|
|
649
|
+
deps.log(`${product} demo torn down`);
|
|
650
|
+
}
|