@norskvideo/ctl-sdk 0.1.31 → 0.1.33

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.
@@ -10,6 +10,10 @@ export declare const PRODUCT_ROLE_LABEL = "norsk-ctl.role=product";
10
10
  * (see product-reach.ts). Omitted leaves it on docker's default bridge. */
11
11
  export interface ProductRunOpts {
12
12
  network?: string;
13
+ /** Deploy-time environment for the control-plane container, passed as
14
+ * `-e KEY=VALUE` (e.g. `FUNKE_HARDWARE=software`). The map is the caller's;
15
+ * what a product reads from it is the product's own contract. */
16
+ env?: Record<string, string>;
13
17
  }
14
18
  /** argv (sans leading "docker") that launches a product container: detached,
15
19
  * auto-removed, loopback-published to its internal 4321, and role-labelled so
package/docker-runner.js CHANGED
@@ -27,6 +27,7 @@ export function productRunArgs(image, opts = {}) {
27
27
  "--label",
28
28
  PRODUCT_ROLE_LABEL,
29
29
  ...(opts.network ? ["--network", opts.network] : []),
30
+ ...Object.entries(opts.env ?? {}).flatMap(([k, v]) => ["-e", `${k}=${v}`]),
30
31
  "-p",
31
32
  `127.0.0.1::${CONTAINER_INTERNAL_PORT}`,
32
33
  image,
package/index.d.ts CHANGED
@@ -11,6 +11,7 @@ export * from "./license-stager.js";
11
11
  export * from "./license-v2.js";
12
12
  export * from "./manifest-fetch.js";
13
13
  export * from "./manifest-router.js";
14
+ export * from "./migration-client.js";
14
15
  export * from "./openapi-router.js";
15
16
  export * from "./product-health-monitor.js";
16
17
  export * from "./product-hub.js";
package/index.js CHANGED
@@ -13,6 +13,7 @@ export * from "./license-stager.js";
13
13
  export * from "./license-v2.js";
14
14
  export * from "./manifest-fetch.js";
15
15
  export * from "./manifest-router.js";
16
+ export * from "./migration-client.js";
16
17
  export * from "./openapi-router.js";
17
18
  export * from "./product-health-monitor.js";
18
19
  export * from "./product-hub.js";
@@ -130,6 +130,23 @@ export declare const ManifestSchema: z.ZodObject<{
130
130
  model: z.ZodOptional<z.ZodString>;
131
131
  }, z.core.$strip>>;
132
132
  }, z.core.$strip>>;
133
+ accelerator: z.ZodOptional<z.ZodDiscriminatedUnion<[z.ZodObject<{
134
+ mode: z.ZodLiteral<"fixed">;
135
+ value: z.ZodEnum<{
136
+ none: "none";
137
+ nvidia: "nvidia";
138
+ quadra: "quadra";
139
+ }>;
140
+ }, z.core.$strip>, z.ZodObject<{
141
+ mode: z.ZodLiteral<"default">;
142
+ value: z.ZodEnum<{
143
+ none: "none";
144
+ nvidia: "nvidia";
145
+ quadra: "quadra";
146
+ }>;
147
+ }, z.core.$strip>, z.ZodObject<{
148
+ mode: z.ZodLiteral<"required">;
149
+ }, z.core.$strip>], "mode">>;
133
150
  capabilities: z.ZodOptional<z.ZodObject<{
134
151
  mode: z.ZodEnum<{
135
152
  default: "default";
@@ -0,0 +1,60 @@
1
+ export interface MigrationHandlers {
2
+ /** Source side: the target is ready at `payload`. Stop output at or after
3
+ * it, then `reportStoppedOutput`. */
4
+ onCutoverReady?(payload: unknown, migrationId: string): void | Promise<void>;
5
+ /** Target side: the source stopped at `payload`. Take over from it, then
6
+ * `reportApplied`. */
7
+ onCutoverApply?(payload: unknown, migrationId: string): void | Promise<void>;
8
+ /** Either side. A source resumes output; a target expects to be stopped. */
9
+ onAbort?(reason: string, migrationId: string): void | Promise<void>;
10
+ }
11
+ export interface MigrationClient {
12
+ /** "target" iff the worker's hello-ack said so (the instance was launched
13
+ * as a migration target); "none" when there is no socket at all. A source
14
+ * is "source" — every connected instance that is not a target may be
15
+ * asked to hand over. */
16
+ readonly role: "source" | "target" | "none";
17
+ /** The migration a target belongs to, or the one a source is in once a
18
+ * cutover-ready has named it. */
19
+ readonly migrationId?: string;
20
+ /** True while the socket is open and hello-acked. */
21
+ readonly connected: boolean;
22
+ /** Target: ready to take over at `payload` (a position). Once. */
23
+ reportReady(payload: unknown): void;
24
+ /** Source: output stopped at `payload` (a position). */
25
+ reportStoppedOutput(payload: unknown): void;
26
+ /** Target: the handover has been applied. */
27
+ reportApplied(): void;
28
+ /** Either side: give up on the migration. */
29
+ abort(reason: string): void;
30
+ /** Stop reconnecting and close. */
31
+ close(): void;
32
+ }
33
+ /** The subset of the WHATWG WebSocket API the client uses. `ws`'s WebSocket
34
+ * and the Node/Bun/browser globals all satisfy it. */
35
+ export interface MigrationSocket {
36
+ send(data: string): void;
37
+ close(code?: number, reason?: string): void;
38
+ onopen: ((ev: unknown) => void) | null;
39
+ onmessage: ((ev: {
40
+ data: unknown;
41
+ }) => void) | null;
42
+ onclose: ((ev: {
43
+ code: number;
44
+ reason: string;
45
+ }) => void) | null;
46
+ onerror: ((ev: unknown) => void) | null;
47
+ }
48
+ export type MigrationSocketCtor = new (url: string) => MigrationSocket;
49
+ export interface ConnectMigrationOptions {
50
+ /** Override the socket implementation (tests; a runtime with neither a
51
+ * global WebSocket nor `ws`). */
52
+ WebSocket?: MigrationSocketCtor;
53
+ /** Backoff bounds. */
54
+ reconnectMinMs?: number;
55
+ reconnectMaxMs?: number;
56
+ /** Where the client reports what it does; default: console.error, one
57
+ * line per event, prefixed. */
58
+ log?: (line: string) => void;
59
+ }
60
+ export declare function connectMigration(handlers: MigrationHandlers, env?: NodeJS.ProcessEnv, opts?: ConnectMigrationOptions): MigrationClient;
@@ -0,0 +1,267 @@
1
+ // The job side of the worker's job socket (norsk-ctl
2
+ // packages/norsk-mgr/docs/internal/design/job-migration.md §5): what a
3
+ // handover-migratable product calls at startup.
4
+ //
5
+ // const migration = connectMigration({
6
+ // onCutoverReady: async (payload) => { /* source: stop at >= payload */ migration.reportStoppedOutput({...}) },
7
+ // onCutoverApply: async (payload) => { /* target: take over from payload */ migration.reportApplied() },
8
+ // onAbort: (reason) => { /* source: resume output */ },
9
+ // });
10
+ // if (migration.role === "target") migration.reportReady({...});
11
+ //
12
+ // Reads NORSK_WORKER_WS_URL / NORSK_WORKER_TOKEN. When they are absent — a
13
+ // worker with `--no-job-socket`, a local `norsk-ctl` launch, a test — this is
14
+ // a no-op client with role "none", so a product calls it unconditionally.
15
+ //
16
+ // Transport: a WHATWG-shaped WebSocket. Node ≥ 22 and Bun have one as a
17
+ // global; on older Node the `ws` package supplies it. Reconnects with backoff
18
+ // (1 s doubling to 30 s) after any drop, re-sends hello, and holds at most
19
+ // the LATEST unsent report while disconnected — a newer position supersedes
20
+ // an older one, and the worker acts on the first it hears.
21
+ import { JOB_SOCKET_ENV, parseWorkerToJobFrame, } from "@norskvideo/ctl-product-template-schema/migration-socket";
22
+ const DEFAULT_MIN_BACKOFF_MS = 1_000;
23
+ const DEFAULT_MAX_BACKOFF_MS = 30_000;
24
+ export function connectMigration(handlers, env = process.env, opts = {}) {
25
+ const url = env[JOB_SOCKET_ENV.url];
26
+ const token = env[JOB_SOCKET_ENV.token];
27
+ if (!url || !token)
28
+ return noopClient();
29
+ return new SocketMigrationClient(url, token, handlers, opts);
30
+ }
31
+ function noopClient() {
32
+ return {
33
+ role: "none",
34
+ migrationId: undefined,
35
+ connected: false,
36
+ reportReady: () => { },
37
+ reportStoppedOutput: () => { },
38
+ reportApplied: () => { },
39
+ abort: () => { },
40
+ close: () => { },
41
+ };
42
+ }
43
+ class SocketMigrationClient {
44
+ url;
45
+ token;
46
+ handlers;
47
+ opts;
48
+ role = "source";
49
+ migrationId;
50
+ connected = false;
51
+ socket;
52
+ closed = false;
53
+ attempt = 0;
54
+ reconnectTimer;
55
+ pending;
56
+ minBackoffMs;
57
+ maxBackoffMs;
58
+ log;
59
+ constructor(url, token, handlers, opts) {
60
+ this.url = url;
61
+ this.token = token;
62
+ this.handlers = handlers;
63
+ this.opts = opts;
64
+ this.minBackoffMs = opts.reconnectMinMs ?? DEFAULT_MIN_BACKOFF_MS;
65
+ this.maxBackoffMs = opts.reconnectMaxMs ?? DEFAULT_MAX_BACKOFF_MS;
66
+ this.log = opts.log ?? ((line) => console.error(`[migration] ${line}`));
67
+ void this.connect();
68
+ }
69
+ reportReady(payload) {
70
+ this.report({ type: "target-ready", payload: asPayload(payload) });
71
+ }
72
+ reportStoppedOutput(payload) {
73
+ this.report({ type: "source-stopped-output", payload: asPayload(payload) });
74
+ }
75
+ reportApplied() {
76
+ this.report({ type: "target-applied" });
77
+ }
78
+ abort(reason) {
79
+ this.report({ type: "abort", reason });
80
+ }
81
+ close() {
82
+ this.closed = true;
83
+ if (this.reconnectTimer)
84
+ clearTimeout(this.reconnectTimer);
85
+ this.reconnectTimer = undefined;
86
+ const s = this.socket;
87
+ this.socket = undefined;
88
+ this.connected = false;
89
+ try {
90
+ s?.close(1000, "job closing");
91
+ }
92
+ catch {
93
+ // already gone
94
+ }
95
+ }
96
+ // ── private ─────────────────────────────────────────────────────────
97
+ report(msg) {
98
+ if (this.closed)
99
+ return;
100
+ if (this.connected && this.socket) {
101
+ this.send(msg);
102
+ return;
103
+ }
104
+ // Disconnected: the latest report wins. A position reported twice is
105
+ // the newer position; the worker acts on whichever it hears first.
106
+ this.pending = msg;
107
+ }
108
+ send(msg) {
109
+ try {
110
+ this.socket?.send(JSON.stringify(msg));
111
+ }
112
+ catch (e) {
113
+ this.log(`send of '${msg.type}' failed: ${String(e)}`);
114
+ }
115
+ }
116
+ async connect() {
117
+ if (this.closed)
118
+ return;
119
+ let Ctor;
120
+ try {
121
+ Ctor = await resolveWebSocket(this.opts.WebSocket);
122
+ }
123
+ catch (e) {
124
+ this.log(`no WebSocket implementation: ${String(e)} — migration disabled`);
125
+ this.role = "none";
126
+ return;
127
+ }
128
+ if (this.closed)
129
+ return;
130
+ let socket;
131
+ try {
132
+ socket = new Ctor(this.url);
133
+ }
134
+ catch (e) {
135
+ this.log(`connect to ${this.url} failed: ${String(e)}`);
136
+ this.scheduleReconnect();
137
+ return;
138
+ }
139
+ this.socket = socket;
140
+ let acked = false;
141
+ socket.onopen = () => {
142
+ if (this.socket !== socket)
143
+ return;
144
+ this.send({ type: "hello", token: this.token });
145
+ };
146
+ socket.onmessage = (ev) => {
147
+ if (this.socket !== socket)
148
+ return;
149
+ const text = typeof ev.data === "string" ? ev.data : String(ev.data);
150
+ const parsed = parseWorkerToJobFrame(text);
151
+ if (!parsed.ok) {
152
+ this.log(`ignoring frame from the worker: ${parsed.reason}`);
153
+ return;
154
+ }
155
+ const msg = parsed.message;
156
+ if (!acked) {
157
+ if (msg.type !== "hello-ack") {
158
+ this.log(`expected hello-ack, got '${msg.type}'; ignoring`);
159
+ return;
160
+ }
161
+ acked = true;
162
+ this.onAcked(msg);
163
+ return;
164
+ }
165
+ void this.onMessage(msg);
166
+ };
167
+ socket.onclose = (ev) => {
168
+ if (this.socket !== socket)
169
+ return;
170
+ this.socket = undefined;
171
+ this.connected = false;
172
+ if (this.closed)
173
+ return;
174
+ this.log(`socket closed (${ev.code}${ev.reason ? ` ${ev.reason}` : ""})`);
175
+ this.scheduleReconnect();
176
+ };
177
+ socket.onerror = () => {
178
+ // Node's WebSocket (undici, Node 22) fires `error` and NOT `close` when
179
+ // the connection itself fails — the socket stays CONNECTING for ever.
180
+ // Waiting for a close that never comes stranded every job whose worker
181
+ // restarted: the first refused reconnect ended the client's life
182
+ // (seen live 2026-09-07). Treat an error on the current socket as its
183
+ // end; a `close` that does follow finds `this.socket` moved on and is
184
+ // ignored by the guard above.
185
+ if (this.socket !== socket)
186
+ return;
187
+ this.socket = undefined;
188
+ this.connected = false;
189
+ if (this.closed)
190
+ return;
191
+ this.log("socket error; will reconnect");
192
+ try {
193
+ socket.close();
194
+ }
195
+ catch {
196
+ // never opened
197
+ }
198
+ this.scheduleReconnect();
199
+ };
200
+ }
201
+ onAcked(ack) {
202
+ this.attempt = 0;
203
+ this.connected = true;
204
+ if (ack.migration) {
205
+ this.role = "target";
206
+ this.migrationId = ack.migration.id;
207
+ }
208
+ else {
209
+ this.role = "source";
210
+ }
211
+ this.log(`connected as ${this.role}${this.migrationId ? ` (migration ${this.migrationId})` : ""}`);
212
+ const pending = this.pending;
213
+ this.pending = undefined;
214
+ if (pending)
215
+ this.send(pending);
216
+ }
217
+ async onMessage(msg) {
218
+ try {
219
+ switch (msg.type) {
220
+ case "hello-ack":
221
+ return;
222
+ case "cutover-ready":
223
+ this.migrationId = msg.migrationId;
224
+ await this.handlers.onCutoverReady?.(msg.payload, msg.migrationId);
225
+ return;
226
+ case "cutover-apply":
227
+ this.migrationId = msg.migrationId;
228
+ await this.handlers.onCutoverApply?.(msg.payload, msg.migrationId);
229
+ return;
230
+ case "abort":
231
+ await this.handlers.onAbort?.(msg.reason, msg.migrationId);
232
+ if (this.role === "source")
233
+ this.migrationId = undefined;
234
+ return;
235
+ }
236
+ }
237
+ catch (e) {
238
+ this.log(`handler for '${msg.type}' threw: ${String(e)}`);
239
+ }
240
+ }
241
+ scheduleReconnect() {
242
+ if (this.closed || this.reconnectTimer)
243
+ return;
244
+ const delay = Math.min(this.maxBackoffMs, this.minBackoffMs * 2 ** this.attempt);
245
+ this.attempt = Math.min(this.attempt + 1, 30);
246
+ this.reconnectTimer = setTimeout(() => {
247
+ this.reconnectTimer = undefined;
248
+ void this.connect();
249
+ }, delay);
250
+ }
251
+ }
252
+ function asPayload(payload) {
253
+ // Anything JSON.stringify can carry. A non-JSON value (undefined, a
254
+ // function) becomes null rather than an invalid frame.
255
+ return payload === undefined ? null : JSON.parse(JSON.stringify(payload));
256
+ }
257
+ async function resolveWebSocket(explicit) {
258
+ if (explicit)
259
+ return explicit;
260
+ const global = globalThis.WebSocket;
261
+ if (global)
262
+ return global;
263
+ // Older Node: the `ws` package's WebSocket carries the same handler
264
+ // properties. A dynamic import so a runtime with the global never loads it.
265
+ const ws = await import("ws");
266
+ return ws.WebSocket;
267
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-sdk",
3
- "version": "0.1.31",
3
+ "version": "0.1.33",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -31,7 +31,11 @@
31
31
  "types": "./workflow.d.ts",
32
32
  "default": "./workflow.js"
33
33
  },
