@norskvideo/ctl-sdk 0.1.42 → 0.1.44
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/manifest-schema.d.ts +30 -2
- package/manifest-schema.js +28 -0
- package/package.json +1 -1
- package/parsing.d.ts +6 -0
- package/parsing.js +24 -0
- package/product-error.d.ts +6 -2
- package/product-service.d.ts +34 -2
- package/product-service.js +65 -1
- package/product-types.d.ts +14 -0
package/manifest-schema.d.ts
CHANGED
|
@@ -9,6 +9,19 @@ declare const DefaultProductTemplateSchema: z.ZodObject<{
|
|
|
9
9
|
url: z.ZodString;
|
|
10
10
|
}, z.core.$strip>;
|
|
11
11
|
export type DefaultProductTemplate = z.infer<typeof DefaultProductTemplateSchema>;
|
|
12
|
+
/** One product role (RFC 0003 D5a). The name rule is foundation's, so the
|
|
13
|
+
* runner that stamps it and the manifest that declares it cannot disagree. */
|
|
14
|
+
declare const ProductRoleSchema: z.ZodObject<{
|
|
15
|
+
name: z.ZodString;
|
|
16
|
+
description: z.ZodString;
|
|
17
|
+
defaultFor: z.ZodOptional<z.ZodEnum<{
|
|
18
|
+
viewer: "viewer";
|
|
19
|
+
operator: "operator";
|
|
20
|
+
builder: "builder";
|
|
21
|
+
admin: "admin";
|
|
22
|
+
}>>;
|
|
23
|
+
}, z.core.$strip>;
|
|
24
|
+
export type ProductRoleDeclaration = z.infer<typeof ProductRoleSchema>;
|
|
12
25
|
export declare const ManifestSchema: z.ZodObject<{
|
|
13
26
|
manifestSchemaVersion: z.ZodLiteral<1>;
|
|
14
27
|
name: z.ZodString;
|
|
@@ -16,6 +29,18 @@ export declare const ManifestSchema: z.ZodObject<{
|
|
|
16
29
|
minRunnerVersion: z.ZodOptional<z.ZodString>;
|
|
17
30
|
requires: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
18
31
|
accessModes: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
32
|
+
access: z.ZodOptional<z.ZodObject<{
|
|
33
|
+
roles: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
34
|
+
name: z.ZodString;
|
|
35
|
+
description: z.ZodString;
|
|
36
|
+
defaultFor: z.ZodOptional<z.ZodEnum<{
|
|
37
|
+
viewer: "viewer";
|
|
38
|
+
operator: "operator";
|
|
39
|
+
builder: "builder";
|
|
40
|
+
admin: "admin";
|
|
41
|
+
}>>;
|
|
42
|
+
}, z.core.$strip>>>;
|
|
43
|
+
}, z.core.$strip>>;
|
|
19
44
|
api: z.ZodObject<{
|
|
20
45
|
basePath: z.ZodString;
|
|
21
46
|
proxyPaths: z.ZodArray<z.ZodString>;
|
|
@@ -73,9 +98,9 @@ export declare const ManifestSchema: z.ZodObject<{
|
|
|
73
98
|
}, z.core.$strip>>>;
|
|
74
99
|
request: z.ZodOptional<z.ZodObject<{
|
|
75
100
|
method: z.ZodEnum<{
|
|
101
|
+
PUT: "PUT";
|
|
76
102
|
GET: "GET";
|
|
77
103
|
POST: "POST";
|
|
78
|
-
PUT: "PUT";
|
|
79
104
|
DELETE: "DELETE";
|
|
80
105
|
}>;
|
|
81
106
|
path: z.ZodString;
|
|
@@ -83,9 +108,9 @@ export declare const ManifestSchema: z.ZodObject<{
|
|
|
83
108
|
}, z.core.$strip>>>;
|
|
84
109
|
request: z.ZodOptional<z.ZodObject<{
|
|
85
110
|
method: z.ZodEnum<{
|
|
111
|
+
PUT: "PUT";
|
|
86
112
|
GET: "GET";
|
|
87
113
|
POST: "POST";
|
|
88
|
-
PUT: "PUT";
|
|
89
114
|
DELETE: "DELETE";
|
|
90
115
|
}>;
|
|
91
116
|
path: z.ZodString;
|
|
@@ -169,6 +194,9 @@ export declare const ManifestSchema: z.ZodObject<{
|
|
|
169
194
|
}>;
|
|
170
195
|
value: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
171
196
|
}, z.core.$strip>>;
|
|
197
|
+
st2110Nics: z.ZodOptional<z.ZodObject<{
|
|
198
|
+
count: z.ZodNumber;
|
|
199
|
+
}, z.core.$strip>>;
|
|
172
200
|
}, z.core.$strip>>;
|
|
173
201
|
defaultProductTemplates: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
174
202
|
name: z.ZodString;
|
package/manifest-schema.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { PLATFORM_ROLE_LADDER, parseProductRoleName } from "@norskvideo/ctl-foundation";
|
|
1
2
|
import { ProductTemplateRequirementsSchema } from "@norskvideo/ctl-product-template-schema";
|
|
2
3
|
import { z } from "zod";
|
|
3
4
|
const TargetSchema = z.enum(["norsk-ctl", "docker-compose"]);
|
|
@@ -91,6 +92,30 @@ const ExportSchema = z.object({
|
|
|
91
92
|
include: z.array(z.string()).default([]),
|
|
92
93
|
exclude: z.array(z.string()).default([]),
|
|
93
94
|
});
|
|
95
|
+
/** One product role (RFC 0003 D5a). The name rule is foundation's, so the
|
|
96
|
+
* runner that stamps it and the manifest that declares it cannot disagree. */
|
|
97
|
+
const ProductRoleSchema = z.object({
|
|
98
|
+
name: z
|
|
99
|
+
.string()
|
|
100
|
+
.refine((name) => parseProductRoleName(name).status === "ok", {
|
|
101
|
+
message: "a product role is lower-kebab and never a platform role's name",
|
|
102
|
+
})
|
|
103
|
+
.meta({
|
|
104
|
+
description: "Bare role name, as Studio's access policy names it (`mcr-operator`). Lower-kebab, starting with a letter, never a platform role's name. Travels bare in `X-Norsk-Roles`, so two products on one host may share a name only if they agree on its `defaultFor`.",
|
|
105
|
+
}),
|
|
106
|
+
description: z.string().meta({ description: "What holding this role is for, for the operator assigning it." }),
|
|
107
|
+
defaultFor: z.enum(PLATFORM_ROLE_LADDER).optional().meta({
|
|
108
|
+
description: "A platform rung whose holders get this role implicitly on this product's instances (that rung or stronger). An implicit assignment a product release could widen, so the runner records it on add and reload, and an operator can switch implicit defaults off (`acceptProductDefaults: false`).",
|
|
109
|
+
}),
|
|
110
|
+
});
|
|
111
|
+
const AccessSchema = z.object({
|
|
112
|
+
roles: z
|
|
113
|
+
.array(ProductRoleSchema)
|
|
114
|
+
.default([])
|
|
115
|
+
.refine((roles) => new Set(roles.map((r) => r.name)).size === roles.length, {
|
|
116
|
+
message: "a product role is declared at most once",
|
|
117
|
+
}),
|
|
118
|
+
});
|
|
94
119
|
export const ManifestSchema = z.object({
|
|
95
120
|
manifestSchemaVersion: z.literal(1),
|
|
96
121
|
name: z.string(),
|
|
@@ -102,6 +127,9 @@ export const ManifestSchema = z.object({
|
|
|
102
127
|
accessModes: z.array(z.string().min(1)).optional().meta({
|
|
103
128
|
description: "The NORSK_PRODUCT_ACCESS values this product's control plane understands. The runner sets that variable only when the mode it is in appears here; absent, it sets nothing and the control plane runs with its gate dormant. This is the POSITIVE signal RFC 0001's 2026-09-21 amendment requires before a product may decline to serve: a product must never infer that it was promised enforcement from a missing variable, because a developer running it directly and an older runner launching it are indistinguishable from the inside. Declared on the product manifest rather than a product template because the control plane is started by `docker run` at `product add`, before any template for it exists -- templates are fetched FROM it -- and because this manifest is served by the very image that enforces, so the two cannot drift. A list rather than a boolean: the runner must never send a word this image predates.",
|
|
104
129
|
}),
|
|
130
|
+
access: AccessSchema.optional().meta({
|
|
131
|
+
description: "The product roles this product defines (RFC 0003 D5a/D6). Additive: a manifest without it declares none, and every principal reaches its instances on platform roles alone, as before.",
|
|
132
|
+
}),
|
|
105
133
|
api: z.object({
|
|
106
134
|
basePath: z.string(),
|
|
107
135
|
proxyPaths: z.array(z.string()),
|
package/package.json
CHANGED
package/parsing.d.ts
CHANGED
|
@@ -6,4 +6,10 @@ export declare function parseSpec(raw: unknown, where: string, path: string): Pr
|
|
|
6
6
|
export interface ProductsFile {
|
|
7
7
|
products: ProductRegistration[];
|
|
8
8
|
}
|
|
9
|
+
/** The products file's schema version. Unstamped files are version 0 (every
|
|
10
|
+
* file written so far); a file stamped with a higher version was written by
|
|
11
|
+
* a newer norsk-ctl or norsk-mgr and is refused rather than misread, the
|
|
12
|
+
* rule every other store follows. Bump when the shape changes and add the
|
|
13
|
+
* migration at the store. */
|
|
14
|
+
export declare const PRODUCTS_FILE_VERSION = 0;
|
|
9
15
|
export declare function parseProductsFile(raw: unknown, path: string): ProductsFile;
|
package/parsing.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parsePlatformRole } from "@norskvideo/ctl-foundation";
|
|
1
2
|
import { ManifestSchema } from "./manifest-schema.js";
|
|
2
3
|
export class ProductsParseError extends Error {
|
|
3
4
|
constructor(path, reason) {
|
|
@@ -59,9 +60,19 @@ function parseLicense(raw, where, path) {
|
|
|
59
60
|
}
|
|
60
61
|
throw new ProductsParseError(path, `${where}.license.mode must be 'byol' or 'marketplace'`);
|
|
61
62
|
}
|
|
63
|
+
/** The products file's schema version. Unstamped files are version 0 (every
|
|
64
|
+
* file written so far); a file stamped with a higher version was written by
|
|
65
|
+
* a newer norsk-ctl or norsk-mgr and is refused rather than misread, the
|
|
66
|
+
* rule every other store follows. Bump when the shape changes and add the
|
|
67
|
+
* migration at the store. */
|
|
68
|
+
export const PRODUCTS_FILE_VERSION = 0;
|
|
62
69
|
export function parseProductsFile(raw, path) {
|
|
63
70
|
if (!isRecord(raw))
|
|
64
71
|
throw new ProductsParseError(path, "file is not a YAML object");
|
|
72
|
+
if (typeof raw.version === "number" && raw.version > PRODUCTS_FILE_VERSION) {
|
|
73
|
+
throw new ProductsParseError(path, `is at version ${raw.version}, written by a newer norsk-ctl/norsk-mgr; this one understands up to ${PRODUCTS_FILE_VERSION}. ` +
|
|
74
|
+
"Run the newer one, or restore the pre-upgrade copy kept beside the file.");
|
|
75
|
+
}
|
|
65
76
|
const products = raw.products;
|
|
66
77
|
if (products === undefined)
|
|
67
78
|
return { products: [] };
|
|
@@ -100,6 +111,19 @@ export function parseProductsFile(raw, path) {
|
|
|
100
111
|
reg.reachHost = entry.reachHost;
|
|
101
112
|
if (entry.license !== undefined)
|
|
102
113
|
reg.license = parseLicense(entry.license, where, path);
|
|
114
|
+
if (Array.isArray(entry.acceptedRoleDefaults)) {
|
|
115
|
+
// Anything malformed is dropped rather than refused: this is an
|
|
116
|
+
// acceptance record, and the safe reading of a damaged one is "accepted
|
|
117
|
+
// less", which only ever withholds a default.
|
|
118
|
+
reg.acceptedRoleDefaults = entry.acceptedRoleDefaults.flatMap((d) => {
|
|
119
|
+
const { name, defaultFor } = (d ?? {});
|
|
120
|
+
return typeof name === "string" &&
|
|
121
|
+
typeof defaultFor === "string" &&
|
|
122
|
+
parsePlatformRole(defaultFor).status === "ok"
|
|
123
|
+
? [{ name, defaultFor: defaultFor }]
|
|
124
|
+
: [];
|
|
125
|
+
});
|
|
126
|
+
}
|
|
103
127
|
validated.push(reg);
|
|
104
128
|
}
|
|
105
129
|
return { products: validated };
|
package/product-error.d.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
export declare class ProductError extends Error {
|
|
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" | "RUNNER_TOO_OLD" | "PRODUCT_TEMPLATE_FETCH_FAILED"
|
|
3
|
-
|
|
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" | "RUNNER_TOO_OLD" | "PRODUCT_TEMPLATE_FETCH_FAILED"
|
|
3
|
+
/** Two products declare one bare role name with different `defaultFor`. */
|
|
4
|
+
| "ROLE_CONFLICT";
|
|
5
|
+
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" | "RUNNER_TOO_OLD" | "PRODUCT_TEMPLATE_FETCH_FAILED"
|
|
6
|
+
/** Two products declare one bare role name with different `defaultFor`. */
|
|
7
|
+
| "ROLE_CONFLICT", message: string);
|
|
4
8
|
}
|
package/product-service.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { type ProductPlacement } from "./docker-runner.js";
|
|
2
2
|
import { type LicenseStager } from "./license-registration.js";
|
|
3
|
-
import type { Manifest } from "./manifest-schema.js";
|
|
3
|
+
import type { Manifest, ProductRoleDeclaration } from "./manifest-schema.js";
|
|
4
4
|
import { type ProductReach } from "./product-reach.js";
|
|
5
5
|
import type { ProductTemplateSource } from "./product-template-record.js";
|
|
6
|
-
import type { ProductLicense, ProductRegistration, ProductSpec } from "./product-types.js";
|
|
6
|
+
import type { ProductLicense, ProductRegistration, ProductSpec, RoleDefault } from "./product-types.js";
|
|
7
7
|
/**
|
|
8
8
|
* Result of registering a new product. `warnings` reserved for future
|
|
9
9
|
* non-fatal advisories surfaced alongside the success message; currently
|
|
@@ -150,6 +150,18 @@ export interface ProductServiceOptions {
|
|
|
150
150
|
/** Drop whatever `identityEnvFor` minted, once the registration holding it
|
|
151
151
|
* has gone. */
|
|
152
152
|
forgetIdentity?: (productName: string) => void;
|
|
153
|
+
/**
|
|
154
|
+
* Told the product roles a manifest declares (RFC 0003 D5a), once per add
|
|
155
|
+
* and per reload that stores it. `defaultFor` is an implicit assignment a
|
|
156
|
+
* product release can widen, so the host records every declaration rather
|
|
157
|
+
* than taking it silently. Omitted by hosts that assign no product roles.
|
|
158
|
+
*/
|
|
159
|
+
onRolesDeclared?: (productName: string, roles: readonly ProductRoleDeclaration[], acceptance: {
|
|
160
|
+
pending: readonly RoleDefault[];
|
|
161
|
+
}) => void;
|
|
162
|
+
/** Told once a registration is removed, so the host can drop whatever it
|
|
163
|
+
* holds against the product (role assignments on `product/<name>`). */
|
|
164
|
+
onRemoved?: (productName: string) => void;
|
|
153
165
|
/**
|
|
154
166
|
* What the daemon asserts when it dials a REGISTERED product's own routes —
|
|
155
167
|
* the config-screen probe and the product-template fetches (RFC 0003 D7's
|
|
@@ -205,6 +217,16 @@ export declare class ProductService {
|
|
|
205
217
|
/** The address to record for a freshly started container: its own, when
|
|
206
218
|
* this daemon cannot dial the host loopback; otherwise nothing. */
|
|
207
219
|
private reachHostFor;
|
|
220
|
+
private readonly onRolesDeclared?;
|
|
221
|
+
private readonly onRemoved?;
|
|
222
|
+
/**
|
|
223
|
+
* Product roles travel bare, so a name means one thing on a host. Two
|
|
224
|
+
* products may share one — a common vocabulary is the point — but not with
|
|
225
|
+
* different `defaultFor`, which would make "who implicitly holds it" depend
|
|
226
|
+
* on which manifest was read last. Named in the refusal: the operator has to
|
|
227
|
+
* know which two products to talk to.
|
|
228
|
+
*/
|
|
229
|
+
private refuseRoleConflicts;
|
|
208
230
|
private refuseUnknownCapabilities;
|
|
209
231
|
list(): ProductRegistration[];
|
|
210
232
|
/** Docker's default `--pull=missing` keeps whatever a moving tag resolved to
|
|
@@ -257,3 +279,13 @@ export declare class ProductService {
|
|
|
257
279
|
restart(name: string): Promise<void>;
|
|
258
280
|
private restartSerialised;
|
|
259
281
|
}
|
|
282
|
+
/**
|
|
283
|
+
* A registered product's roles as they take effect (RFC 0003 D5): every
|
|
284
|
+
* declared role, carrying its `defaultFor` only where the operator accepted
|
|
285
|
+
* that exact pair. What the host reads, so an unaccepted widening grants
|
|
286
|
+
* nobody anything.
|
|
287
|
+
*/
|
|
288
|
+
export declare function effectiveRoleDeclarations(registration: Pick<ProductRegistration, "manifest" | "acceptedRoleDefaults">): {
|
|
289
|
+
name: string;
|
|
290
|
+
defaultFor?: RoleDefault["defaultFor"];
|
|
291
|
+
}[];
|
package/product-service.js
CHANGED
|
@@ -73,6 +73,8 @@ export class ProductService {
|
|
|
73
73
|
this.reach = opts.reach ?? detectProductReach({});
|
|
74
74
|
this.knownCapabilities = opts.knownCapabilities ?? [];
|
|
75
75
|
this.identityEnvFor = opts.identityEnvFor;
|
|
76
|
+
this.onRolesDeclared = opts.onRolesDeclared;
|
|
77
|
+
this.onRemoved = opts.onRemoved;
|
|
76
78
|
this.forgetIdentity = opts.forgetIdentity;
|
|
77
79
|
this.probeHeadersFor = opts.probeHeadersFor;
|
|
78
80
|
}
|
|
@@ -94,6 +96,30 @@ export class ProductService {
|
|
|
94
96
|
return undefined;
|
|
95
97
|
return this.containerOps.address(containerId);
|
|
96
98
|
}
|
|
99
|
+
onRolesDeclared;
|
|
100
|
+
onRemoved;
|
|
101
|
+
/**
|
|
102
|
+
* Product roles travel bare, so a name means one thing on a host. Two
|
|
103
|
+
* products may share one — a common vocabulary is the point — but not with
|
|
104
|
+
* different `defaultFor`, which would make "who implicitly holds it" depend
|
|
105
|
+
* on which manifest was read last. Named in the refusal: the operator has to
|
|
106
|
+
* know which two products to talk to.
|
|
107
|
+
*/
|
|
108
|
+
refuseRoleConflicts(manifest, others) {
|
|
109
|
+
for (const role of manifest.access?.roles ?? []) {
|
|
110
|
+
for (const other of others) {
|
|
111
|
+
if (other.name === manifest.name)
|
|
112
|
+
continue;
|
|
113
|
+
const theirs = other.manifest.access?.roles.find((r) => r.name === role.name);
|
|
114
|
+
if (theirs !== undefined && theirs.defaultFor !== role.defaultFor) {
|
|
115
|
+
return new ProductError("ROLE_CONFLICT", `product role '${role.name}' is declared by '${other.name}' with defaultFor ${theirs.defaultFor ?? "none"} ` +
|
|
116
|
+
`and by '${manifest.name}' with defaultFor ${role.defaultFor ?? "none"}; product roles are bare names, ` +
|
|
117
|
+
"so two products must agree on one before both can be registered");
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
97
123
|
refuseUnknownCapabilities(manifest) {
|
|
98
124
|
const check = checkRequiredCapabilities({ product: manifest.name, version: manifest.version, requires: manifest.requires }, this.knownCapabilities);
|
|
99
125
|
return check.status === "error" ? new ProductError("RUNNER_TOO_OLD", check.error.message) : undefined;
|
|
@@ -172,6 +198,12 @@ export class ProductService {
|
|
|
172
198
|
await this.containerOps.remove(containerId);
|
|
173
199
|
throw new ProductError("NAME_CONFLICT", `product '${manifest.name}' already registered`);
|
|
174
200
|
}
|
|
201
|
+
const roleConflict = this.refuseRoleConflicts(manifest, existing);
|
|
202
|
+
if (roleConflict) {
|
|
203
|
+
if (containerId)
|
|
204
|
+
await this.containerOps.remove(containerId);
|
|
205
|
+
throw roleConflict;
|
|
206
|
+
}
|
|
175
207
|
const warnings = [];
|
|
176
208
|
// Licensing V2: a per-product license file must actually entitle this
|
|
177
209
|
// product (name + image ref/version + expiry, root signature). Anything
|
|
@@ -280,6 +312,8 @@ export class ProductService {
|
|
|
280
312
|
spec,
|
|
281
313
|
addedAt: new Date().toISOString(),
|
|
282
314
|
manifest,
|
|
315
|
+
// What the operator saw at add is what is accepted (RFC 0003 D5).
|
|
316
|
+
acceptedRoleDefaults: roleDefaultsOf(manifest),
|
|
283
317
|
...(port !== undefined ? { port } : {}),
|
|
284
318
|
...(containerId !== undefined ? { containerId } : {}),
|
|
285
319
|
...(reachHost !== undefined ? { reachHost } : {}),
|
|
@@ -293,6 +327,7 @@ export class ProductService {
|
|
|
293
327
|
};
|
|
294
328
|
await this.store.update((products) => [...products, registration]);
|
|
295
329
|
logger.info(`Product '${manifest.name}' registered (${spec.kind})`);
|
|
330
|
+
this.onRolesDeclared?.(manifest.name, manifest.access?.roles ?? [], { pending: [] });
|
|
296
331
|
// defaultProductTemplates: fetch each declared URL relative to the
|
|
297
332
|
// product's base and hand the bytes to the host's product-template store.
|
|
298
333
|
// Failures here are downgraded to warnings rather than rolling back
|
|
@@ -339,6 +374,7 @@ export class ProductService {
|
|
|
339
374
|
// for a product nothing routes to. Safe because every launch mints afresh,
|
|
340
375
|
// so a re-added product of the same name is stamped anew.
|
|
341
376
|
this.forgetIdentity?.(name);
|
|
377
|
+
this.onRemoved?.(name);
|
|
342
378
|
logger.info(`Product '${name}' removed`);
|
|
343
379
|
}
|
|
344
380
|
/** Bring a registration up to date with what its image now serves. For a
|
|
@@ -383,12 +419,21 @@ export class ProductService {
|
|
|
383
419
|
const refusal = this.refuseUnknownCapabilities(manifest);
|
|
384
420
|
if (refusal)
|
|
385
421
|
throw refusal;
|
|
422
|
+
const roleConflict = this.refuseRoleConflicts(manifest, this.store.read());
|
|
423
|
+
if (roleConflict)
|
|
424
|
+
throw roleConflict;
|
|
386
425
|
if (manifest.name !== target.name) {
|
|
387
426
|
throw new ProductError("NAME_CONFLICT", `manifest now reports name '${manifest.name}', was '${target.name}' — use remove + add`);
|
|
388
427
|
}
|
|
389
|
-
|
|
428
|
+
// A reload may narrow what is accepted, never widen it: a default the
|
|
429
|
+
// operator did not see at add is pending until the product is re-added.
|
|
430
|
+
const declaredDefaults = roleDefaultsOf(manifest);
|
|
431
|
+
const accepted = declaredDefaults.filter((d) => sameDefault(target.acceptedRoleDefaults ?? [], d));
|
|
432
|
+
const pending = declaredDefaults.filter((d) => !sameDefault(accepted, d));
|
|
433
|
+
const updated = await this.store.update((products) => products.map((p) => (p.name === name ? { ...p, manifest, acceptedRoleDefaults: accepted } : p)));
|
|
390
434
|
const registration = updated.find((p) => p.name === name);
|
|
391
435
|
logger.info(`Product '${name}' reloaded${containerRestarted ? " (container re-run)" : ""}`);
|
|
436
|
+
this.onRolesDeclared?.(name, manifest.access?.roles ?? [], { pending });
|
|
392
437
|
// Same downgrade as add's default-template import: the product itself is
|
|
393
438
|
// healthy, so a template the host refuses (in use, foreign) or a fetch
|
|
394
439
|
// that fails is a warning the operator acts on, not a failed reload.
|
|
@@ -537,3 +582,22 @@ function withContainerId(reg, containerId, reachHost) {
|
|
|
537
582
|
function withPlacement(reg, containerId, hostPort, reachHost) {
|
|
538
583
|
return { ...withContainerId(reg, containerId, reachHost), port: hostPort };
|
|
539
584
|
}
|
|
585
|
+
/** The `defaultFor` pairs a manifest declares. */
|
|
586
|
+
function roleDefaultsOf(manifest) {
|
|
587
|
+
return (manifest.access?.roles ?? []).flatMap((r) => r.defaultFor === undefined ? [] : [{ name: r.name, defaultFor: r.defaultFor }]);
|
|
588
|
+
}
|
|
589
|
+
function sameDefault(set, d) {
|
|
590
|
+
return set.some((a) => a.name === d.name && a.defaultFor === d.defaultFor);
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* A registered product's roles as they take effect (RFC 0003 D5): every
|
|
594
|
+
* declared role, carrying its `defaultFor` only where the operator accepted
|
|
595
|
+
* that exact pair. What the host reads, so an unaccepted widening grants
|
|
596
|
+
* nobody anything.
|
|
597
|
+
*/
|
|
598
|
+
export function effectiveRoleDeclarations(registration) {
|
|
599
|
+
const accepted = registration.acceptedRoleDefaults ?? [];
|
|
600
|
+
return (registration.manifest.access?.roles ?? []).map((r) => r.defaultFor !== undefined && sameDefault(accepted, { name: r.name, defaultFor: r.defaultFor })
|
|
601
|
+
? { name: r.name, defaultFor: r.defaultFor }
|
|
602
|
+
: { name: r.name });
|
|
603
|
+
}
|
package/product-types.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { PlatformRole } from "@norskvideo/ctl-foundation";
|
|
1
2
|
import type { Manifest } from "./manifest-schema.js";
|
|
2
3
|
export type ProductSpec =
|
|
3
4
|
/** `env` is the deploy-time environment for the control-plane container,
|
|
@@ -38,4 +39,17 @@ export interface ProductRegistration {
|
|
|
38
39
|
/** Absent on registrations that predate per-product licenses — launches
|
|
39
40
|
* fall back to the host's global license setting. */
|
|
40
41
|
license?: ProductLicense;
|
|
42
|
+
/**
|
|
43
|
+
* The `defaultFor` pairs an operator has accepted for this product (RFC 0003
|
|
44
|
+
* D5). Set to the declared defaults at add — the declaration the operator
|
|
45
|
+
* saw — and only ever narrowed by a reload: a reload that widens a default
|
|
46
|
+
* leaves the widened pair out, reported as pending. Absent on registrations
|
|
47
|
+
* from before acceptance existed, which accepted nothing.
|
|
48
|
+
*/
|
|
49
|
+
acceptedRoleDefaults?: RoleDefault[];
|
|
50
|
+
}
|
|
51
|
+
/** One accepted `defaultFor`: the role and the rung it defaults to. */
|
|
52
|
+
export interface RoleDefault {
|
|
53
|
+
name: string;
|
|
54
|
+
defaultFor: PlatformRole;
|
|
41
55
|
}
|