@norskvideo/ctl-product-template-schema 0.1.19 → 0.1.21
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 +31 -0
- package/index.js +26 -0
- package/migration-socket.d.ts +120 -0
- package/migration-socket.js +157 -0
- package/package.json +5 -1
package/index.d.ts
CHANGED
|
@@ -122,6 +122,17 @@ export declare const ProductTemplateRequirementsSchema: z.ZodObject<{
|
|
|
122
122
|
}, z.core.$strip>>;
|
|
123
123
|
}, z.core.$strip>;
|
|
124
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>;
|
|
125
136
|
export declare const ProductTemplateManifestSchema: z.ZodObject<{
|
|
126
137
|
productTemplateSchemaVersion: z.ZodLiteral<1>;
|
|
127
138
|
productName: z.ZodString;
|
|
@@ -159,6 +170,7 @@ export declare const ProductTemplateManifestSchema: z.ZodObject<{
|
|
|
159
170
|
isStudio: z.ZodOptional<z.ZodBoolean>;
|
|
160
171
|
supportsCustomAssets: z.ZodOptional<z.ZodBoolean>;
|
|
161
172
|
requiresWorkingDirectory: z.ZodOptional<z.ZodBoolean>;
|
|
173
|
+
studioAccessModes: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
162
174
|
sideload: z.ZodOptional<z.ZodObject<{
|
|
163
175
|
gpuInference: z.ZodOptional<z.ZodBoolean>;
|
|
164
176
|
models: z.ZodOptional<z.ZodBoolean>;
|
|
@@ -267,8 +279,19 @@ export declare const ProductTemplateManifestSchema: z.ZodObject<{
|
|
|
267
279
|
preferred: z.ZodNumber;
|
|
268
280
|
label: z.ZodOptional<z.ZodString>;
|
|
269
281
|
}, z.core.$strip>>>;
|
|
282
|
+
migration: z.ZodOptional<z.ZodObject<{
|
|
283
|
+
strategy: z.ZodEnum<{
|
|
284
|
+
overlap: "overlap";
|
|
285
|
+
stopThenStart: "stopThenStart";
|
|
286
|
+
handover: "handover";
|
|
287
|
+
}>;
|
|
288
|
+
}, z.core.$strip>>;
|
|
270
289
|
}, z.core.$strip>;
|
|
271
290
|
export type ProductTemplateManifest = z.infer<typeof ProductTemplateManifestSchema>;
|
|
291
|
+
/** The migration strategy a manifest promises — `stopThenStart` when it
|
|
292
|
+
* declares none, since that is the only strategy safe for a product that
|
|
293
|
+
* has said nothing about how it tolerates being moved. */
|
|
294
|
+
export declare function migrationStrategyOf(manifest: Pick<ProductTemplateManifest, "migration">): MigrationStrategy;
|
|
272
295
|
export declare const STANDARD_ADVANCED_OVERRIDES: {
|
|
273
296
|
readonly containerUser: {};
|
|
274
297
|
readonly publicHost: {};
|
|
@@ -354,6 +377,7 @@ export declare const ProductTemplateMaterialsSchema: z.ZodObject<{
|
|
|
354
377
|
isStudio: z.ZodOptional<z.ZodBoolean>;
|
|
355
378
|
supportsCustomAssets: z.ZodOptional<z.ZodBoolean>;
|
|
356
379
|
requiresWorkingDirectory: z.ZodOptional<z.ZodBoolean>;
|
|
380
|
+
studioAccessModes: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
357
381
|
sideload: z.ZodOptional<z.ZodObject<{
|
|
358
382
|
gpuInference: z.ZodOptional<z.ZodBoolean>;
|
|
359
383
|
models: z.ZodOptional<z.ZodBoolean>;
|
|
@@ -462,6 +486,13 @@ export declare const ProductTemplateMaterialsSchema: z.ZodObject<{
|
|
|
462
486
|
preferred: z.ZodNumber;
|
|
463
487
|
label: z.ZodOptional<z.ZodString>;
|
|
464
488
|
}, z.core.$strip>>>;
|
|
489
|
+
migration: z.ZodOptional<z.ZodObject<{
|
|
490
|
+
strategy: z.ZodEnum<{
|
|
491
|
+
overlap: "overlap";
|
|
492
|
+
stopThenStart: "stopThenStart";
|
|
493
|
+
handover: "handover";
|
|
494
|
+
}>;
|
|
495
|
+
}, z.core.$strip>>;
|
|
465
496
|
}, z.core.$strip>;
|
|
466
497
|
composeYaml: z.ZodString;
|
|
467
498
|
parameters: z.ZodOptional<z.ZodObject<{
|
package/index.js
CHANGED
|
@@ -262,6 +262,20 @@ export const ProductTemplateRequirementsSchema = z
|
|
|
262
262
|
.meta({ description: "Fixed host sockets the job binds (exclusive)." }),
|
|
263
263
|
})
|
|
264
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" });
|
|
265
279
|
export const ProductTemplateManifestSchema = z
|
|
266
280
|
.object({
|
|
267
281
|
productTemplateSchemaVersion: z.literal(1),
|
|
@@ -292,6 +306,9 @@ export const ProductTemplateManifestSchema = z
|
|
|
292
306
|
requiresWorkingDirectory: z.boolean().optional().meta({
|
|
293
307
|
description: "When true, the operator must choose a working directory at launch (a dev product template they own and keep), rather than getting the auto-assigned per-instance default. The runner's launch UI makes the working-directory field mandatory.",
|
|
294
308
|
}),
|
|
309
|
+
studioAccessModes: z.array(z.string().min(1)).optional().meta({
|
|
310
|
+
description: "The NORSK_STUDIO_ACCESS values the Studio image THIS product template pins understands. The launcher sets that variable only when the mode it is in appears here; absent, it sets nothing and Studio runs with its gate dormant, as it does today. Declared here rather than on the product manifest because this template's compose.yml is what pins the Studio image, so the declaration and the image it vouches for travel in one tar. A list rather than a boolean: a launcher must never send a word this image predates, and an unrecognised value resolves to refusing everything inside Studio.",
|
|
311
|
+
}),
|
|
295
312
|
sideload: ProductTemplateSideloadSchema.optional().meta({
|
|
296
313
|
description: "CUDA sideload bundle needs. Only gpu-inference is declared (gpu-video follows the GPU reservation automatically). The runner mounts the matching bundle when the image is slimmed and the GPU is reserved.",
|
|
297
314
|
}),
|
|
@@ -304,8 +321,17 @@ export const ProductTemplateManifestSchema = z
|
|
|
304
321
|
allocatedPorts: z.array(ProductTemplateAllocatedPortSchema).optional().meta({
|
|
305
322
|
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.",
|
|
306
323
|
}),
|
|
324
|
+
migration: ProductTemplateMigrationSchema.optional().meta({
|
|
325
|
+
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.",
|
|
326
|
+
}),
|
|
307
327
|
})
|
|
308
328
|
.meta({ id: "ProductTemplateManifest", outputId: "ProductTemplateManifest" });
|
|
329
|
+
/** The migration strategy a manifest promises — `stopThenStart` when it
|
|
330
|
+
* declares none, since that is the only strategy safe for a product that
|
|
331
|
+
* has said nothing about how it tolerates being moved. */
|
|
332
|
+
export function migrationStrategyOf(manifest) {
|
|
333
|
+
return manifest.migration?.strategy ?? DEFAULT_MIGRATION_STRATEGY;
|
|
334
|
+
}
|
|
309
335
|
// Container user and external URL are runner knobs every product wants
|
|
310
336
|
// overridable per instance — the runner only holds their default values.
|
|
311
337
|
// 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.21",
|
|
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",
|