@norskvideo/ctl-sdk 0.1.0
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/base.css +53 -0
- package/browser.d.ts +8 -0
- package/browser.js +11 -0
- package/capabilities-router.d.ts +9 -0
- package/capabilities-router.js +15 -0
- package/cjs-interop.d.ts +33 -0
- package/cjs-interop.js +61 -0
- package/components/ProductIframe.d.ts +37 -0
- package/components/ProductIframe.js +119 -0
- package/components/ProductTemplateBuildForm.d.ts +40 -0
- package/components/ProductTemplateBuildForm.js +81 -0
- package/components/index.d.ts +3 -0
- package/components/index.js +3 -0
- package/components/ui-primitives.d.ts +22 -0
- package/components/ui-primitives.js +13 -0
- package/dev-url.d.ts +1 -0
- package/dev-url.js +14 -0
- package/docker-runner.d.ts +20 -0
- package/docker-runner.js +54 -0
- package/fonts/Geist-LICENSE.txt +92 -0
- package/fonts/Geist.woff2 +0 -0
- package/fonts/GeistMono.woff2 +0 -0
- package/fonts/README.md +21 -0
- package/fonts/STUDIO-FONT-SYNC.md +86 -0
- package/index.d.ts +15 -0
- package/index.js +17 -0
- package/license-registration.d.ts +52 -0
- package/license-registration.js +38 -0
- package/license-stager.d.ts +30 -0
- package/license-stager.js +118 -0
- package/license-v2.d.ts +107 -0
- package/license-v2.js +205 -0
- package/manifest-fetch.d.ts +29 -0
- package/manifest-fetch.js +100 -0
- package/manifest-router.d.ts +11 -0
- package/manifest-router.js +16 -0
- package/manifest-schema.d.ts +113 -0
- package/manifest-schema.js +135 -0
- package/manifest-seed.d.ts +28 -0
- package/manifest-seed.js +40 -0
- package/openapi-router.d.ts +12 -0
- package/openapi-router.js +21 -0
- package/package.json +46 -0
- package/parsing.d.ts +9 -0
- package/parsing.js +83 -0
- package/product-error.d.ts +4 -0
- package/product-error.js +8 -0
- package/product-health-monitor.d.ts +72 -0
- package/product-health-monitor.js +136 -0
- package/product-service.d.ts +118 -0
- package/product-service.js +340 -0
- package/product-template-error.d.ts +10 -0
- package/product-template-error.js +14 -0
- package/product-template-materials.d.ts +17 -0
- package/product-template-materials.js +51 -0
- package/product-template-parsing.d.ts +14 -0
- package/product-template-parsing.js +66 -0
- package/product-template-record.d.ts +45 -0
- package/product-template-record.js +1 -0
- package/product-types.d.ts +31 -0
- package/product-types.js +1 -0
- package/proxy-middleware.d.ts +7 -0
- package/proxy-middleware.js +112 -0
- package/runtime.d.ts +2 -0
- package/runtime.js +21 -0
- package/validate.d.ts +3 -0
- package/validate.js +22 -0
- package/workflow.d.ts +60 -0
- package/workflow.js +57 -0
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { logger } from "@norskvideo/ctl-foundation";
|
|
3
|
+
import { validateDevUrl } from "./dev-url.js";
|
|
4
|
+
import { dockerRename, dockerRm, dockerRun, productContainerName } from "./docker-runner.js";
|
|
5
|
+
import { resolveLicenseFile } from "./license-registration.js";
|
|
6
|
+
import { checkProductEntitlement, notV2EnvelopeMessage, parseLicenseContents } from "./license-v2.js";
|
|
7
|
+
import { fetchManifest, isDevUrlAlive, probeConfigScreen, specBaseUrl, waitForReady } from "./manifest-fetch.js";
|
|
8
|
+
import { ProductError } from "./product-error.js";
|
|
9
|
+
/** GET a product-template tar from a product. `url` is whatever the manifest
|
|
10
|
+
* declared; we treat it as a path on the product's HTTP surface and
|
|
11
|
+
* resolve against the product's base URL. Returns the raw tar bytes,
|
|
12
|
+
* or throws ProductError on any non-2xx / network failure. */
|
|
13
|
+
async function fetchProductTemplateBytes(baseUrl, url) {
|
|
14
|
+
const full = `${baseUrl.replace(/\/$/, "")}/${url.replace(/^\//, "")}`;
|
|
15
|
+
let response;
|
|
16
|
+
try {
|
|
17
|
+
response = await fetch(full);
|
|
18
|
+
}
|
|
19
|
+
catch (e) {
|
|
20
|
+
throw new ProductError("PRODUCT_TEMPLATE_FETCH_FAILED", `fetch ${full} threw: ${String(e)}`);
|
|
21
|
+
}
|
|
22
|
+
if (!response.ok) {
|
|
23
|
+
throw new ProductError("PRODUCT_TEMPLATE_FETCH_FAILED", `${full} returned ${response.status}`);
|
|
24
|
+
}
|
|
25
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
26
|
+
}
|
|
27
|
+
const defaultContainerOps = {
|
|
28
|
+
run: dockerRun,
|
|
29
|
+
remove: dockerRm,
|
|
30
|
+
rename: dockerRename,
|
|
31
|
+
waitForReady,
|
|
32
|
+
};
|
|
33
|
+
export class ProductService {
|
|
34
|
+
store;
|
|
35
|
+
allocatePort;
|
|
36
|
+
importProductTemplateBytes;
|
|
37
|
+
stageLicense;
|
|
38
|
+
isDevUrlAlive;
|
|
39
|
+
containerOps;
|
|
40
|
+
constructor(opts) {
|
|
41
|
+
this.store = opts.store;
|
|
42
|
+
this.allocatePort = opts.allocatePort;
|
|
43
|
+
this.importProductTemplateBytes = opts.importProductTemplateBytes;
|
|
44
|
+
this.stageLicense = opts.stageLicense;
|
|
45
|
+
this.isDevUrlAlive = opts.isDevUrlAlive ?? isDevUrlAlive;
|
|
46
|
+
this.containerOps = opts.containerOps ?? defaultContainerOps;
|
|
47
|
+
}
|
|
48
|
+
list() {
|
|
49
|
+
return this.store.read();
|
|
50
|
+
}
|
|
51
|
+
/** Whether the product is actually up. Container-mode: we started it, so a
|
|
52
|
+
* tracked containerId means running. Dev-mode: externally owned, so probe
|
|
53
|
+
* the dev URL — registered no longer implies running. */
|
|
54
|
+
async isRunning(reg) {
|
|
55
|
+
if (reg.spec.kind === "container")
|
|
56
|
+
return reg.containerId !== undefined;
|
|
57
|
+
return this.isDevUrlAlive(specBaseUrl(reg.spec));
|
|
58
|
+
}
|
|
59
|
+
async add(spec, opts = {}) {
|
|
60
|
+
const existing = this.store.read();
|
|
61
|
+
let baseUrl;
|
|
62
|
+
let port;
|
|
63
|
+
let containerId;
|
|
64
|
+
if (spec.kind === "dev") {
|
|
65
|
+
validateDevUrl(spec.url);
|
|
66
|
+
baseUrl = specBaseUrl(spec);
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
const portMap = {};
|
|
70
|
+
for (const p of existing)
|
|
71
|
+
if (p.port !== undefined)
|
|
72
|
+
portMap[p.name] = { port: p.port };
|
|
73
|
+
const allocated = this.allocatePort(portMap);
|
|
74
|
+
if (allocated === null) {
|
|
75
|
+
throw new ProductError("PORT_EXHAUSTED", `no free port available for new product`);
|
|
76
|
+
}
|
|
77
|
+
port = allocated;
|
|
78
|
+
logger.info(`Starting product container: ${spec.image} on host port ${port}`);
|
|
79
|
+
containerId = await dockerRun(spec.image, port);
|
|
80
|
+
baseUrl = specBaseUrl(spec, port);
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
await waitForReady(baseUrl);
|
|
84
|
+
}
|
|
85
|
+
catch (e) {
|
|
86
|
+
if (containerId)
|
|
87
|
+
await dockerRm(containerId);
|
|
88
|
+
throw e;
|
|
89
|
+
}
|
|
90
|
+
const manifest = await fetchManifest(baseUrl).catch(async (e) => {
|
|
91
|
+
if (containerId)
|
|
92
|
+
await dockerRm(containerId);
|
|
93
|
+
throw e;
|
|
94
|
+
});
|
|
95
|
+
if (existing.some((p) => p.name === manifest.name)) {
|
|
96
|
+
if (containerId)
|
|
97
|
+
await dockerRm(containerId);
|
|
98
|
+
throw new ProductError("NAME_CONFLICT", `product '${manifest.name}' already registered`);
|
|
99
|
+
}
|
|
100
|
+
const warnings = [];
|
|
101
|
+
// Licensing V2: a per-product license file must actually entitle this
|
|
102
|
+
// product (name + image ref/version + expiry, root signature). Anything
|
|
103
|
+
// that isn't a V2 envelope — a V1 license, marketplace sentinel, or
|
|
104
|
+
// archive — is rejected here rather than left to the engine, where a
|
|
105
|
+
// container refusing to boot is the worst place to discover it.
|
|
106
|
+
// Stage first, so everything below — validation and the recorded path —
|
|
107
|
+
// refers to the copy the daemon owns rather than the operator's argument.
|
|
108
|
+
let licenseFile = opts.license?.mode === "byol" ? opts.license.file : undefined;
|
|
109
|
+
if (opts.license?.mode === "byol") {
|
|
110
|
+
const resolved = resolveLicenseFile(opts.license.file, this.stageLicense);
|
|
111
|
+
licenseFile = resolved.file;
|
|
112
|
+
if (resolved.fatal) {
|
|
113
|
+
if (containerId)
|
|
114
|
+
await dockerRm(containerId);
|
|
115
|
+
throw new ProductError("LICENSE_INVALID", resolved.fatal);
|
|
116
|
+
}
|
|
117
|
+
if (resolved.warning)
|
|
118
|
+
warnings.push(resolved.warning);
|
|
119
|
+
}
|
|
120
|
+
if (opts.license?.mode === "byol") {
|
|
121
|
+
// Read is best-effort: the license file lives wherever the operator put
|
|
122
|
+
// it, and the daemon runs unprivileged — a file readable by docker (root,
|
|
123
|
+
// which mounts it as a compose secret at launch) but not by us must NOT
|
|
124
|
+
// block registration, or we'd regress an install shape that works today.
|
|
125
|
+
// Bytes we cannot read cannot be classified as V1 or V2, so an
|
|
126
|
+
// unreadable file registers unvalidated behind a loud warning and the
|
|
127
|
+
// engine has the last word — the one remaining route by which non-V2
|
|
128
|
+
// bytes can reach a registration.
|
|
129
|
+
let contents;
|
|
130
|
+
try {
|
|
131
|
+
contents = await readFile(licenseFile, "utf-8");
|
|
132
|
+
}
|
|
133
|
+
catch (e) {
|
|
134
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
135
|
+
warnings.push(`could not read license file '${licenseFile}' (${msg}); it was not validated at registration — if it is a V1 license the engine will reject it at launch; contact Norsk support for a reissued license`);
|
|
136
|
+
}
|
|
137
|
+
const parsed = contents !== undefined ? parseLicenseContents(contents) : undefined;
|
|
138
|
+
if (parsed?.kind === "invalid-v2") {
|
|
139
|
+
if (containerId)
|
|
140
|
+
await dockerRm(containerId);
|
|
141
|
+
throw new ProductError("LICENSE_INVALID", parsed.reason);
|
|
142
|
+
}
|
|
143
|
+
if (parsed?.kind === "legacy") {
|
|
144
|
+
if (containerId)
|
|
145
|
+
await dockerRm(containerId);
|
|
146
|
+
throw new ProductError("LICENSE_INVALID", notV2EnvelopeMessage("V1 licenses are no longer accepted at registration"));
|
|
147
|
+
}
|
|
148
|
+
if (parsed?.kind === "v2") {
|
|
149
|
+
const check = checkProductEntitlement(parsed, manifest.name, spec.kind === "container" ? spec.image : undefined);
|
|
150
|
+
if (!check.ok) {
|
|
151
|
+
if (containerId)
|
|
152
|
+
await dockerRm(containerId);
|
|
153
|
+
throw new ProductError("LICENSE_INVALID", check.reason);
|
|
154
|
+
}
|
|
155
|
+
warnings.push(...check.warnings);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
// Now that the manifest name is known and unique, give the container a
|
|
159
|
+
// stable, human-readable name (replacing docker's random alias). Tracked
|
|
160
|
+
// by id, so a rename failure is harmless — hence best-effort.
|
|
161
|
+
if (containerId)
|
|
162
|
+
await dockerRename(containerId, productContainerName(manifest.name));
|
|
163
|
+
// Sanity-probe the config screen so we catch (a) URLs that don't
|
|
164
|
+
// serve anything at the manifest-declared path, and (b) the common
|
|
165
|
+
// "registered the vite dev port" mistake — both are hard rejections
|
|
166
|
+
// because iframed product UIs need the built bundle to function.
|
|
167
|
+
if (manifest.ui?.configScreenUrl) {
|
|
168
|
+
try {
|
|
169
|
+
await probeConfigScreen(baseUrl, manifest.ui.configScreenUrl);
|
|
170
|
+
}
|
|
171
|
+
catch (e) {
|
|
172
|
+
if (containerId)
|
|
173
|
+
await dockerRm(containerId);
|
|
174
|
+
throw e;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const registration = {
|
|
178
|
+
name: manifest.name,
|
|
179
|
+
spec,
|
|
180
|
+
addedAt: new Date().toISOString(),
|
|
181
|
+
manifest,
|
|
182
|
+
...(port !== undefined ? { port } : {}),
|
|
183
|
+
...(containerId !== undefined ? { containerId } : {}),
|
|
184
|
+
...(opts.license !== undefined
|
|
185
|
+
? {
|
|
186
|
+
license: opts.license.mode === "byol" && licenseFile !== undefined
|
|
187
|
+
? { ...opts.license, file: licenseFile }
|
|
188
|
+
: opts.license,
|
|
189
|
+
}
|
|
190
|
+
: {}),
|
|
191
|
+
};
|
|
192
|
+
await this.store.update((products) => [...products, registration]);
|
|
193
|
+
logger.info(`Product '${manifest.name}' registered (${spec.kind})`);
|
|
194
|
+
// defaultProductTemplates: fetch each declared URL relative to the
|
|
195
|
+
// product's base and hand the bytes to the host's product-template store.
|
|
196
|
+
// Failures here are downgraded to warnings rather than rolling back
|
|
197
|
+
// the registration — the product itself is healthy; a flaky default
|
|
198
|
+
// product template just means the operator visits configure as usual.
|
|
199
|
+
// Hosts that don't accept product templates (no importProductTemplateBytes
|
|
200
|
+
// callback) silently skip the whole block.
|
|
201
|
+
if (manifest.defaultProductTemplates.length > 0 && this.importProductTemplateBytes) {
|
|
202
|
+
for (const entry of manifest.defaultProductTemplates) {
|
|
203
|
+
try {
|
|
204
|
+
const bytes = await fetchProductTemplateBytes(baseUrl, entry.url);
|
|
205
|
+
await this.importProductTemplateBytes({
|
|
206
|
+
name: entry.name,
|
|
207
|
+
bytes,
|
|
208
|
+
source: { kind: "product-default", productName: manifest.name, url: entry.url },
|
|
209
|
+
});
|
|
210
|
+
logger.info(`Product '${manifest.name}': stored default product template '${entry.name}' from ${entry.url}`);
|
|
211
|
+
}
|
|
212
|
+
catch (e) {
|
|
213
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
214
|
+
const warning = `default product template '${entry.name}' from ${entry.url} failed: ${msg}`;
|
|
215
|
+
warnings.push(warning);
|
|
216
|
+
logger.warn(`[product:${manifest.name}] ${warning}`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
for (const w of warnings)
|
|
221
|
+
logger.warn(`[product:${manifest.name}] ${w}`);
|
|
222
|
+
return { registration, warnings };
|
|
223
|
+
}
|
|
224
|
+
async remove(name) {
|
|
225
|
+
const existing = this.store.read();
|
|
226
|
+
const target = existing.find((p) => p.name === name);
|
|
227
|
+
if (!target)
|
|
228
|
+
throw new ProductError("NOT_FOUND", `product '${name}' not registered`);
|
|
229
|
+
if (target.containerId)
|
|
230
|
+
await dockerRm(target.containerId);
|
|
231
|
+
await this.store.update((products) => products.filter((p) => p.name !== name));
|
|
232
|
+
logger.info(`Product '${name}' removed`);
|
|
233
|
+
}
|
|
234
|
+
async reload(name) {
|
|
235
|
+
const existing = this.store.read();
|
|
236
|
+
const target = existing.find((p) => p.name === name);
|
|
237
|
+
if (!target)
|
|
238
|
+
throw new ProductError("NOT_FOUND", `product '${name}' not registered`);
|
|
239
|
+
const baseUrl = specBaseUrl(target.spec, target.port);
|
|
240
|
+
const manifest = await fetchManifest(baseUrl);
|
|
241
|
+
if (manifest.name !== target.name) {
|
|
242
|
+
throw new ProductError("NAME_CONFLICT", `manifest now reports name '${manifest.name}', was '${target.name}' — use remove + add`);
|
|
243
|
+
}
|
|
244
|
+
const next = { ...target, manifest };
|
|
245
|
+
await this.store.update((products) => products.map((p) => (p.name === name ? next : p)));
|
|
246
|
+
logger.info(`Product '${name}' manifest reloaded`);
|
|
247
|
+
return next;
|
|
248
|
+
}
|
|
249
|
+
/** Stop every running container-kind product and clear its tracked
|
|
250
|
+
* containerId (model B: the daemon owns container lifecycle, so it reaps
|
|
251
|
+
* the control planes it started). Dev-kind products are externally owned —
|
|
252
|
+
* left alone. Clearing the id keeps the persisted store honest: a stale id
|
|
253
|
+
* left behind would make isRunning() point at a container `--rm` reaped.
|
|
254
|
+
* Best-effort per product — a failing `docker rm` is logged, never thrown,
|
|
255
|
+
* so one stubborn container can't block a clean shutdown. */
|
|
256
|
+
async stopAll() {
|
|
257
|
+
const toStop = this.store.read().filter((p) => p.spec.kind === "container" && p.containerId !== undefined);
|
|
258
|
+
if (toStop.length === 0)
|
|
259
|
+
return;
|
|
260
|
+
const stopped = new Set();
|
|
261
|
+
await Promise.all(toStop.map(async (reg) => {
|
|
262
|
+
try {
|
|
263
|
+
await this.containerOps.remove(reg.containerId);
|
|
264
|
+
}
|
|
265
|
+
catch (e) {
|
|
266
|
+
logger.warn(`Product '${reg.name}': failed to stop container — ${e instanceof Error ? e.message : String(e)}`);
|
|
267
|
+
}
|
|
268
|
+
stopped.add(reg.name);
|
|
269
|
+
}));
|
|
270
|
+
await this.store.update((products) => products.map((p) => (stopped.has(p.name) ? withContainerId(p, undefined) : p)));
|
|
271
|
+
}
|
|
272
|
+
/** Relaunch every container-kind product recorded in the store, refreshing
|
|
273
|
+
* its containerId (model B: called once at daemon boot to re-create the
|
|
274
|
+
* control planes stopped on the previous shutdown). Dev-kind products are
|
|
275
|
+
* externally owned — skipped. Best-effort per product: one that fails to
|
|
276
|
+
* come up is logged and left with its containerId cleared, so isRunning()
|
|
277
|
+
* reports it down rather than pointing at a container that never started. */
|
|
278
|
+
async restoreAll() {
|
|
279
|
+
const toRestore = this.store.read().filter((p) => p.spec.kind === "container");
|
|
280
|
+
if (toRestore.length === 0)
|
|
281
|
+
return;
|
|
282
|
+
const restored = new Map();
|
|
283
|
+
await Promise.all(toRestore.map(async (reg) => {
|
|
284
|
+
if (reg.spec.kind !== "container" || reg.port === undefined) {
|
|
285
|
+
logger.warn(`Product '${reg.name}': cannot restore container without a recorded port — skipping`);
|
|
286
|
+
restored.set(reg.name, undefined);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
try {
|
|
290
|
+
const containerId = await this.containerOps.run(reg.spec.image, reg.port);
|
|
291
|
+
await this.containerOps.waitForReady(specBaseUrl(reg.spec, reg.port));
|
|
292
|
+
await this.containerOps.rename(containerId, productContainerName(reg.name));
|
|
293
|
+
restored.set(reg.name, containerId);
|
|
294
|
+
logger.info(`Product '${reg.name}' container restored on host port ${reg.port}`);
|
|
295
|
+
}
|
|
296
|
+
catch (e) {
|
|
297
|
+
restored.set(reg.name, undefined);
|
|
298
|
+
logger.warn(`Product '${reg.name}': failed to restore — ${e instanceof Error ? e.message : String(e)}`);
|
|
299
|
+
}
|
|
300
|
+
}));
|
|
301
|
+
await this.store.update((products) => products.map((p) => (restored.has(p.name) ? withContainerId(p, restored.get(p.name)) : p)));
|
|
302
|
+
}
|
|
303
|
+
/** Stop (if still present) and relaunch a single container-kind product,
|
|
304
|
+
* recording the fresh containerId. Used by the health monitor to recover a
|
|
305
|
+
* product that has failed its liveness probe. Unlike restoreAll this is not
|
|
306
|
+
* best-effort: a relaunch that never comes ready rejects, so the monitor
|
|
307
|
+
* can count the failed attempt and eventually give up. The new id is
|
|
308
|
+
* persisted before the readiness wait, so even a timed-out restart leaves a
|
|
309
|
+
* tracked container the next restart can reap rather than orphan. */
|
|
310
|
+
async restart(name) {
|
|
311
|
+
const target = this.store.read().find((p) => p.name === name);
|
|
312
|
+
if (!target)
|
|
313
|
+
throw new ProductError("NOT_FOUND", `product '${name}' not registered`);
|
|
314
|
+
if (target.spec.kind !== "container") {
|
|
315
|
+
throw new ProductError("NOT_RESTARTABLE", `product '${name}' is dev-mode (externally owned) — cannot restart`);
|
|
316
|
+
}
|
|
317
|
+
if (target.port === undefined) {
|
|
318
|
+
throw new ProductError("NOT_RESTARTABLE", `product '${name}' has no recorded port — cannot restart`);
|
|
319
|
+
}
|
|
320
|
+
if (target.containerId) {
|
|
321
|
+
try {
|
|
322
|
+
await this.containerOps.remove(target.containerId);
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
// Already gone (crashed / --rm reaped) — nothing to stop.
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
const containerId = await this.containerOps.run(target.spec.image, target.port);
|
|
329
|
+
await this.store.update((products) => products.map((p) => (p.name === name ? withContainerId(p, containerId) : p)));
|
|
330
|
+
await this.containerOps.waitForReady(specBaseUrl(target.spec, target.port));
|
|
331
|
+
await this.containerOps.rename(containerId, productContainerName(name));
|
|
332
|
+
logger.info(`Product '${name}' container restarted on host port ${target.port}`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
/** Return a copy of `reg` with containerId set (or removed when undefined),
|
|
336
|
+
* preserving the "omit the key entirely" shape the store round-trips. */
|
|
337
|
+
function withContainerId(reg, containerId) {
|
|
338
|
+
const { containerId: _drop, ...rest } = reg;
|
|
339
|
+
return containerId === undefined ? rest : { ...rest, containerId };
|
|
340
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error class for product-template lifecycle issues. Codes cover both the
|
|
3
|
+
* "store + extract" path (used by ctl's ProductTemplateService — extraction,
|
|
4
|
+
* manifest parsing, in-use guarding) and the simpler "store raw bytes"
|
|
5
|
+
* path (used by mgr — name validation + bytes persistence).
|
|
6
|
+
*/
|
|
7
|
+
export declare class ProductTemplateError extends Error {
|
|
8
|
+
code: "INVALID_NAME" | "NAME_CONFLICT" | "FILE_NOT_FOUND" | "EXTRACTION_FAILED" | "MANIFEST_MISSING" | "MANIFEST_INVALID" | "COMPOSE_MISSING" | "PARAMETERS_INVALID" | "NOT_FOUND" | "IN_USE";
|
|
9
|
+
constructor(code: "INVALID_NAME" | "NAME_CONFLICT" | "FILE_NOT_FOUND" | "EXTRACTION_FAILED" | "MANIFEST_MISSING" | "MANIFEST_INVALID" | "COMPOSE_MISSING" | "PARAMETERS_INVALID" | "NOT_FOUND" | "IN_USE", message: string);
|
|
10
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error class for product-template lifecycle issues. Codes cover both the
|
|
3
|
+
* "store + extract" path (used by ctl's ProductTemplateService — extraction,
|
|
4
|
+
* manifest parsing, in-use guarding) and the simpler "store raw bytes"
|
|
5
|
+
* path (used by mgr — name validation + bytes persistence).
|
|
6
|
+
*/
|
|
7
|
+
export class ProductTemplateError extends Error {
|
|
8
|
+
code;
|
|
9
|
+
constructor(code, message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.name = "ProductTemplateError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type LoadedFile } from "@norskvideo/ctl-foundation";
|
|
2
|
+
/** Compiled Studio components, in the shape buildProductTemplateMaterials expects. */
|
|
3
|
+
export declare function loadCompiledComponents(root: string): LoadedFile[];
|
|
4
|
+
/**
|
|
5
|
+
* Dashboard files, keyed by workflow name.
|
|
6
|
+
*
|
|
7
|
+
* Each `<workflowName>/` under `root` is packed under that name. Where the
|
|
8
|
+
* workflow has a `dist/`, only its contents are packed (flattened under the
|
|
9
|
+
* workflow name), so a dashboard built from sources ships its built artefact
|
|
10
|
+
* and nothing else. Otherwise the directory is packed wholesale, which is what
|
|
11
|
+
* a pre-built dashboard wants.
|
|
12
|
+
*
|
|
13
|
+
* The runner mounts the result at `<studioDocumentDir>/<workflowName>/dashboards/`
|
|
14
|
+
* inside the studio container, where Studio's `findDashboardDir(workflowName)`
|
|
15
|
+
* finds it and serves it at `/dashboard/<workflowName>/`.
|
|
16
|
+
*/
|
|
17
|
+
export declare function loadDashboardFiles(root: string): LoadedFile[];
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Disk readers for the two directory trees that go into a product-template tar.
|
|
2
|
+
//
|
|
3
|
+
// Every product had its own copy of these. The component reader was identical
|
|
4
|
+
// in all of them; the dashboard reader had forked, and the version reconciled
|
|
5
|
+
// here is the one that packs `dist/` when a workflow has been built from
|
|
6
|
+
// sources — without it a source-based dashboard drags its `src/` and its
|
|
7
|
+
// `node_modules/` into the tar.
|
|
8
|
+
//
|
|
9
|
+
// The root path stays with the product: only it knows where its components and
|
|
10
|
+
// dashboards live.
|
|
11
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { walkFiles } from "@norskvideo/ctl-foundation";
|
|
14
|
+
// Studio scans the mounted `components/` directory in the product-template tar,
|
|
15
|
+
// and only runtime artifacts are useful at load time, so declarations and
|
|
16
|
+
// source maps are filtered out to keep the tar lean.
|
|
17
|
+
const SKIP_SUFFIXES = [".d.ts", ".js.map"];
|
|
18
|
+
/** Compiled Studio components, in the shape buildProductTemplateMaterials expects. */
|
|
19
|
+
export function loadCompiledComponents(root) {
|
|
20
|
+
return walkFiles(root, { skipSuffixes: SKIP_SUFFIXES });
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Dashboard files, keyed by workflow name.
|
|
24
|
+
*
|
|
25
|
+
* Each `<workflowName>/` under `root` is packed under that name. Where the
|
|
26
|
+
* workflow has a `dist/`, only its contents are packed (flattened under the
|
|
27
|
+
* workflow name), so a dashboard built from sources ships its built artefact
|
|
28
|
+
* and nothing else. Otherwise the directory is packed wholesale, which is what
|
|
29
|
+
* a pre-built dashboard wants.
|
|
30
|
+
*
|
|
31
|
+
* The runner mounts the result at `<studioDocumentDir>/<workflowName>/dashboards/`
|
|
32
|
+
* inside the studio container, where Studio's `findDashboardDir(workflowName)`
|
|
33
|
+
* finds it and serves it at `/dashboard/<workflowName>/`.
|
|
34
|
+
*/
|
|
35
|
+
export function loadDashboardFiles(root) {
|
|
36
|
+
if (!existsSync(root))
|
|
37
|
+
return [];
|
|
38
|
+
const out = [];
|
|
39
|
+
for (const entry of readdirSync(root)) {
|
|
40
|
+
const workflowDir = path.join(root, entry);
|
|
41
|
+
// Loose files at the root belong to no workflow and have nowhere to go.
|
|
42
|
+
if (!statSync(workflowDir).isDirectory())
|
|
43
|
+
continue;
|
|
44
|
+
const distDir = path.join(workflowDir, "dist");
|
|
45
|
+
const sourceDir = existsSync(distDir) && statSync(distDir).isDirectory() ? distDir : workflowDir;
|
|
46
|
+
for (const file of walkFiles(sourceDir)) {
|
|
47
|
+
out.push({ path: path.join(entry, file.path), content: file.content });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ProductTemplateRecord } from "./product-template-record.js";
|
|
2
|
+
export declare class ProductTemplatesParseError extends Error {
|
|
3
|
+
constructor(path: string, reason: string);
|
|
4
|
+
}
|
|
5
|
+
export interface ProductTemplatesFile {
|
|
6
|
+
productTemplates: ProductTemplateRecord[];
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Validate raw product-templates.yaml contents into ProductTemplateRecord[].
|
|
10
|
+
* Pure: no file IO — callers parse the YAML themselves (typically via
|
|
11
|
+
* Bun.YAML). Tolerant of legacy entries: missing `source` and missing `sha256`
|
|
12
|
+
* are accepted; the caller can rebuild them later if needed.
|
|
13
|
+
*/
|
|
14
|
+
export declare function parseProductTemplatesFile(raw: unknown, path: string): ProductTemplatesFile;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { ProductTemplateManifestSchema } from "@norskvideo/ctl-product-template-schema";
|
|
2
|
+
export class ProductTemplatesParseError extends Error {
|
|
3
|
+
constructor(path, reason) {
|
|
4
|
+
super(`Failed to parse ${path}: ${reason}`);
|
|
5
|
+
this.name = "ProductTemplatesParseError";
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
function isRecord(v) {
|
|
9
|
+
return typeof v === "object" && v !== null;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Validate raw product-templates.yaml contents into ProductTemplateRecord[].
|
|
13
|
+
* Pure: no file IO — callers parse the YAML themselves (typically via
|
|
14
|
+
* Bun.YAML). Tolerant of legacy entries: missing `source` and missing `sha256`
|
|
15
|
+
* are accepted; the caller can rebuild them later if needed.
|
|
16
|
+
*/
|
|
17
|
+
export function parseProductTemplatesFile(raw, path) {
|
|
18
|
+
if (raw === null || raw === undefined)
|
|
19
|
+
return { productTemplates: [] };
|
|
20
|
+
if (!isRecord(raw))
|
|
21
|
+
throw new ProductTemplatesParseError(path, "file is not a YAML object");
|
|
22
|
+
const productTemplates = raw["product-templates"];
|
|
23
|
+
if (productTemplates === undefined)
|
|
24
|
+
return { productTemplates: [] };
|
|
25
|
+
if (!Array.isArray(productTemplates))
|
|
26
|
+
throw new ProductTemplatesParseError(path, "'product-templates' must be an array");
|
|
27
|
+
const validated = [];
|
|
28
|
+
for (let i = 0; i < productTemplates.length; i++) {
|
|
29
|
+
const where = `product-templates[${i}]`;
|
|
30
|
+
const entry = productTemplates[i];
|
|
31
|
+
if (!isRecord(entry))
|
|
32
|
+
throw new ProductTemplatesParseError(path, `${where} is not an object`);
|
|
33
|
+
if (typeof entry.name !== "string")
|
|
34
|
+
throw new ProductTemplatesParseError(path, `${where}.name must be a string`);
|
|
35
|
+
if (typeof entry.createdAt !== "string")
|
|
36
|
+
throw new ProductTemplatesParseError(path, `${where}.createdAt must be a string`);
|
|
37
|
+
const manifestParse = ProductTemplateManifestSchema.safeParse(entry.manifest);
|
|
38
|
+
if (!manifestParse.success) {
|
|
39
|
+
throw new ProductTemplatesParseError(path, `${where}.manifest is invalid: ${manifestParse.error.message}`);
|
|
40
|
+
}
|
|
41
|
+
const parameters = Array.isArray(entry.parameters) ? entry.parameters : [];
|
|
42
|
+
const rec = {
|
|
43
|
+
name: entry.name,
|
|
44
|
+
manifest: manifestParse.data,
|
|
45
|
+
parameters,
|
|
46
|
+
createdAt: entry.createdAt,
|
|
47
|
+
};
|
|
48
|
+
if (typeof entry.sha256 === "string")
|
|
49
|
+
rec.sha256 = entry.sha256;
|
|
50
|
+
if (isRecord(entry.source)) {
|
|
51
|
+
if (entry.source.kind === "import" && typeof entry.source.fromFile === "string") {
|
|
52
|
+
rec.source = { kind: "import", fromFile: entry.source.fromFile };
|
|
53
|
+
}
|
|
54
|
+
else if (entry.source.kind === "build" && typeof entry.source.productName === "string") {
|
|
55
|
+
rec.source = { kind: "build", productName: entry.source.productName };
|
|
56
|
+
}
|
|
57
|
+
else if (entry.source.kind === "product-default" &&
|
|
58
|
+
typeof entry.source.productName === "string" &&
|
|
59
|
+
typeof entry.source.url === "string") {
|
|
60
|
+
rec.source = { kind: "product-default", productName: entry.source.productName, url: entry.source.url };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
validated.push(rec);
|
|
64
|
+
}
|
|
65
|
+
return { productTemplates: validated };
|
|
66
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ProductTemplateManifest, ProductTemplateParameter } from "@norskvideo/ctl-product-template-schema";
|
|
2
|
+
/**
|
|
3
|
+
* How a stored product template got here.
|
|
4
|
+
*
|
|
5
|
+
* `import` — operator-supplied tar file (CLI / API).
|
|
6
|
+
* `build` — generated by calling a registered product's
|
|
7
|
+
* /api/product-template endpoint with iframe form values.
|
|
8
|
+
* `product-default` — auto-fetched at product-registration time from
|
|
9
|
+
* a URL the product's manifest declared under
|
|
10
|
+
* `defaultProductTemplates[]`. No user input — the
|
|
11
|
+
* product picks the form values itself.
|
|
12
|
+
* `export` — packed by the runner from a running instance's
|
|
13
|
+
* working directory ("save as product template").
|
|
14
|
+
*/
|
|
15
|
+
export type ProductTemplateSource = {
|
|
16
|
+
kind: "import";
|
|
17
|
+
fromFile: string;
|
|
18
|
+
} | {
|
|
19
|
+
kind: "build";
|
|
20
|
+
productName: string;
|
|
21
|
+
} | {
|
|
22
|
+
kind: "product-default";
|
|
23
|
+
productName: string;
|
|
24
|
+
url: string;
|
|
25
|
+
} | {
|
|
26
|
+
kind: "export";
|
|
27
|
+
productName: string;
|
|
28
|
+
fromInstance: string;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Persisted product-template metadata. Stored as one entry per product template
|
|
32
|
+
* in the consumer's product-templates.yaml. The tar bytes themselves live
|
|
33
|
+
* alongside (extracted on ctl's side, raw on mgr's side) — see each app's
|
|
34
|
+
* product-templates/store.ts for the on-disk layout.
|
|
35
|
+
*/
|
|
36
|
+
export interface ProductTemplateRecord {
|
|
37
|
+
name: string;
|
|
38
|
+
manifest: ProductTemplateManifest;
|
|
39
|
+
parameters: ProductTemplateParameter[];
|
|
40
|
+
createdAt: string;
|
|
41
|
+
source?: ProductTemplateSource;
|
|
42
|
+
/** Lowercase hex SHA-256 of the source tar bytes. Optional for
|
|
43
|
+
* historic records that predate the field. */
|
|
44
|
+
sha256?: string;
|
|
45
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Manifest } from "./manifest-schema.js";
|
|
2
|
+
export type ProductSpec = {
|
|
3
|
+
kind: "container";
|
|
4
|
+
image: string;
|
|
5
|
+
} | {
|
|
6
|
+
kind: "dev";
|
|
7
|
+
url: string;
|
|
8
|
+
};
|
|
9
|
+
/** Per-product license (issue #313): supplied at add-product time (or seeded
|
|
10
|
+
* from the host's global license setting), resolved at instance launch in
|
|
11
|
+
* preference to any daemon-wide default. */
|
|
12
|
+
export type ProductLicense = {
|
|
13
|
+
mode: "byol";
|
|
14
|
+
file: string;
|
|
15
|
+
} | {
|
|
16
|
+
mode: "marketplace";
|
|
17
|
+
provider: string;
|
|
18
|
+
};
|
|
19
|
+
export interface ProductRegistration {
|
|
20
|
+
name: string;
|
|
21
|
+
spec: ProductSpec;
|
|
22
|
+
addedAt: string;
|
|
23
|
+
manifest: Manifest;
|
|
24
|
+
/** Host port (container mode only — dev mode reuses the dev URL's port). */
|
|
25
|
+
port?: number;
|
|
26
|
+
/** Docker container ID (container mode only). */
|
|
27
|
+
containerId?: string;
|
|
28
|
+
/** Absent on registrations that predate per-product licenses — launches
|
|
29
|
+
* fall back to the host's global license setting. */
|
|
30
|
+
license?: ProductLicense;
|
|
31
|
+
}
|
package/product-types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Response as ExpressResponse, NextFunction, Request } from "express";
|
|
2
|
+
import type { ProductRegistration } from "./product-types.js";
|
|
3
|
+
interface ProductRegistry {
|
|
4
|
+
list(): ProductRegistration[];
|
|
5
|
+
}
|
|
6
|
+
export declare function createProductProxyMiddleware(svc: ProductRegistry): (req: Request, res: ExpressResponse, next: NextFunction) => Promise<void>;
|
|
7
|
+
export {};
|