@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
package/manifest-seed.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Machine-writable source of truth for a product's pinned {media, studio} image
|
|
2
|
+
// pair (RFC 0001 Workstream C, slice 1). Each product ships a
|
|
3
|
+
// `products/<p>/manifest.seed.json` whose `version.ts` derives its existing tag
|
|
4
|
+
// exports from this shape. This module owns the schema, the inferred types, and
|
|
5
|
+
// a Result-returning parse helper — the conformance gate validates every seed
|
|
6
|
+
// against it in CI, so version.ts consumes the JSON as typed data without
|
|
7
|
+
// re-validating at import time.
|
|
8
|
+
//
|
|
9
|
+
// Zero runtime dependencies beyond zod (already an sdk dep) and foundation's
|
|
10
|
+
// Result, so it lives on its own `./manifest-seed` subpath rather than the `.`
|
|
11
|
+
// barrel (which drags express) — a zero-dep consumer (the gate bot, the
|
|
12
|
+
// conformance test) imports it without the express surface.
|
|
13
|
+
import { err, ok } from "@norskvideo/ctl-foundation";
|
|
14
|
+
import { z } from "zod";
|
|
15
|
+
// A repo-qualified image ref: an `owner/name:tag` string. The repo must be
|
|
16
|
+
// slash-qualified so a bare tag (no repo) is rejected — the seed carries FULL
|
|
17
|
+
// refs uniformly (studio strips the repo back off in its version.ts).
|
|
18
|
+
const RepoQualifiedRef = z
|
|
19
|
+
.string()
|
|
20
|
+
.regex(/^[^\s:]+\/[^\s:]+:[^\s:]+$/, "must be a repo-qualified ref (owner/name:tag)");
|
|
21
|
+
// Why an enum: `inherited` (studio takes its pair from the channel manifest) vs
|
|
22
|
+
// `nightly-baseline` (probe et al. pin their own nightly). The distinction
|
|
23
|
+
// mirrors RFC §3.4 without pre-committing slice-3 catalog semantics.
|
|
24
|
+
export const SourceSchema = z.enum(["inherited", "nightly-baseline"]);
|
|
25
|
+
export const ChannelEntrySchema = z
|
|
26
|
+
.object({
|
|
27
|
+
media: RepoQualifiedRef,
|
|
28
|
+
studio: RepoQualifiedRef,
|
|
29
|
+
source: SourceSchema,
|
|
30
|
+
ratifiedAt: z.string().optional(),
|
|
31
|
+
})
|
|
32
|
+
.strict();
|
|
33
|
+
// A seed is `{ [channel]: entry }` with at least a `latest` key.
|
|
34
|
+
export const SeedSchema = z
|
|
35
|
+
.record(z.string(), ChannelEntrySchema)
|
|
36
|
+
.refine((s) => "latest" in s, "seed must have a 'latest' entry");
|
|
37
|
+
export function parseManifestSeed(raw) {
|
|
38
|
+
const parsed = SeedSchema.safeParse(raw);
|
|
39
|
+
return parsed.success ? ok(parsed.data) : err(z.prettifyError(parsed.error));
|
|
40
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
/**
|
|
3
|
+
* Serves a product's OpenAPI document at `GET /openapi.yaml`, relative to
|
|
4
|
+
* wherever the router is mounted.
|
|
5
|
+
*
|
|
6
|
+
* `build` is called lazily on the first request and its result cached for the
|
|
7
|
+
* life of the router — the document is derived from schemas that are fixed at
|
|
8
|
+
* startup, so rebuilding per request only costs latency. The cache is
|
|
9
|
+
* per-router rather than per-module so two products sharing a process cannot
|
|
10
|
+
* serve each other's document.
|
|
11
|
+
*/
|
|
12
|
+
export declare function createOpenapiRouter(build: () => string): Router;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
/**
|
|
3
|
+
* Serves a product's OpenAPI document at `GET /openapi.yaml`, relative to
|
|
4
|
+
* wherever the router is mounted.
|
|
5
|
+
*
|
|
6
|
+
* `build` is called lazily on the first request and its result cached for the
|
|
7
|
+
* life of the router — the document is derived from schemas that are fixed at
|
|
8
|
+
* startup, so rebuilding per request only costs latency. The cache is
|
|
9
|
+
* per-router rather than per-module so two products sharing a process cannot
|
|
10
|
+
* serve each other's document.
|
|
11
|
+
*/
|
|
12
|
+
export function createOpenapiRouter(build) {
|
|
13
|
+
const router = Router();
|
|
14
|
+
let cached;
|
|
15
|
+
router.get("/openapi.yaml", (_req, res) => {
|
|
16
|
+
if (!cached)
|
|
17
|
+
cached = build();
|
|
18
|
+
res.type("text/yaml").send(cached);
|
|
19
|
+
});
|
|
20
|
+
return router;
|
|
21
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@norskvideo/ctl-sdk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": {
|
|
7
|
+
"types": "./index.d.ts",
|
|
8
|
+
"default": "./index.js"
|
|
9
|
+
},
|
|
10
|
+
"./browser": {
|
|
11
|
+
"types": "./browser.d.ts",
|
|
12
|
+
"default": "./browser.js"
|
|
13
|
+
},
|
|
14
|
+
"./components": {
|
|
15
|
+
"types": "./components/index.d.ts",
|
|
16
|
+
"default": "./components/index.js"
|
|
17
|
+
},
|
|
18
|
+
"./manifest-seed": {
|
|
19
|
+
"types": "./manifest-seed.d.ts",
|
|
20
|
+
"default": "./manifest-seed.js"
|
|
21
|
+
},
|
|
22
|
+
"./runtime": {
|
|
23
|
+
"types": "./runtime.d.ts",
|
|
24
|
+
"default": "./runtime.js"
|
|
25
|
+
},
|
|
26
|
+
"./workflow": {
|
|
27
|
+
"types": "./workflow.d.ts",
|
|
28
|
+
"default": "./workflow.js"
|
|
29
|
+
},
|
|
30
|
+
"./base.css": "./base.css"
|
|
31
|
+
},
|
|
32
|
+
"main": "./index.js",
|
|
33
|
+
"types": "./index.d.ts",
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@norskvideo/ctl-foundation": "^0.1.0",
|
|
36
|
+
"@norskvideo/ctl-product-template-schema": "^0.1.0",
|
|
37
|
+
"express": "5",
|
|
38
|
+
"lucide-react": "^0.483.0",
|
|
39
|
+
"react": "^19.0.0",
|
|
40
|
+
"react-hot-toast": "^2.4.1",
|
|
41
|
+
"zod": "^4.3.6"
|
|
42
|
+
},
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public"
|
|
45
|
+
}
|
|
46
|
+
}
|
package/parsing.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ProductRegistration, ProductSpec } from "./product-types.js";
|
|
2
|
+
export declare class ProductsParseError extends Error {
|
|
3
|
+
constructor(path: string, reason: string);
|
|
4
|
+
}
|
|
5
|
+
export declare function parseSpec(raw: unknown, where: string, path: string): ProductSpec;
|
|
6
|
+
export interface ProductsFile {
|
|
7
|
+
products: ProductRegistration[];
|
|
8
|
+
}
|
|
9
|
+
export declare function parseProductsFile(raw: unknown, path: string): ProductsFile;
|
package/parsing.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { ManifestSchema } from "./manifest-schema.js";
|
|
2
|
+
export class ProductsParseError extends Error {
|
|
3
|
+
constructor(path, reason) {
|
|
4
|
+
super(`Failed to parse ${path}: ${reason}`);
|
|
5
|
+
this.name = "ProductsParseError";
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
function isRecord(v) {
|
|
9
|
+
return typeof v === "object" && v !== null;
|
|
10
|
+
}
|
|
11
|
+
export function parseSpec(raw, where, path) {
|
|
12
|
+
if (!isRecord(raw))
|
|
13
|
+
throw new ProductsParseError(path, `${where}.spec must be an object`);
|
|
14
|
+
if (raw.kind === "container") {
|
|
15
|
+
if (typeof raw.image !== "string") {
|
|
16
|
+
throw new ProductsParseError(path, `${where}.spec.image must be a string`);
|
|
17
|
+
}
|
|
18
|
+
return { kind: "container", image: raw.image };
|
|
19
|
+
}
|
|
20
|
+
if (raw.kind === "dev") {
|
|
21
|
+
if (typeof raw.url !== "string") {
|
|
22
|
+
throw new ProductsParseError(path, `${where}.spec.url must be a string`);
|
|
23
|
+
}
|
|
24
|
+
return { kind: "dev", url: raw.url };
|
|
25
|
+
}
|
|
26
|
+
throw new ProductsParseError(path, `${where}.spec.kind must be 'container' or 'dev'`);
|
|
27
|
+
}
|
|
28
|
+
function parseLicense(raw, where, path) {
|
|
29
|
+
if (!isRecord(raw))
|
|
30
|
+
throw new ProductsParseError(path, `${where}.license must be an object`);
|
|
31
|
+
if (raw.mode === "byol") {
|
|
32
|
+
if (typeof raw.file !== "string") {
|
|
33
|
+
throw new ProductsParseError(path, `${where}.license.file must be a string`);
|
|
34
|
+
}
|
|
35
|
+
return { mode: "byol", file: raw.file };
|
|
36
|
+
}
|
|
37
|
+
if (raw.mode === "marketplace") {
|
|
38
|
+
if (typeof raw.provider !== "string") {
|
|
39
|
+
throw new ProductsParseError(path, `${where}.license.provider must be a string`);
|
|
40
|
+
}
|
|
41
|
+
return { mode: "marketplace", provider: raw.provider };
|
|
42
|
+
}
|
|
43
|
+
throw new ProductsParseError(path, `${where}.license.mode must be 'byol' or 'marketplace'`);
|
|
44
|
+
}
|
|
45
|
+
export function parseProductsFile(raw, path) {
|
|
46
|
+
if (!isRecord(raw))
|
|
47
|
+
throw new ProductsParseError(path, "file is not a YAML object");
|
|
48
|
+
const products = raw.products;
|
|
49
|
+
if (products === undefined)
|
|
50
|
+
return { products: [] };
|
|
51
|
+
if (!Array.isArray(products))
|
|
52
|
+
throw new ProductsParseError(path, "'products' must be an array");
|
|
53
|
+
const validated = [];
|
|
54
|
+
for (let i = 0; i < products.length; i++) {
|
|
55
|
+
const where = `products[${i}]`;
|
|
56
|
+
const entry = products[i];
|
|
57
|
+
if (!isRecord(entry))
|
|
58
|
+
throw new ProductsParseError(path, `${where} is not an object`);
|
|
59
|
+
if (typeof entry.name !== "string")
|
|
60
|
+
throw new ProductsParseError(path, `${where}.name must be a string`);
|
|
61
|
+
if (typeof entry.addedAt !== "string")
|
|
62
|
+
throw new ProductsParseError(path, `${where}.addedAt must be a string`);
|
|
63
|
+
const spec = parseSpec(entry.spec, where, path);
|
|
64
|
+
const manifestParse = ManifestSchema.safeParse(entry.manifest);
|
|
65
|
+
if (!manifestParse.success) {
|
|
66
|
+
throw new ProductsParseError(path, `${where}.manifest is invalid: ${manifestParse.error.message}`);
|
|
67
|
+
}
|
|
68
|
+
const reg = {
|
|
69
|
+
name: entry.name,
|
|
70
|
+
spec,
|
|
71
|
+
addedAt: entry.addedAt,
|
|
72
|
+
manifest: manifestParse.data,
|
|
73
|
+
};
|
|
74
|
+
if (typeof entry.port === "number")
|
|
75
|
+
reg.port = entry.port;
|
|
76
|
+
if (typeof entry.containerId === "string")
|
|
77
|
+
reg.containerId = entry.containerId;
|
|
78
|
+
if (entry.license !== undefined)
|
|
79
|
+
reg.license = parseLicense(entry.license, where, path);
|
|
80
|
+
validated.push(reg);
|
|
81
|
+
}
|
|
82
|
+
return { products: validated };
|
|
83
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
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);
|
|
4
|
+
}
|
package/product-error.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { ProductRegistration } from "./product-types.js";
|
|
2
|
+
export type ProductHealthStatus = "healthy" | "unhealthy" | "restarting" | "unknown";
|
|
3
|
+
export interface ProductHealthState {
|
|
4
|
+
status: ProductHealthStatus;
|
|
5
|
+
/** Failed probes since the last success or restart attempt. */
|
|
6
|
+
consecutiveFailures: number;
|
|
7
|
+
/** Restart attempts since the last successful probe. Caps at maxRestarts. */
|
|
8
|
+
restartAttempts: number;
|
|
9
|
+
/** Clock value of the most recent restart attempt; gates backoff. */
|
|
10
|
+
lastRestartAt?: number;
|
|
11
|
+
}
|
|
12
|
+
export interface ProductHealthMonitorOptions {
|
|
13
|
+
/** Products to consider. Non-container products are ignored (externally
|
|
14
|
+
* owned), so this can safely be the unfiltered `productService.list`. */
|
|
15
|
+
listProducts: () => ProductRegistration[];
|
|
16
|
+
/** Liveness probe — true means healthy. Injected so tests don't hit the
|
|
17
|
+
* network; the daemon wires `probeProductHealth`. */
|
|
18
|
+
probe: (reg: ProductRegistration) => Promise<boolean>;
|
|
19
|
+
/** Recovery action for a product over threshold. Typically
|
|
20
|
+
* `productService.restart`. Rejection counts as a failed attempt. */
|
|
21
|
+
restart: (name: string) => Promise<void>;
|
|
22
|
+
/** Consecutive failed probes before a restart is attempted. Default 3. */
|
|
23
|
+
failureThreshold?: number;
|
|
24
|
+
/** Restart attempts before giving up (until a healthy probe resets). Default 3. */
|
|
25
|
+
maxRestarts?: number;
|
|
26
|
+
/** Minimum ms between restart attempts for one product. Default 30_000. */
|
|
27
|
+
restartBackoffMs?: number;
|
|
28
|
+
/** Poll cadence used by start(). Default 15_000. */
|
|
29
|
+
intervalMs?: number;
|
|
30
|
+
/** Clock seam for backoff. Default Date.now. */
|
|
31
|
+
now?: () => number;
|
|
32
|
+
/** Fired when a product's status changes — for SSE / log surfacing. */
|
|
33
|
+
onChange?: (name: string, state: ProductHealthState) => void;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Periodically probes each container product's health endpoint and, after
|
|
37
|
+
* repeated failures, auto-restarts it (model B: the daemon owns container
|
|
38
|
+
* lifecycle). Reporting and recovery in one loop: status is exposed via
|
|
39
|
+
* `health()`/`snapshot()` and pushed through `onChange`; recovery is bounded
|
|
40
|
+
* by a consecutive-failure threshold, a per-product restart cap, and a
|
|
41
|
+
* backoff window so a genuinely-broken product can't be thrashed.
|
|
42
|
+
*/
|
|
43
|
+
export declare class ProductHealthMonitor {
|
|
44
|
+
private readonly listProducts;
|
|
45
|
+
private readonly probe;
|
|
46
|
+
private readonly restart;
|
|
47
|
+
private readonly failureThreshold;
|
|
48
|
+
private readonly maxRestarts;
|
|
49
|
+
private readonly restartBackoffMs;
|
|
50
|
+
private readonly intervalMs;
|
|
51
|
+
private readonly now;
|
|
52
|
+
private readonly onChange?;
|
|
53
|
+
private readonly states;
|
|
54
|
+
private timer;
|
|
55
|
+
private inFlight;
|
|
56
|
+
constructor(opts: ProductHealthMonitorOptions);
|
|
57
|
+
health(name: string): ProductHealthState;
|
|
58
|
+
snapshot(): Record<string, ProductHealthStatus>;
|
|
59
|
+
start(): void;
|
|
60
|
+
stop(): void;
|
|
61
|
+
/** One probe sweep across all container products. Overlapping calls coalesce
|
|
62
|
+
* onto the in-flight sweep so a slow restart can't stack ticks. */
|
|
63
|
+
poll(): Promise<void>;
|
|
64
|
+
private pollInner;
|
|
65
|
+
private checkOne;
|
|
66
|
+
private set;
|
|
67
|
+
}
|
|
68
|
+
/** Default liveness probe: GET the product's manifest-declared health path
|
|
69
|
+
* (default /healthz) with a short timeout. Any non-2xx, network error, or
|
|
70
|
+
* timeout reads as unhealthy. Container-only — dev products are externally
|
|
71
|
+
* owned and never reach here. */
|
|
72
|
+
export declare function probeProductHealth(reg: ProductRegistration, timeoutMs?: number): Promise<boolean>;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { logger } from "@norskvideo/ctl-foundation";
|
|
2
|
+
import { specBaseUrl } from "./manifest-fetch.js";
|
|
3
|
+
const UNKNOWN = { status: "unknown", consecutiveFailures: 0, restartAttempts: 0 };
|
|
4
|
+
/**
|
|
5
|
+
* Periodically probes each container product's health endpoint and, after
|
|
6
|
+
* repeated failures, auto-restarts it (model B: the daemon owns container
|
|
7
|
+
* lifecycle). Reporting and recovery in one loop: status is exposed via
|
|
8
|
+
* `health()`/`snapshot()` and pushed through `onChange`; recovery is bounded
|
|
9
|
+
* by a consecutive-failure threshold, a per-product restart cap, and a
|
|
10
|
+
* backoff window so a genuinely-broken product can't be thrashed.
|
|
11
|
+
*/
|
|
12
|
+
export class ProductHealthMonitor {
|
|
13
|
+
listProducts;
|
|
14
|
+
probe;
|
|
15
|
+
restart;
|
|
16
|
+
failureThreshold;
|
|
17
|
+
maxRestarts;
|
|
18
|
+
restartBackoffMs;
|
|
19
|
+
intervalMs;
|
|
20
|
+
now;
|
|
21
|
+
onChange;
|
|
22
|
+
states = new Map();
|
|
23
|
+
timer = null;
|
|
24
|
+
inFlight = null;
|
|
25
|
+
constructor(opts) {
|
|
26
|
+
this.listProducts = opts.listProducts;
|
|
27
|
+
this.probe = opts.probe;
|
|
28
|
+
this.restart = opts.restart;
|
|
29
|
+
this.failureThreshold = opts.failureThreshold ?? 3;
|
|
30
|
+
this.maxRestarts = opts.maxRestarts ?? 3;
|
|
31
|
+
this.restartBackoffMs = opts.restartBackoffMs ?? 30_000;
|
|
32
|
+
this.intervalMs = opts.intervalMs ?? 15_000;
|
|
33
|
+
this.now = opts.now ?? Date.now;
|
|
34
|
+
this.onChange = opts.onChange;
|
|
35
|
+
}
|
|
36
|
+
health(name) {
|
|
37
|
+
return this.states.get(name) ?? UNKNOWN;
|
|
38
|
+
}
|
|
39
|
+
snapshot() {
|
|
40
|
+
const out = {};
|
|
41
|
+
for (const [name, state] of this.states)
|
|
42
|
+
out[name] = state.status;
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
start() {
|
|
46
|
+
if (this.timer)
|
|
47
|
+
return;
|
|
48
|
+
void this.poll();
|
|
49
|
+
this.timer = setInterval(() => void this.poll(), this.intervalMs);
|
|
50
|
+
logger.debug(`Product health monitor started (${this.intervalMs}ms interval)`);
|
|
51
|
+
}
|
|
52
|
+
stop() {
|
|
53
|
+
if (this.timer) {
|
|
54
|
+
clearInterval(this.timer);
|
|
55
|
+
this.timer = null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** One probe sweep across all container products. Overlapping calls coalesce
|
|
59
|
+
* onto the in-flight sweep so a slow restart can't stack ticks. */
|
|
60
|
+
async poll() {
|
|
61
|
+
if (this.inFlight)
|
|
62
|
+
return this.inFlight;
|
|
63
|
+
this.inFlight = this.pollInner().finally(() => {
|
|
64
|
+
this.inFlight = null;
|
|
65
|
+
});
|
|
66
|
+
return this.inFlight;
|
|
67
|
+
}
|
|
68
|
+
async pollInner() {
|
|
69
|
+
const products = this.listProducts().filter((p) => p.spec.kind === "container");
|
|
70
|
+
const live = new Set(products.map((p) => p.name));
|
|
71
|
+
for (const name of [...this.states.keys()])
|
|
72
|
+
if (!live.has(name))
|
|
73
|
+
this.states.delete(name);
|
|
74
|
+
await Promise.all(products.map((reg) => this.checkOne(reg)));
|
|
75
|
+
}
|
|
76
|
+
async checkOne(reg) {
|
|
77
|
+
const prev = this.states.get(reg.name) ?? UNKNOWN;
|
|
78
|
+
let healthy;
|
|
79
|
+
try {
|
|
80
|
+
healthy = await this.probe(reg);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
healthy = false;
|
|
84
|
+
}
|
|
85
|
+
if (healthy) {
|
|
86
|
+
this.set(reg.name, { status: "healthy", consecutiveFailures: 0, restartAttempts: 0 }, prev);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const consecutiveFailures = prev.consecutiveFailures + 1;
|
|
90
|
+
const belowThreshold = consecutiveFailures < this.failureThreshold;
|
|
91
|
+
const gaveUp = prev.restartAttempts >= this.maxRestarts;
|
|
92
|
+
const backoffElapsed = prev.lastRestartAt === undefined || this.now() - prev.lastRestartAt >= this.restartBackoffMs;
|
|
93
|
+
if (belowThreshold || gaveUp || !backoffElapsed) {
|
|
94
|
+
this.set(reg.name, { ...prev, status: "unhealthy", consecutiveFailures }, prev);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
// Threshold reached, attempts left, backoff elapsed: attempt recovery.
|
|
98
|
+
const at = this.now();
|
|
99
|
+
const restartAttempts = prev.restartAttempts + 1;
|
|
100
|
+
this.set(reg.name, { status: "restarting", consecutiveFailures: 0, restartAttempts, lastRestartAt: at }, prev);
|
|
101
|
+
try {
|
|
102
|
+
await this.restart(reg.name);
|
|
103
|
+
// Stay "restarting"; the next sweep re-probes to confirm recovery.
|
|
104
|
+
}
|
|
105
|
+
catch (e) {
|
|
106
|
+
logger.warn(`Product '${reg.name}': restart failed — ${e instanceof Error ? e.message : String(e)}`);
|
|
107
|
+
this.set(reg.name, { status: "unhealthy", consecutiveFailures: 0, restartAttempts, lastRestartAt: at }, prev);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
set(name, next, prev) {
|
|
111
|
+
this.states.set(name, next);
|
|
112
|
+
if (next.status !== prev.status) {
|
|
113
|
+
logger.info(`Product '${name}' health: ${prev.status} -> ${next.status}`);
|
|
114
|
+
this.onChange?.(name, next);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** Default liveness probe: GET the product's manifest-declared health path
|
|
119
|
+
* (default /healthz) with a short timeout. Any non-2xx, network error, or
|
|
120
|
+
* timeout reads as unhealthy. Container-only — dev products are externally
|
|
121
|
+
* owned and never reach here. */
|
|
122
|
+
export async function probeProductHealth(reg, timeoutMs = 3_000) {
|
|
123
|
+
if (reg.spec.kind !== "container" || reg.port === undefined)
|
|
124
|
+
return false;
|
|
125
|
+
const path = reg.manifest.api?.healthCheckPath ?? "/healthz";
|
|
126
|
+
const base = specBaseUrl(reg.spec, reg.port);
|
|
127
|
+
try {
|
|
128
|
+
const r = await fetch(`${base}${path.startsWith("/") ? path : `/${path}`}`, {
|
|
129
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
130
|
+
});
|
|
131
|
+
return r.ok;
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { type LicenseStager } from "./license-registration.js";
|
|
2
|
+
import type { ProductTemplateSource } from "./product-template-record.js";
|
|
3
|
+
import type { ProductLicense, ProductRegistration, ProductSpec } from "./product-types.js";
|
|
4
|
+
/**
|
|
5
|
+
* Result of registering a new product. `warnings` reserved for future
|
|
6
|
+
* non-fatal advisories surfaced alongside the success message; currently
|
|
7
|
+
* always empty because the only earlier soft warning (vite-dev URL)
|
|
8
|
+
* graduated into a hard rejection.
|
|
9
|
+
*/
|
|
10
|
+
export interface AddProductResult {
|
|
11
|
+
registration: ProductRegistration;
|
|
12
|
+
warnings: string[];
|
|
13
|
+
}
|
|
14
|
+
export interface AddProductOpts {
|
|
15
|
+
/** Per-product license stored on the registration record (#313). Callers
|
|
16
|
+
* typically seed this from their global license setting when the operator
|
|
17
|
+
* doesn't supply one at add time. */
|
|
18
|
+
license?: ProductLicense;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Storage backend for the registered-product list. Each consumer (norsk-ctl,
|
|
22
|
+
* norsk-mgr, …) supplies its own — typically a YAML file under the app's
|
|
23
|
+
* store directory, but the abstraction means anything addressable works.
|
|
24
|
+
*/
|
|
25
|
+
export interface ProductStore {
|
|
26
|
+
read(): ProductRegistration[];
|
|
27
|
+
update(fn: (current: ProductRegistration[]) => ProductRegistration[]): Promise<ProductRegistration[]>;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Allocates a port from a per-app range for container-kind products. ctl and
|
|
31
|
+
* mgr each have their own port range; supplying the allocator as a
|
|
32
|
+
* dependency keeps the SDK out of that business.
|
|
33
|
+
*
|
|
34
|
+
* Returns null when no free port is available; ProductService.add() turns
|
|
35
|
+
* that into a PORT_EXHAUSTED ProductError.
|
|
36
|
+
*/
|
|
37
|
+
export type AllocatePortFn = (used: Record<string, {
|
|
38
|
+
port: number;
|
|
39
|
+
}>) => number | null;
|
|
40
|
+
/** Callback consumers inject so the SDK can hand each manifest-declared
|
|
41
|
+
* default product template to the host's product-template store. The signature
|
|
42
|
+
* is intentionally narrow — `{ name, bytes, source }` is the common shape
|
|
43
|
+
* norsk-ctl's `ProductTemplateService.importFromBytes` and norsk-mgr's
|
|
44
|
+
* `saveProductTemplate` both already implement. */
|
|
45
|
+
export type ImportProductTemplateBytesFn = (opts: {
|
|
46
|
+
name: string;
|
|
47
|
+
bytes: Uint8Array;
|
|
48
|
+
source: Extract<ProductTemplateSource, {
|
|
49
|
+
kind: "product-default";
|
|
50
|
+
}>;
|
|
51
|
+
}) => Promise<void>;
|
|
52
|
+
/** The container operations stopAll/restoreAll depend on, behind an interface
|
|
53
|
+
* so tests can drive them without shelling out to Docker. Defaults wire
|
|
54
|
+
* straight to docker-runner + the readiness probe. */
|
|
55
|
+
export interface ProductContainerOps {
|
|
56
|
+
run(image: string, hostPort: number): Promise<string>;
|
|
57
|
+
remove(containerId: string): Promise<void>;
|
|
58
|
+
rename(containerId: string, name: string): Promise<void>;
|
|
59
|
+
waitForReady(baseUrl: string): Promise<void>;
|
|
60
|
+
}
|
|
61
|
+
export interface ProductServiceOptions {
|
|
62
|
+
store: ProductStore;
|
|
63
|
+
allocatePort: AllocatePortFn;
|
|
64
|
+
/** Optional: called once per `manifest.defaultProductTemplates` entry at
|
|
65
|
+
* registration time. Omit on hosts that don't store product templates
|
|
66
|
+
* (defaultProductTemplates is then silently skipped). */
|
|
67
|
+
importProductTemplateBytes?: ImportProductTemplateBytesFn;
|
|
68
|
+
/** Optional: copy a byol licence somewhere the host owns, so the path
|
|
69
|
+
* recorded in the registry outlives whatever the operator passed in.
|
|
70
|
+
* Omit on hosts that don't stage (the operator's path is then recorded). */
|
|
71
|
+
stageLicense?: LicenseStager;
|
|
72
|
+
/** Liveness probe for dev-mode products. Injectable for tests; defaults to a
|
|
73
|
+
* fast timeout-bounded GET of the dev URL's `/manifest.json`. */
|
|
74
|
+
isDevUrlAlive?: (baseUrl: string) => Promise<boolean>;
|
|
75
|
+
/** Container lifecycle ops used by stopAll/restoreAll. Injectable for tests;
|
|
76
|
+
* defaults to the real docker-runner functions. */
|
|
77
|
+
containerOps?: ProductContainerOps;
|
|
78
|
+
}
|
|
79
|
+
export declare class ProductService {
|
|
80
|
+
private readonly store;
|
|
81
|
+
private readonly allocatePort;
|
|
82
|
+
private readonly importProductTemplateBytes?;
|
|
83
|
+
private readonly stageLicense?;
|
|
84
|
+
private readonly isDevUrlAlive;
|
|
85
|
+
private readonly containerOps;
|
|
86
|
+
constructor(opts: ProductServiceOptions);
|
|
87
|
+
list(): ProductRegistration[];
|
|
88
|
+
/** Whether the product is actually up. Container-mode: we started it, so a
|
|
89
|
+
* tracked containerId means running. Dev-mode: externally owned, so probe
|
|
90
|
+
* the dev URL — registered no longer implies running. */
|
|
91
|
+
isRunning(reg: ProductRegistration): Promise<boolean>;
|
|
92
|
+
add(spec: ProductSpec, opts?: AddProductOpts): Promise<AddProductResult>;
|
|
93
|
+
remove(name: string): Promise<void>;
|
|
94
|
+
reload(name: string): Promise<ProductRegistration>;
|
|
95
|
+
/** Stop every running container-kind product and clear its tracked
|
|
96
|
+
* containerId (model B: the daemon owns container lifecycle, so it reaps
|
|
97
|
+
* the control planes it started). Dev-kind products are externally owned —
|
|
98
|
+
* left alone. Clearing the id keeps the persisted store honest: a stale id
|
|
99
|
+
* left behind would make isRunning() point at a container `--rm` reaped.
|
|
100
|
+
* Best-effort per product — a failing `docker rm` is logged, never thrown,
|
|
101
|
+
* so one stubborn container can't block a clean shutdown. */
|
|
102
|
+
stopAll(): Promise<void>;
|
|
103
|
+
/** Relaunch every container-kind product recorded in the store, refreshing
|
|
104
|
+
* its containerId (model B: called once at daemon boot to re-create the
|
|
105
|
+
* control planes stopped on the previous shutdown). Dev-kind products are
|
|
106
|
+
* externally owned — skipped. Best-effort per product: one that fails to
|
|
107
|
+
* come up is logged and left with its containerId cleared, so isRunning()
|
|
108
|
+
* reports it down rather than pointing at a container that never started. */
|
|
109
|
+
restoreAll(): Promise<void>;
|
|
110
|
+
/** Stop (if still present) and relaunch a single container-kind product,
|
|
111
|
+
* recording the fresh containerId. Used by the health monitor to recover a
|
|
112
|
+
* product that has failed its liveness probe. Unlike restoreAll this is not
|
|
113
|
+
* best-effort: a relaunch that never comes ready rejects, so the monitor
|
|
114
|
+
* can count the failed attempt and eventually give up. The new id is
|
|
115
|
+
* persisted before the readiness wait, so even a timed-out restart leaves a
|
|
116
|
+
* tracked container the next restart can reap rather than orphan. */
|
|
117
|
+
restart(name: string): Promise<void>;
|
|
118
|
+
}
|