@norskvideo/ctl-sdk 0.1.19 → 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.
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.20",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -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,6 +65,13 @@ 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
@@ -71,6 +94,9 @@ export interface ProductServiceOptions {
71
94
  * registration time. Omit on hosts that don't store product templates
72
95
  * (defaultProductTemplates is then silently skipped). */
73
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;
74
100
  /** Optional: copy a byol licence somewhere the host owns, so the path
75
101
  * recorded in the registry outlives whatever the operator passed in.
76
102
  * Omit on hosts that don't stage (the operator's path is then recorded). */
@@ -101,6 +127,7 @@ export declare class ProductService {
101
127
  private readonly store;
102
128
  private readonly allocatePort;
103
129
  private readonly importProductTemplateBytes?;
130
+ private readonly refreshProductTemplateBytes?;
104
131
  private readonly stageLicense?;
105
132
  private readonly isDevUrlAlive;
106
133
  private readonly containerOps;
@@ -119,7 +146,15 @@ export declare class ProductService {
119
146
  private addSerialised;
120
147
  remove(name: string): Promise<void>;
121
148
  private removeSerialised;
122
- 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>;
123
158
  private reloadSerialised;
124
159
  /** Stop every running container-kind product and clear its tracked
125
160
  * containerId (model B: the daemon owns container lifecycle, so it reaps
@@ -52,6 +52,7 @@ export class ProductService {
52
52
  store;
53
53
  allocatePort;
54
54
  importProductTemplateBytes;
55
+ refreshProductTemplateBytes;
55
56
  stageLicense;
56
57
  isDevUrlAlive;
57
58
  containerOps;
@@ -59,6 +60,7 @@ export class ProductService {
59
60
  this.store = opts.store;
60
61
  this.allocatePort = opts.allocatePort;
61
62
  this.importProductTemplateBytes = opts.importProductTemplateBytes;
63
+ this.refreshProductTemplateBytes = opts.refreshProductTemplateBytes;
62
64
  this.stageLicense = opts.stageLicense;
63
65
  this.isDevUrlAlive = opts.isDevUrlAlive ?? isDevUrlAlive;
64
66
  this.containerOps = opts.containerOps ?? defaultProductContainerOps;
@@ -272,23 +274,76 @@ export class ProductService {
272
274
  await this.store.update((products) => products.filter((p) => p.name !== name));
273
275
  logger.info(`Product '${name}' removed`);
274
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. */
275
285
  async reload(name) {
276
286
  return this.mutations.run(() => this.reloadSerialised(name));
277
287
  }
278
288
  async reloadSerialised(name) {
279
- const existing = this.store.read();
280
- const target = existing.find((p) => p.name === name);
289
+ const target = this.store.read().find((p) => p.name === name);
281
290
  if (!target)
282
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
+ }
283
312
  const baseUrl = specBaseUrl(target.spec, target.port);
284
313
  const manifest = await fetchManifest(baseUrl);
285
314
  if (manifest.name !== target.name) {
286
315
  throw new ProductError("NAME_CONFLICT", `manifest now reports name '${manifest.name}', was '${target.name}' — use remove + add`);
287
316
  }
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;
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 };
292
347
  }
293
348
  /** Stop every running container-kind product and clear its tracked
294
349
  * containerId (model B: the daemon owns container lifecycle, so it reaps