@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 +344 -0
- package/lib/src/automanager.d.ts +1070 -0
- package/lib/src/automanager.js +2746 -0
- package/lib/src/clock.d.ts +14 -0
- package/lib/src/clock.js +21 -0
- package/lib/src/conversions.d.ts +59 -0
- package/lib/src/conversions.js +416 -0
- package/lib/src/index.d.ts +9 -0
- package/lib/src/index.js +37 -0
- package/lib/src/inventory.d.ts +34 -0
- package/lib/src/inventory.js +64 -0
- package/lib/src/knownCapabilities.d.ts +18 -0
- package/lib/src/knownCapabilities.js +22 -0
- package/lib/src/placement.d.ts +268 -0
- package/lib/src/placement.js +468 -0
- package/lib/src/settingsValidation.d.ts +34 -0
- package/lib/src/settingsValidation.js +232 -0
- package/lib/src/shared/utils.d.ts +17 -0
- package/lib/src/shared/utils.js +220 -0
- package/lib/src/types.d.ts +295 -0
- package/lib/src/types.js +20 -0
- package/lib/src/validation.d.ts +68 -0
- package/lib/src/validation.js +198 -0
- package/package.json +66 -0
- package/src/automanager.ts +3570 -0
- package/src/clock.ts +29 -0
- package/src/conversions.ts +458 -0
- package/src/index.ts +27 -0
- package/src/inventory.ts +64 -0
- package/src/knownCapabilities.ts +23 -0
- package/src/placement.ts +804 -0
- package/src/settingsValidation.ts +386 -0
- package/src/types.ts +346 -0
- package/src/validation.ts +297 -0
- package/tsconfig.json +25 -0
|
@@ -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
|
+
}
|