@norskvideo/ctl-dev-kit 0.2.38 → 0.2.40
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.
|
@@ -37,6 +37,14 @@
|
|
|
37
37
|
mounts don't forward host->guest inotify events, so file watchers never fire —
|
|
38
38
|
a failure that looks exactly like a code bug. Unit tests that don't bind-mount
|
|
39
39
|
may keep using `os.tmpdir()`.
|
|
40
|
+
- **Address another container by its unique `<id>-<service>-1`, never a bare
|
|
41
|
+
service name, unless the caller is on the instance's own network only.** A
|
|
42
|
+
bare name (`studio`, `media`) resolves on every network the caller is on, and
|
|
43
|
+
on `norsk-net` it matches every instance's container: a call silently lands on
|
|
44
|
+
another instance. `media` sits on `<id>_default` only (Docker Desktop UDP
|
|
45
|
+
replies break on a dual-homed container), so never add `norsk-net` to it or to
|
|
46
|
+
a sidecar. Full rules: norsk-ctl `docs/product-template-format.md`,
|
|
47
|
+
"Networking: how services address each other".
|
|
40
48
|
- **Don't pipe test runs to `tail`** — you lose the failure context. Write output
|
|
41
49
|
to a temp file, then tail _that_ file for the results.
|
|
42
50
|
- **Evolve the ctl<->product contract additively.** An older ctl must launch a
|
|
@@ -19,11 +19,11 @@ socket), with their ports **published on the host**.
|
|
|
19
19
|
|
|
20
20
|
Consequence — two address classes the harness must not confuse:
|
|
21
21
|
|
|
22
|
-
| Reaching… | Address
|
|
23
|
-
| ------------------------------------ |
|
|
24
|
-
| the daemon, the product backend | `localhost:<port>` (in-process)
|
|
25
|
-
| studio / media host-published ports | `${NORSK_TEST_HOST}:<port>`
|
|
26
|
-
| a launched container by compose name | `<instance>-<service>-1` **on norsk-net
|
|
22
|
+
| Reaching… | Address |
|
|
23
|
+
| ------------------------------------ | ----------------------------------------------------------------------------------------------------- |
|
|
24
|
+
| the daemon, the product backend | `localhost:<port>` (in-process) |
|
|
25
|
+
| studio / media host-published ports | `${NORSK_TEST_HOST}:<port>` |
|
|
26
|
+
| a launched container by compose name | `<instance>-<service>-1` on its **instance network** (`<instance>_default`); studio also on norsk-net |
|
|
27
27
|
|
|
28
28
|
`NORSK_TEST_HOST` is `host.docker.internal` in CI and unset (→ `localhost`)
|
|
29
29
|
locally. Set it in the workflow env. The shared `studio-state` fetchers
|
|
@@ -43,8 +43,13 @@ runner, one of these must hold:
|
|
|
43
43
|
|
|
44
44
|
- `NORSK_TEST_NET=direct` — the preferred answer, and what the canonical
|
|
45
45
|
`integration.yml` / `smoke.yml` / `build-docs.yml` topology step already sets.
|
|
46
|
-
Instances are reached by compose name
|
|
47
|
-
|
|
46
|
+
Instances are reached by compose name at the **container** port, so no host
|
|
47
|
+
publish is involved at all. Studio is on `norsk-net`; **media is on its
|
|
48
|
+
instance network `<instance>_default` only** (Docker Desktop delivers published
|
|
49
|
+
UDP to either IP of a dual-homed container and breaks SRT replies, see
|
|
50
|
+
norsk-ctl `docs/product-template-format.md`, Networking). The harness's media helpers
|
|
51
|
+
(`mediaHttpBase`, `srtEgressUrl`, `mediaDirectUrl`) join the runner to that
|
|
52
|
+
network for you, and `cleanupDaemon` leaves it before deleting the instance.
|
|
48
53
|
- the launch passes `--publish-debug-ports`, restoring the old binding. The
|
|
49
54
|
shared `runProductSmoke` and demo drivers do this for you when the host they
|
|
50
55
|
fetch from is not loopback, and only after probing that the resolved ctl
|
|
@@ -147,24 +152,39 @@ reach. Two cases:
|
|
|
147
152
|
for `NORSK_TEST_HOST`). `assertMultivariantHasRenditions` does this by default.
|
|
148
153
|
- **Proxy-less harness** — the advertised `/instance/<id>/media/...` route is
|
|
149
154
|
served **only by the daemon's nginx proxy**; with no proxy, nothing answers on
|
|
150
|
-
`:443`. Reach the media container **directly
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
155
|
+
`:443`. Reach the media container **directly** instead: `mediaDirectUrl(url)`
|
|
156
|
+
rewrites it to `http://<id>-media-1:8080/<native-path>` (the same bypass
|
|
157
|
+
`whip-driver` uses; the media route is unauthenticated) and joins the runner to
|
|
158
|
+
the instance network `<id>_default`, where that name resolves. Pass it via
|
|
159
|
+
`assertMultivariantHasRenditions`'s `resolveFetchUrl` hook. Anything else that
|
|
160
|
+
addresses `<id>-media-1` by hand (a sibling container, a browser in the runner)
|
|
161
|
+
must be on `<id>_default` too: `currentInstanceNetworks().join(id)` for the
|
|
162
|
+
runner, `docker network connect <id>_default <container>` for a sibling, and
|
|
163
|
+
leave or remove it before the instance is deleted, or `compose down` cannot
|
|
164
|
+
remove the network.
|
|
160
165
|
|
|
161
166
|
## Capture servers (studio pushes → the runner)
|
|
162
167
|
|
|
163
|
-
When a test stands up an HLS/SCTE-35 capture server
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
+
When a test stands up an HLS/SCTE-35 capture server (or an SRT sink) that
|
|
169
|
+
**media pushes to**, media sits on its instance network only and can't reach a
|
|
170
|
+
containerised runner via the host LAN IP. Hand it the runner's address **on the
|
|
171
|
+
instance network**: `currentInstanceNetworks().addressOn(instanceId)` joins that
|
|
172
|
+
network and returns the runner's IP on it, or `null` when the runner is not in a
|
|
173
|
+
container (or the join failed). The harness ships the primitive, not the policy:
|
|
174
|
+
each product picks its own fallback for the `null` case, usually the address
|
|
175
|
+
media already reaches the host by.
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
import { currentInstanceNetworks } from "@norskvideo/ctl-test-harness/container-net";
|
|
179
|
+
|
|
180
|
+
function captureHost(instanceId: string): string {
|
|
181
|
+
// hostLanIp is the product's own off-container fallback.
|
|
182
|
+
return currentInstanceNetworks().addressOn(instanceId) ?? hostLanIp();
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
The runner leaves every network it joined during `cleanupDaemon`, before the
|
|
187
|
+
instance is deleted.
|
|
168
188
|
|
|
169
189
|
## Build the product image in the integration job
|
|
170
190
|
|
package/package.json
CHANGED
|
@@ -16,16 +16,72 @@
|
|
|
16
16
|
* are not reliable yet: it skips only the guide half of the gate, must give a
|
|
17
17
|
* reason, and the reason travels into the published row, so nobody reads
|
|
18
18
|
* "passed" off an image whose guides never ran.
|
|
19
|
+
*
|
|
20
|
+
* Self-contained on purpose: it imports nothing outside node and this package.
|
|
21
|
+
* Products resolve the dev-kit's siblings from their OWN lockfiles, so a sibling
|
|
22
|
+
* import here bound the writer to whatever schema version a product happened to
|
|
23
|
+
* lock -- the first gated publish in every product died on a missing export.
|
|
24
|
+
* The object's contract is @norskvideo/ctl-product-template-schema's
|
|
25
|
+
* product-channels; product-channel.test.ts parses this module's output with it.
|
|
19
26
|
*/
|
|
20
27
|
import { existsSync, readFileSync } from "node:fs";
|
|
21
|
-
import {
|
|
22
|
-
type ProductChannelRow,
|
|
23
|
-
ProductChannelSchema,
|
|
24
|
-
parseProductChannels,
|
|
25
|
-
withChannelRow,
|
|
26
|
-
} from "@norskvideo/ctl-product-template-schema/product-channels";
|
|
27
28
|
import { builtImageName } from "../licence/check-template.ts";
|
|
28
29
|
|
|
30
|
+
const CHANNELS = ["nightly", "rc", "latest"] as const;
|
|
31
|
+
type Channel = (typeof CHANNELS)[number];
|
|
32
|
+
|
|
33
|
+
export interface ProductChannelRow {
|
|
34
|
+
image: string;
|
|
35
|
+
sha: string;
|
|
36
|
+
publishedAt: string;
|
|
37
|
+
runtimeImages: string[];
|
|
38
|
+
gate: { checks: "passed"; guides: "passed" | { skipped: string }; run?: string };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface ProductChannels {
|
|
42
|
+
schemaVersion: 1;
|
|
43
|
+
product: string;
|
|
44
|
+
imageRef: string;
|
|
45
|
+
updated: string;
|
|
46
|
+
channels: Partial<Record<Channel, unknown>>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The current object, checked only as far as the merge needs: other channels'
|
|
50
|
+
* rows are carried through untouched, so a newer writer's fields survive. */
|
|
51
|
+
function parseCurrent(text: string): ProductChannels {
|
|
52
|
+
const raw = JSON.parse(text) as Partial<ProductChannels>;
|
|
53
|
+
if (raw.schemaVersion !== 1) throw new Error(`channels object has schemaVersion ${raw.schemaVersion}, not 1`);
|
|
54
|
+
if (typeof raw.product !== "string" || typeof raw.imageRef !== "string") {
|
|
55
|
+
throw new Error("channels object has no product/imageRef");
|
|
56
|
+
}
|
|
57
|
+
if (typeof raw.channels !== "object" || raw.channels === null) throw new Error("channels object has no channels");
|
|
58
|
+
return raw as ProductChannels;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** `current` with `channel` set to `row`; every other channel is kept. */
|
|
62
|
+
export function withChannelRow(
|
|
63
|
+
current: ProductChannels | null,
|
|
64
|
+
opts: { product: string; imageRef: string; channel: Channel; row: ProductChannelRow; updated: string },
|
|
65
|
+
): ProductChannels {
|
|
66
|
+
if (current && (current.product !== opts.product || current.imageRef !== opts.imageRef)) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`channels object belongs to ${current.product} (${current.imageRef}), not ${opts.product} (${opts.imageRef})`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
schemaVersion: 1,
|
|
73
|
+
product: opts.product,
|
|
74
|
+
imageRef: opts.imageRef,
|
|
75
|
+
updated: opts.updated,
|
|
76
|
+
channels: { ...(current?.channels ?? {}), [opts.channel]: opts.row },
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function parseChannel(value: string): Channel {
|
|
81
|
+
if (!(CHANNELS as readonly string[]).includes(value)) throw new Error(`not a channel: ${value}`);
|
|
82
|
+
return value as Channel;
|
|
83
|
+
}
|
|
84
|
+
|
|
29
85
|
export type GuideGate = { run: true } | { run: false; reason: string };
|
|
30
86
|
|
|
31
87
|
/** Whether the guides gate this product's publish, from its opt-out file. */
|
|
@@ -106,20 +162,12 @@ if (import.meta.main) {
|
|
|
106
162
|
console.log(productForImage(readFileSync(need("template"), "utf8"), need("image")));
|
|
107
163
|
} else if (cmd === "row") {
|
|
108
164
|
const currentText = readIfPresent(flag("current"));
|
|
109
|
-
|
|
110
|
-
if (currentText !== undefined) {
|
|
111
|
-
const parsed = parseProductChannels(JSON.parse(currentText));
|
|
112
|
-
if (!parsed.ok) {
|
|
113
|
-
console.error(`product-channel row: the current object does not parse:\n${parsed.error}`);
|
|
114
|
-
process.exit(1);
|
|
115
|
-
}
|
|
116
|
-
current = parsed.value;
|
|
117
|
-
}
|
|
165
|
+
const current = currentText === undefined ? null : parseCurrent(currentText);
|
|
118
166
|
const skipped = flag("skipped");
|
|
119
167
|
const merged = withChannelRow(current, {
|
|
120
168
|
product: need("product"),
|
|
121
169
|
imageRef: need("image-ref"),
|
|
122
|
-
channel:
|
|
170
|
+
channel: parseChannel(need("channel")),
|
|
123
171
|
updated: need("published-at"),
|
|
124
172
|
row: gateRow({
|
|
125
173
|
image: need("image"),
|