@norskvideo/ctl-product-template-schema 0.1.18 → 0.1.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.d.ts +86 -0
- package/index.js +43 -0
- package/migration-socket.d.ts +120 -0
- package/migration-socket.js +157 -0
- package/package.json +5 -1
package/index.d.ts
CHANGED
|
@@ -49,6 +49,12 @@ export declare const ProductTemplateSideloadSchema: z.ZodObject<{
|
|
|
49
49
|
llmVenv: z.ZodOptional<z.ZodBoolean>;
|
|
50
50
|
}, z.core.$strip>;
|
|
51
51
|
export type ProductTemplateSideload = z.infer<typeof ProductTemplateSideloadSchema>;
|
|
52
|
+
export declare const ProductTemplateAcceleratorSchema: z.ZodEnum<{
|
|
53
|
+
none: "none";
|
|
54
|
+
nvidia: "nvidia";
|
|
55
|
+
quadra: "quadra";
|
|
56
|
+
}>;
|
|
57
|
+
export type ProductTemplateAccelerator = z.infer<typeof ProductTemplateAcceleratorSchema>;
|
|
52
58
|
export declare const ProductTemplateRequirementsSchema: z.ZodObject<{
|
|
53
59
|
capacity: z.ZodOptional<z.ZodObject<{
|
|
54
60
|
mode: z.ZodEnum<{
|
|
@@ -77,6 +83,23 @@ export declare const ProductTemplateRequirementsSchema: z.ZodObject<{
|
|
|
77
83
|
model: z.ZodOptional<z.ZodString>;
|
|
78
84
|
}, z.core.$strip>>;
|
|
79
85
|
}, z.core.$strip>>;
|
|
86
|
+
accelerator: z.ZodOptional<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
87
|
+
mode: z.ZodLiteral<"fixed">;
|
|
88
|
+
value: z.ZodEnum<{
|
|
89
|
+
none: "none";
|
|
90
|
+
nvidia: "nvidia";
|
|
91
|
+
quadra: "quadra";
|
|
92
|
+
}>;
|
|
93
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
94
|
+
mode: z.ZodLiteral<"default">;
|
|
95
|
+
value: z.ZodEnum<{
|
|
96
|
+
none: "none";
|
|
97
|
+
nvidia: "nvidia";
|
|
98
|
+
quadra: "quadra";
|
|
99
|
+
}>;
|
|
100
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
101
|
+
mode: z.ZodLiteral<"required">;
|
|
102
|
+
}, z.core.$strip>], "mode">>;
|
|
80
103
|
capabilities: z.ZodOptional<z.ZodObject<{
|
|
81
104
|
mode: z.ZodEnum<{
|
|
82
105
|
default: "default";
|
|
@@ -99,6 +122,17 @@ export declare const ProductTemplateRequirementsSchema: z.ZodObject<{
|
|
|
99
122
|
}, z.core.$strip>>;
|
|
100
123
|
}, z.core.$strip>;
|
|
101
124
|
export type ProductTemplateRequirements = z.infer<typeof ProductTemplateRequirementsSchema>;
|
|
125
|
+
export declare const PRODUCT_TEMPLATE_MIGRATION_STRATEGIES: readonly ["overlap", "stopThenStart", "handover"];
|
|
126
|
+
export type MigrationStrategy = (typeof PRODUCT_TEMPLATE_MIGRATION_STRATEGIES)[number];
|
|
127
|
+
export declare const DEFAULT_MIGRATION_STRATEGY: MigrationStrategy;
|
|
128
|
+
export declare const ProductTemplateMigrationSchema: z.ZodObject<{
|
|
129
|
+
strategy: z.ZodEnum<{
|
|
130
|
+
overlap: "overlap";
|
|
131
|
+
stopThenStart: "stopThenStart";
|
|
132
|
+
handover: "handover";
|
|
133
|
+
}>;
|
|
134
|
+
}, z.core.$strip>;
|
|
135
|
+
export type ProductTemplateMigration = z.infer<typeof ProductTemplateMigrationSchema>;
|
|
102
136
|
export declare const ProductTemplateManifestSchema: z.ZodObject<{
|
|
103
137
|
productTemplateSchemaVersion: z.ZodLiteral<1>;
|
|
104
138
|
productName: z.ZodString;
|
|
@@ -196,6 +230,23 @@ export declare const ProductTemplateManifestSchema: z.ZodObject<{
|
|
|
196
230
|
model: z.ZodOptional<z.ZodString>;
|
|
197
231
|
}, z.core.$strip>>;
|
|
198
232
|
}, z.core.$strip>>;
|
|
233
|
+
accelerator: z.ZodOptional<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
234
|
+
mode: z.ZodLiteral<"fixed">;
|
|
235
|
+
value: z.ZodEnum<{
|
|
236
|
+
none: "none";
|
|
237
|
+
nvidia: "nvidia";
|
|
238
|
+
quadra: "quadra";
|
|
239
|
+
}>;
|
|
240
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
241
|
+
mode: z.ZodLiteral<"default">;
|
|
242
|
+
value: z.ZodEnum<{
|
|
243
|
+
none: "none";
|
|
244
|
+
nvidia: "nvidia";
|
|
245
|
+
quadra: "quadra";
|
|
246
|
+
}>;
|
|
247
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
248
|
+
mode: z.ZodLiteral<"required">;
|
|
249
|
+
}, z.core.$strip>], "mode">>;
|
|
199
250
|
capabilities: z.ZodOptional<z.ZodObject<{
|
|
200
251
|
mode: z.ZodEnum<{
|
|
201
252
|
default: "default";
|
|
@@ -227,8 +278,19 @@ export declare const ProductTemplateManifestSchema: z.ZodObject<{
|
|
|
227
278
|
preferred: z.ZodNumber;
|
|
228
279
|
label: z.ZodOptional<z.ZodString>;
|
|
229
280
|
}, z.core.$strip>>>;
|
|
281
|
+
migration: z.ZodOptional<z.ZodObject<{
|
|
282
|
+
strategy: z.ZodEnum<{
|
|
283
|
+
overlap: "overlap";
|
|
284
|
+
stopThenStart: "stopThenStart";
|
|
285
|
+
handover: "handover";
|
|
286
|
+
}>;
|
|
287
|
+
}, z.core.$strip>>;
|
|
230
288
|
}, z.core.$strip>;
|
|
231
289
|
export type ProductTemplateManifest = z.infer<typeof ProductTemplateManifestSchema>;
|
|
290
|
+
/** The migration strategy a manifest promises — `stopThenStart` when it
|
|
291
|
+
* declares none, since that is the only strategy safe for a product that
|
|
292
|
+
* has said nothing about how it tolerates being moved. */
|
|
293
|
+
export declare function migrationStrategyOf(manifest: Pick<ProductTemplateManifest, "migration">): MigrationStrategy;
|
|
232
294
|
export declare const STANDARD_ADVANCED_OVERRIDES: {
|
|
233
295
|
readonly containerUser: {};
|
|
234
296
|
readonly publicHost: {};
|
|
@@ -374,6 +436,23 @@ export declare const ProductTemplateMaterialsSchema: z.ZodObject<{
|
|
|
374
436
|
model: z.ZodOptional<z.ZodString>;
|
|
375
437
|
}, z.core.$strip>>;
|
|
376
438
|
}, z.core.$strip>>;
|
|
439
|
+
accelerator: z.ZodOptional<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
440
|
+
mode: z.ZodLiteral<"fixed">;
|
|
441
|
+
value: z.ZodEnum<{
|
|
442
|
+
none: "none";
|
|
443
|
+
nvidia: "nvidia";
|
|
444
|
+
quadra: "quadra";
|
|
445
|
+
}>;
|
|
446
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
447
|
+
mode: z.ZodLiteral<"default">;
|
|
448
|
+
value: z.ZodEnum<{
|
|
449
|
+
none: "none";
|
|
450
|
+
nvidia: "nvidia";
|
|
451
|
+
quadra: "quadra";
|
|
452
|
+
}>;
|
|
453
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
454
|
+
mode: z.ZodLiteral<"required">;
|
|
455
|
+
}, z.core.$strip>], "mode">>;
|
|
377
456
|
capabilities: z.ZodOptional<z.ZodObject<{
|
|
378
457
|
mode: z.ZodEnum<{
|
|
379
458
|
default: "default";
|
|
@@ -405,6 +484,13 @@ export declare const ProductTemplateMaterialsSchema: z.ZodObject<{
|
|
|
405
484
|
preferred: z.ZodNumber;
|
|
406
485
|
label: z.ZodOptional<z.ZodString>;
|
|
407
486
|
}, z.core.$strip>>>;
|
|
487
|
+
migration: z.ZodOptional<z.ZodObject<{
|
|
488
|
+
strategy: z.ZodEnum<{
|
|
489
|
+
overlap: "overlap";
|
|
490
|
+
stopThenStart: "stopThenStart";
|
|
491
|
+
handover: "handover";
|
|
492
|
+
}>;
|
|
493
|
+
}, z.core.$strip>>;
|
|
408
494
|
}, z.core.$strip>;
|
|
409
495
|
composeYaml: z.ZodString;
|
|
410
496
|
parameters: z.ZodOptional<z.ZodObject<{
|
package/index.js
CHANGED
|
@@ -223,6 +223,12 @@ const ProductTemplateGpuRequirementSchema = z.object({
|
|
|
223
223
|
capacity: z.number().positive().optional().meta({ description: "Fractional GPU reference units" }),
|
|
224
224
|
model: z.string().optional().meta({ description: "GPU model match, e.g. 'RTX 4090'" }),
|
|
225
225
|
});
|
|
226
|
+
// The accelerator a product needs reserved on media. Deliberately the same
|
|
227
|
+
// three values as the runner's own hardware knob, restated here rather than
|
|
228
|
+
// imported: this package is upstream of ctl's contract, not downstream of it.
|
|
229
|
+
// `none` is a real value, not an absence — it is how a CPU-only product says
|
|
230
|
+
// "reserve nothing" as a mandate the operator cannot override.
|
|
231
|
+
export const ProductTemplateAcceleratorSchema = z.enum(["none", "nvidia", "quadra"]);
|
|
226
232
|
export const ProductTemplateRequirementsSchema = z
|
|
227
233
|
.object({
|
|
228
234
|
capacity: requirementDimension(z.number().positive()).optional().meta({
|
|
@@ -234,6 +240,20 @@ export const ProductTemplateRequirementsSchema = z
|
|
|
234
240
|
gpu: requirementDimension(ProductTemplateGpuRequirementSchema, (v) => v.capacity !== undefined || v.model !== undefined)
|
|
235
241
|
.optional()
|
|
236
242
|
.meta({ description: "Fractional GPU need (capacity and/or model)." }),
|
|
243
|
+
// Not requirementDimension(): a discriminated union states in the type what
|
|
244
|
+
// that helper can only enforce at parse time — fixed/default always carry a
|
|
245
|
+
// value, required never does. Downstream that removes the guard for a state
|
|
246
|
+
// the schema already forbids.
|
|
247
|
+
accelerator: z
|
|
248
|
+
.discriminatedUnion("mode", [
|
|
249
|
+
z.object({ mode: z.literal("fixed"), value: ProductTemplateAcceleratorSchema }),
|
|
250
|
+
z.object({ mode: z.literal("default"), value: ProductTemplateAcceleratorSchema }),
|
|
251
|
+
z.object({ mode: z.literal("required") }),
|
|
252
|
+
])
|
|
253
|
+
.optional()
|
|
254
|
+
.meta({
|
|
255
|
+
description: "Which accelerator media needs reserved. `fixed` mandates it — the operator cannot override, and a host without it is refused rather than launched unreserved (`fixed`/`none` is how a CPU-only product forbids a reservation). `default` merely prefers it: the operator may change it, and a host without it falls back to no reservation. `required` means the product cannot know, so the operator must choose at launch. Absent = the runner reserves nothing unless the operator asks.",
|
|
256
|
+
}),
|
|
237
257
|
capabilities: requirementDimension(z.array(ProductTemplateCapabilityRequirementSchema).min(1))
|
|
238
258
|
.optional()
|
|
239
259
|
.meta({ description: "Counted/exclusive hardware & software capabilities (nvidia-gpu, quadra, decklink, …)." }),
|
|
@@ -242,6 +262,20 @@ export const ProductTemplateRequirementsSchema = z
|
|
|
242
262
|
.meta({ description: "Fixed host sockets the job binds (exclusive)." }),
|
|
243
263
|
})
|
|
244
264
|
.meta({ id: "ProductTemplateRequirements", outputId: "ProductTemplateRequirements" });
|
|
265
|
+
// How a running instance of the product may be moved between nodes
|
|
266
|
+
// (norsk-mgr/docs/internal/design/job-migration.md §6). The manifest
|
|
267
|
+
// declares the strongest promise the product keeps; a migrate request may
|
|
268
|
+
// only down-grade to a safer strategy. Absent means `stopThenStart`, the
|
|
269
|
+
// one that is safe for any product.
|
|
270
|
+
export const PRODUCT_TEMPLATE_MIGRATION_STRATEGIES = ["overlap", "stopThenStart", "handover"];
|
|
271
|
+
export const DEFAULT_MIGRATION_STRATEGY = "stopThenStart";
|
|
272
|
+
export const ProductTemplateMigrationSchema = z
|
|
273
|
+
.object({
|
|
274
|
+
strategy: z.enum(PRODUCT_TEMPLATE_MIGRATION_STRATEGIES).meta({
|
|
275
|
+
description: "`overlap`: the target is started before the source is stopped, so both instances may produce briefly — a promise only a product whose outputs tolerate a second writer can make. `stopThenStart` (the default when absent): the target is prepared but not started, the source is stopped, then the target is started — safe for any product, at the cost of a gap. `handover`: the job speaks the worker's migration socket and hands over at an exact position, so the output continues seamlessly — a promise the job's own code keeps.",
|
|
276
|
+
}),
|
|
277
|
+
})
|
|
278
|
+
.meta({ id: "ProductTemplateMigration", outputId: "ProductTemplateMigration" });
|
|
245
279
|
export const ProductTemplateManifestSchema = z
|
|
246
280
|
.object({
|
|
247
281
|
productTemplateSchemaVersion: z.literal(1),
|
|
@@ -284,8 +318,17 @@ export const ProductTemplateManifestSchema = z
|
|
|
284
318
|
allocatedPorts: z.array(ProductTemplateAllocatedPortSchema).optional().meta({
|
|
285
319
|
description: "Host-port bindings whose value rides a product-template parameter. Opt-in per launch via the param value: absent = closed, 'auto' = allocate a free port from `preferred`, number = use it. When opened the port is published as a managed host binding and surfaced to the containers via compose interpolation. Alongside the per-param scalars the runner sets NORSK_ALLOCATED_PORTS: a JSON object keyed by param, each value {port, protocol, service?}, describing every opened port ('{}' when none opted in; unset when nothing is declared) — so a product needing the allocation as data maps one var instead of folding scalars into JSON via interpolation.",
|
|
286
320
|
}),
|
|
321
|
+
migration: ProductTemplateMigrationSchema.optional().meta({
|
|
322
|
+
description: "How a running instance may be moved between nodes. `strategy` is the strongest promise the product keeps: `overlap` (target started, then source stopped — both may produce briefly), `stopThenStart` (the default when absent — target prepared but not started, source stopped, target started), or `handover` (the job speaks the worker's migration socket and hands over at an exact position). A migrate request may down-grade to a safer strategy but never up-grade past the declaration. Only the manager reads this; the worker never does.",
|
|
323
|
+
}),
|
|
287
324
|
})
|
|
288
325
|
.meta({ id: "ProductTemplateManifest", outputId: "ProductTemplateManifest" });
|
|
326
|
+
/** The migration strategy a manifest promises — `stopThenStart` when it
|
|
327
|
+
* declares none, since that is the only strategy safe for a product that
|
|
328
|
+
* has said nothing about how it tolerates being moved. */
|
|
329
|
+
export function migrationStrategyOf(manifest) {
|
|
330
|
+
return manifest.migration?.strategy ?? DEFAULT_MIGRATION_STRATEGY;
|
|
331
|
+
}
|
|
289
332
|
// Container user and external URL are runner knobs every product wants
|
|
290
333
|
// overridable per instance — the runner only holds their default values.
|
|
291
334
|
// Spread this into a manifest's `advanced` (adding product-specific keys
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare const JOB_SOCKET_DEFAULT_PORT = 6797;
|
|
3
|
+
export declare const JOB_SOCKET_MAX_FRAME_BYTES: number;
|
|
4
|
+
export declare const JOB_SOCKET_HELLO_TIMEOUT_MS = 5000;
|
|
5
|
+
/** The name a container reaches its own worker's host under. Injected as an
|
|
6
|
+
* `extra_hosts` mapping on every service so it resolves on native Linux too. */
|
|
7
|
+
export declare const JOB_SOCKET_CONTAINER_HOST = "host.docker.internal";
|
|
8
|
+
/** The NodeInventory capability a worker advertises while its listener is up,
|
|
9
|
+
* so placement can require it for a handover target. */
|
|
10
|
+
export declare const JOB_SOCKET_CAPABILITY = "norsk.io/job-socket";
|
|
11
|
+
/** The two reserved env names a job reads. Absent when the worker runs
|
|
12
|
+
* without a listener (`--no-job-socket`), which is what `connectMigration`
|
|
13
|
+
* turns into its no-op client. */
|
|
14
|
+
export declare const JOB_SOCKET_ENV: {
|
|
15
|
+
readonly url: "NORSK_WORKER_WS_URL";
|
|
16
|
+
readonly token: "NORSK_WORKER_TOKEN";
|
|
17
|
+
};
|
|
18
|
+
/** Close codes the worker uses. All in the 4000–4999 application range so a
|
|
19
|
+
* client can tell them from transport failures. */
|
|
20
|
+
export declare const JobSocketCloseCode: {
|
|
21
|
+
/** A frame that breaks the protocol: unparseable, wrong side, wrong phase. */
|
|
22
|
+
readonly protocolError: 4400;
|
|
23
|
+
/** No hello in time, a wrong token, or a path naming no instance. */
|
|
24
|
+
readonly unauthorized: 4401;
|
|
25
|
+
/** A newer connection for the same instance replaced this one. */
|
|
26
|
+
readonly replaced: 4409;
|
|
27
|
+
/** A frame over the size cap. */
|
|
28
|
+
readonly frameTooLarge: 4413;
|
|
29
|
+
};
|
|
30
|
+
export declare function jobSocketPath(jobId: string, role: string): string;
|
|
31
|
+
/** The inverse of {@link jobSocketPath}. Query strings are ignored. */
|
|
32
|
+
export declare function parseJobSocketPath(path: string): {
|
|
33
|
+
jobId: string;
|
|
34
|
+
role: string;
|
|
35
|
+
} | undefined;
|
|
36
|
+
export declare function jobSocketUrl(args: {
|
|
37
|
+
host: string;
|
|
38
|
+
port: number;
|
|
39
|
+
jobId: string;
|
|
40
|
+
role: string;
|
|
41
|
+
}): string;
|
|
42
|
+
declare const JsonPayload: z.ZodJSONSchema;
|
|
43
|
+
/** Any JSON value — what a job may put in `payload`. */
|
|
44
|
+
export type MigrationPayload = z.infer<typeof JsonPayload>;
|
|
45
|
+
export declare const JobToWorkerMessageSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
46
|
+
type: z.ZodLiteral<"hello">;
|
|
47
|
+
token: z.ZodString;
|
|
48
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
49
|
+
type: z.ZodLiteral<"state">;
|
|
50
|
+
payload: z.ZodJSONSchema;
|
|
51
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
52
|
+
type: z.ZodLiteral<"state-refused">;
|
|
53
|
+
reason: z.ZodString;
|
|
54
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
55
|
+
type: z.ZodLiteral<"target-ready">;
|
|
56
|
+
payload: z.ZodJSONSchema;
|
|
57
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
58
|
+
type: z.ZodLiteral<"source-stopped-output">;
|
|
59
|
+
payload: z.ZodJSONSchema;
|
|
60
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
61
|
+
type: z.ZodLiteral<"target-applied">;
|
|
62
|
+
applied: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
63
|
+
failed: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
64
|
+
id: z.ZodString;
|
|
65
|
+
reason: z.ZodString;
|
|
66
|
+
}, z.core.$strip>>>;
|
|
67
|
+
skewMs: z.ZodOptional<z.ZodNumber>;
|
|
68
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
69
|
+
type: z.ZodLiteral<"abort">;
|
|
70
|
+
reason: z.ZodString;
|
|
71
|
+
}, z.core.$strip>], "type">;
|
|
72
|
+
export type JobToWorkerMessage = z.infer<typeof JobToWorkerMessageSchema>;
|
|
73
|
+
export declare const WorkerToJobMessageSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
74
|
+
type: z.ZodLiteral<"hello-ack">;
|
|
75
|
+
migration: z.ZodOptional<z.ZodObject<{
|
|
76
|
+
id: z.ZodString;
|
|
77
|
+
side: z.ZodLiteral<"target">;
|
|
78
|
+
}, z.core.$strip>>;
|
|
79
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
80
|
+
type: z.ZodLiteral<"get-state">;
|
|
81
|
+
migrationId: z.ZodString;
|
|
82
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
83
|
+
type: z.ZodLiteral<"initial-state">;
|
|
84
|
+
migrationId: z.ZodString;
|
|
85
|
+
payload: z.ZodJSONSchema;
|
|
86
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
87
|
+
type: z.ZodLiteral<"cutover-ready">;
|
|
88
|
+
migrationId: z.ZodString;
|
|
89
|
+
payload: z.ZodJSONSchema;
|
|
90
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
91
|
+
type: z.ZodLiteral<"cutover-apply">;
|
|
92
|
+
migrationId: z.ZodString;
|
|
93
|
+
payload: z.ZodJSONSchema;
|
|
94
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
95
|
+
type: z.ZodLiteral<"abort">;
|
|
96
|
+
migrationId: z.ZodString;
|
|
97
|
+
reason: z.ZodString;
|
|
98
|
+
}, z.core.$strip>], "type">;
|
|
99
|
+
export type WorkerToJobMessage = z.infer<typeof WorkerToJobMessageSchema>;
|
|
100
|
+
export type ParsedFrame<T> = {
|
|
101
|
+
ok: true;
|
|
102
|
+
message: T;
|
|
103
|
+
} | {
|
|
104
|
+
ok: false;
|
|
105
|
+
reason: string;
|
|
106
|
+
};
|
|
107
|
+
/** What the worker does with a text frame from a job. */
|
|
108
|
+
export declare function parseJobToWorkerFrame(text: string): ParsedFrame<JobToWorkerMessage>;
|
|
109
|
+
/** What the job does with a text frame from the worker. */
|
|
110
|
+
export declare function parseWorkerToJobFrame(text: string): ParsedFrame<WorkerToJobMessage>;
|
|
111
|
+
/** A socket payload as it rides the gRPC `bytes payload` field: the UTF-8 of
|
|
112
|
+
* its JSON. `undefined` (a job that sent nothing) is carried as `null` so the
|
|
113
|
+
* bytes are always valid JSON. */
|
|
114
|
+
export declare function encodeMigrationPayload(payload: unknown): Uint8Array;
|
|
115
|
+
/** The inverse of {@link encodeMigrationPayload}. Empty bytes — a gRPC
|
|
116
|
+
* message with the field unset — read as `null`; bytes that are not JSON
|
|
117
|
+
* come back as the raw string rather than being dropped, so a foreign
|
|
118
|
+
* Manager's opaque payload still reaches the job. */
|
|
119
|
+
export declare function decodeMigrationPayload(bytes: Uint8Array | undefined): MigrationPayload;
|
|
120
|
+
export {};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// The job socket — the contract between a worker and a job it may hand over
|
|
2
|
+
// (norsk-mgr/docs/internal/design/job-migration.md §5). One module, used by
|
|
3
|
+
// BOTH ends: the worker's listener validates inbound frames with these
|
|
4
|
+
// schemas, and the SDK client (`connectMigration`) validates what the worker
|
|
5
|
+
// sends it. Neither side re-declares a message shape.
|
|
6
|
+
//
|
|
7
|
+
// Transport: a WebSocket on the worker host, default port 6797, reached from
|
|
8
|
+
// a container as `host.docker.internal`. Path `/instances/<jobId>/<role>`
|
|
9
|
+
// where `role` is the instance KEY (unique per migration target), both
|
|
10
|
+
// url-encoded. JSON text frames, each ≤ 64 KiB. `payload` is any JSON, opaque
|
|
11
|
+
// to the worker; on the gRPC wire it becomes the UTF-8 bytes of its
|
|
12
|
+
// JSON.stringify and comes back the same way.
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
export const JOB_SOCKET_DEFAULT_PORT = 6797;
|
|
15
|
+
export const JOB_SOCKET_MAX_FRAME_BYTES = 64 * 1024;
|
|
16
|
+
export const JOB_SOCKET_HELLO_TIMEOUT_MS = 5_000;
|
|
17
|
+
/** The name a container reaches its own worker's host under. Injected as an
|
|
18
|
+
* `extra_hosts` mapping on every service so it resolves on native Linux too. */
|
|
19
|
+
export const JOB_SOCKET_CONTAINER_HOST = "host.docker.internal";
|
|
20
|
+
/** The NodeInventory capability a worker advertises while its listener is up,
|
|
21
|
+
* so placement can require it for a handover target. */
|
|
22
|
+
export const JOB_SOCKET_CAPABILITY = "norsk.io/job-socket";
|
|
23
|
+
/** The two reserved env names a job reads. Absent when the worker runs
|
|
24
|
+
* without a listener (`--no-job-socket`), which is what `connectMigration`
|
|
25
|
+
* turns into its no-op client. */
|
|
26
|
+
export const JOB_SOCKET_ENV = {
|
|
27
|
+
url: "NORSK_WORKER_WS_URL",
|
|
28
|
+
token: "NORSK_WORKER_TOKEN",
|
|
29
|
+
};
|
|
30
|
+
/** Close codes the worker uses. All in the 4000–4999 application range so a
|
|
31
|
+
* client can tell them from transport failures. */
|
|
32
|
+
export const JobSocketCloseCode = {
|
|
33
|
+
/** A frame that breaks the protocol: unparseable, wrong side, wrong phase. */
|
|
34
|
+
protocolError: 4400,
|
|
35
|
+
/** No hello in time, a wrong token, or a path naming no instance. */
|
|
36
|
+
unauthorized: 4401,
|
|
37
|
+
/** A newer connection for the same instance replaced this one. */
|
|
38
|
+
replaced: 4409,
|
|
39
|
+
/** A frame over the size cap. */
|
|
40
|
+
frameTooLarge: 4413,
|
|
41
|
+
};
|
|
42
|
+
export function jobSocketPath(jobId, role) {
|
|
43
|
+
return `/instances/${encodeURIComponent(jobId)}/${encodeURIComponent(role)}`;
|
|
44
|
+
}
|
|
45
|
+
/** The inverse of {@link jobSocketPath}. Query strings are ignored. */
|
|
46
|
+
export function parseJobSocketPath(path) {
|
|
47
|
+
const m = /^\/instances\/([^/?#]+)\/([^/?#]+)\/?(?:[?#].*)?$/.exec(path);
|
|
48
|
+
if (!m?.[1] || !m[2])
|
|
49
|
+
return undefined;
|
|
50
|
+
try {
|
|
51
|
+
return { jobId: decodeURIComponent(m[1]), role: decodeURIComponent(m[2]) };
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export function jobSocketUrl(args) {
|
|
58
|
+
return `ws://${args.host}:${args.port}${jobSocketPath(args.jobId, args.role)}`;
|
|
59
|
+
}
|
|
60
|
+
// ── frames ───────────────────────────────────────────────────────────────
|
|
61
|
+
const JsonPayload = z.json();
|
|
62
|
+
export const JobToWorkerMessageSchema = z.discriminatedUnion("type", [
|
|
63
|
+
z.object({ type: z.literal("hello"), token: z.string() }),
|
|
64
|
+
// The propose, answered by the SOURCE. Exactly one of these two goes
|
|
65
|
+
// back for a `get-state`: `state` accepts (a `null` payload is a
|
|
66
|
+
// perfectly good acceptance — a job with nothing to hand over still
|
|
67
|
+
// takes part), `state-refused` declines and the whole migration ends
|
|
68
|
+
// with no target ever placed. The refusal is its own frame rather than
|
|
69
|
+
// a field inside `payload`, so the worker can act on it without ever
|
|
70
|
+
// opening the opaque blob.
|
|
71
|
+
z.object({ type: z.literal("state"), payload: JsonPayload }),
|
|
72
|
+
z.object({ type: z.literal("state-refused"), reason: z.string() }),
|
|
73
|
+
z.object({ type: z.literal("target-ready"), payload: JsonPayload }),
|
|
74
|
+
z.object({ type: z.literal("source-stopped-output"), payload: JsonPayload }),
|
|
75
|
+
// The handover has been applied — and, past the point of no return, WHAT
|
|
76
|
+
// was applied. A job with more than one migration participant applies them
|
|
77
|
+
// in declared bands and cannot roll back once the source has stopped, so a
|
|
78
|
+
// participant that fails to apply is REPORTED rather than fatal. The three
|
|
79
|
+
// fields are envelope fields, outside any participant's opaque document, so
|
|
80
|
+
// the worker and the Manager can relay them without opening a blob.
|
|
81
|
+
//
|
|
82
|
+
// All three are optional: a job built before there was more than one
|
|
83
|
+
// participant sends the bare frame. And note that `z.object` STRIPS unknown
|
|
84
|
+
// keys rather than rejecting them — without these fields declared here the
|
|
85
|
+
// worker would accept the frame and silently drop the summary, which is why
|
|
86
|
+
// this and Studio's own copy
|
|
87
|
+
// (`norsk-studio workspaces/core/src/migration/socket-contract.ts`) have to
|
|
88
|
+
// move together.
|
|
89
|
+
z.object({
|
|
90
|
+
type: z.literal("target-applied"),
|
|
91
|
+
applied: z.array(z.string()).optional(),
|
|
92
|
+
failed: z.array(z.object({ id: z.string(), reason: z.string() })).optional(),
|
|
93
|
+
skewMs: z.number().optional(),
|
|
94
|
+
}),
|
|
95
|
+
z.object({ type: z.literal("abort"), reason: z.string() }),
|
|
96
|
+
]);
|
|
97
|
+
export const WorkerToJobMessageSchema = z.discriminatedUnion("type", [
|
|
98
|
+
z.object({
|
|
99
|
+
type: z.literal("hello-ack"),
|
|
100
|
+
/** Present only when the instance was launched as a migration target. */
|
|
101
|
+
migration: z.object({ id: z.string(), side: z.literal("target") }).optional(),
|
|
102
|
+
}),
|
|
103
|
+
// The propose, asked of the SOURCE before any target has been placed.
|
|
104
|
+
z.object({ type: z.literal("get-state"), migrationId: z.string() }),
|
|
105
|
+
// The source's state, handed to the TARGET. Always the first frame
|
|
106
|
+
// after `hello-ack` when the instance was started with one, so a
|
|
107
|
+
// target's `ready` can depend on having applied it.
|
|
108
|
+
z.object({ type: z.literal("initial-state"), migrationId: z.string(), payload: JsonPayload }),
|
|
109
|
+
z.object({ type: z.literal("cutover-ready"), migrationId: z.string(), payload: JsonPayload }),
|
|
110
|
+
z.object({ type: z.literal("cutover-apply"), migrationId: z.string(), payload: JsonPayload }),
|
|
111
|
+
z.object({ type: z.literal("abort"), migrationId: z.string(), reason: z.string() }),
|
|
112
|
+
]);
|
|
113
|
+
function parseFrame(schema, text) {
|
|
114
|
+
let raw;
|
|
115
|
+
try {
|
|
116
|
+
raw = JSON.parse(text);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return { ok: false, reason: "frame is not valid JSON" };
|
|
120
|
+
}
|
|
121
|
+
const parsed = schema.safeParse(raw);
|
|
122
|
+
if (!parsed.success)
|
|
123
|
+
return { ok: false, reason: `invalid frame: ${z.prettifyError(parsed.error)}` };
|
|
124
|
+
return { ok: true, message: parsed.data };
|
|
125
|
+
}
|
|
126
|
+
/** What the worker does with a text frame from a job. */
|
|
127
|
+
export function parseJobToWorkerFrame(text) {
|
|
128
|
+
return parseFrame(JobToWorkerMessageSchema, text);
|
|
129
|
+
}
|
|
130
|
+
/** What the job does with a text frame from the worker. */
|
|
131
|
+
export function parseWorkerToJobFrame(text) {
|
|
132
|
+
return parseFrame(WorkerToJobMessageSchema, text);
|
|
133
|
+
}
|
|
134
|
+
// ── payload ↔ gRPC bytes ─────────────────────────────────────────────────
|
|
135
|
+
const utf8Encoder = new TextEncoder();
|
|
136
|
+
const utf8Decoder = new TextDecoder();
|
|
137
|
+
/** A socket payload as it rides the gRPC `bytes payload` field: the UTF-8 of
|
|
138
|
+
* its JSON. `undefined` (a job that sent nothing) is carried as `null` so the
|
|
139
|
+
* bytes are always valid JSON. */
|
|
140
|
+
export function encodeMigrationPayload(payload) {
|
|
141
|
+
return utf8Encoder.encode(JSON.stringify(payload === undefined ? null : payload));
|
|
142
|
+
}
|
|
143
|
+
/** The inverse of {@link encodeMigrationPayload}. Empty bytes — a gRPC
|
|
144
|
+
* message with the field unset — read as `null`; bytes that are not JSON
|
|
145
|
+
* come back as the raw string rather than being dropped, so a foreign
|
|
146
|
+
* Manager's opaque payload still reaches the job. */
|
|
147
|
+
export function decodeMigrationPayload(bytes) {
|
|
148
|
+
if (!bytes || bytes.byteLength === 0)
|
|
149
|
+
return null;
|
|
150
|
+
const text = utf8Decoder.decode(bytes);
|
|
151
|
+
try {
|
|
152
|
+
return JSON.parse(text);
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return text;
|
|
156
|
+
}
|
|
157
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@norskvideo/ctl-product-template-schema",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.20",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": {
|
|
@@ -26,6 +26,10 @@
|
|
|
26
26
|
"./workdir-path": {
|
|
27
27
|
"types": "./workdir-path.d.ts",
|
|
28
28
|
"default": "./workdir-path.js"
|
|
29
|
+
},
|
|
30
|
+
"./migration-socket": {
|
|
31
|
+
"types": "./migration-socket.d.ts",
|
|
32
|
+
"default": "./migration-socket.js"
|
|
29
33
|
}
|
|
30
34
|
},
|
|
31
35
|
"main": "./index.js",
|