// Phase D1 — runtime validation of AutoSettings. Hand-rolled in the // same shape as `validateBundle` (validation.ts) rather than dragging // in zod for a small surface. Returns a list of structured errors — // empty means valid. // // `AutoManager.run` invokes this at entry and throws // `AutoSettingsValidationError` if anything fails. Catches typos and // shape mistakes at boot rather than mid-flight. import type { AutoSettings, FailurePolicyConfig, HotSpareConfig, PlacementConfig } from "./automanager"; import type { PlacementPool } from "./placement"; /** @public */ export interface SettingsValidationError { code: SettingsValidationErrorCode; /** Dotted path to the offending field. */ path: string; message: string; } /** @public */ export type SettingsValidationErrorCode = | "invalidType" | "missingRequired" | "outOfRange" | "invalidEnumValue" | "unknownPool" | "duplicatePool" | "duplicateTier" | "incompatibleConfig" | "experimentalNotEnabled"; /** * Validate an `AutoSettings` value at boot. Returns all errors found * (does not short-circuit). Callbacks are not validated — TypeScript * structural typing already covers them as well as a runtime check * usefully can. The point of this validator is to catch * configuration mistakes operators make (typos in pool names, bad * enum values, negative timeouts), not to second-guess the type * system. * * @public */ export function validateAutoSettings( settings: AutoSettings ): SettingsValidationError[] { const errors: SettingsValidationError[] = []; const ctx = new Ctx(errors); ctx.checkPositive(settings.pendingWindow, "pendingWindow"); ctx.checkPositive(settings.removeStoppedNodesAfter, "removeStoppedNodesAfter"); // Flat optional tunables. ctx.checkOptionalPositive(settings.rejectionBackoffMs, "rejectionBackoffMs"); ctx.checkOptionalPositive( settings.rejectionEscalationCount, "rejectionEscalationCount" ); ctx.checkOptionalPositive(settings.unhealthyCooldownMs, "unhealthyCooldownMs"); ctx.checkOptionalPositive(settings.failureBackoffMs, "failureBackoffMs"); ctx.checkOptionalPositive(settings.maxRestartsPerHour, "maxRestartsPerHour"); ctx.checkOptionalPositive(settings.jobStartupGraceMs, "jobStartupGraceMs"); ctx.checkOptionalPositive( settings.hotSpareReconcileIntervalMs, "hotSpareReconcileIntervalMs" ); // Placement pools. const knownPoolNames = new Set(); if (!Array.isArray(settings.placementPools)) { ctx.add("invalidType", "placementPools", "must be an array"); } else { settings.placementPools.forEach((pool, i) => { validatePool(pool, `placementPools[${i}]`, knownPoolNames, ctx); }); } // Typed configs. if (settings.failurePolicies !== undefined) { validateFailurePolicyConfig(settings.failurePolicies, "failurePolicies", ctx); } if (settings.placement !== undefined) { validatePlacementConfig(settings.placement, "placement", ctx); } if (settings.nodeServices !== undefined) { if (!Array.isArray(settings.nodeServices)) { ctx.add("invalidType", "nodeServices", "must be an array"); } else { // The fence (node-services.md §"Meanwhile"): what exists is a working // F1 and nothing more, so nobody configures a service by accident. if (settings.nodeServices.length > 0 && settings.experimental?.nodeServices !== true) { ctx.add( "experimentalNotEnabled", "nodeServices", "node services are experimental — set experimental.nodeServices: true to enable (see norsk-mgr docs/internal/design/node-services.md)" ); } settings.nodeServices.forEach((svc, i) => { // `lazy` is declared on the type but has no implementation: the // reconciler would accept the service and never place it. Refuse // it here rather than let a configured service silently do nothing. if (svc.lifecycle !== "eager") { ctx.add( "invalidEnumValue", `nodeServices[${i}].lifecycle`, `must be "eager" — lifecycle "${String(svc.lifecycle)}" is not implemented` ); } }); } } if (settings.hotSpares !== undefined) { if (!Array.isArray(settings.hotSpares)) { ctx.add("invalidType", "hotSpares", "must be an array"); } else { const seenPools = new Set(); settings.hotSpares.forEach((entry, i) => { validateHotSpare( entry, `hotSpares[${i}]`, knownPoolNames, settings.placementPools ?? [], ctx ); // One reserve per pool is the whole model — the pool is the entry's // key (norsk-mgr's routes address it that way). Two entries for one // pool would both count the same spares against different targets. if (typeof entry.pool === "string" && entry.pool.length > 0) { if (seenPools.has(entry.pool)) { ctx.add("duplicatePool", `hotSpares[${i}].pool`, `duplicate hot-spare entry for pool "${entry.pool}"`); } seenPools.add(entry.pool); } }); } } return errors; } function validatePool( pool: PlacementPool, path: string, knownPoolNames: Set, ctx: Ctx ): void { if (typeof pool.name !== "string" || pool.name.length === 0) { ctx.add("invalidType", `${path}.name`, "must be a non-empty string"); } else if (knownPoolNames.has(pool.name)) { ctx.add( "duplicatePool", `${path}.name`, `duplicate pool name "${pool.name}"` ); } else { knownPoolNames.add(pool.name); } if (!Array.isArray(pool.tiers) || pool.tiers.length === 0) { ctx.add( "incompatibleConfig", `${path}.tiers`, `pool needs at least one tier` ); return; } const tierNames = new Set(); let fixedTierCount = 0; pool.tiers.forEach((tier, i) => { const tp = `${path}.tiers[${i}]`; if (typeof tier.name !== "string" || tier.name.length === 0) { ctx.add("invalidType", `${tp}.name`, "must be a non-empty string"); } else if (tierNames.has(tier.name)) { ctx.add("duplicateTier", `${tp}.name`, `duplicate tier name "${tier.name}"`); } else { tierNames.add(tier.name); } if (tier.scaleOut === "fixed") fixedTierCount++; validateTier(tier, tp, ctx); }); // nodeMatchesTier maps an untagged (pre-registered cluster) node to the // pool's fixed tier, so that mapping must be unambiguous. if (fixedTierCount > 1) { ctx.add( "incompatibleConfig", `${path}.tiers`, `pool may have at most one "fixed" tier (found ${fixedTierCount})` ); } } function validateTier( tier: PlacementPool["tiers"][number], path: string, ctx: Ctx ): void { if (!["aws", "oci", "local-servers"].includes(tier.kind)) { ctx.add( "invalidEnumValue", `${path}.kind`, `must be one of "aws" | "oci" | "local-servers"` ); } if (!["binpack", "spread"].includes(tier.packingStrategy)) { ctx.add( "invalidEnumValue", `${path}.packingStrategy`, `must be "binpack" or "spread"` ); } if (!["elastic", "fixed"].includes(tier.scaleOut)) { ctx.add( "invalidEnumValue", `${path}.scaleOut`, `must be "elastic" or "fixed"` ); } if (tier.kind === "local-servers" && tier.scaleOut === "elastic") { ctx.add( "incompatibleConfig", `${path}`, `cluster tiers cannot be "elastic" (no auto-scale)` ); } if (tier.rootVolume !== undefined) { const v = tier.rootVolume; const p = `${path}.rootVolume`; if (tier.kind !== "aws") { ctx.add("incompatibleConfig", p, `only aws tiers take a root volume`); } if (typeof v.sizeGib !== "number" || !Number.isInteger(v.sizeGib) || v.sizeGib <= 0) { ctx.add("outOfRange", `${p}.sizeGib`, "must be a positive integer (GiB)"); } if (v.volumeType !== undefined && !["gp2", "gp3", "io1", "io2"].includes(v.volumeType)) { ctx.add("invalidType", `${p}.volumeType`, `must be one of gp2, gp3, io1, io2`); } if (v.iops !== undefined) { if (typeof v.iops !== "number" || !Number.isInteger(v.iops) || v.iops <= 0) { ctx.add("outOfRange", `${p}.iops`, "must be a positive integer"); } else if ((v.volumeType ?? "gp2") === "gp2") { ctx.add("incompatibleConfig", `${p}.iops`, `gp2 volumes do not take provisioned IOPS`); } } } if (!Array.isArray(tier.candidateInstanceTypes)) { ctx.add( "invalidType", `${path}.candidateInstanceTypes`, "must be an array" ); } else { if (tier.scaleOut === "elastic" && tier.candidateInstanceTypes.length === 0) { ctx.add( "incompatibleConfig", `${path}.candidateInstanceTypes`, `elastic tier needs at least one candidate instance type` ); } tier.candidateInstanceTypes.forEach((c, i) => { const p = `${path}.candidateInstanceTypes[${i}]`; if (typeof c.instanceType !== "string" || c.instanceType.length === 0) { ctx.add("invalidType", `${p}.instanceType`, "must be non-empty string"); } if (typeof c.totalCapacity !== "number" || c.totalCapacity <= 0) { ctx.add("outOfRange", `${p}.totalCapacity`, "must be > 0"); } if ( typeof c.totalCores !== "number" || c.totalCores <= 0 || !Number.isInteger(c.totalCores) ) { ctx.add("outOfRange", `${p}.totalCores`, "must be a positive integer"); } }); } } function validateFailurePolicyConfig( policies: FailurePolicyConfig, path: string, ctx: Ctx ): void { // Only the ceilings are checked. The `onFailure` fields these entries // used to carry restated their own key and were never read, so they // went with the rename; a config that still supplies one is ignored // like any other unknown key rather than rejected on upgrade. ctx.checkOptionalPositive( policies.preferSpare?.maxRestartsPerHour, `${path}.preferSpare.maxRestartsPerHour` ); ctx.checkOptionalPositive( policies.avoidSpare?.maxRestartsPerHour, `${path}.avoidSpare.maxRestartsPerHour` ); } function validatePlacementConfig( cfg: PlacementConfig, path: string, ctx: Ctx ): void { ctx.checkOptionalPositive(cfg.rejectionBackoffMs, `${path}.rejectionBackoffMs`); ctx.checkOptionalPositive( cfg.rejectionEscalationCount, `${path}.rejectionEscalationCount` ); ctx.checkOptionalPositive(cfg.unhealthyCooldownMs, `${path}.unhealthyCooldownMs`); ctx.checkOptionalPositive(cfg.failureBackoffMs, `${path}.failureBackoffMs`); ctx.checkOptionalPositive(cfg.jobStartupGraceMs, `${path}.jobStartupGraceMs`); } function validateHotSpare( cfg: HotSpareConfig, path: string, knownPoolNames: Set, placementPools: PlacementPool[], ctx: Ctx ): void { if (typeof cfg.pool !== "string" || cfg.pool.length === 0) { ctx.add("invalidType", `${path}.pool`, "must be a non-empty string"); } else if (!knownPoolNames.has(cfg.pool)) { ctx.add( "unknownPool", `${path}.pool`, `pool "${cfg.pool}" is not declared in placementPools` ); } if ( typeof cfg.targetCount !== "number" || cfg.targetCount < 0 || !Number.isInteger(cfg.targetCount) ) { ctx.add( "outOfRange", `${path}.targetCount`, `must be a non-negative integer (got ${cfg.targetCount})` ); } // Phase G — warmFor / reservedFor are template-name lists; a name with a // comma cannot survive the node tag it rides on, so refuse it here rather // than let it split into two templates that do not exist. for (const field of ["warmFor", "reservedFor"] as const) { const v = cfg[field]; if (v === undefined) continue; if (!Array.isArray(v) || v.some((n) => typeof n !== "string" || n.length === 0)) { ctx.add("invalidType", `${path}.${field}`, "must be an array of template names"); } else if (v.some((n) => n.includes(","))) { ctx.add( "invalidType", `${path}.${field}`, "template names must not contain a comma" ); } } if (cfg.exclusive !== undefined && typeof cfg.exclusive !== "boolean") { ctx.add("invalidType", `${path}.exclusive`, "must be a boolean"); } if (cfg.exclusive === true && (cfg.reservedFor ?? []).length === 0) { ctx.add( "missingRequired", `${path}.reservedFor`, "an exclusive spare needs at least one reservedFor template — otherwise nothing may ever take it" ); } if (cfg.spec === undefined || typeof cfg.spec !== "object") { ctx.add("missingRequired", `${path}.spec`, "spec is required"); return; } // A pool with an elastic tier needs an instance type to provision against. const pool = placementPools.find((p) => p.name === cfg.pool); if ( pool && pool.tiers.some((t) => t.scaleOut === "elastic") && !cfg.spec.instanceType ) { ctx.add( "missingRequired", `${path}.spec.instanceType`, `elastic pool spares require spec.instanceType` ); } } class Ctx { constructor(private readonly errors: SettingsValidationError[]) {} add(code: SettingsValidationErrorCode, path: string, message: string): void { this.errors.push({ code, path, message }); } checkPositive(value: number, path: string): void { if (typeof value !== "number" || value <= 0) { this.add( "outOfRange", path, `must be a positive number (got ${value})` ); } } checkOptionalPositive(value: number | undefined, path: string): void { if (value === undefined) return; if (typeof value !== "number" || value <= 0) { this.add( "outOfRange", path, `must be a positive number when set (got ${value})` ); } } } /** * Thrown by `AutoManager.run` when `validateAutoSettings` finds * configuration errors. The `.errors` property carries the structured * details; `.message` contains a human-readable summary. * * @public */ export class AutoSettingsValidationError extends Error { constructor(public readonly errors: SettingsValidationError[]) { super( `AutoSettings validation failed (${errors.length} error${ errors.length === 1 ? "" : "s" }):\n ` + errors.map((e) => `${e.path}: ${e.message}`).join("\n ") ); this.name = "AutoSettingsValidationError"; } }