@norskvideo/ctl-sdk 0.1.25 → 0.1.27
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/docker-runner.d.ts +30 -3
- package/docker-runner.js +52 -6
- package/package.json +1 -1
- package/parsing.js +6 -0
- package/product-service.d.ts +4 -3
- package/product-service.js +25 -25
package/docker-runner.d.ts
CHANGED
|
@@ -13,8 +13,19 @@ export interface ProductRunOpts {
|
|
|
13
13
|
}
|
|
14
14
|
/** argv (sans leading "docker") that launches a product container: detached,
|
|
15
15
|
* auto-removed, loopback-published to its internal 4321, and role-labelled so
|
|
16
|
-
* it shows up in the Infrastructure tab.
|
|
17
|
-
|
|
16
|
+
* it shows up in the Infrastructure tab.
|
|
17
|
+
*
|
|
18
|
+
* The host side of the publish is EMPTY on purpose. ctl used to pick it from
|
|
19
|
+
* a fixed 14321+ range while consulting only its own registrations, which is
|
|
20
|
+
* no allocator at all: it cannot see another ctl daemon, another compose
|
|
21
|
+
* stack, or any other process on the host. Sibling CI runner containers
|
|
22
|
+
* sharing one docker daemon both picked 14321 and the second died with
|
|
23
|
+
* "driver failed programming external connectivity". Worse, a daemon that is
|
|
24
|
+
* itself a container cannot even probe for the clash — a bind in its own
|
|
25
|
+
* network namespace says nothing about the docker host's port space, which is
|
|
26
|
+
* where the publish actually lands. So the allocation belongs to the docker
|
|
27
|
+
* daemon, which owns that space; ctl reads back what it was given. */
|
|
28
|
+
export declare function productRunArgs(image: string, opts?: ProductRunOpts): string[];
|
|
18
29
|
/** Stable container name for a product, derived from its manifest name, so
|
|
19
30
|
* `docker ps` shows `norsk-product-studio` instead of a random docker alias.
|
|
20
31
|
* Mirrors the singleton naming of norsk-proxy / norsk-ctl-cpu-monitor. */
|
|
@@ -23,7 +34,23 @@ export declare function productContainerName(productName: string): string;
|
|
|
23
34
|
* so there is nothing to refresh; a tag can move under a registration. */
|
|
24
35
|
export declare function isDigestRef(image: string): boolean;
|
|
25
36
|
export declare function dockerPull(image: string): Promise<void>;
|
|
26
|
-
|
|
37
|
+
/** Where a started product container ended up: its id, and the host port
|
|
38
|
+
* docker chose for it. The two are produced together because they change
|
|
39
|
+
* together — the container is `--rm`, so every restart is a new container
|
|
40
|
+
* with a new assignment and a stored port from the last one is already
|
|
41
|
+
* stale. */
|
|
42
|
+
export interface ProductPlacement {
|
|
43
|
+
containerId: string;
|
|
44
|
+
hostPort: number;
|
|
45
|
+
}
|
|
46
|
+
/** The host port from `docker inspect`'s HostPort lookup. Docker prints one
|
|
47
|
+
* line per binding (an IPv4 and an IPv6 row for the same publish is normal),
|
|
48
|
+
* and `<no value>` when the template finds nothing. */
|
|
49
|
+
export declare function parsePublishedPort(inspectOutput: string): number | undefined;
|
|
50
|
+
/** The host port docker published a container's internal 4321 on, or undefined
|
|
51
|
+
* when docker cannot say. */
|
|
52
|
+
export declare function dockerPublishedPort(containerId: string): Promise<number | undefined>;
|
|
53
|
+
export declare function dockerRun(image: string, opts?: ProductRunOpts): Promise<ProductPlacement>;
|
|
27
54
|
/** The container's address on its first network (the default bridge for a
|
|
28
55
|
* product container), or undefined when docker cannot say. */
|
|
29
56
|
export declare function dockerAddress(containerId: string): Promise<string | undefined>;
|
package/docker-runner.js
CHANGED
|
@@ -7,8 +7,19 @@ export const CONTAINER_INTERNAL_PORT = 4321;
|
|
|
7
7
|
export const PRODUCT_ROLE_LABEL = "norsk-ctl.role=product";
|
|
8
8
|
/** argv (sans leading "docker") that launches a product container: detached,
|
|
9
9
|
* auto-removed, loopback-published to its internal 4321, and role-labelled so
|
|
10
|
-
* it shows up in the Infrastructure tab.
|
|
11
|
-
|
|
10
|
+
* it shows up in the Infrastructure tab.
|
|
11
|
+
*
|
|
12
|
+
* The host side of the publish is EMPTY on purpose. ctl used to pick it from
|
|
13
|
+
* a fixed 14321+ range while consulting only its own registrations, which is
|
|
14
|
+
* no allocator at all: it cannot see another ctl daemon, another compose
|
|
15
|
+
* stack, or any other process on the host. Sibling CI runner containers
|
|
16
|
+
* sharing one docker daemon both picked 14321 and the second died with
|
|
17
|
+
* "driver failed programming external connectivity". Worse, a daemon that is
|
|
18
|
+
* itself a container cannot even probe for the clash — a bind in its own
|
|
19
|
+
* network namespace says nothing about the docker host's port space, which is
|
|
20
|
+
* where the publish actually lands. So the allocation belongs to the docker
|
|
21
|
+
* daemon, which owns that space; ctl reads back what it was given. */
|
|
22
|
+
export function productRunArgs(image, opts = {}) {
|
|
12
23
|
return [
|
|
13
24
|
"run",
|
|
14
25
|
"-d",
|
|
@@ -17,7 +28,7 @@ export function productRunArgs(image, hostPort, opts = {}) {
|
|
|
17
28
|
PRODUCT_ROLE_LABEL,
|
|
18
29
|
...(opts.network ? ["--network", opts.network] : []),
|
|
19
30
|
"-p",
|
|
20
|
-
`127.0.0.1
|
|
31
|
+
`127.0.0.1::${CONTAINER_INTERNAL_PORT}`,
|
|
21
32
|
image,
|
|
22
33
|
];
|
|
23
34
|
}
|
|
@@ -44,15 +55,50 @@ export async function dockerPull(image) {
|
|
|
44
55
|
throw new ProductError("DOCKER_PULL_FAILED", `docker pull failed (exit ${exitCode}): ${stderr || "no stderr"}`);
|
|
45
56
|
}
|
|
46
57
|
}
|
|
47
|
-
|
|
48
|
-
|
|
58
|
+
/** The host port from `docker inspect`'s HostPort lookup. Docker prints one
|
|
59
|
+
* line per binding (an IPv4 and an IPv6 row for the same publish is normal),
|
|
60
|
+
* and `<no value>` when the template finds nothing. */
|
|
61
|
+
export function parsePublishedPort(inspectOutput) {
|
|
62
|
+
for (const line of inspectOutput.split(/\r?\n/)) {
|
|
63
|
+
const port = Number.parseInt(line.trim(), 10);
|
|
64
|
+
if (Number.isInteger(port) && port > 0)
|
|
65
|
+
return port;
|
|
66
|
+
}
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
/** The host port docker published a container's internal 4321 on, or undefined
|
|
70
|
+
* when docker cannot say. */
|
|
71
|
+
export async function dockerPublishedPort(containerId) {
|
|
72
|
+
const proc = Bun.spawn([
|
|
73
|
+
"docker",
|
|
74
|
+
"inspect",
|
|
75
|
+
"-f",
|
|
76
|
+
`{{range index .NetworkSettings.Ports "${CONTAINER_INTERNAL_PORT}/tcp"}}{{.HostPort}}\n{{end}}`,
|
|
77
|
+
containerId,
|
|
78
|
+
], { stdout: "pipe", stderr: "pipe" });
|
|
79
|
+
const exitCode = await proc.exited;
|
|
80
|
+
const stdout = await new Response(proc.stdout).text();
|
|
81
|
+
if (exitCode !== 0)
|
|
82
|
+
return undefined;
|
|
83
|
+
return parsePublishedPort(stdout);
|
|
84
|
+
}
|
|
85
|
+
export async function dockerRun(image, opts = {}) {
|
|
86
|
+
const proc = Bun.spawn(["docker", ...productRunArgs(image, opts)], { stdout: "pipe", stderr: "pipe" });
|
|
49
87
|
const exitCode = await proc.exited;
|
|
50
88
|
const stdout = (await new Response(proc.stdout).text()).trim();
|
|
51
89
|
const stderr = (await new Response(proc.stderr).text()).trim();
|
|
52
90
|
if (exitCode !== 0 || !stdout) {
|
|
53
91
|
throw new ProductError("DOCKER_RUN_FAILED", `docker run failed (exit ${exitCode}): ${stderr || "no stderr"}`);
|
|
54
92
|
}
|
|
55
|
-
|
|
93
|
+
const containerId = stdout;
|
|
94
|
+
const hostPort = await dockerPublishedPort(containerId);
|
|
95
|
+
if (hostPort === undefined) {
|
|
96
|
+
// Nothing to fall back to: the port is docker's to report, and a
|
|
97
|
+
// registration without one cannot be dialled or restarted.
|
|
98
|
+
await dockerRm(containerId);
|
|
99
|
+
throw new ProductError("DOCKER_RUN_FAILED", `docker did not report a published host port for ${CONTAINER_INTERNAL_PORT}/tcp`);
|
|
100
|
+
}
|
|
101
|
+
return { containerId, hostPort };
|
|
56
102
|
}
|
|
57
103
|
/** The container's address on its first network (the default bridge for a
|
|
58
104
|
* product container), or undefined when docker cannot say. */
|
package/package.json
CHANGED
package/parsing.js
CHANGED
|
@@ -75,6 +75,12 @@ export function parseProductsFile(raw, path) {
|
|
|
75
75
|
reg.port = entry.port;
|
|
76
76
|
if (typeof entry.containerId === "string")
|
|
77
77
|
reg.containerId = entry.containerId;
|
|
78
|
+
// Round-trips with containerId or the daemon loses where to dial: a
|
|
79
|
+
// containerised daemon records the container's own address here, and
|
|
80
|
+
// without it every later reader falls back to 127.0.0.1:<port>, which is
|
|
81
|
+
// the daemon's own loopback and answers nothing.
|
|
82
|
+
if (typeof entry.reachHost === "string")
|
|
83
|
+
reg.reachHost = entry.reachHost;
|
|
78
84
|
if (entry.license !== undefined)
|
|
79
85
|
reg.license = parseLicense(entry.license, where, path);
|
|
80
86
|
validated.push(reg);
|
package/product-service.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ProductPlacement } from "./docker-runner.js";
|
|
1
2
|
import { type LicenseStager } from "./license-registration.js";
|
|
2
3
|
import { type ProductReach } from "./product-reach.js";
|
|
3
4
|
import type { ProductTemplateSource } from "./product-template-record.js";
|
|
@@ -80,7 +81,9 @@ export type RefreshProductTemplateBytesFn = ImportProductTemplateBytesFn;
|
|
|
80
81
|
* made an injected fake silently inert on add/remove. */
|
|
81
82
|
export interface ProductContainerOps {
|
|
82
83
|
pull(image: string): Promise<void>;
|
|
83
|
-
|
|
84
|
+
/** Start the container. The host port comes back from docker rather than
|
|
85
|
+
* going in: docker owns the host port space (see docker-runner.ts). */
|
|
86
|
+
run(image: string): Promise<ProductPlacement>;
|
|
84
87
|
remove(containerId: string): Promise<void>;
|
|
85
88
|
rename(containerId: string, name: string): Promise<void>;
|
|
86
89
|
waitForReady(baseUrl: string): Promise<void>;
|
|
@@ -94,7 +97,6 @@ export interface ProductContainerOps {
|
|
|
94
97
|
export declare const defaultProductContainerOps: ProductContainerOps;
|
|
95
98
|
export interface ProductServiceOptions {
|
|
96
99
|
store: ProductStore;
|
|
97
|
-
allocatePort: AllocatePortFn;
|
|
98
100
|
/** Optional: called once per `manifest.defaultProductTemplates` entry at
|
|
99
101
|
* registration time. Omit on hosts that don't store product templates
|
|
100
102
|
* (defaultProductTemplates is then silently skipped). */
|
|
@@ -135,7 +137,6 @@ export declare class ProductService {
|
|
|
135
137
|
*/
|
|
136
138
|
private readonly mutations;
|
|
137
139
|
private readonly store;
|
|
138
|
-
private readonly allocatePort;
|
|
139
140
|
private readonly importProductTemplateBytes?;
|
|
140
141
|
private readonly refreshProductTemplateBytes?;
|
|
141
142
|
private readonly stageLicense?;
|
package/product-service.js
CHANGED
|
@@ -52,7 +52,6 @@ export class ProductService {
|
|
|
52
52
|
*/
|
|
53
53
|
mutations = new Mutex();
|
|
54
54
|
store;
|
|
55
|
-
allocatePort;
|
|
56
55
|
importProductTemplateBytes;
|
|
57
56
|
refreshProductTemplateBytes;
|
|
58
57
|
stageLicense;
|
|
@@ -61,7 +60,6 @@ export class ProductService {
|
|
|
61
60
|
reach;
|
|
62
61
|
constructor(opts) {
|
|
63
62
|
this.store = opts.store;
|
|
64
|
-
this.allocatePort = opts.allocatePort;
|
|
65
63
|
this.importProductTemplateBytes = opts.importProductTemplateBytes;
|
|
66
64
|
this.refreshProductTemplateBytes = opts.refreshProductTemplateBytes;
|
|
67
65
|
this.stageLicense = opts.stageLicense;
|
|
@@ -117,18 +115,12 @@ export class ProductService {
|
|
|
117
115
|
baseUrl = specBaseUrl(spec);
|
|
118
116
|
}
|
|
119
117
|
else {
|
|
120
|
-
const portMap = {};
|
|
121
|
-
for (const p of existing)
|
|
122
|
-
if (p.port !== undefined)
|
|
123
|
-
portMap[p.name] = { port: p.port };
|
|
124
|
-
const allocated = this.allocatePort(portMap);
|
|
125
|
-
if (allocated === null) {
|
|
126
|
-
throw new ProductError("PORT_EXHAUSTED", `no free port available for new product`);
|
|
127
|
-
}
|
|
128
|
-
port = allocated;
|
|
129
118
|
await this.refreshImage(spec.image);
|
|
130
|
-
logger.info(`Starting product container: ${spec.image}
|
|
131
|
-
|
|
119
|
+
logger.info(`Starting product container: ${spec.image} (reach: ${this.reach})`);
|
|
120
|
+
const placement = await this.containerOps.run(spec.image);
|
|
121
|
+
containerId = placement.containerId;
|
|
122
|
+
port = placement.hostPort;
|
|
123
|
+
logger.info(`Product container ${containerId.slice(0, 12)} published on host port ${port}`);
|
|
132
124
|
reachHost = await this.reachHostFor(containerId);
|
|
133
125
|
baseUrl = specBaseUrl(spec, port, reachHost);
|
|
134
126
|
}
|
|
@@ -317,10 +309,10 @@ export class ProductService {
|
|
|
317
309
|
// Already gone (crashed / --rm reaped) — nothing to stop.
|
|
318
310
|
}
|
|
319
311
|
}
|
|
320
|
-
const containerId = await this.containerOps.run(target.spec.image
|
|
312
|
+
const { containerId, hostPort } = await this.containerOps.run(target.spec.image);
|
|
321
313
|
const reachHost = await this.reachHostFor(containerId);
|
|
322
|
-
await this.store.update((products) => products.map((p) => (p.name === name ?
|
|
323
|
-
await this.containerOps.waitForReady(specBaseUrl(target.spec,
|
|
314
|
+
await this.store.update((products) => products.map((p) => (p.name === name ? withPlacement(p, containerId, hostPort, reachHost) : p)));
|
|
315
|
+
await this.containerOps.waitForReady(specBaseUrl(target.spec, hostPort, reachHost));
|
|
324
316
|
await this.containerOps.rename(containerId, productContainerName(name));
|
|
325
317
|
containerRestarted = true;
|
|
326
318
|
}
|
|
@@ -408,12 +400,12 @@ export class ProductService {
|
|
|
408
400
|
return;
|
|
409
401
|
}
|
|
410
402
|
try {
|
|
411
|
-
const containerId = await this.containerOps.run(reg.spec.image
|
|
403
|
+
const { containerId, hostPort } = await this.containerOps.run(reg.spec.image);
|
|
412
404
|
const reachHost = await this.reachHostFor(containerId);
|
|
413
|
-
await this.containerOps.waitForReady(specBaseUrl(reg.spec,
|
|
405
|
+
await this.containerOps.waitForReady(specBaseUrl(reg.spec, hostPort, reachHost));
|
|
414
406
|
await this.containerOps.rename(containerId, productContainerName(reg.name));
|
|
415
|
-
restored.set(reg.name, { containerId, reachHost });
|
|
416
|
-
logger.info(`Product '${reg.name}' container restored on host port ${
|
|
407
|
+
restored.set(reg.name, { containerId, hostPort, reachHost });
|
|
408
|
+
logger.info(`Product '${reg.name}' container restored on host port ${hostPort}`);
|
|
417
409
|
}
|
|
418
410
|
catch (e) {
|
|
419
411
|
restored.set(reg.name, undefined);
|
|
@@ -424,7 +416,9 @@ export class ProductService {
|
|
|
424
416
|
if (!restored.has(p.name))
|
|
425
417
|
return p;
|
|
426
418
|
const r = restored.get(p.name);
|
|
427
|
-
return
|
|
419
|
+
return r === undefined
|
|
420
|
+
? withContainerId(p, undefined)
|
|
421
|
+
: withPlacement(p, r.containerId, r.hostPort, r.reachHost);
|
|
428
422
|
}));
|
|
429
423
|
}
|
|
430
424
|
/** Stop (if still present) and relaunch a single container-kind product,
|
|
@@ -455,12 +449,12 @@ export class ProductService {
|
|
|
455
449
|
// Already gone (crashed / --rm reaped) — nothing to stop.
|
|
456
450
|
}
|
|
457
451
|
}
|
|
458
|
-
const containerId = await this.containerOps.run(target.spec.image
|
|
452
|
+
const { containerId, hostPort } = await this.containerOps.run(target.spec.image);
|
|
459
453
|
const reachHost = await this.reachHostFor(containerId);
|
|
460
|
-
await this.store.update((products) => products.map((p) => (p.name === name ?
|
|
461
|
-
await this.containerOps.waitForReady(specBaseUrl(target.spec,
|
|
454
|
+
await this.store.update((products) => products.map((p) => (p.name === name ? withPlacement(p, containerId, hostPort, reachHost) : p)));
|
|
455
|
+
await this.containerOps.waitForReady(specBaseUrl(target.spec, hostPort, reachHost));
|
|
462
456
|
await this.containerOps.rename(containerId, productContainerName(name));
|
|
463
|
-
logger.info(`Product '${name}' container restarted on host port ${
|
|
457
|
+
logger.info(`Product '${name}' container restarted on host port ${hostPort}`);
|
|
464
458
|
}
|
|
465
459
|
}
|
|
466
460
|
/** Return a copy of `reg` with containerId (and the address it was reached at)
|
|
@@ -473,3 +467,9 @@ function withContainerId(reg, containerId, reachHost) {
|
|
|
473
467
|
return rest;
|
|
474
468
|
return { ...rest, containerId, ...(reachHost !== undefined ? { reachHost } : {}) };
|
|
475
469
|
}
|
|
470
|
+
/** As withContainerId, but also records the host port docker assigned THIS
|
|
471
|
+
* container. Every (re)start is a new container with a new assignment, so the
|
|
472
|
+
* port travels with the id or the next dial goes to the dead one's port. */
|
|
473
|
+
function withPlacement(reg, containerId, hostPort, reachHost) {
|
|
474
|
+
return { ...withContainerId(reg, containerId, reachHost), port: hostPort };
|
|
475
|
+
}
|