@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/demo/run.js CHANGED
@@ -1,13 +1,25 @@
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.
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 { ensureRunnerOnNetwork, netReachMode, STUDIO_INTERNAL_PORT, studioBaseFrom } from "../container-net.js";
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 daemon on its band, the dev backend on the driver's port, the
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, timeouts) {
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.ports = demoPorts(slug);
287
- this.storeDir = deps.storeDir(slug);
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
- async startDaemon() {
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", "--dev-url", this.devUrl];
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, daemon (with its proxy), dev backend, store. */
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((id) => [`norsk-inst-${id}-studio`, `norsk-inst-${id}-media`]),
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 `http://${host}:${o.ports.proxyPort}${ref.proxy.replaceAll("{id}", o.instanceId)}`;
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
- const prev = deps.state.read(spec.product);
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.startDaemon();
502
- await s.startDev();
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
- action: opts.action,
545
- product: spec.product,
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
- handles = await deps.startSources({ daemonPort: s.ports.daemonPort, instanceId, targets });
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 = gateName(gate, ctx);
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, opts.timeouts);
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.startDaemon();
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
- try {
649
- await deps.cleanup({
650
- deleteInstance: (id) => cli(["instance", "delete", id, "--purge"]),
651
- stopDaemon: () => cli(["shutdown"]),
652
- instances: state.instanceId ? [state.instanceId] : [],
653
- daemon: null,
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
- catch (e) {
661
- deps.log(`cleanup: ${e instanceof Error ? e.message : String(e)}; nuking the store as root`);
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
- deps.nukeStoreAsRoot(state.storeDir);
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
  }