@norskvideo/ctl-product-template-schema 0.1.19 → 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 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;
@@ -267,8 +278,19 @@ export declare const ProductTemplateManifestSchema: z.ZodObject<{
267
278
  preferred: z.ZodNumber;
268
279
  label: z.ZodOptional<z.ZodString>;
269
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>>;
270
288
  }, z.core.$strip>;
271
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;
272
294
  export declare const STANDARD_ADVANCED_OVERRIDES: {
273
295
  readonly containerUser: {};
274
296
  readonly publicHost: {};
@@ -462,6 +484,13 @@ export declare const ProductTemplateMaterialsSchema: z.ZodObject<{
462
484
  preferred: z.ZodNumber;
463
485
  label: z.ZodOptional<z.ZodString>;
464
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>>;
465
494
  }, z.core.$strip>;
466
495
  composeYaml: z.ZodString;
467
496
  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),
@@ -304,8 +318,17 @@ export const ProductTemplateManifestSchema = z
304
318
  allocatedPorts: z.array(ProductTemplateAllocatedPortSchema).optional().meta({
305
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.",
306
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
+ }),
307
324
  })
308
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
+ }
309
332
  // Container user and external URL are runner knobs every product wants
310
333
  // overridable per instance — the runner only holds their default values.
311
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.19",
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",