@norskvideo/ctl-sdk 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.
@@ -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;
package/index.d.ts CHANGED
@@ -12,6 +12,7 @@ export * from "./manifest-fetch.js";
12
12
  export * from "./manifest-router.js";
13
13
  export * from "./openapi-router.js";
14
14
  export * from "./product-health-monitor.js";
15
+ export * from "./product-reach.js";
15
16
  export * from "./product-service.js";
16
17
  export * from "./product-template-materials.js";
17
18
  export * from "./proxy-middleware.js";
package/index.js CHANGED
@@ -14,6 +14,7 @@ export * from "./manifest-fetch.js";
14
14
  export * from "./manifest-router.js";
15
15
  export * from "./openapi-router.js";
16
16
  export * from "./product-health-monitor.js";
17
+ export * from "./product-reach.js";
17
18
  export * from "./product-service.js";
18
19
  export * from "./product-template-materials.js";
19
20
  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.19",
3
+ "version": "0.1.21",
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
  /**
@@ -11,6 +12,22 @@ export interface AddProductResult {
11
12
  registration: ProductRegistration;
12
13
  warnings: string[];
13
14
  }
15
+ /** Result of `reload`. `containerRestarted` is false for dev products, which
16
+ * are externally owned. `productTemplates` reports the manifest-declared
17
+ * default templates handed to the host's refresh callback: `skipped` carries
18
+ * the host's reason (typically "in use"), and every skip is also a warning. */
19
+ export interface ReloadProductResult {
20
+ registration: ProductRegistration;
21
+ warnings: string[];
22
+ containerRestarted: boolean;
23
+ productTemplates: {
24
+ refreshed: string[];
25
+ skipped: Array<{
26
+ name: string;
27
+ reason: string;
28
+ }>;
29
+ };
30
+ }
14
31
  export interface AddProductOpts {
15
32
  /** Per-product license stored on the registration record (#313). Callers
16
33
  * typically seed this from their global license setting when the operator
@@ -49,6 +66,13 @@ export type ImportProductTemplateBytesFn = (opts: {
49
66
  kind: "product-default";
50
67
  }>;
51
68
  }) => Promise<void>;
69
+ /** `reload`'s counterpart to ImportProductTemplateBytesFn: re-render the
70
+ * stored snapshot of a product-default template from fresh bytes, or store
71
+ * it if the product only started declaring it. The host owns the refusal
72
+ * rules (a template an instance is using, one that is not product-default);
73
+ * it throws, and the SDK reports the message as a skip rather than failing
74
+ * the reload. */
75
+ export type RefreshProductTemplateBytesFn = ImportProductTemplateBytesFn;
52
76
  /** Every container operation this service performs, behind an interface so
53
77
  * tests can drive them without shelling out to Docker. Defaults wire straight
54
78
  * to docker-runner + the readiness probe. Reach for these rather than the
@@ -60,6 +84,10 @@ export interface ProductContainerOps {
60
84
  remove(containerId: string): Promise<void>;
61
85
  rename(containerId: string, name: string): Promise<void>;
62
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>;
63
91
  }
64
92
  /** The real docker-runner wiring. Exported so a host can override one op
65
93
  * (typically `pull`, onto its own docker adapter) and keep the rest. */
@@ -71,6 +99,9 @@ export interface ProductServiceOptions {
71
99
  * registration time. Omit on hosts that don't store product templates
72
100
  * (defaultProductTemplates is then silently skipped). */
73
101
  importProductTemplateBytes?: ImportProductTemplateBytesFn;
102
+ /** Optional: called once per `manifest.defaultProductTemplates` entry by
103
+ * `reload`. Omit on hosts that don't store product templates. */
104
+ refreshProductTemplateBytes?: RefreshProductTemplateBytesFn;
74
105
  /** Optional: copy a byol licence somewhere the host owns, so the path
75
106
  * recorded in the registry outlives whatever the operator passed in.
76
107
  * Omit on hosts that don't stage (the operator's path is then recorded). */
@@ -81,6 +112,11 @@ export interface ProductServiceOptions {
81
112
  /** Container lifecycle ops used by add/remove/restart/stopAll/restoreAll.
82
113
  * Injectable for tests; defaults to the real docker-runner functions. */
83
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;
84
120
  }
85
121
  export declare class ProductService {
86
122
  /**
@@ -101,10 +137,15 @@ export declare class ProductService {
101
137
  private readonly store;
102
138
  private readonly allocatePort;
103
139
  private readonly importProductTemplateBytes?;
140
+ private readonly refreshProductTemplateBytes?;
104
141
  private readonly stageLicense?;
105
142
  private readonly isDevUrlAlive;
106
143
  private readonly containerOps;
144
+ private readonly reach;
107
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;
108
149
  list(): ProductRegistration[];
109
150
  /** Docker's default `--pull=missing` keeps whatever a moving tag resolved to
110
151
  * last time, so a rebuilt `:latest`/`:dev` never reached a new registration.
@@ -119,7 +160,15 @@ export declare class ProductService {
119
160
  private addSerialised;
120
161
  remove(name: string): Promise<void>;
121
162
  private removeSerialised;
122
- reload(name: string): Promise<ProductRegistration>;
163
+ /** Bring a registration up to date with what its image now serves. For a
164
+ * container product: pull the tag (digests cannot move), re-run the
165
+ * control plane on its recorded port, re-read the manifest, then hand every
166
+ * manifest-declared default template to the host to re-render. Dev products
167
+ * are externally owned, so only the manifest is re-read. This is the verb
168
+ * the per-product "remove template, remove product, add again" scripts
169
+ * stood in for. Like restart, the new container id is persisted before the
170
+ * readiness wait so a timed-out reload leaves a tracked container. */
171
+ reload(name: string): Promise<ReloadProductResult>;
123
172
  private reloadSerialised;
124
173
  /** Stop every running container-kind product and clear its tracked
125
174
  * containerId (model B: the daemon owns container lifecycle, so it reaps
@@ -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
  /**
@@ -52,16 +54,27 @@ export class ProductService {
52
54
  store;
53
55
  allocatePort;
54
56
  importProductTemplateBytes;
57
+ refreshProductTemplateBytes;
55
58
  stageLicense;
56
59
  isDevUrlAlive;
57
60
  containerOps;
61
+ reach;
58
62
  constructor(opts) {
59
63
  this.store = opts.store;
60
64
  this.allocatePort = opts.allocatePort;
61
65
  this.importProductTemplateBytes = opts.importProductTemplateBytes;
66
+ this.refreshProductTemplateBytes = opts.refreshProductTemplateBytes;
62
67
  this.stageLicense = opts.stageLicense;
63
68
  this.isDevUrlAlive = opts.isDevUrlAlive ?? isDevUrlAlive;
64
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);
65
78
  }
66
79
  list() {
67
80
  return this.store.read();
@@ -88,7 +101,7 @@ export class ProductService {
88
101
  async isRunning(reg) {
89
102
  if (reg.spec.kind === "container")
90
103
  return reg.containerId !== undefined;
91
- return this.isDevUrlAlive(specBaseUrl(reg.spec));
104
+ return this.isDevUrlAlive(productBaseUrl(reg));
92
105
  }
93
106
  async add(spec, opts = {}) {
94
107
  return this.mutations.run(() => this.addSerialised(spec, opts));
@@ -98,6 +111,7 @@ export class ProductService {
98
111
  let baseUrl;
99
112
  let port;
100
113
  let containerId;
114
+ let reachHost;
101
115
  if (spec.kind === "dev") {
102
116
  validateDevUrl(spec.url);
103
117
  baseUrl = specBaseUrl(spec);
@@ -115,7 +129,8 @@ export class ProductService {
115
129
  await this.refreshImage(spec.image);
116
130
  logger.info(`Starting product container: ${spec.image} on host port ${port}`);
117
131
  containerId = await this.containerOps.run(spec.image, port);
118
- baseUrl = specBaseUrl(spec, port);
132
+ reachHost = await this.reachHostFor(containerId);
133
+ baseUrl = specBaseUrl(spec, port, reachHost);
119
134
  }
120
135
  try {
121
136
  await this.containerOps.waitForReady(baseUrl);
@@ -219,6 +234,7 @@ export class ProductService {
219
234
  manifest,
220
235
  ...(port !== undefined ? { port } : {}),
221
236
  ...(containerId !== undefined ? { containerId } : {}),
237
+ ...(reachHost !== undefined ? { reachHost } : {}),
222
238
  ...(opts.license !== undefined
223
239
  ? {
224
240
  license: opts.license.mode === "byol" && licenseFile !== undefined
@@ -272,23 +288,78 @@ export class ProductService {
272
288
  await this.store.update((products) => products.filter((p) => p.name !== name));
273
289
  logger.info(`Product '${name}' removed`);
274
290
  }
291
+ /** Bring a registration up to date with what its image now serves. For a
292
+ * container product: pull the tag (digests cannot move), re-run the
293
+ * control plane on its recorded port, re-read the manifest, then hand every
294
+ * manifest-declared default template to the host to re-render. Dev products
295
+ * are externally owned, so only the manifest is re-read. This is the verb
296
+ * the per-product "remove template, remove product, add again" scripts
297
+ * stood in for. Like restart, the new container id is persisted before the
298
+ * readiness wait so a timed-out reload leaves a tracked container. */
275
299
  async reload(name) {
276
300
  return this.mutations.run(() => this.reloadSerialised(name));
277
301
  }
278
302
  async reloadSerialised(name) {
279
- const existing = this.store.read();
280
- const target = existing.find((p) => p.name === name);
303
+ const target = this.store.read().find((p) => p.name === name);
281
304
  if (!target)
282
305
  throw new ProductError("NOT_FOUND", `product '${name}' not registered`);
283
- const baseUrl = specBaseUrl(target.spec, target.port);
306
+ let containerRestarted = false;
307
+ if (target.spec.kind === "container") {
308
+ if (target.port === undefined) {
309
+ throw new ProductError("NOT_RESTARTABLE", `product '${name}' has no recorded port — cannot reload`);
310
+ }
311
+ await this.refreshImage(target.spec.image);
312
+ if (target.containerId) {
313
+ try {
314
+ await this.containerOps.remove(target.containerId);
315
+ }
316
+ catch {
317
+ // Already gone (crashed / --rm reaped) — nothing to stop.
318
+ }
319
+ }
320
+ const containerId = await this.containerOps.run(target.spec.image, 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));
324
+ await this.containerOps.rename(containerId, productContainerName(name));
325
+ containerRestarted = true;
326
+ }
327
+ const current = this.store.read().find((p) => p.name === name) ?? target;
328
+ const baseUrl = productBaseUrl(current);
284
329
  const manifest = await fetchManifest(baseUrl);
285
330
  if (manifest.name !== target.name) {
286
331
  throw new ProductError("NAME_CONFLICT", `manifest now reports name '${manifest.name}', was '${target.name}' — use remove + add`);
287
332
  }
288
- const next = { ...target, manifest };
289
- await this.store.update((products) => products.map((p) => (p.name === name ? next : p)));
290
- logger.info(`Product '${name}' manifest reloaded`);
291
- return next;
333
+ const updated = await this.store.update((products) => products.map((p) => (p.name === name ? { ...p, manifest } : p)));
334
+ const registration = updated.find((p) => p.name === name);
335
+ logger.info(`Product '${name}' reloaded${containerRestarted ? " (container re-run)" : ""}`);
336
+ // Same downgrade as add's default-template import: the product itself is
337
+ // healthy, so a template the host refuses (in use, foreign) or a fetch
338
+ // that fails is a warning the operator acts on, not a failed reload.
339
+ const warnings = [];
340
+ const productTemplates = { refreshed: [], skipped: [] };
341
+ if (this.refreshProductTemplateBytes) {
342
+ for (const entry of manifest.defaultProductTemplates) {
343
+ try {
344
+ const bytes = await fetchProductTemplateBytes(baseUrl, entry.url);
345
+ await this.refreshProductTemplateBytes({
346
+ name: entry.name,
347
+ bytes,
348
+ source: { kind: "product-default", productName: manifest.name, url: entry.url },
349
+ });
350
+ productTemplates.refreshed.push(entry.name);
351
+ logger.info(`Product '${name}': refreshed default product template '${entry.name}' from ${entry.url}`);
352
+ }
353
+ catch (e) {
354
+ const reason = e instanceof Error ? e.message : String(e);
355
+ productTemplates.skipped.push({ name: entry.name, reason });
356
+ warnings.push(`default product template '${entry.name}' not refreshed: ${reason}`);
357
+ }
358
+ }
359
+ }
360
+ for (const w of warnings)
361
+ logger.warn(`[product:${name}] ${w}`);
362
+ return { registration, warnings, containerRestarted, productTemplates };
292
363
  }
293
364
  /** Stop every running container-kind product and clear its tracked
294
365
  * containerId (model B: the daemon owns container lifecycle, so it reaps
@@ -338,9 +409,10 @@ export class ProductService {
338
409
  }
339
410
  try {
340
411
  const containerId = await this.containerOps.run(reg.spec.image, reg.port);
341
- 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));
342
414
  await this.containerOps.rename(containerId, productContainerName(reg.name));
343
- restored.set(reg.name, containerId);
415
+ restored.set(reg.name, { containerId, reachHost });
344
416
  logger.info(`Product '${reg.name}' container restored on host port ${reg.port}`);
345
417
  }
346
418
  catch (e) {
@@ -348,7 +420,12 @@ export class ProductService {
348
420
  logger.warn(`Product '${reg.name}': failed to restore — ${e instanceof Error ? e.message : String(e)}`);
349
421
  }
350
422
  }));
351
- 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
+ }));
352
429
  }
353
430
  /** Stop (if still present) and relaunch a single container-kind product,
354
431
  * recording the fresh containerId. Used by the health monitor to recover a
@@ -379,15 +456,20 @@ export class ProductService {
379
456
  }
380
457
  }
381
458
  const containerId = await this.containerOps.run(target.spec.image, target.port);
382
- await this.store.update((products) => products.map((p) => (p.name === name ? withContainerId(p, containerId) : p)));
383
- 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));
384
462
  await this.containerOps.rename(containerId, productContainerName(name));
385
463
  logger.info(`Product '${name}' container restarted on host port ${target.port}`);
386
464
  }
387
465
  }
388
- /** Return a copy of `reg` with containerId set (or removed when undefined),
389
- * preserving the "omit the key entirely" shape the store round-trips. */
390
- function withContainerId(reg, containerId) {
391
- const { containerId: _drop, ...rest } = reg;
392
- 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 } : {}) };
393
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
  }