@norskvideo/norsk-auto-manager 0.1.0

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/README.md ADDED
@@ -0,0 +1,344 @@
1
+ # `@norskvideo/norsk-auto-manager`
2
+
3
+ The opinionated orchestration layer above Norsk Manager. Manager exposes a
4
+ mechanical API — start node, terminate node, register job, report events —
5
+ without policy. AutoManager owns policy: where jobs run, how nodes are sized
6
+ and packed, how failures recover, when hot spares are provisioned.
7
+
8
+ This README is the conceptual guide. The TSDoc comments on each public
9
+ symbol are the authoritative API reference; run `npx tsc --declaration` or
10
+ read `lib/src/automanager.d.ts` for the full signatures.
11
+
12
+ For the design rationale, see `.ai/steve/tasks/37-auto-manager-design.md`
13
+ in the repo root.
14
+
15
+ ---
16
+
17
+ ## Quick start
18
+
19
+ ```ts
20
+ import { AutoManager, PlacementPool } from "@norskvideo/norsk-auto-manager";
21
+
22
+ const awsEast: PlacementPool = {
23
+ name: "aws-east",
24
+ kind: "aws",
25
+ region: "eu-west-1",
26
+ packingStrategy: "binpack",
27
+ scaleOut: "elastic",
28
+ candidateInstanceTypes: [
29
+ { instanceType: "m5.xlarge", totalCapacity: 100, totalCores: 16, capabilities: [] },
30
+ ],
31
+ };
32
+
33
+ const [autoManager, runPromise] = await AutoManager.run({
34
+ url: "manager.example.com:6789",
35
+ pendingWindow: 60,
36
+ removeStoppedNodesAfter: 3600,
37
+ placementPools: [awsEast],
38
+
39
+ // Recommended callbacks for ops tooling:
40
+ onPlacementDecision: (info) => console.log("placement", info),
41
+ onBundleBroken: (id, reason) => console.warn("bundle broken", id, reason),
42
+ onConnectionStateChange: (state) => console.log("manager", state),
43
+ });
44
+
45
+ // AutoManager runs in the background. `runPromise` resolves when the
46
+ // event-stream loop exits (e.g. after `autoManager.close()`).
47
+ ```
48
+
49
+ The `AutoManager.run` call validates the settings up front (throws
50
+ `AutoSettingsValidationError` on shape/typo problems) and connects to
51
+ Manager. Once the event stream is established, AutoManager mirrors
52
+ Manager's state (jobs, nodes, bundles, inventories) and starts running
53
+ placement decisions.
54
+
55
+ ---
56
+
57
+ ## Bundles
58
+
59
+ A **bundle** is the unit of work you submit. It has one or more **replicas**
60
+ (1 = no backup, 2 = primary + backup, N>2 = N-way redundancy). Each replica
61
+ contains one or more **jobs** — the atomic placeable units.
62
+
63
+ ```ts
64
+ import { Bundle } from "@norskvideo/norsk-auto-manager";
65
+
66
+ const bundle: Bundle = {
67
+ bundleId: "my-channel-1",
68
+ replicas: 2, // primary + backup
69
+ pools: ["aws-east", "aws-west"], // preference order; spills if first is full
70
+ jobs: [
71
+ {
72
+ jobName: "encode",
73
+ requirements: {
74
+ requiredCapacity: 8, // benchmarked reference-workload units
75
+ requiredCores: 4, // explicit cpuset pinning
76
+ requiredCapabilities: [], // hardware/software requirements
77
+ priorityBand: "gold", // see "Priority bands" below
78
+ },
79
+ },
80
+ ],
81
+ };
82
+
83
+ await autoManager.createBundle(bundle);
84
+ ```
85
+
86
+ Submit a bundle by calling `autoManager.createBundle(bundle)`. Manager
87
+ expands it into one runtime Job per replica, AutoManager runs placement
88
+ for each, and the workflow begins.
89
+
90
+ ### Placement preferences
91
+
92
+ `pools` is an ordered preference list. Placement tries the first pool; if
93
+ it can't satisfy the job there, it falls through to the next. Each replica
94
+ can override the bundle default via `replicaOverrides`:
95
+
96
+ ```ts
97
+ replicaOverrides: { 1: { pools: ["oci-fra", "aws-east"] } }
98
+ ```
99
+
100
+ This is the canonical "primary in AWS, backup in OCI, fall back to the
101
+ other on either side" cross-cloud DR pattern.
102
+
103
+ ### Intra/inter-replica rules
104
+
105
+ Within a replica, jobs can be co-located or kept apart on different
106
+ nodes (`coLocateWith`, `separateFrom` on `BundleJobSpec`). Between
107
+ replicas, the defaults are "different node" (hard) and "same AZ" (soft),
108
+ configurable via `intraReplicaPlacement` and `interReplicaPlacement`.
109
+
110
+ ---
111
+
112
+ ## Priority bands
113
+
114
+ Every job declares a `priorityBand: "gold" | "silver" | "bronze"`. The band
115
+ governs failure behaviour:
116
+
117
+ | Band | On failure | Restart placement |
118
+ |--------|------------------|------------------------|
119
+ | gold | restart | **prefers hot spare** |
120
+ | silver | restart | any available |
121
+ | bronze | drop (no restart)| n/a |
122
+
123
+ Restart bounds (`maxRestartsPerHour`, default 6) apply to gold and silver:
124
+ if a replica's restart rate exceeds the ceiling, AutoManager marks the
125
+ bundle `broken`, fires `onBundleBroken`, and stops trying. A successful
126
+ restart clears the count for that replica.
127
+
128
+ Bronze jobs that hit a recovery path fire `onJobDropped` and exit
129
+ silently.
130
+
131
+ You can configure each band's behaviour explicitly:
132
+
133
+ ```ts
134
+ bands: {
135
+ gold: { onFailure: "restart", restartPlacement: "preferHotSpare", maxRestartsPerHour: 6 },
136
+ silver: { onFailure: "restart", restartPlacement: "any", maxRestartsPerHour: 6 },
137
+ bronze: { onFailure: "drop" },
138
+ }
139
+ ```
140
+
141
+ ---
142
+
143
+ ## Hot spares
144
+
145
+ A **hot spare** is a warm-running node that holds no jobs but is ready to
146
+ take one immediately when a recovery placement picks it. Configure per pool
147
+ (and optionally per band):
148
+
149
+ ```ts
150
+ hotSpares: [
151
+ {
152
+ pool: "aws-east",
153
+ targetCount: 2,
154
+ forBand: "gold", // optional: dedicate spares to a band
155
+ spec: { instanceType: "m5.xlarge", region: "eu-west-1" },
156
+ },
157
+ ],
158
+ ```
159
+
160
+ The **reconciler** runs continually:
161
+
162
+ - Under target → provisions more spares (calls `createAwsNode`/`createOciNode` with the `spare=true` tag).
163
+ - Over target → terminates the oldest excess (supports rotation for security patching).
164
+ - A spare that picks up a job is automatically counted as "no longer a spare"; underflow fires immediately and a replacement is provisioned.
165
+
166
+ `onSpareUnderflow(pool, current, target)` fires whenever the live count
167
+ drops below the target.
168
+
169
+ For **cluster pools** (`scaleOut: "fixed"`), the reconciler can't
170
+ auto-scale — it fires `onSpareUnderflow` but leaves the actual hardware
171
+ to the operator's IT process.
172
+
173
+ ---
174
+
175
+ ## Drain / cordon
176
+
177
+ Take a node out of service gracefully:
178
+
179
+ ```ts
180
+ autoManager.cordonNode(nodeId); // no new placements land here
181
+ autoManager.uncordonNode(nodeId); // reverse
182
+
183
+ autoManager.drainNode(nodeId); // cordon + terminate (existing jobs
184
+ // recover per band rules)
185
+
186
+ autoManager.isCordoned(nodeId); // → boolean
187
+ ```
188
+
189
+ In v1, `drainNode` is the heavy-handed variant: cordon then terminate.
190
+ Manager emits `nodeStopping` for every job hosted on the node, and the
191
+ standard band-aware recovery path re-places them onto other nodes. Gold
192
+ jobs prefer spares; bronze jobs are dropped.
193
+
194
+ Phase E migration (next section) provides the drop-free variant.
195
+
196
+ ---
197
+
198
+ ## Migration
199
+
200
+ Move a running job from one node to another with cooperation from the job
201
+ code:
202
+
203
+ ```ts
204
+ const migrationId = await autoManager.migrateJob(jobId, targetNodeId);
205
+
206
+ // Track progress via callbacks:
207
+ // onMigrationPhase(state) — fires on every phase transition
208
+ // onMigrationCompleted(id) — handshake completed successfully
209
+ // onMigrationAborted(id, reason) — workflow / operator aborted
210
+
211
+ // Or poll:
212
+ autoManager.migrationById(migrationId);
213
+ autoManager.migrationForJob(jobId);
214
+ autoManager.migrations();
215
+
216
+ // Operator escape hatch:
217
+ await autoManager.abortMigration(migrationId, "operator changed mind");
218
+ ```
219
+
220
+ The protocol is **cooperative** — the workflow itself must implement the
221
+ cutover hooks (workflow ↔ worker channel; not modelled here). For
222
+ workflows that don't support migration, fall back to `drainNode` (which
223
+ is a frame drop).
224
+
225
+ No concurrent migrations per source jobId. The target instance wears
226
+ role `"migrationTarget"`; the source keeps its original role.
227
+
228
+ ---
229
+
230
+ ## Metrics
231
+
232
+ ```ts
233
+ const m = autoManager.metrics();
234
+ // {
235
+ // startedAt: Date,
236
+ // uptimeMs: number,
237
+ // placementsAttempted / Succeeded / Failed,
238
+ // jobsRejected,
239
+ // bundlesBroken,
240
+ // jobsDropped,
241
+ // pools: [{ poolName, nodeCount, liveSpares, spareTarget, usedCapacityFraction }],
242
+ // brokenBundleIds: BundleId[],
243
+ // }
244
+ ```
245
+
246
+ Counters are cumulative since `startedAt`; per-pool gauges are computed
247
+ at call time. Poll on your own cadence; AutoManager doesn't push.
248
+
249
+ ---
250
+
251
+ ## Validation
252
+
253
+ Two checks worth running before submitting bundles:
254
+
255
+ ```ts
256
+ // Static shape: replicas count, pool refs, capability satisfiability.
257
+ const errors = autoManager.validateBundle(bundle);
258
+
259
+ // Static + placement viability: "would each replica place right now?"
260
+ const result = autoManager.validateBundleAdmission(bundle);
261
+ // → { kind: "ok" }
262
+ // | { kind: "validationErrors"; errors: ValidationError[] }
263
+ // | { kind: "noCapacity"; unplaceable: { replicaIndex, jobName, reason }[] }
264
+ ```
265
+
266
+ `AutoSettings` itself is validated by `AutoManager.run` — bad settings
267
+ throw `AutoSettingsValidationError` carrying the structured error list.
268
+
269
+ ---
270
+
271
+ ## Callbacks reference
272
+
273
+ All optional. None block AutoManager's event loop on error — exceptions
274
+ are caught and forwarded to `onError`.
275
+
276
+ ### Connection / lifecycle
277
+
278
+ - `onConnectionStateChange(state)` — `"idle" | "connecting" | "ready" | "transientFailure" | "shutdown"`.
279
+ - `onError(err)` — catch-all; back-compat surface.
280
+
281
+ ### Job lifecycle
282
+
283
+ - `onJobUpdated(jobWithHistory)` — Manager pushed a job change.
284
+ - `onJobDeleted(jobId)` — job removed from the cache.
285
+ - `onJobRejected(info)` — worker rejected a placement (typed).
286
+ - `onJobConfigUpdated(jobId, config)` — runtime config refresh.
287
+ - `onJobDropped(jobId)` — bronze-band recovery dropped the job.
288
+
289
+ ### Node lifecycle
290
+
291
+ - `onNodeStarting / onNodeStarted / onNodeStopped / onNodeTerminated`.
292
+ - `onNodeUnhealthy(nodeId)` — rejection rate trip.
293
+
294
+ ### Bundle lifecycle
295
+
296
+ - `onBundleUpdated(bundle)` / `onBundleDeleted(bundleId)`.
297
+ - `onBundleBroken(bundleId, reason)` — restart-rate ceiling tripped.
298
+
299
+ ### Placement
300
+
301
+ - `onPlacementDecision(info)` — fires every time placement runs, with full trace.
302
+ - `onPlacementFailed(info)` — typed failure callback (also surfaces via `onError`).
303
+ - `onSpareUnderflow(pool, current, target)` — hot-spare reconciler is short.
304
+
305
+ ### Migration
306
+
307
+ - `onMigrationPhase / onMigrationCompleted / onMigrationAborted`.
308
+
309
+ ---
310
+
311
+ ## Operator-side config overrides (Manager)
312
+
313
+ Independent of this package but worth mentioning: Manager supports an
314
+ external JSON overrides file at `/var/data/manager-overrides.json` (or
315
+ `NORSK_MANAGER_OVERRIDES_FILE`) for the cloud / region / AZ / instance-
316
+ type fields of provider configs. Lets operators tune those without
317
+ rebuilding the Manager container. See
318
+ `.ai/steve/tasks/37-auto-manager-design.md` §D2 for the shape.
319
+
320
+ ---
321
+
322
+ ## Lifecycle
323
+
324
+ ```ts
325
+ autoManager.close(); // cancels intervals, closes the gRPC channel,
326
+ // resolves runPromise
327
+ autoManager.dispose(); // alias for close()
328
+ ```
329
+
330
+ Idempotent. After `close()`, `runPromise` settles.
331
+
332
+ ---
333
+
334
+ ## Worker-side dependencies
335
+
336
+ Several capabilities depend on worker-side work that lives outside this
337
+ package (in norsk-ctl, by deployment convention):
338
+
339
+ - **Hardware inventory & capacity scores** — workers advertise their cores, GPUs, NICs, and capabilities. Without this, placement runs against an empty `inventoryMap` and falls back to elastic-pool provisioning.
340
+ - **Worker-side enforcement** — workers refuse incoming starts that exceed their advertised capacity. Without this, AutoManager's optimistic placement isn't backstopped.
341
+ - **Migration handshake** — workers translate the four `NodeControllerMigration*` messages into workflow-level signals, and translate workflow responses back. Without this, `migrateJob` runs the protocol but the workflow never actually performs cutover.
342
+
343
+ See the `.ai/steve/tasks/37-auto-manager-design.md` §"Worker-side work"
344
+ section for the full norsk-ctl contract.