@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.
@@ -0,0 +1,386 @@
1
+ // Phase D1 — runtime validation of AutoSettings. Hand-rolled in the
2
+ // same shape as `validateBundle` (validation.ts) rather than dragging
3
+ // in zod for a small surface. Returns a list of structured errors —
4
+ // empty means valid.
5
+ //
6
+ // `AutoManager.run` invokes this at entry and throws
7
+ // `AutoSettingsValidationError` if anything fails. Catches typos and
8
+ // shape mistakes at boot rather than mid-flight.
9
+
10
+ import type { AutoSettings, BandConfig, HotSpareConfig, PlacementConfig } from "./automanager";
11
+ import type { PlacementPool } from "./placement";
12
+
13
+ /** @public */
14
+ export interface SettingsValidationError {
15
+ code: SettingsValidationErrorCode;
16
+ /** Dotted path to the offending field. */
17
+ path: string;
18
+ message: string;
19
+ }
20
+
21
+ /** @public */
22
+ export type SettingsValidationErrorCode =
23
+ | "invalidType"
24
+ | "missingRequired"
25
+ | "outOfRange"
26
+ | "invalidEnumValue"
27
+ | "unknownPool"
28
+ | "duplicatePool"
29
+ | "incompatibleConfig";
30
+
31
+ /**
32
+ * Validate an `AutoSettings` value at boot. Returns all errors found
33
+ * (does not short-circuit). Callbacks are not validated — TypeScript
34
+ * structural typing already covers them as well as a runtime check
35
+ * usefully can. The point of this validator is to catch
36
+ * configuration mistakes operators make (typos in pool names, bad
37
+ * enum values, negative timeouts), not to second-guess the type
38
+ * system.
39
+ *
40
+ * @public
41
+ */
42
+ export function validateAutoSettings(
43
+ settings: AutoSettings
44
+ ): SettingsValidationError[] {
45
+ const errors: SettingsValidationError[] = [];
46
+ const ctx = new Ctx(errors);
47
+
48
+ ctx.checkPositive(settings.pendingWindow, "pendingWindow");
49
+ ctx.checkPositive(settings.removeStoppedNodesAfter, "removeStoppedNodesAfter");
50
+
51
+ // Flat optional tunables.
52
+ ctx.checkOptionalPositive(settings.rejectionBackoffMs, "rejectionBackoffMs");
53
+ ctx.checkOptionalPositive(
54
+ settings.rejectionEscalationCount,
55
+ "rejectionEscalationCount"
56
+ );
57
+ ctx.checkOptionalPositive(settings.failureBackoffMs, "failureBackoffMs");
58
+ ctx.checkOptionalPositive(settings.maxRestartsPerHour, "maxRestartsPerHour");
59
+ ctx.checkOptionalPositive(settings.jobStartupGraceMs, "jobStartupGraceMs");
60
+ ctx.checkOptionalPositive(
61
+ settings.hotSpareReconcileIntervalMs,
62
+ "hotSpareReconcileIntervalMs"
63
+ );
64
+
65
+ // Placement pools.
66
+ const knownPoolNames = new Set<string>();
67
+ if (!Array.isArray(settings.placementPools)) {
68
+ ctx.add("invalidType", "placementPools", "must be an array");
69
+ } else {
70
+ settings.placementPools.forEach((pool, i) => {
71
+ validatePool(pool, `placementPools[${i}]`, knownPoolNames, ctx);
72
+ });
73
+ }
74
+
75
+ // Typed configs.
76
+ if (settings.bands !== undefined) {
77
+ validateBandConfig(settings.bands, "bands", ctx);
78
+ }
79
+ if (settings.placement !== undefined) {
80
+ validatePlacementConfig(settings.placement, "placement", ctx);
81
+ }
82
+ if (settings.hotSpares !== undefined) {
83
+ if (!Array.isArray(settings.hotSpares)) {
84
+ ctx.add("invalidType", "hotSpares", "must be an array");
85
+ } else {
86
+ settings.hotSpares.forEach((entry, i) => {
87
+ validateHotSpare(
88
+ entry,
89
+ `hotSpares[${i}]`,
90
+ knownPoolNames,
91
+ settings.placementPools ?? [],
92
+ ctx
93
+ );
94
+ });
95
+ }
96
+ }
97
+
98
+ return errors;
99
+ }
100
+
101
+ function validatePool(
102
+ pool: PlacementPool,
103
+ path: string,
104
+ knownPoolNames: Set<string>,
105
+ ctx: Ctx
106
+ ): void {
107
+ if (typeof pool.name !== "string" || pool.name.length === 0) {
108
+ ctx.add("invalidType", `${path}.name`, "must be a non-empty string");
109
+ } else if (knownPoolNames.has(pool.name)) {
110
+ ctx.add(
111
+ "duplicatePool",
112
+ `${path}.name`,
113
+ `duplicate pool name "${pool.name}"`
114
+ );
115
+ } else {
116
+ knownPoolNames.add(pool.name);
117
+ }
118
+ if (!Array.isArray(pool.tiers) || pool.tiers.length === 0) {
119
+ ctx.add(
120
+ "incompatibleConfig",
121
+ `${path}.tiers`,
122
+ `pool needs at least one tier`
123
+ );
124
+ return;
125
+ }
126
+ const tierNames = new Set<string>();
127
+ let fixedTierCount = 0;
128
+ pool.tiers.forEach((tier, i) => {
129
+ const tp = `${path}.tiers[${i}]`;
130
+ if (typeof tier.name !== "string" || tier.name.length === 0) {
131
+ ctx.add("invalidType", `${tp}.name`, "must be a non-empty string");
132
+ } else if (tierNames.has(tier.name)) {
133
+ ctx.add("duplicatePool", `${tp}.name`, `duplicate tier name "${tier.name}"`);
134
+ } else {
135
+ tierNames.add(tier.name);
136
+ }
137
+ if (tier.scaleOut === "fixed") fixedTierCount++;
138
+ validateTier(tier, tp, ctx);
139
+ });
140
+ // nodeMatchesTier maps an untagged (pre-registered cluster) node to the
141
+ // pool's fixed tier, so that mapping must be unambiguous.
142
+ if (fixedTierCount > 1) {
143
+ ctx.add(
144
+ "incompatibleConfig",
145
+ `${path}.tiers`,
146
+ `pool may have at most one "fixed" tier (found ${fixedTierCount})`
147
+ );
148
+ }
149
+ }
150
+
151
+ function validateTier(
152
+ tier: PlacementPool["tiers"][number],
153
+ path: string,
154
+ ctx: Ctx
155
+ ): void {
156
+ if (!["aws", "oci", "local-servers"].includes(tier.kind)) {
157
+ ctx.add(
158
+ "invalidEnumValue",
159
+ `${path}.kind`,
160
+ `must be one of "aws" | "oci" | "local-servers"`
161
+ );
162
+ }
163
+ if (!["binpack", "spread"].includes(tier.packingStrategy)) {
164
+ ctx.add(
165
+ "invalidEnumValue",
166
+ `${path}.packingStrategy`,
167
+ `must be "binpack" or "spread"`
168
+ );
169
+ }
170
+ if (!["elastic", "fixed"].includes(tier.scaleOut)) {
171
+ ctx.add(
172
+ "invalidEnumValue",
173
+ `${path}.scaleOut`,
174
+ `must be "elastic" or "fixed"`
175
+ );
176
+ }
177
+ if (tier.kind === "local-servers" && tier.scaleOut === "elastic") {
178
+ ctx.add(
179
+ "incompatibleConfig",
180
+ `${path}`,
181
+ `cluster tiers cannot be "elastic" (no auto-scale)`
182
+ );
183
+ }
184
+ if (!Array.isArray(tier.candidateInstanceTypes)) {
185
+ ctx.add(
186
+ "invalidType",
187
+ `${path}.candidateInstanceTypes`,
188
+ "must be an array"
189
+ );
190
+ } else {
191
+ if (tier.scaleOut === "elastic" && tier.candidateInstanceTypes.length === 0) {
192
+ ctx.add(
193
+ "incompatibleConfig",
194
+ `${path}.candidateInstanceTypes`,
195
+ `elastic tier needs at least one candidate instance type`
196
+ );
197
+ }
198
+ tier.candidateInstanceTypes.forEach((c, i) => {
199
+ const p = `${path}.candidateInstanceTypes[${i}]`;
200
+ if (typeof c.instanceType !== "string" || c.instanceType.length === 0) {
201
+ ctx.add("invalidType", `${p}.instanceType`, "must be non-empty string");
202
+ }
203
+ if (typeof c.totalCapacity !== "number" || c.totalCapacity <= 0) {
204
+ ctx.add("outOfRange", `${p}.totalCapacity`, "must be > 0");
205
+ }
206
+ if (
207
+ typeof c.totalCores !== "number" ||
208
+ c.totalCores <= 0 ||
209
+ !Number.isInteger(c.totalCores)
210
+ ) {
211
+ ctx.add("outOfRange", `${p}.totalCores`, "must be a positive integer");
212
+ }
213
+ });
214
+ }
215
+ }
216
+
217
+ function validateBandConfig(
218
+ bands: BandConfig,
219
+ path: string,
220
+ ctx: Ctx
221
+ ): void {
222
+ if (bands.gold) {
223
+ if (bands.gold.onFailure !== "restart") {
224
+ ctx.add(
225
+ "invalidEnumValue",
226
+ `${path}.gold.onFailure`,
227
+ `must be "restart"`
228
+ );
229
+ }
230
+ if (!["preferHotSpare", "any"].includes(bands.gold.restartPlacement)) {
231
+ ctx.add(
232
+ "invalidEnumValue",
233
+ `${path}.gold.restartPlacement`,
234
+ `must be "preferHotSpare" or "any"`
235
+ );
236
+ }
237
+ ctx.checkOptionalPositive(
238
+ bands.gold.maxRestartsPerHour,
239
+ `${path}.gold.maxRestartsPerHour`
240
+ );
241
+ }
242
+ if (bands.silver) {
243
+ if (bands.silver.onFailure !== "restart") {
244
+ ctx.add(
245
+ "invalidEnumValue",
246
+ `${path}.silver.onFailure`,
247
+ `must be "restart"`
248
+ );
249
+ }
250
+ if (bands.silver.restartPlacement !== "any") {
251
+ ctx.add(
252
+ "invalidEnumValue",
253
+ `${path}.silver.restartPlacement`,
254
+ `must be "any"`
255
+ );
256
+ }
257
+ ctx.checkOptionalPositive(
258
+ bands.silver.maxRestartsPerHour,
259
+ `${path}.silver.maxRestartsPerHour`
260
+ );
261
+ }
262
+ if (bands.bronze) {
263
+ if (bands.bronze.onFailure !== "drop") {
264
+ ctx.add(
265
+ "invalidEnumValue",
266
+ `${path}.bronze.onFailure`,
267
+ `must be "drop"`
268
+ );
269
+ }
270
+ }
271
+ }
272
+
273
+ function validatePlacementConfig(
274
+ cfg: PlacementConfig,
275
+ path: string,
276
+ ctx: Ctx
277
+ ): void {
278
+ ctx.checkOptionalPositive(cfg.rejectionBackoffMs, `${path}.rejectionBackoffMs`);
279
+ ctx.checkOptionalPositive(
280
+ cfg.rejectionEscalationCount,
281
+ `${path}.rejectionEscalationCount`
282
+ );
283
+ ctx.checkOptionalPositive(cfg.failureBackoffMs, `${path}.failureBackoffMs`);
284
+ ctx.checkOptionalPositive(cfg.jobStartupGraceMs, `${path}.jobStartupGraceMs`);
285
+ }
286
+
287
+ function validateHotSpare(
288
+ cfg: HotSpareConfig,
289
+ path: string,
290
+ knownPoolNames: Set<string>,
291
+ placementPools: PlacementPool[],
292
+ ctx: Ctx
293
+ ): void {
294
+ if (typeof cfg.pool !== "string" || cfg.pool.length === 0) {
295
+ ctx.add("invalidType", `${path}.pool`, "must be a non-empty string");
296
+ } else if (!knownPoolNames.has(cfg.pool)) {
297
+ ctx.add(
298
+ "unknownPool",
299
+ `${path}.pool`,
300
+ `pool "${cfg.pool}" is not declared in placementPools`
301
+ );
302
+ }
303
+ if (
304
+ typeof cfg.targetCount !== "number" ||
305
+ cfg.targetCount < 0 ||
306
+ !Number.isInteger(cfg.targetCount)
307
+ ) {
308
+ ctx.add(
309
+ "outOfRange",
310
+ `${path}.targetCount`,
311
+ `must be a non-negative integer (got ${cfg.targetCount})`
312
+ );
313
+ }
314
+ if (cfg.forBand !== undefined && !["gold", "silver", "bronze"].includes(cfg.forBand)) {
315
+ ctx.add(
316
+ "invalidEnumValue",
317
+ `${path}.forBand`,
318
+ `must be "gold" | "silver" | "bronze"`
319
+ );
320
+ }
321
+ if (cfg.spec === undefined || typeof cfg.spec !== "object") {
322
+ ctx.add("missingRequired", `${path}.spec`, "spec is required");
323
+ return;
324
+ }
325
+ // A pool with an elastic tier needs an instance type to provision against.
326
+ const pool = placementPools.find((p) => p.name === cfg.pool);
327
+ if (
328
+ pool &&
329
+ pool.tiers.some((t) => t.scaleOut === "elastic") &&
330
+ !cfg.spec.instanceType
331
+ ) {
332
+ ctx.add(
333
+ "missingRequired",
334
+ `${path}.spec.instanceType`,
335
+ `elastic pool spares require spec.instanceType`
336
+ );
337
+ }
338
+ }
339
+
340
+ class Ctx {
341
+ constructor(private readonly errors: SettingsValidationError[]) {}
342
+
343
+ add(code: SettingsValidationErrorCode, path: string, message: string): void {
344
+ this.errors.push({ code, path, message });
345
+ }
346
+
347
+ checkPositive(value: number, path: string): void {
348
+ if (typeof value !== "number" || value <= 0) {
349
+ this.add(
350
+ "outOfRange",
351
+ path,
352
+ `must be a positive number (got ${value})`
353
+ );
354
+ }
355
+ }
356
+
357
+ checkOptionalPositive(value: number | undefined, path: string): void {
358
+ if (value === undefined) return;
359
+ if (typeof value !== "number" || value <= 0) {
360
+ this.add(
361
+ "outOfRange",
362
+ path,
363
+ `must be a positive number when set (got ${value})`
364
+ );
365
+ }
366
+ }
367
+ }
368
+
369
+ /**
370
+ * Thrown by `AutoManager.run` when `validateAutoSettings` finds
371
+ * configuration errors. The `.errors` property carries the structured
372
+ * details; `.message` contains a human-readable summary.
373
+ *
374
+ * @public
375
+ */
376
+ export class AutoSettingsValidationError extends Error {
377
+ constructor(public readonly errors: SettingsValidationError[]) {
378
+ super(
379
+ `AutoSettings validation failed (${errors.length} error${
380
+ errors.length === 1 ? "" : "s"
381
+ }):\n ` +
382
+ errors.map((e) => `${e.path}: ${e.message}`).join("\n ")
383
+ );
384
+ this.name = "AutoSettingsValidationError";
385
+ }
386
+ }