@norskvideo/ctl-test-harness 0.1.20 → 0.1.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/compose-advancing.d.ts +26 -0
- package/compose-advancing.js +61 -0
- package/container-logs.d.ts +48 -0
- package/container-logs.js +101 -0
- package/demo/cli.d.ts +6 -2
- package/demo/cli.js +37 -11
- package/demo/gates.d.ts +22 -0
- package/demo/gates.js +56 -0
- package/demo/index.d.ts +4 -3
- package/demo/index.js +2 -1
- package/demo/run.d.ts +37 -3
- package/demo/run.js +348 -70
- package/demo/spec.d.ts +36 -4
- package/demo/spec.js +17 -0
- package/package.json +9 -1
package/demo/run.js
CHANGED
|
@@ -1,13 +1,25 @@
|
|
|
1
|
-
// The demo driver (fleet review 05-demo s4, s7
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
1
|
+
// The demo driver (fleet review 05-demo s4, s7 steps 1-2): runs a DemoSpec on
|
|
2
|
+
// a daemon, registers the product — `--mode dev`: the dev backend on a
|
|
3
|
+
// driver-chosen port, `product add --dev-url`; `--mode image`: the built
|
|
4
|
+
// product image, `product add --image`, the customer path — launches the
|
|
5
|
+
// template, resolves every source's ingest port from the instance, pumps,
|
|
6
|
+
// gates on `ready` while the sources run, runs `after`, prints `open`, then
|
|
7
|
+
// either holds (`up`, released by Ctrl-C or `demo down`) or tears down and
|
|
8
|
+
// exits (`check`, what CI runs).
|
|
9
|
+
//
|
|
10
|
+
// Whose daemon is a policy. `private` (the default, and always for `check`):
|
|
11
|
+
// a makeStoreDir store on a banded port, exactly as the product harnesses and
|
|
12
|
+
// the smoke tier do; teardown is a full `shutdown` (instances, the proxy
|
|
13
|
+
// containers the private daemon created, the daemon) because a demo runs on a
|
|
14
|
+
// developer's box, not a throwaway runner. `reuse`: the developer's listening
|
|
15
|
+
// daemon and real store, where `product add` refuses an existing name and
|
|
16
|
+
// stored templates are immutable — so the driver runs the mandatory sequence
|
|
17
|
+
// (delete the instance, remove the product's templates and the product, drop
|
|
18
|
+
// the control-plane container) before `product add`, verifies the re-rendered
|
|
19
|
+
// template's image pins against manifest.seed.json (funke's iterate.sh pin
|
|
20
|
+
// guard, generalised), pumps with the proxy secret, and at teardown removes
|
|
21
|
+
// only the instance: the daemon, its store and the registration are the
|
|
22
|
+
// developer's.
|
|
11
23
|
//
|
|
12
24
|
// Every side effect sits behind DemoDeps so the sequencing is unit-tested with
|
|
13
25
|
// fakes; defaultDemoDeps wires the real harness. Argv, not Command: this
|
|
@@ -16,19 +28,27 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
16
28
|
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, writeFileSync, } from "node:fs";
|
|
17
29
|
import { homedir } from "node:os";
|
|
18
30
|
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
19
|
-
import {
|
|
31
|
+
import { parseManifestSeed, repoOf } from "@norskvideo/ctl-sdk/manifest-seed";
|
|
32
|
+
import { DOCKER_NETWORK_NAME, ensureRunnerOnNetwork, netReachMode, STUDIO_INTERNAL_PORT, studioBaseFrom, } from "../container-net.js";
|
|
20
33
|
import { cleanupDaemon, requireLicenseFile, runCli, startDaemon, } from "../daemon.js";
|
|
21
34
|
import { hashSlot } from "../harness-config.js";
|
|
22
35
|
import { ctlSupportsNoPublish, runnerContainerUser } from "../launch.js";
|
|
23
36
|
import { pollUntil } from "../poll.js";
|
|
24
37
|
import { startSrtSources } from "../source-pump.js";
|
|
25
38
|
import { makeStoreDir } from "../temp-dir.js";
|
|
39
|
+
import { DemoGateFailure } from "./gates.js";
|
|
26
40
|
const DEMO_PORT_BASE = 35000;
|
|
27
41
|
const DEMO_BAND_WIDTH = 20;
|
|
28
42
|
const DEMO_BANDS = 50;
|
|
43
|
+
const DEFAULT_DAEMON_PORT = 8333;
|
|
44
|
+
const DEFAULT_PROXY_PORT = 443;
|
|
29
45
|
const STUDIO_HOST_PORT_PARAM = "STUDIO_HOST_PORT";
|
|
30
46
|
const NUKE_IMAGE = "alpine:3";
|
|
31
47
|
const DEFAULT_DEV_READY_PATH = "/manifest.json";
|
|
48
|
+
const SEED_FILE = "manifest.seed.json";
|
|
49
|
+
const TEMPLATES_DIR = "product-templates";
|
|
50
|
+
const PROXY_SECRET_FILE = "proxy-secret";
|
|
51
|
+
const EXTRA_LABEL = "norsk-ctl.demo";
|
|
32
52
|
/** Per-slug port band above the smoke tier's (33000-34000), so a demo and a
|
|
33
53
|
* smoke run of the same product never meet. */
|
|
34
54
|
export function demoPorts(slug, overrides = {}) {
|
|
@@ -114,6 +134,29 @@ export function resolveIngestPort(ingest, rows, t = NO_PARAMS) {
|
|
|
114
134
|
}
|
|
115
135
|
return row.port;
|
|
116
136
|
}
|
|
137
|
+
/** Image pins are read, never typed (05-demo s4): for every repo the seed's
|
|
138
|
+
* `latest` names (studio, media), the stored compose must pin the same ref.
|
|
139
|
+
* A repo the compose does not name is not a mismatch — the guard is about a
|
|
140
|
+
* stale snapshot, not template completeness. */
|
|
141
|
+
export function checkTemplatePins(composeText, seed) {
|
|
142
|
+
const stored = [...composeText.matchAll(/^\s*image:\s*["']?([^\s"'#]+)/gm)].map((m) => m[1]);
|
|
143
|
+
const out = [];
|
|
144
|
+
for (const expected of [seed.latest.studio, seed.latest.media]) {
|
|
145
|
+
const repo = repoOf(expected);
|
|
146
|
+
const actual = stored.find((ref) => repoOf(ref) === repo);
|
|
147
|
+
if (actual !== undefined && actual !== expected)
|
|
148
|
+
out.push({ repo, stored: actual, seed: expected });
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
/** The two config.yaml scalars the proxy URL needs; a regex, not a YAML
|
|
153
|
+
* parser, because that is all the driver reads from a store it does not own. */
|
|
154
|
+
export function parseProxyConfig(configText) {
|
|
155
|
+
const text = configText ?? "";
|
|
156
|
+
const port = Number(/^proxyPort:\s*(\d+)/m.exec(text)?.[1]);
|
|
157
|
+
const tls = /^certSource:\s*\S+/m.test(text);
|
|
158
|
+
return { scheme: tls ? "https" : "http", port: Number.isInteger(port) && port > 0 ? port : DEFAULT_PROXY_PORT };
|
|
159
|
+
}
|
|
117
160
|
function parseJson(r, what) {
|
|
118
161
|
try {
|
|
119
162
|
return JSON.parse(r.stdout);
|
|
@@ -183,11 +226,25 @@ export function killGroup(pid) {
|
|
|
183
226
|
}
|
|
184
227
|
catch { }
|
|
185
228
|
}
|
|
229
|
+
/** Both naming schemes: compose's `<id>-<service>-1` on the released ctl,
|
|
230
|
+
* `norsk-inst-<id>-<service>` before it. Teardown waits on whichever exists. */
|
|
231
|
+
function instanceContainerNames(id) {
|
|
232
|
+
return [`${id}-studio-1`, `${id}-media-1`, `norsk-inst-${id}-studio`, `norsk-inst-${id}-media`];
|
|
233
|
+
}
|
|
186
234
|
export function defaultDemoDeps(cwd) {
|
|
187
235
|
return {
|
|
188
236
|
licenseFile: () => requireLicenseFile({ missing: "throw" }),
|
|
189
237
|
storeDir: (slug) => makeStoreDir(`norsk-demo-${slug}-`),
|
|
238
|
+
realStoreDir: () => process.env.NORSK_CTL_STORE_DIR ?? join(homedir(), ".norsk-ctl"),
|
|
190
239
|
writeFile: (path, contents) => writeFileSync(path, contents),
|
|
240
|
+
readFile: (path) => {
|
|
241
|
+
try {
|
|
242
|
+
return readFileSync(path, "utf8");
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
},
|
|
191
248
|
fileExists: (path) => existsSync(path),
|
|
192
249
|
startDaemon,
|
|
193
250
|
daemonAnswers: async (port) => {
|
|
@@ -223,6 +280,10 @@ export function defaultDemoDeps(cwd) {
|
|
|
223
280
|
exited,
|
|
224
281
|
};
|
|
225
282
|
},
|
|
283
|
+
docker: (argv) => {
|
|
284
|
+
const r = spawnSync("docker", argv, { encoding: "utf8" });
|
|
285
|
+
return { stdout: r.stdout ?? "", stderr: r.error ? r.error.message : (r.stderr ?? ""), exitCode: r.status ?? 1 };
|
|
286
|
+
},
|
|
226
287
|
fetch: (url, init) => fetch(url, { signal: AbortSignal.timeout(5000), ...init }),
|
|
227
288
|
startSources: startSrtSources,
|
|
228
289
|
stopSources: async (handles) => {
|
|
@@ -266,34 +327,63 @@ export function defaultDemoDeps(cwd) {
|
|
|
266
327
|
};
|
|
267
328
|
}
|
|
268
329
|
/** The pieces of a run that both `runDemo` and `runExportCheck` share: the
|
|
269
|
-
* private
|
|
270
|
-
* registration, and the template (built or chosen). */
|
|
330
|
+
* daemon (private on its band, or the developer's), the dev backend on the
|
|
331
|
+
* driver's port, the registration, and the template (built or chosen). */
|
|
271
332
|
class DemoSession {
|
|
272
333
|
spec;
|
|
273
334
|
slug;
|
|
274
335
|
cwd;
|
|
275
336
|
deps;
|
|
337
|
+
mode;
|
|
338
|
+
policy;
|
|
276
339
|
ports;
|
|
277
340
|
storeDir;
|
|
341
|
+
/** Scheme + authority of the daemon's proxy, for `proxy` URLs. */
|
|
342
|
+
proxyBase;
|
|
278
343
|
daemon = null;
|
|
279
344
|
dev = null;
|
|
345
|
+
extras = [];
|
|
280
346
|
timeouts;
|
|
281
|
-
constructor(spec, slug, cwd, deps,
|
|
347
|
+
constructor(spec, slug, cwd, deps, opts) {
|
|
282
348
|
this.spec = spec;
|
|
283
349
|
this.slug = slug;
|
|
284
350
|
this.cwd = cwd;
|
|
285
351
|
this.deps = deps;
|
|
286
|
-
this.
|
|
287
|
-
this.
|
|
352
|
+
this.mode = opts.mode;
|
|
353
|
+
this.policy = opts.daemon;
|
|
354
|
+
const host = process.env.NORSK_TEST_HOST ?? "localhost";
|
|
355
|
+
if (opts.daemon === "reuse") {
|
|
356
|
+
const daemonPort = opts.daemonPort ?? (Number(process.env.NORSK_CTL_PORT) || DEFAULT_DAEMON_PORT);
|
|
357
|
+
this.storeDir = deps.realStoreDir();
|
|
358
|
+
const proxy = parseProxyConfig(deps.readFile(join(this.storeDir, "config.yaml")));
|
|
359
|
+
this.ports = demoPorts(slug, { daemonPort, proxyPort: proxy.port });
|
|
360
|
+
this.proxyBase = `${proxy.scheme}://${host}:${proxy.port}`;
|
|
361
|
+
}
|
|
362
|
+
else {
|
|
363
|
+
this.ports = demoPorts(slug);
|
|
364
|
+
this.storeDir = deps.storeDir(slug);
|
|
365
|
+
// The private daemon is initialised without a cert source, so its proxy
|
|
366
|
+
// speaks plain http (seen live: https:// gave 000, http:// served the page).
|
|
367
|
+
this.proxyBase = `http://${host}:${this.ports.proxyPort}`;
|
|
368
|
+
}
|
|
288
369
|
this.timeouts = {
|
|
289
|
-
devReadyMs: timeouts?.devReadyMs ?? 120_000,
|
|
290
|
-
healthyMs: timeouts?.healthyMs ?? 180_000,
|
|
291
|
-
readyMs: timeouts?.readyMs ?? 180_000,
|
|
370
|
+
devReadyMs: opts.timeouts?.devReadyMs ?? 120_000,
|
|
371
|
+
healthyMs: opts.timeouts?.healthyMs ?? 180_000,
|
|
372
|
+
readyMs: opts.timeouts?.readyMs ?? 180_000,
|
|
292
373
|
};
|
|
293
374
|
}
|
|
294
375
|
get devUrl() {
|
|
295
376
|
return `http://localhost:${this.ports.backendPort}`;
|
|
296
377
|
}
|
|
378
|
+
templateDir(name) {
|
|
379
|
+
return join(this.storeDir, TEMPLATES_DIR, name);
|
|
380
|
+
}
|
|
381
|
+
/** What the real daemon's proxy demands on `/api/*`; absent on a private daemon. */
|
|
382
|
+
proxySecret() {
|
|
383
|
+
if (this.policy !== "reuse")
|
|
384
|
+
return undefined;
|
|
385
|
+
return this.deps.readFile(join(this.storeDir, PROXY_SECRET_FILE))?.trim() || undefined;
|
|
386
|
+
}
|
|
297
387
|
cli = async (argv, opts = {}) => this.deps.cli(this.storeDir, [
|
|
298
388
|
"--port",
|
|
299
389
|
String(this.ports.daemonPort),
|
|
@@ -307,7 +397,15 @@ class DemoSession {
|
|
|
307
397
|
}
|
|
308
398
|
return r;
|
|
309
399
|
};
|
|
310
|
-
|
|
400
|
+
/** Private: init a virgin store and start a daemon on the band. Reuse: the
|
|
401
|
+
* developer's daemon must already answer. */
|
|
402
|
+
async attachDaemon() {
|
|
403
|
+
if (this.policy === "reuse") {
|
|
404
|
+
if (!(await this.deps.daemonAnswers(this.ports.daemonPort))) {
|
|
405
|
+
throw new Error(`no daemon answers on :${this.ports.daemonPort} — start one with \`norsk-ctl serve\`, or drop --daemon reuse for a private one`);
|
|
406
|
+
}
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
311
409
|
await this.cliOk([
|
|
312
410
|
"init",
|
|
313
411
|
"--network-mode",
|
|
@@ -337,8 +435,35 @@ class DemoSession {
|
|
|
337
435
|
return r.ok;
|
|
338
436
|
}, { timeoutMs: this.timeouts.devReadyMs, intervalMs: 1000, label: `dev backend did not answer at ${url}` });
|
|
339
437
|
}
|
|
438
|
+
/** The reuse policy's mandatory sequence: `product add` refuses an existing
|
|
439
|
+
* name and stored templates are immutable, so the instance, every template
|
|
440
|
+
* the product publishes (plus the spec's built one), the product and its
|
|
441
|
+
* control-plane container all go first. Each step tolerates absence. */
|
|
442
|
+
async resetRegistration(instanceId) {
|
|
443
|
+
await this.cli(["instance", "delete", instanceId, "--purge"]);
|
|
444
|
+
const listed = await this.cli(["template", "list"], { output: "json" });
|
|
445
|
+
const names = listed.exitCode === 0
|
|
446
|
+
? (parseJson(listed, "template list").productTemplates ?? [])
|
|
447
|
+
.filter((x) => x.source?.productName === this.spec.product)
|
|
448
|
+
.map((x) => x.name)
|
|
449
|
+
: [];
|
|
450
|
+
const t = this.spec.template;
|
|
451
|
+
if (t && "build" in t)
|
|
452
|
+
names.push(t.build.name ?? `${this.spec.product}-demo`);
|
|
453
|
+
for (const name of new Set(names))
|
|
454
|
+
await this.cli(["template", "remove", name]);
|
|
455
|
+
await this.cli(["product", "remove", this.spec.product]);
|
|
456
|
+
this.deps.docker(["rm", "-f", `norsk-product-${this.spec.product}`]);
|
|
457
|
+
this.deps.log(`reuse: removed instance ${instanceId}, templates [${[...new Set(names)].join(", ")}], product ${this.spec.product}`);
|
|
458
|
+
}
|
|
340
459
|
async register(opts) {
|
|
341
|
-
const argv = ["product", "add"
|
|
460
|
+
const argv = ["product", "add"];
|
|
461
|
+
if (this.mode === "image") {
|
|
462
|
+
argv.push("--image", this.spec.image);
|
|
463
|
+
}
|
|
464
|
+
else {
|
|
465
|
+
argv.push("--dev-url", this.devUrl);
|
|
466
|
+
}
|
|
342
467
|
if (opts.licence)
|
|
343
468
|
argv.push("--license-file", this.deps.licenseFile());
|
|
344
469
|
const added = parseJson(await this.cliOk(argv, { output: "json" }), "product add");
|
|
@@ -379,6 +504,42 @@ class DemoSession {
|
|
|
379
504
|
throw new Error(`${this.spec.product} publishes no default template; name one with template: { build }`);
|
|
380
505
|
return first;
|
|
381
506
|
}
|
|
507
|
+
/** Funke's iterate.sh pin guard for every product: the stored compose must
|
|
508
|
+
* pin what manifest.seed.json declares. Image mode, and any reuse — a
|
|
509
|
+
* private dev-mode daemon renders the template fresh from source, so there
|
|
510
|
+
* is nothing stale to catch there. */
|
|
511
|
+
async pinGuard(templateName) {
|
|
512
|
+
if (this.mode !== "image" && this.policy !== "reuse")
|
|
513
|
+
return;
|
|
514
|
+
const seedPath = join(this.cwd, SEED_FILE);
|
|
515
|
+
const seedText = this.deps.readFile(seedPath);
|
|
516
|
+
if (seedText === null) {
|
|
517
|
+
this.deps.log(`no ${SEED_FILE} in ${this.cwd} — pin guard skipped`);
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
let raw;
|
|
521
|
+
try {
|
|
522
|
+
raw = JSON.parse(seedText);
|
|
523
|
+
}
|
|
524
|
+
catch (e) {
|
|
525
|
+
throw new Error(`${seedPath} is not JSON: ${e instanceof Error ? e.message : String(e)}`);
|
|
526
|
+
}
|
|
527
|
+
const seed = parseManifestSeed(raw);
|
|
528
|
+
if (seed.status !== "ok")
|
|
529
|
+
throw new Error(`${seedPath}: ${seed.error}`);
|
|
530
|
+
const composePath = join(this.templateDir(templateName), "compose.yml");
|
|
531
|
+
const compose = this.deps.readFile(composePath);
|
|
532
|
+
if (compose === null)
|
|
533
|
+
throw new Error(`stored template '${templateName}' has no compose at ${composePath}`);
|
|
534
|
+
const mismatches = checkTemplatePins(compose, seed.value);
|
|
535
|
+
if (mismatches.length) {
|
|
536
|
+
const lines = mismatches.map((m) => ` ${m.repo}: stored ${m.stored}, seed ${m.seed}`).join("\n");
|
|
537
|
+
throw new Error(`stored template '${templateName}' pins ${mismatches.map((m) => m.stored).join(", ")} but ${SEED_FILE} says ${mismatches
|
|
538
|
+
.map((m) => m.seed)
|
|
539
|
+
.join(", ")} — the stored template did not refresh:\n${lines}\n (product reload re-fetches only the manifest; delete the instance, remove the template and the product, add again)`);
|
|
540
|
+
}
|
|
541
|
+
this.deps.log(`template '${templateName}' pins match ${SEED_FILE}`);
|
|
542
|
+
}
|
|
382
543
|
/** name -> stringified default, from `template show`. */
|
|
383
544
|
async declaredParams(templateName) {
|
|
384
545
|
const shown = await this.cli(["template", "show", templateName], { output: "json" });
|
|
@@ -387,6 +548,34 @@ class DemoSession {
|
|
|
387
548
|
const parameters = parseJson(shown, "template show").parameters ?? [];
|
|
388
549
|
return new Map(parameters.map((p) => [p.name, p.default]));
|
|
389
550
|
}
|
|
551
|
+
startExtras(instanceId) {
|
|
552
|
+
for (const e of this.spec.extras ?? []) {
|
|
553
|
+
this.deps.docker(["rm", "-f", e.name]);
|
|
554
|
+
const r = this.deps.docker([
|
|
555
|
+
"run",
|
|
556
|
+
"-d",
|
|
557
|
+
"--name",
|
|
558
|
+
e.name,
|
|
559
|
+
"--network",
|
|
560
|
+
DOCKER_NETWORK_NAME,
|
|
561
|
+
"--label",
|
|
562
|
+
`${EXTRA_LABEL}=${instanceId}`,
|
|
563
|
+
...(e.args ?? []),
|
|
564
|
+
e.image,
|
|
565
|
+
...(e.command ?? []),
|
|
566
|
+
]);
|
|
567
|
+
this.extras.push(e.name);
|
|
568
|
+
if (r.exitCode !== 0) {
|
|
569
|
+
throw new Error(`extra '${e.name}' failed to start (exit ${r.exitCode}): ${(r.stderr || r.stdout).trim()}`);
|
|
570
|
+
}
|
|
571
|
+
this.deps.log(`extra ${e.name}: ${e.image}`);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
stopExtras() {
|
|
575
|
+
for (const name of this.extras)
|
|
576
|
+
this.deps.docker(["rm", "-f", name]);
|
|
577
|
+
this.extras = [];
|
|
578
|
+
}
|
|
390
579
|
async waitRunning(instanceId) {
|
|
391
580
|
await pollUntil(async () => {
|
|
392
581
|
const r = await this.cli(["instance", "list"], { output: "json" });
|
|
@@ -396,6 +585,12 @@ class DemoSession {
|
|
|
396
585
|
return inst?.status === "running" || inst?.status === "healthy";
|
|
397
586
|
}, { timeoutMs: this.timeouts.healthyMs, intervalMs: 1000, label: `instance ${instanceId} never reported running` });
|
|
398
587
|
}
|
|
588
|
+
async instanceListed(instanceId) {
|
|
589
|
+
const r = await this.cli(["instance", "list"], { output: "json" });
|
|
590
|
+
if (r.exitCode !== 0)
|
|
591
|
+
return false;
|
|
592
|
+
return (parseJson(r, "instance list").instances?.some((i) => i.id === instanceId) ?? false);
|
|
593
|
+
}
|
|
399
594
|
async ingestPorts(instanceId) {
|
|
400
595
|
const r = await this.cliOk(["instance", "describe", instanceId], { output: "json" });
|
|
401
596
|
return parseJson(r, "instance describe").ingestPorts ?? [];
|
|
@@ -403,10 +598,22 @@ class DemoSession {
|
|
|
403
598
|
gateTimeout(ms) {
|
|
404
599
|
return ms ?? this.timeouts.readyMs;
|
|
405
600
|
}
|
|
406
|
-
/** Tear down in reverse: sources, instance,
|
|
601
|
+
/** Tear down in reverse: sources, instance, then — private only — the
|
|
602
|
+
* daemon (with its proxy), the dev backend, the store. Reuse leaves the
|
|
603
|
+
* developer's daemon, store and registration as they are. */
|
|
407
604
|
async teardown(opts) {
|
|
408
605
|
if (opts.handles.length)
|
|
409
606
|
await this.deps.stopSources(opts.handles).catch(() => { });
|
|
607
|
+
if (this.policy === "reuse") {
|
|
608
|
+
for (const id of opts.instances) {
|
|
609
|
+
const r = await this.cli(["instance", "delete", id, "--purge"]);
|
|
610
|
+
if (r.exitCode !== 0)
|
|
611
|
+
this.deps.log(`instance delete ${id}: ${(r.stderr || r.stdout).trim()}`);
|
|
612
|
+
}
|
|
613
|
+
this.stopExtras();
|
|
614
|
+
this.dev?.kill();
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
410
617
|
try {
|
|
411
618
|
await this.deps.cleanup({
|
|
412
619
|
deleteInstance: (id) => this.cli(["instance", "delete", id, "--purge"]),
|
|
@@ -414,12 +621,13 @@ class DemoSession {
|
|
|
414
621
|
instances: opts.instances,
|
|
415
622
|
daemon: this.daemon,
|
|
416
623
|
storeDir: this.storeDir,
|
|
417
|
-
containers: opts.instances.flatMap(
|
|
624
|
+
containers: opts.instances.flatMap(instanceContainerNames),
|
|
418
625
|
});
|
|
419
626
|
}
|
|
420
627
|
catch (e) {
|
|
421
628
|
this.deps.log(`cleanup could not remove the store itself (${e instanceof Error ? e.message : String(e)}); nuking as root`);
|
|
422
629
|
}
|
|
630
|
+
this.stopExtras();
|
|
423
631
|
this.dev?.kill();
|
|
424
632
|
this.deps.nukeStoreAsRoot(this.storeDir);
|
|
425
633
|
}
|
|
@@ -432,10 +640,8 @@ function urlResolver(o) {
|
|
|
432
640
|
return ref.url;
|
|
433
641
|
if ("control" in ref)
|
|
434
642
|
return `http://localhost:${o.ports.backendPort}${ref.control}`;
|
|
435
|
-
// The private daemon is initialised without a cert source, so its proxy
|
|
436
|
-
// speaks plain http (reuse mode, 05-demo s7 step 2, will read the real one).
|
|
437
643
|
if ("proxy" in ref)
|
|
438
|
-
return
|
|
644
|
+
return `${o.proxyBase}${ref.proxy.replaceAll("{id}", o.instanceId)}`;
|
|
439
645
|
return `${studioBaseFrom({ instanceId: o.instanceId, studioHostPort: o.studioHostPort }, host, mode)}${ref.studio}`;
|
|
440
646
|
};
|
|
441
647
|
}
|
|
@@ -454,6 +660,27 @@ function gateName(gate, ctx) {
|
|
|
454
660
|
return gate.label ?? "custom gate";
|
|
455
661
|
return `${ctx.url(gate.http)} -> ${gate.status ?? 200}${gate.bodyIncludes ? ` containing '${gate.bodyIncludes}'` : ""}`;
|
|
456
662
|
}
|
|
663
|
+
/** Poll a gate until it holds — or stop at once when it declares its failure
|
|
664
|
+
* final (DemoGateFailure), which pollUntil would otherwise keep retrying. */
|
|
665
|
+
async function awaitGate(gate, ctx, timeoutMs) {
|
|
666
|
+
const name = gateName(gate, ctx);
|
|
667
|
+
let fatal;
|
|
668
|
+
await pollUntil(async () => {
|
|
669
|
+
try {
|
|
670
|
+
return await gateHolds(gate, ctx);
|
|
671
|
+
}
|
|
672
|
+
catch (e) {
|
|
673
|
+
if (e instanceof DemoGateFailure) {
|
|
674
|
+
fatal = e;
|
|
675
|
+
return true;
|
|
676
|
+
}
|
|
677
|
+
throw e;
|
|
678
|
+
}
|
|
679
|
+
}, { timeoutMs, intervalMs: 1000, label: `ready gate never held: ${name}` });
|
|
680
|
+
if (fatal)
|
|
681
|
+
throw new Error(`ready gate failed: ${name}: ${fatal.message}`);
|
|
682
|
+
return name;
|
|
683
|
+
}
|
|
457
684
|
async function resolveOpen(spec, ctx) {
|
|
458
685
|
const out = [];
|
|
459
686
|
for (const o of spec.open ?? []) {
|
|
@@ -473,35 +700,74 @@ async function resolveOpen(spec, ctx) {
|
|
|
473
700
|
}
|
|
474
701
|
return out;
|
|
475
702
|
}
|
|
703
|
+
/** `up` refuses to run beside a live earlier run of the same demo. Private:
|
|
704
|
+
* its daemon still answers. Reuse: the daemon always answers, so the record
|
|
705
|
+
* is live only while its instance still exists. A dead record is forgotten. */
|
|
706
|
+
async function refuseIfUp(spec, s, deps) {
|
|
707
|
+
const prev = deps.state.read(spec.product);
|
|
708
|
+
if (!prev)
|
|
709
|
+
return;
|
|
710
|
+
const answers = await deps.daemonAnswers(prev.daemonPort);
|
|
711
|
+
const live = prev.daemon === "reuse"
|
|
712
|
+
? answers && prev.instanceId !== undefined && (await s.instanceListed(prev.instanceId))
|
|
713
|
+
: answers;
|
|
714
|
+
if (live) {
|
|
715
|
+
throw new Error(`demo '${spec.product}' is already up (daemon :${prev.daemonPort}, store ${prev.storeDir}) — run \`demo down\` first`);
|
|
716
|
+
}
|
|
717
|
+
deps.log(`forgetting a stale record of a run on :${prev.daemonPort} (nothing of it is left)`);
|
|
718
|
+
deps.state.remove(spec.product);
|
|
719
|
+
}
|
|
476
720
|
export async function runDemo(spec, opts, deps = defaultDemoDeps(opts.cwd)) {
|
|
477
721
|
const slug = opts.slug ?? demoSlug(spec.product);
|
|
478
722
|
const instanceId = `demo-${slug}`;
|
|
723
|
+
const policy = opts.daemon ?? "private";
|
|
724
|
+
if (opts.mode === "image" && !spec.image) {
|
|
725
|
+
throw new Error(`--mode image needs the spec's \`image\` (the built product image) — ${spec.product} declares none`);
|
|
726
|
+
}
|
|
727
|
+
const s = new DemoSession(spec, slug, opts.cwd, deps, {
|
|
728
|
+
mode: opts.mode,
|
|
729
|
+
daemon: policy,
|
|
730
|
+
...(opts.daemonPort !== undefined ? { daemonPort: opts.daemonPort } : {}),
|
|
731
|
+
...(opts.timeouts !== undefined ? { timeouts: opts.timeouts } : {}),
|
|
732
|
+
});
|
|
479
733
|
if (opts.action === "up") {
|
|
480
|
-
|
|
481
|
-
if (prev) {
|
|
482
|
-
if (await deps.daemonAnswers(prev.daemonPort)) {
|
|
483
|
-
throw new Error(`demo '${spec.product}' is already up (daemon :${prev.daemonPort}, store ${prev.storeDir}) — run \`demo down\` first`);
|
|
484
|
-
}
|
|
485
|
-
deps.log(`forgetting a stale record of a run on :${prev.daemonPort} (nothing answers there)`);
|
|
486
|
-
deps.state.remove(spec.product);
|
|
487
|
-
}
|
|
734
|
+
await refuseIfUp(spec, s, deps);
|
|
488
735
|
for (const p of spec.prerequisites ?? []) {
|
|
489
736
|
const path = expandHome(p.path);
|
|
490
737
|
if (!deps.fileExists(path))
|
|
491
738
|
throw new Error(`prerequisite missing: ${path}\n make it with: ${p.hint}`);
|
|
492
739
|
}
|
|
493
740
|
}
|
|
494
|
-
const s = new DemoSession(spec, slug, opts.cwd, deps, opts.timeouts);
|
|
495
741
|
let launched = false;
|
|
496
742
|
let handles = [];
|
|
497
743
|
let recorded = false;
|
|
498
744
|
let result;
|
|
499
745
|
let journeyError;
|
|
500
746
|
try {
|
|
501
|
-
await s.
|
|
502
|
-
|
|
747
|
+
await s.attachDaemon();
|
|
748
|
+
if (opts.mode === "dev")
|
|
749
|
+
await s.startDev();
|
|
750
|
+
if (policy === "reuse")
|
|
751
|
+
await s.resetRegistration(instanceId);
|
|
503
752
|
await s.register({ licence: true });
|
|
504
753
|
const templateName = await s.resolveTemplate();
|
|
754
|
+
await s.pinGuard(templateName);
|
|
755
|
+
const launchCtx = {
|
|
756
|
+
action: opts.action,
|
|
757
|
+
mode: opts.mode,
|
|
758
|
+
daemon: policy,
|
|
759
|
+
product: spec.product,
|
|
760
|
+
instanceId,
|
|
761
|
+
daemonPort: s.ports.daemonPort,
|
|
762
|
+
storeDir: s.storeDir,
|
|
763
|
+
templateName,
|
|
764
|
+
templateDir: s.templateDir(templateName),
|
|
765
|
+
cli: s.cli,
|
|
766
|
+
log: deps.log,
|
|
767
|
+
};
|
|
768
|
+
s.startExtras(instanceId);
|
|
769
|
+
if (spec.beforeLaunch)
|
|
770
|
+
await spec.beforeLaunch(launchCtx);
|
|
505
771
|
const declared = await s.declaredParams(templateName);
|
|
506
772
|
const specParams = spec.launch?.params ?? {};
|
|
507
773
|
const studioHostPort = specParams[STUDIO_HOST_PORT_PARAM] !== undefined
|
|
@@ -541,29 +807,24 @@ export async function runDemo(spec, opts, deps = defaultDemoDeps(opts.cwd)) {
|
|
|
541
807
|
...(src.streamId !== undefined ? { streamId: src.streamId } : {}),
|
|
542
808
|
}));
|
|
543
809
|
const ctx = {
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
instanceId,
|
|
547
|
-
daemonPort: s.ports.daemonPort,
|
|
548
|
-
storeDir: s.storeDir,
|
|
549
|
-
url: urlResolver({ instanceId, ports: s.ports, studioHostPort }),
|
|
550
|
-
cli: s.cli,
|
|
810
|
+
...launchCtx,
|
|
811
|
+
url: urlResolver({ instanceId, ports: s.ports, studioHostPort, proxyBase: s.proxyBase }),
|
|
551
812
|
fetch: deps.fetch,
|
|
552
|
-
log: deps.log,
|
|
553
813
|
};
|
|
554
814
|
// Sources first: a gate such as "the switcher is composing" or "the probe
|
|
555
815
|
// is analysing" can only hold with something on the wire.
|
|
556
816
|
if (targets.length) {
|
|
557
|
-
|
|
817
|
+
const secret = s.proxySecret();
|
|
818
|
+
handles = await deps.startSources({
|
|
819
|
+
daemonPort: s.ports.daemonPort,
|
|
820
|
+
instanceId,
|
|
821
|
+
targets,
|
|
822
|
+
...(secret !== undefined ? { proxySecret: secret } : {}),
|
|
823
|
+
});
|
|
558
824
|
deps.log(`sources: ${targets.map((t) => `${t.name} -> :${t.port}`).join(", ")}`);
|
|
559
825
|
}
|
|
560
826
|
for (const gate of spec.ready ?? []) {
|
|
561
|
-
const name =
|
|
562
|
-
await pollUntil(() => gateHolds(gate, ctx), {
|
|
563
|
-
timeoutMs: s.gateTimeout(gate.timeoutMs),
|
|
564
|
-
intervalMs: 1000,
|
|
565
|
-
label: `ready gate never held: ${name}`,
|
|
566
|
-
});
|
|
827
|
+
const name = await awaitGate(gate, ctx, s.gateTimeout(gate.timeoutMs));
|
|
567
828
|
deps.log(`ready: ${name}`);
|
|
568
829
|
}
|
|
569
830
|
if (spec.after)
|
|
@@ -580,6 +841,8 @@ export async function runDemo(spec, opts, deps = defaultDemoDeps(opts.cwd)) {
|
|
|
580
841
|
storeDir: s.storeDir,
|
|
581
842
|
daemonPort: s.ports.daemonPort,
|
|
582
843
|
instanceId,
|
|
844
|
+
daemon: policy,
|
|
845
|
+
...(s.extras.length ? { extras: [...s.extras] } : {}),
|
|
583
846
|
...(s.dev?.pid !== undefined ? { devPid: s.dev.pid } : {}),
|
|
584
847
|
});
|
|
585
848
|
recorded = true;
|
|
@@ -595,8 +858,9 @@ export async function runDemo(spec, opts, deps = defaultDemoDeps(opts.cwd)) {
|
|
|
595
858
|
deps.state.remove(spec.product);
|
|
596
859
|
if (journeyError !== undefined)
|
|
597
860
|
throw journeyError;
|
|
598
|
-
if (deps.storeExists(s.storeDir))
|
|
861
|
+
if (policy === "private" && deps.storeExists(s.storeDir)) {
|
|
599
862
|
throw new Error(`store ${s.storeDir} still present after the root-container nuke`);
|
|
863
|
+
}
|
|
600
864
|
return result;
|
|
601
865
|
}
|
|
602
866
|
/** `demo check --mode standalone --export-only` (05-demo s4): build the
|
|
@@ -606,11 +870,15 @@ export async function runDemo(spec, opts, deps = defaultDemoDeps(opts.cwd)) {
|
|
|
606
870
|
* path that rotted (05-demo s1) and it is cheap enough to gate every push. */
|
|
607
871
|
export async function runExportCheck(spec, opts, deps = defaultDemoDeps(opts.cwd)) {
|
|
608
872
|
const slug = opts.slug ?? demoSlug(spec.product);
|
|
609
|
-
const s = new DemoSession(spec, slug, opts.cwd, deps,
|
|
873
|
+
const s = new DemoSession(spec, slug, opts.cwd, deps, {
|
|
874
|
+
mode: "dev",
|
|
875
|
+
daemon: "private",
|
|
876
|
+
...(opts.timeouts !== undefined ? { timeouts: opts.timeouts } : {}),
|
|
877
|
+
});
|
|
610
878
|
const exportDir = join(s.storeDir, "export");
|
|
611
879
|
let journeyError;
|
|
612
880
|
try {
|
|
613
|
-
await s.
|
|
881
|
+
await s.attachDaemon();
|
|
614
882
|
await s.startDev();
|
|
615
883
|
await s.register({ licence: false });
|
|
616
884
|
const templateName = await s.resolveTemplate();
|
|
@@ -645,24 +913,34 @@ export async function demoDown(product, deps = defaultDemoDeps(process.cwd())) {
|
|
|
645
913
|
return;
|
|
646
914
|
}
|
|
647
915
|
const cli = (argv) => deps.cli(state.storeDir, ["--port", String(state.daemonPort), ...argv]);
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
storeDir: state.storeDir,
|
|
655
|
-
containers: state.instanceId
|
|
656
|
-
? [`norsk-inst-${state.instanceId}-studio`, `norsk-inst-${state.instanceId}-media`]
|
|
657
|
-
: [],
|
|
658
|
-
});
|
|
916
|
+
if (state.daemon === "reuse") {
|
|
917
|
+
if (state.instanceId) {
|
|
918
|
+
const r = await cli(["instance", "delete", state.instanceId, "--purge"]);
|
|
919
|
+
if (r.exitCode !== 0)
|
|
920
|
+
deps.log(`instance delete ${state.instanceId}: ${(r.stderr || r.stdout).trim()}`);
|
|
921
|
+
}
|
|
659
922
|
}
|
|
660
|
-
|
|
661
|
-
|
|
923
|
+
else {
|
|
924
|
+
try {
|
|
925
|
+
await deps.cleanup({
|
|
926
|
+
deleteInstance: (id) => cli(["instance", "delete", id, "--purge"]),
|
|
927
|
+
stopDaemon: () => cli(["shutdown"]),
|
|
928
|
+
instances: state.instanceId ? [state.instanceId] : [],
|
|
929
|
+
daemon: null,
|
|
930
|
+
storeDir: state.storeDir,
|
|
931
|
+
containers: state.instanceId ? instanceContainerNames(state.instanceId) : [],
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
catch (e) {
|
|
935
|
+
deps.log(`cleanup: ${e instanceof Error ? e.message : String(e)}; nuking the store as root`);
|
|
936
|
+
}
|
|
662
937
|
}
|
|
938
|
+
for (const name of state.extras ?? [])
|
|
939
|
+
deps.docker(["rm", "-f", name]);
|
|
663
940
|
if (state.devPid !== undefined)
|
|
664
941
|
deps.killPid?.(state.devPid);
|
|
665
|
-
|
|
942
|
+
if (state.daemon !== "reuse")
|
|
943
|
+
deps.nukeStoreAsRoot(state.storeDir);
|
|
666
944
|
deps.state.remove(product);
|
|
667
|
-
deps.log(`${product} demo torn down`);
|
|
945
|
+
deps.log(`${product} demo torn down${state.daemon === "reuse" ? " (your daemon and its registration are untouched)" : ""}`);
|
|
668
946
|
}
|