@norskvideo/ctl-sdk 0.1.20 → 0.1.22

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.
@@ -1,3 +1,4 @@
1
+ export declare const CONTAINER_INTERNAL_PORT = 4321;
1
2
  /** Role label stamped on every product control-plane container. The daemon's
2
3
  * infrastructure view discovers managed singletons by the presence of a
3
4
  * `norsk-ctl.role` label (proxy, proxy-oauth2, cpu-monitor, product); the
@@ -16,6 +17,9 @@ export declare function productContainerName(productName: string): string;
16
17
  export declare function isDigestRef(image: string): boolean;
17
18
  export declare function dockerPull(image: string): Promise<void>;
18
19
  export declare function dockerRun(image: string, hostPort: number): Promise<string>;
20
+ /** The container's address on its first network (the default bridge for a
21
+ * product container), or undefined when docker cannot say. */
22
+ export declare function dockerAddress(containerId: string): Promise<string | undefined>;
19
23
  export declare function dockerRm(containerId: string): Promise<void>;
20
24
  /** Best-effort rename of a running container. The product is tracked by
21
25
  * container id, never by name, so a rename failure (e.g. a stale container
package/docker-runner.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { ProductError } from "./product-error.js";
2
- const CONTAINER_INTERNAL_PORT = 4321;
2
+ export const CONTAINER_INTERNAL_PORT = 4321;
3
3
  /** Role label stamped on every product control-plane container. The daemon's
4
4
  * infrastructure view discovers managed singletons by the presence of a
5
5
  * `norsk-ctl.role` label (proxy, proxy-oauth2, cpu-monitor, product); the
@@ -53,6 +53,16 @@ export async function dockerRun(image, hostPort) {
53
53
  }
54
54
  return stdout;
55
55
  }
56
+ /** The container's address on its first network (the default bridge for a
57
+ * product container), or undefined when docker cannot say. */
58
+ export async function dockerAddress(containerId) {
59
+ const proc = Bun.spawn(["docker", "inspect", "-f", "{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}", containerId], { stdout: "pipe", stderr: "pipe" });
60
+ const exitCode = await proc.exited;
61
+ const stdout = (await new Response(proc.stdout).text()).trim();
62
+ if (exitCode !== 0 || !stdout)
63
+ return undefined;
64
+ return stdout.split(/\s+/)[0];
65
+ }
56
66
  export async function dockerRm(containerId) {
57
67
  const proc = Bun.spawn(["docker", "rm", "-f", containerId], { stdout: "pipe", stderr: "pipe" });
58
68
  await proc.exited;
@@ -0,0 +1,21 @@
1
+ import { type Express } from "express";
2
+ /**
3
+ * Where a product's docs bundle (dev-kit doc-guide `buildBundle`) is, given the
4
+ * backend's own directory — or null when the product shipped none.
5
+ *
6
+ * Two layouts, one relative path: the image bakes the bundle at `<app>/docs`
7
+ * beside `backend/` (Dockerfile.bundle), a repo checkout has it at
8
+ * `<repo>/docs/generated/bundle`; from `backend/dist` and `backend/src`
9
+ * respectively both are `../../docs`. The image's `docs/` exists even when
10
+ * empty, so presence is judged by `bundle.json`, not the directory.
11
+ */
12
+ export declare function docsBundleDir(fromDir: string): string | null;
13
+ /**
14
+ * Mount the docs bundle at `/docs`: `manual.html` as the index, `bundle.json`,
15
+ * `pages/*.md` and `captures/*` by path. Returns whether anything was mounted,
16
+ * so a product without a bundle boots exactly as before. `/docs` redirects to
17
+ * `/docs/` with a root-absolute Location, which the daemon's product proxy
18
+ * rewrites under `/products/<name>` — so the manual's hash routing works
19
+ * through the daemon too.
20
+ */
21
+ export declare function serveDocs(app: Express, dir: string | null): boolean;
package/docs-router.js ADDED
@@ -0,0 +1,35 @@
1
+ import { existsSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import express from "express";
4
+ /**
5
+ * Where a product's docs bundle (dev-kit doc-guide `buildBundle`) is, given the
6
+ * backend's own directory — or null when the product shipped none.
7
+ *
8
+ * Two layouts, one relative path: the image bakes the bundle at `<app>/docs`
9
+ * beside `backend/` (Dockerfile.bundle), a repo checkout has it at
10
+ * `<repo>/docs/generated/bundle`; from `backend/dist` and `backend/src`
11
+ * respectively both are `../../docs`. The image's `docs/` exists even when
12
+ * empty, so presence is judged by `bundle.json`, not the directory.
13
+ */
14
+ export function docsBundleDir(fromDir) {
15
+ const docs = resolve(fromDir, "../../docs");
16
+ for (const candidate of [docs, resolve(docs, "generated/bundle")]) {
17
+ if (existsSync(resolve(candidate, "bundle.json")))
18
+ return candidate;
19
+ }
20
+ return null;
21
+ }
22
+ /**
23
+ * Mount the docs bundle at `/docs`: `manual.html` as the index, `bundle.json`,
24
+ * `pages/*.md` and `captures/*` by path. Returns whether anything was mounted,
25
+ * so a product without a bundle boots exactly as before. `/docs` redirects to
26
+ * `/docs/` with a root-absolute Location, which the daemon's product proxy
27
+ * rewrites under `/products/<name>` — so the manual's hash routing works
28
+ * through the daemon too.
29
+ */
30
+ export function serveDocs(app, dir) {
31
+ if (!dir)
32
+ return false;
33
+ app.use("/docs", express.static(dir, { index: "manual.html" }));
34
+ return true;
35
+ }
package/index.d.ts CHANGED
@@ -3,6 +3,7 @@ export * from "./capabilities-router.js";
3
3
  export * from "./cjs-interop.js";
4
4
  export * from "./dev-url.js";
5
5
  export * from "./docker-runner.js";
6
+ export * from "./docs-router.js";
6
7
  export * from "./http-proxy.js";
7
8
  export * from "./license-registration.js";
8
9
  export * from "./license-staged.js";
@@ -12,6 +13,7 @@ export * from "./manifest-fetch.js";
12
13
  export * from "./manifest-router.js";
13
14
  export * from "./openapi-router.js";
14
15
  export * from "./product-health-monitor.js";
16
+ export * from "./product-reach.js";
15
17
  export * from "./product-service.js";
16
18
  export * from "./product-template-materials.js";
17
19
  export * from "./proxy-middleware.js";
package/index.js CHANGED
@@ -5,6 +5,7 @@ export * from "./capabilities-router.js";
5
5
  export * from "./cjs-interop.js";
6
6
  export * from "./dev-url.js";
7
7
  export * from "./docker-runner.js";
8
+ export * from "./docs-router.js";
8
9
  export * from "./http-proxy.js";
9
10
  export * from "./license-registration.js";
10
11
  export * from "./license-staged.js";
@@ -14,6 +15,7 @@ export * from "./manifest-fetch.js";
14
15
  export * from "./manifest-router.js";
15
16
  export * from "./openapi-router.js";
16
17
  export * from "./product-health-monitor.js";
18
+ export * from "./product-reach.js";
17
19
  export * from "./product-service.js";
18
20
  export * from "./product-template-materials.js";
19
21
  export * from "./proxy-middleware.js";
@@ -1,5 +1,5 @@
1
1
  import { type Manifest } from "./manifest-schema.js";
2
- import type { ProductSpec } from "./product-types.js";
2
+ import type { ProductRegistration, ProductSpec } from "./product-types.js";
3
3
  /**
4
4
  * Fast liveness probe for a registered product. A dev-mode product is
5
5
  * externally owned, so the only way to know it's actually up is to ask it.
@@ -8,7 +8,14 @@ import type { ProductSpec } from "./product-types.js";
8
8
  * because it runs on the product-list render path.
9
9
  */
10
10
  export declare function isDevUrlAlive(baseUrl: string): Promise<boolean>;
11
- export declare function specBaseUrl(spec: ProductSpec, port?: number): string;
11
+ /** Where the daemon dials a product. A container product is loopback-published
12
+ * on `port`; a daemon that itself runs in a container cannot reach that
13
+ * loopback and dials the container's own address (`reachHost`) on the internal
14
+ * port instead — see product-reach.ts. */
15
+ export declare function specBaseUrl(spec: ProductSpec, port?: number, reachHost?: string): string;
16
+ /** specBaseUrl for a registration — the one call every dial site should make,
17
+ * so a recorded reachHost is never forgotten. */
18
+ export declare function productBaseUrl(reg: Pick<ProductRegistration, "spec" | "port" | "reachHost">): string;
12
19
  export declare function fetchManifest(baseUrl: string): Promise<Manifest>;
13
20
  export declare function waitForReady(baseUrl: string): Promise<void>;
14
21
  /**
package/manifest-fetch.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { CONTAINER_INTERNAL_PORT } from "./docker-runner.js";
1
2
  import { ManifestSchema } from "./manifest-schema.js";
2
3
  import { ProductError } from "./product-error.js";
3
4
  const READINESS_TIMEOUT_MS = 60_000;
@@ -19,13 +20,24 @@ export async function isDevUrlAlive(baseUrl) {
19
20
  return false;
20
21
  }
21
22
  }
22
- export function specBaseUrl(spec, port) {
23
+ /** Where the daemon dials a product. A container product is loopback-published
24
+ * on `port`; a daemon that itself runs in a container cannot reach that
25
+ * loopback and dials the container's own address (`reachHost`) on the internal
26
+ * port instead — see product-reach.ts. */
27
+ export function specBaseUrl(spec, port, reachHost) {
23
28
  if (spec.kind === "dev")
24
29
  return spec.url.replace(/\/$/, "");
30
+ if (reachHost !== undefined)
31
+ return `http://${reachHost}:${CONTAINER_INTERNAL_PORT}`;
25
32
  if (port === undefined)
26
33
  throw new Error("container spec requires port");
27
34
  return `http://127.0.0.1:${port}`;
28
35
  }
36
+ /** specBaseUrl for a registration — the one call every dial site should make,
37
+ * so a recorded reachHost is never forgotten. */
38
+ export function productBaseUrl(reg) {
39
+ return specBaseUrl(reg.spec, reg.port, reg.reachHost);
40
+ }
29
41
  export async function fetchManifest(baseUrl) {
30
42
  let response;
31
43
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-sdk",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -1,5 +1,5 @@
1
1
  import { logger } from "@norskvideo/ctl-foundation";
2
- import { specBaseUrl } from "./manifest-fetch.js";
2
+ import { productBaseUrl } from "./manifest-fetch.js";
3
3
  const UNKNOWN = { status: "unknown", consecutiveFailures: 0, restartAttempts: 0 };
4
4
  /**
5
5
  * Periodically probes each container product's health endpoint and, after
@@ -123,7 +123,7 @@ export async function probeProductHealth(reg, timeoutMs = 3_000) {
123
123
  if (reg.spec.kind !== "container" || reg.port === undefined)
124
124
  return false;
125
125
  const path = reg.manifest.api?.healthCheckPath ?? "/healthz";
126
- const base = specBaseUrl(reg.spec, reg.port);
126
+ const base = productBaseUrl(reg);
127
127
  try {
128
128
  const r = await fetch(`${base}${path.startsWith("/") ? path : `/${path}`}`, {
129
129
  signal: AbortSignal.timeout(timeoutMs),
@@ -0,0 +1,9 @@
1
+ export type ProductReach = "host" | "container-address";
2
+ /** The 64-hex docker container id from a /proc/self/cgroup dump, or null on a
3
+ * bare host. Present in both cgroup v1 (`/docker/<id>`) and v2
4
+ * (`docker-<id>.scope`) layouts. */
5
+ export declare function parseContainerIdFromCgroup(text: string): string | null;
6
+ export declare function detectProductReach(opts: {
7
+ cgroup?: () => string;
8
+ env?: Record<string, string | undefined>;
9
+ }): ProductReach;
@@ -0,0 +1,32 @@
1
+ // How the daemon reaches a product's control-plane container. The container is
2
+ // loopback-published (`-p 127.0.0.1:<host port>:4321`), which is right when the
3
+ // daemon runs on the docker host. When the daemon itself runs in a container —
4
+ // a docker-outside-of-docker CI runner — that loopback belongs to the docker
5
+ // host, not to the daemon, and every registration times out at readiness. The
6
+ // container's own bridge address on the internal port is what such a daemon
7
+ // can dial, so a containerised daemon uses that and remembers it on the
8
+ // registration (`reachHost`). Detection reads the daemon's own cgroup, the
9
+ // same tell the test harness uses; NORSK_CTL_PRODUCT_REACH overrides it.
10
+ import { readFileSync } from "node:fs";
11
+ const CGROUP_PATH = "/proc/self/cgroup";
12
+ const REACH_ENV = "NORSK_CTL_PRODUCT_REACH";
13
+ /** The 64-hex docker container id from a /proc/self/cgroup dump, or null on a
14
+ * bare host. Present in both cgroup v1 (`/docker/<id>`) and v2
15
+ * (`docker-<id>.scope`) layouts. */
16
+ export function parseContainerIdFromCgroup(text) {
17
+ const m = text.match(/[0-9a-f]{64}/);
18
+ return m ? m[0] : null;
19
+ }
20
+ export function detectProductReach(opts) {
21
+ const env = opts.env ?? process.env;
22
+ const forced = env[REACH_ENV];
23
+ if (forced === "host" || forced === "container-address")
24
+ return forced;
25
+ const read = opts.cgroup ?? (() => readFileSync(CGROUP_PATH, "utf-8"));
26
+ try {
27
+ return parseContainerIdFromCgroup(read()) ? "container-address" : "host";
28
+ }
29
+ catch {
30
+ return "host";
31
+ }
32
+ }
@@ -1,4 +1,5 @@
1
1
  import { type LicenseStager } from "./license-registration.js";
2
+ import { type ProductReach } from "./product-reach.js";
2
3
  import type { ProductTemplateSource } from "./product-template-record.js";
3
4
  import type { ProductLicense, ProductRegistration, ProductSpec } from "./product-types.js";
4
5
  /**
@@ -83,6 +84,10 @@ export interface ProductContainerOps {
83
84
  remove(containerId: string): Promise<void>;
84
85
  rename(containerId: string, name: string): Promise<void>;
85
86
  waitForReady(baseUrl: string): Promise<void>;
87
+ /** The container's own address, consulted only under reach
88
+ * "container-address" (see product-reach.ts). Optional: a host without it
89
+ * falls back to the loopback host port. */
90
+ address?(containerId: string): Promise<string | undefined>;
86
91
  }
87
92
  /** The real docker-runner wiring. Exported so a host can override one op
88
93
  * (typically `pull`, onto its own docker adapter) and keep the rest. */
@@ -107,6 +112,11 @@ export interface ProductServiceOptions {
107
112
  /** Container lifecycle ops used by add/remove/restart/stopAll/restoreAll.
108
113
  * Injectable for tests; defaults to the real docker-runner functions. */
109
114
  containerOps?: ProductContainerOps;
115
+ /** How this daemon reaches a product container: the loopback host port
116
+ * (a host daemon) or the container's own address (a daemon that is itself
117
+ * in a container). Default: detected from the daemon's cgroup, overridable
118
+ * with NORSK_CTL_PRODUCT_REACH. */
119
+ reach?: ProductReach;
110
120
  }
111
121
  export declare class ProductService {
112
122
  /**
@@ -131,7 +141,11 @@ export declare class ProductService {
131
141
  private readonly stageLicense?;
132
142
  private readonly isDevUrlAlive;
133
143
  private readonly containerOps;
144
+ private readonly reach;
134
145
  constructor(opts: ProductServiceOptions);
146
+ /** The address to record for a freshly started container: its own, when
147
+ * this daemon cannot dial the host loopback; otherwise nothing. */
148
+ private reachHostFor;
135
149
  list(): ProductRegistration[];
136
150
  /** Docker's default `--pull=missing` keeps whatever a moving tag resolved to
137
151
  * last time, so a rebuilt `:latest`/`:dev` never reached a new registration.
@@ -1,11 +1,12 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { logger, Mutex } from "@norskvideo/ctl-foundation";
3
3
  import { validateDevUrl } from "./dev-url.js";
4
- import { dockerPull, dockerRename, dockerRm, dockerRun, isDigestRef, productContainerName } from "./docker-runner.js";
4
+ import { dockerAddress, dockerPull, dockerRename, dockerRm, dockerRun, isDigestRef, productContainerName, } from "./docker-runner.js";
5
5
  import { resolveLicenseFile } from "./license-registration.js";
6
6
  import { checkProductEntitlement, notV2EnvelopeMessage, parseLicenseContents } from "./license-v2.js";
7
- import { fetchManifest, isDevUrlAlive, probeConfigScreen, specBaseUrl, waitForReady } from "./manifest-fetch.js";
7
+ import { fetchManifest, isDevUrlAlive, probeConfigScreen, productBaseUrl, specBaseUrl, waitForReady, } from "./manifest-fetch.js";
8
8
  import { ProductError } from "./product-error.js";
9
+ import { detectProductReach } from "./product-reach.js";
9
10
  /** GET a product-template tar from a product. `url` is whatever the manifest
10
11
  * declared; we treat it as a path on the product's HTTP surface and
11
12
  * resolve against the product's base URL. Returns the raw tar bytes,
@@ -32,6 +33,7 @@ export const defaultProductContainerOps = {
32
33
  remove: dockerRm,
33
34
  rename: dockerRename,
34
35
  waitForReady,
36
+ address: dockerAddress,
35
37
  };
36
38
  export class ProductService {
37
39
  /**
@@ -56,6 +58,7 @@ export class ProductService {
56
58
  stageLicense;
57
59
  isDevUrlAlive;
58
60
  containerOps;
61
+ reach;
59
62
  constructor(opts) {
60
63
  this.store = opts.store;
61
64
  this.allocatePort = opts.allocatePort;
@@ -64,6 +67,14 @@ export class ProductService {
64
67
  this.stageLicense = opts.stageLicense;
65
68
  this.isDevUrlAlive = opts.isDevUrlAlive ?? isDevUrlAlive;
66
69
  this.containerOps = opts.containerOps ?? defaultProductContainerOps;
70
+ this.reach = opts.reach ?? detectProductReach({});
71
+ }
72
+ /** The address to record for a freshly started container: its own, when
73
+ * this daemon cannot dial the host loopback; otherwise nothing. */
74
+ async reachHostFor(containerId) {
75
+ if (this.reach !== "container-address" || !this.containerOps.address)
76
+ return undefined;
77
+ return this.containerOps.address(containerId);
67
78
  }
68
79
  list() {
69
80
  return this.store.read();
@@ -90,7 +101,7 @@ export class ProductService {
90
101
  async isRunning(reg) {
91
102
  if (reg.spec.kind === "container")
92
103
  return reg.containerId !== undefined;
93
- return this.isDevUrlAlive(specBaseUrl(reg.spec));
104
+ return this.isDevUrlAlive(productBaseUrl(reg));
94
105
  }
95
106
  async add(spec, opts = {}) {
96
107
  return this.mutations.run(() => this.addSerialised(spec, opts));
@@ -100,6 +111,7 @@ export class ProductService {
100
111
  let baseUrl;
101
112
  let port;
102
113
  let containerId;
114
+ let reachHost;
103
115
  if (spec.kind === "dev") {
104
116
  validateDevUrl(spec.url);
105
117
  baseUrl = specBaseUrl(spec);
@@ -117,7 +129,8 @@ export class ProductService {
117
129
  await this.refreshImage(spec.image);
118
130
  logger.info(`Starting product container: ${spec.image} on host port ${port}`);
119
131
  containerId = await this.containerOps.run(spec.image, port);
120
- baseUrl = specBaseUrl(spec, port);
132
+ reachHost = await this.reachHostFor(containerId);
133
+ baseUrl = specBaseUrl(spec, port, reachHost);
121
134
  }
122
135
  try {
123
136
  await this.containerOps.waitForReady(baseUrl);
@@ -221,6 +234,7 @@ export class ProductService {
221
234
  manifest,
222
235
  ...(port !== undefined ? { port } : {}),
223
236
  ...(containerId !== undefined ? { containerId } : {}),
237
+ ...(reachHost !== undefined ? { reachHost } : {}),
224
238
  ...(opts.license !== undefined
225
239
  ? {
226
240
  license: opts.license.mode === "byol" && licenseFile !== undefined
@@ -304,12 +318,14 @@ export class ProductService {
304
318
  }
305
319
  }
306
320
  const containerId = await this.containerOps.run(target.spec.image, target.port);
307
- await this.store.update((products) => products.map((p) => (p.name === name ? withContainerId(p, containerId) : p)));
308
- await this.containerOps.waitForReady(specBaseUrl(target.spec, target.port));
321
+ 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));
309
324
  await this.containerOps.rename(containerId, productContainerName(name));
310
325
  containerRestarted = true;
311
326
  }
312
- const baseUrl = specBaseUrl(target.spec, target.port);
327
+ const current = this.store.read().find((p) => p.name === name) ?? target;
328
+ const baseUrl = productBaseUrl(current);
313
329
  const manifest = await fetchManifest(baseUrl);
314
330
  if (manifest.name !== target.name) {
315
331
  throw new ProductError("NAME_CONFLICT", `manifest now reports name '${manifest.name}', was '${target.name}' — use remove + add`);
@@ -393,9 +409,10 @@ export class ProductService {
393
409
  }
394
410
  try {
395
411
  const containerId = await this.containerOps.run(reg.spec.image, reg.port);
396
- await this.containerOps.waitForReady(specBaseUrl(reg.spec, reg.port));
412
+ const reachHost = await this.reachHostFor(containerId);
413
+ await this.containerOps.waitForReady(specBaseUrl(reg.spec, reg.port, reachHost));
397
414
  await this.containerOps.rename(containerId, productContainerName(reg.name));
398
- restored.set(reg.name, containerId);
415
+ restored.set(reg.name, { containerId, reachHost });
399
416
  logger.info(`Product '${reg.name}' container restored on host port ${reg.port}`);
400
417
  }
401
418
  catch (e) {
@@ -403,7 +420,12 @@ export class ProductService {
403
420
  logger.warn(`Product '${reg.name}': failed to restore — ${e instanceof Error ? e.message : String(e)}`);
404
421
  }
405
422
  }));
406
- await this.store.update((products) => products.map((p) => (restored.has(p.name) ? withContainerId(p, restored.get(p.name)) : p)));
423
+ await this.store.update((products) => products.map((p) => {
424
+ if (!restored.has(p.name))
425
+ return p;
426
+ const r = restored.get(p.name);
427
+ return withContainerId(p, r?.containerId, r?.reachHost);
428
+ }));
407
429
  }
408
430
  /** Stop (if still present) and relaunch a single container-kind product,
409
431
  * recording the fresh containerId. Used by the health monitor to recover a
@@ -434,15 +456,20 @@ export class ProductService {
434
456
  }
435
457
  }
436
458
  const containerId = await this.containerOps.run(target.spec.image, target.port);
437
- await this.store.update((products) => products.map((p) => (p.name === name ? withContainerId(p, containerId) : p)));
438
- await this.containerOps.waitForReady(specBaseUrl(target.spec, target.port));
459
+ 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));
439
462
  await this.containerOps.rename(containerId, productContainerName(name));
440
463
  logger.info(`Product '${name}' container restarted on host port ${target.port}`);
441
464
  }
442
465
  }
443
- /** Return a copy of `reg` with containerId set (or removed when undefined),
444
- * preserving the "omit the key entirely" shape the store round-trips. */
445
- function withContainerId(reg, containerId) {
446
- const { containerId: _drop, ...rest } = reg;
447
- return containerId === undefined ? rest : { ...rest, containerId };
466
+ /** Return a copy of `reg` with containerId (and the address it was reached at)
467
+ * set, or both removed when undefined, preserving the "omit the key entirely"
468
+ * shape the store round-trips. A stale reachHost would point the health
469
+ * monitor at a container that no longer exists, so it travels with the id. */
470
+ function withContainerId(reg, containerId, reachHost) {
471
+ const { containerId: _drop, reachHost: _dropHost, ...rest } = reg;
472
+ if (containerId === undefined)
473
+ return rest;
474
+ return { ...rest, containerId, ...(reachHost !== undefined ? { reachHost } : {}) };
448
475
  }
@@ -25,6 +25,10 @@ export interface ProductRegistration {
25
25
  port?: number;
26
26
  /** Docker container ID (container mode only). */
27
27
  containerId?: string;
28
+ /** The container's own address, recorded when the daemon runs in a
29
+ * container and cannot dial the loopback-published host port (see
30
+ * product-reach.ts). Absent on a host daemon: 127.0.0.1:<port> is right. */
31
+ reachHost?: string;
28
32
  /** Absent on registrations that predate per-product licenses — launches
29
33
  * fall back to the host's global license setting. */
30
34
  license?: ProductLicense;
@@ -1,8 +1,9 @@
1
1
  import { logger } from "@norskvideo/ctl-foundation";
2
2
  import { buildForwardHeaders, forwardUpstreamResponse, readRequestBody } from "./http-proxy.js";
3
+ import { productBaseUrl } from "./manifest-fetch.js";
3
4
  function targetBaseUrl(reg) {
4
5
  if (reg.spec.kind === "container") {
5
- return reg.port !== undefined ? `http://127.0.0.1:${reg.port}` : null;
6
+ return reg.port !== undefined ? productBaseUrl(reg) : null;
6
7
  }
7
8
  return reg.spec.url.replace(/\/$/, "");
8
9
  }