@norskvideo/ctl-sdk 0.1.18 → 0.1.20

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.
@@ -11,6 +11,10 @@ export declare function productRunArgs(image: string, hostPort: number): string[
11
11
  * `docker ps` shows `norsk-product-studio` instead of a random docker alias.
12
12
  * Mirrors the singleton naming of norsk-proxy / norsk-ctl-cpu-monitor. */
13
13
  export declare function productContainerName(productName: string): string;
14
+ /** An `@sha256:...` (or any `@algo:hex`) reference names one immutable image,
15
+ * so there is nothing to refresh; a tag can move under a registration. */
16
+ export declare function isDigestRef(image: string): boolean;
17
+ export declare function dockerPull(image: string): Promise<void>;
14
18
  export declare function dockerRun(image: string, hostPort: number): Promise<string>;
15
19
  export declare function dockerRm(containerId: string): Promise<void>;
16
20
  /** Best-effort rename of a running container. The product is tracked by
package/docker-runner.js CHANGED
@@ -30,6 +30,19 @@ export function productContainerName(productName) {
30
30
  .replace(/^-+|-+$/g, "");
31
31
  return `norsk-product-${slug || "unnamed"}`;
32
32
  }
33
+ /** An `@sha256:...` (or any `@algo:hex`) reference names one immutable image,
34
+ * so there is nothing to refresh; a tag can move under a registration. */
35
+ export function isDigestRef(image) {
36
+ return /@[a-z0-9]+:[0-9a-f]+$/i.test(image);
37
+ }
38
+ export async function dockerPull(image) {
39
+ const proc = Bun.spawn(["docker", "pull", image], { stdout: "pipe", stderr: "pipe" });
40
+ const exitCode = await proc.exited;
41
+ const stderr = (await new Response(proc.stderr).text()).trim();
42
+ if (exitCode !== 0) {
43
+ throw new ProductError("DOCKER_PULL_FAILED", `docker pull failed (exit ${exitCode}): ${stderr || "no stderr"}`);
44
+ }
45
+ }
33
46
  export async function dockerRun(image, hostPort) {
34
47
  const proc = Bun.spawn(["docker", ...productRunArgs(image, hostPort)], { stdout: "pipe", stderr: "pipe" });
35
48
  const exitCode = await proc.exited;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-sdk",
3
- "version": "0.1.18",
3
+ "version": "0.1.20",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -1,4 +1,4 @@
1
1
  export declare class ProductError extends Error {
2
- code: "DEV_URL_INVALID" | "DEV_URL_NOT_LOCALHOST" | "PORT_EXHAUSTED" | "DOCKER_RUN_FAILED" | "READINESS_TIMEOUT" | "MANIFEST_FETCH_FAILED" | "MANIFEST_INVALID" | "CONFIG_SCREEN_UNREACHABLE" | "CONFIG_SCREEN_DEV_SERVER" | "LICENSE_INVALID" | "NAME_CONFLICT" | "NOT_FOUND" | "NOT_RESTARTABLE" | "PRODUCT_TEMPLATE_FETCH_FAILED";
3
- constructor(code: "DEV_URL_INVALID" | "DEV_URL_NOT_LOCALHOST" | "PORT_EXHAUSTED" | "DOCKER_RUN_FAILED" | "READINESS_TIMEOUT" | "MANIFEST_FETCH_FAILED" | "MANIFEST_INVALID" | "CONFIG_SCREEN_UNREACHABLE" | "CONFIG_SCREEN_DEV_SERVER" | "LICENSE_INVALID" | "NAME_CONFLICT" | "NOT_FOUND" | "NOT_RESTARTABLE" | "PRODUCT_TEMPLATE_FETCH_FAILED", message: string);
2
+ code: "DEV_URL_INVALID" | "DEV_URL_NOT_LOCALHOST" | "PORT_EXHAUSTED" | "DOCKER_PULL_FAILED" | "DOCKER_RUN_FAILED" | "READINESS_TIMEOUT" | "MANIFEST_FETCH_FAILED" | "MANIFEST_INVALID" | "CONFIG_SCREEN_UNREACHABLE" | "CONFIG_SCREEN_DEV_SERVER" | "LICENSE_INVALID" | "NAME_CONFLICT" | "NOT_FOUND" | "NOT_RESTARTABLE" | "PRODUCT_TEMPLATE_FETCH_FAILED";
3
+ constructor(code: "DEV_URL_INVALID" | "DEV_URL_NOT_LOCALHOST" | "PORT_EXHAUSTED" | "DOCKER_PULL_FAILED" | "DOCKER_RUN_FAILED" | "READINESS_TIMEOUT" | "MANIFEST_FETCH_FAILED" | "MANIFEST_INVALID" | "CONFIG_SCREEN_UNREACHABLE" | "CONFIG_SCREEN_DEV_SERVER" | "LICENSE_INVALID" | "NAME_CONFLICT" | "NOT_FOUND" | "NOT_RESTARTABLE" | "PRODUCT_TEMPLATE_FETCH_FAILED", message: string);
4
4
  }
@@ -11,6 +11,22 @@ export interface AddProductResult {
11
11
  registration: ProductRegistration;
12
12
  warnings: string[];
13
13
  }
14
+ /** Result of `reload`. `containerRestarted` is false for dev products, which
15
+ * are externally owned. `productTemplates` reports the manifest-declared
16
+ * default templates handed to the host's refresh callback: `skipped` carries
17
+ * the host's reason (typically "in use"), and every skip is also a warning. */
18
+ export interface ReloadProductResult {
19
+ registration: ProductRegistration;
20
+ warnings: string[];
21
+ containerRestarted: boolean;
22
+ productTemplates: {
23
+ refreshed: string[];
24
+ skipped: Array<{
25
+ name: string;
26
+ reason: string;
27
+ }>;
28
+ };
29
+ }
14
30
  export interface AddProductOpts {
15
31
  /** Per-product license stored on the registration record (#313). Callers
16
32
  * typically seed this from their global license setting when the operator
@@ -49,17 +65,28 @@ export type ImportProductTemplateBytesFn = (opts: {
49
65
  kind: "product-default";
50
66
  }>;
51
67
  }) => Promise<void>;
68
+ /** `reload`'s counterpart to ImportProductTemplateBytesFn: re-render the
69
+ * stored snapshot of a product-default template from fresh bytes, or store
70
+ * it if the product only started declaring it. The host owns the refusal
71
+ * rules (a template an instance is using, one that is not product-default);
72
+ * it throws, and the SDK reports the message as a skip rather than failing
73
+ * the reload. */
74
+ export type RefreshProductTemplateBytesFn = ImportProductTemplateBytesFn;
52
75
  /** Every container operation this service performs, behind an interface so
53
76
  * tests can drive them without shelling out to Docker. Defaults wire straight
54
77
  * to docker-runner + the readiness probe. Reach for these rather than the
55
78
  * module functions: calling docker-runner directly re-creates the bypass that
56
79
  * made an injected fake silently inert on add/remove. */
57
80
  export interface ProductContainerOps {
81
+ pull(image: string): Promise<void>;
58
82
  run(image: string, hostPort: number): Promise<string>;
59
83
  remove(containerId: string): Promise<void>;
60
84
  rename(containerId: string, name: string): Promise<void>;
61
85
  waitForReady(baseUrl: string): Promise<void>;
62
86
  }
87
+ /** The real docker-runner wiring. Exported so a host can override one op
88
+ * (typically `pull`, onto its own docker adapter) and keep the rest. */
89
+ export declare const defaultProductContainerOps: ProductContainerOps;
63
90
  export interface ProductServiceOptions {
64
91
  store: ProductStore;
65
92
  allocatePort: AllocatePortFn;
@@ -67,6 +94,9 @@ export interface ProductServiceOptions {
67
94
  * registration time. Omit on hosts that don't store product templates
68
95
  * (defaultProductTemplates is then silently skipped). */
69
96
  importProductTemplateBytes?: ImportProductTemplateBytesFn;
97
+ /** Optional: called once per `manifest.defaultProductTemplates` entry by
98
+ * `reload`. Omit on hosts that don't store product templates. */
99
+ refreshProductTemplateBytes?: RefreshProductTemplateBytesFn;
70
100
  /** Optional: copy a byol licence somewhere the host owns, so the path
71
101
  * recorded in the registry outlives whatever the operator passed in.
72
102
  * Omit on hosts that don't stage (the operator's path is then recorded). */
@@ -97,11 +127,17 @@ export declare class ProductService {
97
127
  private readonly store;
98
128
  private readonly allocatePort;
99
129
  private readonly importProductTemplateBytes?;
130
+ private readonly refreshProductTemplateBytes?;
100
131
  private readonly stageLicense?;
101
132
  private readonly isDevUrlAlive;
102
133
  private readonly containerOps;
103
134
  constructor(opts: ProductServiceOptions);
104
135
  list(): ProductRegistration[];
136
+ /** Docker's default `--pull=missing` keeps whatever a moving tag resolved to
137
+ * last time, so a rebuilt `:latest`/`:dev` never reached a new registration.
138
+ * Best-effort: a locally built image is in no registry, and the run that
139
+ * follows must still get its turn. */
140
+ private refreshImage;
105
141
  /** Whether the product is actually up. Container-mode: we started it, so a
106
142
  * tracked containerId means running. Dev-mode: externally owned, so probe
107
143
  * the dev URL — registered no longer implies running. */
@@ -110,7 +146,15 @@ export declare class ProductService {
110
146
  private addSerialised;
111
147
  remove(name: string): Promise<void>;
112
148
  private removeSerialised;
113
- reload(name: string): Promise<ProductRegistration>;
149
+ /** Bring a registration up to date with what its image now serves. For a
150
+ * container product: pull the tag (digests cannot move), re-run the
151
+ * control plane on its recorded port, re-read the manifest, then hand every
152
+ * manifest-declared default template to the host to re-render. Dev products
153
+ * are externally owned, so only the manifest is re-read. This is the verb
154
+ * the per-product "remove template, remove product, add again" scripts
155
+ * stood in for. Like restart, the new container id is persisted before the
156
+ * readiness wait so a timed-out reload leaves a tracked container. */
157
+ reload(name: string): Promise<ReloadProductResult>;
114
158
  private reloadSerialised;
115
159
  /** Stop every running container-kind product and clear its tracked
116
160
  * containerId (model B: the daemon owns container lifecycle, so it reaps
@@ -1,7 +1,7 @@
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 { dockerRename, dockerRm, dockerRun, productContainerName } from "./docker-runner.js";
4
+ import { 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
7
  import { fetchManifest, isDevUrlAlive, probeConfigScreen, specBaseUrl, waitForReady } from "./manifest-fetch.js";
@@ -24,7 +24,10 @@ async function fetchProductTemplateBytes(baseUrl, url) {
24
24
  }
25
25
  return new Uint8Array(await response.arrayBuffer());
26
26
  }
27
- const defaultContainerOps = {
27
+ /** The real docker-runner wiring. Exported so a host can override one op
28
+ * (typically `pull`, onto its own docker adapter) and keep the rest. */
29
+ export const defaultProductContainerOps = {
30
+ pull: dockerPull,
28
31
  run: dockerRun,
29
32
  remove: dockerRm,
30
33
  rename: dockerRename,
@@ -49,6 +52,7 @@ export class ProductService {
49
52
  store;
50
53
  allocatePort;
51
54
  importProductTemplateBytes;
55
+ refreshProductTemplateBytes;
52
56
  stageLicense;
53
57
  isDevUrlAlive;
54
58
  containerOps;
@@ -56,13 +60,30 @@ export class ProductService {
56
60
  this.store = opts.store;
57
61
  this.allocatePort = opts.allocatePort;
58
62
  this.importProductTemplateBytes = opts.importProductTemplateBytes;
63
+ this.refreshProductTemplateBytes = opts.refreshProductTemplateBytes;
59
64
  this.stageLicense = opts.stageLicense;
60
65
  this.isDevUrlAlive = opts.isDevUrlAlive ?? isDevUrlAlive;
61
- this.containerOps = opts.containerOps ?? defaultContainerOps;
66
+ this.containerOps = opts.containerOps ?? defaultProductContainerOps;
62
67
  }
63
68
  list() {
64
69
  return this.store.read();
65
70
  }
71
+ /** Docker's default `--pull=missing` keeps whatever a moving tag resolved to
72
+ * last time, so a rebuilt `:latest`/`:dev` never reached a new registration.
73
+ * Best-effort: a locally built image is in no registry, and the run that
74
+ * follows must still get its turn. */
75
+ async refreshImage(image) {
76
+ if (isDigestRef(image))
77
+ return;
78
+ logger.info(`Pulling product image: ${image}`);
79
+ try {
80
+ await this.containerOps.pull(image);
81
+ }
82
+ catch (e) {
83
+ const msg = e instanceof Error ? e.message : String(e);
84
+ logger.warn(`Pull of ${image} did not complete (${msg}); continuing with the local image if present`);
85
+ }
86
+ }
66
87
  /** Whether the product is actually up. Container-mode: we started it, so a
67
88
  * tracked containerId means running. Dev-mode: externally owned, so probe
68
89
  * the dev URL — registered no longer implies running. */
@@ -93,6 +114,7 @@ export class ProductService {
93
114
  throw new ProductError("PORT_EXHAUSTED", `no free port available for new product`);
94
115
  }
95
116
  port = allocated;
117
+ await this.refreshImage(spec.image);
96
118
  logger.info(`Starting product container: ${spec.image} on host port ${port}`);
97
119
  containerId = await this.containerOps.run(spec.image, port);
98
120
  baseUrl = specBaseUrl(spec, port);
@@ -252,23 +274,76 @@ export class ProductService {
252
274
  await this.store.update((products) => products.filter((p) => p.name !== name));
253
275
  logger.info(`Product '${name}' removed`);
254
276
  }
277
+ /** Bring a registration up to date with what its image now serves. For a
278
+ * container product: pull the tag (digests cannot move), re-run the
279
+ * control plane on its recorded port, re-read the manifest, then hand every
280
+ * manifest-declared default template to the host to re-render. Dev products
281
+ * are externally owned, so only the manifest is re-read. This is the verb
282
+ * the per-product "remove template, remove product, add again" scripts
283
+ * stood in for. Like restart, the new container id is persisted before the
284
+ * readiness wait so a timed-out reload leaves a tracked container. */
255
285
  async reload(name) {
256
286
  return this.mutations.run(() => this.reloadSerialised(name));
257
287
  }
258
288
  async reloadSerialised(name) {
259
- const existing = this.store.read();
260
- const target = existing.find((p) => p.name === name);
289
+ const target = this.store.read().find((p) => p.name === name);
261
290
  if (!target)
262
291
  throw new ProductError("NOT_FOUND", `product '${name}' not registered`);
292
+ let containerRestarted = false;
293
+ if (target.spec.kind === "container") {
294
+ if (target.port === undefined) {
295
+ throw new ProductError("NOT_RESTARTABLE", `product '${name}' has no recorded port — cannot reload`);
296
+ }
297
+ await this.refreshImage(target.spec.image);
298
+ if (target.containerId) {
299
+ try {
300
+ await this.containerOps.remove(target.containerId);
301
+ }
302
+ catch {
303
+ // Already gone (crashed / --rm reaped) — nothing to stop.
304
+ }
305
+ }
306
+ 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));
309
+ await this.containerOps.rename(containerId, productContainerName(name));
310
+ containerRestarted = true;
311
+ }
263
312
  const baseUrl = specBaseUrl(target.spec, target.port);
264
313
  const manifest = await fetchManifest(baseUrl);
265
314
  if (manifest.name !== target.name) {
266
315
  throw new ProductError("NAME_CONFLICT", `manifest now reports name '${manifest.name}', was '${target.name}' — use remove + add`);
267
316
  }
268
- const next = { ...target, manifest };
269
- await this.store.update((products) => products.map((p) => (p.name === name ? next : p)));
270
- logger.info(`Product '${name}' manifest reloaded`);
271
- return next;
317
+ const updated = await this.store.update((products) => products.map((p) => (p.name === name ? { ...p, manifest } : p)));
318
+ const registration = updated.find((p) => p.name === name);
319
+ logger.info(`Product '${name}' reloaded${containerRestarted ? " (container re-run)" : ""}`);
320
+ // Same downgrade as add's default-template import: the product itself is
321
+ // healthy, so a template the host refuses (in use, foreign) or a fetch
322
+ // that fails is a warning the operator acts on, not a failed reload.
323
+ const warnings = [];
324
+ const productTemplates = { refreshed: [], skipped: [] };
325
+ if (this.refreshProductTemplateBytes) {
326
+ for (const entry of manifest.defaultProductTemplates) {
327
+ try {
328
+ const bytes = await fetchProductTemplateBytes(baseUrl, entry.url);
329
+ await this.refreshProductTemplateBytes({
330
+ name: entry.name,
331
+ bytes,
332
+ source: { kind: "product-default", productName: manifest.name, url: entry.url },
333
+ });
334
+ productTemplates.refreshed.push(entry.name);
335
+ logger.info(`Product '${name}': refreshed default product template '${entry.name}' from ${entry.url}`);
336
+ }
337
+ catch (e) {
338
+ const reason = e instanceof Error ? e.message : String(e);
339
+ productTemplates.skipped.push({ name: entry.name, reason });
340
+ warnings.push(`default product template '${entry.name}' not refreshed: ${reason}`);
341
+ }
342
+ }
343
+ }
344
+ for (const w of warnings)
345
+ logger.warn(`[product:${name}] ${w}`);
346
+ return { registration, warnings, containerRestarted, productTemplates };
272
347
  }
273
348
  /** Stop every running container-kind product and clear its tracked
274
349
  * containerId (model B: the daemon owns container lifecycle, so it reaps