@norskvideo/ctl-test-harness 0.1.24 → 0.1.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/demo/cli.js +2 -1
- package/demo/harness.d.ts +108 -0
- package/demo/harness.js +386 -0
- package/demo/index.d.ts +4 -2
- package/demo/index.js +2 -1
- package/demo/run.d.ts +25 -1
- package/demo/run.js +32 -245
- package/package.json +1 -1
package/demo/cli.js
CHANGED
|
@@ -25,8 +25,9 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
25
25
|
// under bun only: the spec is TypeScript and so is every product's dev shell.
|
|
26
26
|
import { resolve } from "node:path";
|
|
27
27
|
import { devLoopDown, runDevLoop } from "./dev-loop.js";
|
|
28
|
+
import { runDemo } from "./harness.js";
|
|
28
29
|
import { demoPaneList, devLoopPaneList, launchLayout, renderLayout, standaloneDirs, } from "./panes.js";
|
|
29
|
-
import { demoDown, demoPorts, demoSlug,
|
|
30
|
+
import { demoDown, demoPorts, demoSlug, runExportCheck } from "./run.js";
|
|
30
31
|
const USAGE = `usage: demo <up|check|down|spec|ui> [--mode dev|image|standalone] [--daemon private|reuse] [--export-only] [--json]
|
|
31
32
|
[--ui zellij|tmux|none] [--public-host <host>] [--spec <path>]
|
|
32
33
|
demo dev-loop <up|refresh|down|ui> [--ui zellij|tmux|none] [--public-host <host>] [--spec <path>]
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { type StudioTarget } from "../container-net.js";
|
|
2
|
+
import type { SourceHandle } from "../source-pump.js";
|
|
3
|
+
import { type CliStep, type DemoDeps, type DemoPorts, type DemoRunOptions, type DemoRunResult, DemoSession, type DemoTimeouts, type IngestPortRow } from "./run.js";
|
|
4
|
+
import type { DemoContext, DemoDaemonPolicy, DemoIngest, DemoMode, DemoReady, DemoSpec, DemoTemplate } from "./spec.js";
|
|
5
|
+
export interface ProductHarnessOptions {
|
|
6
|
+
/** The product repo root: dev command cwd, relative inputs, manifest.seed.json. */
|
|
7
|
+
cwd: string;
|
|
8
|
+
/** Default: the product name without its `norsk-` prefix. */
|
|
9
|
+
slug?: string;
|
|
10
|
+
/** Default: the spec's `mode`, else `dev`. */
|
|
11
|
+
mode?: DemoMode;
|
|
12
|
+
/** Default private. */
|
|
13
|
+
daemon?: DemoDaemonPolicy;
|
|
14
|
+
daemonPort?: number;
|
|
15
|
+
timeouts?: DemoTimeouts;
|
|
16
|
+
publicHost?: string;
|
|
17
|
+
deps?: DemoDeps;
|
|
18
|
+
/** What the spec's hooks see as `ctx.action`: `check` (the default — a
|
|
19
|
+
* fixture, CI) or `up` (the held demo, where `launch.workingDirectory` applies). */
|
|
20
|
+
action?: "up" | "check";
|
|
21
|
+
/** Private daemon: init the proxy with a basic-auth user (a browser signs
|
|
22
|
+
* in) instead of `--proxy-auth none`. */
|
|
23
|
+
proxy?: {
|
|
24
|
+
user: string;
|
|
25
|
+
password: string;
|
|
26
|
+
};
|
|
27
|
+
/** Private daemon: extra environment for the daemon process (a doc guide
|
|
28
|
+
* names its own proxy container so a run cannot adopt the developer's). */
|
|
29
|
+
daemonEnv?: Record<string, string>;
|
|
30
|
+
/** Containers teardown must also wait out — an overridden proxy's pair. */
|
|
31
|
+
extraContainers?: string[];
|
|
32
|
+
}
|
|
33
|
+
export interface LaunchOverrides {
|
|
34
|
+
/** Default `demo-<slug>`. */
|
|
35
|
+
instanceId?: string;
|
|
36
|
+
/** Default: the spec's template (the product's first default when it names none). */
|
|
37
|
+
template?: DemoTemplate;
|
|
38
|
+
/** Merged over the spec's `launch.params`. */
|
|
39
|
+
params?: Record<string, string | number>;
|
|
40
|
+
hardware?: "nvidia" | "none";
|
|
41
|
+
workingDirectory?: string;
|
|
42
|
+
}
|
|
43
|
+
/** An instance `launch()` brought to running: its resolved ports and URLs,
|
|
44
|
+
* and the spec's sources and gates, on demand. */
|
|
45
|
+
export interface LaunchedInstance {
|
|
46
|
+
readonly instanceId: string;
|
|
47
|
+
readonly templateName: string;
|
|
48
|
+
readonly studioHostPort: number;
|
|
49
|
+
readonly ctx: DemoContext;
|
|
50
|
+
/** The daemon's `ingestPorts` rows for this instance (`instance describe`). */
|
|
51
|
+
readonly ingestPorts: IngestPortRow[];
|
|
52
|
+
/** A port the way the spec names one: resolved from the instance, never restated. */
|
|
53
|
+
ingestPort(ingest: DemoIngest): number;
|
|
54
|
+
/** The reach studio-state's fetchers take. */
|
|
55
|
+
studioTarget(): StudioTarget;
|
|
56
|
+
/** Pump the spec's sources (all, or the named subset) at this instance.
|
|
57
|
+
* The handles are stopped at teardown; stop one early via its `stop()`. */
|
|
58
|
+
startSources(names?: readonly string[]): Promise<SourceHandle[]>;
|
|
59
|
+
/** Poll the spec's `ready` gates (or the given ones) in order. */
|
|
60
|
+
awaitReady(gates?: readonly DemoReady[]): Promise<void>;
|
|
61
|
+
}
|
|
62
|
+
export declare class ProductHarness {
|
|
63
|
+
readonly spec: DemoSpec;
|
|
64
|
+
readonly slug: string;
|
|
65
|
+
readonly session: DemoSession;
|
|
66
|
+
readonly action: "up" | "check";
|
|
67
|
+
/** Every instance `launch()` brought to running, in order. */
|
|
68
|
+
readonly instances: LaunchedInstance[];
|
|
69
|
+
/** Every instance id a launch named, running or not: what teardown deletes. */
|
|
70
|
+
private readonly launchedIds;
|
|
71
|
+
private handles;
|
|
72
|
+
private readonly deps;
|
|
73
|
+
constructor(spec: DemoSpec, opts: ProductHarnessOptions);
|
|
74
|
+
/** Every CLI step this fixture ran, as a reader would type it, with its output. */
|
|
75
|
+
get transcript(): readonly CliStep[];
|
|
76
|
+
get ports(): DemoPorts;
|
|
77
|
+
get storeDir(): string;
|
|
78
|
+
get mode(): DemoMode;
|
|
79
|
+
get policy(): DemoDaemonPolicy;
|
|
80
|
+
/** The demo's instance id: what `launch()` uses when not told otherwise. */
|
|
81
|
+
get defaultInstanceId(): string;
|
|
82
|
+
cli: (argv: string[], opts?: {
|
|
83
|
+
output?: "json" | "yaml";
|
|
84
|
+
}) => Promise<import("./run.js").CliResult>;
|
|
85
|
+
cliOk: (argv: string[], opts?: {
|
|
86
|
+
output?: "json" | "yaml";
|
|
87
|
+
}) => Promise<import("./run.js").CliResult>;
|
|
88
|
+
/** The daemon (private on its band, or the developer's), the dev backend
|
|
89
|
+
* on the driver's port, and the registration. Nothing is launched. */
|
|
90
|
+
setup(): Promise<void>;
|
|
91
|
+
/** Store a template now — built from an input, or checked against what the
|
|
92
|
+
* product publishes — for a launch by `{ name }` later. What a product
|
|
93
|
+
* harness's buildAndImport does before its launchScenario. */
|
|
94
|
+
storeTemplate(template: DemoTemplate): Promise<string>;
|
|
95
|
+
/** Store and launch a template, wait until the instance reports running,
|
|
96
|
+
* and resolve what a test needs from it. Extras start and `beforeLaunch`
|
|
97
|
+
* runs before each launch, exactly as the demo does for its one. */
|
|
98
|
+
launch(overrides?: LaunchOverrides): Promise<LaunchedInstance>;
|
|
99
|
+
/** The demo journey on the fixture: launch, pump, gate, `after`, `open`. */
|
|
100
|
+
run(): Promise<DemoRunResult>;
|
|
101
|
+
/** Sources, every instance a launch named, then — private only — the
|
|
102
|
+
* daemon, the dev backend and the store. Throws if the store outlives the
|
|
103
|
+
* root-container nuke: a leak a runner would otherwise accumulate. */
|
|
104
|
+
teardown(): Promise<void>;
|
|
105
|
+
private nextStudioHostPort;
|
|
106
|
+
}
|
|
107
|
+
/** The demo: a ProductHarness set up, run, held (`up`) or not (`check`), torn down. */
|
|
108
|
+
export declare function runDemo(spec: DemoSpec, opts: DemoRunOptions, deps?: DemoDeps): Promise<DemoRunResult>;
|
package/demo/harness.js
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
// ProductHarness (05-demo s6 last paragraph, s7 step 6): the integration
|
|
2
|
+
// fixture built from the demo spec. A product's `harness.ts` used to hand-roll
|
|
3
|
+
// the same skeleton the demo driver runs — store, daemon, dev backend,
|
|
4
|
+
// registration, template, launch, wait-for-running, teardown — five times
|
|
5
|
+
// across the fleet, each copy drifting (container naming, root-owned-store
|
|
6
|
+
// cleanup, ports). Here the skeleton is one class the spec constructs:
|
|
7
|
+
// `setup()` brings the daemon and the registration up, `launch()` puts an
|
|
8
|
+
// instance on it (the spec's template, or an override — a matrix test builds
|
|
9
|
+
// its own), `run()` is the demo journey on top of that, and `runDemo` is
|
|
10
|
+
// `setup → run → hold → teardown` — so the demo and the test fixture cannot
|
|
11
|
+
// rot apart: they are the same code path.
|
|
12
|
+
//
|
|
13
|
+
// What stays in a product's harness.ts: its probes (a probe-api status, a
|
|
14
|
+
// program SRT listener, an HLS ladder), its config-schema port remap for a
|
|
15
|
+
// built template, and whatever sidecar its output points at.
|
|
16
|
+
import { netReachMode, STUDIO_INTERNAL_PORT, studioBaseFrom } from "../container-net.js";
|
|
17
|
+
import { pollUntil } from "../poll.js";
|
|
18
|
+
import { DemoGateFailure } from "./gates.js";
|
|
19
|
+
import { DemoSession, defaultDemoDeps, demoSlug, expandHome, resolveIngestPort, } from "./run.js";
|
|
20
|
+
const STUDIO_HOST_PORT_PARAM = "STUDIO_HOST_PORT";
|
|
21
|
+
/** Launches after the first take studio host ports above the proxy's, inside
|
|
22
|
+
* the band and below `instancePortBase`: six of them. */
|
|
23
|
+
const EXTRA_STUDIO_PORTS = 6;
|
|
24
|
+
export class ProductHarness {
|
|
25
|
+
spec;
|
|
26
|
+
slug;
|
|
27
|
+
session;
|
|
28
|
+
action;
|
|
29
|
+
/** Every instance `launch()` brought to running, in order. */
|
|
30
|
+
instances = [];
|
|
31
|
+
/** Every instance id a launch named, running or not: what teardown deletes. */
|
|
32
|
+
launchedIds = [];
|
|
33
|
+
handles = [];
|
|
34
|
+
deps;
|
|
35
|
+
constructor(spec, opts) {
|
|
36
|
+
const mode = opts.mode ?? spec.mode ?? "dev";
|
|
37
|
+
if (mode === "image" && !spec.image) {
|
|
38
|
+
throw new Error(`--mode image needs the spec's \`image\` (the built product image) — ${spec.product} declares none`);
|
|
39
|
+
}
|
|
40
|
+
this.spec = spec;
|
|
41
|
+
this.slug = opts.slug ?? demoSlug(spec.product);
|
|
42
|
+
this.action = opts.action ?? "check";
|
|
43
|
+
this.deps = opts.deps ?? defaultDemoDeps(opts.cwd);
|
|
44
|
+
this.session = new DemoSession(spec, this.slug, opts.cwd, this.deps, {
|
|
45
|
+
mode,
|
|
46
|
+
daemon: opts.daemon ?? "private",
|
|
47
|
+
...(opts.daemonPort !== undefined ? { daemonPort: opts.daemonPort } : {}),
|
|
48
|
+
...(opts.timeouts !== undefined ? { timeouts: opts.timeouts } : {}),
|
|
49
|
+
...(opts.publicHost !== undefined ? { publicHost: opts.publicHost } : {}),
|
|
50
|
+
...(opts.proxy !== undefined ? { proxy: opts.proxy } : {}),
|
|
51
|
+
...(opts.daemonEnv !== undefined ? { daemonEnv: opts.daemonEnv } : {}),
|
|
52
|
+
...(opts.extraContainers !== undefined ? { extraContainers: opts.extraContainers } : {}),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
/** Every CLI step this fixture ran, as a reader would type it, with its output. */
|
|
56
|
+
get transcript() {
|
|
57
|
+
return this.session.transcript;
|
|
58
|
+
}
|
|
59
|
+
get ports() {
|
|
60
|
+
return this.session.ports;
|
|
61
|
+
}
|
|
62
|
+
get storeDir() {
|
|
63
|
+
return this.session.storeDir;
|
|
64
|
+
}
|
|
65
|
+
get mode() {
|
|
66
|
+
return this.session.mode;
|
|
67
|
+
}
|
|
68
|
+
get policy() {
|
|
69
|
+
return this.session.policy;
|
|
70
|
+
}
|
|
71
|
+
/** The demo's instance id: what `launch()` uses when not told otherwise. */
|
|
72
|
+
get defaultInstanceId() {
|
|
73
|
+
return `demo-${this.slug}`;
|
|
74
|
+
}
|
|
75
|
+
cli = (argv, opts) => this.session.cli(argv, opts);
|
|
76
|
+
cliOk = (argv, opts) => this.session.cliOk(argv, opts);
|
|
77
|
+
/** The daemon (private on its band, or the developer's), the dev backend
|
|
78
|
+
* on the driver's port, and the registration. Nothing is launched. */
|
|
79
|
+
async setup() {
|
|
80
|
+
const s = this.session;
|
|
81
|
+
await s.attachDaemon();
|
|
82
|
+
if (s.mode === "dev")
|
|
83
|
+
await s.startDev();
|
|
84
|
+
if (s.policy === "reuse")
|
|
85
|
+
await s.resetRegistration(this.defaultInstanceId);
|
|
86
|
+
await s.register({ licence: true });
|
|
87
|
+
}
|
|
88
|
+
/** Store a template now — built from an input, or checked against what the
|
|
89
|
+
* product publishes — for a launch by `{ name }` later. What a product
|
|
90
|
+
* harness's buildAndImport does before its launchScenario. */
|
|
91
|
+
async storeTemplate(template) {
|
|
92
|
+
return this.session.resolveTemplate(template);
|
|
93
|
+
}
|
|
94
|
+
/** Store and launch a template, wait until the instance reports running,
|
|
95
|
+
* and resolve what a test needs from it. Extras start and `beforeLaunch`
|
|
96
|
+
* runs before each launch, exactly as the demo does for its one. */
|
|
97
|
+
async launch(overrides = {}) {
|
|
98
|
+
const { spec, session: s, deps } = this;
|
|
99
|
+
const instanceId = overrides.instanceId ?? this.defaultInstanceId;
|
|
100
|
+
const templateName = await s.resolveTemplate(overrides.template ?? spec.template);
|
|
101
|
+
await s.pinGuard(templateName);
|
|
102
|
+
const launchCtx = {
|
|
103
|
+
action: this.action,
|
|
104
|
+
mode: s.mode,
|
|
105
|
+
daemon: s.policy,
|
|
106
|
+
product: spec.product,
|
|
107
|
+
instanceId,
|
|
108
|
+
daemonPort: s.ports.daemonPort,
|
|
109
|
+
storeDir: s.storeDir,
|
|
110
|
+
templateName,
|
|
111
|
+
templateDir: s.templateDir(templateName),
|
|
112
|
+
cli: s.cli,
|
|
113
|
+
log: deps.log,
|
|
114
|
+
};
|
|
115
|
+
s.startExtras(instanceId);
|
|
116
|
+
if (spec.beforeLaunch)
|
|
117
|
+
await spec.beforeLaunch(launchCtx);
|
|
118
|
+
const declared = await s.declaredParams(templateName);
|
|
119
|
+
const specParams = { ...(spec.launch?.params ?? {}), ...(overrides.params ?? {}) };
|
|
120
|
+
const studioHostPort = specParams[STUDIO_HOST_PORT_PARAM] !== undefined
|
|
121
|
+
? Number(specParams[STUDIO_HOST_PORT_PARAM])
|
|
122
|
+
: this.nextStudioHostPort();
|
|
123
|
+
const params = [`INSTANCE_NAME=${instanceId}`, ...Object.entries(specParams).map(([k, v]) => `${k}=${v}`)];
|
|
124
|
+
const launchArgv = ["instance", "launch-template", instanceId, "--template", templateName];
|
|
125
|
+
const internalOnly = netReachMode() === "direct" && (await deps.supportsNoPublish());
|
|
126
|
+
if (declared.has(STUDIO_HOST_PORT_PARAM)) {
|
|
127
|
+
if (specParams[STUDIO_HOST_PORT_PARAM] === undefined)
|
|
128
|
+
params.push(`${STUDIO_HOST_PORT_PARAM}=${studioHostPort}`);
|
|
129
|
+
}
|
|
130
|
+
else if (!internalOnly) {
|
|
131
|
+
launchArgv.push("--host-ports", `${studioHostPort}:${STUDIO_INTERNAL_PORT}@studio`);
|
|
132
|
+
}
|
|
133
|
+
for (const p of params)
|
|
134
|
+
launchArgv.push("--param", p);
|
|
135
|
+
const hardware = overrides.hardware ?? spec.launch?.hardware;
|
|
136
|
+
if (hardware)
|
|
137
|
+
launchArgv.push("--hardware", hardware);
|
|
138
|
+
const workingDirectory = overrides.workingDirectory ?? spec.launch?.workingDirectory;
|
|
139
|
+
if (this.action === "up" && workingDirectory) {
|
|
140
|
+
launchArgv.push("--working-directory", expandHome(workingDirectory));
|
|
141
|
+
}
|
|
142
|
+
const user = deps.containerUser();
|
|
143
|
+
if (user)
|
|
144
|
+
launchArgv.push("--container-user", user);
|
|
145
|
+
if (internalOnly)
|
|
146
|
+
launchArgv.push("--internal-only");
|
|
147
|
+
if (!this.launchedIds.includes(instanceId))
|
|
148
|
+
this.launchedIds.push(instanceId);
|
|
149
|
+
await s.cliOk(launchArgv);
|
|
150
|
+
await s.waitRunning(instanceId);
|
|
151
|
+
const rows = await s.ingestPorts(instanceId);
|
|
152
|
+
const templateParams = { declared, overrides: specParams };
|
|
153
|
+
const ctx = {
|
|
154
|
+
...launchCtx,
|
|
155
|
+
url: urlResolver({ instanceId, ports: s.ports, studioHostPort, proxyBase: s.proxyBase, host: s.host }),
|
|
156
|
+
fetch: deps.fetch,
|
|
157
|
+
};
|
|
158
|
+
const instance = {
|
|
159
|
+
instanceId,
|
|
160
|
+
templateName,
|
|
161
|
+
studioHostPort,
|
|
162
|
+
ctx,
|
|
163
|
+
ingestPorts: rows,
|
|
164
|
+
ingestPort: (ingest) => resolveIngestPort(ingest, rows, templateParams),
|
|
165
|
+
studioTarget: () => ({ instanceId, studioHostPort }),
|
|
166
|
+
startSources: async (names) => {
|
|
167
|
+
const chosen = (spec.sources ?? []).filter((src) => !names || names.includes(src.name));
|
|
168
|
+
const missing = (names ?? []).filter((n) => !chosen.some((src) => src.name === n));
|
|
169
|
+
if (missing.length)
|
|
170
|
+
throw new Error(`no source named ${missing.join(", ")} in the spec`);
|
|
171
|
+
const targets = chosen.map((src) => ({
|
|
172
|
+
port: resolveIngestPort(src.ingest, rows, templateParams),
|
|
173
|
+
name: src.name,
|
|
174
|
+
asset: src.asset ?? { preset: "camera1" },
|
|
175
|
+
...(src.streamId !== undefined ? { streamId: src.streamId } : {}),
|
|
176
|
+
}));
|
|
177
|
+
if (!targets.length)
|
|
178
|
+
return [];
|
|
179
|
+
const secret = s.proxySecret();
|
|
180
|
+
const handles = await deps.startSources({
|
|
181
|
+
daemonPort: s.ports.daemonPort,
|
|
182
|
+
instanceId,
|
|
183
|
+
targets,
|
|
184
|
+
...(secret !== undefined ? { proxySecret: secret } : {}),
|
|
185
|
+
});
|
|
186
|
+
this.handles.push(...handles);
|
|
187
|
+
deps.log(`sources: ${targets.map((t) => `${t.name} -> :${t.port}`).join(", ")}`);
|
|
188
|
+
return handles;
|
|
189
|
+
},
|
|
190
|
+
awaitReady: async (gates = spec.ready ?? []) => {
|
|
191
|
+
for (const gate of gates) {
|
|
192
|
+
const name = await awaitGate(gate, ctx, s.gateTimeout(gate.timeoutMs));
|
|
193
|
+
deps.log(`ready: ${name}`);
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
this.instances.push(instance);
|
|
198
|
+
return instance;
|
|
199
|
+
}
|
|
200
|
+
/** The demo journey on the fixture: launch, pump, gate, `after`, `open`. */
|
|
201
|
+
async run() {
|
|
202
|
+
const { spec, session: s, deps } = this;
|
|
203
|
+
const inst = await this.launch();
|
|
204
|
+
// Sources first: a gate such as "the switcher is composing" or "the probe
|
|
205
|
+
// is analysing" can only hold with something on the wire.
|
|
206
|
+
if (spec.sources?.length)
|
|
207
|
+
await inst.startSources();
|
|
208
|
+
await inst.awaitReady();
|
|
209
|
+
if (spec.after)
|
|
210
|
+
await spec.after(inst.ctx);
|
|
211
|
+
const open = await resolveOpen(spec, inst.ctx);
|
|
212
|
+
const width = Math.max(0, ...open.map((o) => o.name.length));
|
|
213
|
+
deps.log(`${spec.product} demo is ${this.action === "up" ? "UP" : "up (check)"} — instance ${inst.instanceId}`);
|
|
214
|
+
for (const o of open)
|
|
215
|
+
deps.log(` ${o.name.padEnd(width)} ${o.value}`);
|
|
216
|
+
return { instanceId: inst.instanceId, daemonPort: s.ports.daemonPort, storeDir: s.storeDir, open };
|
|
217
|
+
}
|
|
218
|
+
/** Sources, every instance a launch named, then — private only — the
|
|
219
|
+
* daemon, the dev backend and the store. Throws if the store outlives the
|
|
220
|
+
* root-container nuke: a leak a runner would otherwise accumulate. */
|
|
221
|
+
async teardown() {
|
|
222
|
+
const s = this.session;
|
|
223
|
+
const handles = this.handles;
|
|
224
|
+
this.handles = [];
|
|
225
|
+
await s.teardown({ instances: [...this.launchedIds], handles });
|
|
226
|
+
if (s.policy === "private" && this.deps.storeExists(s.storeDir)) {
|
|
227
|
+
throw new Error(`store ${s.storeDir} still present after the root-container nuke`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
nextStudioHostPort() {
|
|
231
|
+
const n = this.launchedIds.length;
|
|
232
|
+
if (n === 0)
|
|
233
|
+
return this.ports.studioHostPort;
|
|
234
|
+
if (n > EXTRA_STUDIO_PORTS) {
|
|
235
|
+
throw new Error(`launch ${n + 1}: the band holds ${EXTRA_STUDIO_PORTS + 1} studio host ports — pass params.${STUDIO_HOST_PORT_PARAM}`);
|
|
236
|
+
}
|
|
237
|
+
return this.ports.proxyPort + n;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
function urlResolver(o) {
|
|
241
|
+
const host = o.host;
|
|
242
|
+
const mode = netReachMode();
|
|
243
|
+
return (ref) => {
|
|
244
|
+
if ("url" in ref)
|
|
245
|
+
return ref.url;
|
|
246
|
+
if ("control" in ref)
|
|
247
|
+
return `http://${host}:${o.ports.backendPort}${ref.control}`;
|
|
248
|
+
if ("proxy" in ref)
|
|
249
|
+
return `${o.proxyBase}${ref.proxy.replaceAll("{id}", o.instanceId)}`;
|
|
250
|
+
return `${studioBaseFrom({ instanceId: o.instanceId, studioHostPort: o.studioHostPort }, host, mode)}${ref.studio}`;
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
async function gateHolds(gate, ctx) {
|
|
254
|
+
if ("custom" in gate)
|
|
255
|
+
return gate.custom(ctx);
|
|
256
|
+
const r = await ctx.fetch(ctx.url(gate.http));
|
|
257
|
+
if (r.status !== (gate.status ?? 200))
|
|
258
|
+
return false;
|
|
259
|
+
if (gate.bodyIncludes === undefined)
|
|
260
|
+
return true;
|
|
261
|
+
return (await r.text()).includes(gate.bodyIncludes);
|
|
262
|
+
}
|
|
263
|
+
function gateName(gate, ctx) {
|
|
264
|
+
if ("custom" in gate)
|
|
265
|
+
return gate.label ?? "custom gate";
|
|
266
|
+
return `${ctx.url(gate.http)} -> ${gate.status ?? 200}${gate.bodyIncludes ? ` containing '${gate.bodyIncludes}'` : ""}`;
|
|
267
|
+
}
|
|
268
|
+
/** Poll a gate until it holds — or stop at once when it declares its failure
|
|
269
|
+
* final (DemoGateFailure), which pollUntil would otherwise keep retrying. */
|
|
270
|
+
async function awaitGate(gate, ctx, timeoutMs) {
|
|
271
|
+
const name = gateName(gate, ctx);
|
|
272
|
+
let fatal;
|
|
273
|
+
await pollUntil(async () => {
|
|
274
|
+
try {
|
|
275
|
+
return await gateHolds(gate, ctx);
|
|
276
|
+
}
|
|
277
|
+
catch (e) {
|
|
278
|
+
if (e instanceof DemoGateFailure) {
|
|
279
|
+
fatal = e;
|
|
280
|
+
return true;
|
|
281
|
+
}
|
|
282
|
+
throw e;
|
|
283
|
+
}
|
|
284
|
+
}, { timeoutMs, intervalMs: 1000, label: `ready gate never held: ${name}` });
|
|
285
|
+
if (fatal)
|
|
286
|
+
throw new Error(`ready gate failed: ${name}: ${fatal.message}`);
|
|
287
|
+
return name;
|
|
288
|
+
}
|
|
289
|
+
async function resolveOpen(spec, ctx) {
|
|
290
|
+
const out = [];
|
|
291
|
+
for (const o of spec.open ?? []) {
|
|
292
|
+
const url = ctx.url(o.url);
|
|
293
|
+
if (!o.pick) {
|
|
294
|
+
out.push({ name: o.name, value: url });
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
try {
|
|
298
|
+
const body = await (await ctx.fetch(url)).text();
|
|
299
|
+
const m = body.match(new RegExp(o.pick));
|
|
300
|
+
out.push({ name: o.name, value: m ? m[0] : `<no match for /${o.pick}/ at ${url}>` });
|
|
301
|
+
}
|
|
302
|
+
catch (e) {
|
|
303
|
+
out.push({ name: o.name, value: `<unavailable: ${e instanceof Error ? e.message : String(e)}>` });
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return out;
|
|
307
|
+
}
|
|
308
|
+
/** `up` refuses to run beside a live earlier run of the same demo. Private:
|
|
309
|
+
* its daemon still answers. Reuse: the daemon always answers, so the record
|
|
310
|
+
* is live only while its instance still exists. A dead record is forgotten. */
|
|
311
|
+
async function refuseIfUp(spec, s, deps) {
|
|
312
|
+
const prev = deps.state.read(spec.product);
|
|
313
|
+
if (!prev)
|
|
314
|
+
return;
|
|
315
|
+
const answers = await deps.daemonAnswers(prev.daemonPort);
|
|
316
|
+
const live = prev.daemon === "reuse"
|
|
317
|
+
? answers && prev.instanceId !== undefined && (await s.instanceListed(prev.instanceId))
|
|
318
|
+
: answers;
|
|
319
|
+
if (live) {
|
|
320
|
+
throw new Error(`demo '${spec.product}' is already up (daemon :${prev.daemonPort}, store ${prev.storeDir}) — run \`demo down\` first`);
|
|
321
|
+
}
|
|
322
|
+
deps.log(`forgetting a stale record of a run on :${prev.daemonPort} (nothing of it is left)`);
|
|
323
|
+
deps.state.remove(spec.product);
|
|
324
|
+
}
|
|
325
|
+
/** The demo: a ProductHarness set up, run, held (`up`) or not (`check`), torn down. */
|
|
326
|
+
export async function runDemo(spec, opts, deps = defaultDemoDeps(opts.cwd)) {
|
|
327
|
+
const policy = opts.daemon ?? "private";
|
|
328
|
+
const h = new ProductHarness(spec, {
|
|
329
|
+
cwd: opts.cwd,
|
|
330
|
+
mode: opts.mode,
|
|
331
|
+
daemon: policy,
|
|
332
|
+
deps,
|
|
333
|
+
action: opts.action,
|
|
334
|
+
...(opts.slug !== undefined ? { slug: opts.slug } : {}),
|
|
335
|
+
...(opts.daemonPort !== undefined ? { daemonPort: opts.daemonPort } : {}),
|
|
336
|
+
...(opts.timeouts !== undefined ? { timeouts: opts.timeouts } : {}),
|
|
337
|
+
...(opts.publicHost !== undefined ? { publicHost: opts.publicHost } : {}),
|
|
338
|
+
});
|
|
339
|
+
if (opts.action === "up") {
|
|
340
|
+
await refuseIfUp(spec, h.session, deps);
|
|
341
|
+
for (const p of spec.prerequisites ?? []) {
|
|
342
|
+
const path = expandHome(p.path);
|
|
343
|
+
if (!deps.fileExists(path))
|
|
344
|
+
throw new Error(`prerequisite missing: ${path}\n make it with: ${p.hint}`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
let recorded = false;
|
|
348
|
+
let result;
|
|
349
|
+
let journeyError;
|
|
350
|
+
try {
|
|
351
|
+
await h.setup();
|
|
352
|
+
result = await h.run();
|
|
353
|
+
if (opts.action === "up") {
|
|
354
|
+
const s = h.session;
|
|
355
|
+
deps.state.write({
|
|
356
|
+
product: spec.product,
|
|
357
|
+
storeDir: s.storeDir,
|
|
358
|
+
daemonPort: s.ports.daemonPort,
|
|
359
|
+
instanceId: result.instanceId,
|
|
360
|
+
daemon: policy,
|
|
361
|
+
...(s.extras.length ? { extras: [...s.extras] } : {}),
|
|
362
|
+
...(s.dev?.pid !== undefined ? { devPid: s.dev.pid } : {}),
|
|
363
|
+
});
|
|
364
|
+
recorded = true;
|
|
365
|
+
deps.log("holding — Ctrl-C (or `demo down` from another shell) tears down");
|
|
366
|
+
await deps.hold(opts.abort);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
catch (e) {
|
|
370
|
+
journeyError = e;
|
|
371
|
+
}
|
|
372
|
+
let teardownError;
|
|
373
|
+
try {
|
|
374
|
+
await h.teardown();
|
|
375
|
+
}
|
|
376
|
+
catch (e) {
|
|
377
|
+
teardownError = e;
|
|
378
|
+
}
|
|
379
|
+
if (recorded)
|
|
380
|
+
deps.state.remove(spec.product);
|
|
381
|
+
if (journeyError !== undefined)
|
|
382
|
+
throw journeyError;
|
|
383
|
+
if (teardownError !== undefined)
|
|
384
|
+
throw teardownError;
|
|
385
|
+
return result;
|
|
386
|
+
}
|
package/demo/index.d.ts
CHANGED
|
@@ -3,9 +3,11 @@ export { defaultDemoCliIo, demoMain, parseDemoArgs, resolvedSpecView } from "./c
|
|
|
3
3
|
export type { DevLoopOptions, DevLoopResult, StandaloneSource } from "./dev-loop.js";
|
|
4
4
|
export { devLoopDown, pumpScript, runDevLoop } from "./dev-loop.js";
|
|
5
5
|
export { composeAdvancingGate, DemoGateFailure, logsCleanGate } from "./gates.js";
|
|
6
|
+
export type { LaunchedInstance, LaunchOverrides, ProductHarnessOptions } from "./harness.js";
|
|
7
|
+
export { ProductHarness, runDemo } from "./harness.js";
|
|
6
8
|
export type { DemoPane, DemoPaneOptions, DemoUi, DevLoopPaneOptions, PaneLayout, StandaloneDirs } from "./panes.js";
|
|
7
9
|
export { DEV_LOOP_SOURCES, DEV_LOOP_STAMP, demoPaneList, devLoopPaneList, launchLayout, paneArgv, paneCommandLine, paneShellLine, renderLayout, shellQuote, standaloneDirs, } from "./panes.js";
|
|
8
|
-
export type { CliResult, DemoDeps, DemoPorts, DemoRunOptions, DemoRunResult, DemoState, DemoStateKind, DemoStateStore, DemoTimeouts, ExportCheckOptions, IngestPortRow, PinMismatch, ProcessHandle, TemplateParams, } from "./run.js";
|
|
9
|
-
export { checkTemplatePins, defaultDemoDeps, demoDown, demoPorts, demoSlug, expandHome, exportStandaloneWorkdir, fileStateStore, findBrokenSymlinks, killGroup, resolveIngestPort,
|
|
10
|
+
export type { CliResult, CliStep, DemoDeps, DemoPorts, DemoRunOptions, DemoRunResult, DemoState, DemoStateKind, DemoStateStore, DemoTimeouts, ExportCheckOptions, IngestPortRow, PinMismatch, ProcessHandle, TemplateParams, } from "./run.js";
|
|
11
|
+
export { checkTemplatePins, defaultDemoDeps, demoDown, demoPorts, demoSlug, expandHome, exportStandaloneWorkdir, fileStateStore, findBrokenSymlinks, killGroup, resolveIngestPort, runExportCheck, } from "./run.js";
|
|
10
12
|
export type { DemoContext, DemoDaemonPolicy, DemoExtra, DemoIngest, DemoLaunchContext, DemoMode, DemoOpen, DemoReady, DemoSource, DemoSpec, DemoTemplate, DemoUrl, } from "./spec.js";
|
|
11
13
|
export { DemoSpecSchema, defineDemo, formatSpecIssues } from "./spec.js";
|
package/demo/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { defaultDemoCliIo, demoMain, parseDemoArgs, resolvedSpecView } from "./cli.js";
|
|
2
2
|
export { devLoopDown, pumpScript, runDevLoop } from "./dev-loop.js";
|
|
3
3
|
export { composeAdvancingGate, DemoGateFailure, logsCleanGate } from "./gates.js";
|
|
4
|
+
export { ProductHarness, runDemo } from "./harness.js";
|
|
4
5
|
export { DEV_LOOP_SOURCES, DEV_LOOP_STAMP, demoPaneList, devLoopPaneList, launchLayout, paneArgv, paneCommandLine, paneShellLine, renderLayout, shellQuote, standaloneDirs, } from "./panes.js";
|
|
5
|
-
export { checkTemplatePins, defaultDemoDeps, demoDown, demoPorts, demoSlug, expandHome, exportStandaloneWorkdir, fileStateStore, findBrokenSymlinks, killGroup, resolveIngestPort,
|
|
6
|
+
export { checkTemplatePins, defaultDemoDeps, demoDown, demoPorts, demoSlug, expandHome, exportStandaloneWorkdir, fileStateStore, findBrokenSymlinks, killGroup, resolveIngestPort, runExportCheck, } from "./run.js";
|
|
6
7
|
export { DemoSpecSchema, defineDemo, formatSpecIssues } from "./spec.js";
|
package/demo/run.d.ts
CHANGED
|
@@ -188,6 +188,15 @@ export declare function fileStateStore(cwd: string): DemoStateStore;
|
|
|
188
188
|
* own), falling back to the pid alone if it is not a group leader. */
|
|
189
189
|
export declare function killGroup(pid: number): void;
|
|
190
190
|
export declare function defaultDemoDeps(cwd: string): DemoDeps;
|
|
191
|
+
/** A CLI step as it ran: the argv a reader would type (no daemon port, no
|
|
192
|
+
* output format) and what it printed — what a doc guide renders verbatim
|
|
193
|
+
* instead of hand-typing (and drifting from) the command. */
|
|
194
|
+
export interface CliStep {
|
|
195
|
+
argv: string[];
|
|
196
|
+
stdout: string;
|
|
197
|
+
stderr: string;
|
|
198
|
+
exitCode: number;
|
|
199
|
+
}
|
|
191
200
|
export interface SessionOptions {
|
|
192
201
|
mode: DemoMode;
|
|
193
202
|
daemon: DemoDaemonPolicy;
|
|
@@ -196,6 +205,17 @@ export interface SessionOptions {
|
|
|
196
205
|
publicHost?: string;
|
|
197
206
|
/** Private: attach to an existing private store (a recorded run) instead of making one. */
|
|
198
207
|
storeDir?: string;
|
|
208
|
+
/** Private: init the proxy with a basic-auth user (a browser signs in)
|
|
209
|
+
* instead of `--proxy-auth none`. */
|
|
210
|
+
proxy?: {
|
|
211
|
+
user: string;
|
|
212
|
+
password: string;
|
|
213
|
+
};
|
|
214
|
+
/** Private: extra environment for the daemon process (a doc guide names its
|
|
215
|
+
* own proxy container so a run cannot adopt the developer's). */
|
|
216
|
+
daemonEnv?: Record<string, string>;
|
|
217
|
+
/** Containers teardown must also wait out — an overridden proxy's pair. */
|
|
218
|
+
extraContainers?: string[];
|
|
199
219
|
}
|
|
200
220
|
/** The pieces of a run that `runDemo`, `runExportCheck` and the dev-loop
|
|
201
221
|
* share: the daemon (private on its band, or the developer's), the dev
|
|
@@ -218,7 +238,12 @@ export declare class DemoSession {
|
|
|
218
238
|
daemon: DaemonProcess | null;
|
|
219
239
|
dev: ProcessHandle | null;
|
|
220
240
|
extras: string[];
|
|
241
|
+
/** Every CLI call this session made, in order. */
|
|
242
|
+
readonly transcript: CliStep[];
|
|
221
243
|
private readonly timeouts;
|
|
244
|
+
private readonly proxyAuth;
|
|
245
|
+
private readonly daemonEnv;
|
|
246
|
+
private readonly extraContainers;
|
|
222
247
|
constructor(spec: DemoSpec, slug: string, cwd: string, deps: DemoDeps, opts: SessionOptions);
|
|
223
248
|
get devUrl(): string;
|
|
224
249
|
templateDir(name: string): string;
|
|
@@ -266,7 +291,6 @@ export declare class DemoSession {
|
|
|
266
291
|
handles: SourceHandle[];
|
|
267
292
|
}): Promise<void>;
|
|
268
293
|
}
|
|
269
|
-
export declare function runDemo(spec: DemoSpec, opts: DemoRunOptions, deps?: DemoDeps): Promise<DemoRunResult>;
|
|
270
294
|
export interface ExportCheckOptions {
|
|
271
295
|
cwd: string;
|
|
272
296
|
slug?: string;
|
package/demo/run.js
CHANGED
|
@@ -29,20 +29,18 @@ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSy
|
|
|
29
29
|
import { homedir } from "node:os";
|
|
30
30
|
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
31
31
|
import { parseManifestSeed, repoOf } from "@norskvideo/ctl-sdk/manifest-seed";
|
|
32
|
-
import { DOCKER_NETWORK_NAME, ensureRunnerOnNetwork, netReachMode
|
|
32
|
+
import { DOCKER_NETWORK_NAME, ensureRunnerOnNetwork, netReachMode } from "../container-net.js";
|
|
33
33
|
import { cleanupDaemon, requireLicenseFile, runCli, startDaemon, } from "../daemon.js";
|
|
34
34
|
import { hashSlot } from "../harness-config.js";
|
|
35
35
|
import { ctlSupportsNoPublish, runnerContainerUser } from "../launch.js";
|
|
36
36
|
import { pollUntil } from "../poll.js";
|
|
37
37
|
import { startSrtSources } from "../source-pump.js";
|
|
38
38
|
import { makeStoreDir } from "../temp-dir.js";
|
|
39
|
-
import { DemoGateFailure } from "./gates.js";
|
|
40
39
|
const DEMO_PORT_BASE = 35000;
|
|
41
40
|
const DEMO_BAND_WIDTH = 20;
|
|
42
41
|
const DEMO_BANDS = 50;
|
|
43
42
|
const DEFAULT_DAEMON_PORT = 8333;
|
|
44
43
|
const DEFAULT_PROXY_PORT = 443;
|
|
45
|
-
const STUDIO_HOST_PORT_PARAM = "STUDIO_HOST_PORT";
|
|
46
44
|
const NUKE_IMAGE = "alpine:3";
|
|
47
45
|
const DEFAULT_DEV_READY_PATH = "/manifest.json";
|
|
48
46
|
const SEED_FILE = "manifest.seed.json";
|
|
@@ -348,7 +346,12 @@ export class DemoSession {
|
|
|
348
346
|
daemon = null;
|
|
349
347
|
dev = null;
|
|
350
348
|
extras = [];
|
|
349
|
+
/** Every CLI call this session made, in order. */
|
|
350
|
+
transcript = [];
|
|
351
351
|
timeouts;
|
|
352
|
+
proxyAuth;
|
|
353
|
+
daemonEnv;
|
|
354
|
+
extraContainers;
|
|
352
355
|
constructor(spec, slug, cwd, deps, opts) {
|
|
353
356
|
this.spec = spec;
|
|
354
357
|
this.slug = slug;
|
|
@@ -373,6 +376,9 @@ export class DemoSession {
|
|
|
373
376
|
// speaks plain http (seen live: https:// gave 000, http:// served the page).
|
|
374
377
|
this.proxyBase = `http://${host}:${this.ports.proxyPort}`;
|
|
375
378
|
}
|
|
379
|
+
this.proxyAuth = opts.proxy;
|
|
380
|
+
this.daemonEnv = opts.daemonEnv ?? {};
|
|
381
|
+
this.extraContainers = opts.extraContainers ?? [];
|
|
376
382
|
this.timeouts = {
|
|
377
383
|
devReadyMs: opts.timeouts?.devReadyMs ?? 120_000,
|
|
378
384
|
healthyMs: opts.timeouts?.healthyMs ?? 180_000,
|
|
@@ -391,12 +397,16 @@ export class DemoSession {
|
|
|
391
397
|
return undefined;
|
|
392
398
|
return this.deps.readFile(join(this.storeDir, PROXY_SECRET_FILE))?.trim() || undefined;
|
|
393
399
|
}
|
|
394
|
-
cli = async (argv, opts = {}) =>
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
+
cli = async (argv, opts = {}) => {
|
|
401
|
+
const r = await this.deps.cli(this.storeDir, [
|
|
402
|
+
"--port",
|
|
403
|
+
String(this.ports.daemonPort),
|
|
404
|
+
...argv,
|
|
405
|
+
...(opts.output ? ["-o", opts.output] : []),
|
|
406
|
+
]);
|
|
407
|
+
this.transcript.push({ argv: [...argv], stdout: r.stdout, stderr: r.stderr, exitCode: r.exitCode });
|
|
408
|
+
return r;
|
|
409
|
+
};
|
|
400
410
|
cliOk = async (argv, opts = {}) => {
|
|
401
411
|
const r = await this.cli(argv, opts);
|
|
402
412
|
if (r.exitCode !== 0) {
|
|
@@ -425,13 +435,22 @@ export class DemoSession {
|
|
|
425
435
|
// /instance/<id>/... routes (the visualiser a gate reads, the `open`
|
|
426
436
|
// URLs) must answer without a session: with oauth on, the compose gate
|
|
427
437
|
// read the sign-in page as "0 frames" for 180s (playout CI, 2026-08-29).
|
|
428
|
-
|
|
429
|
-
|
|
438
|
+
// A doc guide that shows the sign-in asks for a user instead.
|
|
439
|
+
...(this.proxyAuth
|
|
440
|
+
? ["--proxy-user", this.proxyAuth.user, "--proxy-password", this.proxyAuth.password]
|
|
441
|
+
: ["--proxy-auth", "none"]),
|
|
430
442
|
"--no-http-redirect",
|
|
431
443
|
"--no-start-server",
|
|
432
444
|
...(this.publicHost ? ["--public-host", this.publicHost] : []),
|
|
433
445
|
]);
|
|
434
|
-
|
|
446
|
+
// The instance ports the daemon allocates come from this band too, so two
|
|
447
|
+
// products' fixtures on one box never share one (what every product
|
|
448
|
+
// harness passed by hand).
|
|
449
|
+
const started = this.deps.startDaemon(this.storeDir, {
|
|
450
|
+
port: this.ports.daemonPort,
|
|
451
|
+
seedConfig: false,
|
|
452
|
+
env: { NORSK_CTL_INSTANCE_PORT_BASE: String(this.ports.instancePortBase), ...this.daemonEnv },
|
|
453
|
+
});
|
|
435
454
|
this.daemon = started.daemon;
|
|
436
455
|
await started.ready;
|
|
437
456
|
if (netReachMode() === "direct" && this.deps.ensureNetwork() === "failed") {
|
|
@@ -634,7 +653,7 @@ export class DemoSession {
|
|
|
634
653
|
instances: opts.instances,
|
|
635
654
|
daemon: this.daemon,
|
|
636
655
|
storeDir: this.storeDir,
|
|
637
|
-
containers: opts.instances.flatMap(instanceContainerNames),
|
|
656
|
+
containers: [...opts.instances.flatMap(instanceContainerNames), ...this.extraContainers],
|
|
638
657
|
});
|
|
639
658
|
}
|
|
640
659
|
catch (e) {
|
|
@@ -645,238 +664,6 @@ export class DemoSession {
|
|
|
645
664
|
this.deps.nukeStoreAsRoot(this.storeDir);
|
|
646
665
|
}
|
|
647
666
|
}
|
|
648
|
-
function urlResolver(o) {
|
|
649
|
-
const host = o.host;
|
|
650
|
-
const mode = netReachMode();
|
|
651
|
-
return (ref) => {
|
|
652
|
-
if ("url" in ref)
|
|
653
|
-
return ref.url;
|
|
654
|
-
if ("control" in ref)
|
|
655
|
-
return `http://${host}:${o.ports.backendPort}${ref.control}`;
|
|
656
|
-
if ("proxy" in ref)
|
|
657
|
-
return `${o.proxyBase}${ref.proxy.replaceAll("{id}", o.instanceId)}`;
|
|
658
|
-
return `${studioBaseFrom({ instanceId: o.instanceId, studioHostPort: o.studioHostPort }, host, mode)}${ref.studio}`;
|
|
659
|
-
};
|
|
660
|
-
}
|
|
661
|
-
async function gateHolds(gate, ctx) {
|
|
662
|
-
if ("custom" in gate)
|
|
663
|
-
return gate.custom(ctx);
|
|
664
|
-
const r = await ctx.fetch(ctx.url(gate.http));
|
|
665
|
-
if (r.status !== (gate.status ?? 200))
|
|
666
|
-
return false;
|
|
667
|
-
if (gate.bodyIncludes === undefined)
|
|
668
|
-
return true;
|
|
669
|
-
return (await r.text()).includes(gate.bodyIncludes);
|
|
670
|
-
}
|
|
671
|
-
function gateName(gate, ctx) {
|
|
672
|
-
if ("custom" in gate)
|
|
673
|
-
return gate.label ?? "custom gate";
|
|
674
|
-
return `${ctx.url(gate.http)} -> ${gate.status ?? 200}${gate.bodyIncludes ? ` containing '${gate.bodyIncludes}'` : ""}`;
|
|
675
|
-
}
|
|
676
|
-
/** Poll a gate until it holds — or stop at once when it declares its failure
|
|
677
|
-
* final (DemoGateFailure), which pollUntil would otherwise keep retrying. */
|
|
678
|
-
async function awaitGate(gate, ctx, timeoutMs) {
|
|
679
|
-
const name = gateName(gate, ctx);
|
|
680
|
-
let fatal;
|
|
681
|
-
await pollUntil(async () => {
|
|
682
|
-
try {
|
|
683
|
-
return await gateHolds(gate, ctx);
|
|
684
|
-
}
|
|
685
|
-
catch (e) {
|
|
686
|
-
if (e instanceof DemoGateFailure) {
|
|
687
|
-
fatal = e;
|
|
688
|
-
return true;
|
|
689
|
-
}
|
|
690
|
-
throw e;
|
|
691
|
-
}
|
|
692
|
-
}, { timeoutMs, intervalMs: 1000, label: `ready gate never held: ${name}` });
|
|
693
|
-
if (fatal)
|
|
694
|
-
throw new Error(`ready gate failed: ${name}: ${fatal.message}`);
|
|
695
|
-
return name;
|
|
696
|
-
}
|
|
697
|
-
async function resolveOpen(spec, ctx) {
|
|
698
|
-
const out = [];
|
|
699
|
-
for (const o of spec.open ?? []) {
|
|
700
|
-
const url = ctx.url(o.url);
|
|
701
|
-
if (!o.pick) {
|
|
702
|
-
out.push({ name: o.name, value: url });
|
|
703
|
-
continue;
|
|
704
|
-
}
|
|
705
|
-
try {
|
|
706
|
-
const body = await (await ctx.fetch(url)).text();
|
|
707
|
-
const m = body.match(new RegExp(o.pick));
|
|
708
|
-
out.push({ name: o.name, value: m ? m[0] : `<no match for /${o.pick}/ at ${url}>` });
|
|
709
|
-
}
|
|
710
|
-
catch (e) {
|
|
711
|
-
out.push({ name: o.name, value: `<unavailable: ${e instanceof Error ? e.message : String(e)}>` });
|
|
712
|
-
}
|
|
713
|
-
}
|
|
714
|
-
return out;
|
|
715
|
-
}
|
|
716
|
-
/** `up` refuses to run beside a live earlier run of the same demo. Private:
|
|
717
|
-
* its daemon still answers. Reuse: the daemon always answers, so the record
|
|
718
|
-
* is live only while its instance still exists. A dead record is forgotten. */
|
|
719
|
-
async function refuseIfUp(spec, s, deps) {
|
|
720
|
-
const prev = deps.state.read(spec.product);
|
|
721
|
-
if (!prev)
|
|
722
|
-
return;
|
|
723
|
-
const answers = await deps.daemonAnswers(prev.daemonPort);
|
|
724
|
-
const live = prev.daemon === "reuse"
|
|
725
|
-
? answers && prev.instanceId !== undefined && (await s.instanceListed(prev.instanceId))
|
|
726
|
-
: answers;
|
|
727
|
-
if (live) {
|
|
728
|
-
throw new Error(`demo '${spec.product}' is already up (daemon :${prev.daemonPort}, store ${prev.storeDir}) — run \`demo down\` first`);
|
|
729
|
-
}
|
|
730
|
-
deps.log(`forgetting a stale record of a run on :${prev.daemonPort} (nothing of it is left)`);
|
|
731
|
-
deps.state.remove(spec.product);
|
|
732
|
-
}
|
|
733
|
-
export async function runDemo(spec, opts, deps = defaultDemoDeps(opts.cwd)) {
|
|
734
|
-
const slug = opts.slug ?? demoSlug(spec.product);
|
|
735
|
-
const instanceId = `demo-${slug}`;
|
|
736
|
-
const policy = opts.daemon ?? "private";
|
|
737
|
-
if (opts.mode === "image" && !spec.image) {
|
|
738
|
-
throw new Error(`--mode image needs the spec's \`image\` (the built product image) — ${spec.product} declares none`);
|
|
739
|
-
}
|
|
740
|
-
const s = new DemoSession(spec, slug, opts.cwd, deps, {
|
|
741
|
-
mode: opts.mode,
|
|
742
|
-
daemon: policy,
|
|
743
|
-
...(opts.daemonPort !== undefined ? { daemonPort: opts.daemonPort } : {}),
|
|
744
|
-
...(opts.timeouts !== undefined ? { timeouts: opts.timeouts } : {}),
|
|
745
|
-
...(opts.publicHost !== undefined ? { publicHost: opts.publicHost } : {}),
|
|
746
|
-
});
|
|
747
|
-
if (opts.action === "up") {
|
|
748
|
-
await refuseIfUp(spec, s, deps);
|
|
749
|
-
for (const p of spec.prerequisites ?? []) {
|
|
750
|
-
const path = expandHome(p.path);
|
|
751
|
-
if (!deps.fileExists(path))
|
|
752
|
-
throw new Error(`prerequisite missing: ${path}\n make it with: ${p.hint}`);
|
|
753
|
-
}
|
|
754
|
-
}
|
|
755
|
-
let launched = false;
|
|
756
|
-
let handles = [];
|
|
757
|
-
let recorded = false;
|
|
758
|
-
let result;
|
|
759
|
-
let journeyError;
|
|
760
|
-
try {
|
|
761
|
-
await s.attachDaemon();
|
|
762
|
-
if (opts.mode === "dev")
|
|
763
|
-
await s.startDev();
|
|
764
|
-
if (policy === "reuse")
|
|
765
|
-
await s.resetRegistration(instanceId);
|
|
766
|
-
await s.register({ licence: true });
|
|
767
|
-
const templateName = await s.resolveTemplate();
|
|
768
|
-
await s.pinGuard(templateName);
|
|
769
|
-
const launchCtx = {
|
|
770
|
-
action: opts.action,
|
|
771
|
-
mode: opts.mode,
|
|
772
|
-
daemon: policy,
|
|
773
|
-
product: spec.product,
|
|
774
|
-
instanceId,
|
|
775
|
-
daemonPort: s.ports.daemonPort,
|
|
776
|
-
storeDir: s.storeDir,
|
|
777
|
-
templateName,
|
|
778
|
-
templateDir: s.templateDir(templateName),
|
|
779
|
-
cli: s.cli,
|
|
780
|
-
log: deps.log,
|
|
781
|
-
};
|
|
782
|
-
s.startExtras(instanceId);
|
|
783
|
-
if (spec.beforeLaunch)
|
|
784
|
-
await spec.beforeLaunch(launchCtx);
|
|
785
|
-
const declared = await s.declaredParams(templateName);
|
|
786
|
-
const specParams = spec.launch?.params ?? {};
|
|
787
|
-
const studioHostPort = specParams[STUDIO_HOST_PORT_PARAM] !== undefined
|
|
788
|
-
? Number(specParams[STUDIO_HOST_PORT_PARAM])
|
|
789
|
-
: s.ports.studioHostPort;
|
|
790
|
-
const params = [`INSTANCE_NAME=${instanceId}`, ...Object.entries(specParams).map(([k, v]) => `${k}=${v}`)];
|
|
791
|
-
const launchArgv = ["instance", "launch-template", instanceId, "--template", templateName];
|
|
792
|
-
const internalOnly = netReachMode() === "direct" && (await deps.supportsNoPublish());
|
|
793
|
-
if (declared.has(STUDIO_HOST_PORT_PARAM)) {
|
|
794
|
-
if (specParams[STUDIO_HOST_PORT_PARAM] === undefined)
|
|
795
|
-
params.push(`${STUDIO_HOST_PORT_PARAM}=${studioHostPort}`);
|
|
796
|
-
}
|
|
797
|
-
else if (!internalOnly) {
|
|
798
|
-
launchArgv.push("--host-ports", `${studioHostPort}:${STUDIO_INTERNAL_PORT}@studio`);
|
|
799
|
-
}
|
|
800
|
-
for (const p of params)
|
|
801
|
-
launchArgv.push("--param", p);
|
|
802
|
-
if (spec.launch?.hardware)
|
|
803
|
-
launchArgv.push("--hardware", spec.launch.hardware);
|
|
804
|
-
if (opts.action === "up" && spec.launch?.workingDirectory) {
|
|
805
|
-
launchArgv.push("--working-directory", expandHome(spec.launch.workingDirectory));
|
|
806
|
-
}
|
|
807
|
-
const user = deps.containerUser();
|
|
808
|
-
if (user)
|
|
809
|
-
launchArgv.push("--container-user", user);
|
|
810
|
-
if (internalOnly)
|
|
811
|
-
launchArgv.push("--internal-only");
|
|
812
|
-
launched = true;
|
|
813
|
-
await s.cliOk(launchArgv);
|
|
814
|
-
await s.waitRunning(instanceId);
|
|
815
|
-
const rows = await s.ingestPorts(instanceId);
|
|
816
|
-
const templateParams = { declared, overrides: specParams };
|
|
817
|
-
const targets = (spec.sources ?? []).map((src) => ({
|
|
818
|
-
port: resolveIngestPort(src.ingest, rows, templateParams),
|
|
819
|
-
name: src.name,
|
|
820
|
-
asset: src.asset ?? { preset: "camera1" },
|
|
821
|
-
...(src.streamId !== undefined ? { streamId: src.streamId } : {}),
|
|
822
|
-
}));
|
|
823
|
-
const ctx = {
|
|
824
|
-
...launchCtx,
|
|
825
|
-
url: urlResolver({ instanceId, ports: s.ports, studioHostPort, proxyBase: s.proxyBase, host: s.host }),
|
|
826
|
-
fetch: deps.fetch,
|
|
827
|
-
};
|
|
828
|
-
// Sources first: a gate such as "the switcher is composing" or "the probe
|
|
829
|
-
// is analysing" can only hold with something on the wire.
|
|
830
|
-
if (targets.length) {
|
|
831
|
-
const secret = s.proxySecret();
|
|
832
|
-
handles = await deps.startSources({
|
|
833
|
-
daemonPort: s.ports.daemonPort,
|
|
834
|
-
instanceId,
|
|
835
|
-
targets,
|
|
836
|
-
...(secret !== undefined ? { proxySecret: secret } : {}),
|
|
837
|
-
});
|
|
838
|
-
deps.log(`sources: ${targets.map((t) => `${t.name} -> :${t.port}`).join(", ")}`);
|
|
839
|
-
}
|
|
840
|
-
for (const gate of spec.ready ?? []) {
|
|
841
|
-
const name = await awaitGate(gate, ctx, s.gateTimeout(gate.timeoutMs));
|
|
842
|
-
deps.log(`ready: ${name}`);
|
|
843
|
-
}
|
|
844
|
-
if (spec.after)
|
|
845
|
-
await spec.after(ctx);
|
|
846
|
-
const open = await resolveOpen(spec, ctx);
|
|
847
|
-
const width = Math.max(0, ...open.map((o) => o.name.length));
|
|
848
|
-
deps.log(`${spec.product} demo is ${opts.action === "up" ? "UP" : "up (check)"} — instance ${instanceId}`);
|
|
849
|
-
for (const o of open)
|
|
850
|
-
deps.log(` ${o.name.padEnd(width)} ${o.value}`);
|
|
851
|
-
result = { instanceId, daemonPort: s.ports.daemonPort, storeDir: s.storeDir, open };
|
|
852
|
-
if (opts.action === "up") {
|
|
853
|
-
deps.state.write({
|
|
854
|
-
product: spec.product,
|
|
855
|
-
storeDir: s.storeDir,
|
|
856
|
-
daemonPort: s.ports.daemonPort,
|
|
857
|
-
instanceId,
|
|
858
|
-
daemon: policy,
|
|
859
|
-
...(s.extras.length ? { extras: [...s.extras] } : {}),
|
|
860
|
-
...(s.dev?.pid !== undefined ? { devPid: s.dev.pid } : {}),
|
|
861
|
-
});
|
|
862
|
-
recorded = true;
|
|
863
|
-
deps.log("holding — Ctrl-C (or `demo down` from another shell) tears down");
|
|
864
|
-
await deps.hold(opts.abort);
|
|
865
|
-
}
|
|
866
|
-
}
|
|
867
|
-
catch (e) {
|
|
868
|
-
journeyError = e;
|
|
869
|
-
}
|
|
870
|
-
await s.teardown({ instances: launched ? [instanceId] : [], handles });
|
|
871
|
-
if (recorded)
|
|
872
|
-
deps.state.remove(spec.product);
|
|
873
|
-
if (journeyError !== undefined)
|
|
874
|
-
throw journeyError;
|
|
875
|
-
if (policy === "private" && deps.storeExists(s.storeDir)) {
|
|
876
|
-
throw new Error(`store ${s.storeDir} still present after the root-container nuke`);
|
|
877
|
-
}
|
|
878
|
-
return result;
|
|
879
|
-
}
|
|
880
667
|
/** `demo check --mode standalone --export-only` (05-demo s4): build the
|
|
881
668
|
* template, export the standalone workdir with the spec's live-source links,
|
|
882
669
|
* assert every symlink resolves. No instance, no licence — the daemon's own
|