34
- "./base.css": "./base.css"
34
+ "./base.css": "./base.css",
35
+ "./migration": {
36
+ "types": "./migration-client.d.ts",
37
+ "default": "./migration-client.js"
38
+ }
35
39
  },
36
40
  "main": "./index.js",
37
41
  "types": "./index.d.ts",
@@ -42,7 +46,8 @@
42
46
  "lucide-react": "^0.483.0",
43
47
  "react": "^19.0.0",
44
48
  "react-hot-toast": "^2.4.1",
45
- "zod": "4.4.3"
49
+ "zod": "4.4.3",
50
+ "ws": "^8.20.1"
46
51
  },
47
52
  "publishConfig": {
48
53
  "access": "public"
package/parsing.js CHANGED
@@ -15,7 +15,8 @@ export function parseSpec(raw, where, path) {
15
15
  if (typeof raw.image !== "string") {
16
16
  throw new ProductsParseError(path, `${where}.spec.image must be a string`);
17
17
  }
18
- return { kind: "container", image: raw.image };
18
+ const env = parseEnv(raw.env, where, path);
19
+ return { kind: "container", image: raw.image, ...(env !== undefined ? { env } : {}) };
19
20
  }
20
21
  if (raw.kind === "dev") {
21
22
  if (typeof raw.url !== "string") {
@@ -25,6 +26,22 @@ export function parseSpec(raw, where, path) {
25
26
  }
26
27
  throw new ProductsParseError(path, `${where}.spec.kind must be 'container' or 'dev'`);
27
28
  }
29
+ /** The container spec's deploy-time env, round-tripped from the store as a
30
+ * `{ KEY: VALUE }` map of strings. Absent stays absent; a present value must
31
+ * be an object of string→string. */
32
+ function parseEnv(raw, where, path) {
33
+ if (raw === undefined)
34
+ return undefined;
35
+ if (!isRecord(raw))
36
+ throw new ProductsParseError(path, `${where}.spec.env must be an object`);
37
+ const env = {};
38
+ for (const [k, v] of Object.entries(raw)) {
39
+ if (typeof v !== "string")
40
+ throw new ProductsParseError(path, `${where}.spec.env.${k} must be a string`);
41
+ env[k] = v;
42
+ }
43
+ return env;
44
+ }
28
45
  function parseLicense(raw, where, path) {
29
46
  if (!isRecord(raw))
30
47
  throw new ProductsParseError(path, `${where}.license must be an object`);
@@ -82,8 +82,10 @@ export type RefreshProductTemplateBytesFn = ImportProductTemplateBytesFn;
82
82
  export interface ProductContainerOps {
83
83
  pull(image: string): Promise<void>;
84
84
  /** Start the container. The host port comes back from docker rather than
85
- * going in: docker owns the host port space (see docker-runner.ts). */
86
- run(image: string): Promise<ProductPlacement>;
85
+ * going in: docker owns the host port space (see docker-runner.ts). `env`
86
+ * is the spec's deploy-time environment, passed straight through as
87
+ * `-e KEY=VALUE`. */
88
+ run(image: string, env?: Record<string, string>): Promise<ProductPlacement>;
87
89
  remove(containerId: string): Promise<void>;
88
90
  rename(containerId: string, name: string): Promise<void>;
89
91
  waitForReady(baseUrl: string): Promise<void>;
@@ -29,7 +29,7 @@ async function fetchProductTemplateBytes(baseUrl, url) {
29
29
  * (typically `pull`, onto its own docker adapter) and keep the rest. */
30
30
  export const defaultProductContainerOps = {
31
31
  pull: dockerPull,
32
- run: dockerRun,
32
+ run: (image, env) => dockerRun(image, env ? { env } : {}),
33
33
  remove: dockerRm,
34
34
  rename: dockerRename,
35
35
  waitForReady,
@@ -117,7 +117,7 @@ export class ProductService {
117
117
  else {
118
118
  await this.refreshImage(spec.image);
119
119
  logger.info(`Starting product container: ${spec.image} (reach: ${this.reach})`);
120
- const placement = await this.containerOps.run(spec.image);
120
+ const placement = await this.containerOps.run(spec.image, spec.env);
121
121
  containerId = placement.containerId;
122
122
  port = placement.hostPort;
123
123
  logger.info(`Product container ${containerId.slice(0, 12)} published on host port ${port}`);
@@ -309,7 +309,7 @@ export class ProductService {
309
309
  // Already gone (crashed / --rm reaped) — nothing to stop.
310
310
  }
311
311
  }
312
- const { containerId, hostPort } = await this.containerOps.run(target.spec.image);
312
+ const { containerId, hostPort } = await this.containerOps.run(target.spec.image, target.spec.env);
313
313
  const reachHost = await this.reachHostFor(containerId);
314
314
  await this.store.update((products) => products.map((p) => (p.name === name ? withPlacement(p, containerId, hostPort, reachHost) : p)));
315
315
  await this.containerOps.waitForReady(specBaseUrl(target.spec, hostPort, reachHost));
@@ -400,7 +400,7 @@ export class ProductService {
400
400
  return;
401
401
  }
402
402
  try {
403
- const { containerId, hostPort } = await this.containerOps.run(reg.spec.image);
403
+ const { containerId, hostPort } = await this.containerOps.run(reg.spec.image, reg.spec.env);
404
404
  const reachHost = await this.reachHostFor(containerId);
405
405
  await this.containerOps.waitForReady(specBaseUrl(reg.spec, hostPort, reachHost));
406
406
  await this.containerOps.rename(containerId, productContainerName(reg.name));
@@ -449,7 +449,7 @@ export class ProductService {
449
449
  // Already gone (crashed / --rm reaped) — nothing to stop.
450
450
  }
451
451
  }
452
- const { containerId, hostPort } = await this.containerOps.run(target.spec.image);
452
+ const { containerId, hostPort } = await this.containerOps.run(target.spec.image, target.spec.env);
453
453
  const reachHost = await this.reachHostFor(containerId);
454
454
  await this.store.update((products) => products.map((p) => (p.name === name ? withPlacement(p, containerId, hostPort, reachHost) : p)));
455
455
  await this.containerOps.waitForReady(specBaseUrl(target.spec, hostPort, reachHost));
@@ -56,7 +56,8 @@ export interface ProductTemplateRecord {
56
56
  * launch's parameter map beneath the operator's instance values —
57
57
  * instance wins, template default wins over manifest default. Names
58
58
  * must be env-var shaped and never `NORSK_`-prefixed (that namespace is
59
- * infra-injected: NORSK_RUNTIME_ROLE, NORSK_MATCHED_CAPABILITIES, …). */
59
+ * infra-injected: NORSK_RUNTIME_ROLE, NORSK_MIGRATION_ID,
60
+ * NORSK_MATCHED_CAPABILITIES, …). */
60
61
  parameterDefaults?: Record<string, string>;
61
62
  /** Template-author security-group suggestions, by discovery-tag LABEL
62
63
  * (portable across deployments; ids are not). The launch UI pre-ticks
@@ -1,7 +1,13 @@
1
1
  import type { Manifest } from "./manifest-schema.js";
2
- export type ProductSpec = {
2
+ export type ProductSpec =
3
+ /** `env` is the deploy-time environment for the control-plane container,
4
+ * supplied at `product add --env KEY=VALUE`. It lives on the spec (not just
5
+ * the add call) so it persists with the registration and every later
6
+ * restart/reload/restore re-runs the container with the same environment. */
7
+ {
3
8
  kind: "container";
4
9
  image: string;
10
+ env?: Record<string, string>;
5
11
  } | {
6
12
  kind: "dev";
7
13
  url: string;