@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/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 +22 -3
- package/demo/cli.js +136 -21
- package/demo/dev-loop-cli.d.ts +2 -0
- package/demo/dev-loop-cli.js +8 -0
- package/demo/dev-loop.d.ts +33 -0
- package/demo/dev-loop.js +205 -0
- package/demo/gates.d.ts +22 -0
- package/demo/gates.js +56 -0
- package/demo/index.d.ts +8 -3
- package/demo/index.js +4 -1
- package/demo/panes.d.ts +81 -0
- package/demo/panes.js +236 -0
- package/demo/run.d.ts +132 -5
- package/demo/run.js +386 -94
- package/demo/spec.d.ts +48 -6
- package/demo/spec.js +37 -11
- package/package.json +11 -2
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);
|
|
@@ -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
|
|
269
|
-
*
|
|
270
|
-
* registration, and the template (built
|
|
271
|
-
|
|
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,
|
|
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.
|
|
287
|
-
this.
|
|
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
|
-
|
|
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"
|
|
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,
|
|
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(
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
502
|
-
|
|
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
|
-
|
|
545
|
-
|
|
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
|
-
|
|
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 =
|
|
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,
|
|
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.
|
|
889
|
+
await s.attachDaemon();
|
|
614
890
|
await s.startDev();
|
|
615
891
|
await s.register({ licence: false });
|
|
616
892
|
const templateName = await s.resolveTemplate();
|
|
617
|
-
|
|
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
|
-
|
|
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
|
-
});
|
|
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
|
-
|
|
661
|
-
|
|
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
|
-
|
|
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
|
}
|