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