// Static validation of Bundle definitions at submission time. Per §4.3 // of the design doc, this catches constraint contradictions and "no // allowed pool can ever satisfy this" cases before placement runs. // // Two flavours of result: // • `errors` — spec is structurally wrong; submission must be // rejected (unknown pool, duplicate job name, contradictory // placement, capacity unsatisfiable in an elastic pool // with a fixed candidateInstanceTypes list, etc). // • `warnings` — spec is structurally fine but current capacity won't // satisfy it. Specifically the `unsatisfiable*` // conditions when ANY targeted pool is `kind: "cluster"` // — those pools' capacity is event-driven (workers can // join later), so a snapshot-time miss isn't a permanent // reject. Callers should surface warnings to the operator // but still allow submission; placement will queue or // fail at runtime as appropriate. // // The function is pure: callers pass in the bundle and a description of // the configured pools; it returns ValidationResult. import { Bundle, Capability, CapabilityRequirement, Capacity, CoreCount } from "./types"; /** @public */ export interface ValidationError { /** Stable machine-readable code for tests / structured logging. */ code: ValidationErrorCode; /** Human-readable message including the offending value. */ message: string; } /** @public */ export type ValidationErrorCode = | "duplicateJobName" | "unknownJobNameInPlacement" | "selfReferenceInPlacement" | "contradictoryPlacement" | "mixedExclusiveGroup" | "singleUseRequiresExclusive" | "mixedSingleUseGroup" | "invalidRequirement" | "unsatisfiableCapacity" | "unsatisfiableCores" | "unsatisfiableCapability" | "emptyPoolName" | "unknownPoolName" | "unknownResiliencePool" | "resilienceUnsatisfiable"; /** * Outcome of validateBundle. An empty `errors` array means submission * is allowed; `warnings` may still be non-empty (capacity issues against * a cluster pool, where workers may join later). * * @public */ export interface ValidationResult { errors: ValidationError[]; warnings: ValidationError[]; } /** * Description of a configured pool, sufficient for static validation. A * pool's `nodeProfiles` represent the kinds of node available — for * elastic pools this is each allowed instance type's capability set; * for fixed cluster pools it's the actual registered nodes' capability * sets. Validation accepts a bundle if at least one profile in at least * one named pool can satisfy each requirement. * * `kind` controls whether an unsatisfied capacity / cores / capability * requirement becomes a hard `error` ("elastic"; candidateInstanceTypes * is operator-configured and time-invariant — if it doesn't fit, future * instances won't either) or a soft `warning` ("cluster"; live worker * inventory may change before placement runs). * * @public */ export interface PoolDescriptor { poolName: string; kind: "elastic" | "cluster"; nodeProfiles: NodeProfile[]; /** Distinct cloud/failure-domain keys the pool's tiers span (tier kinds). * Used for the resilience sameCloud cardinality check. */ clouds: string[]; } /** @public */ export interface NodeProfile { /** * Reference-workload-normalised compute score the node advertises * (or, for elastic pools, what the candidate instance type is * scored at). Used to statically reject bundles whose * `requiredCapacity` exceeds the largest profile available in any * candidate pool. */ totalCapacity: Capacity; totalCores: CoreCount; capabilities: Capability[]; } /** * Validate a Bundle against a set of configured pools. Returns all * findings (does not short-circuit on first one) so callers can surface * a complete list to the user. See `ValidationResult` for the * errors-vs-warnings split. * * @public */ export function validateBundle( bundle: Bundle, pools: PoolDescriptor[] ): ValidationResult { const errors: ValidationError[] = []; const warnings: ValidationError[] = []; const knownPools = new Set(pools.map((p) => p.poolName)); // ---- pool ---- if (bundle.pool.length === 0) { errors.push({ code: "emptyPoolName", message: "bundle.pool must name a pool", }); } else if (!knownPools.has(bundle.pool)) { errors.push({ code: "unknownPoolName", message: `pool "${bundle.pool}" is not configured`, }); } // ---- resilience policy ---- // Static reject is cardinality-only: the clearly-impossible case where the // backup shares the primary's pool and that pool spans a single cloud, so // sameCloud:forbid can never be honoured. AZ/node spans aren't statically // known (cluster nodes register, elastic AZs are region-dependent), so // those flags are best-effort at runtime, not rejected here. const policy = bundle.resiliencePolicy; if (policy) { if (policy.pool !== undefined && !knownPools.has(policy.pool)) { errors.push({ code: "unknownResiliencePool", message: `resiliencePolicy.pool "${policy.pool}" is not configured`, }); } if (policy.sameCloud === "forbid") { const samePool = policy.pool === undefined || policy.pool === bundle.pool; const primaryPool = pools.find((p) => p.poolName === bundle.pool); if (samePool && primaryPool && primaryPool.clouds.length < 2) { errors.push({ code: "resilienceUnsatisfiable", message: `resiliencePolicy sameCloud:forbid, but the backup shares pool "${bundle.pool}" which spans a single cloud`, }); } } } // ---- jobs ---- const jobNames = new Set(); const seenNames = new Set(); for (const j of bundle.jobs) { if (seenNames.has(j.jobName)) { errors.push({ code: "duplicateJobName", message: `duplicate jobName "${j.jobName}" in bundle`, }); } seenNames.add(j.jobName); jobNames.add(j.jobName); } // Need a complete name set before checking cross-references. for (const j of bundle.jobs) { const coLocate = new Set(j.coLocateWith ?? []); const separate = new Set(j.separateFrom ?? []); for (const ref of coLocate) { if (ref === j.jobName) { errors.push({ code: "selfReferenceInPlacement", message: `job "${j.jobName}" coLocateWith references itself`, }); } else if (!jobNames.has(ref)) { errors.push({ code: "unknownJobNameInPlacement", message: `job "${j.jobName}" coLocateWith references unknown jobName "${ref}"`, }); } } for (const ref of separate) { if (ref === j.jobName) { errors.push({ code: "selfReferenceInPlacement", message: `job "${j.jobName}" separateFrom references itself`, }); } else if (!jobNames.has(ref)) { errors.push({ code: "unknownJobNameInPlacement", message: `job "${j.jobName}" separateFrom references unknown jobName "${ref}"`, }); } } for (const ref of coLocate) { if (separate.has(ref)) { errors.push({ code: "contradictoryPlacement", message: `job "${j.jobName}" both coLocateWith and separateFrom "${ref}"`, }); } } // Exclusive must be uniform across a co-located group: a non-exclusive // member alone on the shared node would let an outside job land before // the exclusive member arrives — a guaranteed placement wedge. Callers // (norsk-mgr's composer) flag whole groups, so this only catches // hand-rolled specs. if (j.exclusive) { for (const ref of coLocate) { const peer = bundle.jobs.find((p) => p.jobName === ref); if (peer && !peer.exclusive) { errors.push({ code: "mixedExclusiveGroup", message: `job "${j.jobName}" is exclusive but co-located "${ref}" is not — flag the whole group exclusive`, }); } } } if (j.singleUse) { // Termination is only safe when nothing else shares the node. if (!j.exclusive) { errors.push({ code: "singleUseRequiresExclusive", message: `job "${j.jobName}" is single-use but not exclusive — a shared node cannot be terminated on completion`, }); } // And uniform across a group: if the LAST member out isn't // single-use, its end never triggers the terminate and the node // silently lingers — the flag's whole point defeated. for (const ref of coLocate) { const peer = bundle.jobs.find((p) => p.jobName === ref); if (peer && !peer.singleUse) { errors.push({ code: "mixedSingleUseGroup", message: `job "${j.jobName}" is single-use but co-located "${ref}" is not — flag the whole group single-use`, }); } } } // ---- requirement domains ---- // The numbers are summed into node reservations (deriveReservation), // so a value outside its domain is not merely unsatisfiable — a // negative capacity would INCREASE the node's free capacity. Domains: // requiredCapacity finite, >= 0 (0 = no CPU claim) // requiredCores absent, or a finite integer >= 0 (0 = no pin) // requiredGpuCapacity absent, or finite >= 0 (0 = no GPU, as // placement reads it) { const reqs = j.requirements; const invalid = (field: string, value: number, domain: string) => errors.push({ code: "invalidRequirement", message: `job "${j.jobName}" ${field} is ${value}; must be ${domain}`, }); if (!(Number.isFinite(reqs.requiredCapacity) && reqs.requiredCapacity >= 0)) invalid("requiredCapacity", reqs.requiredCapacity, "a finite number >= 0"); if ( reqs.requiredCores !== undefined && !(Number.isInteger(reqs.requiredCores) && reqs.requiredCores >= 0) ) invalid("requiredCores", reqs.requiredCores, "an integer >= 0"); if ( reqs.requiredGpuCapacity !== undefined && !(Number.isFinite(reqs.requiredGpuCapacity) && reqs.requiredGpuCapacity >= 0) ) invalid("requiredGpuCapacity", reqs.requiredGpuCapacity, "a finite number >= 0"); } // ---- requirement vs. pool capacity ---- const requestedPools = poolsForJob(bundle, knownPools); if (requestedPools.length > 0) { const reqs = j.requirements; // Whether the job's pool list includes any cluster-kind pool // determines whether unsatisfiable* outcomes are errors (no // cluster pool — only elastic pools whose candidateInstanceTypes // is operator-configured, so a miss is permanent) or warnings // (cluster pool present — capacity can arrive via a worker // joining; let the operator submit anyway). const targetsCluster = requestedPools.some((name) => { const pool = pools.find((p) => p.poolName === name); return pool?.kind === "cluster"; }); const pushUnsatisfiable = (entry: ValidationError) => (targetsCluster ? warnings : errors).push(entry); if (reqs.requiredCapacity > 0) { if ( !anyProfileSatisfies(requestedPools, pools, (prof) => prof.totalCapacity >= reqs.requiredCapacity ) ) { pushUnsatisfiable({ code: "unsatisfiableCapacity", message: `job "${j.jobName}" requires capacity ${reqs.requiredCapacity}, no node profile in any allowed pool offers it`, }); } } if (reqs.requiredCores !== undefined && reqs.requiredCores > 0) { if ( !anyProfileSatisfies(requestedPools, pools, (prof) => prof.totalCores >= (reqs.requiredCores ?? 0) ) ) { pushUnsatisfiable({ code: "unsatisfiableCores", message: `job "${j.jobName}" requires ${reqs.requiredCores} cores, no node profile in any allowed pool meets this`, }); } } for (const cap of reqs.requiredCapabilities) { if ( !anyProfileSatisfies(requestedPools, pools, (prof) => profileSatisfiesCapability(prof, cap) ) ) { pushUnsatisfiable({ code: "unsatisfiableCapability", message: `job "${j.jobName}" requires capability "${cap.name}" count ${cap.count}, no node profile in any allowed pool advertises it`, }); } } } } return { errors, warnings }; } function poolsForJob(bundle: Bundle, knownPools: Set): string[] { // Which pool might a job land in? Just the bundle's single pool (if it's // configured). If it can satisfy the requirement we don't report it as // statically unsatisfiable. return knownPools.has(bundle.pool) ? [bundle.pool] : []; } function anyProfileSatisfies( poolNames: string[], pools: PoolDescriptor[], pred: (p: NodeProfile) => boolean ): boolean { for (const name of poolNames) { const pool = pools.find((p) => p.poolName === name); if (!pool) continue; if (pool.nodeProfiles.some(pred)) return true; } return false; } function profileSatisfiesCapability( profile: NodeProfile, req: CapabilityRequirement ): boolean { const cap = profile.capabilities.find((c) => c.name === req.name); if (!cap || cap.count < req.count) return false; if (req.attributeMatches) { for (const [k, v] of Object.entries(req.attributeMatches)) { if (cap.attributes?.[k] !== v) return false; } } return true; }