@norskvideo/ctl-test-harness 0.1.20 → 0.1.22

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);
@@ -153,11 +196,11 @@ export function findBrokenSymlinks(dir) {
153
196
  * consumer's repo, where `down` from another shell can find it. */
154
197
  export function fileStateStore(cwd) {
155
198
  const dir = join(cwd, "test-temp", "demo");
156
- const path = (product) => join(dir, `${product}.json`);
199
+ const path = (product, kind) => join(dir, `${product}${kind === "dev-loop" ? ".dev-loop" : ""}.json`);
157
200
  return {
158
- read: (product) => {
201
+ read: (product, kind) => {
159
202
  try {
160
- return JSON.parse(readFileSync(path(product), "utf8"));
203
+ return JSON.parse(readFileSync(path(product, kind), "utf8"));
161
204
  }
162
205
  catch {
163
206
  return null;
@@ -165,9 +208,9 @@ export function fileStateStore(cwd) {
165
208
  },
166
209
  write: (state) => {
167
210
  mkdirSync(dir, { recursive: true });
168
- writeFileSync(path(state.product), `${JSON.stringify(state, null, 2)}\n`);
211
+ writeFileSync(path(state.product, state.kind), `${JSON.stringify(state, null, 2)}\n`);
169
212
  },
170
- remove: (product) => rmSync(path(product), { force: true }),
213
+ remove: (product, kind) => rmSync(path(product, kind), { force: true }),
171
214
  };
172
215
  }
173
216
  /** SIGTERM a process group (a pid startProcess spawned detached leads its
@@ -183,11 +226,26 @@ 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
+ removeFile: (path) => rmSync(path, { force: true }),
241
+ readFile: (path) => {
242
+ try {
243
+ return readFileSync(path, "utf8");
244
+ }
245
+ catch {
246
+ return null;
247
+ }
248
+ },
191
249
  fileExists: (path) => existsSync(path),
192
250
  startDaemon,
193
251
  daemonAnswers: async (port) => {
@@ -223,6 +281,10 @@ export function defaultDemoDeps(cwd) {
223
281
  exited,
224
282
  };
225
283
  },
284
+ docker: (argv) => {
285
+ const r = spawnSync("docker", argv, { encoding: "utf8" });
286
+ return { stdout: r.stdout ?? "", stderr: r.error ? r.error.message : (r.stderr ?? ""), exitCode: r.status ?? 1 };
287
+ },
226
288
  fetch: (url, init) => fetch(url, { signal: AbortSignal.timeout(5000), ...init }),
227
289
  startSources: startSrtSources,
228
290
  stopSources: async (handles) => {
@@ -265,35 +327,70 @@ export function defaultDemoDeps(cwd) {
265
327
  log: (line) => console.log(`[demo] ${line}`),
266
328
  };
267
329
  }
268
- /** 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). */
271
- class DemoSession {
330
+ /** The pieces of a run that `runDemo`, `runExportCheck` and the dev-loop
331
+ * share: the daemon (private on its band, or the developer's), the dev
332
+ * backend on the driver's port, the registration, and the template (built
333
+ * or chosen). */
334
+ export class DemoSession {
272
335
  spec;
273
336
  slug;
274
337
  cwd;
275
338
  deps;
339
+ mode;
340
+ policy;
276
341
  ports;
277
342
  storeDir;
343
+ /** The host every printed URL names. */
344
+ host;
345
+ publicHost;
346
+ /** Scheme + authority of the daemon's proxy, for `proxy` URLs. */
347
+ proxyBase;
278
348
  daemon = null;
279
349
  dev = null;
350
+ extras = [];
280
351
  timeouts;
281
- constructor(spec, slug, cwd, deps, timeouts) {
352
+ constructor(spec, slug, cwd, deps, opts) {
282
353
  this.spec = spec;
283
354
  this.slug = slug;
284
355
  this.cwd = cwd;
285
356
  this.deps = deps;
286
- this.ports = demoPorts(slug);
287
- this.storeDir = deps.storeDir(slug);
357
+ this.mode = opts.mode;
358
+ this.policy = opts.daemon;
359
+ this.publicHost = opts.publicHost;
360
+ const host = opts.publicHost ?? process.env.NORSK_TEST_HOST ?? "localhost";
361
+ this.host = host;
362
+ if (opts.daemon === "reuse") {
363
+ const daemonPort = opts.daemonPort ?? (Number(process.env.NORSK_CTL_PORT) || DEFAULT_DAEMON_PORT);
364
+ this.storeDir = deps.realStoreDir();
365
+ const proxy = parseProxyConfig(deps.readFile(join(this.storeDir, "config.yaml")));
366
+ this.ports = demoPorts(slug, { daemonPort, proxyPort: proxy.port });
367
+ this.proxyBase = `${proxy.scheme}://${host}:${proxy.port}`;
368
+ }
369
+ else {
370
+ this.ports = demoPorts(slug);
371
+ this.storeDir = opts.storeDir ?? deps.storeDir(slug);
372
+ // The private daemon is initialised without a cert source, so its proxy
373
+ // speaks plain http (seen live: https:// gave 000, http:// served the page).
374
+ this.proxyBase = `http://${host}:${this.ports.proxyPort}`;
375
+ }
288
376
  this.timeouts = {
289
- devReadyMs: timeouts?.devReadyMs ?? 120_000,
290
- healthyMs: timeouts?.healthyMs ?? 180_000,
291
- readyMs: timeouts?.readyMs ?? 180_000,
377
+ devReadyMs: opts.timeouts?.devReadyMs ?? 120_000,
378
+ healthyMs: opts.timeouts?.healthyMs ?? 180_000,
379
+ readyMs: opts.timeouts?.readyMs ?? 180_000,
292
380
  };
293
381
  }
294
382
  get devUrl() {
295
383
  return `http://localhost:${this.ports.backendPort}`;
296
384
  }
385
+ templateDir(name) {
386
+ return join(this.storeDir, TEMPLATES_DIR, name);
387
+ }
388
+ /** What the real daemon's proxy demands on `/api/*`; absent on a private daemon. */
389
+ proxySecret() {
390
+ if (this.policy !== "reuse")
391
+ return undefined;
392
+ return this.deps.readFile(join(this.storeDir, PROXY_SECRET_FILE))?.trim() || undefined;
393
+ }
297
394
  cli = async (argv, opts = {}) => this.deps.cli(this.storeDir, [
298
395
  "--port",
299
396
  String(this.ports.daemonPort),
@@ -307,7 +404,15 @@ class DemoSession {
307
404
  }
308
405
  return r;
309
406
  };
310
- async startDaemon() {
407
+ /** Private: init a virgin store and start a daemon on the band. Reuse: the
408
+ * developer's daemon must already answer. */
409
+ async attachDaemon() {
410
+ if (this.policy === "reuse") {
411
+ if (!(await this.deps.daemonAnswers(this.ports.daemonPort))) {
412
+ throw new Error(`no daemon answers on :${this.ports.daemonPort} — start one with \`norsk-ctl serve\`, or drop --daemon reuse for a private one`);
413
+ }
414
+ return;
415
+ }
311
416
  await this.cliOk([
312
417
  "init",
313
418
  "--network-mode",
@@ -318,6 +423,7 @@ class DemoSession {
318
423
  String(this.ports.proxyPort),
319
424
  "--no-http-redirect",
320
425
  "--no-start-server",
426
+ ...(this.publicHost ? ["--public-host", this.publicHost] : []),
321
427
  ]);
322
428
  const started = this.deps.startDaemon(this.storeDir, { port: this.ports.daemonPort, seedConfig: false });
323
429
  this.daemon = started.daemon;
@@ -337,8 +443,35 @@ class DemoSession {
337
443
  return r.ok;
338
444
  }, { timeoutMs: this.timeouts.devReadyMs, intervalMs: 1000, label: `dev backend did not answer at ${url}` });
339
445
  }
446
+ /** The reuse policy's mandatory sequence: `product add` refuses an existing
447
+ * name and stored templates are immutable, so the instance, every template
448
+ * the product publishes (plus the spec's built one), the product and its
449
+ * control-plane container all go first. Each step tolerates absence. */
450
+ async resetRegistration(instanceId) {
451
+ await this.cli(["instance", "delete", instanceId, "--purge"]);
452
+ const listed = await this.cli(["template", "list"], { output: "json" });
453
+ const names = listed.exitCode === 0
454
+ ? (parseJson(listed, "template list").productTemplates ?? [])
455
+ .filter((x) => x.source?.productName === this.spec.product)
456
+ .map((x) => x.name)
457
+ : [];
458
+ const t = this.spec.template;
459
+ if (t && "build" in t)
460
+ names.push(t.build.name ?? `${this.spec.product}-demo`);
461
+ for (const name of new Set(names))
462
+ await this.cli(["template", "remove", name]);
463
+ await this.cli(["product", "remove", this.spec.product]);
464
+ this.deps.docker(["rm", "-f", `norsk-product-${this.spec.product}`]);
465
+ this.deps.log(`reuse: removed instance ${instanceId}, templates [${[...new Set(names)].join(", ")}], product ${this.spec.product}`);
466
+ }
340
467
  async register(opts) {
341
- const argv = ["product", "add", "--dev-url", this.devUrl];
468
+ const argv = ["product", "add"];
469
+ if (this.mode === "image") {
470
+ argv.push("--image", this.spec.image);
471
+ }
472
+ else {
473
+ argv.push("--dev-url", this.devUrl);
474
+ }
342
475
  if (opts.licence)
343
476
  argv.push("--license-file", this.deps.licenseFile());
344
477
  const added = parseJson(await this.cliOk(argv, { output: "json" }), "product add");
@@ -348,8 +481,7 @@ class DemoSession {
348
481
  }
349
482
  /** The template to launch: built from the spec's input, or a name the
350
483
  * product publishes (its first default when the spec names none). */
351
- async resolveTemplate() {
352
- const t = this.spec.template;
484
+ async resolveTemplate(t = this.spec.template) {
353
485
  if (t && "build" in t) {
354
486
  const name = t.build.name ?? `${this.spec.product}-demo`;
355
487
  let inputPath;
@@ -379,6 +511,42 @@ class DemoSession {
379
511
  throw new Error(`${this.spec.product} publishes no default template; name one with template: { build }`);
380
512
  return first;
381
513
  }
514
+ /** Funke's iterate.sh pin guard for every product: the stored compose must
515
+ * pin what manifest.seed.json declares. Image mode, and any reuse — a
516
+ * private dev-mode daemon renders the template fresh from source, so there
517
+ * is nothing stale to catch there. */
518
+ async pinGuard(templateName) {
519
+ if (this.mode !== "image" && this.policy !== "reuse")
520
+ return;
521
+ const seedPath = join(this.cwd, SEED_FILE);
522
+ const seedText = this.deps.readFile(seedPath);
523
+ if (seedText === null) {
524
+ this.deps.log(`no ${SEED_FILE} in ${this.cwd} — pin guard skipped`);
525
+ return;
526
+ }
527
+ let raw;
528
+ try {
529
+ raw = JSON.parse(seedText);
530
+ }
531
+ catch (e) {
532
+ throw new Error(`${seedPath} is not JSON: ${e instanceof Error ? e.message : String(e)}`);
533
+ }
534
+ const seed = parseManifestSeed(raw);
535
+ if (seed.status !== "ok")
536
+ throw new Error(`${seedPath}: ${seed.error}`);
537
+ const composePath = join(this.templateDir(templateName), "compose.yml");
538
+ const compose = this.deps.readFile(composePath);
539
+ if (compose === null)
540
+ throw new Error(`stored template '${templateName}' has no compose at ${composePath}`);
541
+ const mismatches = checkTemplatePins(compose, seed.value);
542
+ if (mismatches.length) {
543
+ const lines = mismatches.map((m) => ` ${m.repo}: stored ${m.stored}, seed ${m.seed}`).join("\n");
544
+ throw new Error(`stored template '${templateName}' pins ${mismatches.map((m) => m.stored).join(", ")} but ${SEED_FILE} says ${mismatches
545
+ .map((m) => m.seed)
546
+ .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)`);
547
+ }
548
+ this.deps.log(`template '${templateName}' pins match ${SEED_FILE}`);
549
+ }
382
550
  /** name -> stringified default, from `template show`. */
383
551
  async declaredParams(templateName) {
384
552
  const shown = await this.cli(["template", "show", templateName], { output: "json" });
@@ -387,6 +555,34 @@ class DemoSession {
387
555
  const parameters = parseJson(shown, "template show").parameters ?? [];
388
556
  return new Map(parameters.map((p) => [p.name, p.default]));
389
557
  }
558
+ startExtras(instanceId) {
559
+ for (const e of this.spec.extras ?? []) {
560
+ this.deps.docker(["rm", "-f", e.name]);
561
+ const r = this.deps.docker([
562
+ "run",
563
+ "-d",
564
+ "--name",
565
+ e.name,
566
+ "--network",
567
+ DOCKER_NETWORK_NAME,
568
+ "--label",
569
+ `${EXTRA_LABEL}=${instanceId}`,
570
+ ...(e.args ?? []),
571
+ e.image,
572
+ ...(e.command ?? []),
573
+ ]);
574
+ this.extras.push(e.name);
575
+ if (r.exitCode !== 0) {
576
+ throw new Error(`extra '${e.name}' failed to start (exit ${r.exitCode}): ${(r.stderr || r.stdout).trim()}`);
577
+ }
578
+ this.deps.log(`extra ${e.name}: ${e.image}`);
579
+ }
580
+ }
581
+ stopExtras() {
582
+ for (const name of this.extras)
583
+ this.deps.docker(["rm", "-f", name]);
584
+ this.extras = [];
585
+ }
390
586
  async waitRunning(instanceId) {
391
587
  await pollUntil(async () => {
392
588
  const r = await this.cli(["instance", "list"], { output: "json" });
@@ -396,6 +592,12 @@ class DemoSession {
396
592
  return inst?.status === "running" || inst?.status === "healthy";
397
593
  }, { timeoutMs: this.timeouts.healthyMs, intervalMs: 1000, label: `instance ${instanceId} never reported running` });
398
594
  }
595
+ async instanceListed(instanceId) {
596
+ const r = await this.cli(["instance", "list"], { output: "json" });
597
+ if (r.exitCode !== 0)
598
+ return false;
599
+ return (parseJson(r, "instance list").instances?.some((i) => i.id === instanceId) ?? false);
600
+ }
399
601
  async ingestPorts(instanceId) {
400
602
  const r = await this.cliOk(["instance", "describe", instanceId], { output: "json" });
401
603
  return parseJson(r, "instance describe").ingestPorts ?? [];
@@ -403,10 +605,22 @@ class DemoSession {
403
605
  gateTimeout(ms) {
404
606
  return ms ?? this.timeouts.readyMs;
405
607
  }
406
- /** Tear down in reverse: sources, instance, daemon (with its proxy), dev backend, store. */
608
+ /** Tear down in reverse: sources, instance, then private only the
609
+ * daemon (with its proxy), the dev backend, the store. Reuse leaves the
610
+ * developer's daemon, store and registration as they are. */
407
611
  async teardown(opts) {
408
612
  if (opts.handles.length)
409
613
  await this.deps.stopSources(opts.handles).catch(() => { });
614
+ if (this.policy === "reuse") {
615
+ for (const id of opts.instances) {
616
+ const r = await this.cli(["instance", "delete", id, "--purge"]);
617
+ if (r.exitCode !== 0)
618
+ this.deps.log(`instance delete ${id}: ${(r.stderr || r.stdout).trim()}`);
619
+ }
620
+ this.stopExtras();
621
+ this.dev?.kill();
622
+ return;
623
+ }
410
624
  try {
411
625
  await this.deps.cleanup({
412
626
  deleteInstance: (id) => this.cli(["instance", "delete", id, "--purge"]),
@@ -414,28 +628,27 @@ class DemoSession {
414
628
  instances: opts.instances,
415
629
  daemon: this.daemon,
416
630
  storeDir: this.storeDir,
417
- containers: opts.instances.flatMap((id) => [`norsk-inst-${id}-studio`, `norsk-inst-${id}-media`]),
631
+ containers: opts.instances.flatMap(instanceContainerNames),
418
632
  });
419
633
  }
420
634
  catch (e) {
421
635
  this.deps.log(`cleanup could not remove the store itself (${e instanceof Error ? e.message : String(e)}); nuking as root`);
422
636
  }
637
+ this.stopExtras();
423
638
  this.dev?.kill();
424
639
  this.deps.nukeStoreAsRoot(this.storeDir);
425
640
  }
426
641
  }
427
642
  function urlResolver(o) {
428
- const host = process.env.NORSK_TEST_HOST ?? "localhost";
643
+ const host = o.host;
429
644
  const mode = netReachMode();
430
645
  return (ref) => {
431
646
  if ("url" in ref)
432
647
  return ref.url;
433
648
  if ("control" in ref)
434
- 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).
649
+ return `http://${host}:${o.ports.backendPort}${ref.control}`;
437
650
  if ("proxy" in ref)
438
- return `http://${host}:${o.ports.proxyPort}${ref.proxy.replaceAll("{id}", o.instanceId)}`;
651
+ return `${o.proxyBase}${ref.proxy.replaceAll("{id}", o.instanceId)}`;
439
652
  return `${studioBaseFrom({ instanceId: o.instanceId, studioHostPort: o.studioHostPort }, host, mode)}${ref.studio}`;
440
653
  };
441
654
  }
@@ -454,6 +667,27 @@ function gateName(gate, ctx) {
454
667
  return gate.label ?? "custom gate";
455
668
  return `${ctx.url(gate.http)} -> ${gate.status ?? 200}${gate.bodyIncludes ? ` containing '${gate.bodyIncludes}'` : ""}`;
456
669
  }
670
+ /** Poll a gate until it holds — or stop at once when it declares its failure
671
+ * final (DemoGateFailure), which pollUntil would otherwise keep retrying. */
672
+ async function awaitGate(gate, ctx, timeoutMs) {
673
+ const name = gateName(gate, ctx);
674
+ let fatal;
675
+ await pollUntil(async () => {
676
+ try {
677
+ return await gateHolds(gate, ctx);
678
+ }
679
+ catch (e) {
680
+ if (e instanceof DemoGateFailure) {
681
+ fatal = e;
682
+ return true;
683
+ }
684
+ throw e;
685
+ }
686
+ }, { timeoutMs, intervalMs: 1000, label: `ready gate never held: ${name}` });
687
+ if (fatal)
688
+ throw new Error(`ready gate failed: ${name}: ${fatal.message}`);
689
+ return name;
690
+ }
457
691
  async function resolveOpen(spec, ctx) {
458
692
  const out = [];
459
693
  for (const o of spec.open ?? []) {
@@ -473,35 +707,75 @@ async function resolveOpen(spec, ctx) {
473
707
  }
474
708
  return out;
475
709
  }
710
+ /** `up` refuses to run beside a live earlier run of the same demo. Private:
711
+ * its daemon still answers. Reuse: the daemon always answers, so the record
712
+ * is live only while its instance still exists. A dead record is forgotten. */
713
+ async function refuseIfUp(spec, s, deps) {
714
+ const prev = deps.state.read(spec.product);
715
+ if (!prev)
716
+ return;
717
+ const answers = await deps.daemonAnswers(prev.daemonPort);
718
+ const live = prev.daemon === "reuse"
719
+ ? answers && prev.instanceId !== undefined && (await s.instanceListed(prev.instanceId))
720
+ : answers;
721
+ if (live) {
722
+ throw new Error(`demo '${spec.product}' is already up (daemon :${prev.daemonPort}, store ${prev.storeDir}) — run \`demo down\` first`);
723
+ }
724
+ deps.log(`forgetting a stale record of a run on :${prev.daemonPort} (nothing of it is left)`);
725
+ deps.state.remove(spec.product);
726
+ }
476
727
  export async function runDemo(spec, opts, deps = defaultDemoDeps(opts.cwd)) {
477
728
  const slug = opts.slug ?? demoSlug(spec.product);
478
729
  const instanceId = `demo-${slug}`;
730
+ const policy = opts.daemon ?? "private";
731
+ if (opts.mode === "image" && !spec.image) {
732
+ throw new Error(`--mode image needs the spec's \`image\` (the built product image) — ${spec.product} declares none`);
733
+ }
734
+ const s = new DemoSession(spec, slug, opts.cwd, deps, {
735
+ mode: opts.mode,
736
+ daemon: policy,
737
+ ...(opts.daemonPort !== undefined ? { daemonPort: opts.daemonPort } : {}),
738
+ ...(opts.timeouts !== undefined ? { timeouts: opts.timeouts } : {}),
739
+ ...(opts.publicHost !== undefined ? { publicHost: opts.publicHost } : {}),
740
+ });
479
741
  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
- }
742
+ await refuseIfUp(spec, s, deps);
488
743
  for (const p of spec.prerequisites ?? []) {
489
744
  const path = expandHome(p.path);
490
745
  if (!deps.fileExists(path))
491
746
  throw new Error(`prerequisite missing: ${path}\n make it with: ${p.hint}`);
492
747
  }
493
748
  }
494
- const s = new DemoSession(spec, slug, opts.cwd, deps, opts.timeouts);
495
749
  let launched = false;
496
750
  let handles = [];
497
751
  let recorded = false;
498
752
  let result;
499
753
  let journeyError;
500
754
  try {
501
- await s.startDaemon();
502
- await s.startDev();
755
+ await s.attachDaemon();
756
+ if (opts.mode === "dev")
757
+ await s.startDev();
758
+ if (policy === "reuse")
759
+ await s.resetRegistration(instanceId);
503
760
  await s.register({ licence: true });
504
761
  const templateName = await s.resolveTemplate();
762
+ await s.pinGuard(templateName);
763
+ const launchCtx = {
764
+ action: opts.action,
765
+ mode: opts.mode,
766
+ daemon: policy,
767
+ product: spec.product,
768
+ instanceId,
769
+ daemonPort: s.ports.daemonPort,
770
+ storeDir: s.storeDir,
771
+ templateName,
772
+ templateDir: s.templateDir(templateName),
773
+ cli: s.cli,
774
+ log: deps.log,
775
+ };
776
+ s.startExtras(instanceId);
777
+ if (spec.beforeLaunch)
778
+ await spec.beforeLaunch(launchCtx);
505
779
  const declared = await s.declaredParams(templateName);
506
780
  const specParams = spec.launch?.params ?? {};
507
781
  const studioHostPort = specParams[STUDIO_HOST_PORT_PARAM] !== undefined
@@ -541,29 +815,24 @@ export async function runDemo(spec, opts, deps = defaultDemoDeps(opts.cwd)) {
541
815
  ...(src.streamId !== undefined ? { streamId: src.streamId } : {}),
542
816
  }));
543
817
  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,
818
+ ...launchCtx,
819
+ url: urlResolver({ instanceId, ports: s.ports, studioHostPort, proxyBase: s.proxyBase, host: s.host }),
551
820
  fetch: deps.fetch,
552
- log: deps.log,
553
821
  };
554
822
  // Sources first: a gate such as "the switcher is composing" or "the probe
555
823
  // is analysing" can only hold with something on the wire.
556
824
  if (targets.length) {
557
- handles = await deps.startSources({ daemonPort: s.ports.daemonPort, instanceId, targets });
825
+ const secret = s.proxySecret();
826
+ handles = await deps.startSources({
827
+ daemonPort: s.ports.daemonPort,
828
+ instanceId,
829
+ targets,
830
+ ...(secret !== undefined ? { proxySecret: secret } : {}),
831
+ });
558
832
  deps.log(`sources: ${targets.map((t) => `${t.name} -> :${t.port}`).join(", ")}`);
559
833
  }
560
834
  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
- });
835
+ const name = await awaitGate(gate, ctx, s.gateTimeout(gate.timeoutMs));
567
836
  deps.log(`ready: ${name}`);
568
837
  }
569
838
  if (spec.after)
@@ -580,6 +849,8 @@ export async function runDemo(spec, opts, deps = defaultDemoDeps(opts.cwd)) {
580
849
  storeDir: s.storeDir,
581
850
  daemonPort: s.ports.daemonPort,
582
851
  instanceId,
852
+ daemon: policy,
853
+ ...(s.extras.length ? { extras: [...s.extras] } : {}),
583
854
  ...(s.dev?.pid !== undefined ? { devPid: s.dev.pid } : {}),
584
855
  });
585
856
  recorded = true;
@@ -595,8 +866,9 @@ export async function runDemo(spec, opts, deps = defaultDemoDeps(opts.cwd)) {
595
866
  deps.state.remove(spec.product);
596
867
  if (journeyError !== undefined)
597
868
  throw journeyError;
598
- if (deps.storeExists(s.storeDir))
869
+ if (policy === "private" && deps.storeExists(s.storeDir)) {
599
870
  throw new Error(`store ${s.storeDir} still present after the root-container nuke`);
871
+ }
600
872
  return result;
601
873
  }
602
874
  /** `demo check --mode standalone --export-only` (05-demo s4): build the
@@ -606,27 +878,19 @@ export async function runDemo(spec, opts, deps = defaultDemoDeps(opts.cwd)) {
606
878
  * path that rotted (05-demo s1) and it is cheap enough to gate every push. */
607
879
  export async function runExportCheck(spec, opts, deps = defaultDemoDeps(opts.cwd)) {
608
880
  const slug = opts.slug ?? demoSlug(spec.product);
609
- const s = new DemoSession(spec, slug, opts.cwd, deps, opts.timeouts);
881
+ const s = new DemoSession(spec, slug, opts.cwd, deps, {
882
+ mode: "dev",
883
+ daemon: "private",
884
+ ...(opts.timeouts !== undefined ? { timeouts: opts.timeouts } : {}),
885
+ });
610
886
  const exportDir = join(s.storeDir, "export");
611
887
  let journeyError;
612
888
  try {
613
- await s.startDaemon();
889
+ await s.attachDaemon();
614
890
  await s.startDev();
615
891
  await s.register({ licence: false });
616
892
  const templateName = await s.resolveTemplate();
617
- const argv = ["template", "export-workdir", templateName, "--to", exportDir];
618
- for (const [k, v] of Object.entries(spec.standalone?.params ?? {}))
619
- argv.push("--param", `${k}=${v}`);
620
- for (const [pkg, path] of Object.entries(spec.standalone?.links ?? {})) {
621
- argv.push("--link-component", `${pkg}=${resolve(opts.cwd, path)}`);
622
- }
623
- if (spec.standalone?.dashboards)
624
- argv.push("--link-dashboards", resolve(opts.cwd, spec.standalone.dashboards));
625
- await s.cliOk(argv);
626
- const broken = deps.brokenSymlinks(exportDir);
627
- if (broken.length) {
628
- throw new Error(`exported workdir has ${broken.length} broken symlink(s):\n ${broken.join("\n ")}`);
629
- }
893
+ await exportStandaloneWorkdir(s, templateName, exportDir);
630
894
  deps.log(`export ok: ${templateName} -> ${exportDir}, every symlink resolves`);
631
895
  }
632
896
  catch (e) {
@@ -637,6 +901,24 @@ export async function runExportCheck(spec, opts, deps = defaultDemoDeps(opts.cwd
637
901
  throw journeyError;
638
902
  return { exportDir };
639
903
  }
904
+ /** `template export-workdir` with the spec's live-source links and params,
905
+ * then the check that rotted (05-demo s1): every symlink resolves. */
906
+ export async function exportStandaloneWorkdir(s, templateName, to) {
907
+ const { spec, cwd, deps } = s;
908
+ const argv = ["template", "export-workdir", templateName, "--to", to];
909
+ for (const [k, v] of Object.entries(spec.standalone?.params ?? {}))
910
+ argv.push("--param", `${k}=${v}`);
911
+ for (const [pkg, path] of Object.entries(spec.standalone?.links ?? {})) {
912
+ argv.push("--link-component", `${pkg}=${resolve(cwd, path)}`);
913
+ }
914
+ if (spec.standalone?.dashboards)
915
+ argv.push("--link-dashboards", resolve(cwd, spec.standalone.dashboards));
916
+ await s.cliOk(argv);
917
+ const broken = deps.brokenSymlinks(to);
918
+ if (broken.length) {
919
+ throw new Error(`exported workdir has ${broken.length} broken symlink(s):\n ${broken.join("\n ")}`);
920
+ }
921
+ }
640
922
  /** `demo down`: tear down the run `up` recorded, from any shell. */
641
923
  export async function demoDown(product, deps = defaultDemoDeps(process.cwd())) {
642
924
  const state = deps.state.read(product);
@@ -645,24 +927,34 @@ export async function demoDown(product, deps = defaultDemoDeps(process.cwd())) {
645
927
  return;
646
928
  }
647
929
  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
- });
930
+ if (state.daemon === "reuse") {
931
+ if (state.instanceId) {
932
+ const r = await cli(["instance", "delete", state.instanceId, "--purge"]);
933
+ if (r.exitCode !== 0)
934
+ deps.log(`instance delete ${state.instanceId}: ${(r.stderr || r.stdout).trim()}`);
935
+ }
659
936
  }
660
- catch (e) {
661
- deps.log(`cleanup: ${e instanceof Error ? e.message : String(e)}; nuking the store as root`);
937
+ else {
938
+ try {
939
+ await deps.cleanup({
940
+ deleteInstance: (id) => cli(["instance", "delete", id, "--purge"]),
941
+ stopDaemon: () => cli(["shutdown"]),
942
+ instances: state.instanceId ? [state.instanceId] : [],
943
+ daemon: null,
944
+ storeDir: state.storeDir,
945
+ containers: state.instanceId ? instanceContainerNames(state.instanceId) : [],
946
+ });
947
+ }
948
+ catch (e) {
949
+ deps.log(`cleanup: ${e instanceof Error ? e.message : String(e)}; nuking the store as root`);
950
+ }
662
951
  }
952
+ for (const name of state.extras ?? [])
953
+ deps.docker(["rm", "-f", name]);
663
954
  if (state.devPid !== undefined)
664
955
  deps.killPid?.(state.devPid);
665
- deps.nukeStoreAsRoot(state.storeDir);
956
+ if (state.daemon !== "reuse")
957
+ deps.nukeStoreAsRoot(state.storeDir);
666
958
  deps.state.remove(product);
667
- deps.log(`${product} demo torn down`);
959
+ deps.log(`${product} demo torn down${state.daemon === "reuse" ? " (your daemon and its registration are untouched)" : ""}`);
668
960
  }