@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,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
|
package/lib/src/types.js
ADDED
|
@@ -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
|
+
}
|