@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.
@@ -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
- export declare function productRunArgs(image: string, hostPort: number, opts?: ProductRunOpts): string[];
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
- export declare function dockerRun(image: string, hostPort: number, opts?: ProductRunOpts): Promise<string>;
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
- export function productRunArgs(image, hostPort, opts = {}) {
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:${hostPort}:${CONTAINER_INTERNAL_PORT}`,
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
- export async function dockerRun(image, hostPort, opts = {}) {
48
- const proc = Bun.spawn(["docker", ...productRunArgs(image, hostPort, opts)], { stdout: "pipe", stderr: "pipe" });
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
- return stdout;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-sdk",
3
- "version": "0.1.25",
3
+ "version": "0.1.27",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
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);
@@ -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
- run(image: string, hostPort: number): Promise<string>;
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?;
@@ -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} on host port ${port} (reach: ${this.reach})`);
131
- containerId = await this.containerOps.run(spec.image, port);
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, target.port);
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 ? withContainerId(p, containerId, reachHost) : p)));
323
- await this.containerOps.waitForReady(specBaseUrl(target.spec, target.port, reachHost));
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, reg.port);
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, reg.port, reachHost));
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 ${reg.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 withContainerId(p, r?.containerId, r?.reachHost);
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, target.port);
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 ? withContainerId(p, containerId, reachHost) : p)));
461
- await this.containerOps.waitForReady(specBaseUrl(target.spec, target.port, reachHost));
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 ${target.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
+ }