@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/src/types.ts ADDED
@@ -0,0 +1,346 @@
1
+ // Type model for the AutoManager v1 design (see
2
+ // .ai/steve/tasks/37-auto-manager-design.md). These are the new
3
+ // scheduler-facing types — capabilities, capacity, bundles, replicas,
4
+ // priority bands, node inventory. They sit alongside the existing
5
+ // AutoManager types in automanager.ts (AutoJob, NodeSummary, AutoSettings)
6
+ // which serve the older API surface; new code should prefer the types
7
+ // here.
8
+
9
+ import * as ManagerSdk from "@norskvideo/norsk-manager-sdk";
10
+ import { ProductTemplateRef, JobId, NodeId, Role } from "@norskvideo/norsk-manager-sdk";
11
+
12
+ // ----- IDs -------------------------------------------------------------
13
+
14
+ /** @public */
15
+ export type BundleId = string;
16
+ /** @public */
17
+ export type ServiceId = string;
18
+
19
+ // ----- Capacity & cores (§3.1) -----------------------------------------
20
+
21
+ /**
22
+ * Reference-workload-normalised compute units. The reference is real-time
23
+ * H.264 1080p30 libx264 preset `medium` single-stream. A node's
24
+ * `totalCapacity` is its score for that benchmark; a job's
25
+ * `requiredCapacity` is what it consumes against the same scale.
26
+ *
27
+ * @public
28
+ */
29
+ export type Capacity = number;
30
+
31
+ /**
32
+ * Integer count of CPU cores, used both for nodes (`totalCores`) and for
33
+ * jobs that need explicit cpuset pinning (`requiredCores`). Distinct from
34
+ * `Capacity` because pinning is about parallelism slicing, not throughput.
35
+ *
36
+ * @public
37
+ */
38
+ export type CoreCount = number;
39
+
40
+ // ----- GPU (§3.2) ------------------------------------------------------
41
+
42
+ /**
43
+ * Per-device GPU pool. Each physical GPU on a node appears as its own
44
+ * `GpuResource` with a capacity score. Jobs request fractional capacity
45
+ * via `requiredGpuCapacity`; for whole-GPU exclusive use, request the
46
+ * `norsk.io/nvidia-gpu` capability instead (see §3.3).
47
+ *
48
+ * @public
49
+ */
50
+ export interface GpuResource {
51
+ index: number;
52
+ model?: string;
53
+ totalCapacity: number;
54
+ reservedCapacity: number;
55
+ /** True if a whole-GPU exclusive job has taken this device. */
56
+ exclusivelyReserved: boolean;
57
+ }
58
+
59
+ // ----- Capabilities (§3.3) ---------------------------------------------
60
+
61
+ /**
62
+ * A capability advertised by a node — physical hardware (NICs, GPUs,
63
+ * capture cards), installed software (drivers, licenses), or capabilities
64
+ * provided dynamically by a NodeService. Stringly-typed namespaced names
65
+ * follow the K8s extended-resource pattern; see `KnownCapabilities` for
66
+ * the standard names shipped with Norsk.
67
+ *
68
+ * @public
69
+ */
70
+ export interface Capability {
71
+ /** Namespaced name, e.g. "norsk.io/st2110-nic", "acme/license-key". */
72
+ name: string;
73
+ /** Integer count, exclusive in v1. */
74
+ count: number;
75
+ /** Optional metadata used for matching and runtime service-discovery. */
76
+ attributes?: Record<string, string>;
77
+ }
78
+
79
+ /**
80
+ * A capability a job requires. Matches against a node's advertised
81
+ * capabilities by name and (optionally) by attribute equality.
82
+ *
83
+ * @public
84
+ */
85
+ export interface CapabilityRequirement {
86
+ name: string;
87
+ count: number;
88
+ /**
89
+ * Each entry must be present on the matched capability with the given
90
+ * value. Useful for "GPU model = RTX 4090" or "license tier = enterprise".
91
+ */
92
+ attributeMatches?: Record<string, string>;
93
+ }
94
+
95
+ // ----- Priority bands (§3.4) -------------------------------------------
96
+
97
+ /** @public */
98
+ export type PriorityBand = "gold" | "silver" | "bronze";
99
+
100
+ // ----- Job requirements ------------------------------------------------
101
+
102
+ /**
103
+ * Resource and policy requirements for a single job. Bundles of jobs
104
+ * compose these per-job specs together; placement filters and scoring
105
+ * use them as inputs.
106
+ *
107
+ * @public
108
+ */
109
+ export interface JobRequirements {
110
+ requiredCapacity: Capacity;
111
+ /** Omit (or 0) to share whatever cores are available — no cpuset pin. */
112
+ requiredCores?: CoreCount;
113
+ /** GPU reference units. Omit if the job doesn't need a GPU. */
114
+ requiredGpuCapacity?: number;
115
+ /** Optional model match (e.g. "RTX 4090") for the chosen GPU. */
116
+ requiredGpuModel?: string;
117
+ /** Counted/exclusive resources — hardware and software capabilities. */
118
+ requiredCapabilities: CapabilityRequirement[];
119
+ priorityBand: PriorityBand;
120
+ }
121
+
122
+ // ----- Bundles & replicas (§3.5) ---------------------------------------
123
+
124
+ /**
125
+ * A bundle is a logical workload unit. It runs a primary copy plus — when
126
+ * a `resiliencePolicy` is set — one backup (replica index 0 = primary,
127
+ * 1 = backup). The copy count is derived, not operator-set (see
128
+ * `replicaCount`). Each copy's jobs are placed under the bundle's
129
+ * intra-replica rules; the primary is placed against the bundle's single
130
+ * `pool` (cost cascade lives in the pool's ordered tiers), the backup from
131
+ * the resilience policy's pool subject to its anti-affinity flags.
132
+ *
133
+ * @public
134
+ */
135
+ export interface Bundle {
136
+ bundleId: BundleId;
137
+ /** The pool this bundle's primary jobs are placed into. */
138
+ pool: string;
139
+ jobs: BundleJobSpec[];
140
+ intraReplicaPlacement?: IntraReplicaRules;
141
+ /**
142
+ * When set, the bundle runs a primary + one backup; absent ⇒ active-only
143
+ * (primary only — AutoManager still restarts a failed primary).
144
+ */
145
+ resiliencePolicy?: ResiliencePolicy;
146
+ /**
147
+ * Lifecycle state. `"live"` for newly created bundles; transitions to
148
+ * `"deleting"` when an operator issues DeleteBundle and the daemon
149
+ * starts the stop-then-delete saga. The entry leaves the daemon's
150
+ * BundleStore once all jobs have confirmed stop. Optional on read so
151
+ * older callers can ignore the field; conversions default to `"live"`.
152
+ */
153
+ state?: BundleState;
154
+ }
155
+
156
+ /** @public */
157
+ export type BundleState = "live" | "deleting";
158
+
159
+ /** @public */
160
+ export interface BundleJobSpec {
161
+ /** Unique within the bundle. */
162
+ jobName: string;
163
+ requirements: JobRequirements;
164
+ /** jobNames in the same replica that MUST land on the same node. */
165
+ coLocateWith?: string[];
166
+ /** jobNames in the same replica that MUST land on different nodes. */
167
+ separateFrom?: string[];
168
+ /**
169
+ * What this bundle member launches. Either a product-template tar (the new,
170
+ * preferred path; opaque to Manager, materialised by the worker's
171
+ * runner) or a legacy services/volumes Job template (existing
172
+ * pre-product-template flow).
173
+ *
174
+ * Per-replica differences flow through `Job.tags` projected into
175
+ * the product-template's `.env` by the worker — there is no separate
176
+ * per-replica parameter overlay. See
177
+ * `.ai/steve/tasks/39-blueprint-jobs.md`.
178
+ */
179
+ launch?: LaunchSpec;
180
+ }
181
+
182
+ /** @public */
183
+ export type LaunchSpec =
184
+ | {
185
+ kind: "productTemplate";
186
+ ref: ProductTemplateRef;
187
+ /** Resolved env-var overrides for the compose stack (same for every replica). */
188
+ parameters?: Record<string, string>;
189
+ /**
190
+ * When this job should start. Required by Manager-daemon for every
191
+ * stored job; if omitted, AutoManager stamps `new Date()` (immediate)
192
+ * at conversion time. Surfaced explicitly so launch UIs can offer a
193
+ * "now / pick a future time" picker without needing a separate field.
194
+ */
195
+ startDateTime?: Date;
196
+ /**
197
+ * Optional auto-stop. When set and in the future, AutoManager
198
+ * schedules a per-bundle timer that fires `deleteBundle(bundleId)`
199
+ * at this instant. There's no daemon-side concept of stop time —
200
+ * this is wire-encoded as a reserved `__stopDateTime` tag on the
201
+ * proto Job (in `tags`), so it persists across reconnects via
202
+ * the daemon's tag store. Use `setBundleStopTime` to change after
203
+ * launch; the timer reschedules.
204
+ */
205
+ stopDateTime?: Date;
206
+ /**
207
+ * Free-form tags forwarded to PbJob.tags. Daemon preserves them
208
+ * verbatim; worker reads via cluster events. Names starting with
209
+ * `__` are reserved for SDK use (e.g. `__stopDateTime`) and are
210
+ * stripped on both inbound and outbound conversion — callers
211
+ * who try to set them get a quiet no-op.
212
+ */
213
+ tags?: Record<string, string>;
214
+ }
215
+ | {
216
+ kind: "legacy";
217
+ /** Job template Manager stamps per replica (legacy services/volumes path). */
218
+ job: ManagerSdk.Job;
219
+ };
220
+
221
+ /** @public */
222
+ export interface IntraReplicaRules {
223
+ /** Default sameNode for jobs without coLocateWith/separateFrom override. */
224
+ sameNodeDefault: boolean;
225
+ sameAz: "hard" | "soft" | "off";
226
+ }
227
+
228
+ /**
229
+ * Anti-affinity strength for a resilience-policy flag. v1 is best-effort
230
+ * always: `"forbid"` means "keep the backup out of this failure domain if
231
+ * at all possible" — but if no domain-respecting node is free the engine
232
+ * places it anyway and surfaces the degradation. `"allow"` = no constraint.
233
+ *
234
+ * @public
235
+ */
236
+ export type AntiAffinity = "allow" | "forbid";
237
+
238
+ /**
239
+ * When present on a bundle, the bundle runs a primary + one backup. The
240
+ * backup is placed from `pool` (omitted ⇒ the bundle's own pool) subject to
241
+ * the anti-affinity flags, which keep it in a different failure domain
242
+ * (node / AZ / cloud) from the primary.
243
+ *
244
+ * @public
245
+ */
246
+ export interface ResiliencePolicy {
247
+ /** Backup capacity pool. Omitted ⇒ use the bundle's own `pool`. */
248
+ pool?: string;
249
+ sameNode: AntiAffinity;
250
+ sameAz: AntiAffinity;
251
+ sameCloud: AntiAffinity;
252
+ }
253
+
254
+ /**
255
+ * Derived copy count: primary (1) plus one backup when a resilience policy
256
+ * is present. Replaces the old operator-set `replicas` field.
257
+ *
258
+ * @public
259
+ */
260
+ export function replicaCount(bundle: Bundle): number {
261
+ return bundle.resiliencePolicy ? 2 : 1;
262
+ }
263
+
264
+ // ----- Inventory (§3.6) ------------------------------------------------
265
+
266
+ /**
267
+ * What a node advertises through `nodeInventoryUpdated` events. Workers
268
+ * are the source of truth — placement reads, never writes, this view.
269
+ *
270
+ * @public
271
+ */
272
+ export interface NodeInventory {
273
+ totalCapacity: Capacity;
274
+ /** Fraction (0-1) of totalCapacity that's safe to allocate. */
275
+ utilizationCap: number;
276
+ totalCores: CoreCount;
277
+ gpus: GpuResource[];
278
+ capabilities: Capability[];
279
+ reservedCapacity: Capacity;
280
+ reservedCores: CoreCount;
281
+ reservedCapabilities: Capability[];
282
+ reachable: boolean;
283
+ lastSeen: Date;
284
+ /** Set true by an operator drain; placement skips this node. */
285
+ cordoned: boolean;
286
+ assignedJobs: NodeAssignedJob[];
287
+ }
288
+
289
+ /** @public */
290
+ export interface NodeAssignedJob {
291
+ jobId: JobId;
292
+ /** CPU core indices, if pinned. */
293
+ coreSet?: number[];
294
+ /** Index of the GPU bound to this job, if any. */
295
+ gpuIndex?: number;
296
+ }
297
+
298
+ // ----- NodeService (§3.8) ----------------------------------------------
299
+
300
+ /**
301
+ * A long-running job that consumes capabilities and provides new ones.
302
+ *
303
+ * The motivating example is an MXL bridge that takes exclusive
304
+ * possession of an ST2110 NIC (a `consumes` capability) and
305
+ * advertises N consumer slots (a `provides` capability) for other
306
+ * jobs on the same node to use.
307
+ *
308
+ * AutoManager's NodeService reconciler places services on every
309
+ * eligible node (for `lifecycle: "eager"`) or on first consumer
310
+ * demand (for `lifecycle: "lazy"` — deferred in v1).
311
+ *
312
+ * The actual capability advertisement is worker-side work
313
+ * (`provides[]` is added to the node's inventory by the worker once
314
+ * the service reaches `ready`). Without that, AutoManager places
315
+ * the service but no consumer can see its capabilities. See
316
+ * §"Worker-side work" in the design doc.
317
+ *
318
+ * @public
319
+ */
320
+ export interface NodeService {
321
+ serviceId: ServiceId;
322
+ /**
323
+ * SDK-shape runtime Job template. AutoManager stamps in the
324
+ * generated jobId and adds `tags["nodeService"] = serviceId` and
325
+ * `tags["auto-manager"] = "true"`; everything else flows through
326
+ * verbatim. The worker recognises NodeService instances by the
327
+ * `nodeService` tag.
328
+ */
329
+ jobSpec: ManagerSdk.Job;
330
+ /** Capabilities the service requires from the host node. */
331
+ consumes: CapabilityRequirement[];
332
+ /** Capabilities the service advertises while running and ready. */
333
+ provides: Capability[];
334
+ /** Pool names the reconciler considers when placing this service. */
335
+ pools: string[];
336
+ /**
337
+ * - `eager`: place on every node in `pools` that satisfies `consumes`.
338
+ * - `lazy`: place only when an unplaceable consumer would benefit.
339
+ * (Deferred in v1 — only eager is implemented.)
340
+ */
341
+ lifecycle: "eager" | "lazy";
342
+ }
343
+
344
+ // ----- Re-export IDs we reuse from manager-sdk -------------------------
345
+
346
+ export type { JobId, NodeId, Role };
@@ -0,0 +1,297 @@
1
+ // Static validation of Bundle definitions at submission time. Per §4.3
2
+ // of the design doc, this catches constraint contradictions and "no
3
+ // allowed pool can ever satisfy this" cases before placement runs.
4
+ //
5
+ // Two flavours of result:
6
+ // • `errors` — spec is structurally wrong; submission must be
7
+ // rejected (unknown pool, duplicate job name, contradictory
8
+ // placement, capacity unsatisfiable in an elastic pool
9
+ // with a fixed candidateInstanceTypes list, etc).
10
+ // • `warnings` — spec is structurally fine but current capacity won't
11
+ // satisfy it. Specifically the `unsatisfiable*`
12
+ // conditions when ANY targeted pool is `kind: "cluster"`
13
+ // — those pools' capacity is event-driven (workers can
14
+ // join later), so a snapshot-time miss isn't a permanent
15
+ // reject. Callers should surface warnings to the operator
16
+ // but still allow submission; placement will queue or
17
+ // fail at runtime as appropriate.
18
+ //
19
+ // The function is pure: callers pass in the bundle and a description of
20
+ // the configured pools; it returns ValidationResult.
21
+
22
+ import { Bundle, Capability, CapabilityRequirement, Capacity, CoreCount } from "./types";
23
+
24
+ /** @public */
25
+ export interface ValidationError {
26
+ /** Stable machine-readable code for tests / structured logging. */
27
+ code: ValidationErrorCode;
28
+ /** Human-readable message including the offending value. */
29
+ message: string;
30
+ }
31
+
32
+ /** @public */
33
+ export type ValidationErrorCode =
34
+ | "duplicateJobName"
35
+ | "unknownJobNameInPlacement"
36
+ | "selfReferenceInPlacement"
37
+ | "contradictoryPlacement"
38
+ | "unsatisfiableCapacity"
39
+ | "unsatisfiableCores"
40
+ | "unsatisfiableCapability"
41
+ | "emptyPoolList"
42
+ | "unknownPoolName"
43
+ | "unknownResiliencePool"
44
+ | "resilienceUnsatisfiable";
45
+
46
+ /**
47
+ * Outcome of validateBundle. An empty `errors` array means submission
48
+ * is allowed; `warnings` may still be non-empty (capacity issues against
49
+ * a cluster pool, where workers may join later).
50
+ *
51
+ * @public
52
+ */
53
+ export interface ValidationResult {
54
+ errors: ValidationError[];
55
+ warnings: ValidationError[];
56
+ }
57
+
58
+ /**
59
+ * Description of a configured pool, sufficient for static validation. A
60
+ * pool's `nodeProfiles` represent the kinds of node available — for
61
+ * elastic pools this is each allowed instance type's capability set;
62
+ * for fixed cluster pools it's the actual registered nodes' capability
63
+ * sets. Validation accepts a bundle if at least one profile in at least
64
+ * one named pool can satisfy each requirement.
65
+ *
66
+ * `kind` controls whether an unsatisfied capacity / cores / capability
67
+ * requirement becomes a hard `error` ("elastic"; candidateInstanceTypes
68
+ * is operator-configured and time-invariant — if it doesn't fit, future
69
+ * instances won't either) or a soft `warning` ("cluster"; live worker
70
+ * inventory may change before placement runs).
71
+ *
72
+ * @public
73
+ */
74
+ export interface PoolDescriptor {
75
+ poolName: string;
76
+ kind: "elastic" | "cluster";
77
+ nodeProfiles: NodeProfile[];
78
+ /** Distinct cloud/failure-domain keys the pool's tiers span (tier kinds).
79
+ * Used for the resilience sameCloud cardinality check. */
80
+ clouds: string[];
81
+ }
82
+
83
+ /** @public */
84
+ export interface NodeProfile {
85
+ /**
86
+ * Reference-workload-normalised compute score the node advertises
87
+ * (or, for elastic pools, what the candidate instance type is
88
+ * scored at). Used to statically reject bundles whose
89
+ * `requiredCapacity` exceeds the largest profile available in any
90
+ * candidate pool.
91
+ */
92
+ totalCapacity: Capacity;
93
+ totalCores: CoreCount;
94
+ capabilities: Capability[];
95
+ }
96
+
97
+ /**
98
+ * Validate a Bundle against a set of configured pools. Returns all
99
+ * findings (does not short-circuit on first one) so callers can surface
100
+ * a complete list to the user. See `ValidationResult` for the
101
+ * errors-vs-warnings split.
102
+ *
103
+ * @public
104
+ */
105
+ export function validateBundle(
106
+ bundle: Bundle,
107
+ pools: PoolDescriptor[]
108
+ ): ValidationResult {
109
+ const errors: ValidationError[] = [];
110
+ const warnings: ValidationError[] = [];
111
+ const knownPools = new Set(pools.map((p) => p.poolName));
112
+
113
+ // ---- pool ----
114
+ if (bundle.pool.length === 0) {
115
+ errors.push({
116
+ code: "emptyPoolList",
117
+ message: "bundle.pool must name a pool",
118
+ });
119
+ } else if (!knownPools.has(bundle.pool)) {
120
+ errors.push({
121
+ code: "unknownPoolName",
122
+ message: `pool "${bundle.pool}" is not configured`,
123
+ });
124
+ }
125
+
126
+ // ---- resilience policy ----
127
+ // Static reject is cardinality-only: the clearly-impossible case where the
128
+ // backup shares the primary's pool and that pool spans a single cloud, so
129
+ // sameCloud:forbid can never be honoured. AZ/node spans aren't statically
130
+ // known (cluster nodes register, elastic AZs are region-dependent), so
131
+ // those flags are best-effort at runtime, not rejected here.
132
+ const policy = bundle.resiliencePolicy;
133
+ if (policy) {
134
+ if (policy.pool !== undefined && !knownPools.has(policy.pool)) {
135
+ errors.push({
136
+ code: "unknownResiliencePool",
137
+ message: `resiliencePolicy.pool "${policy.pool}" is not configured`,
138
+ });
139
+ }
140
+ if (policy.sameCloud === "forbid") {
141
+ const samePool = policy.pool === undefined || policy.pool === bundle.pool;
142
+ const primaryPool = pools.find((p) => p.poolName === bundle.pool);
143
+ if (samePool && primaryPool && primaryPool.clouds.length < 2) {
144
+ errors.push({
145
+ code: "resilienceUnsatisfiable",
146
+ message: `resiliencePolicy sameCloud:forbid, but the backup shares pool "${bundle.pool}" which spans a single cloud`,
147
+ });
148
+ }
149
+ }
150
+ }
151
+
152
+ // ---- jobs ----
153
+ const jobNames = new Set<string>();
154
+ const seenNames = new Set<string>();
155
+ for (const j of bundle.jobs) {
156
+ if (seenNames.has(j.jobName)) {
157
+ errors.push({
158
+ code: "duplicateJobName",
159
+ message: `duplicate jobName "${j.jobName}" in bundle`,
160
+ });
161
+ }
162
+ seenNames.add(j.jobName);
163
+ jobNames.add(j.jobName);
164
+ }
165
+
166
+ // Need a complete name set before checking cross-references.
167
+ for (const j of bundle.jobs) {
168
+ const coLocate = new Set(j.coLocateWith ?? []);
169
+ const separate = new Set(j.separateFrom ?? []);
170
+
171
+ for (const ref of coLocate) {
172
+ if (ref === j.jobName) {
173
+ errors.push({
174
+ code: "selfReferenceInPlacement",
175
+ message: `job "${j.jobName}" coLocateWith references itself`,
176
+ });
177
+ } else if (!jobNames.has(ref)) {
178
+ errors.push({
179
+ code: "unknownJobNameInPlacement",
180
+ message: `job "${j.jobName}" coLocateWith references unknown jobName "${ref}"`,
181
+ });
182
+ }
183
+ }
184
+ for (const ref of separate) {
185
+ if (ref === j.jobName) {
186
+ errors.push({
187
+ code: "selfReferenceInPlacement",
188
+ message: `job "${j.jobName}" separateFrom references itself`,
189
+ });
190
+ } else if (!jobNames.has(ref)) {
191
+ errors.push({
192
+ code: "unknownJobNameInPlacement",
193
+ message: `job "${j.jobName}" separateFrom references unknown jobName "${ref}"`,
194
+ });
195
+ }
196
+ }
197
+ for (const ref of coLocate) {
198
+ if (separate.has(ref)) {
199
+ errors.push({
200
+ code: "contradictoryPlacement",
201
+ message: `job "${j.jobName}" both coLocateWith and separateFrom "${ref}"`,
202
+ });
203
+ }
204
+ }
205
+
206
+ // ---- requirement vs. pool capacity ----
207
+ const requestedPools = poolsForJob(bundle, knownPools);
208
+ if (requestedPools.length > 0) {
209
+ const reqs = j.requirements;
210
+ // Whether the job's pool list includes any cluster-kind pool
211
+ // determines whether unsatisfiable* outcomes are errors (no
212
+ // cluster pool — only elastic pools whose candidateInstanceTypes
213
+ // is operator-configured, so a miss is permanent) or warnings
214
+ // (cluster pool present — capacity can arrive via a worker
215
+ // joining; let the operator submit anyway).
216
+ const targetsCluster = requestedPools.some((name) => {
217
+ const pool = pools.find((p) => p.poolName === name);
218
+ return pool?.kind === "cluster";
219
+ });
220
+ const pushUnsatisfiable = (entry: ValidationError) =>
221
+ (targetsCluster ? warnings : errors).push(entry);
222
+
223
+ if (reqs.requiredCapacity > 0) {
224
+ if (
225
+ !anyProfileSatisfies(requestedPools, pools, (prof) =>
226
+ prof.totalCapacity >= reqs.requiredCapacity
227
+ )
228
+ ) {
229
+ pushUnsatisfiable({
230
+ code: "unsatisfiableCapacity",
231
+ message: `job "${j.jobName}" requires capacity ${reqs.requiredCapacity}, no node profile in any allowed pool offers it`,
232
+ });
233
+ }
234
+ }
235
+ if (reqs.requiredCores !== undefined && reqs.requiredCores > 0) {
236
+ if (
237
+ !anyProfileSatisfies(requestedPools, pools, (prof) =>
238
+ prof.totalCores >= (reqs.requiredCores ?? 0)
239
+ )
240
+ ) {
241
+ pushUnsatisfiable({
242
+ code: "unsatisfiableCores",
243
+ message: `job "${j.jobName}" requires ${reqs.requiredCores} cores, no node profile in any allowed pool meets this`,
244
+ });
245
+ }
246
+ }
247
+ for (const cap of reqs.requiredCapabilities) {
248
+ if (
249
+ !anyProfileSatisfies(requestedPools, pools, (prof) =>
250
+ profileSatisfiesCapability(prof, cap)
251
+ )
252
+ ) {
253
+ pushUnsatisfiable({
254
+ code: "unsatisfiableCapability",
255
+ message: `job "${j.jobName}" requires capability "${cap.name}" count ${cap.count}, no node profile in any allowed pool advertises it`,
256
+ });
257
+ }
258
+ }
259
+ }
260
+ }
261
+
262
+ return { errors, warnings };
263
+ }
264
+
265
+ function poolsForJob(bundle: Bundle, knownPools: Set<string>): string[] {
266
+ // Which pool might a job land in? Just the bundle's single pool (if it's
267
+ // configured). If it can satisfy the requirement we don't report it as
268
+ // statically unsatisfiable.
269
+ return knownPools.has(bundle.pool) ? [bundle.pool] : [];
270
+ }
271
+
272
+ function anyProfileSatisfies(
273
+ poolNames: string[],
274
+ pools: PoolDescriptor[],
275
+ pred: (p: NodeProfile) => boolean
276
+ ): boolean {
277
+ for (const name of poolNames) {
278
+ const pool = pools.find((p) => p.poolName === name);
279
+ if (!pool) continue;
280
+ if (pool.nodeProfiles.some(pred)) return true;
281
+ }
282
+ return false;
283
+ }
284
+
285
+ function profileSatisfiesCapability(
286
+ profile: NodeProfile,
287
+ req: CapabilityRequirement
288
+ ): boolean {
289
+ const cap = profile.capabilities.find((c) => c.name === req.name);
290
+ if (!cap || cap.count < req.count) return false;
291
+ if (req.attributeMatches) {
292
+ for (const [k, v] of Object.entries(req.attributeMatches)) {
293
+ if (cap.attributes?.[k] !== v) return false;
294
+ }
295
+ }
296
+ return true;
297
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "compilerOptions": {
3
+ "allowJs": true,
4
+ "composite": true,
5
+ "declaration": true,
6
+ "declarationMap": true,
7
+ "esModuleInterop": true,
8
+ "experimentalDecorators": true,
9
+ "module": "commonjs",
10
+ "noImplicitAny": true,
11
+ "outDir": "lib",
12
+ "sourceMap": true,
13
+ "sourceRoot": "src",
14
+ "strictFunctionTypes": true,
15
+ "strictNullChecks": true,
16
+ "stripInternal": true,
17
+ "strictPropertyInitialization": false,
18
+ "target": "ES2020",
19
+ "typeRoots": ["./node_modules/@types", "../../node_modules/@types"],
20
+ "types": ["node", "uuid"]
21
+ },
22
+ "include": [
23
+ "./src/**/*"
24
+ ]
25
+ }