@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.
- package/docker-runner.d.ts +4 -0
- package/docker-runner.js +13 -0
- package/package.json +1 -1
- package/product-error.d.ts +2 -2
- package/product-service.d.ts +45 -1
- package/product-service.js +84 -9
package/docker-runner.d.ts
CHANGED
|
@@ -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
package/product-error.d.ts
CHANGED
|
@@ -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
|
}
|
package/product-service.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
package/product-service.js
CHANGED
|
@@ -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
|
-
|
|
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 ??
|
|
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
|
|
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
|
|
269
|
-
|
|
270
|
-
logger.info(`Product '${name}'
|
|
271
|
-
|
|
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
|