@norskvideo/ctl-test-harness 0.1.19 → 0.1.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/compose-advancing.d.ts +26 -0
- package/compose-advancing.js +61 -0
- package/container-logs.d.ts +48 -0
- package/container-logs.js +101 -0
- package/demo/cli.d.ts +6 -2
- package/demo/cli.js +37 -11
- package/demo/gates.d.ts +22 -0
- package/demo/gates.js +56 -0
- package/demo/index.d.ts +4 -3
- package/demo/index.js +2 -1
- package/demo/run.d.ts +37 -3
- package/demo/run.js +348 -70
- package/demo/spec.d.ts +36 -4
- package/demo/spec.js +17 -0
- package/package.json +9 -1
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
|
|
2
|
+
export declare const DEFAULT_COMPOSE_NODE = "video_compose";
|
|
3
|
+
/** The highest mediaIn across every node of that name; 0 when none. */
|
|
4
|
+
export declare function maxNodeMediaIn(summary: unknown, node: string): number;
|
|
5
|
+
/** One sample: the highest mediaIn of `node` across every root workflow under
|
|
6
|
+
* `base` (`.../visualiser`). An unreachable visualiser reads as 0 — the
|
|
7
|
+
* caller's second sample, not a throw, decides. */
|
|
8
|
+
export declare function composeFrames(opts: {
|
|
9
|
+
fetch: FetchLike;
|
|
10
|
+
base: string;
|
|
11
|
+
node?: string;
|
|
12
|
+
}): Promise<number>;
|
|
13
|
+
export interface ComposeAdvancingResult {
|
|
14
|
+
advancing: boolean;
|
|
15
|
+
before: number;
|
|
16
|
+
after: number;
|
|
17
|
+
node: string;
|
|
18
|
+
}
|
|
19
|
+
/** Two samples `settleMs` apart: advancing when the second is above the first
|
|
20
|
+
* and above zero — the fallback card and a stalled feed both read flat. */
|
|
21
|
+
export declare function composeAdvancing(opts: {
|
|
22
|
+
fetch: FetchLike;
|
|
23
|
+
base: string;
|
|
24
|
+
node?: string;
|
|
25
|
+
settleMs?: number;
|
|
26
|
+
}): Promise<ComposeAdvancingResult>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Is a programme really reaching the compositor? The daemon reports a source
|
|
2
|
+
// "running" as soon as its ffmpeg sidecar is up, even if the SRT never
|
|
3
|
+
// connected or the frames stalled; the only honest signal is the compose
|
|
4
|
+
// node's own mediaIn climbing between two samples of the engine's visualiser
|
|
5
|
+
// (`/visualiser/workflow` -> rootWorkflows[].wfid -> `/workflow/<wfid>/summary`
|
|
6
|
+
// -> nodes[].mediaIn). Playout's scripts/ensure-sources.sh:52-64 as TypeScript
|
|
7
|
+
// (05-demo s7 step 2), pure over an injected fetch so it is unit-tested and
|
|
8
|
+
// reachable through whichever base the caller has: the daemon proxy's
|
|
9
|
+
// `/instance/<id>/visualiser`, or `<id>-media-1:6791/visualiser` on norsk-net.
|
|
10
|
+
export const DEFAULT_COMPOSE_NODE = "video_compose";
|
|
11
|
+
const DEFAULT_SETTLE_MS = 6000;
|
|
12
|
+
/** The highest mediaIn across every node of that name; 0 when none. */
|
|
13
|
+
export function maxNodeMediaIn(summary, node) {
|
|
14
|
+
const nodes = summary?.nodes;
|
|
15
|
+
if (!Array.isArray(nodes))
|
|
16
|
+
return 0;
|
|
17
|
+
let max = 0;
|
|
18
|
+
for (const n of nodes) {
|
|
19
|
+
if (n?.name !== node)
|
|
20
|
+
continue;
|
|
21
|
+
const v = Number(n.mediaIn);
|
|
22
|
+
if (Number.isFinite(v) && v > max)
|
|
23
|
+
max = v;
|
|
24
|
+
}
|
|
25
|
+
return max;
|
|
26
|
+
}
|
|
27
|
+
async function getJson(fetch, url) {
|
|
28
|
+
const r = await fetch(url, { signal: AbortSignal.timeout(5000) });
|
|
29
|
+
if (!r.ok)
|
|
30
|
+
throw new Error(`GET ${url} -> ${r.status}`);
|
|
31
|
+
return r.json();
|
|
32
|
+
}
|
|
33
|
+
/** One sample: the highest mediaIn of `node` across every root workflow under
|
|
34
|
+
* `base` (`.../visualiser`). An unreachable visualiser reads as 0 — the
|
|
35
|
+
* caller's second sample, not a throw, decides. */
|
|
36
|
+
export async function composeFrames(opts) {
|
|
37
|
+
const node = opts.node ?? DEFAULT_COMPOSE_NODE;
|
|
38
|
+
try {
|
|
39
|
+
const wf = (await getJson(opts.fetch, `${opts.base}/workflow`));
|
|
40
|
+
let max = 0;
|
|
41
|
+
for (const root of wf?.rootWorkflows ?? []) {
|
|
42
|
+
if (typeof root?.wfid !== "string")
|
|
43
|
+
continue;
|
|
44
|
+
const summary = await getJson(opts.fetch, `${opts.base}/workflow/${encodeURIComponent(root.wfid)}/summary`);
|
|
45
|
+
max = Math.max(max, maxNodeMediaIn(summary, node));
|
|
46
|
+
}
|
|
47
|
+
return max;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return 0;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Two samples `settleMs` apart: advancing when the second is above the first
|
|
54
|
+
* and above zero — the fallback card and a stalled feed both read flat. */
|
|
55
|
+
export async function composeAdvancing(opts) {
|
|
56
|
+
const node = opts.node ?? DEFAULT_COMPOSE_NODE;
|
|
57
|
+
const before = await composeFrames({ fetch: opts.fetch, base: opts.base, node });
|
|
58
|
+
await new Promise((r) => setTimeout(r, opts.settleMs ?? DEFAULT_SETTLE_MS));
|
|
59
|
+
const after = await composeFrames({ fetch: opts.fetch, base: opts.base, node });
|
|
60
|
+
return { advancing: after > before && after > 0, before, after, node };
|
|
61
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export interface SpawnResult {
|
|
2
|
+
stdout: string;
|
|
3
|
+
stderr: string;
|
|
4
|
+
error?: Error;
|
|
5
|
+
}
|
|
6
|
+
export type SpawnLike = (cmd: string, args: string[], opts: {
|
|
7
|
+
encoding: "utf8";
|
|
8
|
+
timeout: number;
|
|
9
|
+
}) => SpawnResult;
|
|
10
|
+
/** `docker ps` argv listing "<name>\t<state>" for ONE instance's containers. */
|
|
11
|
+
export declare function instanceContainersArgs(instanceId: string): string[];
|
|
12
|
+
/** `docker logs` argv tailing one container. Combined stdout+stderr at the call site. */
|
|
13
|
+
export declare function containerLogArgs(container: string, tail?: number): string[];
|
|
14
|
+
export interface ContainerLog {
|
|
15
|
+
name: string;
|
|
16
|
+
state: string;
|
|
17
|
+
log: string;
|
|
18
|
+
}
|
|
19
|
+
/** Parse `docker ps --format "{{.Names}}\t{{.State}}"` output into rows. */
|
|
20
|
+
export declare function parseContainerRows(psStdout: string): {
|
|
21
|
+
name: string;
|
|
22
|
+
state: string;
|
|
23
|
+
}[];
|
|
24
|
+
/** Pure: renders captured logs as an indented block per container. */
|
|
25
|
+
export declare function formatContainerLogs(instanceId: string, entries: ContainerLog[]): string;
|
|
26
|
+
/** Tail every container of one instance. Best-effort: docker unreachable is
|
|
27
|
+
* an empty list, never a throw, so a diagnostic cannot mask what it describes. */
|
|
28
|
+
export declare function collectInstanceContainerLogs(instanceId: string, tail?: number, spawn?: SpawnLike): ContainerLog[];
|
|
29
|
+
/** The ready-to-print diagnostic block (empty-marker when nothing is found). */
|
|
30
|
+
export declare function dumpInstanceContainerLogs(instanceId: string, tail?: number, spawn?: SpawnLike): string;
|
|
31
|
+
export interface LogHit {
|
|
32
|
+
container: string;
|
|
33
|
+
line: string;
|
|
34
|
+
}
|
|
35
|
+
/** A string forbids a substring; a RegExp is tested per line. */
|
|
36
|
+
export type LogPattern = string | RegExp;
|
|
37
|
+
/** Every line, in any container of the instance, that matches a forbidden
|
|
38
|
+
* pattern. `clean` when there are none — including when docker cannot be
|
|
39
|
+
* reached, which is absence of evidence, not a failure of the thing probed. */
|
|
40
|
+
export declare function containerLogsClean(opts: {
|
|
41
|
+
instanceId: string;
|
|
42
|
+
forbid: readonly LogPattern[];
|
|
43
|
+
tail?: number;
|
|
44
|
+
spawn?: SpawnLike;
|
|
45
|
+
}): {
|
|
46
|
+
clean: boolean;
|
|
47
|
+
hits: LogHit[];
|
|
48
|
+
};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// A launched instance's container logs, by the `norsk-ctl.instance=<id>`
|
|
2
|
+
// label every per-instance container carries (norsk-ctl docker/constants.ts)
|
|
3
|
+
// — so it finds them under either naming scheme (`<id>-media-1` on the
|
|
4
|
+
// released ctl, `norsk-inst-<id>-media` on older ones). Lifted from funke's
|
|
5
|
+
// tests/integration/support/container-logs.ts (05-demo s7 step 2): the
|
|
6
|
+
// diagnostic dump, plus containerLogsClean — iterate.sh's segfault guard
|
|
7
|
+
// generalised to "no forbidden line in any container of this instance".
|
|
8
|
+
// Pure argv/parse/format helpers; only the two exported probes shell out,
|
|
9
|
+
// through an injectable spawn.
|
|
10
|
+
import { spawnSync } from "node:child_process";
|
|
11
|
+
const defaultSpawn = (cmd, args, opts) => {
|
|
12
|
+
const r = spawnSync(cmd, args, opts);
|
|
13
|
+
return {
|
|
14
|
+
stdout: typeof r.stdout === "string" ? r.stdout : "",
|
|
15
|
+
stderr: typeof r.stderr === "string" ? r.stderr : "",
|
|
16
|
+
...(r.error ? { error: r.error } : {}),
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
const DEFAULT_TAIL = 80;
|
|
20
|
+
/** `docker ps` argv listing "<name>\t<state>" for ONE instance's containers. */
|
|
21
|
+
export function instanceContainersArgs(instanceId) {
|
|
22
|
+
return ["ps", "-a", "--filter", `label=norsk-ctl.instance=${instanceId}`, "--format", "{{.Names}}\t{{.State}}"];
|
|
23
|
+
}
|
|
24
|
+
/** `docker logs` argv tailing one container. Combined stdout+stderr at the call site. */
|
|
25
|
+
export function containerLogArgs(container, tail = DEFAULT_TAIL) {
|
|
26
|
+
return ["logs", "--tail", String(tail), container];
|
|
27
|
+
}
|
|
28
|
+
/** Parse `docker ps --format "{{.Names}}\t{{.State}}"` output into rows. */
|
|
29
|
+
export function parseContainerRows(psStdout) {
|
|
30
|
+
return psStdout
|
|
31
|
+
.split("\n")
|
|
32
|
+
.map((l) => l.trim())
|
|
33
|
+
.filter(Boolean)
|
|
34
|
+
.map((l) => {
|
|
35
|
+
const [name, state] = l.split("\t");
|
|
36
|
+
return { name: name ?? "?", state: state ?? "?" };
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
/** Pure: renders captured logs as an indented block per container. */
|
|
40
|
+
export function formatContainerLogs(instanceId, entries) {
|
|
41
|
+
if (entries.length === 0) {
|
|
42
|
+
return ` container logs for ${instanceId}: <none found / docker unreachable>`;
|
|
43
|
+
}
|
|
44
|
+
return entries
|
|
45
|
+
.map((e) => {
|
|
46
|
+
const body = e.log.trim() === ""
|
|
47
|
+
? " <empty>"
|
|
48
|
+
: e.log
|
|
49
|
+
.trimEnd()
|
|
50
|
+
.split("\n")
|
|
51
|
+
.map((l) => ` ${l}`)
|
|
52
|
+
.join("\n");
|
|
53
|
+
return ` --- ${e.name} (${e.state}) ---\n${body}`;
|
|
54
|
+
})
|
|
55
|
+
.join("\n");
|
|
56
|
+
}
|
|
57
|
+
/** Tail every container of one instance. Best-effort: docker unreachable is
|
|
58
|
+
* an empty list, never a throw, so a diagnostic cannot mask what it describes. */
|
|
59
|
+
export function collectInstanceContainerLogs(instanceId, tail = DEFAULT_TAIL, spawn = defaultSpawn) {
|
|
60
|
+
let rows = [];
|
|
61
|
+
try {
|
|
62
|
+
const ps = spawn("docker", instanceContainersArgs(instanceId), { encoding: "utf8", timeout: 15_000 });
|
|
63
|
+
if (ps.error)
|
|
64
|
+
return [];
|
|
65
|
+
rows = parseContainerRows(ps.stdout);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
const entries = [];
|
|
71
|
+
for (const row of rows) {
|
|
72
|
+
let log = "";
|
|
73
|
+
try {
|
|
74
|
+
const r = spawn("docker", containerLogArgs(row.name, tail), { encoding: "utf8", timeout: 15_000 });
|
|
75
|
+
log = r.error ? "<docker logs failed>" : `${r.stdout}${r.stderr}`;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
log = "<docker logs failed>";
|
|
79
|
+
}
|
|
80
|
+
entries.push({ name: row.name, state: row.state, log });
|
|
81
|
+
}
|
|
82
|
+
return entries;
|
|
83
|
+
}
|
|
84
|
+
/** The ready-to-print diagnostic block (empty-marker when nothing is found). */
|
|
85
|
+
export function dumpInstanceContainerLogs(instanceId, tail = DEFAULT_TAIL, spawn = defaultSpawn) {
|
|
86
|
+
return formatContainerLogs(instanceId, collectInstanceContainerLogs(instanceId, tail, spawn));
|
|
87
|
+
}
|
|
88
|
+
const matches = (pattern, line) => typeof pattern === "string" ? line.includes(pattern) : pattern.test(line);
|
|
89
|
+
/** Every line, in any container of the instance, that matches a forbidden
|
|
90
|
+
* pattern. `clean` when there are none — including when docker cannot be
|
|
91
|
+
* reached, which is absence of evidence, not a failure of the thing probed. */
|
|
92
|
+
export function containerLogsClean(opts) {
|
|
93
|
+
const hits = [];
|
|
94
|
+
for (const entry of collectInstanceContainerLogs(opts.instanceId, opts.tail ?? DEFAULT_TAIL, opts.spawn)) {
|
|
95
|
+
for (const line of entry.log.split("\n")) {
|
|
96
|
+
if (opts.forbid.some((p) => matches(p, line)))
|
|
97
|
+
hits.push({ container: entry.name, line });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return { clean: hits.length === 0, hits };
|
|
101
|
+
}
|
package/demo/cli.d.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { type DemoRunOptions, type DemoRunResult, demoPorts } from "./run.js";
|
|
3
|
-
import type { DemoSpec } from "./spec.js";
|
|
3
|
+
import type { DemoDaemonPolicy, DemoMode, DemoSpec } from "./spec.js";
|
|
4
4
|
export interface DemoArgs {
|
|
5
5
|
action: "up" | "check" | "down" | "spec";
|
|
6
|
-
|
|
6
|
+
/** Absent: the spec's `mode`, else dev. */
|
|
7
|
+
mode?: DemoMode | "standalone";
|
|
8
|
+
daemon: DemoDaemonPolicy;
|
|
7
9
|
exportOnly: boolean;
|
|
8
10
|
json: boolean;
|
|
9
11
|
spec: string;
|
|
@@ -27,6 +29,8 @@ export interface ResolvedSpecView {
|
|
|
27
29
|
};
|
|
28
30
|
launch?: DemoSpec["launch"];
|
|
29
31
|
prerequisites?: DemoSpec["prerequisites"];
|
|
32
|
+
extras: string[];
|
|
33
|
+
beforeLaunch: boolean;
|
|
30
34
|
sources: Array<{
|
|
31
35
|
name: string;
|
|
32
36
|
asset: NonNullable<DemoSpec["sources"]>[number]["asset"];
|
package/demo/cli.js
CHANGED
|
@@ -9,8 +9,9 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
9
9
|
};
|
|
10
10
|
// `ctl-demo`, the bin behind every product's `bun run demo` (05-demo s4):
|
|
11
11
|
//
|
|
12
|
-
// demo up [--mode dev]
|
|
13
|
-
//
|
|
12
|
+
// demo up [--mode dev|image] [--daemon private|reuse]
|
|
13
|
+
// launch, print the URLs, hold; Ctrl-C tears down
|
|
14
|
+
// demo check [--mode dev|image] the same, then tear down and exit 0/1 (what CI runs)
|
|
14
15
|
// demo check --mode standalone --export-only build + export-workdir + every symlink resolves
|
|
15
16
|
// demo down tear down what `up` recorded, from any shell
|
|
16
17
|
// demo spec [--json] the resolved spec: ports, template, sources, URLs
|
|
@@ -20,15 +21,21 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
20
21
|
// under bun only: the spec is TypeScript and so is every product's dev shell.
|
|
21
22
|
import { resolve } from "node:path";
|
|
22
23
|
import { demoDown, demoPorts, demoSlug, runDemo, runExportCheck, } from "./run.js";
|
|
23
|
-
const USAGE = `usage: demo <up|check|down|spec> [--mode dev|standalone] [--export-only] [--json] [--spec <path>]
|
|
24
|
+
const USAGE = `usage: demo <up|check|down|spec> [--mode dev|image|standalone] [--daemon private|reuse] [--export-only] [--json] [--spec <path>]
|
|
24
25
|
|
|
25
|
-
up launch
|
|
26
|
-
check up, then tear down and exit 0/1 — what CI runs
|
|
26
|
+
up launch, print the URLs, hold (Ctrl-C tears down)
|
|
27
|
+
check up, then tear down and exit 0/1 — what CI runs, always on a private daemon
|
|
27
28
|
check --mode standalone --export-only
|
|
28
29
|
build the template, export the standalone workdir, assert every symlink resolves
|
|
29
30
|
down tear down what \`up\` recorded
|
|
30
31
|
spec [--json] print the resolved spec without launching anything
|
|
31
32
|
|
|
33
|
+
--mode dev run the product from source (bun run dev) and add it by URL
|
|
34
|
+
--mode image add the built product image — the customer path
|
|
35
|
+
--daemon private a throwaway daemon on its own store (default)
|
|
36
|
+
--daemon reuse your listening daemon and real store; the driver deletes the instance,
|
|
37
|
+
removes the template and product, then adds again (stored templates
|
|
38
|
+
are immutable by name)
|
|
32
39
|
--spec <path> the demo spec (default tests/demo.spec.ts)`;
|
|
33
40
|
const DEFAULT_SPEC = "tests/demo.spec.ts";
|
|
34
41
|
export function parseDemoArgs(argv) {
|
|
@@ -36,7 +43,7 @@ export function parseDemoArgs(argv) {
|
|
|
36
43
|
if (action !== "up" && action !== "check" && action !== "down" && action !== "spec") {
|
|
37
44
|
throw new Error(action ? `unknown action '${action}'\n${USAGE}` : USAGE);
|
|
38
45
|
}
|
|
39
|
-
const args = { action,
|
|
46
|
+
const args = { action, daemon: "private", exportOnly: false, json: false, spec: DEFAULT_SPEC };
|
|
40
47
|
for (let i = 0; i < rest.length; i++) {
|
|
41
48
|
const flag = rest[i];
|
|
42
49
|
const value = () => {
|
|
@@ -48,13 +55,18 @@ export function parseDemoArgs(argv) {
|
|
|
48
55
|
switch (flag) {
|
|
49
56
|
case "--mode": {
|
|
50
57
|
const m = value();
|
|
51
|
-
if (m
|
|
52
|
-
throw new Error("--mode image is not yet implemented (05-demo s7 step 2); use --mode dev");
|
|
53
|
-
if (m !== "dev" && m !== "standalone")
|
|
58
|
+
if (m !== "dev" && m !== "image" && m !== "standalone")
|
|
54
59
|
throw new Error(`unknown mode '${m}'\n${USAGE}`);
|
|
55
60
|
args.mode = m;
|
|
56
61
|
break;
|
|
57
62
|
}
|
|
63
|
+
case "--daemon": {
|
|
64
|
+
const d = value();
|
|
65
|
+
if (d !== "private" && d !== "reuse")
|
|
66
|
+
throw new Error(`unknown daemon policy '${d}'\n${USAGE}`);
|
|
67
|
+
args.daemon = d;
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
58
70
|
case "--export-only":
|
|
59
71
|
args.exportOnly = true;
|
|
60
72
|
break;
|
|
@@ -74,6 +86,9 @@ export function parseDemoArgs(argv) {
|
|
|
74
86
|
if (args.exportOnly && args.mode !== "standalone") {
|
|
75
87
|
throw new Error("--export-only belongs to --mode standalone");
|
|
76
88
|
}
|
|
89
|
+
if (args.action === "check" && args.daemon === "reuse") {
|
|
90
|
+
throw new Error("check runs on a private daemon (it is what CI runs); to reload your own daemon use `up --daemon reuse`");
|
|
91
|
+
}
|
|
77
92
|
return args;
|
|
78
93
|
}
|
|
79
94
|
/** `demo spec --json`: what will run, resolved as far as it can be without a
|
|
@@ -109,6 +124,8 @@ export function resolvedSpecView(spec, opts) {
|
|
|
109
124
|
template,
|
|
110
125
|
...(spec.launch !== undefined ? { launch: spec.launch } : {}),
|
|
111
126
|
...(spec.prerequisites !== undefined ? { prerequisites: spec.prerequisites } : {}),
|
|
127
|
+
extras: (spec.extras ?? []).map((e) => e.name),
|
|
128
|
+
beforeLaunch: spec.beforeLaunch !== undefined,
|
|
112
129
|
sources: (spec.sources ?? []).map((s) => ({
|
|
113
130
|
name: s.name,
|
|
114
131
|
asset: s.asset ?? { preset: "camera1" },
|
|
@@ -158,10 +175,11 @@ export async function demoMain(argv, io) {
|
|
|
158
175
|
}
|
|
159
176
|
return 1;
|
|
160
177
|
}
|
|
178
|
+
const mode = args.mode ?? spec.mode ?? "dev";
|
|
161
179
|
try {
|
|
162
180
|
switch (args.action) {
|
|
163
181
|
case "spec": {
|
|
164
|
-
const view = resolvedSpecView(spec, { mode
|
|
182
|
+
const view = resolvedSpecView(spec, { mode });
|
|
165
183
|
io.stdout(args.json ? JSON.stringify(view, null, 2) : renderView(view));
|
|
166
184
|
return 0;
|
|
167
185
|
}
|
|
@@ -174,9 +192,13 @@ export async function demoMain(argv, io) {
|
|
|
174
192
|
await io.runExportCheck(spec, { cwd: io.cwd });
|
|
175
193
|
return 0;
|
|
176
194
|
}
|
|
195
|
+
if (mode === "standalone") {
|
|
196
|
+
throw new Error("the full standalone tier is not yet implemented (05-demo s7 step 3)");
|
|
197
|
+
}
|
|
177
198
|
await io.runDemo(spec, {
|
|
178
199
|
action: args.action,
|
|
179
|
-
mode
|
|
200
|
+
mode,
|
|
201
|
+
daemon: args.daemon,
|
|
180
202
|
cwd: io.cwd,
|
|
181
203
|
...(io.abort ? { abort: io.abort } : {}),
|
|
182
204
|
});
|
|
@@ -195,6 +217,10 @@ function renderView(v) {
|
|
|
195
217
|
` daemon :${v.ports.daemonPort} dev backend :${v.ports.backendPort} proxy :${v.ports.proxyPort} studio :${v.ports.studioHostPort}`,
|
|
196
218
|
` template ${"name" in v.template ? v.template.name : `build ${v.template.build.name}`}`,
|
|
197
219
|
];
|
|
220
|
+
if (v.image)
|
|
221
|
+
lines.push(` image ${v.image}`);
|
|
222
|
+
for (const e of v.extras)
|
|
223
|
+
lines.push(` extra ${e}`);
|
|
198
224
|
for (const s of v.sources)
|
|
199
225
|
lines.push(` source ${s.name}: ${JSON.stringify(s.asset)} -> ingest ${JSON.stringify(s.ingest)}`);
|
|
200
226
|
for (const g of v.ready)
|
package/demo/gates.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type LogPattern, type SpawnLike } from "../container-logs.js";
|
|
2
|
+
import type { DemoReady } from "./spec.js";
|
|
3
|
+
/** Thrown by a gate whose failure is final: the driver stops polling and the
|
|
4
|
+
* run fails with this message, teardown included. */
|
|
5
|
+
export declare class DemoGateFailure extends Error {
|
|
6
|
+
constructor(message: string);
|
|
7
|
+
}
|
|
8
|
+
/** No forbidden line in any container of the instance (`Segmentation fault`,
|
|
9
|
+
* say — funke's iterate.sh:156). Holds on clean logs; a hit fails the run at
|
|
10
|
+
* once, naming the container and the line. */
|
|
11
|
+
export declare function logsCleanGate(opts: {
|
|
12
|
+
forbid: readonly LogPattern[];
|
|
13
|
+
tail?: number;
|
|
14
|
+
spawn?: SpawnLike;
|
|
15
|
+
}): DemoReady;
|
|
16
|
+
/** The compositor's mediaIn is climbing (playout's ensure-sources.sh gate),
|
|
17
|
+
* read through the daemon proxy's visualiser route for this instance. */
|
|
18
|
+
export declare function composeAdvancingGate(opts?: {
|
|
19
|
+
node?: string;
|
|
20
|
+
settleMs?: number;
|
|
21
|
+
timeoutMs?: number;
|
|
22
|
+
}): DemoReady;
|
package/demo/gates.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Ready gates a spec composes rather than writes (05-demo s5): the lifted
|
|
2
|
+
// probes as `{ custom, label }` entries for `ready: [...]`. A gate returns
|
|
3
|
+
// false to be polled again; it throws DemoGateFailure to stop the run at once
|
|
4
|
+
// — a segfault is not going to un-happen by waiting.
|
|
5
|
+
import { composeAdvancing, DEFAULT_COMPOSE_NODE } from "../compose-advancing.js";
|
|
6
|
+
import { containerLogsClean } from "../container-logs.js";
|
|
7
|
+
/** Thrown by a gate whose failure is final: the driver stops polling and the
|
|
8
|
+
* run fails with this message, teardown included. */
|
|
9
|
+
export class DemoGateFailure extends Error {
|
|
10
|
+
constructor(message) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = "DemoGateFailure";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/** No forbidden line in any container of the instance (`Segmentation fault`,
|
|
16
|
+
* say — funke's iterate.sh:156). Holds on clean logs; a hit fails the run at
|
|
17
|
+
* once, naming the container and the line. */
|
|
18
|
+
export function logsCleanGate(opts) {
|
|
19
|
+
const label = `container logs clean of ${opts.forbid.map(String).join(", ")}`;
|
|
20
|
+
return {
|
|
21
|
+
label,
|
|
22
|
+
custom: async (ctx) => {
|
|
23
|
+
const r = containerLogsClean({
|
|
24
|
+
instanceId: ctx.instanceId,
|
|
25
|
+
forbid: opts.forbid,
|
|
26
|
+
...(opts.tail !== undefined ? { tail: opts.tail } : {}),
|
|
27
|
+
...(opts.spawn !== undefined ? { spawn: opts.spawn } : {}),
|
|
28
|
+
});
|
|
29
|
+
if (r.clean)
|
|
30
|
+
return true;
|
|
31
|
+
const lines = r.hits.map((h) => ` ${h.container}: ${h.line}`).join("\n");
|
|
32
|
+
throw new DemoGateFailure(`forbidden line(s) in the instance's container logs:\n${lines}`);
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/** The compositor's mediaIn is climbing (playout's ensure-sources.sh gate),
|
|
37
|
+
* read through the daemon proxy's visualiser route for this instance. */
|
|
38
|
+
export function composeAdvancingGate(opts = {}) {
|
|
39
|
+
const node = opts.node ?? DEFAULT_COMPOSE_NODE;
|
|
40
|
+
return {
|
|
41
|
+
label: `${node} advancing`,
|
|
42
|
+
...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
|
|
43
|
+
custom: async (ctx) => {
|
|
44
|
+
const base = ctx.url({ proxy: "/instance/{id}/visualiser" });
|
|
45
|
+
const r = await composeAdvancing({
|
|
46
|
+
fetch: ctx.fetch,
|
|
47
|
+
base,
|
|
48
|
+
node,
|
|
49
|
+
...(opts.settleMs !== undefined ? { settleMs: opts.settleMs } : {}),
|
|
50
|
+
});
|
|
51
|
+
if (!r.advancing)
|
|
52
|
+
ctx.log(`${node} not advancing yet (${r.before} -> ${r.after})`);
|
|
53
|
+
return r.advancing;
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
package/demo/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export type { DemoArgs, DemoCliIo, ResolvedSpecView } from "./cli.js";
|
|
2
2
|
export { defaultDemoCliIo, demoMain, parseDemoArgs, resolvedSpecView } from "./cli.js";
|
|
3
|
-
export
|
|
4
|
-
export {
|
|
5
|
-
export
|
|
3
|
+
export { composeAdvancingGate, DemoGateFailure, logsCleanGate } from "./gates.js";
|
|
4
|
+
export type { CliResult, DemoDeps, DemoPorts, DemoRunOptions, DemoRunResult, DemoState, DemoStateStore, DemoTimeouts, ExportCheckOptions, IngestPortRow, PinMismatch, ProcessHandle, TemplateParams, } from "./run.js";
|
|
5
|
+
export { checkTemplatePins, defaultDemoDeps, demoDown, demoPorts, demoSlug, expandHome, fileStateStore, findBrokenSymlinks, killGroup, resolveIngestPort, runDemo, runExportCheck, } from "./run.js";
|
|
6
|
+
export type { DemoContext, DemoDaemonPolicy, DemoExtra, DemoIngest, DemoLaunchContext, DemoMode, DemoOpen, DemoReady, DemoSource, DemoSpec, DemoTemplate, DemoUrl, } from "./spec.js";
|
|
6
7
|
export { DemoSpecSchema, defineDemo, formatSpecIssues } from "./spec.js";
|
package/demo/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { defaultDemoCliIo, demoMain, parseDemoArgs, resolvedSpecView } from "./cli.js";
|
|
2
|
-
export {
|
|
2
|
+
export { composeAdvancingGate, DemoGateFailure, logsCleanGate } from "./gates.js";
|
|
3
|
+
export { checkTemplatePins, defaultDemoDeps, demoDown, demoPorts, demoSlug, expandHome, fileStateStore, findBrokenSymlinks, killGroup, resolveIngestPort, runDemo, runExportCheck, } from "./run.js";
|
|
3
4
|
export { DemoSpecSchema, defineDemo, formatSpecIssues } from "./spec.js";
|
package/demo/run.d.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import { type ManifestSeed } from "@norskvideo/ctl-sdk/manifest-seed";
|
|
1
2
|
import { ensureRunnerOnNetwork } from "../container-net.js";
|
|
2
3
|
import { type DaemonProcess, type StartDaemonOptions } from "../daemon.js";
|
|
3
4
|
import { type BaseHarnessPorts } from "../harness-config.js";
|
|
4
5
|
import { type SourceHandle, type SrtPumpTarget } from "../source-pump.js";
|
|
5
|
-
import type { DemoIngest, DemoSpec } from "./spec.js";
|
|
6
|
+
import type { DemoDaemonPolicy, DemoIngest, DemoMode, DemoSpec } from "./spec.js";
|
|
6
7
|
export interface CliResult {
|
|
7
8
|
stdout: string;
|
|
8
9
|
stderr: string;
|
|
@@ -20,6 +21,10 @@ export interface DemoState {
|
|
|
20
21
|
daemonPort: number;
|
|
21
22
|
instanceId?: string;
|
|
22
23
|
devPid?: number;
|
|
24
|
+
/** Absent on records from before the policy existed: private. */
|
|
25
|
+
daemon?: DemoDaemonPolicy;
|
|
26
|
+
/** Extra containers `up` started, for `down` to remove. */
|
|
27
|
+
extras?: string[];
|
|
23
28
|
}
|
|
24
29
|
export interface DemoStateStore {
|
|
25
30
|
read(product: string): DemoState | null;
|
|
@@ -29,7 +34,12 @@ export interface DemoStateStore {
|
|
|
29
34
|
export interface DemoDeps {
|
|
30
35
|
licenseFile(): string;
|
|
31
36
|
storeDir(slug: string): string;
|
|
37
|
+
/** The developer's real store, for `--daemon reuse`. */
|
|
38
|
+
realStoreDir(): string;
|
|
32
39
|
writeFile(path: string, contents: string): void;
|
|
40
|
+
/** File contents, or null when unreadable (compose.yml, manifest.seed.json,
|
|
41
|
+
* config.yaml, proxy-secret). */
|
|
42
|
+
readFile(path: string): string | null;
|
|
33
43
|
fileExists(path: string): boolean;
|
|
34
44
|
startDaemon(storeDir: string, options: StartDaemonOptions): {
|
|
35
45
|
daemon: DaemonProcess;
|
|
@@ -42,11 +52,14 @@ export interface DemoDeps {
|
|
|
42
52
|
cwd: string;
|
|
43
53
|
env: Record<string, string>;
|
|
44
54
|
}): ProcessHandle;
|
|
55
|
+
/** One `docker <argv>` invocation: extras, and the reuse policy's control-plane container sweep. */
|
|
56
|
+
docker(argv: string[]): CliResult;
|
|
45
57
|
fetch(url: string, init?: RequestInit): Promise<Response>;
|
|
46
58
|
startSources(opts: {
|
|
47
59
|
daemonPort: number;
|
|
48
60
|
instanceId: string;
|
|
49
61
|
targets: readonly SrtPumpTarget[];
|
|
62
|
+
proxySecret?: string;
|
|
50
63
|
timeoutMs?: number;
|
|
51
64
|
}): Promise<SourceHandle[]>;
|
|
52
65
|
stopSources(handles: SourceHandle[]): Promise<void>;
|
|
@@ -83,8 +96,12 @@ export interface DemoTimeouts {
|
|
|
83
96
|
}
|
|
84
97
|
export interface DemoRunOptions {
|
|
85
98
|
action: "up" | "check";
|
|
86
|
-
mode:
|
|
87
|
-
/**
|
|
99
|
+
mode: DemoMode;
|
|
100
|
+
/** Default private. */
|
|
101
|
+
daemon?: DemoDaemonPolicy;
|
|
102
|
+
/** `reuse`: the daemon's port (default NORSK_CTL_PORT, else 8333). */
|
|
103
|
+
daemonPort?: number;
|
|
104
|
+
/** The product repo root: dev command cwd, relative inputs and links, manifest.seed.json. */
|
|
88
105
|
cwd: string;
|
|
89
106
|
/** Default: the product name without its `norsk-` prefix. */
|
|
90
107
|
slug?: string;
|
|
@@ -134,6 +151,23 @@ export interface TemplateParams {
|
|
|
134
151
|
* no parameter carries it and the instance offers it as the conventional
|
|
135
152
|
* default, so the demo follows the template when the template moves. */
|
|
136
153
|
export declare function resolveIngestPort(ingest: DemoIngest, rows: IngestPortRow[], t?: TemplateParams): number;
|
|
154
|
+
/** A stored template's `image:` ref that disagrees with the product's seed. */
|
|
155
|
+
export interface PinMismatch {
|
|
156
|
+
repo: string;
|
|
157
|
+
stored: string;
|
|
158
|
+
seed: string;
|
|
159
|
+
}
|
|
160
|
+
/** Image pins are read, never typed (05-demo s4): for every repo the seed's
|
|
161
|
+
* `latest` names (studio, media), the stored compose must pin the same ref.
|
|
162
|
+
* A repo the compose does not name is not a mismatch — the guard is about a
|
|
163
|
+
* stale snapshot, not template completeness. */
|
|
164
|
+
export declare function checkTemplatePins(composeText: string, seed: ManifestSeed): PinMismatch[];
|
|
165
|
+
/** The two config.yaml scalars the proxy URL needs; a regex, not a YAML
|
|
166
|
+
* parser, because that is all the driver reads from a store it does not own. */
|
|
167
|
+
export declare function parseProxyConfig(configText: string | null): {
|
|
168
|
+
scheme: "http" | "https";
|
|
169
|
+
port: number;
|
|
170
|
+
};
|
|
137
171
|
export declare function findBrokenSymlinks(dir: string): string[];
|
|
138
172
|
/** `<cwd>/test-temp/demo/<product>.json` — beside the store dirs, inside the
|
|
139
173
|
* consumer's repo, where `down` from another shell can find it. */
|