diff --git a/apis/eksclusters/definition.yaml b/apis/eksclusters/definition.yaml index 082d9502f..9f17057a3 100644 --- a/apis/eksclusters/definition.yaml +++ b/apis/eksclusters/definition.yaml @@ -43,10 +43,12 @@ spec: maxLength: 32 kubernetesVersion: type: string - default: "1.31" + default: "1.36" description: >- EKS cluster Kubernetes version. Must be a version EKS - currently supports. + currently supports. Defaults to a version where Dynamic + Resource Allocation (how GPUs bind to pods) is generally + available. minLength: 1 maxLength: 16 networking: diff --git a/apis/inferenceclasses/definition.yaml b/apis/inferenceclasses/definition.yaml index 7c04659da..d278b243e 100644 --- a/apis/inferenceclasses/definition.yaml +++ b/apis/inferenceclasses/definition.yaml @@ -21,7 +21,7 @@ spec: properties: spec: type: object - required: [resources] + required: [devices] properties: description: type: string @@ -50,6 +50,12 @@ spec: minimum: 10 accelerator: type: object + description: >- + GPU accelerator to attach when provisioning the node + pool. Provisioning input only: the scheduler matches + against spec.devices, not this block, so count here is + the GCP machine's GPU count and need not be restated in + devices. required: [type, count] properties: type: @@ -81,6 +87,10 @@ spec: minimum: 10 accelerator: type: object + description: >- + GPU accelerator to attach when provisioning the node + group. Provisioning input only: the scheduler matches + against spec.devices, not this block. required: [type, count] properties: type: @@ -95,26 +105,117 @@ spec: type: integer minimum: 1 maximum: 16 - resources: - type: object + devices: + type: array description: >- - Hardware resources a node of this class exposes. - required: [gpu] - properties: - gpu: - type: object - required: [count, memory] - properties: - count: - type: integer - description: GPUs per node. - minimum: 1 - maximum: 16 - memory: - type: string - description: Per-GPU VRAM (e.g. "24Gi", "80Gi"). - minLength: 1 - maxLength: 16 + Devices a node of this class has, following DRA's model + (KEP-4381). Each entry describes one kind of device with a + count, mirroring what a DRA driver publishes in a + ResourceSlice (one entry per kind rather than per physical + device). ModelDeployment.nodeSelector matches against these, + and claim: DRA devices are emitted as requests in a DRA + ResourceClaim when scheduling a worker to this pool. + + A scheduled worker pod is pinned to its pool with a + nodeSelector on the modelplane.ai/pool node label. + Modelplane-provisioned (EKS, GKE) pools carry this label + automatically. On a BYO (Existing) cluster Modelplane doesn't + provision the nodes, so the operator must label the pool's + nodes modelplane.ai/pool= themselves, or + worker pods for this class will stay Pending. + minItems: 1 + maxItems: 16 + x-kubernetes-list-type: map + x-kubernetes-list-map-keys: [name] + items: + type: object + required: [name, driver] + properties: + name: + type: string + description: >- + Name of this device within the class (e.g. gpu, nic). + minLength: 1 + maxLength: 63 + claim: + type: string + description: >- + How Modelplane treats this device. DRA emits it as a + request in a ResourceClaim, so DRA binds a matching + device to the pod at admission time; use it for hardware + a real DRA driver exposes. Synthetic describes the device + for fleet scheduling only and never claims it; use it for + hardware that matters for placement but has no DRA driver + yet, like an InfiniBand fabric. + enum: [DRA, Synthetic] + default: DRA + driver: + type: string + description: >- + DRA driver that owns this device (e.g. gpu.nvidia.com). + Becomes the attribute/capacity domain a nodeSelector + reads as device.attributes[""].. + minLength: 1 + maxLength: 253 + deviceClassName: + type: string + description: >- + Name of the cluster-scoped DRA DeviceClass to claim this + device through. Required for claim: DRA devices; the DRA + driver install creates the DeviceClass (e.g. + gpu.nvidia.com). Ignored for Synthetic devices. + minLength: 1 + maxLength: 253 + count: + type: integer + description: How many of this device a node has. + default: 1 + minimum: 1 + maximum: 64 + attributes: + type: object + description: >- + DRA-style typed attributes for this device. Keys are + bare names (e.g. architecture); the domain comes from the + device's driver. Each value sets exactly one typed field. + maxProperties: 32 + additionalProperties: + type: object + properties: + string: + type: string + maxLength: 253 + version: + type: string + description: Semantic version (e.g. "9.0.0"). + maxLength: 32 + bool: + type: boolean + int: + type: integer + format: int64 + x-kubernetes-validations: + - rule: "[has(self.string), has(self.version), has(self.bool), has(self.int)].filter(x, x).size() == 1" + message: "exactly one of string, version, bool, int must be set" + capacity: + type: object + description: >- + DRA-style capacity quantities for this device. Keys are + bare names (e.g. memory); values are Kubernetes + Quantities. + maxProperties: 32 + additionalProperties: + type: object + required: [value] + properties: + value: + type: string + description: A Kubernetes Quantity (e.g. "141Gi"). + minLength: 1 + maxLength: 32 + x-kubernetes-validations: + - rule: "self.claim != 'DRA' || has(self.deviceClassName)" + message: "deviceClassName is required when claim is DRA" status: type: object properties: diff --git a/apis/inferenceclusters/definition.yaml b/apis/inferenceclusters/definition.yaml index aed4dc134..a1e658e07 100644 --- a/apis/inferenceclusters/definition.yaml +++ b/apis/inferenceclusters/definition.yaml @@ -142,7 +142,11 @@ spec: maxLength: 32 kubernetesVersion: type: string - default: "1.31" + default: "1.36" + description: >- + EKS cluster Kubernetes version. Defaults to a + version where Dynamic Resource Allocation (how GPUs + bind to pods) is generally available. cache: type: object description: >- @@ -224,29 +228,83 @@ spec: description: >- External IP of the inference gateway on the remote cluster. Used by ModelDeployment for unified endpoint routing. - capacity: - type: object + gpuPools: + type: array description: >- - Declared capacity derived from the referenced classes - and the per-pool node counts. - properties: - gpuPools: - type: array - items: - type: object - properties: - acceleratorType: - type: string - memory: - type: string - description: Per-GPU VRAM (e.g. "24Gi"). - countPerNode: - type: integer - nodes: - type: integer - description: >- - Number of nodes in this pool. Derived from - maxNodeCount (if autoscaling) or nodeCount. + Schedulable GPU node pools on this cluster, derived from the + referenced classes and the per-pool node counts. + ModelDeployment scheduling matches against these. + maxItems: 8 + x-kubernetes-list-type: map + x-kubernetes-list-map-keys: [name] + items: + type: object + required: [name] + properties: + name: + type: string + description: >- + Node pool name, matching spec.nodePools[].name. + Used to pin a ModelReplica to a specific pool via + spec.nodePoolName. + nodes: + type: integer + description: >- + Number of nodes in this pool. Derived from + maxNodeCount (if autoscaling) or nodeCount. + devices: + type: array + description: >- + Devices copied from the pool's InferenceClass. + ModelDeployment.nodeSelector matches against these. + maxItems: 16 + x-kubernetes-list-type: map + x-kubernetes-list-map-keys: [name] + items: + type: object + required: [name, driver] + properties: + name: + type: string + maxLength: 63 + claim: + type: string + enum: [DRA, Synthetic] + driver: + type: string + maxLength: 253 + deviceClassName: + type: string + maxLength: 253 + count: + type: integer + attributes: + type: object + maxProperties: 32 + additionalProperties: + type: object + properties: + string: + type: string + maxLength: 253 + version: + type: string + maxLength: 32 + bool: + type: boolean + int: + type: integer + format: int64 + capacity: + type: object + maxProperties: 32 + additionalProperties: + type: object + required: [value] + properties: + value: + type: string + maxLength: 32 namespace: type: string description: >- diff --git a/apis/modeldeployments/definition.yaml b/apis/modeldeployments/definition.yaml index bf8b25fd0..382785c8f 100644 --- a/apis/modeldeployments/definition.yaml +++ b/apis/modeldeployments/definition.yaml @@ -25,7 +25,7 @@ spec: properties: spec: type: object - required: [replicas, workers] + required: [replicas, workers, nodeSelector] properties: replicas: type: integer @@ -43,6 +43,85 @@ spec: matchLabels: type: object x-kubernetes-preserve-unknown-fields: true + nodeSelector: + type: object + description: >- + Node-level matching, a list of device requests mirroring a + DRA ResourceClaim. The scheduler matches each request against a + candidate pool's InferenceClass devices (surfaced on + InferenceCluster status.gpuPools) and pins the replica to a + pool that satisfies every request. claim: DRA requests also + become DeviceRequests in the ResourceClaim the serving pods + bind GPUs through. Required: GPUs bind only via DRA, so a + deployment must declare the devices its model needs. At least + one request must resolve to a claimable (claim: DRA) device; + the serving workload binds its GPUs through the resulting + ResourceClaim. Synthetic devices refine placement but are never + claimed, so a nodeSelector that matches only synthetic devices + leaves the workload nothing to claim - the scheduler treats + such a pool as ineligible and the deployment reports + InsufficientCapacity. + required: [devices] + properties: + devices: + type: array + description: >- + Device requests. A pool matches a request when it has a + device whose count covers the request and whose driver, + attributes, and capacity satisfy every selector. + minItems: 1 + maxItems: 16 + x-kubernetes-list-type: map + x-kubernetes-list-map-keys: [name] + items: + type: object + required: [name, selectors] + properties: + name: + type: string + description: >- + Name of this request. Mirrors a DRA DeviceRequest + name; carried through to the ResourceClaim. + minLength: 1 + maxLength: 63 + count: + type: integer + description: >- + How many matching devices a node must have. For a GPU + request this is the per-node GPU count (matches the + worker topology's GPUs per node). + default: 1 + minimum: 1 + maximum: 64 + selectors: + type: array + description: >- + Selectors a device must satisfy, all ANDed. Each is a + one-of; today only cel is supported. + minItems: 1 + maxItems: 8 + x-kubernetes-list-type: atomic + items: + type: object + # A selector must carry at least one selector kind + # (today only cel). Without this an empty {} selector + # would match every device, and since nodeSelector is + # the only path to a GPU that silently claims an + # arbitrary one. + minProperties: 1 + properties: + cel: + type: string + description: >- + A DRA CEL expression evaluated against one + device. Reads device.driver, + device.attributes[""]. (typed), + and device.capacity[""]. (a + Quantity), with quantity() and semver() helpers, + e.g. + device.capacity["gpu.nvidia.com"].memory.compareTo(quantity("141Gi")) >= 0. + minLength: 1 + maxLength: 10240 modelCacheRef: type: object required: [name] diff --git a/apis/modelreplicas/definition.yaml b/apis/modelreplicas/definition.yaml index 8aae6c499..22fa6ba28 100644 --- a/apis/modelreplicas/definition.yaml +++ b/apis/modelreplicas/definition.yaml @@ -25,7 +25,7 @@ spec: properties: spec: type: object - required: [clusterName, workers] + required: [clusterName, nodePoolName, deviceRequests, workers] properties: clusterName: type: string @@ -38,6 +38,68 @@ spec: degraded state via its conditions. If the cluster is deleted entirely the parent ModelDeployment re-places the replica on another viable cluster. + nodePoolName: + type: string + minLength: 1 + description: >- + Name of the node pool on the pinned InferenceCluster the + scheduler selected for this replica. The scheduler pins every + replica to a specific matching pool, so this is always set. + deviceRequests: + type: array + description: >- + Resolved DRA device requests for the matched pool. The parent + ModelDeployment's compose function joins the nodeSelector + requests with the matched InferenceClass devices and stamps the + claim: DRA devices here. This function turns each into a + DeviceRequest in a DRA ResourceClaim for the serving pods. At + least one request is always present: the scheduler only pins a + replica to a pool that yields a claimable device, so the + serving workload always has a ResourceClaim to bind through. + minItems: 1 + maxItems: 16 + x-kubernetes-list-type: map + x-kubernetes-list-map-keys: [name] + items: + type: object + required: [name, deviceClassName] + properties: + name: + type: string + description: Request name; becomes the DeviceRequest name. + minLength: 1 + maxLength: 63 + deviceClassName: + type: string + description: >- + Cluster-scoped DRA DeviceClass to claim through, from the + matched InferenceClass device. + minLength: 1 + maxLength: 253 + count: + type: integer + description: How many devices to claim. + default: 1 + minimum: 1 + maximum: 64 + selectors: + type: array + description: >- + DRA CEL selectors copied verbatim from the nodeSelector + request, ANDed in the DeviceRequest. + maxItems: 8 + x-kubernetes-list-type: atomic + items: + type: object + # A selector must carry a selector kind (today only cel). + # An empty {} selector would match any device, silently + # widening the DRA claim the serving pod binds through. + minProperties: 1 + properties: + cel: + type: string + minLength: 1 + maxLength: 10240 modelCacheRef: type: object required: [name] diff --git a/apis/servingstacks/definition.yaml b/apis/servingstacks/definition.yaml index 1f797948b..c8749a28c 100644 --- a/apis/servingstacks/definition.yaml +++ b/apis/servingstacks/definition.yaml @@ -103,6 +103,24 @@ spec: description: LeaderWorkerSet chart version. minLength: 1 maxLength: 32 + nodeFeatureDiscovery: + type: string + default: "0.18.3" + description: >- + Node Feature Discovery chart version. NFD labels GPU + nodes so the NVIDIA DRA driver targets its kubelet + plugin to them. + minLength: 1 + maxLength: 32 + nvidiaDraDriver: + type: string + default: "0.4.0" + description: >- + NVIDIA DRA driver chart version. Publishes GPUs as DRA + ResourceSlices and the gpu.nvidia.com DeviceClass that + ModelReplica ResourceClaims bind through. + minLength: 1 + maxLength: 32 gateway: type: object description: >- diff --git a/design/design.md b/design/design.md index 48584fa16..232f45c93 100644 --- a/design/design.md +++ b/design/design.md @@ -23,10 +23,13 @@ spec: modelplane.ai/tier: production replicas: 1 nodeSelector: - cel: | - capacity["gpu.nvidia.com/memory"].compareTo(quantity("141Gi")) >= 0 && - attributes["gpu.nvidia.com/cudaComputeCapability"].isGreaterThan(version("9.0.0")) && - attributes["modelplane.ai/networkInterNode"].string == "infiniband" + devices: + - name: gpu + count: 8 + selectors: + - cel: | + device.attributes["gpu.nvidia.com"].cudaComputeCapability.isGreaterThan(semver("9.0.0")) && + device.capacity["gpu.nvidia.com"].memory.compareTo(quantity("141Gi")) >= 0 workers: topology: tensor: 8 @@ -170,7 +173,7 @@ flowchart TD | Resource | Scope | Created by | Purpose | |----------|-------|------------|---------| | `InferenceGateway` | Cluster | Platform team | Control plane routing infrastructure | -| `InferenceClass` | Cluster | Platform team (or Modelplane defaults) | Hardware recipe: attributes, capacity + provisioning | +| `InferenceClass` | Cluster | Platform team (or Modelplane defaults) | Hardware recipe: devices + provisioning | | `InferenceCluster` | Cluster | Platform team | A GPU cluster in the inference fleet | | `ModelDeployment` | Namespace | ML team | Self-contained model deployment spec | | `ModelReplica` | Namespace | Modelplane (composed) | One complete serving instance | @@ -186,26 +189,29 @@ and workload resources. ### InferenceClass -A tested recipe for a GPU node pool. Each class bundles **attributes and -capacity** (what this hardware has, used by the scheduler) and optionally -**provisioning** (how to create it on a specific cloud). - -Attributes and capacity follow DRA's schema ([KEP-4381]) for structure: -attributes are typed key-value pairs (`{string: "Hopper"}`, `{version: -"9.0.0"}`), capacity is a map of Kubernetes Quantities. Keys use DRA's -qualified-name convention (`domain/name`). - -The keys and values are a contract between the platform team (who authors -InferenceClasses) and the ML team (who writes `nodeSelector.cel` on -ModelDeployments). Modelplane doesn't enforce or validate specific keys or -values. Keys that correspond to real DRA device attributes (e.g. -`gpu.nvidia.com/architecture`, `gpu.nvidia.com/memory`) should match what the -DRA driver publishes in ResourceSlices on the actual node pools. The composition -function passes these through to DRA ResourceClaim selectors when binding GPUs -to pods. Keys prefixed with `modelplane.ai/*` are fleet-scheduling attributes — -pool-level properties like GPU count per node or inter-node networking that -don't correspond to per-device DRA attributes. These are filtered out when -forming ResourceClaims. +A tested recipe for a GPU node pool. Each class describes the **devices** a node +of this class has (what the scheduler matches against) and optionally +**provisioning** (how to create the pool on a specific cloud). + +Devices follow DRA's model ([KEP-4381]). Each device has a `driver`, a `count` +(how many per node), typed `attributes` (`{string: "Hopper"}`, `{version: +"9.0.0"}`), and `capacity` (Kubernetes Quantities). This is the shape the NVIDIA +DRA driver publishes in a ResourceSlice, except a real driver publishes one +entry per physical device where we publish one per *kind* with a `count`: the +eight identical H200s in a node collapse to `count: 8`. + +A `claim` discriminator says how Modelplane treats the device. `DRA` (the +default) emits the device as a request in a `ResourceClaim`, and DRA binds a +matching device to the pod at admission time; use it for hardware a real DRA +driver exposes, today GPUs. `Synthetic` describes the device for scheduling +only, never claiming it; use it for hardware that matters for placement but has +no DRA driver yet, like an InfiniBand fabric. + +The driver, attribute keys, and capacity keys are a contract between the +platform team who authors InferenceClasses and the ML team who writes +`nodeSelector`. For `claim: DRA` devices they should mirror what the DRA driver +publishes, so a `nodeSelector` written against the class also selects the right +device at claim time. [KEP-4381]: https://github.com/kubernetes/enhancements/tree/master/keps/sig-node/4381-dra-structured-parameters @@ -230,31 +236,32 @@ spec: diskSizeGb: 200 networking: gpuDirectTCPX: true - attributes: - # These match what the NVIDIA DRA driver publishes per device. - gpu.nvidia.com/architecture: - string: Hopper - gpu.nvidia.com/productName: - string: "NVIDIA H200 141GB HBM3e" - gpu.nvidia.com/cudaComputeCapability: - version: "9.0.0" - # These are fleet-scheduling attributes. They don't correspond to - # per-device DRA attributes and are filtered out of ResourceClaims. - modelplane.ai/interconnectIntraNode: - string: nvswitch - modelplane.ai/networkInterNode: - string: gpudirect-tcpx - capacity: - # Matches what the NVIDIA DRA driver publishes per device. - gpu.nvidia.com/memory: - value: "141Gi" - # Fleet-scheduling capacity. Filtered out of ResourceClaims. - modelplane.ai/gpuCount: - value: "8" - modelplane.ai/networkBandwidth: - value: "200Gi" # bits per second + devices: + - name: gpu + claim: DRA # default; emitted as a request in the ResourceClaim + driver: gpu.nvidia.com + count: 8 + attributes: + # These mirror what the NVIDIA DRA driver publishes per device. + architecture: { string: Hopper } + productName: { string: "NVIDIA H200 141GB HBM3e" } + cudaComputeCapability: { version: "9.0.0" } + capacity: + memory: { value: "141Gi" } + - name: nic + claim: Synthetic # described for scheduling only; not claimed + driver: nic.nvidia.com # no real DRA driver yet; we author it anyway + count: 8 + attributes: + linkType: { string: gpudirect-tcpx } + capacity: + bandwidth: { value: "200Gi" } # bits per second ``` +Keys are bare names (`architecture`, `memory`), not qualified ones. The domain +comes from the device's `driver`, as in a real ResourceSlice: a `nodeSelector` +reads them back as `device.attributes["gpu.nvidia.com"].architecture`. + Different clouds and different networking imply different classes. A GKE H200 pool with GPUDirect-TCPX is `gke-h200-8x-a3-ib`. A Coreweave H200 pool with InfiniBand is `h200-8x-ib` (no provisioning). @@ -372,8 +379,11 @@ spec: modelplane.ai/tier: production replicas: 2 nodeSelector: - cel: | - capacity["gpu.nvidia.com/memory"].compareTo(quantity("80Gi")) >= 0 + devices: + - name: gpu + count: 2 + selectors: + - cel: device.capacity["gpu.nvidia.com"].memory.compareTo(quantity("80Gi")) >= 0 workers: topology: tensor: 2 @@ -394,12 +404,26 @@ Cluster-level matching uses `clusterSelector.matchLabels` against standard Kubernetes labels on InferenceCluster. This is organizational metadata: tier, region, provider, compliance posture. String equality is sufficient. -Node-level matching uses `nodeSelector.cel`, a CEL expression evaluated against -the pool's `InferenceClass` attributes and capacity. The attribute schema -follows DRA's typed format (`{string: "Hopper"}`, `{version: "9.0.0"}`) with -qualified keys (`gpu.nvidia.com/*`, `modelplane.ai/*`). Capacity uses -Kubernetes Quantity values. Keys matching real DRA device attributes pass -through to ResourceClaim selectors; `modelplane.ai/*` keys are filtered out. +Node-level matching uses `nodeSelector.devices`, a list of device requests +mirroring a DRA `ResourceClaim`. Each request has a `name`, a `count`, and a +list of `selectors`. A pool matches a request when it has a device whose `count` +covers the request and whose `driver`, `attributes`, and `capacity` satisfy +every selector, and matches the deployment when it satisfies every request. + +`nodeSelector` is required. GPUs bind to pods only through DRA: each `claim: DRA` +request becomes a `DeviceRequest` in the `ResourceClaim` the serving pods claim +GPUs through, so a deployment with no device requests gets no GPU. Modelplane +won't infer one. A request's selectors are how the ML team says what the model +needs. A 0.5B model and a 70B model want very different GPUs, and inferring an +empty "any GPU" request would schedule a model onto whatever pool has a free +device and hope it fits. Declaring the request makes the requirement explicit +and binds the matching device at admission. + +The CEL is real DRA CEL: `device.driver`, +`device.attributes["gpu.nvidia.com"].architecture` for a typed attribute under +the driver's domain, `device.capacity["gpu.nvidia.com"].memory` for a Quantity, +with `quantity()` and `semver()` to construct comparable values. Someone who +knows DRA writes the same expressions they'd write in a `ResourceClaim`. #### Workers and topology @@ -419,11 +443,13 @@ derivation formula is the same regardless of which axes are active: | Total GPUs per worker | `tensor * data * pipeline` | -The scheduler derives the physical shape from the topology. No separate node -count or GPU count fields; the topology fully determines the resource -requirements. The scheduler checks: does the matched pool's InferenceClass have -`modelplane.ai/gpuCount` >= GPUs-per-node, and does the pool have enough -available nodes? +Topology drives provisioning: it shapes how the workload is laid out into pods +and a LeaderWorkerSet. The scheduler reads only one number from it, +nodes-per-replica (`pipeline * (data / dataLocal) * workers.count`, i.e. +nodes-per-worker times the workers per replica), which it gates against the +pool's available nodes. Per-node GPU count is a `nodeSelector` concern: a GPU +request with `count: 8` selects a pool whose GPU device has a count of at least +8. #### Disaggregated prefill/decode @@ -452,9 +478,15 @@ spec: # Top-level = decode. 3 decode workers, each TP=8 PP=2. nodeSelector: - cel: | - capacity["gpu.nvidia.com/memory"].compareTo(quantity("141Gi")) >= 0 && - attributes["modelplane.ai/networkInterNode"].string == "infiniband" + devices: + - name: gpu + count: 8 + selectors: + - cel: device.capacity["gpu.nvidia.com"].memory.compareTo(quantity("141Gi")) >= 0 + - name: nic + count: 8 + selectors: + - cel: device.attributes["nic.nvidia.com"].linkType == "infiniband" workers: count: 3 topology: @@ -472,9 +504,15 @@ spec: # Prefill: 5 workers, each single-GPU. Self-contained. prefill: nodeSelector: - cel: | - capacity["gpu.nvidia.com/memory"].compareTo(quantity("80Gi")) >= 0 && - attributes["modelplane.ai/networkInterNode"].string == "infiniband" + devices: + - name: gpu + count: 1 + selectors: + - cel: device.capacity["gpu.nvidia.com"].memory.compareTo(quantity("80Gi")) >= 0 + - name: nic + count: 1 + selectors: + - cel: device.attributes["nic.nvidia.com"].linkType == "infiniband" workers: count: 5 topology: @@ -650,24 +688,25 @@ The fleet scheduler picks `(InferenceCluster, pool)` for each ModelReplica: 1. **Filter clusters** by `clusterSelector.matchLabels` against InferenceCluster labels. -2. **Filter pools** by evaluating `nodeSelector.cel` against each pool's - InferenceClass attributes and capacity. -3. **Derive physical shape** from `workers.topology`: nodes per worker, GPUs per - node. -4. **Check capacity.** Does the pool have enough available nodes? Available = +2. **Filter pools** by evaluating each `nodeSelector.devices` request against the + pool's InferenceClass devices. A pool matches a request when it has a device + whose `count` covers the request and whose `driver`, `attributes`, and + `capacity` satisfy every selector. A pool matches the deployment when it + satisfies every request. +3. **Check capacity.** Does the pool have enough available nodes for one replica + (`pipeline * (data / dataLocal) * workers.count` from `workers`)? Available = `maxNodeCount` minus nodes consumed by existing ModelReplicas on that cluster. Modelplane will support affinity and anti-affinity in a future version. -DRA is the device binding mechanism on every InferenceCluster. The composition -function forms `ResourceClaim`s from the matched pool's InferenceClass. It -references the driver's DeviceClass (e.g. `gpu.nvidia.com`) and adds CEL -selectors derived from the InferenceClass's attributes and capacity, filtering -out `modelplane.ai/*` keys. Because the remaining keys use the same qualified -names the DRA driver publishes, the translation is a straightforward split of -`domain/name` into `device.attributes["domain"].name`. DRA handles actual -device-to-node binding at pod admission time. +DRA binds devices on every InferenceCluster. Because `nodeSelector` is already a +list of DRA device requests, forming the `ResourceClaim` is mechanical: each +request whose matched device is `claim: DRA` becomes one `DeviceRequest`, +carrying the request's `count` and CEL selectors verbatim, referencing the +driver's DeviceClass. Requests matching a `claim: Synthetic` device are dropped; +the fleet scheduler already enforced them by pool selection. DRA binds +device-to-node at pod admission time. ## Autoscaling @@ -683,6 +722,17 @@ pattern as Kubernetes Deployment + HPA. ## Alternatives considered +### A single flat CEL expression for nodeSelector + +`nodeSelector` could be one CEL expression over a pool's merged attributes and +capacity, rather than a list of device requests. It's simpler for the common +case of one GPU kind. But it can't describe a node with a GPU *and* a NIC as +distinct devices: everything flattens onto one synthetic device, so an ML team +can't filter on "a GPU like X and a NIC like Y," and the `ResourceClaim` +translation isn't mechanical for multi-device requirements. The device list +costs a little verbosity in the common case to make the multi-device case +expressible and the DRA translation one-to-one. + ### Model catalog (ClusterModel / Model) I considered splitting model identity and deployment configuration across diff --git a/docs/content/concepts.md b/docs/content/concepts.md index 4d482618c..edfd7d656 100644 --- a/docs/content/concepts.md +++ b/docs/content/concepts.md @@ -70,8 +70,11 @@ kubectl get ig default An InferenceClass is a tested recipe for a GPU node pool. It bundles: -- **Resources**: what hardware the class exposes (GPU count, memory). Used by - the scheduler to match deployments to clusters. +- **Devices**: the node's hardware as a list of DRA-style devices, each with a + driver, count, typed attributes, and capacity. A `claim: DRA` device (a GPU) is + bound to pods through a DRA `ResourceClaim`; a `claim: Synthetic` device (an + InfiniBand NIC, say) is described for scheduling only. The scheduler matches a + `ModelDeployment.nodeSelector` against these devices. - **Provisioning** (optional): how to create a node pool of this class on a specific cloud. Classes without provisioning are for existing clusters where the pool already exists. @@ -86,8 +89,8 @@ serving. Platform teams create these to provide GPU capacity. Each cluster has: -- A **cluster source**: `GKE` (Modelplane provisions the full cluster) or - `Existing` (bring a cluster you manage yourself). +- A **cluster source**: `GKE` or `EKS` (Modelplane provisions the full cluster) + or `Existing` (bring a cluster you manage yourself). - One or more **node pools**, each referencing an `InferenceClass` for its hardware capabilities and provisioning recipe. - **Labels** for organizational metadata: tier, region, provider. These are the @@ -109,15 +112,16 @@ When you create a ModelDeployment, the scheduler: if set). 2. Derives the physical shape from `workers.topology`: GPUs per node (tensor) and nodes per worker (pipeline, default 1). -3. Checks GPU capacity: does the cluster have a pool with enough GPUs per node - and enough available nodes? -4. Creates a `ModelReplica` for each selected cluster. +3. Matches each `nodeSelector` device request against a candidate pool's + InferenceClass devices, gated on the pool having enough available nodes, and + pins the replica to a pool that satisfies every request. +4. Creates a `ModelReplica` for each selected cluster, carrying the resolved + `claim: DRA` requests so the replica forms a DRA `ResourceClaim`. 5. Creates a `ModelEndpoint` for each replica, carrying the URL and rewrite path for routing. -The worker template is a curated subset of `PodTemplateSpec`. The container -named `engine` is the inference engine (e.g. vLLM); additional containers pass -through as sidecars. +The worker template is a curated subset of `PodTemplateSpec`. It carries a +single container named `engine`, the inference engine (e.g. vLLM). ### Scaling diff --git a/docs/content/getting-started.md b/docs/content/getting-started.md index 29c34b90c..7a933f21f 100644 --- a/docs/content/getting-started.md +++ b/docs/content/getting-started.md @@ -279,8 +279,11 @@ kubectl apply -f examples/deployment/model-deployment.yaml kubectl apply -f examples/deployment/model-service.yaml ``` -The scheduler matches the deployment's topology against the cluster's GPU -capacity and creates a ModelReplica. Wait for the deployment to become ready: +The deployment's `nodeSelector` declares the GPU its model needs as a DRA +device request (here, a GPU with at least 24Gi of memory). The scheduler matches +that request against each cluster's GPU pools, pins the ModelReplica to a pool +that satisfies it, and the same request becomes the DRA ResourceClaim the +serving pod binds its GPU through. Wait for the deployment to become ready: ```bash kubectl get md -n ml-team --watch diff --git a/examples/deployment/model-deployment-multinode.yaml b/examples/deployment/model-deployment-multinode.yaml index 14776cea6..12ec47cfe 100644 --- a/examples/deployment/model-deployment-multinode.yaml +++ b/examples/deployment/model-deployment-multinode.yaml @@ -17,6 +17,21 @@ spec: matchLabels: modelplane.ai/tier: production + # Request 8 GPUs per node with enough memory for a 405B model. count matches + # the tensor topology (GPUs per node); the scheduler pins the replica to a + # pool whose devices satisfy this, and the request becomes a DRA DeviceRequest + # the serving pods claim GPUs through. 70Gi selects an 80GB-class GPU (an H100 + # reports ~79Gi of its nominal 80GB): the threshold has to sit below the + # device's real reported memory, since DRA evaluates this CEL against the + # GPU's actual ResourceSlice at admission, not its marketing capacity. + nodeSelector: + devices: + - name: gpu + count: 8 + selectors: + - cel: | + device.capacity["gpu.nvidia.com"].memory.compareTo(quantity("70Gi")) >= 0 + workers: topology: tensor: 8 # 8 GPUs per node. diff --git a/examples/deployment/model-deployment.yaml b/examples/deployment/model-deployment.yaml index 7e21307f2..2ff924b52 100644 --- a/examples/deployment/model-deployment.yaml +++ b/examples/deployment/model-deployment.yaml @@ -1,6 +1,7 @@ # A ModelDeployment deploys a model to one or more inference clusters. -# The scheduler picks clusters based on the workers.topology shape and -# optional label selectors. Each matched cluster gets one ModelReplica. +# The scheduler picks clusters by clusterSelector labels and nodeSelector +# device requests, gated on available nodes. Each matched cluster gets one +# ModelReplica. # # The control plane creates a unified OpenAI-compatible endpoint: # http://///v1/chat/completions @@ -19,6 +20,25 @@ spec: # matchLabels: # modelplane.ai/region: us-central + # Node-level matching: a list of DRA device requests. The scheduler matches + # each request against a candidate pool's InferenceClass devices and pins the + # replica to a pool that satisfies them. Each request's CEL is real DRA CEL + # over a single device; quantity() and semver() are helpers. claim: DRA + # devices also become requests in the DRA ResourceClaim the serving pods claim + # GPUs through, so a deployment must declare the GPUs its model needs. + nodeSelector: + devices: + - name: gpu + count: 1 + selectors: + # Qwen2.5-0.5B needs very little VRAM; 20Gi comfortably selects an L4 + # without over-constraining. A larger model would ask for more memory or a + # specific architecture here. This CEL is real DRA CEL: the scheduler + # matches it against the pool's declared device, and DRA matches it again + # against the GPU's ResourceSlice when it binds the claim. + - cel: | + device.capacity["gpu.nvidia.com"].memory.compareTo(quantity("20Gi")) >= 0 + workers: # Compute topology for one worker. tensor=1 means one GPU per pod. topology: diff --git a/examples/platform/inference-class-eks-l4.yaml b/examples/platform/inference-class-eks-l4.yaml index 2c18d4326..8daab89ea 100644 --- a/examples/platform/inference-class-eks-l4.yaml +++ b/examples/platform/inference-class-eks-l4.yaml @@ -1,9 +1,9 @@ # An InferenceClass describing EKS g6.xlarge with one NVIDIA L4 GPU. # # The provisioning block tells Modelplane how to create a node group of -# this class on EKS. The resources block describes what hardware a node -# of this class exposes - used by the scheduler to match models to -# clusters. +# this class on EKS. The devices block describes what hardware a node of +# this class has, DRA-style - used by the scheduler to match models to +# clusters, and to form DRA ResourceClaims for claim: DRA devices. apiVersion: modelplane.ai/v1alpha1 kind: InferenceClass metadata: @@ -18,7 +18,17 @@ spec: accelerator: type: nvidia-l4 count: 1 - resources: - gpu: - count: 1 - memory: 24Gi + devices: + - name: gpu + claim: DRA + driver: gpu.nvidia.com + deviceClassName: gpu.nvidia.com + count: 1 + attributes: + architecture: { string: Ada Lovelace } + capacity: + # The L4's real usable VRAM, what the NVIDIA DRA driver reports in the + # node's ResourceSlice, not its nominal 24GB. The scheduler matches a + # nodeSelector against this, so declaring the marketing number would let + # it place a model that wants 23Gi onto a node DRA then can't satisfy. + memory: { value: "23034Mi" } diff --git a/examples/platform/inference-class-gke-l4.yaml b/examples/platform/inference-class-gke-l4.yaml index f6fb906ce..1c29ef3d0 100644 --- a/examples/platform/inference-class-gke-l4.yaml +++ b/examples/platform/inference-class-gke-l4.yaml @@ -1,9 +1,9 @@ # An InferenceClass describing GKE g2-standard-8 with one NVIDIA L4 GPU. # # The provisioning block tells Modelplane how to create a node pool of -# this class on GKE. The resources block describes what hardware a node -# of this class exposes - used by the scheduler to match models to -# clusters. +# this class on GKE. The devices block describes what hardware a node of +# this class has, DRA-style - used by the scheduler to match models to +# clusters, and to form DRA ResourceClaims for claim: DRA devices. apiVersion: modelplane.ai/v1alpha1 kind: InferenceClass metadata: @@ -18,7 +18,13 @@ spec: accelerator: type: nvidia-l4 count: 1 - resources: - gpu: - count: 1 - memory: 24Gi + devices: + - name: gpu + claim: DRA + driver: gpu.nvidia.com + deviceClassName: gpu.nvidia.com + count: 1 + attributes: + architecture: { string: Ada Lovelace } + capacity: + memory: { value: "23034Mi" } diff --git a/examples/platform/inference-class-h100-byo.yaml b/examples/platform/inference-class-h100-byo.yaml index ec6b39cbe..fc070712d 100644 --- a/examples/platform/inference-class-h100-byo.yaml +++ b/examples/platform/inference-class-h100-byo.yaml @@ -1,15 +1,35 @@ # An InferenceClass describing a BYO 8x H100 node pool. # # No provisioning block: this class describes hardware that already -# exists on a bring-your-own cluster. Modelplane uses the resources -# block to populate the cluster's status.capacity for scheduling. +# exists on a bring-your-own cluster. Modelplane copies the devices block +# onto the cluster's status.gpuPools for the scheduler to match against. apiVersion: modelplane.ai/v1alpha1 kind: InferenceClass metadata: name: h100-8x-byo spec: description: "BYO 8x NVIDIA H100 80GB" - resources: - gpu: - count: 8 - memory: 80Gi + # DRA-style devices. These are the contract a ModelDeployment's + # nodeSelector matches against. Keys are bare names; the domain comes + # from each device's driver. claim: DRA devices are emitted as requests + # in a DRA ResourceClaim; claim: Synthetic devices (here the InfiniBand + # fabric, which has no DRA driver) are matched for scheduling only. + devices: + - name: gpu + claim: DRA + driver: gpu.nvidia.com + deviceClassName: gpu.nvidia.com + count: 8 + attributes: + architecture: { string: Hopper } + cudaComputeCapability: { version: "9.0.0" } + capacity: + # The H100 80GB's real usable VRAM, what the NVIDIA DRA driver reports, + # not its nominal 80GB. A nodeSelector asking for >= 80Gi would never bind. + memory: { value: "81559Mi" } + - name: nic + claim: Synthetic + driver: nic.nvidia.com + count: 8 + attributes: + linkType: { string: infiniband } diff --git a/examples/platform/inference-cluster-existing.yaml b/examples/platform/inference-cluster-existing.yaml index 8b0f12f55..fe5cc30c8 100644 --- a/examples/platform/inference-cluster-existing.yaml +++ b/examples/platform/inference-cluster-existing.yaml @@ -28,6 +28,10 @@ spec: # name: byo-cluster-sa-key # key: private_key + # Each pool's nodes must be labeled modelplane.ai/pool= (here + # modelplane.ai/pool=gpu-h100). The scheduler pins a worker to its pool by + # this label; Modelplane provisions and labels EKS/GKE pools itself, but on a + # BYO cluster you label the nodes. Without it worker pods stay Pending. nodePools: - name: gpu-h100 className: h100-8x-byo diff --git a/examples/qwen-demo/02-class.yaml b/examples/qwen-demo/02-class.yaml index 80451140b..d5d33ca5c 100644 --- a/examples/qwen-demo/02-class.yaml +++ b/examples/qwen-demo/02-class.yaml @@ -13,7 +13,13 @@ spec: accelerator: type: nvidia-l4 count: 1 - resources: - gpu: - count: 1 - memory: 24Gi + devices: + - name: gpu + claim: DRA + driver: gpu.nvidia.com + deviceClassName: gpu.nvidia.com + count: 1 + attributes: + architecture: { string: Ada Lovelace } + capacity: + memory: { value: "24Gi" } diff --git a/flake.lock b/flake.lock index a7a63d288..428eae686 100644 --- a/flake.lock +++ b/flake.lock @@ -7,16 +7,15 @@ "nixpkgs-unstable": "nixpkgs-unstable" }, "locked": { - "lastModified": 1779477155, - "narHash": "sha256-qRbJLCPxXz5wQaGB3XKUskHOZIdClSwpKh1szn1PAHE=", - "owner": "negz", + "lastModified": 1780687580, + "narHash": "sha256-03N3bljLb+2UYKwAyFQyJ6h2uNGhRbslNJxE3bOatJg=", + "owner": "crossplane", "repo": "cli", - "rev": "5208019668b92f9e912442583f100e78514d030a", + "rev": "5ed17c904256cc7f9bcc463f286b8eda9b46637c", "type": "github" }, "original": { - "owner": "negz", - "ref": "diy", + "owner": "crossplane", "repo": "cli", "type": "github" } diff --git a/flake.nix b/flake.nix index dc738dd93..edeb0fe62 100644 --- a/flake.nix +++ b/flake.nix @@ -12,8 +12,10 @@ # tracking the latest uv_build releases. nixpkgs-unstable.url = "github:NixOS/nixpkgs/nixos-unstable"; - # Pinned to the 'diy' branch until crossplane/cli#24 merges. - crossplane-cli.url = "github:negz/cli/diy"; + # Pinned to crossplane/cli main: it carries the merged-but-unreleased + # datamodel-code-generator bump (crossplane/cli#24, #64) that fixes Python + # model generation for fields named int/bool. Repin to a tag once released. + crossplane-cli.url = "github:crossplane/cli"; # uv2nix reads a uv workspace's uv.lock and generates Nix derivations # for each Python package, using pyproject.nix's build infrastructure. @@ -60,6 +62,7 @@ "compose-inference-cluster" "compose-inference-gateway" "compose-serving-stack" + "compose-model-cache" "compose-model-deployment" "compose-model-endpoint" "compose-model-replica" diff --git a/functions/compose-eks-cluster/function/fn.py b/functions/compose-eks-cluster/function/fn.py index 8538dd5a2..03aa9e459 100644 --- a/functions/compose-eks-cluster/function/fn.py +++ b/functions/compose-eks-cluster/function/fn.py @@ -20,9 +20,7 @@ from crossplane.function.proto.v1 import run_function_pb2_grpc as grpcv1 from models.ai.modelplane.infrastructure.ekscluster import v1alpha1 from models.io.crossplane.m.helm.providerconfig import v1beta1 as helmpcv1beta1 -from models.io.crossplane.m.kubernetes.object import v1alpha1 as k8sobjv1alpha1 from models.io.crossplane.m.kubernetes.providerconfig import v1alpha1 as k8spcv1alpha1 -from models.io.crossplane.protection.usage import v1beta1 as usagev1beta1 from models.io.k8s.apimachinery.pkg.apis.meta import v1 as metav1 from models.io.upbound.m.aws.ec2.internetgateway import v1beta1 as igwv1beta1 from models.io.upbound.m.aws.ec2.route import v1beta1 as routev1beta1 @@ -73,11 +71,7 @@ _AMI_TYPE_GPU = "AL2023_x86_64_NVIDIA" # GPU taint applied to GPU node groups so non-GPU pods don't land on -# expensive GPU nodes. GPU workloads still schedule because EKS enables -# the ExtendedResourceToleration admission controller, which injects a -# matching nvidia.com/gpu toleration into any pod that requests the -# nvidia.com/gpu extended resource. The device plugin DaemonSet tolerates -# the taint explicitly (it doesn't request the resource). +# expensive GPU nodes. _GPU_TAINT_KEY = "nvidia.com/gpu" _GPU_TAINT_VALUE = "true" _GPU_TAINT_EFFECT = "NO_SCHEDULE" @@ -108,23 +102,6 @@ # in-cluster DNS. All three are required for a functional cluster. _ADDONS = ("vpc-cni", "kube-proxy", "coredns") -# NVIDIA device plugin. The GPU node groups run the AL2023 NVIDIA AMI, -# which ships the driver and container toolkit but not the Kubernetes -# device plugin. Without the plugin, nodes never advertise the -# nvidia.com/gpu resource and GPU pods stay Pending. Managed clusters -# like GKE install this automatically; EKS does not, so we install it. -# It runs as a DaemonSet on GPU nodes (selected by the GPU pool label) -# and tolerates the GPU taint those nodes carry. -_DEVICE_PLUGIN_IMAGE = "nvcr.io/nvidia/k8s-device-plugin:v0.19.2" -_DEVICE_PLUGIN_NAME = "nvidia-device-plugin-daemonset" -_DEVICE_PLUGIN_NAMESPACE = "kube-system" - -# Label distinguishing the device-plugin Object from other composed -# provider-kubernetes Objects, so the deletion-ordering Usages can select -# it specifically by controller ref + label. -_LABEL_RESOURCE = "modelplane.ai/resource" -_RESOURCE_DEVICE_PLUGIN = "device-plugin" - def _kubeconfig_secret_name(xr): """Derive the kubeconfig secret name from the XR.""" @@ -179,7 +156,6 @@ def compose(self): self.compose_node_groups() self.compose_addons() self.compose_provider_configs() - self.compose_device_plugin() self.write_status() self.mark_readiness() @@ -551,152 +527,6 @@ def compose_provider_configs(self): ), ) - def compose_device_plugin(self): - """Compose the NVIDIA device plugin DaemonSet on the cluster. - - Gated on ClusterAuth being ready: the plugin is applied via - provider-kubernetes using the kubeconfig that ClusterAuth writes, - so there's nothing to target until that Secret exists. Once - observed, the Object stays composed so it isn't dropped on later - reconciles. - - Deletion ordering is handled by Usages (see compose_device_plugin_ - usages): the Object's Delete uninstalls the DaemonSet from the - cluster, which only works while the Cluster (API endpoint) and - ClusterAuth (kubeconfig credentials) still exist. The Usages hold - both until the Object is gone. - """ - kubeconfig_secret = _kubeconfig_secret_name(self.xr) - auth_ready = resource.get_condition(self.req.observed.resources.get("cluster-auth"), "Ready").status == "True" - if not (auth_ready or "device-plugin" in self.req.observed.resources): - return - - resource.update( - self.rsp.desired.resources["device-plugin"], - k8sobjv1alpha1.Object( - metadata=metav1.ObjectMeta(labels={_LABEL_RESOURCE: _RESOURCE_DEVICE_PLUGIN}), - spec=k8sobjv1alpha1.Spec( - providerConfigRef=k8sobjv1alpha1.ProviderConfigRef( - kind="ProviderConfig", - name=kubeconfig_secret, - ), - forProvider=k8sobjv1alpha1.ForProvider( - manifest=self._device_plugin_manifest(), - ), - ), - ), - ) - self.compose_device_plugin_usages() - - def compose_device_plugin_usages(self): - """Compose Usages ordering the device-plugin Object's deletion. - - The Object deletes the DaemonSet from the cluster via provider- - kubernetes, which needs both the cluster's API endpoint and the - kubeconfig credentials ClusterAuth writes. Without ordering, the - EKSCluster XR's composed resources delete concurrently and the - Object hangs on its finalizer once the Cluster or the kubeconfig - Secret is gone. These Usages hold the Cluster and ClusterAuth - until the Object is deleted first. - """ - for key, of_kind in ( - ("usage-cluster-by-device-plugin", "Cluster"), - ("usage-cluster-auth-by-device-plugin", "ClusterAuth"), - ): - resource.update( - self.rsp.desired.resources[key], - usagev1beta1.Usage( - spec=usagev1beta1.Spec( - of=usagev1beta1.Of( - apiVersion="eks.aws.m.upbound.io/v1beta1", - kind=of_kind, - resourceSelector=usagev1beta1.ResourceSelectorModel(matchControllerRef=True), - ), - by=usagev1beta1.By( - apiVersion="kubernetes.m.crossplane.io/v1alpha1", - kind="Object", - resourceSelector=usagev1beta1.ResourceSelector( - matchControllerRef=True, - matchLabels={_LABEL_RESOURCE: _RESOURCE_DEVICE_PLUGIN}, - ), - ), - replayDeletion=True, - ), - ), - ) - self.rsp.desired.resources[key].ready = fnv1.READY_TRUE - - def _device_plugin_manifest(self) -> dict: - """Return the NVIDIA device plugin DaemonSet manifest. - - Scheduled onto GPU nodes only (the GPU pool label) and tolerating - the GPU taint those nodes carry, so it doesn't run on the system - pool where there are no GPUs to advertise. - """ - labels = {"name": _DEVICE_PLUGIN_NAME} - return { - "apiVersion": "apps/v1", - "kind": "DaemonSet", - "metadata": { - "name": _DEVICE_PLUGIN_NAME, - "namespace": _DEVICE_PLUGIN_NAMESPACE, - }, - "spec": { - "selector": {"matchLabels": labels}, - "updateStrategy": {"type": "RollingUpdate"}, - "template": { - "metadata": {"labels": labels}, - "spec": { - "priorityClassName": "system-node-critical", - "tolerations": [ - { - "key": _GPU_TAINT_KEY, - "operator": "Exists", - "effect": "NoSchedule", - }, - ], - # Run only on GPU nodes. The GPU pool label is set - # to the accelerator type (e.g. nvidia-t4), so match - # on the key's presence with Exists rather than a - # fixed value. - "affinity": { - "nodeAffinity": { - "requiredDuringSchedulingIgnoredDuringExecution": { - "nodeSelectorTerms": [ - { - "matchExpressions": [ - {"key": _LABEL_GPU, "operator": "Exists"}, - ], - }, - ], - }, - }, - }, - "containers": [ - { - "name": "nvidia-device-plugin-ctr", - "image": _DEVICE_PLUGIN_IMAGE, - "env": [{"name": "FAIL_ON_INIT_ERROR", "value": "false"}], - "securityContext": { - "allowPrivilegeEscalation": False, - "capabilities": {"drop": ["ALL"]}, - }, - "volumeMounts": [ - {"name": "device-plugin", "mountPath": "/var/lib/kubelet/device-plugins"}, - ], - }, - ], - "volumes": [ - { - "name": "device-plugin", - "hostPath": {"path": "/var/lib/kubelet/device-plugins"}, - }, - ], - }, - }, - }, - } - def write_status(self): status = v1alpha1.Status( secrets=[ @@ -740,9 +570,6 @@ def mark_readiness(self): managed_resources += [f"nodegroup-{p.name}" for p in self.xr.spec.nodePools] managed_resources += [f"addon-{a}" for a in _ADDONS] - if "device-plugin" in self.rsp.desired.resources: - managed_resources.append("device-plugin") - for r in managed_resources: if resource.get_condition(self.req.observed.resources.get(r), "Ready").status == "True": self.rsp.desired.resources[r].ready = fnv1.READY_TRUE diff --git a/functions/compose-eks-cluster/pyproject.toml b/functions/compose-eks-cluster/pyproject.toml index ea9f83dd7..a241fd23f 100644 --- a/functions/compose-eks-cluster/pyproject.toml +++ b/functions/compose-eks-cluster/pyproject.toml @@ -9,7 +9,7 @@ description = "Compose an EKS cluster with networking, node groups, and IAM role requires-python = ">=3.11,<3.14" license = "Apache-2.0" dependencies = [ - "crossplane-function-sdk-python>=0.12.0", + "crossplane-function-sdk-python>=0.13.0", "click>=8.1.0", "grpcio>=1.73.1", "crossplane-models", diff --git a/functions/compose-eks-cluster/tests/test_fn.py b/functions/compose-eks-cluster/tests/test_fn.py index 93197f462..2d850db84 100644 --- a/functions/compose-eks-cluster/tests/test_fn.py +++ b/functions/compose-eks-cluster/tests/test_fn.py @@ -201,7 +201,7 @@ def _eks_cluster() -> dict: "spec": { "forProvider": { "region": "us-west-2", - "version": "1.31", + "version": "1.36", "roleArnSelector": { "matchControllerRef": True, "matchLabels": {"modelplane.ai/iam-role": "cluster"}, @@ -338,46 +338,6 @@ def _expected_status() -> dict: } -def _device_plugin() -> dict: - # The DaemonSet manifest is built by the function; assert on the wrapper - # Object and reuse the function's manifest builder so the two can't drift. - return { - "apiVersion": "kubernetes.m.crossplane.io/v1alpha1", - "kind": "Object", - "metadata": {"labels": {"modelplane.ai/resource": "device-plugin"}}, - "spec": { - "providerConfigRef": {"kind": "ProviderConfig", "name": _KUBECONFIG_SECRET}, - "forProvider": {"manifest": fn.Composer._device_plugin_manifest(None)}, - }, - } - - -def _device_plugin_usage(of_kind: str) -> dict: - # Deletion-ordering Usage: holds the EKS Cluster / ClusterAuth until the - # device-plugin Object is deleted, so its DaemonSet uninstall can reach - # the cluster. - return { - "apiVersion": "protection.crossplane.io/v1beta1", - "kind": "Usage", - "spec": { - "of": { - "apiVersion": "eks.aws.m.upbound.io/v1beta1", - "kind": of_kind, - "resourceSelector": {"matchControllerRef": True}, - }, - "by": { - "apiVersion": "kubernetes.m.crossplane.io/v1alpha1", - "kind": "Object", - "resourceSelector": { - "matchControllerRef": True, - "matchLabels": {"modelplane.ai/resource": "device-plugin"}, - }, - }, - "replayDeletion": True, - }, - } - - def _expected_resources() -> dict: return { "vpc": fnv1.Resource(resource=resource.dict_to_struct(_vpc())), @@ -466,22 +426,6 @@ async def test_compose(self) -> None: resource=ready_resources["cluster-auth"].resource, ready=fnv1.READY_TRUE, ) - # ClusterAuth is observed Ready in the second pass, so the device - # plugin is composed (it targets the cluster via the kubeconfig - # ClusterAuth writes). It has no observed Ready condition yet, so - # it stays not-ready. Its deletion-ordering Usages are marked ready - # immediately by the function. - ready_resources["device-plugin"] = fnv1.Resource( - resource=resource.dict_to_struct(_device_plugin()), - ) - ready_resources["usage-cluster-by-device-plugin"] = fnv1.Resource( - resource=resource.dict_to_struct(_device_plugin_usage("Cluster")), - ready=fnv1.READY_TRUE, - ) - ready_resources["usage-cluster-auth-by-device-plugin"] = fnv1.Resource( - resource=resource.dict_to_struct(_device_plugin_usage("ClusterAuth")), - ready=fnv1.READY_TRUE, - ) cases = [ Case( diff --git a/functions/compose-gke-cluster/pyproject.toml b/functions/compose-gke-cluster/pyproject.toml index ce6a68705..6c1146d7d 100644 --- a/functions/compose-gke-cluster/pyproject.toml +++ b/functions/compose-gke-cluster/pyproject.toml @@ -9,7 +9,7 @@ description = "Compose a GKE cluster with networking, node pools, and service ac requires-python = ">=3.11,<3.14" license = "Apache-2.0" dependencies = [ - "crossplane-function-sdk-python>=0.12.0", + "crossplane-function-sdk-python>=0.13.0", "click>=8.1.0", "grpcio>=1.73.1", "crossplane-models", diff --git a/functions/compose-inference-class/function/fn.py b/functions/compose-inference-class/function/fn.py index fd56e030e..9d7f5e654 100644 --- a/functions/compose-inference-class/function/fn.py +++ b/functions/compose-inference-class/function/fn.py @@ -1,7 +1,7 @@ """Compose an InferenceClass. -InferenceClass is a data resource: it describes hardware (resources) -and optionally how to provision it (provisioning). It has no composed +InferenceClass is a data resource: it describes hardware (devices) and +optionally how to provision it (provisioning). It has no composed children. This function just marks the XR Ready. """ diff --git a/functions/compose-inference-class/pyproject.toml b/functions/compose-inference-class/pyproject.toml index 8448edcff..f06fcbe56 100644 --- a/functions/compose-inference-class/pyproject.toml +++ b/functions/compose-inference-class/pyproject.toml @@ -9,7 +9,7 @@ description = "Mark an InferenceClass as ready." requires-python = ">=3.11,<3.14" license = "Apache-2.0" dependencies = [ - "crossplane-function-sdk-python>=0.12.0", + "crossplane-function-sdk-python>=0.13.0", "click>=8.1.0", "grpcio>=1.73.1", "crossplane-models", diff --git a/functions/compose-inference-class/tests/test_fn.py b/functions/compose-inference-class/tests/test_fn.py index adc5ebadd..7eb34c500 100644 --- a/functions/compose-inference-class/tests/test_fn.py +++ b/functions/compose-inference-class/tests/test_fn.py @@ -45,9 +45,16 @@ async def test_compose(self) -> None: v1alpha1.InferenceClass( metadata=metav1.ObjectMeta(name="gpu-l4"), spec=v1alpha1.Spec( - resources=v1alpha1.Resources( - gpu=v1alpha1.Gpu(count=1, memory="24Gi"), - ), + devices=[ + v1alpha1.Device( + name="gpu", + claim="DRA", + driver="gpu.nvidia.com", + deviceClassName="gpu.nvidia.com", + count=1, + capacity={"memory": v1alpha1.Capacity(value="24Gi")}, + ), + ], ), ).model_dump(exclude_none=True, mode="json") ), diff --git a/functions/compose-inference-cluster/function/fn.py b/functions/compose-inference-cluster/function/fn.py index 30a37d43d..d304b745b 100644 --- a/functions/compose-inference-cluster/function/fn.py +++ b/functions/compose-inference-cluster/function/fn.py @@ -8,7 +8,7 @@ clusters the class's provisioning block describes how to build the pool; for BYO (Existing) clusters the class is a pure description of pools that already exist. Either way, the class's resources block populates -status.capacity.gpuPools so the scheduler can match models. +status.gpuPools so the scheduler can match models. For provisioned clusters, a system node pool is injected automatically to host control-plane components (Envoy Gateway, Prometheus, etc.). @@ -216,7 +216,7 @@ def compose_eks(self, eks): backend_secrets = self.resolve_eks_backend_secrets(eks_ready, backend_exists) if backend_secrets or backend_exists: if backend_secrets: - self.compose_kserve_backend(backend_secrets) + self.compose_serving_stack(backend_secrets) self.compose_eks_usage() if eks_ready: @@ -366,9 +366,7 @@ def write_status(self, gpu_pools): name=resource.child_name(self.xr.metadata.name, "cluster-kubeconfig"), ), namespace=_NAMESPACE_SYSTEM, - capacity=v1alpha1.Capacity( - gpuPools=gpu_pools, - ), + gpuPools=gpu_pools, ) gateway_address = self.observed_gateway_address() if gateway_address: @@ -536,7 +534,7 @@ def compose_eks_usage(self): ), by=usagev1beta1.By( apiVersion="infrastructure.modelplane.ai/v1alpha1", - kind="KServeBackend", + kind="ServingStack", resourceSelector=usagev1beta1.ResourceSelector(matchControllerRef=True), ), replayDeletion=True, @@ -624,32 +622,29 @@ def resolve_gke_backend_secrets(self, gke_ready, backend_exists) -> list[ssv1alp return None def gpu_pools(self): - """Derive status.capacity.gpuPools from each node pool's class. + """Derive status.gpuPools from each node pool's class. - The same logic applies to every cluster source: the class - declares the per-node GPU resources, the pool declares how many - nodes. The accelerator type comes from whichever provisioning - block the class carries (GKE or EKS); for BYO classes the - accelerator type is empty. + The class declares the node's devices (DRA-style); the pool declares how + many nodes. We copy the class's devices verbatim so + ModelDeployment.nodeSelector can match against them, and record the node + count for the scheduler's available-node gate. """ gpu_pools = [] for pool in self.xr.spec.nodePools or []: cls = self.classes.get(pool.className) - if not cls or not cls.spec.resources or not cls.spec.resources.gpu: + if not cls or not cls.spec.devices: continue - gpu = cls.spec.resources.gpu - accelerator_type = "" - prov = cls.spec.provisioning - if prov and prov.gke and prov.gke.accelerator: - accelerator_type = prov.gke.accelerator.type - elif prov and prov.eks and prov.eks.accelerator: - accelerator_type = prov.eks.accelerator.type + # Copy the class's devices verbatim. model_dump drops None fields, + # keeping the typed attribute value objects + # (string/version/bool/int) one-of clean. by_alias keeps DRA's wire + # names (bool/int) rather than the generated bool_/int_ attributes, + # so the published status matches the InferenceClass schema. + devices = [d.model_dump(by_alias=True, exclude_none=True) for d in cls.spec.devices] gpu_pools.append( { - "acceleratorType": accelerator_type, - "memory": gpu.memory, - "countPerNode": gpu.count, + "name": pool.name, "nodes": pool.maxNodeCount or pool.nodeCount, + "devices": devices, } ) return gpu_pools diff --git a/functions/compose-inference-cluster/pyproject.toml b/functions/compose-inference-cluster/pyproject.toml index 8ca0bba25..227eb1edb 100644 --- a/functions/compose-inference-cluster/pyproject.toml +++ b/functions/compose-inference-cluster/pyproject.toml @@ -9,7 +9,7 @@ description = "Compose an InferenceCluster from a cluster source and serving sta requires-python = ">=3.11,<3.14" license = "Apache-2.0" dependencies = [ - "crossplane-function-sdk-python>=0.12.0", + "crossplane-function-sdk-python>=0.13.0", "click>=8.1.0", "grpcio>=1.73.1", "crossplane-models", diff --git a/functions/compose-inference-cluster/tests/test_fn.py b/functions/compose-inference-cluster/tests/test_fn.py index 2ee52c063..a29ee755f 100644 --- a/functions/compose-inference-cluster/tests/test_fn.py +++ b/functions/compose-inference-cluster/tests/test_fn.py @@ -43,9 +43,16 @@ async def test_compose(self) -> None: "kind": "InferenceClass", "metadata": {"name": "gpu-l4"}, "spec": { - "resources": { - "gpu": {"count": 1, "memory": "24Gi"}, - }, + "devices": [ + { + "name": "gpu", + "claim": "DRA", + "driver": "gpu.nvidia.com", + "deviceClassName": "gpu.nvidia.com", + "count": 1, + "capacity": {"memory": {"value": "24Gi"}}, + }, + ], "provisioning": { "provider": "GKE", "gke": { @@ -113,16 +120,22 @@ async def test_compose(self) -> None: "name": "test-cluster-cluster-kubeconfig-d0f89", }, "namespace": "modelplane-system", - "capacity": { - "gpuPools": [ - { - "acceleratorType": "nvidia-l4", - "memory": "24Gi", - "countPerNode": 1, - "nodes": 4, - }, - ], - }, + "gpuPools": [ + { + "name": "l4-pool", + "nodes": 4, + "devices": [ + { + "name": "gpu", + "claim": "DRA", + "driver": "gpu.nvidia.com", + "deviceClassName": "gpu.nvidia.com", + "count": 1, + "capacity": {"memory": {"value": "24Gi"}}, + }, + ], + }, + ], }, } ), @@ -235,16 +248,22 @@ async def test_compose(self) -> None: "name": "test-cluster-cluster-kubeconfig-d0f89", }, "namespace": "modelplane-system", - "capacity": { - "gpuPools": [ - { - "acceleratorType": "nvidia-l4", - "memory": "24Gi", - "countPerNode": 1, - "nodes": 4, - }, - ], - }, + "gpuPools": [ + { + "name": "l4-pool", + "nodes": 4, + "devices": [ + { + "name": "gpu", + "claim": "DRA", + "driver": "gpu.nvidia.com", + "deviceClassName": "gpu.nvidia.com", + "count": 1, + "capacity": {"memory": {"value": "24Gi"}}, + }, + ], + }, + ], }, } ), @@ -262,6 +281,7 @@ async def test_compose(self) -> None: "spec": { "project": "my-gcp-project", "region": "us-central1", + "kubernetesVersion": "1.35", "nodePools": [ { "name": "l4-pool", @@ -270,8 +290,10 @@ async def test_compose(self) -> None: "nodeCount": 2, "minNodeCount": None, "maxNodeCount": 4, + "diskSizeGb": 100, "gpu": { "acceleratorType": "nvidia-l4", + "acceleratorCount": 1, }, "zones": ["us-central1-a"], }, @@ -359,16 +381,22 @@ async def test_compose(self) -> None: "name": "test-cluster-cluster-kubeconfig-d0f89", }, "namespace": "modelplane-system", - "capacity": { - "gpuPools": [ - { - "acceleratorType": "nvidia-l4", - "memory": "24Gi", - "countPerNode": 1, - "nodes": 4, - }, - ], - }, + "gpuPools": [ + { + "name": "l4-pool", + "nodes": 4, + "devices": [ + { + "name": "gpu", + "claim": "DRA", + "driver": "gpu.nvidia.com", + "deviceClassName": "gpu.nvidia.com", + "count": 1, + "capacity": {"memory": {"value": "24Gi"}}, + }, + ], + }, + ], "gateway": {"address": "34.55.100.10"}, }, } @@ -441,9 +469,16 @@ async def test_compose(self) -> None: "kind": "InferenceClass", "metadata": {"name": "gpu-l4-eks"}, "spec": { - "resources": { - "gpu": {"count": 1, "memory": "24Gi"}, - }, + "devices": [ + { + "name": "gpu", + "claim": "DRA", + "driver": "gpu.nvidia.com", + "deviceClassName": "gpu.nvidia.com", + "count": 1, + "capacity": {"memory": {"value": "24Gi"}}, + }, + ], "provisioning": { "provider": "EKS", "eks": { @@ -504,16 +539,22 @@ async def test_compose(self) -> None: "name": "test-cluster-cluster-kubeconfig-d0f89", }, "namespace": "modelplane-system", - "capacity": { - "gpuPools": [ - { - "acceleratorType": "nvidia-l4", - "memory": "24Gi", - "countPerNode": 1, - "nodes": 4, - }, - ], - }, + "gpuPools": [ + { + "name": "l4-pool", + "nodes": 4, + "devices": [ + { + "name": "gpu", + "claim": "DRA", + "driver": "gpu.nvidia.com", + "deviceClassName": "gpu.nvidia.com", + "count": 1, + "capacity": {"memory": {"value": "24Gi"}}, + }, + ], + }, + ], }, }, ), @@ -530,6 +571,7 @@ async def test_compose(self) -> None: }, "spec": { "region": "us-west-2", + "kubernetesVersion": "1.36", "nodePools": [ { "name": "l4-pool", @@ -538,6 +580,7 @@ async def test_compose(self) -> None: "nodeCount": 2, "minNodeCount": None, "maxNodeCount": 4, + "diskSizeGb": 100, "gpu": { "acceleratorType": "nvidia-l4", }, @@ -676,16 +719,22 @@ async def test_compose(self) -> None: "name": "test-cluster-cluster-kubeconfig-d0f89", }, "namespace": "modelplane-system", - "capacity": { - "gpuPools": [ - { - "acceleratorType": "nvidia-l4", - "memory": "24Gi", - "countPerNode": 1, - "nodes": 4, - }, - ], - }, + "gpuPools": [ + { + "name": "l4-pool", + "nodes": 4, + "devices": [ + { + "name": "gpu", + "claim": "DRA", + "driver": "gpu.nvidia.com", + "deviceClassName": "gpu.nvidia.com", + "count": 1, + "capacity": {"memory": {"value": "24Gi"}}, + } + ], + }, + ], }, } ), @@ -703,6 +752,7 @@ async def test_compose(self) -> None: "spec": { "project": "my-gcp-project", "region": "us-central1", + "kubernetesVersion": "1.35", "nodePools": [ { "name": "l4-pool", @@ -711,8 +761,10 @@ async def test_compose(self) -> None: "nodeCount": 2, "minNodeCount": None, "maxNodeCount": 4, + "diskSizeGb": 100, "gpu": { "acceleratorType": "nvidia-l4", + "acceleratorCount": 1, }, "zones": ["us-central1-a"], }, @@ -762,9 +814,7 @@ async def test_compose(self) -> None: "kind": "ClusterProviderConfig", "name": "test-cluster-cluster-kubeconfig-d0f89", }, - # policy defaults to SuccessfulCreate, so - # the typed model drops it on serialization. - "readiness": {}, + "readiness": {"policy": "SuccessfulCreate"}, "forProvider": { "manifest": { "apiVersion": "storage.k8s.io/v1", @@ -857,6 +907,147 @@ async def test_compose(self) -> None: ) want6.requirements.resources["class-gpu-l4"].CopyFrom(class_selector) + # --- Case 7: EKS cluster ready - kubeconfig observed on the EKSCluster + # status. The function wires the ClusterProviderConfig, composes the + # ServingStack backend, and emits the Usage that blocks EKSCluster + # deletion until the ServingStack is gone. --- + req7 = fnv1.RunFunctionRequest() + req7.CopyFrom(req4) + req7.observed.resources["eks-cluster"].CopyFrom( + fnv1.Resource( + resource=resource.dict_to_struct( + { + "apiVersion": "infrastructure.modelplane.ai/v1alpha1", + "kind": "EKSCluster", + "metadata": {"name": "test-cluster", "namespace": "modelplane-system"}, + "spec": { + "region": "us-west-2", + "nodePools": [ + { + "name": "l4-pool", + "role": "GPU", + "instanceType": "g6.xlarge", + "nodeCount": 2, + }, + ], + }, + "status": { + "conditions": [ + { + "type": "Ready", + "status": "True", + "reason": "Available", + "lastTransitionTime": "2024-01-01T00:00:00Z", + }, + ], + "secrets": [ + { + "type": "Kubeconfig", + "name": "test-cluster-kubeconfig-abcde", + "key": "kubeconfig", + }, + ], + }, + } + ), + ), + ) + + want7 = fnv1.RunFunctionResponse() + want7.CopyFrom(want4) + want7.desired.resources["eks-cluster"].ready = fnv1.READY_TRUE + want7.desired.resources["cluster-provider-config-kubernetes"].CopyFrom( + fnv1.Resource( + resource=resource.dict_to_struct( + { + "apiVersion": "kubernetes.m.crossplane.io/v1alpha1", + "kind": "ClusterProviderConfig", + "metadata": {"name": "test-cluster-cluster-kubeconfig-d0f89"}, + "spec": { + "credentials": { + "source": "Secret", + "secretRef": { + "namespace": "modelplane-system", + "name": "test-cluster-kubeconfig-abcde", + "key": "kubeconfig", + }, + }, + }, + } + ), + ready=fnv1.READY_TRUE, + ), + ) + want7.desired.resources["serving-stack"].CopyFrom( + fnv1.Resource( + resource=resource.dict_to_struct( + { + "apiVersion": "infrastructure.modelplane.ai/v1alpha1", + "kind": "ServingStack", + "metadata": { + "name": "test-cluster-serving-stack-fd00b", + "namespace": "modelplane-system", + }, + "spec": { + "secrets": [ + { + "type": "Kubeconfig", + "name": "test-cluster-kubeconfig-abcde", + "key": "kubeconfig", + }, + ], + }, + } + ), + ), + ) + want7.desired.resources["usage-eks-by-backend"].CopyFrom( + fnv1.Resource( + resource=resource.dict_to_struct( + { + "apiVersion": "protection.crossplane.io/v1beta1", + "kind": "Usage", + "metadata": {"namespace": "modelplane-system"}, + "spec": { + "of": { + "apiVersion": "infrastructure.modelplane.ai/v1alpha1", + "kind": "EKSCluster", + "resourceSelector": {"matchControllerRef": True}, + }, + "by": { + "apiVersion": "infrastructure.modelplane.ai/v1alpha1", + "kind": "ServingStack", + "resourceSelector": {"matchControllerRef": True}, + }, + "replayDeletion": True, + }, + } + ), + ready=fnv1.READY_TRUE, + ), + ) + del want7.conditions[:] + want7.conditions.extend( + [ + fnv1.Condition( + type="ClusterReady", + status=fnv1.STATUS_CONDITION_TRUE, + reason="ClusterRunning", + ), + fnv1.Condition( + type="BackendReady", + status=fnv1.STATUS_CONDITION_FALSE, + reason="Installing", + ), + ] + ) + want7.results.append( + fnv1.Result( + severity=fnv1.SEVERITY_NORMAL, + message="EKS cluster ready, composing backend", + ) + ) + cases = [ Case(name="existing cluster with secrets composes backend and CPC", req=req1, want=want1), Case(name="GKE cluster first pass composes GKECluster XR only", req=req2, want=want2), @@ -864,6 +1055,7 @@ async def test_compose(self) -> None: Case(name="EKS cluster first pass composes EKSCluster XR only", req=req4, want=want4), Case(name="EKS cluster not ready re-emits existing CPC unchanged", req=req5, want=want5), Case(name="GKE cluster ready composes CPC, backend, usage, and RWX StorageClass", req=req6, want=want6), + Case(name="EKS cluster ready composes ServingStack and Usage", req=req7, want=want7), ] for case in cases: diff --git a/functions/compose-inference-gateway/pyproject.toml b/functions/compose-inference-gateway/pyproject.toml index aeafcd104..6e9aa1879 100644 --- a/functions/compose-inference-gateway/pyproject.toml +++ b/functions/compose-inference-gateway/pyproject.toml @@ -9,7 +9,7 @@ description = "Compose the control plane routing gateway with Envoy Gateway and requires-python = ">=3.11,<3.14" license = "Apache-2.0" dependencies = [ - "crossplane-function-sdk-python>=0.12.0", + "crossplane-function-sdk-python>=0.13.0", "click>=8.1.0", "grpcio>=1.73.1", "crossplane-models", diff --git a/functions/compose-model-deployment/function/cel.py b/functions/compose-model-deployment/function/cel.py new file mode 100644 index 000000000..64896d897 --- /dev/null +++ b/functions/compose-model-deployment/function/cel.py @@ -0,0 +1,257 @@ +"""DRA CEL evaluation for ModelDeployment.nodeSelector. + +The design lets an ML team write CEL selectors that the fleet scheduler +evaluates against each candidate pool's devices, in the same dialect a user +would write in a DRA ResourceClaim ([KEP-4381]): + + device.attributes["gpu.nvidia.com"].cudaComputeCapability.isGreaterThan(semver("9.0.0")) && + device.capacity["gpu.nvidia.com"].memory.compareTo(quantity("141Gi")) >= 0 + +This reimplements Kubernetes' DRA device-selector CEL surface +(k8s.io/dynamic-resource-allocation/cel) on top of the pure-Python celpy +evaluator, so an expression that selects a device upstream selects the same +device here. Each selector is evaluated against ONE device, exposed as `device`: + +* device.driver -> the device's DRA driver (a string) +* device.attributes[""]. -> a typed attribute under a domain +* device.capacity[""]. -> a capacity Quantity + +Upstream builds the `device` input by splitting each attribute/capacity +QualifiedName into (domain, id): a bare name defaults to the device's driver as +its domain, a "domain/id" name is split on the first "/". A single device can +therefore expose several domains. Looking up an UNKNOWN domain yields an empty +map (upstream's mapper.Find default), not an error; looking up an unknown id +within a domain is an error. We mirror both. + +The quantity() and semver() function families live in the sibling `quantity` +and `semver` modules, which match the apiserver CEL libraries those names come +from. + +Deliberate divergences we CANNOT match exactly (documented, not bugs): + +* Extra base-environment libraries. A DRA selector is compiled against the + apiserver base CEL env, which also registers URLs, Regex, IP, CIDR, Format, + Sets, two-variable comprehensions, the apiserver Lists library (isSorted/sum/ + max/min/indexOf/...), and Strings v2. celpy provides the CEL standard macros + and some of these but not the Kubernetes-specific ones. Selectors that use + those functions will fail to compile here. Device selectors in practice use + attribute/capacity comparisons with quantity()/semver(), which we cover. +* List-type attributes and the includes() function (KEP feature-gated, + DRAListTypeAttributes, ~v1.36). We only model scalar attributes + (string/bool/int/version), matching the default-off behavior. +* Runtime cost limits. Upstream caps a selector at CELSelectorExpressionMaxCost + (1,000,000 cost units) and the expression at 10 KiB. celpy has no cost + accounting, so we don't enforce the cost ceiling (the XRD bounds expression + length). +* cel.bind / ext.Bindings is available upstream for domain reuse; celpy parses + cel.bind, so simple uses work, but we don't guarantee parity with the full + ext.Bindings semantics. +* Output type checking. Upstream rejects a selector that doesn't evaluate to a + bool at compile time. celpy doesn't expose the output type, so we can't reject + at compile; instead a non-bool result is treated as a non-match (never a + truthy coercion), so a stray non-bool selector excludes devices rather than + spuriously matching them. +* Member vs global call style. celpy dispatches x.method(y) to the function + method(x, y), so it can't distinguish a member call from a global one. We + therefore accept both spellings of every function, whereas upstream is strict + per-overload: quantity()'s sign is a GLOBAL function (sign(q) only; q.sign() + is a compile error upstream), while compareTo/isGreaterThan/isLessThan are + member-only. This only makes us MORE permissive (an extra accepted spelling), + never changing which devices a well-formed selector matches. +* has() on an index. cel-go's has() macro accepts only a field selection + (has(x.y)), not an index (has(x["y"])), so has(device.attributes["domain"]) + is a compile error upstream; the domain-presence idiom is + "domain" in device.attributes. celpy accepts the has(index) form too, so + again we're more permissive. A selector that needs to test domain presence + should use the in form, which compiles both here and upstream. + +Operationally: a device that errors on evaluation (unknown id, malformed +quantity/version, type mismatch) is treated as a non-match rather than failing +the whole reconcile. A malformed expression (compile error) is a user error, +surfaced via CELCompileError. + +[KEP-4381]: https://github.com/kubernetes/enhancements/tree/master/keps/sig-node/4381-dra-structured-parameters +""" + +from __future__ import annotations + +import celpy +from celpy import celtypes + +from function import quantity, semver + + +class CELCompileError(Exception): + """Raised when a nodeSelector CEL expression fails to compile.""" + + +# compareTo/isGreaterThan/isLessThan are shared method names: a CEL selector +# calls them on either a Quantity or a Semver, so dispatch on the operand type. +def _compare_to(a, b): + return semver.compare_to(a, b) if isinstance(a, semver.Semver) else quantity.compare_to(a, b) + + +def _is_greater_than(a, b): + return semver.is_greater_than(a, b) if isinstance(a, semver.Semver) else quantity.is_greater_than(a, b) + + +def _is_less_than(a, b): + return semver.is_less_than(a, b) if isinstance(a, semver.Semver) else quantity.is_less_than(a, b) + + +# Registered into every celpy program. celpy dispatches method-call syntax +# x.method(y) to the function method(x, y), so the same entries serve both the +# method-call and free-function styles. +_FUNCTIONS = { + "quantity": quantity.quantity, + "isQuantity": quantity.is_quantity, + "sign": quantity.sign, + "isInteger": quantity.is_integer, + "asInteger": quantity.as_integer, + "asApproximateFloat": quantity.as_approximate_float, + "add": quantity.add, + "sub": quantity.sub, + "semver": semver.semver, + "isSemver": semver.is_semver, + "major": semver.major, + "minor": semver.minor, + "patch": semver.patch, + "compareTo": _compare_to, + "isGreaterThan": _is_greater_than, + "isLessThan": _is_less_than, +} + + +class _DefaultMap(celtypes.MapType): + """A map whose missing-key lookup returns an empty map. + + Mirrors upstream's newStringInterfaceMapWithDefault: device.attributes and + device.capacity return an empty map for an UNKNOWN domain (not an error). + Looking up an unknown id within a (known or defaulted-empty) domain still + errors, because the inner maps are plain MapTypes. + """ + + def __missing__(self, key): + return celtypes.MapType() + + +class Program: + """A compiled DRA CEL selector, reusable across devices.""" + + def __init__(self, expr: str): + env = celpy.Environment() + # Any compile-time failure is a malformed expression - a user error. + # celpy raises CELParseError for syntax errors, but we catch broadly so + # no compile failure escapes as an unhandled reconcile crash; the caller + # turns CELCompileError into an InvalidNodeSelector condition. + try: + ast = env.compile(expr) + self._prgm = env.program(ast, functions=_FUNCTIONS) + except Exception as e: + raise CELCompileError(str(e)) from e + + def matches(self, device: dict) -> bool: + """Evaluate the selector against one device. + + `device` is the raw dict from a pool's status.gpuPools devices + entry, shaped like: + + device = { + "driver": "", + "count": , + "attributes": {"": {"string"|"version"|...: }}, + "capacity": {"": {"value": ""}}, + } + + Attribute/capacity names may be qualified ("domain/id"); a bare name + uses the driver as its domain. A device that errors (unknown id, + malformed quantity/version, type mismatch) is a non-match. + """ + try: + activation = {"device": _device_activation(device)} + result = self._prgm.evaluate(activation) + except Exception: # noqa: BLE001 - any eval failure is a non-match, never a crash + # Unknown id, malformed quantity/version, type mismatch, etc. The + # device simply does not match. We catch broadly (not just + # CELEvalError/ValueError) because celpy can surface other error + # types for some expressions, and arbitrary user CEL evaluated + # against arbitrary device data must never crash the whole reconcile + # - a non-match is always the safe outcome. + return False + # Enforce the bool output type the module docstring describes: a non-bool + # result is a non-match, never a truthy coercion. isinstance, not ==: + # celpy's BoolType.__eq__ raises on a cross-type compare. + return isinstance(result, (bool, celtypes.BoolType)) and bool(result) + + +def _split_qualified(name: str, default_domain: str) -> tuple[str, str]: + """Split a QualifiedName into (domain, id), defaulting the domain. + + Mirrors upstream parseQualifiedName: split on the FIRST "/"; a name without + "/" uses the device's driver as its domain. + """ + sep = name.find("/") + if sep == -1: + return default_domain, name + return name[:sep], name[sep + 1 :] + + +def _device_activation(device: dict) -> celtypes.MapType: + """Build the `device` activation map for one device. + + Groups attributes and capacity into per-domain maps keyed by the qualified + name's domain (driver-defaulted), exactly as upstream DeviceMatches does, so + a device can expose several domains. Unknown-domain lookups return an empty + map. Raises ValueError on a malformed Quantity or version attribute. + """ + # attributes/capacity are optional, so a device without them simply omits the + # key (the dicts come from model_dump(exclude_none=True), so a present value + # is never None). driver is XRD-required on a real device, but default it so + # an expression that never reads device.driver still evaluates. + driver = device.get("driver", "") + + out = celtypes.MapType() + out[celtypes.StringType("driver")] = celtypes.StringType(driver) + + attributes = _DefaultMap() + for name, raw in device.get("attributes", {}).items(): + domain, ident = _split_qualified(name, driver) + bucket = attributes.setdefault(celtypes.StringType(domain), celtypes.MapType()) + bucket[celtypes.StringType(ident)] = _attribute_value(raw) + out[celtypes.StringType("attributes")] = attributes + + capacity = _DefaultMap() + for name, raw in device.get("capacity", {}).items(): + value = raw.get("value") + if value is None: + continue + domain, ident = _split_qualified(name, driver) + bucket = capacity.setdefault(celtypes.StringType(domain), celtypes.MapType()) + bucket[celtypes.StringType(ident)] = quantity.quantity(value) + out[celtypes.StringType("capacity")] = capacity + + return out + + +def _attribute_value(entry: dict): + """Convert one typed attribute value object to its CEL value. + + A version attribute is pre-parsed to a Semver (strict), matching upstream + which pre-parses VersionValue attributes; the selector compares it with + semver() directly. Other typed values (string/bool/int) pass through as + their natural CEL type. The XRD enforces exactly one field is set. + """ + if entry.get("version") is not None: + return semver.semver(entry["version"]) + for field in ("string", "bool", "int"): + if entry.get(field) is not None: + return celpy.json_to_cel(entry[field]) + # No supported value: upstream returns "unsupported attribute value" error. + raise ValueError("unsupported attribute value") + + +def compile_selector(expr: str | None) -> Program | None: + """Compile a DRA CEL selector, or None if there is none.""" + if not expr: + return None + return Program(expr) diff --git a/functions/compose-model-deployment/function/fn.py b/functions/compose-model-deployment/function/fn.py index 8d438b37b..975cbc81c 100644 --- a/functions/compose-model-deployment/function/fn.py +++ b/functions/compose-model-deployment/function/fn.py @@ -16,7 +16,7 @@ from models.ai.modelplane.modelreplica import v1alpha1 as mrv1alpha1 from models.io.k8s.apimachinery.pkg.apis.meta import v1 as metav1 -from function import scheduling +from function import cel, name, scheduling # Condition types and reasons for the ModelDeployment XR. CONDITION_TYPE_REPLICAS_SCHEDULED = "ReplicasScheduled" @@ -24,6 +24,7 @@ CONDITION_REASON_NO_CLUSTERS = "NoClusters" CONDITION_REASON_INSUFFICIENT_CAPACITY = "InsufficientCapacity" +CONDITION_REASON_INVALID_NODE_SELECTOR = "InvalidNodeSelector" CONDITION_REASON_REPLICAS_CREATED = "ReplicasCreated" CONDITION_REASON_SCHEDULING = "Scheduling" CONDITION_REASON_NO_REPLICAS_SCHEDULED = "NoReplicasScheduled" @@ -35,8 +36,13 @@ _LABEL_CLUSTER = "modelplane.ai/cluster" _LABEL_REPLICA = "modelplane.ai/replica" _LABEL_DEPLOYMENT = "modelplane.ai/deployment" +# Per-cluster-local index distinguishing co-located replicas of one deployment. +# Read back by the scheduler to reconstruct a replica's (cluster, index) +# identity from observed state. Not an ordering - just a collision breaker. +_LABEL_INDEX = "modelplane.ai/replica-index" _LABEL_VALUE_TRUE = "true" + # Scheme for gateway-facing URLs. Traffic between the control plane gateway # and remote cluster gateways uses plain HTTP; TLS terminates at the edge. _GATEWAY_SCHEME = "http" @@ -50,8 +56,7 @@ def _inference_cluster( ic.status = ic.status or icv1alpha1.Status() ic.status.providerConfigRef = ic.status.providerConfigRef or icv1alpha1.ProviderConfigRef() ic.status.gateway = ic.status.gateway or icv1alpha1.Gateway() - ic.status.capacity = ic.status.capacity or icv1alpha1.Capacity() - ic.status.capacity.gpuPools = ic.status.capacity.gpuPools or [] + ic.status.gpuPools = ic.status.gpuPools or [] return ic @@ -86,7 +91,23 @@ def __init__(self, req, rsp): def compose(self): if not self.resolve_inputs(): return - matched = self.schedule() + try: + matched = self.schedule() + except cel.CELCompileError as e: + # A malformed nodeSelector CEL selector is a user error. Surface it + # and stop - we can't schedule without valid selectors. + response.set_conditions( + self.rsp, + resource.Condition( + typ=CONDITION_TYPE_REPLICAS_SCHEDULED, + status="False", + reason=CONDITION_REASON_INVALID_NODE_SELECTOR, + message=f"Invalid nodeSelector CEL expression: {e}", + ), + ) + response.warning(self.rsp, f"Invalid nodeSelector CEL expression: {e}") + self.rsp.desired.composite.ready = fnv1.READY_FALSE + return self.compose_replicas(matched) self.compose_endpoints(matched) self.write_status(matched) @@ -142,13 +163,17 @@ def schedule(self): """Match the deployment's topology against available clusters.""" matched = scheduling.schedule(self.xr, self.clusters, self.all_replicas) - # Transition: emit which clusters were matched (first time only). + # Transition: emit which clusters were matched (first time only). A + # cluster can host several replicas, so report it once. if not matched: return matched - prev_replica_count = sum(1 for c in matched if f"replica-{c.name}" in self.req.observed.resources) + prev_replica_count = sum(1 for c in matched if name.replica_key(c) in self.req.observed.resources) if prev_replica_count == 0: - matched_names = [c.name for c in matched] - response.normal(self.rsp, f"Matched {len(matched)} clusters: {', '.join(matched_names)}") + matched_names = sorted({c.name for c in matched}) + response.normal( + self.rsp, + f"Scheduled {len(matched)} replicas across {len(matched_names)} clusters: {', '.join(matched_names)}", + ) return matched @@ -167,26 +192,46 @@ def compose_replicas(self, matched): workers = mrv1alpha1.Workers.model_validate(self.xr.spec.workers.model_dump(exclude_none=True)) for cluster_info in matched: - replica_key = f"replica-{cluster_info.name}" + replica_key = name.replica_key(cluster_info) + + # Stamp the resolved claim: DRA device requests so the replica + # function can form a DRA ResourceClaim. The scheduler only places a + # replica on a pool that yields at least one claimable device, so + # this is always non-empty. + device_requests = [ + mrv1alpha1.DeviceRequest( + name=r.name, + deviceClassName=r.device_class_name, + count=r.count, + selectors=[mrv1alpha1.Selector(cel=c) for c in r.cel_selectors], + ) + for r in cluster_info.device_requests + ] replica = mrv1alpha1.ModelReplica( metadata=metav1.ObjectMeta( - name=resource.child_name(self.xr.metadata.name, cluster_info.name), + name=name.replica(self.xr.metadata.name, cluster_info), namespace=self.xr.metadata.namespace, labels={ _LABEL_REPLICA: _LABEL_VALUE_TRUE, _LABEL_DEPLOYMENT: self.xr.metadata.name, _LABEL_CLUSTER: cluster_info.name, + _LABEL_INDEX: str(cluster_info.index), }, ), - spec=mrv1alpha1.SpecModel(clusterName=cluster_info.name, workers=workers), + spec=mrv1alpha1.SpecModel( + clusterName=cluster_info.name, + nodePoolName=cluster_info.pool, + deviceRequests=device_requests, + workers=workers, + ), ) if self.xr.spec.modelCacheRef: replica.spec.modelCacheRef = mrv1alpha1.ModelCacheRef(name=self.xr.spec.modelCacheRef.name) resource.update(self.rsp.desired.resources[replica_key], replica) def compose_endpoints(self, matched): - """Compose one ModelEndpoint per matched cluster. + """Compose one ModelEndpoint per matched replica. Endpoints are labeled with the deployment name so a ModelService can select them. The URL points at the per-replica path on the @@ -207,10 +252,11 @@ def compose_endpoints(self, matched): continue # The replica name (== the ModelReplica and the backend's workload - # resources) is the per-placement routing key. - replica_name = resource.child_name(self.xr.metadata.name, cluster_info.name) + # resources) is the per-placement routing key. Must match the name + # composed in compose_replicas so routing lands on this replica. + replica_name = name.replica(self.xr.metadata.name, cluster_info) rewrite_path = f"/{self.xr.metadata.namespace}/{replica_name}/" - endpoint_key = f"endpoint-{cluster_info.name}" + endpoint_key = name.endpoint_key(cluster_info) url = f"{_GATEWAY_SCHEME}://{cluster_info.gateway_address}{rewrite_path}v1" resource.update( @@ -222,6 +268,7 @@ def compose_endpoints(self, matched): labels={ _LABEL_DEPLOYMENT: self.xr.metadata.name, _LABEL_CLUSTER: cluster_info.name, + _LABEL_INDEX: str(cluster_info.index), }, ), spec=mev1alpha1.Spec( @@ -236,7 +283,7 @@ def write_status(self, matched): replicas_ready = sum( 1 for c in matched - if resource.get_condition(self.req.observed.resources.get(f"replica-{c.name}"), "Ready").status == "True" + if resource.get_condition(self.req.observed.resources.get(name.replica_key(c)), "Ready").status == "True" ) status = v1alpha1.Status( @@ -257,16 +304,17 @@ def derive_conditions(self, matched): self.rsp.desired.composite.ready = fnv1.READY_FALSE def derive_replicas_scheduled(self, matched): - """ReplicasScheduled: clusters matched and replicas created.""" - any_observed = any(f"replica-{c.name}" in self.req.observed.resources for c in matched) + """ReplicasScheduled: replicas placed and created.""" + any_observed = any(name.replica_key(c) in self.req.observed.resources for c in matched) scheduled = len(matched) > 0 and any_observed + desired = int(self.xr.spec.replicas) if not matched: reason = CONDITION_REASON_INSUFFICIENT_CAPACITY - msg = f"0 of {int(self.xr.spec.replicas)} clusters matched (checked {len(self.clusters)})" + msg = f"0 of {desired} replicas scheduled (checked {len(self.clusters)} clusters)" elif scheduled: reason = CONDITION_REASON_REPLICAS_CREATED - msg = f"Matched {len(matched)} clusters" + msg = f"Scheduled {len(matched)} of {desired} replicas" else: reason = CONDITION_REASON_SCHEDULING msg = "" @@ -285,7 +333,7 @@ def derive_replicas_ready(self, matched): """ReplicasReady: all replicas are serving traffic.""" replicas_ready = 0 for c in matched: - replica_key = f"replica-{c.name}" + replica_key = name.replica_key(c) if resource.get_condition(self.req.observed.resources.get(replica_key), "Ready").status == "True": self.rsp.desired.resources[replica_key].ready = fnv1.READY_TRUE replicas_ready += 1 @@ -315,7 +363,7 @@ def derive_replicas_ready(self, matched): def mark_endpoint_readiness(self, matched): """Mark each composed ModelEndpoint Ready when observed Ready.""" for c in matched: - endpoint_key = f"endpoint-{c.name}" + endpoint_key = name.endpoint_key(c) if endpoint_key not in self.rsp.desired.resources: continue if resource.get_condition(self.req.observed.resources.get(endpoint_key), "Ready").status == "True": diff --git a/functions/compose-model-deployment/function/name.py b/functions/compose-model-deployment/function/name.py new file mode 100644 index 000000000..a22d55c19 --- /dev/null +++ b/functions/compose-model-deployment/function/name.py @@ -0,0 +1,66 @@ +"""Derive the identifiers a ModelDeployment uses for its replicas. + +One place owns every name a replica's resources are known by, so the +composed ModelReplica, its ModelEndpoint, and the backend workloads all +agree. Two kinds of identifier live here: + +* Object names (replica): the metadata.name of a replica's ModelReplica and + ModelEndpoint - a DNS-label-safe, stable, opaque handle. The replica name is + also the per-placement routing key baked into the endpoint URL, so both the + replica and the endpoint must derive it identically. +* Desired-resource keys (replica_key/endpoint_key): function-local handles into + the desired-resources map. Not Kubernetes names, so they need no DNS-safety - + they only have to be distinct per co-located replica and deterministic from + observed state. +""" + +import hashlib + +# DNS label limit and hash suffix length for opaque child names, matching +# crossplane's resource.child_name so names stay valid 63-char DNS labels. +_DNS_LABEL_MAX = 63 +_HASH_LEN = 5 + + +def opaque_name(visible: str, *discriminators: str) -> str: + """A DNS-label-safe name that reads as visible-. + + Like crossplane's resource.child_name, but the discriminators are folded + into the hash WITHOUT appearing in the readable prefix. child_name joins all + of its parts into both the prefix and the hashed value, so it can't hide a + part; we hash the visible name plus the discriminators together, then keep + only the visible name in the prefix. The hash makes co-located replicas + distinct and stable; identity lives in labels, not the name (like a Pod's + name doesn't encode its node). + """ + full = "-".join((visible, *discriminators)) + h = hashlib.sha256(full.encode()).hexdigest()[:_HASH_LEN] + max_prefix = _DNS_LABEL_MAX - _HASH_LEN - 1 + prefix = visible[:max_prefix].rstrip("-") + return f"{prefix}-{h}" + + +def replica(deployment_name: str, candidate) -> str: + """The opaque, DNS-safe name for a replica's resources. + + Hashed from (deployment, cluster, index) so co-located replicas get distinct + names. Cluster and index are not exposed in the readable prefix - identity + lives in labels, the name is just a stable handle (matching Crossplane's + opaque-name convention, and how a Pod's name doesn't encode its node). + """ + return opaque_name(deployment_name, candidate.name, str(candidate.index)) + + +def replica_key(candidate) -> str: + """Function-local desired-resource handle for a replica's (cluster, index). + + Distinct per co-located replica so two replicas on one cluster don't share a + desired-resource slot. Deterministic from observed state, so a replica + placed this reconcile maps back to the same handle once observed. + """ + return f"replica-{candidate.name}-{candidate.index}" + + +def endpoint_key(candidate) -> str: + """Function-local desired-resource handle for a replica's ModelEndpoint.""" + return f"endpoint-{candidate.name}-{candidate.index}" diff --git a/functions/compose-model-deployment/function/quantity.py b/functions/compose-model-deployment/function/quantity.py new file mode 100644 index 000000000..3d8de1b68 --- /dev/null +++ b/functions/compose-model-deployment/function/quantity.py @@ -0,0 +1,233 @@ +"""Kubernetes resource.Quantity, reimplemented for DRA CEL selectors. + +Mirrors the apiserver Quantity CEL library (k8s.io/apiserver/pkg/cel/library/ +quantity.go) and resource.Quantity parsing/comparison, so quantity() in a DRA +device selector behaves the same here as upstream. Parsed values are rounded up +to nano scale, as resource.ParseQuantity does, so comparisons match +resource.Quantity.Cmp. +""" + +from __future__ import annotations + +import decimal +import re + +from celpy import celtypes + +# Quantity suffix multipliers. Binary (power-of-two) and decimal (SI) suffixes, +# matching k8s.io/apimachinery/pkg/api/resource suffix.go. Order matters: check +# two-character binary suffixes before single-character decimal ones. +_BINARY = { + "Ki": decimal.Decimal(2**10), + "Mi": decimal.Decimal(2**20), + "Gi": decimal.Decimal(2**30), + "Ti": decimal.Decimal(2**40), + "Pi": decimal.Decimal(2**50), + "Ei": decimal.Decimal(2**60), +} +# Single-character decimal SI suffixes (suffix.go), including nano/micro. +_DECIMAL = { + "n": decimal.Decimal(10) ** -9, + "u": decimal.Decimal(10) ** -6, + "m": decimal.Decimal(10) ** -3, + "k": decimal.Decimal(10) ** 3, + "M": decimal.Decimal(10) ** 6, + "G": decimal.Decimal(10) ** 9, + "T": decimal.Decimal(10) ** 12, + "P": decimal.Decimal(10) ** 15, + "E": decimal.Decimal(10) ** 18, +} + +# int64 range. asInteger() fails (upstream: error) outside this, matching +# resource.Quantity.AsInt64. +_INT64_MIN = -(2**63) +_INT64_MAX = 2**63 - 1 + +# resource.ParseQuantity rounds every value up to nano (10^-9) scale before +# storing/comparing, so two quantities equal at nano resolution compare equal. +# We round the same way for parity with resource.Quantity.Cmp. Go's inf.RoundUp +# rounds away from zero, which is decimal.ROUND_UP (not ROUND_CEILING). +_NANO = decimal.Decimal(10) ** -9 + +# A Quantity number: optional sign, then digits[.digits] | digits. | .digits. +# Matches k8s resource.Quantity's : a trailing dot ("5.") is +# allowed, mirroring the upstream parser. +_QUANTITY_NUMBER = re.compile(r"^[+-]?(\d+(\.\d*)?|\.\d+)$") + +# A decimal exponent suffix: 'e' or 'E' followed by a signed integer (e.g. +# "256e3"). resource.Quantity treats this as a DecimalExponent suffix. +_EXPONENT = re.compile(r"^[eE][+-]?\d+$") + + +def parse(s: str) -> decimal.Decimal: + """Parse a Kubernetes Quantity string into a Decimal of base units. + + Mirrors k8s resource.ParseQuantity: a signed number followed by a binary SI + suffix (Ki, Mi, Gi, Ti, Pi, Ei), a decimal SI suffix (n, u, m, k, M, G, T, + P, E, or none), or a decimal exponent (e/E + signed int). The result is + rounded up to nano scale, as resource.Quantity does, so comparisons match + resource.Quantity.Cmp. A binary-suffix value that overflows int64 saturates + to ±int64-max (see _saturate), again matching resource.Quantity.Cmp. Raises + ValueError on input resource.Quantity would reject (e.g. capital 'K', a + comma) so a malformed device capacity surfaces as a non-match rather than a + silently-wrong number. Unlike UnmarshalJSON, the quantity() CEL constructor + does NOT trim whitespace, so neither do we. + + Known divergences from resource.ParseQuantity (corner cases no device + capacity hits, not worth reproducing the upstream parser state machine for): + + * Bare suffix. Upstream parses most bare suffixes as 0 ("Mi", "Ki", "k", + "M", "E", "n" -> 0) but inconsistently errors on a few ("Ei", "" -> error). + We reject every bare suffix (no number) as a malformed quantity. + * Very large decimal-exponent values. Upstream's inf.Dec scale handling + mis-renders some (e.g. "1000E" -> "1"); we keep the mathematically correct + value. Neither appears as a real device capacity. + """ + s = str(s) + # Binary SI suffix (two characters; check first so "Mi" is not read as "M"). + for suffix, mult in _BINARY.items(): + if s.endswith(suffix): + return _round_nano(_saturate(_number(s[: -len(suffix)], s) * mult)) + # Decimal exponent (e/E + signed int): the exponent is part of the number. + for i, c in enumerate(s): + if c in "eE" and i > 0: + if not _EXPONENT.match(s[i:]): + break + return _round_nano(_number(s[:i], s) * (decimal.Decimal(10) ** int(s[i + 1 :]))) + # Single-character decimal SI suffix (n, u, m, k, M, G, T, P, E). + if s and s[-1] in _DECIMAL: + return _round_nano(_number(s[:-1], s) * _DECIMAL[s[-1]]) + # No suffix. + return _round_nano(_number(s, s)) + + +def _number(num: str, original: str) -> decimal.Decimal: + """Parse the numeric part of a quantity, or raise referencing the original.""" + if not _QUANTITY_NUMBER.match(num): + raise ValueError(f"invalid quantity: {original!r}") + # A trailing dot ("5.") is valid upstream but not for Decimal; drop it. + if num.endswith("."): + num = num[:-1] + return decimal.Decimal(num) + + +def _saturate(value: decimal.Decimal) -> decimal.Decimal: + """Clamp an overflowing binary-suffix value to ±int64-max, as upstream does. + + resource.ParseQuantity stores a binary-suffix quantity (Ki..Ei) in a + BinarySI int64; a magnitude at or above 2^63 overflows and saturates to + int64-max, KEEPING THE SIGN (so -10Ei stores -(2^63-1), not int64-min). + Confirmed against resource.Quantity.Cmp: quantity("8Ei"), quantity("10Ei"), + and quantity("100Ei") all compare equal to quantity("9223372036854775807"). + Only the binary path saturates - the decimal/exponent path keeps its value + (quantity("256E") stays 2.56e20) - so we clamp only there. Without this we'd + report 10Ei > 8Ei where upstream reports them equal. + """ + if value > _INT64_MAX: + return decimal.Decimal(_INT64_MAX) + if value < -_INT64_MAX: + return decimal.Decimal(-_INT64_MAX) + return value + + +def _round_nano(value: decimal.Decimal) -> decimal.Decimal: + """Round up to nano scale, matching resource.ParseQuantity (inf.RoundUp). + + Uses a wide local precision so a large decimal-path quantity (e.g. + quantity("256E") == 2.56e20, which needs >28 significant digits at nano + scale) rounds instead of raising decimal.InvalidOperation under Python's + default 28-digit context. Go's inf.RoundUp rounds away from zero, which is + decimal.ROUND_UP (not ROUND_CEILING). + """ + with decimal.localcontext() as ctx: + ctx.prec = 60 + return value.quantize(_NANO, rounding=decimal.ROUND_UP) + + +def _cmp(a: decimal.Decimal, b: decimal.Decimal) -> int: + return (a > b) - (a < b) + + +class Quantity: + """A Kubernetes Quantity as a nano-rounded Decimal of base units. + + Implements the comparison, arithmetic, and conversion methods of the + apiserver Quantity CEL type. There are deliberately no bare = operators: + upstream's Quantity type doesn't register them either, only the methods. + """ + + __slots__ = ("value",) + + def __init__(self, value: decimal.Decimal): + self.value = value + + def __eq__(self, other) -> bool: + return isinstance(other, Quantity) and self.value == other.value + + def __hash__(self) -> int: + return hash(self.value) + + +def quantity(s) -> Quantity: + """The CEL quantity() constructor.""" + return Quantity(parse(s)) + + +def is_quantity(s) -> celtypes.BoolType: + """The CEL isQuantity() predicate. + + Returns false for any parse failure, mirroring upstream (isQuantity is true + iff quantity() would not error). + """ + try: + parse(s) + except Exception: # noqa: BLE001 - any parse failure is "not a quantity" + valid = False + else: + valid = True + return celtypes.BoolType(valid) + + +def compare_to(a: Quantity, b: Quantity) -> celtypes.IntType: + return celtypes.IntType(_cmp(a.value, b.value)) + + +def is_greater_than(a: Quantity, b: Quantity) -> celtypes.BoolType: + return celtypes.BoolType(a.value > b.value) + + +def is_less_than(a: Quantity, b: Quantity) -> celtypes.BoolType: + return celtypes.BoolType(a.value < b.value) + + +def sign(a: Quantity) -> celtypes.IntType: + return celtypes.IntType(_cmp(a.value, decimal.Decimal(0))) + + +def is_integer(a: Quantity) -> celtypes.BoolType: + return celtypes.BoolType(a.value == a.value.to_integral_value() and _INT64_MIN <= a.value <= _INT64_MAX) + + +def as_integer(a: Quantity) -> celtypes.IntType: + if a.value != a.value.to_integral_value() or not (_INT64_MIN <= a.value <= _INT64_MAX): + raise ValueError("cannot convert value to integer") + return celtypes.IntType(int(a.value)) + + +def as_approximate_float(a: Quantity) -> celtypes.DoubleType: + return celtypes.DoubleType(float(a.value)) + + +def add(a: Quantity, b) -> Quantity: + return Quantity(a.value + _operand(b)) + + +def sub(a: Quantity, b) -> Quantity: + return Quantity(a.value - _operand(b)) + + +def _operand(b) -> decimal.Decimal: + """add/sub accept a Quantity or an integer (upstream has both overloads).""" + if isinstance(b, Quantity): + return b.value + return decimal.Decimal(int(b)) diff --git a/functions/compose-model-deployment/function/scheduling.py b/functions/compose-model-deployment/function/scheduling.py index 66299bda5..76a1715fa 100644 --- a/functions/compose-model-deployment/function/scheduling.py +++ b/functions/compose-model-deployment/function/scheduling.py @@ -1,71 +1,135 @@ -"""Schedule model replicas across inference clusters. - -Replicas are pinned to clusters at creation time. On each reconcile the -scheduler runs in two phases: - -1. Retain. For each existing replica of this deployment, keep its - pinned cluster assignment if the cluster still exists. The cluster - does not need to be Ready - a pinned replica stays pinned even if - its cluster is temporarily unavailable, and the parent - ModelDeployment surfaces the degraded state via its conditions. - -2. Place. For any unfilled replicas (scale-up, or replicas whose - pinned cluster was deleted entirely), pick from the remaining - candidate clusters by filtering against capacity and ranking - deterministically. - -A merely degraded cluster (not Ready, or no gateway address) does not -trigger re-placement - the replica stays pinned and the deployment -reflects the degradation via conditions. Re-placement happens only -when the pinned cluster is gone from the cluster set entirely, or when -the underlying ModelReplica is deleted (e.g. by reducing replicas). +"""Schedule a ModelDeployment's replicas across inference clusters. + +The scheduler is a pure function of observed state. Every reconcile it is +handed the deployment, every InferenceCluster with its published capacity, and +every existing ModelReplica, and it recomputes the whole placement from +scratch. Given the same observed state it returns the same placement, so it is +safe to run on every reconcile. + +A replica's identity is the pair (cluster, index): the cluster it runs on and a +per-cluster-local index that distinguishes co-located replicas of the same +deployment. The index is a collision breaker, not an ordering - replicas are +fungible. A replica never moves cluster. If its cluster is deleted (or, in +future, drained) the replica's desired entry stops being emitted, Crossplane +garbage-collects it, and the fill phase mints a fresh replica elsewhere to +refill the deployment's replica count. Moving is always delete-plus-create, +mirroring how Kubernetes treats a Pod whose node is gone. + +Scheduling runs in two phases: + +1. Retain. For each existing replica, keep its (cluster, index) if the cluster + still exists and its pinned pool still satisfies the (possibly edited) + nodeSelector. Retention is otherwise unconditional: a healthy replica is + never moved or dropped to improve the global picture. A degraded cluster + (not Ready, or no gateway address) is still retained - transient outages + surface via the deployment's conditions, not re-placement. This is what + makes the scheduler stable: existing placements are inputs, not decisions. + +2. Fill. If the deployment wants more replicas than were retained, place the + shortfall one at a time. Each new replica goes to the eligible cluster + hosting the fewest of this deployment's replicas (spread first, pack only + when every eligible cluster already has its share), against a running ledger + of free node capacity so we never overcommit a cluster. If the deployment + wants fewer, drop the highest-index replicas first, consolidating off the + clusters we packed onto last. + +Capacity is gated on nodes, not on individual DRA devices. The per-node device +count is a device request's count; the only number the scheduler reads from +topology is nodes-per-replica, which it gates against a pool's available nodes. +Device-count contention BETWEEN deployments is left to DRA admission on the +workload cluster, which is authoritative: it rejects a Pod whose ResourceClaim +can't be satisfied, and the next reconcile sees the updated observed state. The +control-plane scheduler stays deliberately coarse - "could this cluster +plausibly host this replica" - rather than duplicating the real DRA scheduler. """ -from dataclasses import dataclass +from dataclasses import dataclass, field from models.ai.modelplane.inferencecluster import v1alpha1 as icv1alpha1 from models.ai.modelplane.modeldeployment import v1alpha1 as mdv1alpha1 from models.ai.modelplane.modelreplica import v1alpha1 as mrv1alpha1 -# Label key written by compose-model-deployment. Used to find existing -# replicas of this deployment so we can preserve their cluster pins. +from function import cel + +# Labels written by compose-model-deployment, read back here to reconstruct a +# replica's (cluster, index) identity from observed state. _LABEL_DEPLOYMENT = "modelplane.ai/deployment" +_LABEL_CLUSTER = "modelplane.ai/cluster" +_LABEL_INDEX = "modelplane.ai/replica-index" + +# claim discriminator values on an InferenceClass device. +_CLAIM_DRA = "DRA" + + +@dataclass +class DeviceRequest: + """A resolved DRA device request for a matched pool device. + + Carries everything compose-model-replica needs to emit one DeviceRequest in + a ResourceClaim: the request name, the DeviceClass to claim through (from the + matched InferenceClass device), the count, and the CEL selectors. Only + claim: DRA devices produce one of these; synthetic devices are matched for + scheduling but never claimed. + """ + + name: str + device_class_name: str + count: int + cel_selectors: list[str] @dataclass class Candidate: - """A cluster selected to host a ModelReplica.""" + """A ModelReplica placement: one replica on one cluster. + + A deployment's placement is a list of these, one per desired replica that + could be retained or placed. Each is identified by (name, index): the + cluster name and a per-cluster-local index distinguishing co-located + replicas. The index is meaningless beyond breaking name collisions. + """ name: str + # Per-cluster-local index distinguishing this replica from others of the + # same deployment on the same cluster. Stable across reconciles for a + # retained replica. + index: int # The cluster's gateway address. Empty if the cluster is pinned but # currently unavailable (no Ready condition or no gateway address). # Callers should not compose a ModelEndpoint when this is empty - # there is nothing to route traffic to. - gateway_address: str + gateway_address: str = "" + # The node pool the scheduler matched on this cluster, propagated to the + # ModelReplica as spec.nodePoolName. Always set: a candidate exists only + # because a named pool matched (the pool name is XRD-required). + pool: str = "" + # Resolved claim: DRA device requests for the matched pool, in nodeSelector + # order. Stamped onto the ModelReplica as spec.deviceRequests. Always + # non-empty: a pool matches only when at least one claim: DRA device + # resolves (see _match_pool), so every scheduled replica has a claim. + device_requests: list[DeviceRequest] = field(default_factory=list) @dataclass class Shape: - """Physical shape derived from workers.topology and workers.count.""" + """Physical shape derived from workers.topology and workers.count. + + Only nodes_per_replica is a scheduling input (the available-node gate). + Topology otherwise drives provisioning, not pool selection. + """ - gpus_per_node: int # GPUs per pod (= tensor). - nodes_per_worker: int # Pods per worker (= pipeline, default 1). - total_gpus: int # Total GPUs consumed by all workers in one replica. + nodes_per_replica: int # Total nodes consumed by one ModelReplica. def topology_shape(workers) -> Shape: - """Derive the physical shape of one ModelReplica from workers.""" + """Derive nodes-per-replica from workers. + + Nodes per worker is pipeline (the only multi-node axis in v0.1); a replica + has workers.count workers, so nodes-per-replica is pipeline * count. + """ topology = workers.topology count = int(workers.count or 1) - gpus_per_node = int(topology.tensor) nodes_per_worker = int(topology.pipeline or 1) - - total_gpus = gpus_per_node * nodes_per_worker * count - return Shape( - gpus_per_node=gpus_per_node, - nodes_per_worker=nodes_per_worker, - total_gpus=total_gpus, - ) + return Shape(nodes_per_replica=nodes_per_worker * count) def _cluster_ready(cluster: icv1alpha1.InferenceCluster) -> bool: @@ -81,153 +145,516 @@ def _cluster_ready(cluster: icv1alpha1.InferenceCluster) -> bool: return any(c.type == "Ready" and c.status == "True" for c in cluster.status.conditions or []) -def _pool_fits_shape(pool, shape: Shape) -> bool: - """Check whether a pool can host one ModelReplica of this shape.""" - count_per_node = int(pool.countPerNode or 0) - nodes = int(pool.nodes or 0) +@dataclass +class _CompiledRequest: + """One nodeSelector device request with its CEL selectors compiled. - if count_per_node < shape.gpus_per_node: - return False - return nodes >= shape.nodes_per_worker + cel_selectors are the raw expressions (carried through to the DeviceRequest); + programs are the compiled forms used to match a pool device. + """ + name: str + count: int + cel_selectors: list[str] + programs: list[cel.Program] -def _cluster_fits_shape(cluster: icv1alpha1.InferenceCluster, shape: Shape) -> tuple[bool, int]: - """Return whether any pool on the cluster can host the shape, and - the total eligible GPU capacity across fitting pools.""" - eligible_total = 0 - fit = False - for pool in cluster.status.capacity.gpuPools: - if not _pool_fits_shape(pool, shape): - continue - fit = True - eligible_total += int(pool.countPerNode or 0) * int(pool.nodes or 0) - return fit, eligible_total +def compile_requests(deployment: mdv1alpha1.ModelDeployment) -> list[_CompiledRequest]: + """Compile every nodeSelector device request's selectors once. -def schedule( + nodeSelector is required (the XRD enforces at least one device request), so + GPUs always bind through a DRA ResourceClaim derived from these requests. + Raises cel.CELCompileError on a malformed expression; the caller turns that + into an InvalidNodeSelector condition. + """ + requests = [] + for req in deployment.spec.nodeSelector.devices: + cel_selectors = [s.cel for s in req.selectors if s.cel] + requests.append( + _CompiledRequest( + name=req.name, + count=int(req.count or 1), + cel_selectors=cel_selectors, + programs=[cel.Program(c) for c in cel_selectors], + ) + ) + return requests + + +def _device_satisfies(device, programs: list[cel.Program]) -> bool: + """Whether a pool device satisfies every selector (all ANDed).""" + # by_alias keeps the DRA wire names (bool/int, not the generated bool_/int_ + # Python attribute names) so the CEL activation sees device.attributes the + # way DRA selectors expect. + raw = device.model_dump(by_alias=True, exclude_none=True) + return all(p.matches(raw) for p in programs) + + +def _match_pool(pool, requests: list[_CompiledRequest]) -> list[DeviceRequest] | None: + """Match a pool against the device requests. + + Returns the resolved claim: DRA DeviceRequests when the pool satisfies every + request AND at least one matched device is claim: DRA, or None when the pool + fails any request or matches only synthetic devices. + + A request matches a pool device when the device has enough UNCONSUMED count + to cover the request and every selector evaluates true against that device. + Each resolved DRA request becomes a distinct DeviceRequest in one + ResourceClaim, and DRA allocates distinct devices per request, so a device's + count is consumed as requests claim it: two requests cannot both be satisfied + by the same single-count device, and N requests against one device must fit + within that device's count. This accounting keeps us from accepting a pool + DRA can't actually satisfy. + + A replica's serving workload binds its GPUs through this ResourceClaim, so a + pool that matches only synthetic devices (claim: Synthetic, matched for fleet + scheduling but never claimed) yields nothing to claim and is not a viable + host. Synthetic devices are co-selectors that refine placement alongside a + claimable device; a selector that resolves to synthetic devices alone leaves + the workload with no claim, so we reject the pool. The deployment then finds + no eligible pool and surfaces InsufficientCapacity. The ModelDeployment XRD + documents that a nodeSelector must match at least one claimable device. + + Assignment is GREEDY in request order: each request takes the first device + that satisfies it and has count left, with no backtracking. Greedy is exact + when no device satisfies two different requests, and that holds for both + patterns that occur in practice. First, a workload asking for N of one device + is a single request, so nothing contends. Second, a workload asking for + different device DOMAINS (e.g. a GPU and a NIC) writes selectors that read + different attribute domains, so again no device satisfies two requests and + order can't starve either. + + Greedy can falsely reject only when two requests' match sets OVERLAP on a + shared device kind - e.g. a broad request (memory >= 80Gi, matches an H100 + and an H200) and a narrow one (an H200 specifically) against a pool holding + one of each. If the broad request takes the H200 first, the narrow one finds + nothing and we reject the pool, though broad->H100, narrow->H200 would have + fit. This needs one deployment to ask for multiple GPUs of deliberately + different specificity from one mixed-GPU pool, written as overlapping rather + than disjoint selectors - a shape no real workload writes (you'd name both + GPUs, or use one request with a count). It also fails SAFE: a false reject + surfaces as InsufficientCapacity, never an overcommit or a bad placement, and + the user can resolve it by making the selectors disjoint. + """ + devices = pool.devices or [] + # Track remaining count per device by its index in the pool, so capacity + # consumed by an earlier request isn't offered again to a later one. + remaining = [int(d.count or 1) for d in devices] + resolved: list[DeviceRequest] = [] + for req in requests: + match = None + for i, device in enumerate(devices): + if remaining[i] < req.count: + continue + if not _device_satisfies(device, req.programs): + continue + match = device + remaining[i] -= req.count + break + if match is None: + return None + if (match.claim or _CLAIM_DRA) == _CLAIM_DRA: + resolved.append( + DeviceRequest( + name=req.name, + device_class_name=match.deviceClassName or "", + count=req.count, + cel_selectors=req.cel_selectors, + ) + ) + # Every request matched, but if none resolved to a claim: DRA device the + # replica would have no ResourceClaim to bind its GPUs through. Reject the + # pool rather than place a claimless workload. + if not resolved: + return None + return resolved + + +def _pool_by_name(cluster: icv1alpha1.InferenceCluster, pool_name: str): + """The cluster's published pool with this name, or None.""" + for pool in cluster.status.gpuPools or []: + if (pool.name or "") == pool_name: + return pool + return None + + +def _is_ours(replica: mrv1alpha1.ModelReplica, deployment: mdv1alpha1.ModelDeployment) -> bool: + """Whether a replica belongs to this deployment.""" + return (replica.metadata.labels or {}).get(_LABEL_DEPLOYMENT) == deployment.metadata.name + + +def _replica_index(replica: mrv1alpha1.ModelReplica) -> int: + """The per-cluster-local index recorded on a replica, defaulting to 0. + + Read from the modelplane.ai/replica-index label. A replica from before this + label existed (or with a malformed value) is treated as index 0; that's the + natural single-replica-per-cluster case those replicas came from. + """ + raw = (replica.metadata.labels or {}).get(_LABEL_INDEX) + try: + return int(raw) + except (TypeError, ValueError): + return 0 + + +@dataclass +class _Ledger: + """Free node capacity per (cluster, pool). + + Built by _build_ledger from published capacity minus the replicas already + committed to each pool (see there for exactly which replicas count). The + fill phase then decrements it via consume() as it places each new replica, + which is what stops a single scheduling pass overcommitting one cluster. + """ + + free: dict[tuple[str, str], int] + + def available(self, cluster: str, pool: str) -> int: + return self.free.get((cluster, pool), 0) + + def consume(self, cluster: str, pool: str, nodes: int) -> None: + self.free[(cluster, pool)] = self.available(cluster, pool) - nodes + + +def _build_ledger( deployment: mdv1alpha1.ModelDeployment, clusters: list[icv1alpha1.InferenceCluster], + retained: list[Candidate], all_replicas: list[mrv1alpha1.ModelReplica], -) -> list[Candidate]: - """Pick clusters for a deployment's ModelReplicas. +) -> _Ledger: + """Compute free node capacity per (cluster, pool). + + Starts from each pool's published node count and subtracts the nodes already + committed to it. A replica counts when it is either: + + * another deployment's replica - capacity we don't control, or + * one of THIS deployment's RETAINED replicas - a placement we're keeping. + + It deliberately does NOT subtract this deployment's observed replicas that + were dropped from the retained set (cluster gone, or pinned pool no longer + matches the nodeSelector). Those are being deleted, so their nodes are + freeing up and must be available to the fill phase that re-places them - + otherwise re-placement (delete-old + create-new) could never converge. + + Every counted replica is charged at its OWN observed node cost (derived from + its spec.workers), not the deployment's current shape. A replica still + physically consumes whatever it was created with until it's rolled, and + editing workers without editing the nodeSelector doesn't re-roll it. + + A replica pinned to a known pool is subtracted from that pool. One with no + pool pin (or naming a pool no longer published) can't be attributed to a + specific pool, so it's charged to EVERY pool on its cluster. That's + deliberately conservative: it can only make the gate decline to pack where + it technically could, never overcommit. In practice every replica this + function creates records its pool, so unattributed consumption is limited to + legacy replicas predating the pool pin. + """ + free: dict[tuple[str, str], int] = {} + pools_by_cluster: dict[str, list[str]] = {} + for cluster in clusters: + name = cluster.metadata.name + pools_by_cluster[name] = [] + for pool in cluster.status.gpuPools or []: + free[(name, pool.name or "")] = int(pool.nodes or 0) + pools_by_cluster[name].append(pool.name or "") + + def charge(cluster_name: str, pool_name: str, nodes: int) -> None: + # A real pool pin is charged to that pool; anything else (no pin, or a + # pool no longer published) is unattributable and charged to every pool + # on the cluster (conservative). Keying on pool_name's truthiness, not on + # dict membership, keeps an unpinned replica from ever colliding with a + # published pool. + if pool_name and (cluster_name, pool_name) in free: + free[(cluster_name, pool_name)] -= nodes + return + for p in pools_by_cluster.get(cluster_name, []): + free[(cluster_name, p)] -= nodes + + # Identities (cluster, index) of the replicas we're keeping. + retained_ids = {(c.name, c.index) for c in retained} - Existing replicas keep their pinned cluster. Any remaining replica - slots are filled by picking deterministically from the remaining - candidate clusters. + for r in all_replicas: + if not r.spec.workers: + continue + ours = _is_ours(r, deployment) + # Skip our own replicas that aren't being retained: dropped (re-placed) + # ones are freeing their nodes, and scaled-down ones are going away. + if ours and (r.spec.clusterName, _replica_index(r)) not in retained_ids: + continue + charge(r.spec.clusterName, r.spec.nodePoolName or "", topology_shape(r.spec.workers).nodes_per_replica) - Returns up to deployment.spec.replicas candidates. Returns fewer - if not enough viable clusters exist. - """ - desired_replicas = int(deployment.spec.replicas) - shape = topology_shape(deployment.spec.workers) - clusters_by_name = {c.metadata.name: c for c in clusters} + return _Ledger(free=free) + + +def _retain( + deployment: mdv1alpha1.ModelDeployment, + clusters_by_name: dict[str, icv1alpha1.InferenceCluster], + all_replicas: list[mrv1alpha1.ModelReplica], + requests: list[_CompiledRequest], +) -> list[Candidate]: + """Keep existing replicas whose cluster exists and pool still matches. - # Phase 1: retain. Each existing replica stays on its pinned - # cluster, as long as that cluster still exists in the candidate - # set. We keep degraded clusters (not Ready, no gateway address) so - # transient outages don't trigger re-placement. + Returns one Candidate per retained replica, carrying its (cluster, index) + identity. A replica is dropped from the retained set (and so re-placed by + the fill phase) when its cluster is gone, or when its pinned pool no longer + satisfies the nodeSelector - the Kubernetes "template changed, roll the + replica" behavior. A degraded-but-present cluster is retained. + """ retained: list[Candidate] = [] - retained_names: set[str] = set() + seen: set[tuple[str, int]] = set() for r in all_replicas: - if (r.metadata.labels or {}).get(_LABEL_DEPLOYMENT) != deployment.metadata.name: + if not _is_ours(r, deployment): continue cluster_name = r.spec.clusterName - # Replicas without a pin (shouldn't happen given the XRD requires - # clusterName) or pinned to a cluster that no longer exists are - # dropped from the retained set - the scheduler will pick a - # replacement in phase 2. if not cluster_name or cluster_name not in clusters_by_name: continue - if cluster_name in retained_names: + identity = (cluster_name, _replica_index(r)) + if identity in seen: continue cluster = clusters_by_name[cluster_name] + if not _pinned_pool_still_matches(r, cluster, requests): + continue + seen.add(identity) retained.append( Candidate( name=cluster_name, - # Empty when the cluster is degraded. Callers must check - # this before composing routing resources. - gateway_address=(cluster.status.gateway.address if cluster.status.gateway else "") or "", + index=identity[1], + gateway_address=_gateway_address(cluster), + pool=r.spec.nodePoolName or "", + device_requests=_retained_requests(r, cluster, requests), ) ) - retained_names.add(cluster_name) - - # Trim retained to desired replica count. Scale-down keeps the - # lexicographically earliest pinned clusters so the choice is - # deterministic and stable across reconciles. - retained.sort(key=lambda c: c.name) - retained = retained[:desired_replicas] - retained_names = {c.name for c in retained} - - # Phase 2: place. Fill any remaining slots from clusters that don't - # already host one of this deployment's replicas. Only clusters that - # are Ready and have free capacity are eligible. - remaining = desired_replicas - len(retained) - placed: list[Candidate] = [] - if remaining > 0: - placed = _place_new(deployment, shape, clusters, retained_names, all_replicas, remaining) + return retained + + +def _pinned_pool_still_matches( + replica: mrv1alpha1.ModelReplica, + cluster: icv1alpha1.InferenceCluster, + requests: list[_CompiledRequest], +) -> bool: + """Whether a retained replica's pinned pool still satisfies the requests. + + Modelplane follows Kubernetes here. A change to the deployment's nodeSelector + is a change to the deployment "template", so - like editing a Deployment's + Pod template - replicas that no longer match are re-placed (Kubernetes does a + rolling replacement; we drop the pin and let the fill phase pick a matching + pool). This is distinct from a pool's own device attributes drifting under a + still-matching replica, which we leave pinned (Kubernetes' + IgnoredDuringExecution: node-label drift does not evict a bound Pod). + + Returns False (re-place) when: + * the replica carries no pool pin (it needs a real pool pin), or + * the pinned pool no longer exists on the cluster, or + * the pinned pool no longer satisfies the requests. + """ + pool_name = replica.spec.nodePoolName + if not pool_name: + return False + pool = _pool_by_name(cluster, pool_name) + if pool is None: + # Pinned pool is gone from the cluster's published capacity. + return False + return _match_pool(pool, requests) is not None - return retained + placed +def _retained_requests(replica, cluster, requests: list[_CompiledRequest]) -> list[DeviceRequest]: + """Resolve the claim: DRA requests for a retained replica's pinned pool. + + Only called for a replica _pinned_pool_still_matches already accepted, so the + pinned pool exists and yields at least one DRA request: _match_pool returns a + non-empty list here, never None. If that contract were ever broken the empty + result would surface as an XRD validation error in compose_replicas (which + requires deviceRequests), not as a silently claimless replica. + """ + pool = _pool_by_name(cluster, replica.spec.nodePoolName) + return _match_pool(pool, requests) or [] -def _place_new( - deployment: mdv1alpha1.ModelDeployment, - shape: Shape, - clusters: list[icv1alpha1.InferenceCluster], - skip: set[str], - all_replicas: list[mrv1alpha1.ModelReplica], - n: int, -) -> list[Candidate]: - """Pick up to n clusters for new replicas. - Skips clusters in the skip set (already retained). Filters by - readiness, topology fit, and free capacity. Returns at most n - candidates sorted alphabetically. +def _eligible_pool( + cluster: icv1alpha1.InferenceCluster, + shape: Shape, + requests: list[_CompiledRequest], + ledger: _Ledger, +) -> tuple[str, list[DeviceRequest]] | None: + """Pick the first pool on a cluster that can host one more replica. + + A pool is eligible when it satisfies the nodeSelector requests AND has at + least nodes-per-replica free in the ledger (which already accounts for + replicas placed earlier in this pass). Pools are considered in published + order, which is deterministic. Returns (pool_name, resolved_requests) or + None if no pool on the cluster is eligible. """ - candidates: list[Candidate] = [] - for cluster in clusters: - if cluster.metadata.name in skip: - continue - if not _cluster_ready(cluster): + for pool in cluster.status.gpuPools or []: + name = pool.name or "" + if ledger.available(cluster.metadata.name, name) < shape.nodes_per_replica: continue - - fit, eligible_total = _cluster_fits_shape(cluster, shape) - if not fit: + resolved = _match_pool(pool, requests) + if resolved is None: continue + return name, resolved + return None - # Subtract GPUs consumed by other deployments' replicas on this - # cluster. Our own replicas can't be on this cluster (we skipped - # those above). - used_gpus = _used_gpus(deployment, cluster, all_replicas) - if eligible_total - used_gpus < shape.total_gpus: - continue +def _fill( + shape: Shape, + clusters: list[icv1alpha1.InferenceCluster], + retained: list[Candidate], + ledger: _Ledger, + requests: list[_CompiledRequest], + n: int, +) -> list[Candidate]: + """Place n new replicas, spreading across clusters and packing when forced. + + Places one replica at a time. For each, the eligible clusters are those that + are Ready, have a nodeSelector-matching pool, and have free capacity in the + ledger. Among them we pick the cluster hosting the fewest of this + deployment's replicas so far (spread), breaking ties by cluster name for + determinism. A second replica lands on a cluster only once every other + eligible cluster already has its share; when capacity forces it, replicas + pack onto fewer clusters. Each placement decrements the ledger and takes the + lowest free index on its chosen cluster, so the next iteration sees the + updated load. Stops early (placing fewer than n) when no cluster can host + another replica - the caller surfaces that as InsufficientCapacity. + """ + # Per-cluster load and used indices seeded from retained replicas, so spread + # accounts for what's already there and new indices don't collide. + load: dict[str, int] = {} + used_indices: dict[str, set[int]] = {} + for c in retained: + load[c.name] = load.get(c.name, 0) + 1 + used_indices.setdefault(c.name, set()).add(c.index) - candidates.append( + placed: list[Candidate] = [] + for _ in range(n): + choice = _pick_cluster(shape, clusters, load, ledger, requests) + if choice is None: + break + cluster, pool_name, resolved = choice + name = cluster.metadata.name + index = _lowest_free_index(used_indices.setdefault(name, set())) + + placed.append( Candidate( - name=cluster.metadata.name, + name=name, + index=index, gateway_address=cluster.status.gateway.address, + pool=pool_name, + device_requests=resolved, ) ) - candidates.sort(key=lambda c: c.name) - return candidates[:n] + load[name] = load.get(name, 0) + 1 + used_indices[name].add(index) + ledger.consume(name, pool_name, shape.nodes_per_replica) + return placed -def _used_gpus(deployment, cluster, all_replicas) -> int: - """Sum GPUs consumed by other deployments' replicas on this cluster. - Other deployments' replicas are read from observed state. Each - replica reports its topology, from which we derive total GPUs. - Our own replicas are excluded - the scheduler treats this - deployment's own demand separately. +def _pick_cluster( + shape: Shape, + clusters: list[icv1alpha1.InferenceCluster], + load: dict[str, int], + ledger: _Ledger, + requests: list[_CompiledRequest], +) -> tuple[icv1alpha1.InferenceCluster, str, list[DeviceRequest]] | None: + """Pick the eligible cluster hosting the fewest of this deployment's replicas. + + Eligible means Ready, with a nodeSelector-matching pool that has free + capacity in the ledger. The chosen key is (load on the cluster, cluster + name): fewest replicas first for spread, name for a deterministic tiebreak. + load already counts only this deployment's replicas (seeded from retained + plus those placed earlier in the pass). Returns (cluster, pool_name, + resolved_requests) or None when no cluster is eligible. """ - used = 0 - for r in all_replicas: - if (r.metadata.labels or {}).get(_LABEL_DEPLOYMENT) == deployment.metadata.name: - continue - if r.spec.clusterName != cluster.metadata.name: + best = None + best_key = None + for cluster in clusters: + if not _cluster_ready(cluster): continue - if not r.spec.workers: + eligible = _eligible_pool(cluster, shape, requests, ledger) + if eligible is None: continue - used += topology_shape(r.spec.workers).total_gpus - return used + pool_name, resolved = eligible + key = (load.get(cluster.metadata.name, 0), cluster.metadata.name) + if best_key is None or key < best_key: + best_key = key + best = (cluster, pool_name, resolved) + return best + + +def _lowest_free_index(used: set[int]) -> int: + """The smallest non-negative integer not in used.""" + i = 0 + while i in used: + i += 1 + return i + + +def _gateway_address(cluster: icv1alpha1.InferenceCluster) -> str: + """The cluster's gateway address, or empty when degraded/unset.""" + return (cluster.status.gateway.address if cluster.status.gateway else "") or "" + + +def _scale_down(retained: list[Candidate], desired: int) -> list[Candidate]: + """Drop replicas to reach desired, consolidating off the most-packed clusters. + + Each victim is the highest-index replica on whichever cluster currently + hosts the most of this deployment's replicas. Removing from the most-loaded + cluster first preserves spread (a cluster never loses its sole replica while + another still has two), and taking the highest index there keeps the + survivors' indices dense and stable. Ties between equally-loaded clusters + break by cluster name for determinism. We only remove at the margin; the + survivors are never reshuffled. + """ + survivors = list(retained) + while len(survivors) > desired: + load: dict[str, int] = {} + for c in survivors: + load[c.name] = load.get(c.name, 0) + 1 + # Victim: the most-loaded cluster, then the lexicographically later + # cluster name, then the highest index on it. max() picks the largest of + # each in turn, so later names and higher indices are dropped first. + victim = max(survivors, key=lambda c: (load[c.name], c.name, c.index)) + survivors.remove(victim) + return survivors + + +def schedule( + deployment: mdv1alpha1.ModelDeployment, + clusters: list[icv1alpha1.InferenceCluster], + all_replicas: list[mrv1alpha1.ModelReplica], +) -> list[Candidate]: + """Pick clusters for a deployment's ModelReplicas. + + Retains existing replicas on their pinned (cluster, index), then fills any + shortfall by spreading new replicas across clusters (packing onto fewer only + when capacity forces it). Returns up to deployment.spec.replicas candidates, + fewer if not enough capacity exists. + """ + desired = int(deployment.spec.replicas) + shape = topology_shape(deployment.spec.workers) + clusters_by_name = {c.metadata.name: c for c in clusters} + + # Compile every nodeSelector request's selectors once and reuse them across + # every pool of every cluster. Raises CELCompileError on a malformed + # expression - the caller turns that into a condition. + requests = compile_requests(deployment) + + retained = _retain(deployment, clusters_by_name, all_replicas, requests) + + if len(retained) > desired: + retained = _scale_down(retained, desired) + + # Build the ledger AFTER retain and scale-down: it charges the replicas in + # the final `retained` set (plus other deployments' replicas), and must not + # charge our dropped or scaled-down replicas, whose nodes are freeing up. + # Fill then decrements it only as it places NEW replicas. + ledger = _build_ledger(deployment, clusters, retained, all_replicas) + + placed: list[Candidate] = [] + if len(retained) < desired: + placed = _fill(shape, clusters, retained, ledger, requests, desired - len(retained)) + + result = retained + placed + result.sort(key=lambda c: (c.name, c.index)) + return result diff --git a/functions/compose-model-deployment/function/semver.py b/functions/compose-model-deployment/function/semver.py new file mode 100644 index 000000000..0d1b8d9f8 --- /dev/null +++ b/functions/compose-model-deployment/function/semver.py @@ -0,0 +1,284 @@ +"""Semantic versions, reimplemented for DRA CEL selectors. + +Mirrors the apiserver Semver CEL library (k8s.io/apiserver/pkg/cel/library/ +semverlib.go), which parses with github.com/blang/semver/v4. Parsing is strict +by default (full major.minor.patch, no "v" prefix, no leading zeros) with a +lenient normalize overload; comparison is precedence-ordered, so prerelease +identifiers are significant and sort before the corresponding release, while +build metadata is ignored. A version() device attribute is pre-parsed strictly, +exactly as upstream pre-parses VersionValue attributes. +""" + +from __future__ import annotations + +from celpy import celtypes + +# Character classes for validation. Numeric identifiers (the release numbers and +# numeric prerelease identifiers) allow only digits; alphanumeric identifiers +# (non-numeric prerelease and build identifiers) also allow letters and the +# hyphen. We validate against explicit sets rather than a regex to mirror blang's +# byte-by-byte checks exactly, including its specific error cases. +_NUMBERS = set("0123456789") +_ALPHANUM = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-0123456789") + +# A semver has exactly three dot-separated release components (major.minor.patch). +_SEMVER_PARTS = 3 + + +def _cmp(a, b) -> int: + # Three-way compare: -1, 0, or +1, like Go's cmp/blang's Compare. Python has + # no built-in spaceship operator; (a > b) - (a < b) evaluates the two bools + # to 0/1 and subtracts, yielding -1/0/+1 without branching. + return (a > b) - (a < b) + + +def _has_leading_zeroes(s: str) -> bool: + # A numeric identifier may not have a leading zero (semver spec: "1.01.0" is + # invalid). "0" itself is fine - a single zero is not a "leading" zero - so we + # only flag a leading '0' when there's more than one character. + return len(s) > 1 and s[0] == "0" + + +def _contains_only(s: str, allowed: set[str]) -> bool: + # True when s is non-empty and every character is in the allowed set. The + # non-empty check matters: an empty identifier (e.g. the "" between two dots + # in "1..0", or a trailing "-") is always invalid, never vacuously valid. + return len(s) > 0 and all(c in allowed for c in s) + + +class _PRVersion: + """A single prerelease identifier (one dot-separated piece of the prerelease). + + A prerelease like "rc.1" is a list of these: "rc" (alphanumeric) and "1" + (numeric). Each identifier is one of two kinds, and the kind decides ordering: + a purely numeric identifier sorts before an alphanumeric one, numeric + identifiers compare as integers, and alphanumeric identifiers compare as ASCII + strings (semver spec rule 11). We store the kind in is_num and keep the value + in whichever of num/string applies; the other is left at its zero value. + """ + + # __slots__ avoids a per-instance __dict__: a prerelease can have several + # identifiers and these are created on every parse, so the memory and + # attribute-access savings are worth the rigidity. + __slots__ = ("is_num", "num", "string") + + def __init__(self, s: str): + # An empty identifier (e.g. from "1.0.0-" or "1.0.0-a..b") is invalid. + if s == "": + raise ValueError("prerelease is empty") + # All-digits -> numeric identifier. Checked first because "123" is both + # all-numeric and all-alphanumeric, and the numeric kind takes precedence. + if _contains_only(s, _NUMBERS): + if _has_leading_zeroes(s): + raise ValueError(f"numeric prerelease must not contain leading zeroes: {s!r}") + self.num = int(s) + self.is_num = True + self.string = "" + # Otherwise it must be a valid alphanumeric identifier (letters, digits, + # hyphen). num is unused for this kind, so it's parked at 0. + elif _contains_only(s, _ALPHANUM): + self.num = 0 + self.is_num = False + self.string = s + else: + raise ValueError(f"invalid character(s) in prerelease: {s!r}") + + def compare(self, o: _PRVersion) -> int: + if self.is_num and not o.is_num: + return -1 + if not self.is_num and o.is_num: + return 1 + if self.is_num and o.is_num: + return _cmp(self.num, o.num) + # Python's str comparison is codepoint order, which matches blang's byte + # comparison over the ASCII subset a valid identifier is restricted to. + return _cmp(self.string, o.string) + + +class Semver: + """A semantic version parsed per github.com/blang/semver/v4. + + Comparison is precedence-ordered: major, minor, patch, then prerelease + (a version with no prerelease outranks one with a prerelease; identifiers + compare element-wise; a longer prerelease list wins when its prefix is + equal). Build metadata is ignored for ordering. + """ + + # Build metadata is intentionally NOT stored: it's parsed and validated (a + # malformed build is a parse error) but ignored for ordering and equality, so + # there's nothing to keep. pre is the list of _PRVersion identifiers, empty for + # a release version with no prerelease. + __slots__ = ("major", "minor", "patch", "pre") + + def __init__(self, major: int, minor: int, patch: int, pre: list[_PRVersion]): + self.major = major + self.minor = minor + self.patch = patch + self.pre = pre + + def compare(self, o: Semver) -> int: + # Major, then minor, then patch; return at the first that differs. + for a, b in ((self.major, o.major), (self.minor, o.minor), (self.patch, o.patch)): + if a != b: + return _cmp(a, b) + # Release numbers equal - the prerelease breaks the tie. No prerelease + # outranks a prerelease (1.0.0 > 1.0.0-rc1). + if not self.pre and not o.pre: + return 0 + if not self.pre: + return 1 + if not o.pre: + return -1 + # strict=False is REQUIRED, not a lazy default: the lists are expected to + # differ in length (the longer-list rule below), and strict=True would + # raise instead of stopping at the end of the shorter one. + for pa, pb in zip(self.pre, o.pre, strict=False): + c = pa.compare(pb) + if c != 0: + return c + # Shared identifiers all equal, so the longer prerelease list wins + # (1.0.0-rc.1 < 1.0.0-rc.1.1). + return _cmp(len(self.pre), len(o.pre)) + + # Equality and hashing are defined together (an object that defines __eq__ + # without __hash__ becomes unhashable) so a Semver can be a dict key / set + # member, and so == agrees with precedence comparison. + def __eq__(self, other) -> bool: + # Equal iff precedence-equal. Note this means build metadata doesn't + # affect equality, consistent with it being ignored for ordering. + return isinstance(other, Semver) and self.compare(other) == 0 + + def __hash__(self) -> int: + # Hash over the same fields compare() looks at, so equal versions hash + # equal. Each _PRVersion is reduced to its (is_num, num, string) triple + # because _PRVersion has no __hash__ of its own; the outer tuple makes the + # whole thing hashable. + return hash((self.major, self.minor, self.patch, tuple((p.is_num, p.num, p.string) for p in self.pre))) + + +def parse(s: str) -> Semver: + """Strict parse per blang/semver Parse: full major.minor.patch. + + This is the form used to pre-parse version() device attributes. + """ + s = str(s) + if s == "": + raise ValueError("version string empty") + parts = s.split(".", 2) + if len(parts) != _SEMVER_PARTS: + raise ValueError("no Major.Minor.Patch elements found") + + def num(component: str, label: str) -> int: + if not _contains_only(component, _NUMBERS): + raise ValueError(f"invalid character(s) in {label} number: {component!r}") + if _has_leading_zeroes(component): + raise ValueError(f"{label} number must not contain leading zeroes: {component!r}") + return int(component) + + major = num(parts[0], "major") + minor = num(parts[1], "minor") + + # The third dotted component carries the patch plus the optional suffixes: + # patch[-prerelease][+build] (major and minor are bare numbers). Peel them off + # to leave a clean patch number. Build is split off BEFORE prerelease and on + # the FIRST delimiter, both deliberately: build metadata may itself contain + # '-' (e.g. 3+build-7), so removing "+build" first stops that '-' being + # mistaken for the prerelease delimiter; and the first '-' that remains is the + # prerelease delimiter, while any later '-' belongs to a prerelease identifier + # (e.g. 3-alpha-beta -> "alpha-beta"). Each suffix is optional, so find() + # returning -1 leaves the corresponding string empty. + patch_str = parts[2] + build = "" + pre_str = "" + bi = patch_str.find("+") + if bi != -1: + build = patch_str[bi + 1 :] + patch_str = patch_str[:bi] + pi = patch_str.find("-") + if pi != -1: + pre_str = patch_str[pi + 1 :] + patch_str = patch_str[:pi] + patch = num(patch_str, "patch") + + pre = [_PRVersion(p) for p in pre_str.split(".")] if pre_str else [] + + # Validate build metadata identifiers (ignored for ordering, but a malformed + # one is a parse error upstream). + if build: + for ident in build.split("."): + if ident == "" or not _contains_only(ident, _ALPHANUM): + raise ValueError(f"invalid build metadata: {ident!r}") + + return Semver(major, minor, patch, pre) + + +def _normalize_and_parse(s: str) -> Semver: + """Parse per the DRA library's normalizeAndParse (lenient). + + Like blang ParseTolerant but does NOT trim whitespace: strips a leading "v", + splits into <=3 parts, strips leading zeros per part, fills missing trailing + parts with "0" (a shortened version may not carry prerelease/build), then + parses strictly. + """ + s = str(s) + if s.startswith("v"): + s = s[1:] + parts = s.split(".", 2) + for i, part in enumerate(parts): + if len(part) > 1: + stripped = part.lstrip("0") + if len(stripped) == 0 or stripped[0] not in "0123456789": + stripped = "0" + stripped + parts[i] = stripped + if len(parts) < _SEMVER_PARTS: + if any(c in "+-" for c in parts[-1]): + raise ValueError("short version cannot contain PreRelease/Build meta data") + while len(parts) < _SEMVER_PARTS: + parts.append("0") + return parse(".".join(parts)) + + +def semver(s, normalize=None) -> Semver: + """The CEL semver([, ]) constructor.""" + if normalize is not None and bool(normalize): + return _normalize_and_parse(s) + return parse(s) + + +def is_semver(s, normalize=None) -> celtypes.BoolType: + """The CEL isSemver([, ]) predicate. + + Returns false for any parse failure, mirroring upstream (isSemver is true + iff semver() would not error). + """ + try: + semver(s, normalize) + except Exception: # noqa: BLE001 - any parse failure is "not a semver" + valid = False + else: + valid = True + return celtypes.BoolType(valid) + + +def compare_to(a: Semver, b: Semver) -> celtypes.IntType: + return celtypes.IntType(a.compare(b)) + + +def is_greater_than(a: Semver, b: Semver) -> celtypes.BoolType: + return celtypes.BoolType(a.compare(b) == 1) + + +def is_less_than(a: Semver, b: Semver) -> celtypes.BoolType: + return celtypes.BoolType(a.compare(b) == -1) + + +def major(a: Semver) -> celtypes.IntType: + return celtypes.IntType(a.major) + + +def minor(a: Semver) -> celtypes.IntType: + return celtypes.IntType(a.minor) + + +def patch(a: Semver) -> celtypes.IntType: + return celtypes.IntType(a.patch) diff --git a/functions/compose-model-deployment/pyproject.toml b/functions/compose-model-deployment/pyproject.toml index 317cf3273..f63e35896 100644 --- a/functions/compose-model-deployment/pyproject.toml +++ b/functions/compose-model-deployment/pyproject.toml @@ -9,9 +9,10 @@ description = "Fan out a ModelDeployment to ModelReplicas and ModelEndpoints." requires-python = ">=3.11,<3.14" license = "Apache-2.0" dependencies = [ - "crossplane-function-sdk-python>=0.12.0", + "crossplane-function-sdk-python>=0.13.0", "click>=8.1.0", "grpcio>=1.73.1", + "cel-python>=0.3.0", "crossplane-models", ] diff --git a/functions/compose-model-deployment/tests/oracle/go.mod b/functions/compose-model-deployment/tests/oracle/go.mod new file mode 100644 index 000000000..428f4e443 --- /dev/null +++ b/functions/compose-model-deployment/tests/oracle/go.mod @@ -0,0 +1,55 @@ +module github.com/modelplaneai/modelplane/functions/compose-model-deployment/tests/oracle + +go 1.25.0 + +require ( + k8s.io/api v0.33.0 + k8s.io/apimachinery v0.33.0 + k8s.io/dynamic-resource-allocation v0.33.0 +) + +require ( + cel.dev/expr v0.25.1 // indirect + github.com/antlr4-go/antlr/v4 v4.13.0 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/blang/semver/v4 v4.0.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/cel-go v0.26.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.67.5 // indirect + github.com/prometheus/procfs v0.19.2 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/stoewer/go-strcase v1.3.0 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.opentelemetry.io/otel v1.41.0 // indirect + go.opentelemetry.io/otel/trace v1.41.0 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.33.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + k8s.io/apiserver v0.33.0 // indirect + k8s.io/component-base v0.33.0 // indirect + k8s.io/klog/v2 v2.140.0 // indirect + k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect + k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect + sigs.k8s.io/yaml v1.4.0 // indirect +) diff --git a/functions/compose-model-deployment/tests/oracle/go.sum b/functions/compose-model-deployment/tests/oracle/go.sum new file mode 100644 index 000000000..d0ff26781 --- /dev/null +++ b/functions/compose-model-deployment/tests/oracle/go.sum @@ -0,0 +1,165 @@ +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= +github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/google/cel-go v0.26.0 h1:DPGjXackMpJWH680oGY4lZhYjIameYmR+/6RBdDGmaI= +github.com/google/cel-go v0.26.0/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= +github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= +github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= +go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= +go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= +go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0= +golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.33.0 h1:yTgZVn1XEe6opVpP1FylmNrIFWuDqe2H0V8CT5gxfIU= +k8s.io/api v0.33.0/go.mod h1:CTO61ECK/KU7haa3qq8sarQ0biLq2ju405IZAd9zsiM= +k8s.io/apimachinery v0.33.0 h1:1a6kHrJxb2hs4t8EE5wuR/WxKDwGN1FKH3JvDtA0CIQ= +k8s.io/apimachinery v0.33.0/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM= +k8s.io/apiserver v0.33.0 h1:QqcM6c+qEEjkOODHppFXRiw/cE2zP85704YrQ9YaBbc= +k8s.io/apiserver v0.33.0/go.mod h1:EixYOit0YTxt8zrO2kBU7ixAtxFce9gKGq367nFmqI8= +k8s.io/component-base v0.33.0 h1:Ot4PyJI+0JAD9covDhwLp9UNkUja209OzsJ4FzScBNk= +k8s.io/component-base v0.33.0/go.mod h1:aXYZLbw3kihdkOPMDhWbjGCO6sg+luw554KP51t8qCU= +k8s.io/dynamic-resource-allocation v0.33.0 h1:/b04omcFsLBf2vqh/t1aXhj8fs2eHgKIZzUS/y3Z2qo= +k8s.io/dynamic-resource-allocation v0.33.0/go.mod h1:BHw7vU8bheqj6sdz31gETuvjQkNOcEleBTnrJ9j8vA8= +k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= +k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= +k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg= +k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= +k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU= +k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v4 v4.6.0 h1:IUA9nvMmnKWcj5jl84xn+T5MnlZKThmUW1TdblaLVAc= +sigs.k8s.io/structured-merge-diff/v4 v4.6.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= +sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= +sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/functions/compose-model-deployment/tests/oracle/main.go b/functions/compose-model-deployment/tests/oracle/main.go new file mode 100644 index 000000000..acb07f70d --- /dev/null +++ b/functions/compose-model-deployment/tests/oracle/main.go @@ -0,0 +1,241 @@ +// Command oracle is a parity oracle for the quantity, semver, and cel Python +// modules. It runs inputs through the real Kubernetes code those modules +// reimplement and prints what upstream actually does: +// +// - parse mode uses k8s.io/apimachinery's resource.ParseQuantity (the oracle +// for quantity.py's parsing, canonical form, and int64 saturation); +// - match mode uses k8s.io/dynamic-resource-allocation/cel - the exact CEL env +// a real DRA device selector compiles against, carrying the quantity() and +// semver() libraries and the typed `device` input (the oracle for cel.py, +// and for the quantity()/semver() CEL surface). It evaluates each selector +// against a device, optionally one supplied as JSON. +// +// It is a DEVELOPER TOOL, not part of the test suite. The committed Python +// tests (test_quantity.py, test_semver.py, test_cel.py) pin upstream-correct +// answers as a regression guard; this oracle is how you DISCOVER those answers +// when you change one of the modules or bump the target Kubernetes version. +// Run it, then transcribe its answers into the test tables. +// +// The parity target is the Kubernetes version pinned in go.mod (see +// k8s.io/apimachinery, k8s.io/apiserver, and k8s.io/dynamic-resource-allocation). +// Bump those to retarget. +// +// Usage (Go is not in the dev shell - pull it in ad hoc): +// +// cd functions/compose-model-deployment/tests/oracle +// +// # Quantity parsing: is each string a valid resource.Quantity, and what is +// # its canonical form? One quantity string per line on stdin. +// printf '256E\n10Ei\n8Ei\nMi\n' | nix shell nixpkgs#go --command go run . parse +// +// # Selector evaluation, no device: deviceless quantity()/semver() checks and +// # domain-presence return a real bool; an expression reading a concrete +// # attribute/capacity value gets eval-error (the value is unbound). +// printf 'isQuantity("256E")\nsign(quantity("50k")) == 1\n' | \ +// nix shell nixpkgs#go --command go run . match +// +// # Selector evaluation against a device (leading JSON arg): attribute and +// # capacity reads now return real bools. +// DEV='{"driver":"gpu.nvidia.com","attributes":{"architecture":{"string":"Hopper"}},"capacity":{"memory":{"value":"141Gi"}}}' +// printf 'device.attributes["gpu.nvidia.com"].architecture == "Hopper"\n' | \ +// nix shell nixpkgs#go --command go run . match "$DEV" +// +// Inputs may also be passed as arguments instead of on stdin: +// +// nix shell nixpkgs#go --command go run . parse 256E 10Ei 8Ei +package main + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "os" + "strings" + + resourceapi "k8s.io/api/resource/v1beta1" + "k8s.io/apimachinery/pkg/api/resource" + dracel "k8s.io/dynamic-resource-allocation/cel" +) + +func main() { + if len(os.Args) < 2 { + usage() + } + mode := os.Args[1] + + switch mode { + case "parse": + runParse(readInputs(os.Args[2:])) + case "match": + // An optional leading device JSON (starts with '{') sets the device to + // match against; without it the device is empty, which is all a + // deviceless quantity()/semver() check needs. + args := os.Args[2:] + device := dracel.Device{} + if len(args) > 0 && strings.HasPrefix(strings.TrimSpace(args[0]), "{") { + device = parseDevice(args[0]) + args = args[1:] + } + runMatch(readInputs(args), device) + default: + usage() + } +} + +func usage() { + fmt.Fprintln(os.Stderr, "usage:") + fmt.Fprintln(os.Stderr, " oracle parse [quantity...] -> ok / canonical form") + fmt.Fprintln(os.Stderr, " oracle match [device-json] [expr...] -> bool / error") + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "quantity/expr inputs come from args, or one per line on stdin if none given.") + fmt.Fprintln(os.Stderr, "match takes an OPTIONAL leading device JSON; without one the device is empty") + fmt.Fprintln(os.Stderr, "(enough for deviceless quantity()/semver() checks). device-json shape:") + fmt.Fprintln(os.Stderr, " {\"driver\":\"d\",\"attributes\":{\"name\":{\"string|bool|int|version\":v}},") + fmt.Fprintln(os.Stderr, " \"capacity\":{\"name\":{\"value\":\"\"}}}") + os.Exit(2) +} + +// readInputs returns args verbatim, or each non-empty line of stdin when no +// args are given. +func readInputs(args []string) []string { + if len(args) > 0 { + return args + } + var in []string + sc := bufio.NewScanner(os.Stdin) + sc.Buffer(make([]byte, 1<<20), 1<<20) + for sc.Scan() { + line := sc.Text() + if strings.TrimSpace(line) == "" { + continue + } + in = append(in, line) + } + return in +} + +// runParse reports, for each input, whether resource.ParseQuantity accepts it +// and the parsed Quantity's canonical String(). This is the oracle for +// quantity.py's parse()/isQuantity(). +func runParse(inputs []string) { + var b strings.Builder + for _, in := range inputs { + q, err := resource.ParseQuantity(in) + if err != nil { + fmt.Fprintf(&b, "%-28q reject\n", in) + continue + } + fmt.Fprintf(&b, "%-28q ok %s\n", in, q.String()) + } + emit(b.String()) +} + +// runMatch compiles and evaluates each CEL expression through the DRA device +// selector compiler (k8s.io/dynamic-resource-allocation/cel) - the exact env a +// real DRA selector compiles against, and the env cel.py/quantity.py/semver.py +// reimplement. Using it (rather than a hand-built cel-go env) gets four things +// right at once: +// +// - the quantity() and semver() libraries, including semver's normalize +// overload (which the bare apiserver SemverLib() leaves off by default); +// - upstream's STRICT call surface, so e.g. quantity(...).sign() is a compile +// error (sign is a global function) where celpy accepts it; +// - the real `device` OBJECT type, so has(device.attributes["domain"]) is a +// compile error - cel-go's has() macro rejects an index argument, and only +// upstream's declared type reproduces that; +// - real attribute/capacity matching, when a device is supplied. +// +// Each expression is evaluated against the given device. An empty Device{} +// still answers deviceless quantity()/semver() checks and domain-presence ("x" +// in device.attributes); an expression that reads a concrete attribute/capacity +// value needs a device that carries it (else eval-error - the value is unbound). +func runMatch(inputs []string, device dracel.Device) { + // GetCompiler returns a cached singleton, so calling it per expression is + // cheap; its concrete type is unexported, so we use it inline rather than + // passing it around. At the pinned k8s version it takes no feature gates and + // compiles against the default-off DRA surface, which is the subset cel.py + // reimplements. + c := dracel.GetCompiler() + var b strings.Builder + for _, expr := range inputs { + // DisableCostEstimation: we want parity of the result, not the + // worst-case cost estimate. + result := c.CompileCELExpression(expr, dracel.Options{DisableCostEstimation: true}) + b.WriteString(fmt.Sprintf("%-60s %s\n", expr, evalResult(result, device))) + } + emit(b.String()) +} + +func evalResult(result dracel.CompilationResult, device dracel.Device) string { + if result.Error != nil { + return "compile-error" + } + matches, _, err := result.DeviceMatches(context.Background(), device) + if err != nil { + return "eval-error" + } + if matches { + return "true" + } + return "false" +} + +// deviceJSON is the wire shape of a `match`-mode device. It mirrors the dict +// cel.py's matches() consumes (and the test _device(...) fixtures produce): an +// attribute is exactly one of string/bool/int/version; a capacity is a quantity +// string under "value". A qualified attribute/capacity name ("domain/id") lands +// under its own domain; a bare name defaults to the driver's domain (upstream's +// parseQualifiedName, which DeviceMatches applies). +type deviceJSON struct { + Driver string `json:"driver"` + Attributes map[string]attributeJSON `json:"attributes"` + Capacity map[string]map[string]string `json:"capacity"` +} + +type attributeJSON struct { + String *string `json:"string"` + Bool *bool `json:"bool"` + Int *int64 `json:"int"` + Version *string `json:"version"` +} + +// parseDevice builds a DRA Device from the match-mode JSON. It exits on bad +// input rather than returning an error - it's a dev tool, and a malformed device +// is a usage mistake to surface immediately. +func parseDevice(s string) dracel.Device { + var dj deviceJSON + if err := json.Unmarshal([]byte(s), &dj); err != nil { + fmt.Fprintln(os.Stderr, "parse device JSON:", err) + os.Exit(2) + } + + dev := dracel.Device{ + Driver: dj.Driver, + Attributes: make(map[resourceapi.QualifiedName]resourceapi.DeviceAttribute, len(dj.Attributes)), + Capacity: make(map[resourceapi.QualifiedName]resourceapi.DeviceCapacity, len(dj.Capacity)), + } + for name, a := range dj.Attributes { + dev.Attributes[resourceapi.QualifiedName(name)] = resourceapi.DeviceAttribute{ + StringValue: a.String, + BoolValue: a.Bool, + IntValue: a.Int, + VersionValue: a.Version, + } + } + for name, c := range dj.Capacity { + dev.Capacity[resourceapi.QualifiedName(name)] = resourceapi.DeviceCapacity{ + Value: resource.MustParse(c["value"]), + } + } + return dev +} + +// emit writes the assembled output to stdout, failing loudly if the write does. +func emit(s string) { + if _, err := io.WriteString(os.Stdout, s); err != nil { + fmt.Fprintln(os.Stderr, "write:", err) + os.Exit(1) + } +} diff --git a/functions/compose-model-deployment/tests/test_cel.py b/functions/compose-model-deployment/tests/test_cel.py new file mode 100644 index 000000000..e235eda50 --- /dev/null +++ b/functions/compose-model-deployment/tests/test_cel.py @@ -0,0 +1,311 @@ +"""Tests for the DRA CEL selector module. + +Pins Program.matches - the device activation shape, qualified-name domains, +unknown-domain handling, and quantity()/semver() dispatch - against upstream +DRA behavior (k8s.io/dynamic-resource-allocation/cel). Table-driven: each case +is one (selector expression, device, want). +""" + +import dataclasses +import unittest + +from function import cel + + +def _device(driver="gpu.nvidia.com", attributes=None, capacity=None, **extra) -> dict: + """A pool device in the raw dict shape cel.Program.matches expects.""" + return { + "driver": driver, + "attributes": attributes or {}, + "capacity": capacity or {}, + **extra, + } + + +@dataclasses.dataclass +class Case: + name: str + expr: str + device: dict + want: bool + + +# Reusable device fixtures. +_GPU = _device( + driver="gpu.nvidia.com", + attributes={ + "architecture": {"string": "Hopper"}, + "cudaComputeCapability": {"version": "9.5.3"}, + # A qualified name lands under its own domain, not the driver's. + "resource.kubernetes.io/pcieRoot": {"string": "pci0"}, + }, + capacity={"memory": {"value": "141Gi"}}, +) +_NIC = _device(driver="nic.nvidia.com", attributes={"linkType": {"string": "infiniband"}}) + +_ATTR = 'device.attributes["gpu.nvidia.com"]' +_CAP = 'device.capacity["gpu.nvidia.com"]' + +# Device fixtures for the verbatim selector examples in the DRA docs. +# (k8s.io/docs concept page and the allocate-devices-dra task page.) +_LARGE_BLACK = _device( + driver="resource-driver.example.com", + attributes={"color": {"string": "black"}, "size": {"string": "large"}}, +) +_SMALL_WHITE = _device( + driver="resource-driver.example.com", + attributes={"color": {"string": "white"}, "size": {"string": "small"}}, +) +_EXAMPLE_GPU = _device( + driver="gpu.example.com", + attributes={"type": {"string": "gpu"}}, +) +_GPU_64GI = _device( + driver="driver.example.com", + attributes={"type": {"string": "gpu"}}, + capacity={"memory": {"value": "64Gi"}}, +) + + +class TestMatches(unittest.TestCase): + def test_matches(self) -> None: + cases = [ + # driver. + Case(name="driver equals", expr='device.driver == "gpu.nvidia.com"', device=_GPU, want=True), + Case(name="driver not equals", expr='device.driver == "nic.nvidia.com"', device=_GPU, want=False), + # Quantity comparison + methods. + Case( + name="quantity compareTo ge", + expr=f'{_CAP}.memory.compareTo(quantity("141Gi")) >= 0', + device=_GPU, + want=True, + ), + Case( + name="quantity compareTo too big", + expr=f'{_CAP}.memory.compareTo(quantity("200Gi")) >= 0', + device=_GPU, + want=False, + ), + Case( + name="quantity isGreaterThan", + expr=f'{_CAP}.memory.isGreaterThan(quantity("80Gi"))', + device=_GPU, + want=True, + ), + Case( + name="quantity isLessThan", expr=f'{_CAP}.memory.isLessThan(quantity("200Gi"))', device=_GPU, want=True + ), + Case(name="quantity sign", expr=f"{_CAP}.memory.sign() == 1", device=_GPU, want=True), + Case(name="quantity asInteger", expr=f"{_CAP}.memory.asInteger() == {141 * 2**30}", device=_GPU, want=True), + Case(name="quantity isInteger", expr=f"{_CAP}.memory.isInteger()", device=_GPU, want=True), + Case( + name="quantity add", + expr=f'{_CAP}.memory.add(quantity("1Gi")).compareTo(quantity("142Gi")) == 0', + device=_GPU, + want=True, + ), + Case(name="isQuantity true", expr='isQuantity("1.3Gi")', device=_GPU, want=True), + Case(name="isQuantity false", expr='isQuantity("200K")', device=_GPU, want=False), + # Semver comparison + methods. + Case( + name="semver isGreaterThan", + expr=f'{_ATTR}.cudaComputeCapability.isGreaterThan(semver("9.0.0"))', + device=_GPU, + want=True, + ), + Case( + name="semver not greater", + expr=f'{_ATTR}.cudaComputeCapability.isGreaterThan(semver("9.9.0"))', + device=_GPU, + want=False, + ), + Case(name="semver major", expr=f"{_ATTR}.cudaComputeCapability.major() == 9", device=_GPU, want=True), + Case(name="semver minor", expr=f"{_ATTR}.cudaComputeCapability.minor() == 5", device=_GPU, want=True), + Case(name="semver patch", expr=f"{_ATTR}.cudaComputeCapability.patch() == 3", device=_GPU, want=True), + Case( + name="semver equality", expr=f'{_ATTR}.cudaComputeCapability == semver("9.5.3")', device=_GPU, want=True + ), + Case(name="isSemver strict true", expr='isSemver("1.0.0")', device=_GPU, want=True), + Case(name="isSemver strict rejects short", expr='isSemver("1.0")', device=_GPU, want=False), + Case(name="isSemver normalize accepts short", expr='isSemver("1.0", true)', device=_GPU, want=True), + Case(name="semver normalize overload", expr='semver("v1.0", true).major() == 1', device=_GPU, want=True), + # Typed scalar attributes (resolve straight to the value, no .string). + Case(name="string attribute", expr=f'{_ATTR}.architecture == "Hopper"', device=_GPU, want=True), + Case( + name="string attribute mismatch", + expr='device.attributes["nic.nvidia.com"].linkType == "infiniband"', + device=_NIC, + want=True, + ), + Case( + name="bool attribute true", + expr=f"{_ATTR}.x", + device=_device(attributes={"x": {"bool": True}}), + want=True, + ), + Case( + name="bool attribute false", + expr=f"{_ATTR}.x", + device=_device(attributes={"x": {"bool": False}}), + want=False, + ), + Case( + name="int attribute", + expr=f"{_ATTR}.x >= 8", + device=_device(attributes={"x": {"int": 8}}), + want=True, + ), + Case( + name="int attribute below", + expr=f"{_ATTR}.x >= 8", + device=_device(attributes={"x": {"int": 4}}), + want=False, + ), + # Qualified names split into their own domain. + Case( + name="qualified name under its domain", + expr='device.attributes["resource.kubernetes.io"].pcieRoot == "pci0"', + device=_GPU, + want=True, + ), + Case( + name="bare name under driver domain", expr=f'{_ATTR}.architecture == "Hopper"', device=_GPU, want=True + ), + # Non-matches that must not raise. + Case( + name="two-component version is non-match", + expr=f'{_ATTR}.cudaComputeCapability.isGreaterThan(semver("8.0.0"))', + device=_device(attributes={"cudaComputeCapability": {"version": "9.0"}}), + want=False, + ), + Case( + name="malformed quantity is non-match", + expr=f'{_CAP}.memory.compareTo(quantity("1Gi")) >= 0', + device=_device(capacity={"memory": {"value": "10Mo"}}), + want=False, + ), + Case(name="unknown id is non-match", expr=f'{_ATTR}.nope == "x"', device=_GPU, want=False), + # A non-bool selector must not spuriously match. Upstream rejects it + # at compile time; we treat a non-bool result as a non-match. + Case(name="non-bool string selector is non-match", expr='"5"', device=_GPU, want=False), + Case( + name="non-bool int selector is non-match", + expr=f"{_ATTR}.x", + device=_device(attributes={"x": {"int": 5}}), + want=False, + ), + # Domain presence. Upstream's domain-presence idiom is "" in + # device.attributes, not has(device.attributes[""]): cel-go's + # has() macro rejects an index argument, so the has() form is a + # compile error on a real cluster (celpy accepts it - see cel.py's + # documented divergences). An unknown domain is simply absent (False), + # not present-but-empty. + Case(name="unknown domain absent", expr='"other.com" in device.attributes', device=_GPU, want=False), + Case(name="known domain present", expr='"gpu.nvidia.com" in device.attributes', device=_GPU, want=True), + # Reading an unknown domain resolves to an empty map (not an error), + # so an id lookup under it is a non-match rather than a failure. + Case( + name="unknown domain id is non-match", + expr='device.attributes["other.com"].x == "y"', + device=_GPU, + want=False, + ), + # Guard a domain read with the in idiom before indexing it. + Case( + name="guarded known domain", + expr=f'"gpu.nvidia.com" in device.attributes && {_ATTR}.architecture == "Hopper"', + device=_GPU, + want=True, + ), + # The full design selector. + Case( + name="full design expression", + expr=( + f'{_ATTR}.cudaComputeCapability.isGreaterThan(semver("9.0.0")) && ' + f'{_CAP}.memory.compareTo(quantity("141Gi")) >= 0' + ), + device=_GPU, + want=True, + ), + # Verbatim selector examples from the DRA docs, each against a device + # that should and should not match. + Case( + name="docs: large-black subrequest matches", + expr=( + 'device.attributes["resource-driver.example.com"].color == "black" && ' + 'device.attributes["resource-driver.example.com"].size == "large"' + ), + device=_LARGE_BLACK, + want=True, + ), + Case( + name="docs: large-black subrequest rejects small-white", + expr=( + 'device.attributes["resource-driver.example.com"].color == "black" && ' + 'device.attributes["resource-driver.example.com"].size == "large"' + ), + device=_SMALL_WHITE, + want=False, + ), + Case( + name="docs: small-white subrequest matches", + expr=( + 'device.attributes["resource-driver.example.com"].color == "white" && ' + 'device.attributes["resource-driver.example.com"].size == "small"' + ), + device=_SMALL_WHITE, + want=True, + ), + Case( + name="docs: extended-resource DeviceClass selector matches", + expr="device.driver == 'gpu.example.com' && device.attributes['gpu.example.com'].type == 'gpu'", + device=_EXAMPLE_GPU, + want=True, + ), + Case( + name="docs: extended-resource DeviceClass selector rejects other driver", + expr="device.driver == 'gpu.example.com' && device.attributes['gpu.example.com'].type == 'gpu'", + device=_NIC, + want=False, + ), + Case( + name="docs: ResourceClaim type+memory selector matches", + expr=( + 'device.attributes["driver.example.com"].type == "gpu" && ' + 'device.capacity["driver.example.com"].memory == quantity("64Gi")' + ), + device=_GPU_64GI, + want=True, + ), + Case( + name="docs: ResourceClaim type+memory selector rejects wrong memory", + expr=( + 'device.attributes["driver.example.com"].type == "gpu" && ' + 'device.capacity["driver.example.com"].memory == quantity("64Gi")' + ), + device=_device( + driver="driver.example.com", + attributes={"type": {"string": "gpu"}}, + capacity={"memory": {"value": "32Gi"}}, + ), + want=False, + ), + ] + for case in cases: + with self.subTest(case.name): + got = cel.compile_selector(case.expr).matches(case.device) + self.assertEqual(case.want, got, f"{case.name}: -want, +got") + + +class TestCompile(unittest.TestCase): + def test_compile_selector_none(self) -> None: + self.assertIsNone(cel.compile_selector(None)) + self.assertIsNone(cel.compile_selector("")) + + def test_invalid_expression_raises(self) -> None: + with self.assertRaises(cel.CELCompileError): + cel.compile_selector("not ) valid (") + + +if __name__ == "__main__": + unittest.main() diff --git a/functions/compose-model-deployment/tests/test_fn.py b/functions/compose-model-deployment/tests/test_fn.py index 78aac124b..a48b3c428 100644 --- a/functions/compose-model-deployment/tests/test_fn.py +++ b/functions/compose-model-deployment/tests/test_fn.py @@ -9,9 +9,177 @@ from google.protobuf import duration_pb2 as durationpb from google.protobuf import json_format from google.protobuf import struct_pb2 as structpb +from models.ai.modelplane.inferencecluster import v1alpha1 as icv1alpha1 from models.ai.modelplane.modeldeployment import v1alpha1 +from models.ai.modelplane.modelreplica import v1alpha1 as mrv1alpha1 from models.io.k8s.apimachinery.pkg.apis.meta import v1 as metav1 +# The resolved DRA device requests the scheduler stamps onto each ModelReplica, +# derived from the deployment's nodeSelector matched against the cluster's GPU +# device (deviceClassName gpu.nvidia.com). +_DEVICE_REQUESTS = [ + { + "name": "gpu", + "deviceClassName": "gpu.nvidia.com", + "count": 1, + "selectors": [{"cel": 'device.driver == "gpu.nvidia.com"'}], + } +] + +# A one-replica deployment requesting a single GPU. Reused across most cases. +_XR = v1alpha1.ModelDeployment( + metadata=metav1.ObjectMeta(name="my-model", namespace="ml-team"), + spec=v1alpha1.SpecModel( + replicas=1, + nodeSelector=v1alpha1.NodeSelector( + devices=[ + v1alpha1.Device( + name="gpu", + count=1, + selectors=[v1alpha1.Selector(cel='device.driver == "gpu.nvidia.com"')], + ), + ], + ), + workers=v1alpha1.Workers( + topology=v1alpha1.Topology(tensor=1), + template=v1alpha1.Template( + spec=v1alpha1.Spec( + containers=[ + v1alpha1.Container( + name="engine", + image="vllm/vllm-openai:latest", + args=["--model=Qwen/Qwen3-0.6B"], + ), + ], + ), + ), + ), + ), +).model_dump(exclude_none=True, mode="json") + + +def _cluster(name: str, *, ready: bool = True, address: str | None = "10.0.0.1", nodes: int = 2) -> dict: + """An InferenceCluster input fixture, dumped to a dict. + + A ready cluster has a Ready=True condition and a gateway address. ready=False + flips the condition to Unavailable; address=None drops the gateway entirely + (mirroring an offline cluster). nodes=0 yields a pool with no capacity. + """ + return icv1alpha1.InferenceCluster( + metadata=metav1.ObjectMeta(name=name), + spec=icv1alpha1.Spec( + cluster=icv1alpha1.Cluster( + source="Existing", + existing=icv1alpha1.Existing(secretRef=icv1alpha1.SecretRef(name="k")), + ), + ), + status=icv1alpha1.Status( + conditions=[ + icv1alpha1.Condition( + type="Ready", + status="True" if ready else "False", + reason="Available" if ready else "Unavailable", + lastTransitionTime="2025-01-01T00:00:00Z", + ) + ], + gateway=icv1alpha1.Gateway(address=address) if address else None, + providerConfigRef=icv1alpha1.ProviderConfigRef(name=name), + gpuPools=[ + icv1alpha1.GpuPool( + name="default", + nodes=nodes, + devices=[ + icv1alpha1.Device( + name="gpu", + claim="DRA", + driver="gpu.nvidia.com", + deviceClassName="gpu.nvidia.com", + count=1, + ) + ], + ) + ], + ), + ).model_dump(exclude_none=True, mode="json") + + +# A ready cluster with a two-node GPU pool. Reused across most cases. +_CLUSTER_A = _cluster("cluster-a") + +# An existing ModelReplica pinned to cluster-a, observed across cases 5 and 6. +_EXISTING_REPLICA = mrv1alpha1.ModelReplica( + metadata=metav1.ObjectMeta( + name="my-model-5ab63", + namespace="ml-team", + labels={ + "modelplane.ai/replica": "true", + "modelplane.ai/deployment": "my-model", + "modelplane.ai/cluster": "cluster-a", + "modelplane.ai/replica-index": "0", + }, + ), + spec=mrv1alpha1.SpecModel( + clusterName="cluster-a", + nodePoolName="default", + deviceRequests=[ + mrv1alpha1.DeviceRequest( + name="gpu", + deviceClassName="gpu.nvidia.com", + count=1, + selectors=[mrv1alpha1.Selector(cel='device.driver == "gpu.nvidia.com"')], + ), + ], + workers=mrv1alpha1.Workers( + count=1, + topology=mrv1alpha1.Topology(tensor=1, pipeline=1), + template=mrv1alpha1.Template( + spec=mrv1alpha1.Spec( + containers=[mrv1alpha1.Container(name="engine", image="vllm/vllm-openai:latest")], + ), + ), + ), + ), +).model_dump(exclude_none=True, mode="json") + +# The requirements selectors every want echoes back. +_CLUSTER_SEL = fnv1.ResourceSelector(api_version="modelplane.ai/v1alpha1", kind="InferenceCluster") +_CLUSTER_SEL.match_labels.labels.update({"modelplane.ai/cluster": "true"}) +_REPLICA_SEL = fnv1.ResourceSelector(api_version="modelplane.ai/v1alpha1", kind="ModelReplica") +_REPLICA_SEL.match_labels.labels.update({"modelplane.ai/replica": "true"}) + + +def _req(xr: dict, *, clusters: list[dict], replicas: list[dict] | None = None, observed: dict | None = None): + """Build a RunFunctionRequest with the standard required_resources. + + clusters and replicas populate the "clusters" and "all-replicas" required + resources respectively; both keys are always present (empty when there are + no items). observed populates observed.resources. + """ + req = fnv1.RunFunctionRequest( + observed=fnv1.State( + composite=fnv1.Resource(resource=resource.dict_to_struct(xr)), + resources={key: fnv1.Resource(resource=resource.dict_to_struct(r)) for key, r in (observed or {}).items()}, + ), + ) + if clusters: + for c in clusters: + req.required_resources["clusters"].items.append(fnv1.Resource(resource=resource.dict_to_struct(c))) + else: + req.required_resources["clusters"].SetInParent() + if replicas: + for r in replicas: + req.required_resources["all-replicas"].items.append(fnv1.Resource(resource=resource.dict_to_struct(r))) + else: + req.required_resources["all-replicas"].SetInParent() + return req + + +def _want(resp: fnv1.RunFunctionResponse) -> fnv1.RunFunctionResponse: + """Attach the standard requirements selectors to a response.""" + resp.requirements.resources["clusters"].CopyFrom(_CLUSTER_SEL) + resp.requirements.resources["all-replicas"].CopyFrom(_REPLICA_SEL) + return resp + @dataclasses.dataclass class Case: @@ -33,13 +201,24 @@ class TestFunctionRunner(unittest.IsolatedAsyncioTestCase): def setUpClass(cls) -> None: cls.runner = fn.FunctionRunner() - async def test_compose(self) -> None: # noqa: PLR0915 + async def test_compose(self) -> None: """The function fans out ModelReplicas and ModelEndpoints.""" - xr = v1alpha1.ModelDeployment( + # A deployment that sets spec.modelCacheRef. + xr_cached = v1alpha1.ModelDeployment( metadata=metav1.ObjectMeta(name="my-model", namespace="ml-team"), spec=v1alpha1.SpecModel( replicas=1, + modelCacheRef=v1alpha1.ModelCacheRef(name="qwen"), + nodeSelector=v1alpha1.NodeSelector( + devices=[ + v1alpha1.Device( + name="gpu", + count=1, + selectors=[v1alpha1.Selector(cel='device.driver == "gpu.nvidia.com"')], + ), + ], + ), workers=v1alpha1.Workers( topology=v1alpha1.Topology(tensor=1), template=v1alpha1.Template( @@ -57,742 +236,714 @@ async def test_compose(self) -> None: # noqa: PLR0915 ), ).model_dump(exclude_none=True, mode="json") - cluster_a = { - "apiVersion": "modelplane.ai/v1alpha1", - "kind": "InferenceCluster", - "metadata": {"name": "cluster-a"}, - "spec": { - "cluster": {"source": "Existing", "existing": {"secretRef": {"name": "k"}}}, - }, - "status": { - "conditions": [ - { - "type": "Ready", - "status": "True", - "reason": "Available", - "lastTransitionTime": "2025-01-01T00:00:00Z", - } - ], - "gateway": {"address": "10.0.0.1"}, - "providerConfigRef": {"name": "cluster-a"}, - "capacity": {"gpuPools": [{"countPerNode": 1, "nodes": 2}]}, - }, - } - - # Requirements are the same for all cases that reach resolve_inputs. - cluster_sel = fnv1.ResourceSelector(api_version="modelplane.ai/v1alpha1", kind="InferenceCluster") - cluster_sel.match_labels.labels.update({"modelplane.ai/cluster": "true"}) - replica_sel = fnv1.ResourceSelector(api_version="modelplane.ai/v1alpha1", kind="ModelReplica") - replica_sel.match_labels.labels.update({"modelplane.ai/replica": "true"}) - - # Case 1: one ready cluster matches — composes replica and endpoint. - req1 = fnv1.RunFunctionRequest( - observed=fnv1.State( - composite=fnv1.Resource(resource=resource.dict_to_struct(xr)), - ), - ) - req1.required_resources["clusters"].items.append(fnv1.Resource(resource=resource.dict_to_struct(cluster_a))) - req1.required_resources["all-replicas"].SetInParent() - - want1 = fnv1.RunFunctionResponse( - meta=fnv1.ResponseMeta(ttl=durationpb.Duration(seconds=60)), - desired=fnv1.State( - composite=fnv1.Resource( - resource=resource.dict_to_struct({"status": {"replicas": {"total": 1, "ready": 0}}}), - ), - resources={ - "replica-cluster-a": fnv1.Resource( - resource=resource.dict_to_struct( - { - "apiVersion": "modelplane.ai/v1alpha1", - "kind": "ModelReplica", - "metadata": { - "name": "my-model-cluster-a-bc3c4", - "namespace": "ml-team", - "labels": { - "modelplane.ai/replica": "true", - "modelplane.ai/deployment": "my-model", - "modelplane.ai/cluster": "cluster-a", - }, - }, - "spec": { - "clusterName": "cluster-a", - "workers": { - "topology": {"tensor": 1}, - "template": { - "spec": { - "containers": [ - { - "name": "engine", - "image": "vllm/vllm-openai:latest", - "args": ["--model=Qwen/Qwen3-0.6B"], - } - ], - }, - }, - }, - }, - } + # A two-replica deployment (no container args) for the co-location case. + xr_two = v1alpha1.ModelDeployment( + metadata=metav1.ObjectMeta(name="my-model", namespace="ml-team"), + spec=v1alpha1.SpecModel( + replicas=2, + nodeSelector=v1alpha1.NodeSelector( + devices=[ + v1alpha1.Device( + name="gpu", + count=1, + selectors=[v1alpha1.Selector(cel='device.driver == "gpu.nvidia.com"')], ), - ), - "endpoint-cluster-a": fnv1.Resource( - resource=resource.dict_to_struct( - { - "apiVersion": "modelplane.ai/v1alpha1", - "kind": "ModelEndpoint", - "metadata": { - "name": "my-model-cluster-a-bc3c4", - "namespace": "ml-team", - "labels": { - "modelplane.ai/deployment": "my-model", - "modelplane.ai/cluster": "cluster-a", - }, - }, - "spec": { - "url": "http://10.0.0.1/ml-team/my-model-cluster-a-bc3c4/v1", - "rewritePath": "/ml-team/my-model-cluster-a-bc3c4/", - }, - } + ], + ), + workers=v1alpha1.Workers( + topology=v1alpha1.Topology(tensor=1), + template=v1alpha1.Template( + spec=v1alpha1.Spec( + containers=[v1alpha1.Container(name="engine", image="vllm/vllm-openai:latest")], ), ), - }, - ), - conditions=[ - fnv1.Condition( - type="ReplicasScheduled", - status=fnv1.STATUS_CONDITION_FALSE, - reason="Scheduling", - ), - fnv1.Condition( - type="ReplicasReady", - status=fnv1.STATUS_CONDITION_FALSE, - reason="ModelStarting", - message="0 of 1 ready", ), - ], - results=[ - fnv1.Result(severity=fnv1.SEVERITY_NORMAL, message="Matched 1 clusters: cluster-a"), - ], - context=structpb.Struct(), - ) - want1.requirements.resources["clusters"].CopyFrom(cluster_sel) - want1.requirements.resources["all-replicas"].CopyFrom(replica_sel) - - # Case 2: no clusters — warning. - req2 = fnv1.RunFunctionRequest( - observed=fnv1.State( - composite=fnv1.Resource(resource=resource.dict_to_struct(xr)), ), - ) - req2.required_resources["clusters"].SetInParent() - req2.required_resources["all-replicas"].SetInParent() + ).model_dump(exclude_none=True, mode="json") - want2 = fnv1.RunFunctionResponse( - meta=fnv1.ResponseMeta(ttl=durationpb.Duration(seconds=60)), - desired=fnv1.State(), - conditions=[ - fnv1.Condition( - type="ReplicasScheduled", - status=fnv1.STATUS_CONDITION_FALSE, - reason="NoClusters", + cases = [ + Case( + name="one ready cluster composes replica and endpoint", + req=_req(_XR, clusters=[_CLUSTER_A]), + want=_want( + fnv1.RunFunctionResponse( + meta=fnv1.ResponseMeta(ttl=durationpb.Duration(seconds=60)), + desired=fnv1.State( + composite=fnv1.Resource( + resource=resource.dict_to_struct({"status": {"replicas": {"total": 1, "ready": 0}}}), + ), + resources={ + "replica-cluster-a-0": fnv1.Resource( + resource=resource.dict_to_struct( + { + "apiVersion": "modelplane.ai/v1alpha1", + "kind": "ModelReplica", + "metadata": { + "name": "my-model-5ab63", + "namespace": "ml-team", + "labels": { + "modelplane.ai/replica": "true", + "modelplane.ai/deployment": "my-model", + "modelplane.ai/cluster": "cluster-a", + "modelplane.ai/replica-index": "0", + }, + }, + "spec": { + "clusterName": "cluster-a", + "nodePoolName": "default", + "deviceRequests": _DEVICE_REQUESTS, + "workers": { + "topology": {"tensor": 1, "pipeline": 1}, + "count": 1, + "template": { + "spec": { + "containers": [ + { + "name": "engine", + "image": "vllm/vllm-openai:latest", + "args": ["--model=Qwen/Qwen3-0.6B"], + } + ], + }, + }, + }, + }, + } + ), + ), + "endpoint-cluster-a-0": fnv1.Resource( + resource=resource.dict_to_struct( + { + "apiVersion": "modelplane.ai/v1alpha1", + "kind": "ModelEndpoint", + "metadata": { + "name": "my-model-5ab63", + "namespace": "ml-team", + "labels": { + "modelplane.ai/deployment": "my-model", + "modelplane.ai/cluster": "cluster-a", + "modelplane.ai/replica-index": "0", + }, + }, + "spec": { + "url": "http://10.0.0.1/ml-team/my-model-5ab63/v1", + "rewritePath": "/ml-team/my-model-5ab63/", + }, + } + ), + ), + }, + ), + conditions=[ + fnv1.Condition( + type="ReplicasScheduled", + status=fnv1.STATUS_CONDITION_FALSE, + reason="Scheduling", + ), + fnv1.Condition( + type="ReplicasReady", + status=fnv1.STATUS_CONDITION_FALSE, + reason="ModelStarting", + message="0 of 1 ready", + ), + ], + results=[ + fnv1.Result( + severity=fnv1.SEVERITY_NORMAL, + message="Scheduled 1 replicas across 1 clusters: cluster-a", + ), + ], + context=structpb.Struct(), + ) ), - ], - results=[ - fnv1.Result(severity=fnv1.SEVERITY_WARNING, message="No InferenceClusters found"), - ], - context=structpb.Struct(), - ) - want2.requirements.resources["clusters"].CopyFrom(cluster_sel) - want2.requirements.resources["all-replicas"].CopyFrom(replica_sel) - - # Case 3: cluster has insufficient capacity. - req3 = fnv1.RunFunctionRequest( - observed=fnv1.State( - composite=fnv1.Resource(resource=resource.dict_to_struct(xr)), ), - ) - req3.required_resources["clusters"].items.append( - fnv1.Resource( - resource=resource.dict_to_struct( - { - "apiVersion": "modelplane.ai/v1alpha1", - "kind": "InferenceCluster", - "metadata": {"name": "cluster-a"}, - "spec": { - "cluster": {"source": "Existing", "existing": {"secretRef": {"name": "k"}}}, - }, - "status": { - "conditions": [ - { - "type": "Ready", - "status": "True", - "reason": "Available", - "lastTransitionTime": "2025-01-01T00:00:00Z", - } - ], - "gateway": {"address": "10.0.0.1"}, - "providerConfigRef": {"name": "cluster-a"}, - "capacity": {"gpuPools": [{"countPerNode": 0, "nodes": 0}]}, - }, - } - ) - ) - ) - req3.required_resources["all-replicas"].SetInParent() - - want3 = fnv1.RunFunctionResponse( - meta=fnv1.ResponseMeta(ttl=durationpb.Duration(seconds=60)), - desired=fnv1.State( - composite=fnv1.Resource( - resource=resource.dict_to_struct({"status": {"replicas": {"total": 0, "ready": 0}}}), - ready=fnv1.READY_FALSE, + Case( + name="no clusters produces warning", + req=_req(_XR, clusters=[]), + want=_want( + fnv1.RunFunctionResponse( + meta=fnv1.ResponseMeta(ttl=durationpb.Duration(seconds=60)), + desired=fnv1.State(), + conditions=[ + fnv1.Condition( + type="ReplicasScheduled", + status=fnv1.STATUS_CONDITION_FALSE, + reason="NoClusters", + ), + ], + results=[ + fnv1.Result(severity=fnv1.SEVERITY_WARNING, message="No InferenceClusters found"), + ], + context=structpb.Struct(), + ) ), ), - conditions=[ - fnv1.Condition( - type="ReplicasScheduled", - status=fnv1.STATUS_CONDITION_FALSE, - reason="InsufficientCapacity", - message="0 of 1 clusters matched (checked 1)", - ), - fnv1.Condition( - type="ReplicasReady", - status=fnv1.STATUS_CONDITION_FALSE, - reason="NoReplicasScheduled", - ), - ], - context=structpb.Struct(), - ) - want3.requirements.resources["clusters"].CopyFrom(cluster_sel) - want3.requirements.resources["all-replicas"].CopyFrom(replica_sel) - - # Case 4: existing replica is preserved (stable scheduling). - req4 = fnv1.RunFunctionRequest( - observed=fnv1.State( - composite=fnv1.Resource(resource=resource.dict_to_struct(xr)), - resources={ - "replica-cluster-a": fnv1.Resource( - resource=resource.dict_to_struct( - { - "apiVersion": "modelplane.ai/v1alpha1", - "kind": "ModelReplica", - "metadata": { - "name": "my-model-cluster-a-bc3c4", - "namespace": "ml-team", - "labels": { - "modelplane.ai/replica": "true", - "modelplane.ai/deployment": "my-model", - "modelplane.ai/cluster": "cluster-a", - }, - }, - } + Case( + name="insufficient capacity produces no replicas", + req=_req(_XR, clusters=[_cluster("cluster-a", nodes=0)]), + want=_want( + fnv1.RunFunctionResponse( + meta=fnv1.ResponseMeta(ttl=durationpb.Duration(seconds=60)), + desired=fnv1.State( + composite=fnv1.Resource( + resource=resource.dict_to_struct({"status": {"replicas": {"total": 0, "ready": 0}}}), + ready=fnv1.READY_FALSE, + ), ), - ), - "endpoint-cluster-a": fnv1.Resource( - resource=resource.dict_to_struct( - { - "apiVersion": "modelplane.ai/v1alpha1", - "kind": "ModelEndpoint", - "metadata": {"name": "my-model-cluster-a-bc3c4", "namespace": "ml-team"}, - } - ), - ), - }, + conditions=[ + fnv1.Condition( + type="ReplicasScheduled", + status=fnv1.STATUS_CONDITION_FALSE, + reason="InsufficientCapacity", + message="0 of 1 replicas scheduled (checked 1 clusters)", + ), + fnv1.Condition( + type="ReplicasReady", + status=fnv1.STATUS_CONDITION_FALSE, + reason="NoReplicasScheduled", + ), + ], + context=structpb.Struct(), + ) + ), ), - ) - req4.required_resources["clusters"].items.append(fnv1.Resource(resource=resource.dict_to_struct(cluster_a))) - req4.required_resources["all-replicas"].items.append( - fnv1.Resource( - resource=resource.dict_to_struct( - { - "apiVersion": "modelplane.ai/v1alpha1", - "kind": "ModelReplica", - "metadata": { - "name": "my-model-cluster-a-bc3c4", - "namespace": "ml-team", - "labels": { - "modelplane.ai/replica": "true", - "modelplane.ai/deployment": "my-model", - "modelplane.ai/cluster": "cluster-a", - }, - }, - "spec": { - "clusterName": "cluster-a", - "workers": { - "topology": {"tensor": 1, "pipeline": 1}, - "count": 1, - "template": { - "spec": { - "containers": [ - {"name": "engine", "image": "vllm/vllm-openai:latest"}, - ] - } + Case( + name="existing replica is preserved with stable scheduling", + req=_req( + _XR, + clusters=[_CLUSTER_A], + replicas=[_EXISTING_REPLICA], + observed={ + "replica-cluster-a-0": { + "apiVersion": "modelplane.ai/v1alpha1", + "kind": "ModelReplica", + "metadata": { + "name": "my-model-5ab63", + "namespace": "ml-team", + "labels": { + "modelplane.ai/replica": "true", + "modelplane.ai/deployment": "my-model", + "modelplane.ai/cluster": "cluster-a", + "modelplane.ai/replica-index": "0", }, }, }, - } - ) - ) - ) - - want4 = fnv1.RunFunctionResponse( - meta=fnv1.ResponseMeta(ttl=durationpb.Duration(seconds=60)), - desired=fnv1.State( - composite=fnv1.Resource( - resource=resource.dict_to_struct({"status": {"replicas": {"total": 1, "ready": 0}}}), + "endpoint-cluster-a-0": { + "apiVersion": "modelplane.ai/v1alpha1", + "kind": "ModelEndpoint", + "metadata": {"name": "my-model-5ab63", "namespace": "ml-team"}, + }, + }, ), - resources={ - "replica-cluster-a": fnv1.Resource( - resource=resource.dict_to_struct( - { - "apiVersion": "modelplane.ai/v1alpha1", - "kind": "ModelReplica", - "metadata": { - "name": "my-model-cluster-a-bc3c4", - "namespace": "ml-team", - "labels": { - "modelplane.ai/replica": "true", - "modelplane.ai/deployment": "my-model", - "modelplane.ai/cluster": "cluster-a", - }, - }, - "spec": { - "clusterName": "cluster-a", - "workers": { - "topology": {"tensor": 1}, - "template": { + want=_want( + fnv1.RunFunctionResponse( + meta=fnv1.ResponseMeta(ttl=durationpb.Duration(seconds=60)), + desired=fnv1.State( + composite=fnv1.Resource( + resource=resource.dict_to_struct({"status": {"replicas": {"total": 1, "ready": 0}}}), + ), + resources={ + "replica-cluster-a-0": fnv1.Resource( + resource=resource.dict_to_struct( + { + "apiVersion": "modelplane.ai/v1alpha1", + "kind": "ModelReplica", + "metadata": { + "name": "my-model-5ab63", + "namespace": "ml-team", + "labels": { + "modelplane.ai/replica": "true", + "modelplane.ai/deployment": "my-model", + "modelplane.ai/cluster": "cluster-a", + "modelplane.ai/replica-index": "0", + }, + }, "spec": { - "containers": [ - { - "name": "engine", - "image": "vllm/vllm-openai:latest", - "args": ["--model=Qwen/Qwen3-0.6B"], - } - ], + "clusterName": "cluster-a", + "nodePoolName": "default", + "deviceRequests": _DEVICE_REQUESTS, + "workers": { + "topology": {"tensor": 1, "pipeline": 1}, + "count": 1, + "template": { + "spec": { + "containers": [ + { + "name": "engine", + "image": "vllm/vllm-openai:latest", + "args": ["--model=Qwen/Qwen3-0.6B"], + } + ], + }, + }, + }, }, - }, - }, - }, - } - ), - ), - "endpoint-cluster-a": fnv1.Resource( - resource=resource.dict_to_struct( - { - "apiVersion": "modelplane.ai/v1alpha1", - "kind": "ModelEndpoint", - "metadata": { - "name": "my-model-cluster-a-bc3c4", - "namespace": "ml-team", - "labels": { - "modelplane.ai/deployment": "my-model", - "modelplane.ai/cluster": "cluster-a", - }, - }, - "spec": { - "url": "http://10.0.0.1/ml-team/my-model-cluster-a-bc3c4/v1", - "rewritePath": "/ml-team/my-model-cluster-a-bc3c4/", - }, - } + } + ), + ), + "endpoint-cluster-a-0": fnv1.Resource( + resource=resource.dict_to_struct( + { + "apiVersion": "modelplane.ai/v1alpha1", + "kind": "ModelEndpoint", + "metadata": { + "name": "my-model-5ab63", + "namespace": "ml-team", + "labels": { + "modelplane.ai/deployment": "my-model", + "modelplane.ai/cluster": "cluster-a", + "modelplane.ai/replica-index": "0", + }, + }, + "spec": { + "url": "http://10.0.0.1/ml-team/my-model-5ab63/v1", + "rewritePath": "/ml-team/my-model-5ab63/", + }, + } + ), + ), + }, ), - ), - }, - ), - conditions=[ - fnv1.Condition( - type="ReplicasScheduled", - status=fnv1.STATUS_CONDITION_TRUE, - reason="ReplicasCreated", - message="Matched 1 clusters", - ), - fnv1.Condition( - type="ReplicasReady", - status=fnv1.STATUS_CONDITION_FALSE, - reason="ModelStarting", - message="0 of 1 ready", + conditions=[ + fnv1.Condition( + type="ReplicasScheduled", + status=fnv1.STATUS_CONDITION_TRUE, + reason="ReplicasCreated", + message="Scheduled 1 of 1 replicas", + ), + fnv1.Condition( + type="ReplicasReady", + status=fnv1.STATUS_CONDITION_FALSE, + reason="ModelStarting", + message="0 of 1 ready", + ), + ], + context=structpb.Struct(), + ) ), - ], - context=structpb.Struct(), - ) - want4.requirements.resources["clusters"].CopyFrom(cluster_sel) - want4.requirements.resources["all-replicas"].CopyFrom(replica_sel) - - # Case 5: pinned cluster has gone offline (still exists but is - # no longer Ready and has no gateway address). The replica stays - # pinned to it; the endpoint is not composed. - cluster_a_offline = { - "apiVersion": "modelplane.ai/v1alpha1", - "kind": "InferenceCluster", - "metadata": {"name": "cluster-a"}, - "spec": { - "cluster": {"source": "Existing", "existing": {"secretRef": {"name": "k"}}}, - }, - "status": { - "conditions": [ - { - "type": "Ready", - "status": "False", - "reason": "Unavailable", - "lastTransitionTime": "2025-01-01T00:00:00Z", - } - ], - "providerConfigRef": {"name": "cluster-a"}, - "capacity": {"gpuPools": [{"countPerNode": 1, "nodes": 2}]}, - }, - } - existing_replica = { - "apiVersion": "modelplane.ai/v1alpha1", - "kind": "ModelReplica", - "metadata": { - "name": "my-model-cluster-a-bc3c4", - "namespace": "ml-team", - "labels": { - "modelplane.ai/replica": "true", - "modelplane.ai/deployment": "my-model", - "modelplane.ai/cluster": "cluster-a", - }, - }, - "spec": { - "clusterName": "cluster-a", - "workers": { - "topology": {"tensor": 1, "pipeline": 1}, - "count": 1, - "template": { - "spec": { - "containers": [ - {"name": "engine", "image": "vllm/vllm-openai:latest"}, - ] - } - }, - }, - }, - } - req5 = fnv1.RunFunctionRequest( - observed=fnv1.State( - composite=fnv1.Resource(resource=resource.dict_to_struct(xr)), - resources={ - "replica-cluster-a": fnv1.Resource(resource=resource.dict_to_struct(existing_replica)), - }, ), - ) - req5.required_resources["clusters"].items.append( - fnv1.Resource(resource=resource.dict_to_struct(cluster_a_offline)) - ) - req5.required_resources["all-replicas"].items.append( - fnv1.Resource(resource=resource.dict_to_struct(existing_replica)) - ) - - want5 = fnv1.RunFunctionResponse( - meta=fnv1.ResponseMeta(ttl=durationpb.Duration(seconds=60)), - desired=fnv1.State( - composite=fnv1.Resource( - resource=resource.dict_to_struct({"status": {"replicas": {"total": 1, "ready": 0}}}), + Case( + name="offline pinned cluster keeps replica but drops endpoint", + req=_req( + _XR, + clusters=[_cluster("cluster-a", ready=False, address=None)], + replicas=[_EXISTING_REPLICA], + observed={"replica-cluster-a-0": _EXISTING_REPLICA}, ), - resources={ - "replica-cluster-a": fnv1.Resource( - resource=resource.dict_to_struct( - { - "apiVersion": "modelplane.ai/v1alpha1", - "kind": "ModelReplica", - "metadata": { - "name": "my-model-cluster-a-bc3c4", - "namespace": "ml-team", - "labels": { - "modelplane.ai/replica": "true", - "modelplane.ai/deployment": "my-model", - "modelplane.ai/cluster": "cluster-a", - }, - }, - "spec": { - "clusterName": "cluster-a", - "workers": { - "topology": {"tensor": 1}, - "template": { + want=_want( + fnv1.RunFunctionResponse( + meta=fnv1.ResponseMeta(ttl=durationpb.Duration(seconds=60)), + desired=fnv1.State( + composite=fnv1.Resource( + resource=resource.dict_to_struct({"status": {"replicas": {"total": 1, "ready": 0}}}), + ), + resources={ + "replica-cluster-a-0": fnv1.Resource( + resource=resource.dict_to_struct( + { + "apiVersion": "modelplane.ai/v1alpha1", + "kind": "ModelReplica", + "metadata": { + "name": "my-model-5ab63", + "namespace": "ml-team", + "labels": { + "modelplane.ai/replica": "true", + "modelplane.ai/deployment": "my-model", + "modelplane.ai/cluster": "cluster-a", + "modelplane.ai/replica-index": "0", + }, + }, "spec": { - "containers": [ - { - "name": "engine", - "image": "vllm/vllm-openai:latest", - "args": ["--model=Qwen/Qwen3-0.6B"], - } - ], + "clusterName": "cluster-a", + "nodePoolName": "default", + "deviceRequests": _DEVICE_REQUESTS, + "workers": { + "topology": {"tensor": 1, "pipeline": 1}, + "count": 1, + "template": { + "spec": { + "containers": [ + { + "name": "engine", + "image": "vllm/vllm-openai:latest", + "args": ["--model=Qwen/Qwen3-0.6B"], + } + ], + }, + }, + }, }, - }, - }, - }, - } + } + ), + ), + }, ), - ), - }, - ), - conditions=[ - fnv1.Condition( - type="ReplicasScheduled", - status=fnv1.STATUS_CONDITION_TRUE, - reason="ReplicasCreated", - message="Matched 1 clusters", - ), - fnv1.Condition( - type="ReplicasReady", - status=fnv1.STATUS_CONDITION_FALSE, - reason="ModelStarting", - message="0 of 1 ready", + conditions=[ + fnv1.Condition( + type="ReplicasScheduled", + status=fnv1.STATUS_CONDITION_TRUE, + reason="ReplicasCreated", + message="Scheduled 1 of 1 replicas", + ), + fnv1.Condition( + type="ReplicasReady", + status=fnv1.STATUS_CONDITION_FALSE, + reason="ModelStarting", + message="0 of 1 ready", + ), + ], + context=structpb.Struct(), + ) ), - ], - context=structpb.Struct(), - ) - want5.requirements.resources["clusters"].CopyFrom(cluster_sel) - want5.requirements.resources["all-replicas"].CopyFrom(replica_sel) - - # Case 6: pinned cluster has disappeared entirely. cluster-b is - # available with capacity, so the scheduler re-places the - # replica onto it. - cluster_b = { - "apiVersion": "modelplane.ai/v1alpha1", - "kind": "InferenceCluster", - "metadata": {"name": "cluster-b"}, - "spec": { - "cluster": {"source": "Existing", "existing": {"secretRef": {"name": "k"}}}, - }, - "status": { - "conditions": [ - { - "type": "Ready", - "status": "True", - "reason": "Available", - "lastTransitionTime": "2025-01-01T00:00:00Z", - } - ], - "gateway": {"address": "10.0.0.2"}, - "providerConfigRef": {"name": "cluster-b"}, - "capacity": {"gpuPools": [{"countPerNode": 1, "nodes": 2}]}, - }, - } - req6 = fnv1.RunFunctionRequest( - observed=fnv1.State( - composite=fnv1.Resource(resource=resource.dict_to_struct(xr)), - resources={ - "replica-cluster-a": fnv1.Resource(resource=resource.dict_to_struct(existing_replica)), - }, ), - ) - req6.required_resources["clusters"].items.append(fnv1.Resource(resource=resource.dict_to_struct(cluster_b))) - # The existing replica is observed - its pinned cluster-a is - # gone from the cluster list, so the scheduler must re-place it. - req6.required_resources["all-replicas"].items.append( - fnv1.Resource(resource=resource.dict_to_struct(existing_replica)) - ) - - want6 = fnv1.RunFunctionResponse( - meta=fnv1.ResponseMeta(ttl=durationpb.Duration(seconds=60)), - desired=fnv1.State( - composite=fnv1.Resource( - resource=resource.dict_to_struct({"status": {"replicas": {"total": 1, "ready": 0}}}), + Case( + name="deleted pinned cluster triggers replica re-placement", + req=_req( + _XR, + clusters=[_cluster("cluster-b", address="10.0.0.2")], + replicas=[_EXISTING_REPLICA], + observed={"replica-cluster-a-0": _EXISTING_REPLICA}, ), - resources={ - "replica-cluster-b": fnv1.Resource( - resource=resource.dict_to_struct( - { - "apiVersion": "modelplane.ai/v1alpha1", - "kind": "ModelReplica", - "metadata": { - "name": "my-model-cluster-b-a9d2c", - "namespace": "ml-team", - "labels": { - "modelplane.ai/replica": "true", - "modelplane.ai/deployment": "my-model", - "modelplane.ai/cluster": "cluster-b", - }, - }, - "spec": { - "clusterName": "cluster-b", - "workers": { - "topology": {"tensor": 1}, - "template": { + want=_want( + fnv1.RunFunctionResponse( + meta=fnv1.ResponseMeta(ttl=durationpb.Duration(seconds=60)), + desired=fnv1.State( + composite=fnv1.Resource( + resource=resource.dict_to_struct({"status": {"replicas": {"total": 1, "ready": 0}}}), + ), + resources={ + "replica-cluster-b-0": fnv1.Resource( + resource=resource.dict_to_struct( + { + "apiVersion": "modelplane.ai/v1alpha1", + "kind": "ModelReplica", + "metadata": { + "name": "my-model-f0b76", + "namespace": "ml-team", + "labels": { + "modelplane.ai/replica": "true", + "modelplane.ai/deployment": "my-model", + "modelplane.ai/cluster": "cluster-b", + "modelplane.ai/replica-index": "0", + }, + }, "spec": { - "containers": [ - { - "name": "engine", - "image": "vllm/vllm-openai:latest", - "args": ["--model=Qwen/Qwen3-0.6B"], - } - ], + "clusterName": "cluster-b", + "nodePoolName": "default", + "deviceRequests": _DEVICE_REQUESTS, + "workers": { + "topology": {"tensor": 1, "pipeline": 1}, + "count": 1, + "template": { + "spec": { + "containers": [ + { + "name": "engine", + "image": "vllm/vllm-openai:latest", + "args": ["--model=Qwen/Qwen3-0.6B"], + } + ], + }, + }, + }, }, - }, - }, - }, - } - ), - ), - "endpoint-cluster-b": fnv1.Resource( - resource=resource.dict_to_struct( - { - "apiVersion": "modelplane.ai/v1alpha1", - "kind": "ModelEndpoint", - "metadata": { - "name": "my-model-cluster-b-a9d2c", - "namespace": "ml-team", - "labels": { - "modelplane.ai/deployment": "my-model", - "modelplane.ai/cluster": "cluster-b", - }, - }, - "spec": { - "url": "http://10.0.0.2/ml-team/my-model-cluster-b-a9d2c/v1", - "rewritePath": "/ml-team/my-model-cluster-b-a9d2c/", - }, - } + } + ), + ), + "endpoint-cluster-b-0": fnv1.Resource( + resource=resource.dict_to_struct( + { + "apiVersion": "modelplane.ai/v1alpha1", + "kind": "ModelEndpoint", + "metadata": { + "name": "my-model-f0b76", + "namespace": "ml-team", + "labels": { + "modelplane.ai/deployment": "my-model", + "modelplane.ai/cluster": "cluster-b", + "modelplane.ai/replica-index": "0", + }, + }, + "spec": { + "url": "http://10.0.0.2/ml-team/my-model-f0b76/v1", + "rewritePath": "/ml-team/my-model-f0b76/", + }, + } + ), + ), + }, ), - ), - }, - ), - conditions=[ - fnv1.Condition( - type="ReplicasScheduled", - status=fnv1.STATUS_CONDITION_FALSE, - reason="Scheduling", + conditions=[ + fnv1.Condition( + type="ReplicasScheduled", + status=fnv1.STATUS_CONDITION_FALSE, + reason="Scheduling", + ), + fnv1.Condition( + type="ReplicasReady", + status=fnv1.STATUS_CONDITION_FALSE, + reason="ModelStarting", + message="0 of 1 ready", + ), + ], + results=[ + fnv1.Result( + severity=fnv1.SEVERITY_NORMAL, + message="Scheduled 1 replicas across 1 clusters: cluster-b", + ), + ], + context=structpb.Struct(), + ) ), - fnv1.Condition( - type="ReplicasReady", - status=fnv1.STATUS_CONDITION_FALSE, - reason="ModelStarting", - message="0 of 1 ready", - ), - ], - results=[ - fnv1.Result(severity=fnv1.SEVERITY_NORMAL, message="Matched 1 clusters: cluster-b"), - ], - context=structpb.Struct(), - ) - want6.requirements.resources["clusters"].CopyFrom(cluster_sel) - want6.requirements.resources["all-replicas"].CopyFrom(replica_sel) - - # Case 7: deployment sets spec.modelCacheRef — the ref is - # propagated onto the composed replica's spec so the backend - # knows which cache PVC to mount. - xr_cached = v1alpha1.ModelDeployment( - metadata=metav1.ObjectMeta(name="my-model", namespace="ml-team"), - spec=v1alpha1.SpecModel( - replicas=1, - modelCacheRef=v1alpha1.ModelCacheRef(name="qwen"), - workers=v1alpha1.Workers( - topology=v1alpha1.Topology(tensor=1), - template=v1alpha1.Template( - spec=v1alpha1.Spec( - containers=[ - v1alpha1.Container( - name="engine", - image="vllm/vllm-openai:latest", - args=["--model=Qwen/Qwen3-0.6B"], + ), + Case( + name="modelCacheRef is propagated onto the composed replica", + req=_req(xr_cached, clusters=[_CLUSTER_A]), + want=_want( + fnv1.RunFunctionResponse( + meta=fnv1.ResponseMeta(ttl=durationpb.Duration(seconds=60)), + desired=fnv1.State( + composite=fnv1.Resource( + resource=resource.dict_to_struct({"status": {"replicas": {"total": 1, "ready": 0}}}), + ), + resources={ + "replica-cluster-a-0": fnv1.Resource( + resource=resource.dict_to_struct( + { + "apiVersion": "modelplane.ai/v1alpha1", + "kind": "ModelReplica", + "metadata": { + "name": "my-model-5ab63", + "namespace": "ml-team", + "labels": { + "modelplane.ai/replica": "true", + "modelplane.ai/deployment": "my-model", + "modelplane.ai/cluster": "cluster-a", + "modelplane.ai/replica-index": "0", + }, + }, + "spec": { + "clusterName": "cluster-a", + "nodePoolName": "default", + "deviceRequests": _DEVICE_REQUESTS, + "modelCacheRef": {"name": "qwen"}, + "workers": { + "topology": {"tensor": 1, "pipeline": 1}, + "count": 1, + "template": { + "spec": { + "containers": [ + { + "name": "engine", + "image": "vllm/vllm-openai:latest", + "args": ["--model=Qwen/Qwen3-0.6B"], + } + ], + }, + }, + }, + }, + } + ), ), - ], + "endpoint-cluster-a-0": fnv1.Resource( + resource=resource.dict_to_struct( + { + "apiVersion": "modelplane.ai/v1alpha1", + "kind": "ModelEndpoint", + "metadata": { + "name": "my-model-5ab63", + "namespace": "ml-team", + "labels": { + "modelplane.ai/deployment": "my-model", + "modelplane.ai/cluster": "cluster-a", + "modelplane.ai/replica-index": "0", + }, + }, + "spec": { + "url": "http://10.0.0.1/ml-team/my-model-5ab63/v1", + "rewritePath": "/ml-team/my-model-5ab63/", + }, + } + ), + ), + }, ), - ), + conditions=[ + fnv1.Condition( + type="ReplicasScheduled", + status=fnv1.STATUS_CONDITION_FALSE, + reason="Scheduling", + ), + fnv1.Condition( + type="ReplicasReady", + status=fnv1.STATUS_CONDITION_FALSE, + reason="ModelStarting", + message="0 of 1 ready", + ), + ], + results=[ + fnv1.Result( + severity=fnv1.SEVERITY_NORMAL, + message="Scheduled 1 replicas across 1 clusters: cluster-a", + ), + ], + context=structpb.Struct(), + ) ), ), - ).model_dump(exclude_none=True, mode="json") - - req7 = fnv1.RunFunctionRequest( - observed=fnv1.State( - composite=fnv1.Resource(resource=resource.dict_to_struct(xr_cached)), - ), - ) - req7.required_resources["clusters"].items.append(fnv1.Resource(resource=resource.dict_to_struct(cluster_a))) - req7.required_resources["all-replicas"].SetInParent() - - want7 = fnv1.RunFunctionResponse( - meta=fnv1.ResponseMeta(ttl=durationpb.Duration(seconds=60)), - desired=fnv1.State( - composite=fnv1.Resource( - resource=resource.dict_to_struct({"status": {"replicas": {"total": 1, "ready": 0}}}), - ), - resources={ - "replica-cluster-a": fnv1.Resource( - resource=resource.dict_to_struct( - { - "apiVersion": "modelplane.ai/v1alpha1", - "kind": "ModelReplica", - "metadata": { - "name": "my-model-cluster-a-bc3c4", - "namespace": "ml-team", - "labels": { - "modelplane.ai/replica": "true", - "modelplane.ai/deployment": "my-model", - "modelplane.ai/cluster": "cluster-a", - }, - }, - "spec": { - "clusterName": "cluster-a", - "modelCacheRef": {"name": "qwen"}, - "workers": { - "topology": {"tensor": 1}, - "template": { + Case( + name="two replicas co-locate on one cluster as distinct resources", + req=_req(xr_two, clusters=[_CLUSTER_A]), + want=_want( + fnv1.RunFunctionResponse( + meta=fnv1.ResponseMeta(ttl=durationpb.Duration(seconds=60)), + desired=fnv1.State( + composite=fnv1.Resource( + resource=resource.dict_to_struct({"status": {"replicas": {"total": 2, "ready": 0}}}), + ), + resources={ + "replica-cluster-a-0": fnv1.Resource( + resource=resource.dict_to_struct( + { + "apiVersion": "modelplane.ai/v1alpha1", + "kind": "ModelReplica", + "metadata": { + "name": "my-model-5ab63", + "namespace": "ml-team", + "labels": { + "modelplane.ai/replica": "true", + "modelplane.ai/deployment": "my-model", + "modelplane.ai/cluster": "cluster-a", + "modelplane.ai/replica-index": "0", + }, + }, "spec": { - "containers": [ - { - "name": "engine", - "image": "vllm/vllm-openai:latest", - "args": ["--model=Qwen/Qwen3-0.6B"], - } - ], + "clusterName": "cluster-a", + "nodePoolName": "default", + "deviceRequests": _DEVICE_REQUESTS, + "workers": { + "topology": {"tensor": 1, "pipeline": 1}, + "count": 1, + "template": { + "spec": { + "containers": [ + { + "name": "engine", + "image": "vllm/vllm-openai:latest", + } + ], + }, + }, + }, }, - }, - }, - }, - } - ), - ), - "endpoint-cluster-a": fnv1.Resource( - resource=resource.dict_to_struct( - { - "apiVersion": "modelplane.ai/v1alpha1", - "kind": "ModelEndpoint", - "metadata": { - "name": "my-model-cluster-a-bc3c4", - "namespace": "ml-team", - "labels": { - "modelplane.ai/deployment": "my-model", - "modelplane.ai/cluster": "cluster-a", - }, - }, - "spec": { - "url": "http://10.0.0.1/ml-team/my-model-cluster-a-bc3c4/v1", - "rewritePath": "/ml-team/my-model-cluster-a-bc3c4/", - }, - } + } + ), + ), + "replica-cluster-a-1": fnv1.Resource( + resource=resource.dict_to_struct( + { + "apiVersion": "modelplane.ai/v1alpha1", + "kind": "ModelReplica", + "metadata": { + "name": "my-model-609c5", + "namespace": "ml-team", + "labels": { + "modelplane.ai/replica": "true", + "modelplane.ai/deployment": "my-model", + "modelplane.ai/cluster": "cluster-a", + "modelplane.ai/replica-index": "1", + }, + }, + "spec": { + "clusterName": "cluster-a", + "nodePoolName": "default", + "deviceRequests": _DEVICE_REQUESTS, + "workers": { + "topology": {"tensor": 1, "pipeline": 1}, + "count": 1, + "template": { + "spec": { + "containers": [ + { + "name": "engine", + "image": "vllm/vllm-openai:latest", + } + ], + }, + }, + }, + }, + } + ), + ), + "endpoint-cluster-a-0": fnv1.Resource( + resource=resource.dict_to_struct( + { + "apiVersion": "modelplane.ai/v1alpha1", + "kind": "ModelEndpoint", + "metadata": { + "name": "my-model-5ab63", + "namespace": "ml-team", + "labels": { + "modelplane.ai/deployment": "my-model", + "modelplane.ai/cluster": "cluster-a", + "modelplane.ai/replica-index": "0", + }, + }, + "spec": { + "url": "http://10.0.0.1/ml-team/my-model-5ab63/v1", + "rewritePath": "/ml-team/my-model-5ab63/", + }, + } + ), + ), + "endpoint-cluster-a-1": fnv1.Resource( + resource=resource.dict_to_struct( + { + "apiVersion": "modelplane.ai/v1alpha1", + "kind": "ModelEndpoint", + "metadata": { + "name": "my-model-609c5", + "namespace": "ml-team", + "labels": { + "modelplane.ai/deployment": "my-model", + "modelplane.ai/cluster": "cluster-a", + "modelplane.ai/replica-index": "1", + }, + }, + "spec": { + "url": "http://10.0.0.1/ml-team/my-model-609c5/v1", + "rewritePath": "/ml-team/my-model-609c5/", + }, + } + ), + ), + }, ), - ), - }, - ), - conditions=[ - fnv1.Condition( - type="ReplicasScheduled", - status=fnv1.STATUS_CONDITION_FALSE, - reason="Scheduling", + conditions=[ + fnv1.Condition( + type="ReplicasScheduled", + status=fnv1.STATUS_CONDITION_FALSE, + reason="Scheduling", + ), + fnv1.Condition( + type="ReplicasReady", + status=fnv1.STATUS_CONDITION_FALSE, + reason="ModelStarting", + message="0 of 2 ready", + ), + ], + results=[ + fnv1.Result( + severity=fnv1.SEVERITY_NORMAL, + message="Scheduled 2 replicas across 1 clusters: cluster-a", + ), + ], + context=structpb.Struct(), + ) ), - fnv1.Condition( - type="ReplicasReady", - status=fnv1.STATUS_CONDITION_FALSE, - reason="ModelStarting", - message="0 of 1 ready", - ), - ], - results=[ - fnv1.Result(severity=fnv1.SEVERITY_NORMAL, message="Matched 1 clusters: cluster-a"), - ], - context=structpb.Struct(), - ) - want7.requirements.resources["clusters"].CopyFrom(cluster_sel) - want7.requirements.resources["all-replicas"].CopyFrom(replica_sel) - - cases = [ - Case(name="one ready cluster composes replica and endpoint", req=req1, want=want1), - Case(name="no clusters produces warning", req=req2, want=want2), - Case(name="insufficient capacity produces no replicas", req=req3, want=want3), - Case(name="existing replica is preserved with stable scheduling", req=req4, want=want4), - Case(name="offline pinned cluster keeps replica but drops endpoint", req=req5, want=want5), - Case(name="deleted pinned cluster triggers replica re-placement", req=req6, want=want6), - Case(name="modelCacheRef is propagated onto the composed replica", req=req7, want=want7), + ), ] for case in cases: diff --git a/functions/compose-model-deployment/tests/test_quantity.py b/functions/compose-model-deployment/tests/test_quantity.py new file mode 100644 index 000000000..4126a0ca9 --- /dev/null +++ b/functions/compose-model-deployment/tests/test_quantity.py @@ -0,0 +1,218 @@ +"""Tests for the quantity module. + +The cases mirror upstream's quantity CEL surface so we catch regressions against +it: every example from the quantity.go doc comment and every applicable case +from k8s.io/apiserver/pkg/cel/library/quantity_test.go is represented as a row. + +Expressions are evaluated end to end through a compiled CEL selector (the device +activation is built but unused), so each case reads like the CEL a user writes. +Value-returning expressions are wrapped in a `== ` comparison so the case +asserts a single bool, matching how the upstream table asserts equality. + +A few upstream cases don't apply to our reimplementation and are noted inline: +compile-time overload errors (isQuantity([1,2,3])) - celpy doesn't type-check +overloads; and runtime-error cases (an invalid suffix, integer overflow) which +upstream raises but we treat as a non-match (a CEL eval error -> matches() is +False), exercised here through the parse layer and the selector layer. + +These expected values come from running the inputs through the real Kubernetes +code, not from assertion by hand. When you change the quantity module or want to +add a case, derive its expected value with the parity oracle in ./oracle (see +oracle/README.md) rather than reasoning about it - upstream has surprises (e.g. +binary-suffix overflow saturates to int64-max, so 8Ei == 10Ei). +""" + +import dataclasses +import unittest + +from function import cel, quantity + + +def _eval(expr: str) -> bool: + """Compile and evaluate a deviceless boolean CEL expression.""" + return cel.compile_selector(expr).matches({}) + + +@dataclasses.dataclass +class Case: + name: str + expr: str + want: bool + + +@dataclasses.dataclass +class ParseErrCase: + name: str + input: str + + +class TestQuantityCEL(unittest.TestCase): + """Mirrors quantity_test.go TestQuantity (and the doc-comment examples).""" + + def test_quantity(self) -> None: + cases = [ + # parse + isQuantity. + Case(name="parse", expr='quantity("12Mi").compareTo(quantity("12Mi")) == 0', want=True), + Case(name="isQuantity int string", expr='isQuantity("20")', want=True), + Case(name="isQuantity megabytes", expr='isQuantity("20M")', want=True), + Case(name="isQuantity mebibytes", expr='isQuantity("20Mi")', want=True), + Case(name="isQuantity invalid suffix", expr='isQuantity("20Mo")', want=False), + Case(name="isQuantity passing regex bad suffix", expr='isQuantity("10Mm")', want=False), + # resource.Quantity accepts decimal exponents and nano/micro suffixes. + Case(name="isQuantity exponent lowercase", expr='isQuantity("256e3")', want=True), + Case(name="isQuantity exponent uppercase", expr='isQuantity("1E3")', want=True), + Case(name="exponent value", expr='quantity("256e3").compareTo(quantity("256000")) == 0', want=True), + Case(name="isQuantity nano", expr='isQuantity("100n")', want=True), + Case(name="isQuantity micro", expr='isQuantity("100u")', want=True), + Case(name="isQuantity trailing dot", expr='isQuantity("5.")', want=True), + # The quantity() constructor does NOT trim whitespace. + Case(name="isQuantity leading whitespace false", expr='isQuantity(" 5Gi")', want=False), + Case(name="isQuantity trailing whitespace false", expr='isQuantity("5Gi ")', want=False), + # Values equal at nano resolution compare equal (resource.Quantity.Cmp + # rounds to nano). + Case( + name="nano rounding equality", + expr='quantity("0.0000000004").compareTo(quantity("0.000000001")) == 0', + want=True, + ), + # doc-comment isQuantity examples. + Case(name="isQuantity 1.3G", expr='isQuantity("1.3G")', want=True), + Case(name="isQuantity 1.3Gi", expr='isQuantity("1.3Gi")', want=True), + Case(name="isQuantity comma", expr='isQuantity("1,3G")', want=False), + Case(name="isQuantity 10000k", expr='isQuantity("10000k")', want=True), + Case(name="isQuantity capital K", expr='isQuantity("200K")', want=False), + Case(name="isQuantity Three", expr='isQuantity("Three")', want=False), + Case(name="isQuantity bare suffix", expr='isQuantity("Mi")', want=False), + # equality. + Case(name="equality reflexivity", expr='quantity("200M") == quantity("200M")', want=True), + Case( + name="equality symmetry", + expr='quantity("200M") == quantity("0.2G") && quantity("0.2G") == quantity("200M")', + want=True, + ), + Case( + name="equality transitivity", + expr=( + 'quantity("2M") == quantity("0.002G") && quantity("2000k") == quantity("2M") && ' + 'quantity("0.002G") == quantity("2000k")' + ), + want=True, + ), + Case(name="inequality", expr='quantity("200M") == quantity("0.3G")', want=False), + # isLessThan / isGreaterThan. + Case(name="less", expr='quantity("50M").isLessThan(quantity("50Mi"))', want=True), + Case(name="less obvious", expr='quantity("50M").isLessThan(quantity("100M"))', want=True), + Case(name="less false", expr='quantity("100M").isLessThan(quantity("50M"))', want=False), + Case(name="greater", expr='quantity("50Mi").isGreaterThan(quantity("50M"))', want=True), + Case(name="greater obvious", expr='quantity("150Mi").isGreaterThan(quantity("100Mi"))', want=True), + Case(name="greater false", expr='quantity("50M").isGreaterThan(quantity("100M"))', want=False), + # compareTo. + Case(name="compare equal", expr='quantity("200M").compareTo(quantity("0.2G")) == 0', want=True), + Case(name="compare less", expr='quantity("50M").compareTo(quantity("50Mi")) == -1', want=True), + Case(name="compare greater", expr='quantity("50Mi").compareTo(quantity("50M")) == 1', want=True), + # add / sub (quantity and int overloads). + Case(name="add quantity", expr='quantity("50k").add(quantity("20")) == quantity("50.02k")', want=True), + Case(name="add int not less", expr='quantity("50k").add(20).isLessThan(quantity("50020"))', want=False), + Case(name="sub quantity", expr='quantity("50k").sub(quantity("20")) == quantity("49.98k")', want=True), + Case(name="sub int", expr='quantity("50k").sub(20) == quantity("49980")', want=True), + Case( + name="arith chain 1", + expr='quantity("50k").add(20).sub(quantity("100k")).asInteger() == -49980', + want=True, + ), + Case( + name="arith chain 2", + expr='quantity("50k").add(20).sub(quantity("100k")).sub(-50000).asInteger() == 20', + want=True, + ), + # sign (doc comment). Upstream declares sign as a GLOBAL function + # (sign(q)), not a member (q.sign()); the global form is the parity + # surface. celpy can't tell the two call styles apart, so we accept + # both, but the test asserts the upstream-correct global form (see + # cel.py's documented divergences). + Case(name="sign positive", expr='sign(quantity("50k")) == 1', want=True), + Case(name="sign negative", expr='sign(quantity("-50k")) == -1', want=True), + Case(name="sign zero", expr='sign(quantity("0")) == 0', want=True), + # Binary-suffix overflow saturates to int64-max, keeping sign, so + # 8Ei/10Ei/100Ei all compare equal to int64-max (resource.Quantity + # stores BinarySI in an int64). Confirmed against resource.Quantity. + Case( + name="Ei saturates to int64 max", + expr='quantity("8Ei").compareTo(quantity("9223372036854775807")) == 0', + want=True, + ), + Case(name="8Ei equals 10Ei", expr='quantity("8Ei").compareTo(quantity("10Ei")) == 0', want=True), + Case(name="10Ei equals 100Ei", expr='quantity("10Ei").compareTo(quantity("100Ei")) == 0', want=True), + Case( + name="negative Ei saturates", + expr='quantity("-10Ei").compareTo(quantity("-9223372036854775807")) == 0', + want=True, + ), + # 7Ei is below int64-max, so it does NOT saturate and stays less. + Case(name="7Ei below saturation", expr='quantity("7Ei").isLessThan(quantity("8Ei"))', want=True), + # Large DECIMAL-path values do not saturate (only the binary path + # does) and must not raise on nano-rounding. isQuantity must be true + # and the value must round-trip. + Case(name="isQuantity 256E", expr='isQuantity("256E")', want=True), + Case(name="isQuantity 10E", expr='isQuantity("10E")', want=True), + Case( + name="256E value", expr='quantity("256E").compareTo(quantity("256000000000000000000")) == 0', want=True + ), + Case(name="256E greater than 1Ei", expr='quantity("256E").isGreaterThan(quantity("1Ei"))', want=True), + # asInteger / isInteger. + Case(name="as integer", expr='quantity("50k").asInteger() == 50000', want=True), + Case(name="is integer true small", expr='quantity("50").isInteger()', want=True), + Case(name="is integer true big magnitude", expr='quantity("50000000G").isInteger()', want=True), + Case( + name="is integer false overflow", + expr='quantity("9999999999999999999999999999999999999G").isInteger()', + want=False, + ), + # asInteger overflow is a runtime error upstream -> non-match here. + Case( + name="as integer overflow is non-match", + expr='quantity("9999999999999999999999999999999999999G").asInteger() > 0', + want=False, + ), + # asApproximateFloat. + Case(name="as approximate float", expr='quantity("50.703k").asApproximateFloat() == 50703.0', want=True), + # An invalid suffix is a runtime error upstream -> non-match here. + # (Uses a member method upstream accepts, isGreaterThan, so the + # non-match is the parse failure, not a rejected call form.) + Case( + name="invalid suffix is non-match", + expr='quantity("10Mo").isGreaterThan(quantity("1"))', + want=False, + ), + ] + for case in cases: + with self.subTest(case.name): + self.assertEqual(case.want, _eval(case.expr), f"{case.name}: -want, +got") + + +class TestParseRejects(unittest.TestCase): + """parse() rejects what resource.Quantity rejects (drives the non-matches above). + + The bare-suffix row ("Mi") is a DELIBERATE divergence, not parity: upstream + parses most bare suffixes as 0 but inconsistently errors on a few (see + parse()'s docstring). We reject every bare suffix; no device capacity is + ever a bare suffix. + """ + + def test_parse_rejects(self) -> None: + cases = [ + ParseErrCase(name="invalid suffix Mo", input="10Mo"), + ParseErrCase(name="passing regex bad suffix Mm", input="10Mm"), + ParseErrCase(name="capital K", input="200K"), + ParseErrCase(name="comma", input="1,3G"), + ParseErrCase(name="word", input="Three"), + ParseErrCase(name="bare suffix (deliberate divergence)", input="Mi"), + ParseErrCase(name="empty", input=""), + ] + for case in cases: + with self.subTest(case.name), self.assertRaises(ValueError): + quantity.parse(case.input) + + +if __name__ == "__main__": + unittest.main() diff --git a/functions/compose-model-deployment/tests/test_scheduling.py b/functions/compose-model-deployment/tests/test_scheduling.py index 886040d1c..5034bdc31 100644 --- a/functions/compose-model-deployment/tests/test_scheduling.py +++ b/functions/compose-model-deployment/tests/test_scheduling.py @@ -3,18 +3,27 @@ Unit tests for the retain-then-place scheduler. These construct Pydantic models directly and call schedule() to exercise the core logic without the protobuf/gRPC ceremony of the fn tests. + +Pool selection is driven by nodeSelector device requests (DRA CEL matched +against a pool's devices) plus the available-node gate. Per-node GPU count is +expressed as a device request's count, not derived from topology. """ import dataclasses import unittest -from function import scheduling -from function.scheduling import Candidate +from function import cel, scheduling +from function.scheduling import Candidate, DeviceRequest from models.ai.modelplane.inferencecluster import v1alpha1 as icv1alpha1 from models.ai.modelplane.modeldeployment import v1alpha1 as mdv1alpha1 from models.ai.modelplane.modelreplica import v1alpha1 as mrv1alpha1 from models.io.k8s.apimachinery.pkg.apis.meta import v1 as metav1 +# A GPU memory selector reused across cases. +_MEM_141 = 'device.capacity["gpu.nvidia.com"].memory.compareTo(quantity("141Gi")) >= 0' +_MEM_200 = 'device.capacity["gpu.nvidia.com"].memory.compareTo(quantity("200Gi")) >= 0' +_IB = 'device.attributes["nic.nvidia.com"].linkType == "infiniband"' + @dataclasses.dataclass class Case: @@ -27,12 +36,33 @@ class Case: want: list[Candidate] -def _deployment(name: str = "my-model", replicas: int = 1, tensor: int = 1, pipeline: int = 1, count: int = 1): - """Construct a ModelDeployment with the given topology.""" +def _request(name: str = "gpu", count: int = 1, cel_exprs: list[str] | None = None) -> mdv1alpha1.Device: + """A nodeSelector device request.""" + return mdv1alpha1.Device( + name=name, + count=count, + selectors=[mdv1alpha1.Selector(cel=c) for c in (cel_exprs or [_MEM_141])], + ) + + +def _deployment( + name: str = "my-model", + replicas: int = 1, + tensor: int = 1, + pipeline: int = 1, + count: int = 1, + requests: list[mdv1alpha1.Device] | None = None, +): + """Construct a ModelDeployment with the given topology and device requests. + + nodeSelector is required, so callers that don't care about pool matching get + a single default GPU request that any test pool's GPU device satisfies. + """ return mdv1alpha1.ModelDeployment( metadata=metav1.ObjectMeta(name=name, namespace="ml-team"), spec=mdv1alpha1.SpecModel( replicas=replicas, + nodeSelector=mdv1alpha1.NodeSelector(devices=requests if requests is not None else [_request()]), workers=mdv1alpha1.Workers( count=count, topology=mdv1alpha1.Topology(tensor=tensor, pipeline=pipeline), @@ -46,6 +76,48 @@ def _deployment(name: str = "my-model", replicas: int = 1, tensor: int = 1, pipe ) +def _gpu_device( + name: str = "gpu", + *, + claim: str = "DRA", + driver: str = "gpu.nvidia.com", + device_class: str = "gpu.nvidia.com", + count: int = 1, + memory: str = "141Gi", +) -> dict: + """A GPU device dict for a pool, with memory capacity.""" + d = { + "name": name, + "claim": claim, + "driver": driver, + "count": count, + "capacity": {"memory": {"value": memory}}, + } + if claim == "DRA": + d["deviceClassName"] = device_class + return d + + +def _nic_device(*, link_type: str = "infiniband", count: int = 1) -> dict: + """A synthetic NIC device dict for a pool.""" + return { + "name": "nic", + "claim": "Synthetic", + "driver": "nic.nvidia.com", + "count": count, + "attributes": {"linkType": {"string": link_type}}, + } + + +def _pool(name: str, *, nodes: int = 2, devices: list[dict] | None = None) -> dict: + """A pool with devices, for nodeSelector tests.""" + return { + "name": name, + "nodes": nodes, + "devices": devices if devices is not None else [_gpu_device()], + } + + def _cluster( name: str, *, @@ -60,27 +132,18 @@ def _cluster( the scheduler will retain but not pick anew. """ if pools is None: - pools = [{"countPerNode": 1, "nodes": 2}] - - conditions = [] - if ready: - conditions.append( - icv1alpha1.Condition( - type="Ready", - status="True", - reason="Available", - lastTransitionTime="2025-01-01T00:00:00Z", - ) - ) - else: - conditions.append( - icv1alpha1.Condition( - type="Ready", - status="False", - reason="Unavailable", - lastTransitionTime="2025-01-01T00:00:00Z", - ) + pools = [{"name": "default", "nodes": 2, "devices": [_gpu_device()]}] + + status = "True" if ready else "False" + reason = "Available" if ready else "Unavailable" + conditions = [ + icv1alpha1.Condition( + type="Ready", + status=status, + reason=reason, + lastTransitionTime="2025-01-01T00:00:00Z", ) + ] return icv1alpha1.InferenceCluster( metadata=metav1.ObjectMeta(name=name), @@ -94,7 +157,7 @@ def _cluster( conditions=conditions, gateway=icv1alpha1.Gateway(address=gateway_address) if gateway_address else icv1alpha1.Gateway(), providerConfigRef=icv1alpha1.ProviderConfigRef(name=name), - capacity=icv1alpha1.Capacity(gpuPools=[icv1alpha1.GpuPool(**p) for p in pools]), + gpuPools=[icv1alpha1.GpuPool(**p) for p in pools], ), ) @@ -103,23 +166,40 @@ def _replica( deployment_name: str, cluster_name: str, *, + pool: str = "default", + index: int = 0, tensor: int = 1, pipeline: int = 1, count: int = 1, ) -> mrv1alpha1.ModelReplica: - """Construct an observed ModelReplica pinned to a cluster.""" + """Construct an observed ModelReplica pinned to a (cluster, index). + + nodePoolName and deviceRequests are XRD-required, so every observed replica + carries them; the pool defaults to "default" for cases where the specific + pool isn't material. + """ return mrv1alpha1.ModelReplica( metadata=metav1.ObjectMeta( - name=f"{deployment_name}-{cluster_name}", + name=f"{deployment_name}-{cluster_name}-{index}", namespace="ml-team", labels={ "modelplane.ai/replica": "true", "modelplane.ai/deployment": deployment_name, "modelplane.ai/cluster": cluster_name, + "modelplane.ai/replica-index": str(index), }, ), spec=mrv1alpha1.SpecModel( clusterName=cluster_name, + nodePoolName=pool, + deviceRequests=[ + mrv1alpha1.DeviceRequest( + name="gpu", + deviceClassName="gpu.nvidia.com", + count=1, + selectors=[mrv1alpha1.Selector(cel=_MEM_141)], + ), + ], workers=mrv1alpha1.Workers( count=count, topology=mrv1alpha1.Topology(tensor=tensor, pipeline=pipeline), @@ -133,8 +213,50 @@ def _replica( ) +def _replica_with_pool( + deployment_name: str, + cluster_name: str, + *, + pool: str, + index: int = 0, + tensor: int = 1, + pipeline: int = 1, + count: int = 1, +) -> mrv1alpha1.ModelReplica: + """An observed ModelReplica pinned to a cluster AND a specific node pool.""" + return _replica( + deployment_name, cluster_name, pool=pool, index=index, tensor=tensor, pipeline=pipeline, count=count + ) + + +# Convenience: the resolved DeviceRequest for a default GPU request matching a +# default pool, used in expected candidates for nodeSelector cases. +def _resolved(name: str = "gpu", count: int = 1, cel_exprs: list[str] | None = None) -> DeviceRequest: + return DeviceRequest( + name=name, + device_class_name="gpu.nvidia.com", + count=count, + cel_selectors=cel_exprs or [_MEM_141], + ) + + +# Convenience: build an expected Candidate defaulting to index 0, so the many +# single-replica-per-cluster cases stay terse. Since nodeSelector is required, +# a placed or retained replica resolves to the default pool's GPU request; +# degraded/unplaced cases pass pool="" and device_requests=[] explicitly. +def _cand(name: str, *, index: int = 0, **kwargs) -> Candidate: + kwargs.setdefault("pool", "default") + kwargs.setdefault("device_requests", [_resolved()]) + return Candidate(name=name, index=index, **kwargs) + + class TestSchedule(unittest.TestCase): - """Tests for scheduling.schedule.""" + """Tests for scheduling.schedule placement: retain, spread, scale, capacity. + + Deployments use the default single-GPU nodeSelector request (any pool's GPU + device satisfies it), so these focus on placement rather than pool matching; + TestScheduleNodeSelector covers request-to-device matching. + """ def test_schedule(self) -> None: """The scheduler retains existing pins and places new replicas.""" @@ -152,7 +274,7 @@ def test_schedule(self) -> None: deployment=_deployment(), clusters=[_cluster("cluster-a")], all_replicas=[], - want=[Candidate(name="cluster-a", gateway_address="10.0.0.1")], + want=[_cand(name="cluster-a", gateway_address="10.0.0.1", pool="default")], ), Case( name="not-ready cluster is not picked for a new replica", @@ -168,17 +290,10 @@ def test_schedule(self) -> None: all_replicas=[], want=[], ), - Case( - name="cluster with no fitting pool is skipped", - deployment=_deployment(tensor=8), - clusters=[_cluster("cluster-a", pools=[{"countPerNode": 1, "nodes": 2}])], - all_replicas=[], - want=[], - ), Case( name="multi-node deployment needs enough nodes", deployment=_deployment(tensor=1, pipeline=4), - clusters=[_cluster("cluster-a", pools=[{"countPerNode": 1, "nodes": 2}])], + clusters=[_cluster("cluster-a", pools=[_pool("default", nodes=2)])], all_replicas=[], want=[], ), @@ -186,36 +301,24 @@ def test_schedule(self) -> None: name="existing replica is retained on its pinned cluster", deployment=_deployment(), clusters=[_cluster("cluster-a"), _cluster("cluster-b", gateway_address="10.0.0.2")], - all_replicas=[_replica("my-model", "cluster-a")], - # cluster-a wins even though cluster-b is also viable. - want=[Candidate(name="cluster-a", gateway_address="10.0.0.1")], + all_replicas=[_replica_with_pool("my-model", "cluster-a", pool="default")], + # cluster-a wins even though cluster-b is also viable. The pin + # still matches, so it's retained with its resolved pool/requests. + want=[_cand(name="cluster-a", gateway_address="10.0.0.1")], ), Case( name="degraded pinned cluster is retained with empty gateway", deployment=_deployment(), clusters=[_cluster("cluster-a", ready=False, gateway_address="")], - all_replicas=[_replica("my-model", "cluster-a")], - # Pin survives but gateway_address is empty - callers - # must not compose routing for it. - want=[Candidate(name="cluster-a", gateway_address="")], - ), - Case( - name="degraded pinned cluster keeps gateway address if still published", - deployment=_deployment(), - clusters=[_cluster("cluster-a", ready=False, gateway_address="10.0.0.1")], - all_replicas=[_replica("my-model", "cluster-a")], - # _cluster_ready returns False so this cluster wouldn't - # be picked anew, but a retained pin still surfaces - # whatever gateway_address is published. - want=[Candidate(name="cluster-a", gateway_address="10.0.0.1")], + all_replicas=[_replica_with_pool("my-model", "cluster-a", pool="default")], + want=[_cand(name="cluster-a", gateway_address="")], ), Case( name="deleted pinned cluster triggers re-placement", deployment=_deployment(), clusters=[_cluster("cluster-b", gateway_address="10.0.0.2")], all_replicas=[_replica("my-model", "cluster-a")], - # cluster-a is gone, cluster-b takes its place. - want=[Candidate(name="cluster-b", gateway_address="10.0.0.2")], + want=[_cand(name="cluster-b", gateway_address="10.0.0.2", pool="default")], ), Case( name="scale up places new replicas on additional clusters", @@ -223,28 +326,191 @@ def test_schedule(self) -> None: clusters=[_cluster("cluster-a"), _cluster("cluster-b", gateway_address="10.0.0.2")], all_replicas=[_replica("my-model", "cluster-a")], want=[ - Candidate(name="cluster-a", gateway_address="10.0.0.1"), - Candidate(name="cluster-b", gateway_address="10.0.0.2"), + _cand(name="cluster-a", gateway_address="10.0.0.1"), + _cand(name="cluster-b", gateway_address="10.0.0.2", pool="default"), ], ), Case( name="scale up with no extra capacity returns only retained", deployment=_deployment(replicas=2), - clusters=[_cluster("cluster-a")], - all_replicas=[_replica("my-model", "cluster-a")], - # Only cluster-a exists - cluster-b can't be placed. - want=[Candidate(name="cluster-a", gateway_address="10.0.0.1")], + # Single-node pool, already filled by the retained replica, so no + # second replica can be placed - not even on the same cluster. + clusters=[_cluster("cluster-a", pools=[_pool("default", nodes=1)])], + all_replicas=[_replica_with_pool("my-model", "cluster-a", pool="default")], + want=[_cand(name="cluster-a", gateway_address="10.0.0.1", pool="default")], + ), + Case( + name="two replicas pack onto one cluster when it is the only option", + deployment=_deployment(replicas=2), + # One cluster, a 2-node pool, two 1-node replicas. With nowhere + # to spread, both pack onto cluster-a at indices 0 and 1. + clusters=[_cluster("cluster-a", pools=[_pool("default", nodes=2)])], + all_replicas=[], + want=[ + _cand(name="cluster-a", index=0, gateway_address="10.0.0.1", pool="default"), + _cand(name="cluster-a", index=1, gateway_address="10.0.0.1", pool="default"), + ], + ), + Case( + name="two replicas spread across two clusters before packing", + deployment=_deployment(replicas=2), + # Both clusters can hold two replicas, but we prefer one each. + clusters=[ + _cluster("cluster-a", pools=[_pool("default", nodes=2)]), + _cluster("cluster-b", gateway_address="10.0.0.2", pools=[_pool("default", nodes=2)]), + ], + all_replicas=[], + want=[ + _cand(name="cluster-a", index=0, gateway_address="10.0.0.1", pool="default"), + _cand(name="cluster-b", index=0, gateway_address="10.0.0.2", pool="default"), + ], + ), + Case( + name="three replicas spread first then pack the remainder", + deployment=_deployment(replicas=3), + # Two clusters, plenty of room. Spread gives a, b one each, then + # the third lands back on cluster-a (lowest load, name tiebreak). + clusters=[ + _cluster("cluster-a", pools=[_pool("default", nodes=4)]), + _cluster("cluster-b", gateway_address="10.0.0.2", pools=[_pool("default", nodes=4)]), + ], + all_replicas=[], + want=[ + _cand(name="cluster-a", index=0, gateway_address="10.0.0.1", pool="default"), + _cand(name="cluster-a", index=1, gateway_address="10.0.0.1", pool="default"), + _cand(name="cluster-b", index=0, gateway_address="10.0.0.2", pool="default"), + ], + ), + Case( + name="capacity forces packing past the spread preference", + deployment=_deployment(replicas=3), + # cluster-b holds one replica; cluster-a has room for the rest. + # Spread puts one on each, then the third can't fit on b (full), + # so it packs onto a. + clusters=[ + _cluster("cluster-a", pools=[_pool("default", nodes=4)]), + _cluster("cluster-b", gateway_address="10.0.0.2", pools=[_pool("default", nodes=1)]), + ], + all_replicas=[], + want=[ + _cand(name="cluster-a", index=0, gateway_address="10.0.0.1", pool="default"), + _cand(name="cluster-a", index=1, gateway_address="10.0.0.1", pool="default"), + _cand(name="cluster-b", index=0, gateway_address="10.0.0.2", pool="default"), + ], + ), + Case( + name="new replica spreads onto an empty cluster before doubling up", + deployment=_deployment(replicas=2), + # cluster-a already hosts a replica; cluster-b is empty. The new + # replica prefers empty cluster-b over packing onto a. + clusters=[ + _cluster("cluster-a", pools=[_pool("default", nodes=4)]), + _cluster("cluster-b", gateway_address="10.0.0.2", pools=[_pool("default", nodes=4)]), + ], + all_replicas=[_replica_with_pool("my-model", "cluster-a", pool="default")], + want=[ + _cand(name="cluster-a", index=0, gateway_address="10.0.0.1", pool="default"), + _cand(name="cluster-b", index=0, gateway_address="10.0.0.2", pool="default"), + ], + ), + Case( + name="new replica takes the lowest free index on a packed cluster", + deployment=_deployment(replicas=3), + # Only cluster-a exists, already hosting indices 0 and 2 (1 was + # deleted). The new replica fills the gap at index 1. + clusters=[_cluster("cluster-a", pools=[_pool("default", nodes=4)])], + all_replicas=[ + _replica_with_pool("my-model", "cluster-a", pool="default", index=0), + _replica_with_pool("my-model", "cluster-a", pool="default", index=2), + ], + want=[ + _cand(name="cluster-a", index=0, gateway_address="10.0.0.1", pool="default"), + _cand(name="cluster-a", index=1, gateway_address="10.0.0.1", pool="default"), + _cand(name="cluster-a", index=2, gateway_address="10.0.0.1", pool="default"), + ], + ), + Case( + name="scale down packs off by dropping the highest index first", + deployment=_deployment(replicas=2), + # cluster-a hosts indices 0 and 1; cluster-b hosts index 0. Three + # replicas, want two. Highest index (a/1) is dropped, keeping the + # spread across a/0 and b/0. + clusters=[ + _cluster("cluster-a", pools=[_pool("default", nodes=4)]), + _cluster("cluster-b", gateway_address="10.0.0.2", pools=[_pool("default", nodes=4)]), + ], + all_replicas=[ + _replica_with_pool("my-model", "cluster-a", pool="default", index=0), + _replica_with_pool("my-model", "cluster-a", pool="default", index=1), + _replica_with_pool("my-model", "cluster-b", pool="default", index=0), + ], + want=[ + _cand(name="cluster-a", index=0, gateway_address="10.0.0.1", pool="default"), + _cand(name="cluster-b", index=0, gateway_address="10.0.0.2", pool="default"), + ], + ), + Case( + name="retained replica is charged at its own node cost, not the new shape", + # The deployment's workers grew to pipeline=4 (4 nodes/replica), + # but the existing replica was created at pipeline=2 and is + # retained (no nodeSelector change rolls it). It still consumes + # only its original 2 nodes. The pool has 6, so a second replica + # at the new 4-node cost must still fit (6 - 2 = 4). Regression: + # charging the retained replica at the new shape (4) would leave + # 2 free and wrongly refuse the placement. + deployment=_deployment(replicas=2, pipeline=4), + clusters=[_cluster("cluster-a", pools=[_pool("default", nodes=6)])], + all_replicas=[_replica_with_pool("my-model", "cluster-a", pool="default", pipeline=2)], + want=[ + _cand(name="cluster-a", index=0, gateway_address="10.0.0.1", pool="default"), + _cand(name="cluster-a", index=1, gateway_address="10.0.0.1", pool="default"), + ], + ), + Case( + name="scale down drops from the most-loaded cluster to preserve spread", + deployment=_deployment(replicas=2), + # cluster-a hosts two replicas, cluster-b one. Scaling 3->2 must + # drop a's extra (a/1), NOT b's sole replica - otherwise we'd + # leave a packed and b empty, the opposite of spread. b's index + # is 3 (higher than a/1) to prove we drop by cluster load, not by + # a global index comparison. + clusters=[ + _cluster("cluster-a", pools=[_pool("default", nodes=4)]), + _cluster("cluster-b", gateway_address="10.0.0.2", pools=[_pool("default", nodes=4)]), + ], + all_replicas=[ + _replica_with_pool("my-model", "cluster-a", pool="default", index=0), + _replica_with_pool("my-model", "cluster-a", pool="default", index=1), + _replica_with_pool("my-model", "cluster-b", pool="default", index=3), + ], + want=[ + _cand(name="cluster-a", index=0, gateway_address="10.0.0.1", pool="default"), + _cand(name="cluster-b", index=3, gateway_address="10.0.0.2", pool="default"), + ], + ), + Case( + name="co-located replicas are both retained across a reconcile", + deployment=_deployment(replicas=2), + clusters=[_cluster("cluster-a", pools=[_pool("default", nodes=4)])], + all_replicas=[ + _replica_with_pool("my-model", "cluster-a", pool="default", index=0), + _replica_with_pool("my-model", "cluster-a", pool="default", index=1), + ], + want=[ + _cand(name="cluster-a", index=0, gateway_address="10.0.0.1", pool="default"), + _cand(name="cluster-a", index=1, gateway_address="10.0.0.1", pool="default"), + ], ), Case( - name="scale down keeps lexicographically earliest pins", + name="scale down across clusters drops higher cluster name at equal index", deployment=_deployment(replicas=1), clusters=[_cluster("cluster-a"), _cluster("cluster-b", gateway_address="10.0.0.2")], all_replicas=[ _replica("my-model", "cluster-b"), _replica("my-model", "cluster-a"), ], - # Trim to 1 - cluster-a wins by alphabetical order. - want=[Candidate(name="cluster-a", gateway_address="10.0.0.1")], + # Both at index 0, so the (index, name) tiebreak keeps cluster-a. + want=[_cand(name="cluster-a", gateway_address="10.0.0.1")], ), Case( name="new placement is alphabetical for determinism", @@ -256,35 +522,378 @@ def test_schedule(self) -> None: ], all_replicas=[], want=[ - Candidate(name="cluster-a", gateway_address="10.0.0.1"), - Candidate(name="cluster-b", gateway_address="10.0.0.2"), + _cand(name="cluster-a", gateway_address="10.0.0.1", pool="default"), + _cand(name="cluster-b", gateway_address="10.0.0.2", pool="default"), ], ), Case( - name="other deployment's replicas consume capacity", - deployment=_deployment(tensor=2), - clusters=[_cluster("cluster-a", pools=[{"countPerNode": 2, "nodes": 1}])], - # other-model occupies all 2 GPUs on cluster-a. - all_replicas=[_replica("other-model", "cluster-a", tensor=2)], + name="other deployment's replicas consume node capacity", + deployment=_deployment(pipeline=1), + clusters=[_cluster("cluster-a", pools=[_pool("default", nodes=1)])], + # other-model occupies the single node on cluster-a. + all_replicas=[_replica("other-model", "cluster-a")], want=[], ), Case( name="our own observed replicas don't double-count against us", - deployment=_deployment(tensor=2), - clusters=[_cluster("cluster-a", pools=[{"countPerNode": 2, "nodes": 1}])], - # Our previous replica on cluster-a is excluded from - # 'used' so we still fit. - all_replicas=[_replica("my-model", "cluster-a", tensor=2)], - want=[Candidate(name="cluster-a", gateway_address="10.0.0.1")], + deployment=_deployment(pipeline=1), + clusters=[_cluster("cluster-a", pools=[_pool("default", nodes=1)])], + all_replicas=[_replica_with_pool("my-model", "cluster-a", pool="default")], + # Retained on its pin: the single node it already occupies isn't + # charged against itself, so it stays rather than being evicted. + want=[_cand(name="cluster-a", gateway_address="10.0.0.1")], ), Case( name="replica labeled for our deployment but pinned to unknown cluster is ignored", deployment=_deployment(), clusters=[_cluster("cluster-b", gateway_address="10.0.0.2")], - # cluster-a no longer exists - the orphan pin is dropped - # and the slot is filled by re-placement on cluster-b. all_replicas=[_replica("my-model", "cluster-a")], - want=[Candidate(name="cluster-b", gateway_address="10.0.0.2")], + want=[_cand(name="cluster-b", gateway_address="10.0.0.2", pool="default")], + ), + ] + + for case in cases: + with self.subTest(case.name): + got = scheduling.schedule(case.deployment, case.clusters, case.all_replicas) + self.assertEqual(case.want, got, f"{case.name}: -want, +got") + + +class TestScheduleNodeSelector(unittest.TestCase): + """Tests for nodeSelector device-request matching and pool pinning.""" + + def test_node_selector(self) -> None: + cases = [ + Case( + name="matching request picks the cluster and records the pool", + deployment=_deployment(requests=[_request(cel_exprs=[_MEM_141])]), + clusters=[_cluster("cluster-a", pools=[_pool("frontier")])], + all_replicas=[], + want=[ + _cand( + name="cluster-a", + gateway_address="10.0.0.1", + pool="frontier", + device_requests=[_resolved()], + ) + ], + ), + Case( + name="non-matching request filters the cluster out", + deployment=_deployment(requests=[_request(cel_exprs=[_MEM_200])]), + clusters=[_cluster("cluster-a", pools=[_pool("frontier")])], + all_replicas=[], + want=[], + ), + Case( + name="device count not covered filters out", + # Request 8 GPUs, pool device has only 4. + deployment=_deployment(requests=[_request(count=8, cel_exprs=[_MEM_141])]), + clusters=[_cluster("cluster-a", pools=[_pool("frontier", devices=[_gpu_device(count=4)])])], + all_replicas=[], + want=[], + ), + Case( + name="synthetic NIC device matches but is not in resolved requests", + deployment=_deployment( + requests=[ + _request(name="gpu", cel_exprs=[_MEM_141]), + _request(name="nic", cel_exprs=[_IB]), + ] + ), + clusters=[ + _cluster( + "cluster-a", + pools=[_pool("frontier", devices=[_gpu_device(), _nic_device()])], + ) + ], + all_replicas=[], + # Only the claim: DRA gpu request is resolved; the synthetic nic + # matched for scheduling but isn't claimed. + want=[ + _cand( + name="cluster-a", + gateway_address="10.0.0.1", + pool="frontier", + device_requests=[_resolved(name="gpu")], + ) + ], + ), + Case( + name="multi-device: missing NIC filters the pool out", + deployment=_deployment( + requests=[ + _request(name="gpu", cel_exprs=[_MEM_141]), + _request(name="nic", cel_exprs=[_IB]), + ] + ), + clusters=[_cluster("cluster-a", pools=[_pool("frontier", devices=[_gpu_device()])])], + all_replicas=[], + want=[], + ), + Case( + name="two requests cannot both claim one single-count device", + # Two distinct requests, each matching the same single GPU + # device. DRA allocates distinct devices per request, so a + # count:1 device can satisfy only one. The pool must not match. + deployment=_deployment( + requests=[ + _request(name="gpu-a", cel_exprs=[_MEM_141]), + _request(name="gpu-b", cel_exprs=[_MEM_141]), + ] + ), + clusters=[_cluster("cluster-a", pools=[_pool("frontier", devices=[_gpu_device(count=1)])])], + all_replicas=[], + want=[], + ), + Case( + name="two requests against one device must fit within its count", + # Two count:5 requests need 10 GPUs total; the device has 8. + # Capacity is consumed across requests, so the pool must not + # match (regression: an earlier version checked each request + # against the full device count independently). + deployment=_deployment( + requests=[ + _request(name="gpu-a", count=5, cel_exprs=[_MEM_141]), + _request(name="gpu-b", count=5, cel_exprs=[_MEM_141]), + ] + ), + clusters=[_cluster("cluster-a", pools=[_pool("frontier", devices=[_gpu_device(count=8)])])], + all_replicas=[], + want=[], + ), + Case( + name="two requests sharing a device fit when count covers both", + # 8-GPU device, two count:4 requests = 8 total. Both resolve. + deployment=_deployment( + requests=[ + _request(name="gpu-a", count=4, cel_exprs=[_MEM_141]), + _request(name="gpu-b", count=4, cel_exprs=[_MEM_141]), + ] + ), + clusters=[_cluster("cluster-a", pools=[_pool("frontier", devices=[_gpu_device(count=8)])])], + all_replicas=[], + want=[ + _cand( + name="cluster-a", + gateway_address="10.0.0.1", + pool="frontier", + device_requests=[ + _resolved(name="gpu-a", count=4), + _resolved(name="gpu-b", count=4), + ], + ) + ], + ), + Case( + name="first matching pool wins (deterministic)", + # Both pools carry a claimable GPU; the synthetic NIC's link type + # is the discriminator. Only the infiniband pool satisfies the + # nic selector, so it wins regardless of pool order. + deployment=_deployment( + requests=[ + _request(name="gpu", cel_exprs=[_MEM_141]), + _request(name="nic", cel_exprs=[_IB]), + ] + ), + clusters=[ + _cluster( + "cluster-a", + pools=[ + _pool("dev", devices=[_gpu_device(), _nic_device(link_type="gpudirect-tcpx")]), + _pool("frontier", devices=[_gpu_device(), _nic_device(link_type="infiniband")]), + ], + ) + ], + all_replicas=[], + want=[ + _cand( + name="cluster-a", + gateway_address="10.0.0.1", + pool="frontier", + device_requests=[_resolved(name="gpu")], + ) + ], + ), + Case( + name="synthetic-only selector leaves nothing to claim, pool ineligible", + # The sole request matches a synthetic NIC. The replica's serving + # workload would have no ResourceClaim to bind GPUs through, so + # the pool is not a viable host and nothing is scheduled. + deployment=_deployment(requests=[_request(name="nic", cel_exprs=[_IB])]), + clusters=[ + _cluster( + "cluster-a", + pools=[_pool("frontier", devices=[_gpu_device(), _nic_device(link_type="infiniband")])], + ) + ], + all_replicas=[], + want=[], + ), + Case( + name="retained replica keeps its pinned pool", + deployment=_deployment(requests=[_request(cel_exprs=[_MEM_141])]), + clusters=[_cluster("cluster-a", pools=[_pool("frontier")])], + all_replicas=[_replica_with_pool("my-model", "cluster-a", pool="frontier")], + want=[ + _cand( + name="cluster-a", + gateway_address="10.0.0.1", + pool="frontier", + device_requests=[_resolved()], + ) + ], + ), + Case( + name="selector drift re-places replica onto a now-matching pool", + # A claimable GPU keeps both pools viable hosts; the synthetic + # NIC's link type is the drifting discriminator. + deployment=_deployment( + requests=[ + _request(name="gpu", cel_exprs=[_MEM_141]), + _request(name="nic", cel_exprs=[_IB]), + ] + ), + clusters=[ + _cluster( + "cluster-a", + pools=[ + _pool("a", devices=[_gpu_device(), _nic_device(link_type="gpudirect-tcpx")]), + _pool("b", devices=[_gpu_device(), _nic_device(link_type="infiniband")]), + ], + ) + ], + all_replicas=[_replica_with_pool("my-model", "cluster-a", pool="a")], + want=[ + _cand( + name="cluster-a", + gateway_address="10.0.0.1", + pool="b", + device_requests=[_resolved(name="gpu")], + ) + ], + ), + Case( + name="pinned pool that still matches stays pinned (attribute drift is sticky)", + deployment=_deployment( + requests=[ + _request(name="gpu", cel_exprs=[_MEM_141]), + _request(name="nic", cel_exprs=[_IB]), + ] + ), + clusters=[ + _cluster( + "cluster-a", + pools=[ + _pool("a", devices=[_gpu_device(), _nic_device(link_type="infiniband")]), + _pool("b", devices=[_gpu_device(), _nic_device(link_type="infiniband")]), + ], + ) + ], + all_replicas=[_replica_with_pool("my-model", "cluster-a", pool="a")], + want=[ + _cand( + name="cluster-a", + gateway_address="10.0.0.1", + pool="a", + device_requests=[_resolved(name="gpu")], + ) + ], + ), + Case( + name="no matching pool anywhere drops the replica entirely", + deployment=_deployment( + requests=[ + _request(name="gpu", cel_exprs=[_MEM_141]), + _request(name="nic", cel_exprs=[_IB]), + ] + ), + clusters=[ + _cluster( + "cluster-a", + pools=[_pool("a", devices=[_gpu_device(), _nic_device(link_type="gpudirect-tcpx")])], + ) + ], + all_replicas=[_replica_with_pool("my-model", "cluster-a", pool="a")], + want=[], + ), + Case( + name="replica with no pool pin is re-placed when a selector now applies", + deployment=_deployment( + requests=[ + _request(name="gpu", cel_exprs=[_MEM_141]), + _request(name="nic", cel_exprs=[_IB]), + ] + ), + clusters=[ + _cluster( + "cluster-a", + pools=[_pool("frontier", devices=[_gpu_device(), _nic_device(link_type="infiniband")])], + ) + ], + all_replicas=[_replica("my-model", "cluster-a")], + want=[ + _cand( + name="cluster-a", + gateway_address="10.0.0.1", + pool="frontier", + device_requests=[_resolved(name="gpu")], + ) + ], + ), + Case( + name="dropping a non-matching replica frees its node for the refill", + # a/0 is pinned to a pool that still matches (retained). a/1 is + # pinned to a pool no longer published, so it's dropped and will + # be re-placed. The pool has just 2 nodes; both are notionally in + # use by a/0 and a/1. The refill must see a/1's node freeing up + # (it's being deleted) and re-place onto frontier at index 1. + # Regression: the ledger must not charge dropped replicas. + deployment=_deployment(replicas=2, requests=[_request(name="gpu", cel_exprs=[_MEM_141])]), + clusters=[_cluster("cluster-a", pools=[_pool("frontier", nodes=2)])], + all_replicas=[ + _replica_with_pool("my-model", "cluster-a", pool="frontier", index=0), + _replica_with_pool("my-model", "cluster-a", pool="gone", index=1), + ], + want=[ + _cand( + name="cluster-a", + index=0, + gateway_address="10.0.0.1", + pool="frontier", + device_requests=[_resolved()], + ), + _cand( + name="cluster-a", + index=1, + gateway_address="10.0.0.1", + pool="frontier", + device_requests=[_resolved()], + ), + ], + ), + Case( + name="device count is checked against the pinned pool, not a cluster-wide sum", + # Request 8 GPUs. Pool 'a' has 4/node (doesn't fit); pool 'b' + # has 8 and does. The replica must pin to 'b'. + deployment=_deployment(requests=[_request(count=8, cel_exprs=[_MEM_141])]), + clusters=[ + _cluster( + "cluster-a", + pools=[ + _pool("a", devices=[_gpu_device(count=4)]), + _pool("b", devices=[_gpu_device(count=8)]), + ], + ) + ], + all_replicas=[], + want=[ + _cand( + name="cluster-a", + gateway_address="10.0.0.1", + pool="b", + device_requests=[_resolved(count=8)], + ) + ], ), ] @@ -293,6 +902,12 @@ def test_schedule(self) -> None: got = scheduling.schedule(case.deployment, case.clusters, case.all_replicas) self.assertEqual(case.want, got, f"{case.name}: -want, +got") + def test_invalid_cel_raises(self) -> None: + """A malformed expression raises CELCompileError (caller handles it).""" + deployment = _deployment(requests=[_request(cel_exprs=["this is ) not valid ("])]) + with self.assertRaises(cel.CELCompileError): + scheduling.schedule(deployment, [_cluster("cluster-a", pools=[_pool("frontier")])], []) + if __name__ == "__main__": unittest.main() diff --git a/functions/compose-model-deployment/tests/test_semver.py b/functions/compose-model-deployment/tests/test_semver.py new file mode 100644 index 000000000..f61a0fc62 --- /dev/null +++ b/functions/compose-model-deployment/tests/test_semver.py @@ -0,0 +1,120 @@ +"""Tests for the semver module. + +The cases mirror upstream's semver CEL surface so we catch regressions against +it: every example from the semverlib.go doc comment and every applicable case +from k8s.io/apiserver/pkg/cel/library/semver_test.go is represented as a row. + +Expressions are evaluated end to end through a compiled CEL selector (the device +activation is built but unused). Value-returning expressions are wrapped in a +`== ` comparison so each case asserts a single bool. + +Upstream cases that don't apply: the compile-time overload error +(isSemver([1,2,3])) - celpy doesn't type-check overloads; and the runtime parse +error for semver("v1.0") - upstream raises, we treat a bad version as a +non-match (driven through the parse layer in TestParseRejects). +""" + +import dataclasses +import unittest + +from function import cel, semver + + +def _eval(expr: str) -> bool: + """Compile and evaluate a deviceless boolean CEL expression.""" + return cel.compile_selector(expr).matches({}) + + +@dataclasses.dataclass +class Case: + name: str + expr: str + want: bool + + +@dataclasses.dataclass +class ParseErrCase: + name: str + input: str + + +class TestSemverCEL(unittest.TestCase): + """Mirrors semver_test.go TestSemver (and the doc-comment examples).""" + + def test_semver(self) -> None: + cases = [ + # parse + doc-comment examples. + Case(name="parse", expr='semver("1.2.3").compareTo(semver("1.2.3")) == 0', want=True), + Case(name="parse with prerelease", expr='semver("0.1.0-alpha.1").major() == 0', want=True), + # isSemver strict. + Case(name="isSemver full", expr='isSemver("1.2.3-beta.1+build.1")', want=True), + Case(name="isSemver simple", expr='isSemver("1.0.0")', want=True), + Case(name="isSemver hello", expr='isSemver("hello")', want=False), + Case(name="isSemver empty false", expr='isSemver("")', want=False), + Case(name="isSemver v prefix false", expr='isSemver("v1.0.0")', want=False), + Case(name="isSemver v1.0 false", expr='isSemver("v1.0")', want=False), + Case(name="isSemver leading whitespace false", expr='isSemver(" 1.0.0")', want=False), + Case(name="isSemver inner whitespace false", expr='isSemver("1. 0.0")', want=False), + Case(name="isSemver trailing whitespace false", expr='isSemver("1.0.0 ")', want=False), + Case(name="isSemver leading zeros false", expr='isSemver("01.01.01")', want=False), + Case(name="isSemver major only false", expr='isSemver("1")', want=False), + Case(name="isSemver major minor only false", expr='isSemver("1.1")', want=False), + Case(name="isSemver 200K", expr='isSemver("200K")', want=False), + Case(name="isSemver Mi", expr='isSemver("Mi")', want=False), + # isSemver normalize overload. Normalization does NOT trim whitespace. + Case(name="isSemver empty normalize false", expr='isSemver("", true)', want=False), + Case(name="isSemver leading whitespace normalize false", expr='isSemver(" 1.0.0", true)', want=False), + Case(name="isSemver inner whitespace normalize false", expr='isSemver("1. 0.0", true)', want=False), + Case(name="isSemver trailing whitespace normalize false", expr='isSemver("1.0.0 ", true)', want=False), + Case(name="isSemver v prefix normalize true", expr='isSemver("v1.0.0", true)', want=True), + Case(name="isSemver leading zeros normalize true", expr='isSemver("01.01.01", true)', want=True), + Case(name="isSemver major only normalize true", expr='isSemver("1", true)', want=True), + Case(name="isSemver major minor only normalize true", expr='isSemver("1.1", true)', want=True), + # normalize equality and semver(...) examples. + Case(name="equality normalize", expr='semver("v01.01", true) == semver("1.1.0")', want=True), + Case(name="semver v prefix normalize major", expr='semver("v1.0.0", true).major() == 1', want=True), + Case(name="semver short normalize patch", expr='semver("1.0", true).patch() == 0', want=True), + Case(name="semver leading zeros normalize", expr='semver("01.01.01", true).minor() == 1', want=True), + # equality / comparison. + Case(name="equality reflexivity", expr='semver("1.2.3") == semver("1.2.3")', want=True), + Case(name="inequality", expr='semver("1.2.3") == semver("1.0.0")', want=False), + Case(name="less", expr='semver("1.0.0").isLessThan(semver("1.2.3"))', want=True), + Case(name="less false", expr='semver("1.0.0").isLessThan(semver("1.0.0"))', want=False), + Case(name="greater", expr='semver("1.2.3").isGreaterThan(semver("1.0.0"))', want=True), + Case(name="greater false", expr='semver("1.0.0").isGreaterThan(semver("1.0.0"))', want=False), + Case(name="compare equal", expr='semver("1.2.3").compareTo(semver("1.2.3")) == 0', want=True), + Case(name="compare less", expr='semver("1.2.3").compareTo(semver("2.0.0")) == -1', want=True), + Case(name="compare greater", expr='semver("1.2.3").compareTo(semver("0.1.2")) == 1', want=True), + # major / minor / patch. + Case(name="major", expr='semver("1.2.3").major() == 1', want=True), + Case(name="minor", expr='semver("1.2.3").minor() == 2', want=True), + Case(name="patch", expr='semver("1.2.3").patch() == 3', want=True), + # A bad version is a runtime error upstream -> non-match here. + Case(name="bad version is non-match", expr='semver("v1.0").major() == 1', want=False), + ] + for case in cases: + with self.subTest(case.name): + self.assertEqual(case.want, _eval(case.expr), f"{case.name}: -want, +got") + + +class TestParseRejects(unittest.TestCase): + """parse() (strict) rejects what blang/semver Parse rejects.""" + + def test_parse_rejects(self) -> None: + cases = [ + ParseErrCase(name="v prefix", input="v1.0"), + ParseErrCase(name="major only", input="1"), + ParseErrCase(name="major minor only", input="1.1"), + ParseErrCase(name="leading zeros", input="01.01.01"), + ParseErrCase(name="leading whitespace", input=" 1.0.0"), + ParseErrCase(name="trailing whitespace", input="1.0.0 "), + ParseErrCase(name="empty", input=""), + ParseErrCase(name="word", input="hello"), + ] + for case in cases: + with self.subTest(case.name), self.assertRaises(ValueError): + semver.parse(case.input) + + +if __name__ == "__main__": + unittest.main() diff --git a/functions/compose-model-endpoint/pyproject.toml b/functions/compose-model-endpoint/pyproject.toml index 9c714d333..b5f954850 100644 --- a/functions/compose-model-endpoint/pyproject.toml +++ b/functions/compose-model-endpoint/pyproject.toml @@ -9,7 +9,7 @@ description = "Compose an Envoy Gateway Backend from a ModelEndpoint." requires-python = ">=3.11,<3.14" license = "Apache-2.0" dependencies = [ - "crossplane-function-sdk-python>=0.12.0", + "crossplane-function-sdk-python>=0.13.0", "click>=8.1.0", "grpcio>=1.73.1", "crossplane-models", diff --git a/functions/compose-model-replica/function/backends/base.py b/functions/compose-model-replica/function/backends/base.py index ec213d877..cfde409ad 100644 --- a/functions/compose-model-replica/function/backends/base.py +++ b/functions/compose-model-replica/function/backends/base.py @@ -81,6 +81,72 @@ def apply_cache_args(args: list[str], replica: v1alpha1.ModelReplica, engine) -> return [*args, f"--model={CACHE_MOUNT_PATH}"] +# Namespace for serving workloads (and their ResourceClaimTemplate) on remote +# clusters. +REMOTE_NAMESPACE = "default" + +# Port the engine serves its OpenAI-compatible API on. A contract shared with +# the ModelEndpoint URLs, so it must not diverge between backends. +ENGINE_PORT = 8000 + +# Pod label carrying the ModelService name, used to wire a Service's selector to +# its serving pods. Both backends label pods and select on it. +LABEL_SERVING = "modelplane.ai/serving" + +# Response resource key for the DRA ResourceClaimTemplate. +RESOURCE_CLAIM_KEY = "resource-claim" + +# DRA API the ResourceClaimTemplate targets. The manifest is a raw dict wrapped +# in a provider-kubernetes Object, so no generated model is needed. +_DRA_API_VERSION = "resource.k8s.io/v1" + +# Name of the pod-level claim that references the per-replica +# ResourceClaimTemplate, and the suffix of the template's own name. Containers +# reference individual requests within the claim. +_POD_CLAIM_NAME = "devices" + +# CEL readiness query matching workloads whose all-replicas-available signal is +# an Available=True condition. Both a Deployment and a LeaderWorkerSet publish +# this condition when their desired replicas are up; neither publishes a Ready +# condition, so provider-kubernetes' DeriveFromObject policy (which only checks +# a Ready condition) can never mark them ready. The has() guard keeps the query +# false (not erroring) before the workload first writes status.conditions. +AVAILABLE_CEL = ( + 'has(object.status.conditions) && object.status.conditions.exists(c, c.type == "Available" && c.status == "True")' +) + + +def wrap_object( + provider_config: str, + manifest: dict, + *, + cel_query: str | None = None, +) -> k8sobjv1alpha1.Object: + """Wrap a raw manifest in a provider-kubernetes Object for a remote cluster. + + Readiness defaults to SuccessfulCreate: the Object is ready once applied. + That's right for resources with no meaningful runtime readiness (a Service, + an HTTPRoute, or a ResourceClaimTemplate that's never reconciled). Pass + cel_query for a workload whose readiness must reflect its observed status - + it selects the DeriveFromCelQuery policy with that query (see AVAILABLE_CEL). + """ + readiness = ( + k8sobjv1alpha1.Readiness(policy="DeriveFromCelQuery", celQuery=cel_query) + if cel_query is not None + else k8sobjv1alpha1.Readiness(policy="SuccessfulCreate") + ) + return k8sobjv1alpha1.Object( + spec=k8sobjv1alpha1.Spec( + providerConfigRef=k8sobjv1alpha1.ProviderConfigRef( + kind="ClusterProviderConfig", + name=provider_config, + ), + readiness=readiness, + forProvider=k8sobjv1alpha1.ForProvider(manifest=manifest), + ), + ) + + def engine_container(replica: v1alpha1.ModelReplica): """Return the container named 'engine'. The XRD's CEL validation guarantees exactly one exists, so this always succeeds. @@ -123,6 +189,101 @@ def select_backend(replica: v1alpha1.ModelReplica) -> str: return LLMD +def claim_template_name(replica: v1alpha1.ModelReplica) -> str: + """ResourceClaimTemplate name on the remote cluster. + + Per-replica, derived from the replica's own name so concurrent replicas of + the same deployment on one cluster don't collide. + """ + return resource.child_name(replica.metadata.name, _POD_CLAIM_NAME) + + +def engine_resources() -> dict: + """Container resources for the engine. + + GPUs bind only via DRA: the engine references the pod-level claim backed by + the replica's ResourceClaimTemplate and never sets a device-plugin + extended-resource limit. Every replica carries device requests (the XRD + requires them), so the claim always exists. + + We emit one container claim entry referencing the pod-level claim, with no + `request` field, so the entire claim (all of its device requests) is made + available to the engine. A per-request entry would need a unique `name` per + entry - resources.claims is a list-map keyed on `name` alone - and the engine + uses every device anyway, so referencing the whole claim is both correct and + simplest. + """ + return {"claims": [{"name": _POD_CLAIM_NAME}]} + + +# Taint GPU node groups carry so non-GPU pods don't land on them. A pod that +# claims a GPU must tolerate it to schedule there. With GPUs bound via DRA (not +# the device plugin's extended resource), nothing injects this toleration for us +# - the ExtendedResourceToleration admission controller only acts on +# nvidia.com/gpu resource requests, which DRA pods don't make. +_GPU_TOLERATION = {"key": "nvidia.com/gpu", "operator": "Exists", "effect": "NoSchedule"} + +# Node label identifying the pool a node belongs to. compose-eks-cluster and +# compose-gke-cluster stamp it on every node group they provision; the scheduler +# pins a replica to a pool by name, and we steer the pod onto that pool by +# selecting this label. For BYO (Existing) clusters Modelplane doesn't provision +# the nodes, so the operator must label their pool's nodes with this key for the +# pod to schedule (documented on the InferenceClass XRD). +_LABEL_POOL = "modelplane.ai/pool" + + +def place_pod(pod_spec: dict, replica: v1alpha1.ModelReplica) -> None: + """Constrain a serving pod to the placement the scheduler chose. + + Pins the pod to its scheduled node pool, wires it to claim its GPUs via DRA, + and tolerates the GPU node taint. Every pod that shares this spec - a native + Deployment pod, or an llm-d LWS leader and worker - is placed identically. + + The pool nodeSelector is what makes the scheduler's pool choice real: the + control-plane scheduler matched a pool and stamped spec.nodePoolName, but DRA + would otherwise place the pod on any pool whose devices satisfy the claim. + Without the pin the control plane's per-pool capacity accounting drifts from + where pods actually run, and a claim: Synthetic device (matched for placement + but never claimed) isn't enforced at all, since pool selection is its only + enforcement. nodePoolName is XRD-required, so it's always set. + + The DRA claim references the per-replica ResourceClaimTemplate; every replica + carries device requests (the XRD requires them), so every serving pod claims + through DRA. A template-backed claim (not a shared ResourceClaim) gives each + pod in a gang its own claim. + """ + pod_spec["nodeSelector"] = {_LABEL_POOL: replica.spec.nodePoolName} + pod_spec["resourceClaims"] = [{"name": _POD_CLAIM_NAME, "resourceClaimTemplateName": claim_template_name(replica)}] + pod_spec.setdefault("tolerations", []).append(_GPU_TOLERATION) + + +def resource_claim_template(replica: v1alpha1.ModelReplica, provider_config: str) -> k8sobjv1alpha1.Object: + """Compose a DRA ResourceClaimTemplate Object for the replica. + + Each resolved device request (stamped by compose-model-deployment from the + matched InferenceClass claim: DRA devices) becomes one DeviceRequest carrying + its DeviceClass, count, and CEL selectors verbatim. Every replica carries at + least one device request (the XRD requires them). + """ + device_requests = [] + for r in replica.spec.deviceRequests: + exactly: dict = {"deviceClassName": r.deviceClassName, "count": int(r.count or 1)} + selectors = [{"cel": {"expression": s.cel}} for s in (r.selectors or []) if s.cel] + if selectors: + exactly["selectors"] = selectors + device_requests.append({"name": r.name, "exactly": exactly}) + + return wrap_object( + provider_config, + { + "apiVersion": _DRA_API_VERSION, + "kind": "ResourceClaimTemplate", + "metadata": {"name": claim_template_name(replica), "namespace": REMOTE_NAMESPACE}, + "spec": {"spec": {"devices": {"requests": device_requests}}}, + }, + ) + + class Backend(Protocol): """Builds the cluster-level serving resources for one ModelReplica.""" diff --git a/functions/compose-model-replica/function/backends/llmd.py b/functions/compose-model-replica/function/backends/llmd.py index 7043d80e4..aed79d7eb 100644 --- a/functions/compose-model-replica/function/backends/llmd.py +++ b/functions/compose-model-replica/function/backends/llmd.py @@ -42,15 +42,6 @@ from function.backends import base -# Namespace for serving workloads on remote clusters. -_REMOTE_NAMESPACE = "default" - -# Port the engine serves the OpenAI-compatible API on. -_ENGINE_PORT = 8000 - -# Label joining the LWS pods and the Service selector (mirrors native.py). -_LABEL_SERVING = "modelplane.ai/serving" - # Label set only on the LWS leader pod. The Service selects on it so traffic # reaches the gang leader (the only pod that serves the OpenAI API for vLLM # multi-node; for symmetric engines like SGLang the API server also runs on @@ -66,19 +57,6 @@ _WORKER_BOOTSTRAP = 'exec ray start --address="$LWS_LEADER_ADDRESS:6379" --block' -def _object(provider_config: str, manifest: dict) -> k8sobjv1alpha1.Object: - return k8sobjv1alpha1.Object( - spec=k8sobjv1alpha1.Spec( - providerConfigRef=k8sobjv1alpha1.ProviderConfigRef( - kind="ClusterProviderConfig", - name=provider_config, - ), - readiness=k8sobjv1alpha1.Readiness(policy="DeriveFromObject"), - forProvider=k8sobjv1alpha1.ForProvider(manifest=manifest), - ), - ) - - def _engine_args(engine, tensor: int, pipeline: int) -> list[str]: """vLLM engine args with parallelism flags injected (only if not already set). @@ -136,8 +114,9 @@ def container(command: list[str], *, serving: bool) -> dict: c = { "name": "engine", "image": engine.image, - # GPUs PER POD (one tensor-parallel shard runs per pod in the gang). - "resources": {"limits": {"nvidia.com/gpu": str(tensor)}}, + # GPUs PER POD (one tensor-parallel shard runs per pod in the + # gang), bound via DRA through the pod-level claim. + "resources": base.engine_resources(), # vLLM tensor parallelism needs a large /dev/shm. "volumeMounts": [{"name": "dshm", "mountPath": "/dev/shm"}, *cache_volume_mounts], "command": command, @@ -149,9 +128,9 @@ def container(command: list[str], *, serving: bool) -> dict: if env: c["env"] = env if serving: - c["ports"] = [{"containerPort": _ENGINE_PORT}] + c["ports"] = [{"containerPort": base.ENGINE_PORT}] c["readinessProbe"] = { - "httpGet": {"path": "/health", "port": _ENGINE_PORT}, + "httpGet": {"path": "/health", "port": base.ENGINE_PORT}, "initialDelaySeconds": 30, "periodSeconds": 10, } @@ -162,6 +141,9 @@ def pod_spec(c: dict) -> dict: "containers": [c], "volumes": [{"name": "dshm", "emptyDir": {"medium": "Memory"}}, *cache_volumes], } + # Both the leader and worker pods pin to the scheduled pool and + # claim GPUs via DRA. + base.place_pod(spec, replica) if pull_secrets: spec["imagePullSecrets"] = pull_secrets return spec @@ -169,11 +151,11 @@ def pod_spec(c: dict) -> dict: # Only the leader serves the OpenAI API → it carries the role label the # Service selects on, plus the serving port and readiness probe. leader_pod = { - "metadata": {"labels": {_LABEL_SERVING: name, _LABEL_ROLE: "leader"}}, + "metadata": {"labels": {base.LABEL_SERVING: name, _LABEL_ROLE: "leader"}}, "spec": pod_spec(container(leader_command, serving=True)), } worker_pod = { - "metadata": {"labels": {_LABEL_SERVING: name}}, + "metadata": {"labels": {base.LABEL_SERVING: name}}, "spec": pod_spec(container(worker_command, serving=False)), } @@ -181,7 +163,7 @@ def pod_spec(c: dict) -> dict: leader_worker_set = { "apiVersion": "leaderworkerset.x-k8s.io/v1", "kind": "LeaderWorkerSet", - "metadata": {"name": name, "namespace": _REMOTE_NAMESPACE}, + "metadata": {"name": name, "namespace": base.REMOTE_NAMESPACE}, "spec": { "replicas": int(replica.spec.workers.count or 1), "leaderWorkerTemplate": { @@ -196,10 +178,10 @@ def pod_spec(c: dict) -> dict: service = { "apiVersion": "v1", "kind": "Service", - "metadata": {"name": name, "namespace": _REMOTE_NAMESPACE}, + "metadata": {"name": name, "namespace": base.REMOTE_NAMESPACE}, "spec": { - "selector": {_LABEL_SERVING: name, _LABEL_ROLE: "leader"}, - "ports": [{"port": 80, "targetPort": _ENGINE_PORT}], + "selector": {base.LABEL_SERVING: name, _LABEL_ROLE: "leader"}, + "ports": [{"port": 80, "targetPort": base.ENGINE_PORT}], }, } @@ -207,7 +189,7 @@ def pod_spec(c: dict) -> dict: http_route = { "apiVersion": "gateway.networking.k8s.io/v1", "kind": "HTTPRoute", - "metadata": {"name": name, "namespace": _REMOTE_NAMESPACE}, + "metadata": {"name": name, "namespace": base.REMOTE_NAMESPACE}, "spec": { "parentRefs": [{"name": "inference-gateway", "namespace": "modelplane-system"}], "rules": [ @@ -235,8 +217,10 @@ def pod_spec(c: dict) -> dict: }, } - return { - "model-serving": _object(pc, leader_worker_set), - "model-service": _object(pc, service), - "model-route": _object(pc, http_route), + out = { + "model-serving": base.wrap_object(pc, leader_worker_set, cel_query=base.AVAILABLE_CEL), + "model-service": base.wrap_object(pc, service), + "model-route": base.wrap_object(pc, http_route), } + out[base.RESOURCE_CLAIM_KEY] = base.resource_claim_template(replica, pc) + return out diff --git a/functions/compose-model-replica/function/backends/native.py b/functions/compose-model-replica/function/backends/native.py index 75c4e8c1d..f9314ceff 100644 --- a/functions/compose-model-replica/function/backends/native.py +++ b/functions/compose-model-replica/function/backends/native.py @@ -11,28 +11,6 @@ from function.backends import base -# Namespace for serving workloads on remote clusters. -_REMOTE_NAMESPACE = "default" - -# Port the engine serves the OpenAI-compatible API on. -_ENGINE_PORT = 8000 - -# Label joining the Deployment, its pods, and the Service selector. -_LABEL_SERVING = "modelplane.ai/serving" - - -def _object(provider_config: str, manifest: dict) -> k8sobjv1alpha1.Object: - return k8sobjv1alpha1.Object( - spec=k8sobjv1alpha1.Spec( - providerConfigRef=k8sobjv1alpha1.ProviderConfigRef( - kind="ClusterProviderConfig", - name=provider_config, - ), - readiness=k8sobjv1alpha1.Readiness(policy="DeriveFromObject"), - forProvider=k8sobjv1alpha1.ForProvider(manifest=manifest), - ), - ) - class NativeBackend: def build( @@ -46,7 +24,7 @@ def build( # the deployment — so multiple replicas of one deployment can co-exist on # the same InferenceCluster without colliding on the remote cluster. name = replica.metadata.name - labels = {_LABEL_SERVING: name} + labels = {base.LABEL_SERVING: name} cache_volumes, cache_volume_mounts = base.cache_mounts(replica) args = base.apply_cache_args(list(engine.args or []), replica, engine) @@ -55,12 +33,14 @@ def build( "name": "engine", "image": engine.image, "args": args, - "ports": [{"containerPort": _ENGINE_PORT}], - "resources": {"limits": {"nvidia.com/gpu": str(replica.spec.workers.topology.tensor)}}, + "ports": [{"containerPort": base.ENGINE_PORT}], + # GPUs bind via DRA: the engine references the pod-level claim backed + # by the replica's ResourceClaimTemplate. + "resources": base.engine_resources(), # vLLM tensor parallelism needs a large /dev/shm. "volumeMounts": [{"name": "dshm", "mountPath": "/dev/shm"}, *cache_volume_mounts], "readinessProbe": { - "httpGet": {"path": "/health", "port": _ENGINE_PORT}, + "httpGet": {"path": "/health", "port": base.ENGINE_PORT}, "initialDelaySeconds": 30, "periodSeconds": 10, }, @@ -74,6 +54,8 @@ def build( "containers": [container], "volumes": [{"name": "dshm", "emptyDir": {"medium": "Memory"}}, *cache_volumes], } + # Pin to the scheduled pool and claim GPUs via DRA. + base.place_pod(pod_spec, replica) tmpl = replica.spec.workers.template if tmpl.spec.imagePullSecrets: pod_spec["imagePullSecrets"] = [s.model_dump(exclude_none=True) for s in tmpl.spec.imagePullSecrets] @@ -81,7 +63,7 @@ def build( deployment = { "apiVersion": "apps/v1", "kind": "Deployment", - "metadata": {"name": name, "namespace": _REMOTE_NAMESPACE}, + "metadata": {"name": name, "namespace": base.REMOTE_NAMESPACE}, "spec": { "replicas": int(replica.spec.workers.count or 1), "selector": {"matchLabels": labels}, @@ -92,14 +74,14 @@ def build( service = { "apiVersion": "v1", "kind": "Service", - "metadata": {"name": name, "namespace": _REMOTE_NAMESPACE}, - "spec": {"selector": labels, "ports": [{"port": 80, "targetPort": _ENGINE_PORT}]}, + "metadata": {"name": name, "namespace": base.REMOTE_NAMESPACE}, + "spec": {"selector": labels, "ports": [{"port": 80, "targetPort": base.ENGINE_PORT}]}, } http_route = { "apiVersion": "gateway.networking.k8s.io/v1", "kind": "HTTPRoute", - "metadata": {"name": name, "namespace": _REMOTE_NAMESPACE}, + "metadata": {"name": name, "namespace": base.REMOTE_NAMESPACE}, "spec": { "parentRefs": [{"name": "inference-gateway", "namespace": "modelplane-system"}], "rules": [ @@ -128,8 +110,10 @@ def build( }, } - return { - "model-serving": _object(pc, deployment), - "model-service": _object(pc, service), - "model-route": _object(pc, http_route), + out = { + "model-serving": base.wrap_object(pc, deployment, cel_query=base.AVAILABLE_CEL), + "model-service": base.wrap_object(pc, service), + "model-route": base.wrap_object(pc, http_route), } + out[base.RESOURCE_CLAIM_KEY] = base.resource_claim_template(replica, pc) + return out diff --git a/functions/compose-model-replica/function/fn.py b/functions/compose-model-replica/function/fn.py index 4c78b77b5..56b1d39ce 100644 --- a/functions/compose-model-replica/function/fn.py +++ b/functions/compose-model-replica/function/fn.py @@ -56,8 +56,7 @@ def _inference_cluster( ic.status = ic.status or icv1alpha1.Status() ic.status.providerConfigRef = ic.status.providerConfigRef or icv1alpha1.ProviderConfigRef() ic.status.gateway = ic.status.gateway or icv1alpha1.Gateway() - ic.status.capacity = ic.status.capacity or icv1alpha1.Capacity() - ic.status.capacity.gpuPools = ic.status.capacity.gpuPools or [] + ic.status.gpuPools = ic.status.gpuPools or [] return ic @@ -196,8 +195,22 @@ def derive_conditions(self): ), ) - # Per-resource readiness. Only the workload (model-serving) gates XR - # readiness; the Service/HTTPRoute (and llm-d's pool/EPP) Objects derive - # their own readiness via provider-kubernetes DeriveFromObject. - if MODEL_RESOURCE_KEY in self.rsp.desired.resources and serving_ready: - self.rsp.desired.resources[MODEL_RESOURCE_KEY].ready = fnv1.READY_TRUE + # Per-resource readiness. Crossplane gates the XR's Ready on every + # composed resource being ready, so the function must mark each one - a + # composed resource isn't ready just because provider-kubernetes set its + # Object's own Ready condition. Marking a resource ready asserts the + # function observed it ready, so we only ever mark a resource we can see + # in observed state. The workload (model-serving) additionally gates on + # the model actually serving; the Service, HTTPRoute, and + # ResourceClaimTemplate have no runtime readiness to wait on (existing is + # being ready), so observing them is enough. A freshly composed resource + # isn't in observed yet, so it stays unready until the next reconcile + # sees it applied. + for key in self.rsp.desired.resources: + if key not in self.req.observed.resources: + continue + if key == MODEL_RESOURCE_KEY: + if serving_ready: + self.rsp.desired.resources[key].ready = fnv1.READY_TRUE + else: + self.rsp.desired.resources[key].ready = fnv1.READY_TRUE diff --git a/functions/compose-model-replica/pyproject.toml b/functions/compose-model-replica/pyproject.toml index 4d60e4967..b776af114 100644 --- a/functions/compose-model-replica/pyproject.toml +++ b/functions/compose-model-replica/pyproject.toml @@ -9,7 +9,7 @@ description = "Deploy a model on a single InferenceCluster." requires-python = ">=3.11,<3.14" license = "Apache-2.0" dependencies = [ - "crossplane-function-sdk-python>=0.12.0", + "crossplane-function-sdk-python>=0.13.0", "click>=8.1.0", "grpcio>=1.73.1", "crossplane-models", diff --git a/functions/compose-model-replica/tests/test_backends.py b/functions/compose-model-replica/tests/test_backends.py index cfefd7802..6919353bd 100644 --- a/functions/compose-model-replica/tests/test_backends.py +++ b/functions/compose-model-replica/tests/test_backends.py @@ -10,6 +10,7 @@ import dataclasses import unittest +from crossplane.function import resource from function.backends import base, dynamo, llmd, native from models.ai.modelplane.inferencecluster import v1alpha1 as icv1alpha1 from models.ai.modelplane.modelreplica import v1alpha1 @@ -19,7 +20,20 @@ _ROLE = "modelplane.ai/lws-role" -def _replica(name="r", *, tensor=1, pipeline=1, args=None, command=None, namespace="ml-team"): +# A GPU device request (claim: DRA), as compose-model-deployment stamps it. +_GPU_CEL = 'device.capacity["gpu.nvidia.com"].memory.compareTo(quantity("80Gi")) >= 0' + + +def _gpu_request(count): + return v1alpha1.DeviceRequest( + name="gpu", + deviceClassName="gpu.nvidia.com", + count=count, + selectors=[v1alpha1.Selector(cel=_GPU_CEL)], + ) + + +def _replica(name="r", *, tensor=1, pipeline=1, args=None, command=None, namespace="ml-team", device_requests=None): container = v1alpha1.Container( name="engine", image="vllm/vllm-openai:latest", @@ -27,17 +41,45 @@ def _replica(name="r", *, tensor=1, pipeline=1, args=None, command=None, namespa ) if command is not None: container.command = command - return v1alpha1.ModelReplica( - metadata=metav1.ObjectMeta(name=name, namespace=namespace), - spec=v1alpha1.SpecModel( - clusterName="cluster-a", - workers=v1alpha1.Workers( - count=1, - topology=v1alpha1.Topology(tensor=tensor, pipeline=pipeline), - template=v1alpha1.Template(spec=v1alpha1.Spec(containers=[container])), - ), + # nodePoolName and deviceRequests are XRD-required: compose-model-deployment + # only composes a replica it has pinned to a matching pool with a claimable + # device, so a replica always carries both. + spec = v1alpha1.SpecModel( + clusterName="cluster-a", + nodePoolName="frontier", + deviceRequests=device_requests if device_requests is not None else [_gpu_request(1)], + workers=v1alpha1.Workers( + count=1, + topology=v1alpha1.Topology(tensor=tensor, pipeline=pipeline), + template=v1alpha1.Template(spec=v1alpha1.Spec(containers=[container])), ), ) + return v1alpha1.ModelReplica(metadata=metav1.ObjectMeta(name=name, namespace=namespace), spec=spec) + + +def _claim_template(name, count): + """The ResourceClaimTemplate manifest a replica's device requests produce.""" + return { + "apiVersion": "resource.k8s.io/v1", + "kind": "ResourceClaimTemplate", + "metadata": {"name": resource.child_name(name, "devices"), "namespace": "default"}, + "spec": { + "spec": { + "devices": { + "requests": [ + { + "name": "gpu", + "exactly": { + "deviceClassName": "gpu.nvidia.com", + "count": count, + "selectors": [{"cel": {"expression": _GPU_CEL}}], + }, + } + ] + } + } + }, + } _CLUSTER = icv1alpha1.InferenceCluster( @@ -92,7 +134,7 @@ def _route(name): "image": "vllm/vllm-openai:latest", "args": ["--model=Qwen/Qwen3-0.6B"], "ports": [{"containerPort": 8000}], - "resources": {"limits": {"nvidia.com/gpu": "2"}}, + "resources": {"claims": [{"name": "devices"}]}, "volumeMounts": [{"name": "dshm", "mountPath": "/dev/shm"}], "readinessProbe": { "httpGet": {"path": "/health", "port": 8000}, @@ -102,6 +144,11 @@ def _route(name): } ], "volumes": [{"name": "dshm", "emptyDir": {"medium": "Memory"}}], + "nodeSelector": {"modelplane.ai/pool": "frontier"}, + "resourceClaims": [ + {"name": "devices", "resourceClaimTemplateName": resource.child_name("r", "devices")} + ], + "tolerations": [{"key": "nvidia.com/gpu", "operator": "Exists", "effect": "NoSchedule"}], }, }, }, @@ -113,10 +160,14 @@ def _route(name): "spec": {"selector": {_SERVING: "r"}, "ports": [{"port": 80, "targetPort": 8000}]}, }, "model-route": _route("r"), + "resource-claim": _claim_template("r", 1), } def _lws(leader_container, worker_container): + node_selector = {"modelplane.ai/pool": "frontier"} + claims = [{"name": "devices", "resourceClaimTemplateName": resource.child_name("r", "devices")}] + tolerations = [{"key": "nvidia.com/gpu", "operator": "Exists", "effect": "NoSchedule"}] return { "apiVersion": "leaderworkerset.x-k8s.io/v1", "kind": "LeaderWorkerSet", @@ -130,6 +181,9 @@ def _lws(leader_container, worker_container): "spec": { "containers": [leader_container], "volumes": [{"name": "dshm", "emptyDir": {"medium": "Memory"}}], + "nodeSelector": node_selector, + "resourceClaims": claims, + "tolerations": tolerations, }, }, "workerTemplate": { @@ -137,6 +191,9 @@ def _lws(leader_container, worker_container): "spec": { "containers": [worker_container], "volumes": [{"name": "dshm", "emptyDir": {"medium": "Memory"}}], + "nodeSelector": node_selector, + "resourceClaims": claims, + "tolerations": tolerations, }, }, }, @@ -148,7 +205,7 @@ def _engine(command, *, serving, args=None): c = { "name": "engine", "image": "vllm/vllm-openai:latest", - "resources": {"limits": {"nvidia.com/gpu": "8"}}, + "resources": {"claims": [{"name": "devices"}]}, "volumeMounts": [{"name": "dshm", "mountPath": "/dev/shm"}], "command": command, } @@ -187,6 +244,7 @@ def _engine(command, *, serving, args=None): "spec": {"selector": {_SERVING: "r", _ROLE: "leader"}, "ports": [{"port": 80, "targetPort": 8000}]}, }, "model-route": _route("r"), + "resource-claim": _claim_template("r", 1), } # Escape hatch: the user command runs verbatim on both templates; no Ray @@ -200,6 +258,7 @@ def _engine(command, *, serving, args=None): ), "model-service": _LLMD_VLLM_WANT["model-service"], "model-route": _route("r"), + "resource-claim": _claim_template("r", 1), } @@ -247,11 +306,54 @@ def _names(out): def test_resources_named_after_replica_avoid_collision(self): # Two replicas of one deployment on the same IC must produce distinct - # workload-resource names (Nic's collision concern). + # resource names (Nic's collision concern). The workload, Service, and + # HTTPRoute share the replica name; the ResourceClaimTemplate is named + # after it too (with a -devices suffix) so it can't collide either. a = native.NativeBackend().build(_replica("dep-clusterA"), _CLUSTER) b = native.NativeBackend().build(_replica("dep-clusterB"), _CLUSTER) - self.assertEqual(self._names(a), {"dep-clusterA"}) - self.assertEqual(self._names(b), {"dep-clusterB"}) + self.assertEqual(self._names(a), {"dep-clusterA", resource.child_name("dep-clusterA", "devices")}) + self.assertEqual(self._names(b), {"dep-clusterB", resource.child_name("dep-clusterB", "devices")}) + + def test_workload_readiness_policies(self): + # The serving workload (Deployment or LWS) reports readiness from its + # Available condition via a CEL query: provider-kubernetes' + # DeriveFromObject only checks a Ready condition, which neither kind + # publishes, so it would never report ready. The Service and HTTPRoute + # have no runtime readiness worth waiting on, so they're ready on create. + for name, backend, replica in ( + ("native", native.NativeBackend(), _replica(tensor=2)), + ("llm-d", llmd.LLMDBackend(), _replica(tensor=8, pipeline=2, args=["--model=m"])), + ): + with self.subTest(name): + out = backend.build(replica, _CLUSTER) + serving = out["model-serving"].spec.readiness + self.assertEqual(serving.policy, "DeriveFromCelQuery") + self.assertEqual(serving.celQuery, base.AVAILABLE_CEL) + self.assertEqual(out["model-service"].spec.readiness.policy, "SuccessfulCreate") + self.assertEqual(out["model-route"].spec.readiness.policy, "SuccessfulCreate") + + def test_multiple_device_requests_single_container_claim(self): + # resources.claims is a list-map keyed on name alone, so N device + # requests must NOT produce N container claims all named "devices" + # (a duplicate-key violation the apiserver rejects). The container + # references the whole pod claim once; the template carries all requests. + replica = _replica( + tensor=8, + device_requests=[ + v1alpha1.DeviceRequest(name="gpu", deviceClassName="gpu.nvidia.com", count=8), + v1alpha1.DeviceRequest(name="nic", deviceClassName="nic.nvidia.com", count=8), + ], + ) + out = native.NativeBackend().build(replica, _CLUSTER) + pod = out["model-serving"].spec.forProvider.manifest["spec"]["template"]["spec"] + claims = pod["containers"][0]["resources"]["claims"] + self.assertEqual(claims, [{"name": "devices"}]) + self.assertEqual(pod["resourceClaims"][0]["name"], "devices") + template_requests = out["resource-claim"].spec.forProvider.manifest["spec"]["spec"]["devices"]["requests"] + self.assertEqual([r["name"] for r in template_requests], ["gpu", "nic"]) + # A ResourceClaimTemplate has no runtime status, so its Object is ready on + # create rather than deriving readiness from a (never-written) status. + self.assertEqual(out["resource-claim"].spec.readiness.policy, "SuccessfulCreate") class TestBackendSelection(unittest.TestCase): @@ -284,6 +386,8 @@ class TestCacheMounts(unittest.TestCase): def _replica(self, *, cache=None, args=None, command=None): spec = v1alpha1.SpecModel( clusterName="c", + nodePoolName="frontier", + deviceRequests=[_gpu_request(1)], workers=v1alpha1.Workers( topology=v1alpha1.Topology(tensor=1, pipeline=1), template=v1alpha1.Template( @@ -343,6 +447,8 @@ def _replica(self): metadata=metav1.ObjectMeta(name="r", namespace="ml-team"), spec=v1alpha1.SpecModel( clusterName="cluster-a", + nodePoolName="frontier", + deviceRequests=[_gpu_request(1)], modelCacheRef=v1alpha1.ModelCacheRef(name="qwen"), workers=v1alpha1.Workers( topology=v1alpha1.Topology(tensor=1, pipeline=1), @@ -370,6 +476,8 @@ def _replica(self, *, command=None, args=None): metadata=metav1.ObjectMeta(name="r", namespace="ml-team"), spec=v1alpha1.SpecModel( clusterName="cluster-a", + nodePoolName="frontier", + deviceRequests=[_gpu_request(8)], modelCacheRef=v1alpha1.ModelCacheRef(name="kimi"), workers=v1alpha1.Workers( count=1, diff --git a/functions/compose-model-replica/tests/test_fn.py b/functions/compose-model-replica/tests/test_fn.py index 01408774c..57e40e46f 100644 --- a/functions/compose-model-replica/tests/test_fn.py +++ b/functions/compose-model-replica/tests/test_fn.py @@ -12,6 +12,9 @@ from models.ai.modelplane.modelreplica import v1alpha1 from models.io.k8s.apimachinery.pkg.apis.meta import v1 as metav1 +# A GPU device request CEL selector, as compose-model-deployment stamps it. +_GPU_CEL = 'device.capacity["gpu.nvidia.com"].memory.compareTo(quantity("80Gi")) >= 0' + @dataclasses.dataclass class Case: @@ -47,6 +50,15 @@ async def test_compose(self) -> None: ), spec=v1alpha1.SpecModel( clusterName="cluster-a", + nodePoolName="frontier", + deviceRequests=[ + v1alpha1.DeviceRequest( + name="gpu", + deviceClassName="gpu.nvidia.com", + count=1, + selectors=[v1alpha1.Selector(cel=_GPU_CEL)], + ), + ], workers=v1alpha1.Workers( topology=v1alpha1.Topology(tensor=1), template=v1alpha1.Template( @@ -70,7 +82,10 @@ async def test_compose(self) -> None: match_name="cluster-a", ) - # Case 1: cluster resolved with providerConfigRef — composes native Deployment. + # Case 1: cluster resolved with providerConfigRef — composes native + # Deployment. First reconcile: none of the composed resources are in + # observed yet, so none are marked ready (the function only asserts + # readiness for a resource it can see in observed state). req1 = fnv1.RunFunctionRequest( observed=fnv1.State( composite=fnv1.Resource(resource=resource.dict_to_struct(xr)), @@ -109,7 +124,14 @@ async def test_compose(self) -> None: "kind": "ClusterProviderConfig", "name": "cluster-a-pc", }, - "readiness": {"policy": "DeriveFromObject"}, + "readiness": { + "policy": "DeriveFromCelQuery", + "celQuery": ( + "has(object.status.conditions) && " + "object.status.conditions.exists(" + 'c, c.type == "Available" && c.status == "True")' + ), + }, "forProvider": { "manifest": { "apiVersion": "apps/v1", @@ -138,9 +160,7 @@ async def test_compose(self) -> None: "image": "vllm/vllm-openai:latest", "args": ["--model=Qwen/Qwen3-0.6B"], "ports": [{"containerPort": 8000}], - "resources": { - "limits": {"nvidia.com/gpu": "1"}, - }, + "resources": {"claims": [{"name": "devices"}]}, "volumeMounts": [ {"name": "dshm", "mountPath": "/dev/shm"}, ], @@ -154,6 +174,22 @@ async def test_compose(self) -> None: "volumes": [ {"name": "dshm", "emptyDir": {"medium": "Memory"}}, ], + "nodeSelector": {"modelplane.ai/pool": "frontier"}, + "resourceClaims": [ + { + "name": "devices", + "resourceClaimTemplateName": resource.child_name( + "test-replica", "devices" + ), + }, + ], + "tolerations": [ + { + "key": "nvidia.com/gpu", + "operator": "Exists", + "effect": "NoSchedule", + }, + ], }, }, }, @@ -173,7 +209,7 @@ async def test_compose(self) -> None: "kind": "ClusterProviderConfig", "name": "cluster-a-pc", }, - "readiness": {"policy": "DeriveFromObject"}, + "readiness": {"policy": "SuccessfulCreate"}, "forProvider": { "manifest": { "apiVersion": "v1", @@ -202,7 +238,7 @@ async def test_compose(self) -> None: "kind": "ClusterProviderConfig", "name": "cluster-a-pc", }, - "readiness": {"policy": "DeriveFromObject"}, + "readiness": {"policy": "SuccessfulCreate"}, "forProvider": { "manifest": { "apiVersion": "gateway.networking.k8s.io/v1", @@ -251,6 +287,49 @@ async def test_compose(self) -> None: } ), ), + "resource-claim": fnv1.Resource( + resource=resource.dict_to_struct( + { + "apiVersion": "kubernetes.m.crossplane.io/v1alpha1", + "kind": "Object", + "spec": { + "providerConfigRef": { + "kind": "ClusterProviderConfig", + "name": "cluster-a-pc", + }, + "readiness": {"policy": "SuccessfulCreate"}, + "forProvider": { + "manifest": { + "apiVersion": "resource.k8s.io/v1", + "kind": "ResourceClaimTemplate", + "metadata": { + "name": resource.child_name("test-replica", "devices"), + "namespace": "default", + }, + "spec": { + "spec": { + "devices": { + "requests": [ + { + "name": "gpu", + "exactly": { + "deviceClassName": "gpu.nvidia.com", + "count": 1, + "selectors": [ + {"cel": {"expression": _GPU_CEL}}, + ], + }, + }, + ], + }, + }, + }, + }, + }, + }, + } + ), + ), }, ), conditions=[ @@ -353,10 +432,64 @@ async def test_compose(self) -> None: ) want3.requirements.resources["cluster"].CopyFrom(cluster_requirement) + # Case 4: the resources from case 1 now exist in observed, and the + # workload Object reports Available (so its derived Ready is True). The + # function marks every composed resource ready (it can now observe them), + # the workload because it's serving and the rest because existing is + # being ready for them. Built from case 1, mutating only what the + # observed-ready transition changes: the four ready flags, the + # acceptance/readiness conditions, and the dropped first-reconcile event. + req4 = fnv1.RunFunctionRequest() + req4.CopyFrom(req1) + # The workload Object as provider-kubernetes observes it back: applied + # (atProvider.manifest populated) and Available (its derived Ready=True). + req4.observed.resources["model-serving"].CopyFrom( + fnv1.Resource( + resource=resource.dict_to_struct( + { + "apiVersion": "kubernetes.m.crossplane.io/v1alpha1", + "kind": "Object", + "spec": {"forProvider": {"manifest": {"kind": "Deployment"}}}, + "status": { + "atProvider": {"manifest": {"kind": "Deployment"}}, + "conditions": [ + { + "type": "Ready", + "status": "True", + "reason": "Available", + "lastTransitionTime": "2025-01-01T00:00:00Z", + }, + ], + }, + } + ), + ) + ) + # The other three are observed simply by being present; their content + # doesn't matter, only that the function can see them. + for key in ("model-service", "model-route", "resource-claim"): + req4.observed.resources[key].CopyFrom(fnv1.Resource(resource=structpb.Struct())) + + want4 = fnv1.RunFunctionResponse() + want4.CopyFrom(want1) + for key in ("model-serving", "model-service", "model-route", "resource-claim"): + want4.desired.resources[key].ready = fnv1.READY_TRUE + del want4.conditions[:] + want4.conditions.extend( + [ + fnv1.Condition(type="ModelAccepted", status=fnv1.STATUS_CONDITION_TRUE, reason="Accepted"), + fnv1.Condition(type="ModelReady", status=fnv1.STATUS_CONDITION_TRUE, reason="Serving"), + ] + ) + # The "Composing ..." event fires only the first reconcile (model-serving + # not yet observed), so it's gone now. + del want4.results[:] + cases = [ Case(name="cluster ready composes native Deployment", req=req1, want=want1), Case(name="cluster not resolved returns waiting conditions", req=req2, want=want2), Case(name="cluster without providerConfigRef returns waiting conditions", req=req3, want=want3), + Case(name="observed resources are marked ready", req=req4, want=want4), ] for case in cases: diff --git a/functions/compose-model-service/pyproject.toml b/functions/compose-model-service/pyproject.toml index b14949f0f..233348d82 100644 --- a/functions/compose-model-service/pyproject.toml +++ b/functions/compose-model-service/pyproject.toml @@ -9,7 +9,7 @@ description = "Compose a Gateway API HTTPRoute from a ModelService." requires-python = ">=3.11,<3.14" license = "Apache-2.0" dependencies = [ - "crossplane-function-sdk-python>=0.12.0", + "crossplane-function-sdk-python>=0.13.0", "click>=8.1.0", "grpcio>=1.73.1", "crossplane-models", diff --git a/functions/compose-serving-stack/function/fn.py b/functions/compose-serving-stack/function/fn.py index b1e8669d6..688685add 100644 --- a/functions/compose-serving-stack/function/fn.py +++ b/functions/compose-serving-stack/function/fn.py @@ -57,10 +57,17 @@ def _helm_release( """Build a Helm Release targeting a remote (or local) cluster.""" md = None if labels or metadata_namespace: - md = metav1.ObjectMeta(namespace=metadata_namespace, labels=labels) + # Only set fields that are present; under exclude_unset, an explicit + # namespace=None or labels=None would leak a null into the metadata. + md = metav1.ObjectMeta( + **({"namespace": metadata_namespace} if metadata_namespace is not None else {}), + **({"labels": labels} if labels is not None else {}), + ) release = helmv1beta1.Release( - metadata=md, + # Only set metadata when present (see _k8s_object: avoids a null + # metadata leaking under exclude_unset serialization). + **({"metadata": md} if md is not None else {}), spec=helmv1beta1.Spec( providerConfigRef=helmv1beta1.ProviderConfigRef( kind="ProviderConfig", @@ -89,7 +96,10 @@ def _k8s_object( ) -> k8sobjv1alpha1.Object: """Build a provider-kubernetes Object wrapping an arbitrary manifest.""" obj = k8sobjv1alpha1.Object( - metadata=metadata, + # Only set metadata when present. Under exclude_unset serialization, + # passing metadata=None would emit a null metadata into the composed + # resource rather than omitting it. + **({"metadata": metadata} if metadata is not None else {}), spec=k8sobjv1alpha1.Spec( providerConfigRef=k8sobjv1alpha1.ProviderConfigRef( kind="ProviderConfig", @@ -200,6 +210,8 @@ def compose(self): self.compose_envoy_gateway() self.compose_prometheus() self.compose_leader_worker_set() + self.compose_node_feature_discovery() + self.compose_dra_driver() self.compose_gateway() self.write_status() self.mark_readiness() @@ -472,6 +484,54 @@ def compose_leader_worker_set(self): ), ) + def compose_node_feature_discovery(self): + """Compose Node Feature Discovery. Gated on ProviderConfigs being + observed. NFD labels GPU nodes (e.g. feature.node.kubernetes.io/pci-10de + for NVIDIA) so the DRA driver can target its kubelet plugin to them.""" + pc_observed = self.provider_configs_observed() + if not (pc_observed or "node-feature-discovery" in self.req.observed.resources): + return + + v = self.xr.spec.versions or v1alpha1.Versions() + resource.update( + self.rsp.desired.resources["node-feature-discovery"], + _helm_release( + chart="node-feature-discovery", + repo="oci://registry.k8s.io/nfd/charts", + version=v.nodeFeatureDiscovery, + namespace="node-feature-discovery", + provider_config=_pc_name(self.xr), + ), + ) + + def compose_dra_driver(self): + """Compose the NVIDIA DRA driver. Gated on ProviderConfigs being + observed. The driver publishes each GPU node's devices as DRA + ResourceSlices and registers the gpu.nvidia.com DeviceClass that + ModelReplica ResourceClaims request through, replacing the legacy + device plugin. GPU allocation is opt-in (gpuResourcesEnabledOverride); + ComputeDomains (Multi-Node NVLink) is disabled - we don't use it, and + it would pull in extra prerequisites (GPU Feature Discovery).""" + pc_observed = self.provider_configs_observed() + if not (pc_observed or "dra-driver" in self.req.observed.resources): + return + + v = self.xr.spec.versions or v1alpha1.Versions() + resource.update( + self.rsp.desired.resources["dra-driver"], + _helm_release( + chart="dra-driver-nvidia-gpu", + repo="oci://registry.k8s.io/dra-driver-nvidia/charts", + version=v.nvidiaDraDriver, + namespace="dra-driver-nvidia-gpu", + provider_config=_pc_name(self.xr), + values={ + "gpuResourcesEnabledOverride": True, + "resources": {"computeDomains": {"enabled": False}}, + }, + ), + ) + def compose_gateway(self): """Compose the GatewayClass and Gateway on the remote cluster. Gated on ProviderConfigs being observed.""" @@ -587,6 +647,8 @@ def mark_readiness(self): "envoy-gateway", "prometheus", "leader-worker-set", + "node-feature-discovery", + "dra-driver", "gateway-namespace", "gateway-class", "gateway", diff --git a/functions/compose-serving-stack/pyproject.toml b/functions/compose-serving-stack/pyproject.toml index 37ef5708e..45a43f531 100644 --- a/functions/compose-serving-stack/pyproject.toml +++ b/functions/compose-serving-stack/pyproject.toml @@ -9,7 +9,7 @@ description = "Install the serving stack and supporting components on a remote c requires-python = ">=3.11,<3.14" license = "Apache-2.0" dependencies = [ - "crossplane-function-sdk-python>=0.12.0", + "crossplane-function-sdk-python>=0.13.0", "click>=8.1.0", "grpcio>=1.73.1", "crossplane-models", diff --git a/functions/compose-serving-stack/tests/test_fn.py b/functions/compose-serving-stack/tests/test_fn.py index ac6e0fe56..a37a569eb 100644 --- a/functions/compose-serving-stack/tests/test_fn.py +++ b/functions/compose-serving-stack/tests/test_fn.py @@ -306,6 +306,48 @@ def setUpModule() -> None: }, } +_NODE_FEATURE_DISCOVERY = { + "apiVersion": "helm.m.crossplane.io/v1beta1", + "kind": "Release", + "spec": { + "forProvider": { + "chart": { + "name": "node-feature-discovery", + "repository": "oci://registry.k8s.io/nfd/charts", + "version": "0.18.3", + }, + "namespace": "node-feature-discovery", + }, + "providerConfigRef": { + "kind": "ProviderConfig", + "name": _PC_NAME, + }, + }, +} + +_DRA_DRIVER = { + "apiVersion": "helm.m.crossplane.io/v1beta1", + "kind": "Release", + "spec": { + "forProvider": { + "chart": { + "name": "dra-driver-nvidia-gpu", + "repository": "oci://registry.k8s.io/dra-driver-nvidia/charts", + "version": "0.4.0", + }, + "namespace": "dra-driver-nvidia-gpu", + "values": { + "gpuResourcesEnabledOverride": True, + "resources": {"computeDomains": {"enabled": False}}, + }, + }, + "providerConfigRef": { + "kind": "ProviderConfig", + "name": _PC_NAME, + }, + }, +} + _PROMETHEUS = { "apiVersion": "helm.m.crossplane.io/v1beta1", "kind": "Release", @@ -488,6 +530,12 @@ async def test_second_pass(self) -> None: "leader-worker-set": fnv1.Resource( resource=resource.dict_to_struct(_LEADER_WORKER_SET), ), + "node-feature-discovery": fnv1.Resource( + resource=resource.dict_to_struct(_NODE_FEATURE_DISCOVERY), + ), + "dra-driver": fnv1.Resource( + resource=resource.dict_to_struct(_DRA_DRIVER), + ), "prometheus": fnv1.Resource( resource=resource.dict_to_struct(_PROMETHEUS), ), @@ -599,6 +647,12 @@ async def test_third_pass(self) -> None: "leader-worker-set": fnv1.Resource( resource=resource.dict_to_struct(_LEADER_WORKER_SET), ), + "node-feature-discovery": fnv1.Resource( + resource=resource.dict_to_struct(_NODE_FEATURE_DISCOVERY), + ), + "dra-driver": fnv1.Resource( + resource=resource.dict_to_struct(_DRA_DRIVER), + ), "prometheus": fnv1.Resource( resource=resource.dict_to_struct(_PROMETHEUS), ), diff --git a/pyproject.toml b/pyproject.toml index 7b70283d8..db599a2db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ members = ["functions/*", "schemas/python"] [dependency-groups] dev = [ - "crossplane-function-sdk-python>=0.12.0", + "crossplane-function-sdk-python>=0.13.0", ] [tool.ruff] diff --git a/schemas/.lock.json b/schemas/.lock.json index 967d7c4de..1ddc95812 100644 --- a/schemas/.lock.json +++ b/schemas/.lock.json @@ -1 +1 @@ -{"packages":{"fs://apis":"c2e3dcb521ee74db1bbb2a224eefe85673974b147cfc0830b002548e2a5fef43","git://https://github.com/crossplane/crossplane/cluster/crds":"32300d8ac6b01bb66702187eea8dee208511c75b","xpkg://xpkg.upbound.io/upbound/provider-aws-ec2:v2.5.0":"sha256:956e2688224c7b42fca94864991478b4ecfec9c9cf52f1cdc42300c1110cece8","xpkg://xpkg.upbound.io/upbound/provider-aws-eks:v2.5.0":"sha256:c51761a15be10295b2c95581448b180889653c20296ebac646624a04f3bf634e","xpkg://xpkg.upbound.io/upbound/provider-aws-iam:v2.5.0":"sha256:854fa2fe081872032cd528d29be58c47e7468744eba7da24b1214aa09610f836","xpkg://xpkg.upbound.io/upbound/provider-gcp-cloudplatform:v2.5.0":"sha256:c84812c0e07576ae1c14103a6f1e69cd1a77b341c5b7939e29c269dd8dc30e03","xpkg://xpkg.upbound.io/upbound/provider-gcp-compute:v2.5.0":"sha256:324c89628bad85f27202dd4322caf969e7d354c9d9895695984301533c939799","xpkg://xpkg.upbound.io/upbound/provider-gcp-container:v2.5.0":"sha256:6e3bb9a041cc1c06b2940904b9c865fe0ea0dd7229e7017dd768e7a55eb18669","xpkg://xpkg.upbound.io/upbound/provider-helm:v1.2.3":"sha256:d81cbe87c15c8555c8388bbb372387084ce8fefdf9a08d0a540a6b4d228a0049","xpkg://xpkg.upbound.io/upbound/provider-helm:v1.2.4":"sha256:e5896e93156845d4f3005abbffa5f1d9468b79944be54203c675f8b61ea54e19","xpkg://xpkg.upbound.io/upbound/provider-kubernetes:v1.2.4":"sha256:b32140022ee86530424da210ed8544f561592a350dc3467365719ffde8c124e1","xpkg://xpkg.upbound.io/upbound/provider-kubernetes:v1.2.5":"sha256:8c9788629608066abc5ba8ae7039214aeaa89619d810a0bfc337102c4f4a897d"}} \ No newline at end of file +{"packages":{"fs://apis":"319f7f96ce94d2299fc4538b432e579553e27290ceaaff8e226f1e51c8624665","git://https://github.com/crossplane/crossplane/cluster/crds":"32300d8ac6b01bb66702187eea8dee208511c75b","xpkg://xpkg.upbound.io/upbound/provider-aws-ec2:v2.5.0":"sha256:956e2688224c7b42fca94864991478b4ecfec9c9cf52f1cdc42300c1110cece8","xpkg://xpkg.upbound.io/upbound/provider-aws-eks:v2.5.0":"sha256:c51761a15be10295b2c95581448b180889653c20296ebac646624a04f3bf634e","xpkg://xpkg.upbound.io/upbound/provider-aws-iam:v2.5.0":"sha256:854fa2fe081872032cd528d29be58c47e7468744eba7da24b1214aa09610f836","xpkg://xpkg.upbound.io/upbound/provider-gcp-cloudplatform:v2.5.0":"sha256:c84812c0e07576ae1c14103a6f1e69cd1a77b341c5b7939e29c269dd8dc30e03","xpkg://xpkg.upbound.io/upbound/provider-gcp-compute:v2.5.0":"sha256:324c89628bad85f27202dd4322caf969e7d354c9d9895695984301533c939799","xpkg://xpkg.upbound.io/upbound/provider-gcp-container:v2.5.0":"sha256:6e3bb9a041cc1c06b2940904b9c865fe0ea0dd7229e7017dd768e7a55eb18669","xpkg://xpkg.upbound.io/upbound/provider-helm:v1.2.3":"sha256:d81cbe87c15c8555c8388bbb372387084ce8fefdf9a08d0a540a6b4d228a0049","xpkg://xpkg.upbound.io/upbound/provider-helm:v1.2.4":"sha256:e5896e93156845d4f3005abbffa5f1d9468b79944be54203c675f8b61ea54e19","xpkg://xpkg.upbound.io/upbound/provider-kubernetes:v1.2.4":"sha256:b32140022ee86530424da210ed8544f561592a350dc3467365719ffde8c124e1","xpkg://xpkg.upbound.io/upbound/provider-kubernetes:v1.2.5":"sha256:8c9788629608066abc5ba8ae7039214aeaa89619d810a0bfc337102c4f4a897d"}} \ No newline at end of file diff --git a/schemas/python/models/ai/modelplane/inferenceclass/v1alpha1.py b/schemas/python/models/ai/modelplane/inferenceclass/v1alpha1.py index 4cc263658..b9cea5a88 100644 --- a/schemas/python/models/ai/modelplane/inferenceclass/v1alpha1.py +++ b/schemas/python/models/ai/modelplane/inferenceclass/v1alpha1.py @@ -3,10 +3,9 @@ from __future__ import annotations -from datetime import datetime -from typing import Dict, List, Literal, Optional +from typing import Literal -from pydantic import BaseModel, conint, constr +from pydantic import AwareDatetime, BaseModel, Field, conint, constr from ....io.k8s.apimachinery.pkg.apis.meta import v1 @@ -20,27 +19,75 @@ class CompositionRevisionRef(BaseModel): class CompositionRevisionSelector(BaseModel): - matchLabels: Dict[str, str] + matchLabels: dict[str, str] class CompositionSelector(BaseModel): - matchLabels: Dict[str, str] + matchLabels: dict[str, str] class ResourceRef(BaseModel): apiVersion: str kind: str - name: Optional[str] = None - namespace: Optional[str] = None + name: str | None = None + namespace: str | None = None class Crossplane(BaseModel): - compositionRef: Optional[CompositionRef] = None - compositionRevisionRef: Optional[CompositionRevisionRef] = None - compositionRevisionSelector: Optional[CompositionRevisionSelector] = None - compositionSelector: Optional[CompositionSelector] = None - compositionUpdatePolicy: Optional[Literal['Automatic', 'Manual']] = None - resourceRefs: Optional[List[ResourceRef]] = None + compositionRef: CompositionRef | None = None + compositionRevisionRef: CompositionRevisionRef | None = None + compositionRevisionSelector: CompositionRevisionSelector | None = None + compositionSelector: CompositionSelector | None = None + compositionUpdatePolicy: Literal['Automatic', 'Manual'] | None = None + resourceRefs: list[ResourceRef] | None = None + + +class Attributes(BaseModel): + bool_: bool | None = Field(None, alias='bool') + int_: int | None = Field(None, alias='int') + string: constr(max_length=253) | None = None + version: constr(max_length=32) | None = None + """ + Semantic version (e.g. "9.0.0"). + """ + + +class Capacity(BaseModel): + value: constr(min_length=1, max_length=32) + """ + A Kubernetes Quantity (e.g. "141Gi"). + """ + + +class Device(BaseModel): + attributes: dict[str, Attributes] | None = Field(None, max_length=32) + """ + DRA-style typed attributes for this device. Keys are bare names (e.g. architecture); the domain comes from the device's driver. Each value sets exactly one typed field. + """ + capacity: dict[str, Capacity] | None = Field(None, max_length=32) + """ + DRA-style capacity quantities for this device. Keys are bare names (e.g. memory); values are Kubernetes Quantities. + """ + claim: Literal['DRA', 'Synthetic'] | None = 'DRA' + """ + How Modelplane treats this device. DRA emits it as a request in a ResourceClaim, so DRA binds a matching device to the pod at admission time; use it for hardware a real DRA driver exposes. Synthetic describes the device for fleet scheduling only and never claims it; use it for hardware that matters for placement but has no DRA driver yet, like an InfiniBand fabric. + """ + count: conint(ge=1, le=64) | None = 1 + """ + How many of this device a node has. + """ + deviceClassName: constr(min_length=1, max_length=253) | None = None + """ + Name of the cluster-scoped DRA DeviceClass to claim this device through. Required for claim: DRA devices; the DRA driver install creates the DeviceClass (e.g. gpu.nvidia.com). Ignored for Synthetic devices. + """ + driver: constr(min_length=1, max_length=253) + """ + DRA driver that owns this device (e.g. gpu.nvidia.com). Becomes the attribute/capacity domain a nodeSelector reads as device.attributes[""].. + """ + name: constr(min_length=1, max_length=63) + """ + Name of this device within the class (e.g. gpu, nic). + """ class Accelerator(BaseModel): @@ -53,7 +100,10 @@ class Accelerator(BaseModel): class Eks(BaseModel): accelerator: Accelerator - diskSizeGb: Optional[conint(ge=10)] = 100 + """ + GPU accelerator to attach when provisioning the node group. Provisioning input only: the scheduler matches against spec.devices, not this block. + """ + diskSizeGb: conint(ge=10) | None = 100 instanceType: constr(min_length=1) """ EC2 instance type (e.g. g6.xlarge, p4d.24xlarge). The instance family determines the GPU model; the accelerator block below is informational. @@ -70,97 +120,86 @@ class AcceleratorModel(BaseModel): class Gke(BaseModel): accelerator: AcceleratorModel - diskSizeGb: Optional[conint(ge=10)] = 100 + """ + GPU accelerator to attach when provisioning the node pool. Provisioning input only: the scheduler matches against spec.devices, not this block, so count here is the GCP machine's GPU count and need not be restated in devices. + """ + diskSizeGb: conint(ge=10) | None = 100 machineType: constr(min_length=1) class Provisioning(BaseModel): - eks: Optional[Eks] = None - gke: Optional[Gke] = None + eks: Eks | None = None + gke: Gke | None = None provider: Literal['GKE', 'EKS'] -class Gpu(BaseModel): - count: conint(ge=1, le=16) - """ - GPUs per node. - """ - memory: constr(min_length=1, max_length=16) - """ - Per-GPU VRAM (e.g. "24Gi", "80Gi"). - """ - - -class Resources(BaseModel): - gpu: Gpu - - class Spec(BaseModel): - crossplane: Optional[Crossplane] = None + crossplane: Crossplane | None = None """ Configures how Crossplane will reconcile this composite resource """ - description: Optional[str] = None + description: str | None = None """ Human-readable description of the class. """ - provisioning: Optional[Provisioning] = None + devices: list[Device] = Field(..., max_length=16, min_length=1) """ - How to provision a node pool of this class. Omit for classes that describe BYO node pools that already exist. + Devices a node of this class has, following DRA's model (KEP-4381). Each entry describes one kind of device with a count, mirroring what a DRA driver publishes in a ResourceSlice (one entry per kind rather than per physical device). ModelDeployment.nodeSelector matches against these, and claim: DRA devices are emitted as requests in a DRA ResourceClaim when scheduling a worker to this pool. + A scheduled worker pod is pinned to its pool with a nodeSelector on the modelplane.ai/pool node label. Modelplane-provisioned (EKS, GKE) pools carry this label automatically. On a BYO (Existing) cluster Modelplane doesn't provision the nodes, so the operator must label the pool's nodes modelplane.ai/pool= themselves, or worker pods for this class will stay Pending. """ - resources: Resources + provisioning: Provisioning | None = None """ - Hardware resources a node of this class exposes. + How to provision a node pool of this class. Omit for classes that describe BYO node pools that already exist. """ class Condition(BaseModel): - lastTransitionTime: datetime - message: Optional[str] = None - observedGeneration: Optional[int] = None + lastTransitionTime: AwareDatetime + message: str | None = None + observedGeneration: int | None = None reason: str status: str type: str class Status(BaseModel): - conditions: Optional[List[Condition]] = None + conditions: list[Condition] | None = None """ Conditions of the resource. """ class InferenceClass(BaseModel): - apiVersion: Optional[Literal['modelplane.ai/v1alpha1']] = 'modelplane.ai/v1alpha1' + apiVersion: Literal['modelplane.ai/v1alpha1'] | None = 'modelplane.ai/v1alpha1' """ APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources """ - kind: Optional[Literal['InferenceClass']] = 'InferenceClass' + kind: Literal['InferenceClass'] | None = 'InferenceClass' """ Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds """ - metadata: Optional[v1.ObjectMeta] = None + metadata: v1.ObjectMeta | None = None """ Standard object's metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata """ spec: Spec - status: Optional[Status] = None + status: Status | None = None class InferenceClassList(BaseModel): - apiVersion: Optional[str] = None + apiVersion: str | None = None """ APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources """ - items: List[InferenceClass] + items: list[InferenceClass] """ List of inferenceclasses. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md """ - kind: Optional[str] = None + kind: str | None = None """ Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds """ - metadata: Optional[v1.ListMeta] = None + metadata: v1.ListMeta | None = None """ Standard list metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds """ \ No newline at end of file diff --git a/schemas/python/models/ai/modelplane/inferencecluster/v1alpha1.py b/schemas/python/models/ai/modelplane/inferencecluster/v1alpha1.py index a16a4d30e..fc4a41259 100644 --- a/schemas/python/models/ai/modelplane/inferencecluster/v1alpha1.py +++ b/schemas/python/models/ai/modelplane/inferencecluster/v1alpha1.py @@ -3,27 +3,29 @@ from __future__ import annotations -from datetime import datetime -from typing import Dict, List, Literal, Optional +from typing import Literal -from pydantic import BaseModel, Field, conint, constr +from pydantic import AwareDatetime, BaseModel, Field, conint, constr from ....io.k8s.apimachinery.pkg.apis.meta import v1 class Cache(BaseModel): - storageClassName: Optional[constr(min_length=1)] = 'modelplane-rwx-efs' + storageClassName: constr(min_length=1) | None = 'modelplane-rwx-efs' """ Name of the RWX StorageClass for ModelCache PVCs. Modelplane does not currently provision EFS automatically; the admin must create an EFS file system and the EFS CSI StorageClass on the cluster. """ class Eks(BaseModel): - cache: Optional[Cache] = None + cache: Cache | None = None """ ModelCache configuration for this cluster. """ - kubernetesVersion: Optional[str] = '1.31' + kubernetesVersion: str | None = '1.36' + """ + EKS cluster Kubernetes version. Defaults to a version where Dynamic Resource Allocation (how GPUs bind to pods) is generally available. + """ region: constr(min_length=1, max_length=32) """ AWS region for the cluster (e.g. us-west-2). @@ -31,28 +33,28 @@ class Eks(BaseModel): class CacheModel(BaseModel): - storageClassName: Optional[constr(min_length=1)] = 'modelplane-rwx' + storageClassName: constr(min_length=1) | None = 'modelplane-rwx' """ Name of the RWX StorageClass for ModelCache PVCs. The admin creates the StorageClass on the workload cluster (must support ReadWriteMany dynamic provisioning). """ class IdentitySecretRef(BaseModel): - key: Optional[constr(min_length=1, max_length=253)] = 'private_key' + key: constr(min_length=1, max_length=253) | None = 'private_key' name: constr(min_length=1, max_length=253) class SecretRef(BaseModel): - key: Optional[constr(min_length=1, max_length=253)] = 'kubeconfig' + key: constr(min_length=1, max_length=253) | None = 'kubeconfig' name: constr(min_length=1, max_length=253) class Existing(BaseModel): - cache: Optional[CacheModel] = None + cache: CacheModel | None = None """ ModelCache configuration for this cluster. """ - identitySecretRef: Optional[IdentitySecretRef] = None + identitySecretRef: IdentitySecretRef | None = None """ Optional reference to a Secret containing cloud provider credentials for IAM-based authentication. """ @@ -63,32 +65,32 @@ class Existing(BaseModel): class CacheModel1(BaseModel): - storageClassName: Optional[constr(min_length=1)] = 'modelplane-rwx' + storageClassName: constr(min_length=1) | None = 'modelplane-rwx' """ Name of the RWX StorageClass for ModelCache PVCs. At the default value, Modelplane provisions Filestore Enterprise via the Filestore CSI addon and composes the StorageClass; set this to a different name to use one the admin has already created. """ class Gke(BaseModel): - cache: Optional[CacheModel1] = None + cache: CacheModel1 | None = None """ ModelCache configuration for this cluster. """ - kubernetesVersion: Optional[str] = '1.35' + kubernetesVersion: str | None = '1.35' project: constr(min_length=6, max_length=30) region: constr(min_length=1, max_length=32) class Cluster(BaseModel): - eks: Optional[Eks] = None + eks: Eks | None = None """ EKS cluster configuration. Required when source is EKS. """ - existing: Optional[Existing] = None + existing: Existing | None = None """ Bring-your-own cluster configuration. Required when source is Existing. Modelplane manages the inference stack on the cluster but does not provision the cluster itself. """ - gke: Optional[Gke] = None + gke: Gke | None = None """ GKE cluster configuration. Required when source is GKE. """ @@ -107,27 +109,27 @@ class CompositionRevisionRef(BaseModel): class CompositionRevisionSelector(BaseModel): - matchLabels: Dict[str, str] + matchLabels: dict[str, str] class CompositionSelector(BaseModel): - matchLabels: Dict[str, str] + matchLabels: dict[str, str] class ResourceRef(BaseModel): apiVersion: str kind: str - name: Optional[str] = None - namespace: Optional[str] = None + name: str | None = None + namespace: str | None = None class Crossplane(BaseModel): - compositionRef: Optional[CompositionRef] = None - compositionRevisionRef: Optional[CompositionRevisionRef] = None - compositionRevisionSelector: Optional[CompositionRevisionSelector] = None - compositionSelector: Optional[CompositionSelector] = None - compositionUpdatePolicy: Optional[Literal['Automatic', 'Manual']] = None - resourceRefs: Optional[List[ResourceRef]] = None + compositionRef: CompositionRef | None = None + compositionRevisionRef: CompositionRevisionRef | None = None + compositionRevisionSelector: CompositionRevisionSelector | None = None + compositionSelector: CompositionSelector | None = None + compositionUpdatePolicy: Literal['Automatic', 'Manual'] | None = None + resourceRefs: list[ResourceRef] | None = None class NodePool(BaseModel): @@ -135,14 +137,14 @@ class NodePool(BaseModel): """ Name of the InferenceClass describing this pool's hardware. """ - maxNodeCount: Optional[conint(ge=1)] = None + maxNodeCount: conint(ge=1) | None = None """ Maximum node count for autoscaling. Omit for fixed-size pools. """ - minNodeCount: Optional[conint(ge=0)] = None + minNodeCount: conint(ge=0) | None = None name: constr(max_length=40) - nodeCount: Optional[conint(ge=0)] = 1 - zones: Optional[List[str]] = None + nodeCount: conint(ge=0) | None = 1 + zones: list[str] | None = None """ Zones to restrict this node pool to. Required for provisioned pools because not all zones in a region have every GPU type. """ @@ -150,104 +152,123 @@ class NodePool(BaseModel): class Spec(BaseModel): cluster: Cluster - crossplane: Optional[Crossplane] = None + crossplane: Crossplane | None = None """ Configures how Crossplane will reconcile this composite resource """ - nodePools: Optional[List[NodePool]] = Field(None, max_length=8, min_length=1) + nodePools: list[NodePool] | None = Field(None, max_length=8, min_length=1) """ GPU node pools available on this cluster. Each pool references an InferenceClass that describes the hardware shape and (for provisioned clusters) how to create the pool. System pools for control-plane components are provisioned automatically. """ -class GpuPool(BaseModel): - acceleratorType: Optional[str] = None - countPerNode: Optional[int] = None - memory: Optional[str] = None - """ - Per-GPU VRAM (e.g. "24Gi"). - """ - nodes: Optional[int] = None +class Condition(BaseModel): + lastTransitionTime: AwareDatetime + message: str | None = None + observedGeneration: int | None = None + reason: str + status: str + type: str + + +class Gateway(BaseModel): + address: str | None = None """ - Number of nodes in this pool. Derived from maxNodeCount (if autoscaling) or nodeCount. + External IP of the inference gateway on the remote cluster. Used by ModelDeployment for unified endpoint routing. """ +class Attributes(BaseModel): + bool_: bool | None = Field(None, alias='bool') + int_: int | None = Field(None, alias='int') + string: constr(max_length=253) | None = None + version: constr(max_length=32) | None = None + + class Capacity(BaseModel): - gpuPools: Optional[List[GpuPool]] = None + value: constr(max_length=32) -class Condition(BaseModel): - lastTransitionTime: datetime - message: Optional[str] = None - observedGeneration: Optional[int] = None - reason: str - status: str - type: str +class Device(BaseModel): + attributes: dict[str, Attributes] | None = Field(None, max_length=32) + capacity: dict[str, Capacity] | None = Field(None, max_length=32) + claim: Literal['DRA', 'Synthetic'] | None = None + count: int | None = None + deviceClassName: constr(max_length=253) | None = None + driver: constr(max_length=253) + name: constr(max_length=63) -class Gateway(BaseModel): - address: Optional[str] = None +class GpuPool(BaseModel): + devices: list[Device] | None = Field(None, max_length=16) """ - External IP of the inference gateway on the remote cluster. Used by ModelDeployment for unified endpoint routing. + Devices copied from the pool's InferenceClass. ModelDeployment.nodeSelector matches against these. + """ + name: str + """ + Node pool name, matching spec.nodePools[].name. Used to pin a ModelReplica to a specific pool via spec.nodePoolName. + """ + nodes: int | None = None + """ + Number of nodes in this pool. Derived from maxNodeCount (if autoscaling) or nodeCount. """ class ProviderConfigRef(BaseModel): - name: Optional[str] = None + name: str | None = None """ Name of the ProviderConfig targeting the remote cluster. Used by ModelReplica to create resources on the cluster. """ class Status(BaseModel): - capacity: Optional[Capacity] = None + conditions: list[Condition] | None = None """ - Declared capacity derived from the referenced classes and the per-pool node counts. + Conditions of the resource. """ - conditions: Optional[List[Condition]] = None + gateway: Gateway | None = None + gpuPools: list[GpuPool] | None = Field(None, max_length=8) """ - Conditions of the resource. + Schedulable GPU node pools on this cluster, derived from the referenced classes and the per-pool node counts. ModelDeployment scheduling matches against these. """ - gateway: Optional[Gateway] = None - namespace: Optional[str] = None + namespace: str | None = None """ Namespace where the internal XRs (cluster, backend) were created. """ - providerConfigRef: Optional[ProviderConfigRef] = None + providerConfigRef: ProviderConfigRef | None = None class InferenceCluster(BaseModel): - apiVersion: Optional[Literal['modelplane.ai/v1alpha1']] = 'modelplane.ai/v1alpha1' + apiVersion: Literal['modelplane.ai/v1alpha1'] | None = 'modelplane.ai/v1alpha1' """ APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources """ - kind: Optional[Literal['InferenceCluster']] = 'InferenceCluster' + kind: Literal['InferenceCluster'] | None = 'InferenceCluster' """ Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds """ - metadata: Optional[v1.ObjectMeta] = None + metadata: v1.ObjectMeta | None = None """ Standard object's metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata """ spec: Spec - status: Optional[Status] = None + status: Status | None = None class InferenceClusterList(BaseModel): - apiVersion: Optional[str] = None + apiVersion: str | None = None """ APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources """ - items: List[InferenceCluster] + items: list[InferenceCluster] """ List of inferenceclusters. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md """ - kind: Optional[str] = None + kind: str | None = None """ Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds """ - metadata: Optional[v1.ListMeta] = None + metadata: v1.ListMeta | None = None """ Standard list metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds """ \ No newline at end of file diff --git a/schemas/python/models/ai/modelplane/inferencegateway/v1alpha1.py b/schemas/python/models/ai/modelplane/inferencegateway/v1alpha1.py index b9ad66633..9df058d84 100644 --- a/schemas/python/models/ai/modelplane/inferencegateway/v1alpha1.py +++ b/schemas/python/models/ai/modelplane/inferencegateway/v1alpha1.py @@ -3,10 +3,9 @@ from __future__ import annotations -from datetime import datetime -from typing import Dict, List, Literal, Optional +from typing import Literal -from pydantic import BaseModel +from pydantic import AwareDatetime, BaseModel from ....io.k8s.apimachinery.pkg.apis.meta import v1 @@ -20,27 +19,27 @@ class CompositionRevisionRef(BaseModel): class CompositionRevisionSelector(BaseModel): - matchLabels: Dict[str, str] + matchLabels: dict[str, str] class CompositionSelector(BaseModel): - matchLabels: Dict[str, str] + matchLabels: dict[str, str] class ResourceRef(BaseModel): apiVersion: str kind: str - name: Optional[str] = None - namespace: Optional[str] = None + name: str | None = None + namespace: str | None = None class Crossplane(BaseModel): - compositionRef: Optional[CompositionRef] = None - compositionRevisionRef: Optional[CompositionRevisionRef] = None - compositionRevisionSelector: Optional[CompositionRevisionSelector] = None - compositionSelector: Optional[CompositionSelector] = None - compositionUpdatePolicy: Optional[Literal['Automatic', 'Manual']] = None - resourceRefs: Optional[List[ResourceRef]] = None + compositionRef: CompositionRef | None = None + compositionRevisionRef: CompositionRevisionRef | None = None + compositionRevisionSelector: CompositionRevisionSelector | None = None + compositionSelector: CompositionSelector | None = None + compositionUpdatePolicy: Literal['Automatic', 'Manual'] | None = None + resourceRefs: list[ResourceRef] | None = None class Metallb(BaseModel): @@ -51,11 +50,11 @@ class Metallb(BaseModel): class Traefik(BaseModel): - loadBalancer: Optional[Literal['MetalLB']] = None + loadBalancer: Literal['MetalLB'] | None = None """ Load balancer implementation for the gateway Service. Omit for cloud environments where a native LB controller is available. """ - metallb: Optional[Metallb] = None + metallb: Metallb | None = None """ MetalLB configuration. Required when loadBalancer is MetalLB. Use for kind or bare-metal clusters. """ @@ -70,67 +69,67 @@ class Spec(BaseModel): """ Gateway implementation. """ - crossplane: Optional[Crossplane] = None + crossplane: Crossplane | None = None """ Configures how Crossplane will reconcile this composite resource """ - traefik: Optional[Traefik] = None + traefik: Traefik | None = None """ Traefik Proxy configuration. Required when backend is Traefik. """ class Condition(BaseModel): - lastTransitionTime: datetime - message: Optional[str] = None - observedGeneration: Optional[int] = None + lastTransitionTime: AwareDatetime + message: str | None = None + observedGeneration: int | None = None reason: str status: str type: str class Status(BaseModel): - address: Optional[str] = None + address: str | None = None """ External address of the control plane gateway. Backend-agnostic — works for any routing implementation. """ - conditions: Optional[List[Condition]] = None + conditions: list[Condition] | None = None """ Conditions of the resource. """ class InferenceGateway(BaseModel): - apiVersion: Optional[Literal['modelplane.ai/v1alpha1']] = 'modelplane.ai/v1alpha1' + apiVersion: Literal['modelplane.ai/v1alpha1'] | None = 'modelplane.ai/v1alpha1' """ APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources """ - kind: Optional[Literal['InferenceGateway']] = 'InferenceGateway' + kind: Literal['InferenceGateway'] | None = 'InferenceGateway' """ Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds """ - metadata: Optional[v1.ObjectMeta] = None + metadata: v1.ObjectMeta | None = None """ Standard object's metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata """ spec: Spec - status: Optional[Status] = None + status: Status | None = None class InferenceGatewayList(BaseModel): - apiVersion: Optional[str] = None + apiVersion: str | None = None """ APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources """ - items: List[InferenceGateway] + items: list[InferenceGateway] """ List of inferencegateways. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md """ - kind: Optional[str] = None + kind: str | None = None """ Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds """ - metadata: Optional[v1.ListMeta] = None + metadata: v1.ListMeta | None = None """ Standard list metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds """ \ No newline at end of file diff --git a/schemas/python/models/ai/modelplane/infrastructure/ekscluster/v1alpha1.py b/schemas/python/models/ai/modelplane/infrastructure/ekscluster/v1alpha1.py index 11f665930..7b649b774 100644 --- a/schemas/python/models/ai/modelplane/infrastructure/ekscluster/v1alpha1.py +++ b/schemas/python/models/ai/modelplane/infrastructure/ekscluster/v1alpha1.py @@ -3,10 +3,9 @@ from __future__ import annotations -from datetime import datetime -from typing import Dict, List, Literal, Optional +from typing import Literal -from pydantic import BaseModel, Field, RootModel, conint, constr +from pydantic import AwareDatetime, BaseModel, Field, RootModel, conint, constr from .....io.k8s.apimachinery.pkg.apis.meta import v1 @@ -20,26 +19,26 @@ class CompositionRevisionRef(BaseModel): class CompositionRevisionSelector(BaseModel): - matchLabels: Dict[str, str] + matchLabels: dict[str, str] class CompositionSelector(BaseModel): - matchLabels: Dict[str, str] + matchLabels: dict[str, str] class ResourceRef(BaseModel): apiVersion: str kind: str - name: Optional[str] = None + name: str | None = None class Crossplane(BaseModel): - compositionRef: Optional[CompositionRef] = None - compositionRevisionRef: Optional[CompositionRevisionRef] = None - compositionRevisionSelector: Optional[CompositionRevisionSelector] = None - compositionSelector: Optional[CompositionSelector] = None - compositionUpdatePolicy: Optional[Literal['Automatic', 'Manual']] = None - resourceRefs: Optional[List[ResourceRef]] = None + compositionRef: CompositionRef | None = None + compositionRevisionRef: CompositionRevisionRef | None = None + compositionRevisionSelector: CompositionRevisionSelector | None = None + compositionSelector: CompositionSelector | None = None + compositionUpdatePolicy: Literal['Automatic', 'Manual'] | None = None + resourceRefs: list[ResourceRef] | None = None class SubnetCidr(RootModel[constr(max_length=18)]): @@ -47,18 +46,16 @@ class SubnetCidr(RootModel[constr(max_length=18)]): class Networking(BaseModel): - subnetCidrs: Optional[List[SubnetCidr]] = Field( - default_factory=lambda: [ - SubnetCidr.model_validate(v) - for v in ['10.0.0.0/20', '10.0.16.0/20', '10.0.32.0/20'] - ], + subnetCidrs: list[SubnetCidr] | None = Field( + ['10.0.0.0/20', '10.0.16.0/20', '10.0.32.0/20'], max_length=6, min_length=2, + validate_default=True, ) """ Subnet CIDRs, one per Availability Zone. EKS requires at least two subnets in different AZs. The function picks AZs by index from the region — index 0 maps to the region's first AZ alphabetically (e.g. us-west-2a), index 1 to the second, etc. """ - vpcCidr: Optional[constr(max_length=18)] = '10.0.0.0/16' + vpcCidr: constr(max_length=18) | None = '10.0.0.0/16' """ Primary CIDR block for the VPC. """ @@ -76,11 +73,11 @@ class Zone(RootModel[constr(min_length=1, max_length=63)]): class NodePool(BaseModel): - diskSizeGb: Optional[conint(ge=10, le=65536)] = 100 + diskSizeGb: conint(ge=10, le=65536) | None = 100 """ Root volume size in GB. """ - gpu: Optional[Gpu] = None + gpu: Gpu | None = None """ GPU configuration. Required when role is GPU. """ @@ -88,11 +85,11 @@ class NodePool(BaseModel): """ EC2 instance type (e.g. m6i.large, g6.xlarge, p4d.24xlarge). """ - maxNodeCount: Optional[conint(ge=0, le=1000)] = 8 + maxNodeCount: conint(ge=0, le=1000) | None = 8 """ Maximum number of nodes for autoscaling. """ - minNodeCount: Optional[conint(ge=0, le=1000)] = 0 + minNodeCount: conint(ge=0, le=1000) | None = 0 """ Minimum number of nodes for autoscaling. Set to 1 or higher for groups that must always be available. """ @@ -100,7 +97,7 @@ class NodePool(BaseModel): """ Unique name for this node group. Used as a suffix in the EKS NodeGroup resource name. """ - nodeCount: Optional[conint(ge=0, le=1000)] = 1 + nodeCount: conint(ge=0, le=1000) | None = 1 """ Initial number of nodes. """ @@ -108,26 +105,26 @@ class NodePool(BaseModel): """ Determines what workloads this group runs. System groups host controllers, gateways, and infrastructure. GPU groups host inference workloads and use a GPU-enabled AMI. """ - zones: Optional[List[Zone]] = Field(None, max_length=8, min_length=1) + zones: list[Zone] | None = Field(None, max_length=8, min_length=1) """ Availability Zones to restrict this node group to. Required for GPU groups because not all AZs in a region have every instance type. Example: ["us-west-2a", "us-west-2b"]. """ class Spec(BaseModel): - crossplane: Optional[Crossplane] = None + crossplane: Crossplane | None = None """ Configures how Crossplane will reconcile this composite resource """ - kubernetesVersion: Optional[constr(min_length=1, max_length=16)] = '1.31' + kubernetesVersion: constr(min_length=1, max_length=16) | None = '1.36' """ - EKS cluster Kubernetes version. Must be a version EKS currently supports. + EKS cluster Kubernetes version. Must be a version EKS currently supports. Defaults to a version where Dynamic Resource Allocation (how GPUs bind to pods) is generally available. """ - networking: Optional[Networking] = None + networking: Networking | None = None """ VPC networking configuration. Defaults give a /16 VPC carved into three /20 subnets, one per Availability Zone. Override when VPC-peering multiple clusters to avoid CIDR collisions. """ - nodePools: List[NodePool] = Field(..., max_length=8, min_length=1) + nodePools: list[NodePool] = Field(..., max_length=8, min_length=1) """ Node groups for the cluster. At least one System pool is required for controllers and infrastructure workloads. """ @@ -138,9 +135,9 @@ class Spec(BaseModel): class Condition(BaseModel): - lastTransitionTime: datetime - message: Optional[str] = None - observedGeneration: Optional[int] = None + lastTransitionTime: AwareDatetime + message: str | None = None + observedGeneration: int | None = None reason: str status: str type: str @@ -162,28 +159,28 @@ class Secret(BaseModel): class Status(BaseModel): - conditions: Optional[List[Condition]] = None + conditions: list[Condition] | None = None """ Conditions of the resource. """ - secrets: Optional[List[Secret]] = None + secrets: list[Secret] | None = None """ Secrets produced by this cluster. Consumers use these to authenticate to the cluster. All secrets are in the same namespace as this EKSCluster. """ class EKSCluster(BaseModel): - apiVersion: Optional[Literal['infrastructure.modelplane.ai/v1alpha1']] = ( + apiVersion: Literal['infrastructure.modelplane.ai/v1alpha1'] | None = ( 'infrastructure.modelplane.ai/v1alpha1' ) """ APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources """ - kind: Optional[Literal['EKSCluster']] = 'EKSCluster' + kind: Literal['EKSCluster'] | None = 'EKSCluster' """ Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds """ - metadata: Optional[v1.ObjectMeta] = None + metadata: v1.ObjectMeta | None = None """ Standard object's metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata """ @@ -191,26 +188,26 @@ class EKSCluster(BaseModel): """ EKSClusterSpec defines the desired state of EKSCluster. """ - status: Optional[Status] = None + status: Status | None = None """ EKSClusterStatus defines the observed state of EKSCluster. """ class EKSClusterList(BaseModel): - apiVersion: Optional[str] = None + apiVersion: str | None = None """ APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources """ - items: List[EKSCluster] + items: list[EKSCluster] """ List of eksclusters. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md """ - kind: Optional[str] = None + kind: str | None = None """ Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds """ - metadata: Optional[v1.ListMeta] = None + metadata: v1.ListMeta | None = None """ Standard list metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds """ \ No newline at end of file diff --git a/schemas/python/models/ai/modelplane/infrastructure/gkecluster/v1alpha1.py b/schemas/python/models/ai/modelplane/infrastructure/gkecluster/v1alpha1.py index 719c3bbb0..91189e77a 100644 --- a/schemas/python/models/ai/modelplane/infrastructure/gkecluster/v1alpha1.py +++ b/schemas/python/models/ai/modelplane/infrastructure/gkecluster/v1alpha1.py @@ -3,10 +3,9 @@ from __future__ import annotations -from datetime import datetime -from typing import Dict, List, Literal, Optional +from typing import Literal -from pydantic import BaseModel, Field, RootModel, conint, constr +from pydantic import AwareDatetime, BaseModel, Field, RootModel, conint, constr from .....io.k8s.apimachinery.pkg.apis.meta import v1 @@ -20,45 +19,45 @@ class CompositionRevisionRef(BaseModel): class CompositionRevisionSelector(BaseModel): - matchLabels: Dict[str, str] + matchLabels: dict[str, str] class CompositionSelector(BaseModel): - matchLabels: Dict[str, str] + matchLabels: dict[str, str] class ResourceRef(BaseModel): apiVersion: str kind: str - name: Optional[str] = None + name: str | None = None class Crossplane(BaseModel): - compositionRef: Optional[CompositionRef] = None - compositionRevisionRef: Optional[CompositionRevisionRef] = None - compositionRevisionSelector: Optional[CompositionRevisionSelector] = None - compositionSelector: Optional[CompositionSelector] = None - compositionUpdatePolicy: Optional[Literal['Automatic', 'Manual']] = None - resourceRefs: Optional[List[ResourceRef]] = None + compositionRef: CompositionRef | None = None + compositionRevisionRef: CompositionRevisionRef | None = None + compositionRevisionSelector: CompositionRevisionSelector | None = None + compositionSelector: CompositionSelector | None = None + compositionUpdatePolicy: Literal['Automatic', 'Manual'] | None = None + resourceRefs: list[ResourceRef] | None = None class Networking(BaseModel): - nodeCidr: Optional[constr(max_length=18)] = '10.0.0.0/24' + nodeCidr: constr(max_length=18) | None = '10.0.0.0/24' """ Primary IP range for nodes. """ - podCidr: Optional[constr(max_length=18)] = '10.1.0.0/16' + podCidr: constr(max_length=18) | None = '10.1.0.0/16' """ Secondary IP range for pods. """ - serviceCidr: Optional[constr(max_length=18)] = '10.2.0.0/16' + serviceCidr: constr(max_length=18) | None = '10.2.0.0/16' """ Secondary IP range for services. """ class Gpu(BaseModel): - acceleratorCount: Optional[conint(ge=1, le=16)] = 1 + acceleratorCount: conint(ge=1, le=16) | None = 1 """ Number of GPUs to attach per node. """ @@ -73,11 +72,11 @@ class Zone(RootModel[constr(min_length=1, max_length=63)]): class NodePool(BaseModel): - diskSizeGb: Optional[conint(ge=10, le=65536)] = 100 + diskSizeGb: conint(ge=10, le=65536) | None = 100 """ Boot disk size in GB. """ - gpu: Optional[Gpu] = None + gpu: Gpu | None = None """ GPU configuration. Required when role is GPU. """ @@ -85,11 +84,11 @@ class NodePool(BaseModel): """ GCE machine type (e.g. e2-standard-4, a2-highgpu-8g, g2-standard-48). """ - maxNodeCount: Optional[conint(ge=0, le=1000)] = 8 + maxNodeCount: conint(ge=0, le=1000) | None = 8 """ Maximum number of nodes for autoscaling. """ - minNodeCount: Optional[conint(ge=0, le=1000)] = 0 + minNodeCount: conint(ge=0, le=1000) | None = 0 """ Minimum number of nodes for autoscaling. Set to 1 or higher for pools that must always be available. """ @@ -97,7 +96,7 @@ class NodePool(BaseModel): """ Unique name for this node pool. Used as a suffix in the GKE NodePool resource name. """ - nodeCount: Optional[conint(ge=0, le=1000)] = 1 + nodeCount: conint(ge=0, le=1000) | None = 1 """ Initial number of nodes. """ @@ -105,26 +104,26 @@ class NodePool(BaseModel): """ Determines what workloads this pool runs. System pools host controllers, gateways, and infrastructure. GPU pools host inference workloads and are tainted to exclude non-GPU pods. """ - zones: Optional[List[Zone]] = Field(None, max_length=8, min_length=1) + zones: list[Zone] | None = Field(None, max_length=8, min_length=1) """ Zones to restrict this node pool to. Required for GPU pools because not all zones in a region have every GPU type. Example: ["us-central1-a", "us-central1-b"]. """ class Spec(BaseModel): - crossplane: Optional[Crossplane] = None + crossplane: Crossplane | None = None """ Configures how Crossplane will reconcile this composite resource """ - kubernetesVersion: Optional[constr(min_length=1, max_length=16)] = '1.35' + kubernetesVersion: constr(min_length=1, max_length=16) | None = '1.35' """ GKE cluster Kubernetes version. Must be a version supported by the REGULAR release channel. """ - networking: Optional[Networking] = None + networking: Networking | None = None """ VPC networking configuration. Defaults are suitable for standalone clusters. Override when VPC-peering multiple clusters to avoid CIDR collisions. """ - nodePools: List[NodePool] = Field(..., max_length=8, min_length=1) + nodePools: list[NodePool] = Field(..., max_length=8, min_length=1) """ Node pools for the cluster. At least one System pool is required for controllers and infrastructure workloads. """ @@ -139,16 +138,16 @@ class Spec(BaseModel): class Condition(BaseModel): - lastTransitionTime: datetime - message: Optional[str] = None - observedGeneration: Optional[int] = None + lastTransitionTime: AwareDatetime + message: str | None = None + observedGeneration: int | None = None reason: str status: str type: str class Network(BaseModel): - name: Optional[str] = None + name: str | None = None """ Name of the composed VPC network. """ @@ -170,32 +169,32 @@ class Secret(BaseModel): class Status(BaseModel): - conditions: Optional[List[Condition]] = None + conditions: list[Condition] | None = None """ Conditions of the resource. """ - network: Optional[Network] = None + network: Network | None = None """ The VPC network this cluster runs in. Consumers pin network-scoped resources (e.g. the ModelCache Filestore StorageClass) to this name. Populated once the composed Network is observed; the name carries the provider's generated suffix, so it can't be derived from the XR name. """ - secrets: Optional[List[Secret]] = None + secrets: list[Secret] | None = None """ Secrets produced by this cluster. Consumers use these to authenticate to the cluster. All secrets are in the same namespace as this GKECluster. """ class GKECluster(BaseModel): - apiVersion: Optional[Literal['infrastructure.modelplane.ai/v1alpha1']] = ( + apiVersion: Literal['infrastructure.modelplane.ai/v1alpha1'] | None = ( 'infrastructure.modelplane.ai/v1alpha1' ) """ APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources """ - kind: Optional[Literal['GKECluster']] = 'GKECluster' + kind: Literal['GKECluster'] | None = 'GKECluster' """ Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds """ - metadata: Optional[v1.ObjectMeta] = None + metadata: v1.ObjectMeta | None = None """ Standard object's metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata """ @@ -203,26 +202,26 @@ class GKECluster(BaseModel): """ GKEClusterSpec defines the desired state of GKECluster. """ - status: Optional[Status] = None + status: Status | None = None """ GKEClusterStatus defines the observed state of GKECluster. """ class GKEClusterList(BaseModel): - apiVersion: Optional[str] = None + apiVersion: str | None = None """ APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources """ - items: List[GKECluster] + items: list[GKECluster] """ List of gkeclusters. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md """ - kind: Optional[str] = None + kind: str | None = None """ Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds """ - metadata: Optional[v1.ListMeta] = None + metadata: v1.ListMeta | None = None """ Standard list metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds """ \ No newline at end of file diff --git a/schemas/python/models/ai/modelplane/infrastructure/servingstack/v1alpha1.py b/schemas/python/models/ai/modelplane/infrastructure/servingstack/v1alpha1.py index db996bf48..8bd984161 100644 --- a/schemas/python/models/ai/modelplane/infrastructure/servingstack/v1alpha1.py +++ b/schemas/python/models/ai/modelplane/infrastructure/servingstack/v1alpha1.py @@ -3,10 +3,9 @@ from __future__ import annotations -from datetime import datetime -from typing import Dict, List, Literal, Optional +from typing import Literal -from pydantic import BaseModel, Field, conint, constr +from pydantic import AwareDatetime, BaseModel, Field, conint, constr from .....io.k8s.apimachinery.pkg.apis.meta import v1 @@ -20,26 +19,26 @@ class CompositionRevisionRef(BaseModel): class CompositionRevisionSelector(BaseModel): - matchLabels: Dict[str, str] + matchLabels: dict[str, str] class CompositionSelector(BaseModel): - matchLabels: Dict[str, str] + matchLabels: dict[str, str] class ResourceRef(BaseModel): apiVersion: str kind: str - name: Optional[str] = None + name: str | None = None class Crossplane(BaseModel): - compositionRef: Optional[CompositionRef] = None - compositionRevisionRef: Optional[CompositionRevisionRef] = None - compositionRevisionSelector: Optional[CompositionRevisionSelector] = None - compositionSelector: Optional[CompositionSelector] = None - compositionUpdatePolicy: Optional[Literal['Automatic', 'Manual']] = None - resourceRefs: Optional[List[ResourceRef]] = None + compositionRef: CompositionRef | None = None + compositionRevisionRef: CompositionRevisionRef | None = None + compositionRevisionSelector: CompositionRevisionSelector | None = None + compositionSelector: CompositionSelector | None = None + compositionUpdatePolicy: Literal['Automatic', 'Manual'] | None = None + resourceRefs: list[ResourceRef] | None = None class Listener(BaseModel): @@ -58,11 +57,11 @@ class Listener(BaseModel): class Gateway(BaseModel): - className: Optional[constr(min_length=1, max_length=63)] = 'envoy' + className: constr(min_length=1, max_length=63) | None = 'envoy' """ GatewayClass name. Override if the cluster already has a GatewayClass named envoy. """ - listeners: Optional[List[Listener]] = Field(None, max_length=8) + listeners: list[Listener] | None = Field(None, max_length=8) """ Gateway listeners. Defaults to a single HTTP listener on port 80 if not specified. """ @@ -84,86 +83,94 @@ class Secret(BaseModel): class Versions(BaseModel): - certManager: Optional[constr(min_length=1, max_length=32)] = 'v1.17.1' + certManager: constr(min_length=1, max_length=32) | None = 'v1.17.1' """ cert-manager chart version. """ - envoyGateway: Optional[constr(min_length=1, max_length=32)] = 'v1.3.0' + envoyGateway: constr(min_length=1, max_length=32) | None = 'v1.3.0' """ Envoy Gateway chart version. """ - gatewayApi: Optional[constr(min_length=1, max_length=32)] = 'v1.5.1' + gatewayApi: constr(min_length=1, max_length=32) | None = 'v1.5.1' """ Gateway API CRD version. """ - leaderWorkerSet: Optional[constr(min_length=1, max_length=32)] = 'v0.8.0' + leaderWorkerSet: constr(min_length=1, max_length=32) | None = 'v0.8.0' """ LeaderWorkerSet chart version. """ - prometheus: Optional[constr(min_length=1, max_length=32)] = '72.6.2' + nodeFeatureDiscovery: constr(min_length=1, max_length=32) | None = '0.18.3' + """ + Node Feature Discovery chart version. NFD labels GPU nodes so the NVIDIA DRA driver targets its kubelet plugin to them. + """ + nvidiaDraDriver: constr(min_length=1, max_length=32) | None = '0.4.0' + """ + NVIDIA DRA driver chart version. Publishes GPUs as DRA ResourceSlices and the gpu.nvidia.com DeviceClass that ModelReplica ResourceClaims bind through. + """ + prometheus: constr(min_length=1, max_length=32) | None = '72.6.2' """ kube-prometheus-stack chart version. """ class Spec(BaseModel): - crossplane: Optional[Crossplane] = None + crossplane: Crossplane | None = None """ Configures how Crossplane will reconcile this composite resource """ - gateway: Optional[Gateway] = None + gateway: Gateway | None = None """ Configuration for the cluster's inference traffic gateway. """ - secrets: List[Secret] = Field(..., min_length=1) + secrets: list[Secret] = Field(..., min_length=1) """ Secrets used to authenticate to the target cluster. Typically sourced from a GKECluster's status.secrets. All secrets must be in the same namespace as this ServingStack. A Kubeconfig secret is required. If a cloud-specific credential secret is present (e.g. GCPServiceAccountKey), the ProviderConfigs will use it for identity-based authentication instead of relying on the kubeconfig's embedded credentials. """ - versions: Optional[Versions] = None + versions: Versions | None = None """ Version pins for each component. Defaults are the latest tested combination. Override individual versions to upgrade components independently. """ class Condition(BaseModel): - lastTransitionTime: datetime - message: Optional[str] = None - observedGeneration: Optional[int] = None + lastTransitionTime: AwareDatetime + message: str | None = None + observedGeneration: int | None = None reason: str status: str type: str class GatewayModel(BaseModel): - address: Optional[constr(max_length=256)] = None + address: constr(max_length=256) | None = None """ The gateway's external address, once assigned by the cloud load balancer. """ class Status(BaseModel): - conditions: Optional[List[Condition]] = None + conditions: list[Condition] | None = None """ Conditions of the resource. """ - gateway: Optional[GatewayModel] = None + gateway: GatewayModel | None = None """ Status of the cluster's inference gateway. """ class ServingStack(BaseModel): - apiVersion: Optional[Literal['infrastructure.modelplane.ai/v1alpha1']] = ( + apiVersion: Literal['infrastructure.modelplane.ai/v1alpha1'] | None = ( 'infrastructure.modelplane.ai/v1alpha1' ) """ APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources """ - kind: Optional[Literal['ServingStack']] = 'ServingStack' + kind: Literal['ServingStack'] | None = 'ServingStack' """ Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds """ - metadata: Optional[v1.ObjectMeta] = None + metadata: v1.ObjectMeta | None = None """ Standard object's metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata """ @@ -171,26 +178,26 @@ class ServingStack(BaseModel): """ ServingStackSpec defines the desired state of ServingStack. """ - status: Optional[Status] = None + status: Status | None = None """ ServingStackStatus defines the observed state of ServingStack. """ class ServingStackList(BaseModel): - apiVersion: Optional[str] = None + apiVersion: str | None = None """ APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources """ - items: List[ServingStack] + items: list[ServingStack] """ List of servingstacks. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md """ - kind: Optional[str] = None + kind: str | None = None """ Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds """ - metadata: Optional[v1.ListMeta] = None + metadata: v1.ListMeta | None = None """ Standard list metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds """ \ No newline at end of file diff --git a/schemas/python/models/ai/modelplane/modelcache/v1alpha1.py b/schemas/python/models/ai/modelplane/modelcache/v1alpha1.py index 889529f19..53e8f6b63 100644 --- a/schemas/python/models/ai/modelplane/modelcache/v1alpha1.py +++ b/schemas/python/models/ai/modelplane/modelcache/v1alpha1.py @@ -3,16 +3,15 @@ from __future__ import annotations -from datetime import datetime -from typing import Any, Dict, List, Literal, Optional +from typing import Any, Literal -from pydantic import BaseModel, conint, constr +from pydantic import AwareDatetime, BaseModel, conint, constr from ....io.k8s.apimachinery.pkg.apis.meta import v1 class ClusterSelector(BaseModel): - matchLabels: Optional[Dict[str, Any]] = None + matchLabels: dict[str, Any] | None = None class CompositionRef(BaseModel): @@ -24,35 +23,35 @@ class CompositionRevisionRef(BaseModel): class CompositionRevisionSelector(BaseModel): - matchLabels: Dict[str, str] + matchLabels: dict[str, str] class CompositionSelector(BaseModel): - matchLabels: Dict[str, str] + matchLabels: dict[str, str] class ResourceRef(BaseModel): apiVersion: str kind: str - name: Optional[str] = None + name: str | None = None class Crossplane(BaseModel): - compositionRef: Optional[CompositionRef] = None - compositionRevisionRef: Optional[CompositionRevisionRef] = None - compositionRevisionSelector: Optional[CompositionRevisionSelector] = None - compositionSelector: Optional[CompositionSelector] = None - compositionUpdatePolicy: Optional[Literal['Automatic', 'Manual']] = None - resourceRefs: Optional[List[ResourceRef]] = None + compositionRef: CompositionRef | None = None + compositionRevisionRef: CompositionRevisionRef | None = None + compositionRevisionSelector: CompositionRevisionSelector | None = None + compositionSelector: CompositionSelector | None = None + compositionUpdatePolicy: Literal['Automatic', 'Manual'] | None = None + resourceRefs: list[ResourceRef] | None = None class AuthSecret(BaseModel): - key: Optional[str] = 'HF_TOKEN' + key: str | None = 'HF_TOKEN' name: constr(min_length=1) class HuggingFace(BaseModel): - authSecret: Optional[AuthSecret] = None + authSecret: AuthSecret | None = None """ Optional Secret holding an HF token for gated or private repos. Resolved on the workload cluster at hydration time. """ @@ -60,7 +59,7 @@ class HuggingFace(BaseModel): """ HuggingFace repository ID. """ - revision: Optional[str] = None + revision: str | None = None """ Branch, tag, or commit SHA. Defaults to the repo's default branch. """ @@ -71,15 +70,15 @@ class HuggingFace(BaseModel): class Spec(BaseModel): - clusterSelector: Optional[ClusterSelector] = None + clusterSelector: ClusterSelector | None = None """ Label selector to pick the InferenceClusters that stage this artifact. If omitted, the cache replicates to every cluster. """ - crossplane: Optional[Crossplane] = None + crossplane: Crossplane | None = None """ Configures how Crossplane will reconcile this composite resource """ - huggingFace: Optional[HuggingFace] = None + huggingFace: HuggingFace | None = None """ HuggingFace source. Required when source is HuggingFace. """ @@ -90,73 +89,73 @@ class Spec(BaseModel): class Cluster(BaseModel): - message: Optional[str] = None + message: str | None = None name: str - phase: Optional[Literal['Pending', 'Hydrating', 'Ready', 'Failed']] = None + phase: Literal['Pending', 'Hydrating', 'Ready', 'Failed'] | None = None class Condition(BaseModel): - lastTransitionTime: datetime - message: Optional[str] = None - observedGeneration: Optional[int] = None + lastTransitionTime: AwareDatetime + message: str | None = None + observedGeneration: int | None = None reason: str status: str type: str class Summary(BaseModel): - ready: Optional[str] = None + ready: str | None = None """ e.g. "2/3" """ class Status(BaseModel): - clusters: Optional[List[Cluster]] = None + clusters: list[Cluster] | None = None """ Per-cluster staging status. """ - conditions: Optional[List[Condition]] = None + conditions: list[Condition] | None = None """ Conditions of the resource. """ - summary: Optional[Summary] = None + summary: Summary | None = None """ Per-cluster ready / total counts. """ class ModelCache(BaseModel): - apiVersion: Optional[Literal['modelplane.ai/v1alpha1']] = 'modelplane.ai/v1alpha1' + apiVersion: Literal['modelplane.ai/v1alpha1'] | None = 'modelplane.ai/v1alpha1' """ APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources """ - kind: Optional[Literal['ModelCache']] = 'ModelCache' + kind: Literal['ModelCache'] | None = 'ModelCache' """ Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds """ - metadata: Optional[v1.ObjectMeta] = None + metadata: v1.ObjectMeta | None = None """ Standard object's metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata """ spec: Spec - status: Optional[Status] = None + status: Status | None = None class ModelCacheList(BaseModel): - apiVersion: Optional[str] = None + apiVersion: str | None = None """ APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources """ - items: List[ModelCache] + items: list[ModelCache] """ List of modelcaches. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md """ - kind: Optional[str] = None + kind: str | None = None """ Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds """ - metadata: Optional[v1.ListMeta] = None + metadata: v1.ListMeta | None = None """ Standard list metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds """ \ No newline at end of file diff --git a/schemas/python/models/ai/modelplane/modeldeployment/v1alpha1.py b/schemas/python/models/ai/modelplane/modeldeployment/v1alpha1.py index c33dc42a2..ef1747833 100644 --- a/schemas/python/models/ai/modelplane/modeldeployment/v1alpha1.py +++ b/schemas/python/models/ai/modelplane/modeldeployment/v1alpha1.py @@ -3,16 +3,15 @@ from __future__ import annotations -from datetime import datetime -from typing import Any, Dict, List, Literal, Optional +from typing import Any, Literal -from pydantic import BaseModel, Field, conint, constr +from pydantic import AwareDatetime, BaseModel, Field, conint, constr from ....io.k8s.apimachinery.pkg.apis.meta import v1 class ClusterSelector(BaseModel): - matchLabels: Optional[Dict[str, Any]] = None + matchLabels: dict[str, Any] | None = None class CompositionRef(BaseModel): @@ -24,26 +23,26 @@ class CompositionRevisionRef(BaseModel): class CompositionRevisionSelector(BaseModel): - matchLabels: Dict[str, str] + matchLabels: dict[str, str] class CompositionSelector(BaseModel): - matchLabels: Dict[str, str] + matchLabels: dict[str, str] class ResourceRef(BaseModel): apiVersion: str kind: str - name: Optional[str] = None + name: str | None = None class Crossplane(BaseModel): - compositionRef: Optional[CompositionRef] = None - compositionRevisionRef: Optional[CompositionRevisionRef] = None - compositionRevisionSelector: Optional[CompositionRevisionSelector] = None - compositionSelector: Optional[CompositionSelector] = None - compositionUpdatePolicy: Optional[Literal['Automatic', 'Manual']] = None - resourceRefs: Optional[List[ResourceRef]] = None + compositionRef: CompositionRef | None = None + compositionRevisionRef: CompositionRevisionRef | None = None + compositionRevisionSelector: CompositionRevisionSelector | None = None + compositionSelector: CompositionSelector | None = None + compositionUpdatePolicy: Literal['Automatic', 'Manual'] | None = None + resourceRefs: list[ResourceRef] | None = None class ModelCacheRef(BaseModel): @@ -53,44 +52,73 @@ class ModelCacheRef(BaseModel): """ +class Selector(BaseModel): + cel: constr(min_length=1, max_length=10240) | None = None + """ + A DRA CEL expression evaluated against one device. Reads device.driver, device.attributes[""]. (typed), and device.capacity[""]. (a Quantity), with quantity() and semver() helpers, e.g. device.capacity["gpu.nvidia.com"].memory.compareTo(quantity("141Gi")) >= 0. + """ + + +class Device(BaseModel): + count: conint(ge=1, le=64) | None = 1 + """ + How many matching devices a node must have. For a GPU request this is the per-node GPU count (matches the worker topology's GPUs per node). + """ + name: constr(min_length=1, max_length=63) + """ + Name of this request. Mirrors a DRA DeviceRequest name; carried through to the ResourceClaim. + """ + selectors: list[Selector] = Field(..., max_length=8, min_length=1) + """ + Selectors a device must satisfy, all ANDed. Each is a one-of; today only cel is supported. + """ + + +class NodeSelector(BaseModel): + devices: list[Device] = Field(..., max_length=16, min_length=1) + """ + Device requests. A pool matches a request when it has a device whose count covers the request and whose driver, attributes, and capacity satisfy every selector. + """ + + class Metadata(BaseModel): - annotations: Optional[Dict[str, str]] = None - labels: Optional[Dict[str, str]] = None + annotations: dict[str, str] | None = None + labels: dict[str, str] | None = None class ConfigMapKeyRef(BaseModel): key: str name: str - optional: Optional[bool] = None + optional: bool | None = None class SecretKeyRef(BaseModel): key: str name: str - optional: Optional[bool] = None + optional: bool | None = None class ValueFrom(BaseModel): - configMapKeyRef: Optional[ConfigMapKeyRef] = None - secretKeyRef: Optional[SecretKeyRef] = None + configMapKeyRef: ConfigMapKeyRef | None = None + secretKeyRef: SecretKeyRef | None = None class EnvItem(BaseModel): name: str - value: Optional[str] = None - valueFrom: Optional[ValueFrom] = None + value: str | None = None + valueFrom: ValueFrom | None = None class Container(BaseModel): - args: Optional[List[str]] = None + args: list[str] | None = None """ Container args. For the engine container, these are passed through to the serving engine. Includes the model identifier (e.g. --model=...). """ - command: Optional[List[str]] = None + command: list[str] | None = None """ Container entrypoint override. When set on the engine container of a multi-node deployment, it bypasses the built-in vLLM/Ray bootstrap and runs on every gang pod — the command owns cross-node coordination against the LWS_* environment (LWS_WORKER_INDEX, LWS_LEADER_ADDRESS, LWS_GROUP_SIZE). Use for non-vLLM engines (e.g. SGLang). """ - env: Optional[List[EnvItem]] = None + env: list[EnvItem] | None = None """ Environment variables. Supports valueFrom.secretKeyRef for secrets like HF_TOKEN. """ @@ -109,29 +137,29 @@ class ImagePullSecret(BaseModel): class Spec(BaseModel): - containers: List[Container] = Field(..., max_length=1, min_length=1) + containers: list[Container] = Field(..., max_length=1, min_length=1) """ Containers for the inference pod. v0.1 supports a single container, which must be named "engine" (the inference engine). Sidecar / multi-container support is tracked separately. """ - imagePullSecrets: Optional[List[ImagePullSecret]] = None + imagePullSecrets: list[ImagePullSecret] | None = None """ Image pull secrets for private registries (NGC etc.). """ class Template(BaseModel): - metadata: Optional[Metadata] = None + metadata: Metadata | None = None """ Metadata applied to inference pods. Useful for labels and annotations that control cluster-level features like service mesh injection. """ - spec: Optional[Spec] = None + spec: Spec | None = None """ Pod spec for inference workers. """ class Topology(BaseModel): - pipeline: Optional[conint(ge=1)] = 1 + pipeline: conint(ge=1) | None = 1 """ Nodes per worker. Defaults to 1 (single-node). Values greater than 1 enable multi-node serving via LeaderWorkerSet. """ @@ -142,7 +170,7 @@ class Topology(BaseModel): class Workers(BaseModel): - count: Optional[conint(ge=1)] = 1 + count: conint(ge=1) | None = 1 """ Number of workers per replica. Defaults to 1. """ @@ -157,18 +185,22 @@ class Workers(BaseModel): class SpecModel(BaseModel): - clusterSelector: Optional[ClusterSelector] = None + clusterSelector: ClusterSelector | None = None """ Optional label selector to filter InferenceClusters. If omitted, all ready clusters are candidates. """ - crossplane: Optional[Crossplane] = None + crossplane: Crossplane | None = None """ Configures how Crossplane will reconcile this composite resource """ - modelCacheRef: Optional[ModelCacheRef] = None + modelCacheRef: ModelCacheRef | None = None """ Reference to a ModelCache in the same namespace. Optional for single-node deployments; required for multi-node (workers.topology.pipeline > 1). """ + nodeSelector: NodeSelector + """ + Node-level matching, a list of device requests mirroring a DRA ResourceClaim. The scheduler matches each request against a candidate pool's InferenceClass devices (surfaced on InferenceCluster status.gpuPools) and pins the replica to a pool that satisfies every request. claim: DRA requests also become DeviceRequests in the ResourceClaim the serving pods bind GPUs through. Required: GPUs bind only via DRA, so a deployment must declare the devices its model needs. At least one request must resolve to a claimable (claim: DRA) device; the serving workload binds its GPUs through the resulting ResourceClaim. Synthetic devices refine placement but are never claimed, so a nodeSelector that matches only synthetic devices leaves the workload nothing to claim - the scheduler treats such a pool as ineligible and the deployment reports InsufficientCapacity. + """ replicas: conint(ge=1, le=10) """ How many ModelReplicas to fan out to. Each replica is a complete serving instance scheduled to one InferenceCluster. @@ -180,58 +212,58 @@ class SpecModel(BaseModel): class Condition(BaseModel): - lastTransitionTime: datetime - message: Optional[str] = None - observedGeneration: Optional[int] = None + lastTransitionTime: AwareDatetime + message: str | None = None + observedGeneration: int | None = None reason: str status: str type: str class Replicas(BaseModel): - ready: Optional[int] = None - total: Optional[int] = None + ready: int | None = None + total: int | None = None class Status(BaseModel): - conditions: Optional[List[Condition]] = None + conditions: list[Condition] | None = None """ Conditions of the resource. """ - replicas: Optional[Replicas] = None + replicas: Replicas | None = None class ModelDeployment(BaseModel): - apiVersion: Optional[Literal['modelplane.ai/v1alpha1']] = 'modelplane.ai/v1alpha1' + apiVersion: Literal['modelplane.ai/v1alpha1'] | None = 'modelplane.ai/v1alpha1' """ APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources """ - kind: Optional[Literal['ModelDeployment']] = 'ModelDeployment' + kind: Literal['ModelDeployment'] | None = 'ModelDeployment' """ Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds """ - metadata: Optional[v1.ObjectMeta] = None + metadata: v1.ObjectMeta | None = None """ Standard object's metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata """ spec: SpecModel - status: Optional[Status] = None + status: Status | None = None class ModelDeploymentList(BaseModel): - apiVersion: Optional[str] = None + apiVersion: str | None = None """ APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources """ - items: List[ModelDeployment] + items: list[ModelDeployment] """ List of modeldeployments. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md """ - kind: Optional[str] = None + kind: str | None = None """ Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds """ - metadata: Optional[v1.ListMeta] = None + metadata: v1.ListMeta | None = None """ Standard list metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds """ \ No newline at end of file diff --git a/schemas/python/models/ai/modelplane/modelendpoint/v1alpha1.py b/schemas/python/models/ai/modelplane/modelendpoint/v1alpha1.py index 5b52c1a70..e33e621f0 100644 --- a/schemas/python/models/ai/modelplane/modelendpoint/v1alpha1.py +++ b/schemas/python/models/ai/modelplane/modelendpoint/v1alpha1.py @@ -3,10 +3,9 @@ from __future__ import annotations -from datetime import datetime -from typing import Dict, List, Literal, Optional +from typing import Literal -from pydantic import BaseModel, constr +from pydantic import AwareDatetime, BaseModel, constr from ....io.k8s.apimachinery.pkg.apis.meta import v1 @@ -20,34 +19,34 @@ class CompositionRevisionRef(BaseModel): class CompositionRevisionSelector(BaseModel): - matchLabels: Dict[str, str] + matchLabels: dict[str, str] class CompositionSelector(BaseModel): - matchLabels: Dict[str, str] + matchLabels: dict[str, str] class ResourceRef(BaseModel): apiVersion: str kind: str - name: Optional[str] = None + name: str | None = None class Crossplane(BaseModel): - compositionRef: Optional[CompositionRef] = None - compositionRevisionRef: Optional[CompositionRevisionRef] = None - compositionRevisionSelector: Optional[CompositionRevisionSelector] = None - compositionSelector: Optional[CompositionSelector] = None - compositionUpdatePolicy: Optional[Literal['Automatic', 'Manual']] = None - resourceRefs: Optional[List[ResourceRef]] = None + compositionRef: CompositionRef | None = None + compositionRevisionRef: CompositionRevisionRef | None = None + compositionRevisionSelector: CompositionRevisionSelector | None = None + compositionSelector: CompositionSelector | None = None + compositionUpdatePolicy: Literal['Automatic', 'Manual'] | None = None + resourceRefs: list[ResourceRef] | None = None class Spec(BaseModel): - crossplane: Optional[Crossplane] = None + crossplane: Crossplane | None = None """ Configures how Crossplane will reconcile this composite resource """ - rewritePath: Optional[str] = None + rewritePath: str | None = None """ Path prefix that requests should be rewritten to when routed through this endpoint. Used by ModelService to configure URLRewrite on its HTTPRoute. For Modelplane- composed endpoints this is the per-replica serving path on the remote cluster's gateway, e.g. /ml-team/qwen-demo/. """ @@ -58,63 +57,63 @@ class Spec(BaseModel): class Condition(BaseModel): - lastTransitionTime: datetime - message: Optional[str] = None - observedGeneration: Optional[int] = None + lastTransitionTime: AwareDatetime + message: str | None = None + observedGeneration: int | None = None reason: str status: str type: str class Routing(BaseModel): - backendName: Optional[str] = None + backendName: str | None = None """ Crossplane-generated name of the Backend resource composed by this endpoint. """ class Status(BaseModel): - conditions: Optional[List[Condition]] = None + conditions: list[Condition] | None = None """ Conditions of the resource. """ - routing: Optional[Routing] = None + routing: Routing | None = None """ Routing details for this endpoint. ModelService reads backendName to build HTTPRoute backendRefs. """ class ModelEndpoint(BaseModel): - apiVersion: Optional[Literal['modelplane.ai/v1alpha1']] = 'modelplane.ai/v1alpha1' + apiVersion: Literal['modelplane.ai/v1alpha1'] | None = 'modelplane.ai/v1alpha1' """ APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources """ - kind: Optional[Literal['ModelEndpoint']] = 'ModelEndpoint' + kind: Literal['ModelEndpoint'] | None = 'ModelEndpoint' """ Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds """ - metadata: Optional[v1.ObjectMeta] = None + metadata: v1.ObjectMeta | None = None """ Standard object's metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata """ spec: Spec - status: Optional[Status] = None + status: Status | None = None class ModelEndpointList(BaseModel): - apiVersion: Optional[str] = None + apiVersion: str | None = None """ APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources """ - items: List[ModelEndpoint] + items: list[ModelEndpoint] """ List of modelendpoints. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md """ - kind: Optional[str] = None + kind: str | None = None """ Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds """ - metadata: Optional[v1.ListMeta] = None + metadata: v1.ListMeta | None = None """ Standard list metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds """ \ No newline at end of file diff --git a/schemas/python/models/ai/modelplane/modelreplica/v1alpha1.py b/schemas/python/models/ai/modelplane/modelreplica/v1alpha1.py index 535957f0e..bb9161b5d 100644 --- a/schemas/python/models/ai/modelplane/modelreplica/v1alpha1.py +++ b/schemas/python/models/ai/modelplane/modelreplica/v1alpha1.py @@ -3,10 +3,9 @@ from __future__ import annotations -from datetime import datetime -from typing import Dict, List, Literal, Optional +from typing import Literal -from pydantic import BaseModel, Field, conint, constr +from pydantic import AwareDatetime, BaseModel, Field, conint, constr from ....io.k8s.apimachinery.pkg.apis.meta import v1 @@ -20,26 +19,49 @@ class CompositionRevisionRef(BaseModel): class CompositionRevisionSelector(BaseModel): - matchLabels: Dict[str, str] + matchLabels: dict[str, str] class CompositionSelector(BaseModel): - matchLabels: Dict[str, str] + matchLabels: dict[str, str] class ResourceRef(BaseModel): apiVersion: str kind: str - name: Optional[str] = None + name: str | None = None class Crossplane(BaseModel): - compositionRef: Optional[CompositionRef] = None - compositionRevisionRef: Optional[CompositionRevisionRef] = None - compositionRevisionSelector: Optional[CompositionRevisionSelector] = None - compositionSelector: Optional[CompositionSelector] = None - compositionUpdatePolicy: Optional[Literal['Automatic', 'Manual']] = None - resourceRefs: Optional[List[ResourceRef]] = None + compositionRef: CompositionRef | None = None + compositionRevisionRef: CompositionRevisionRef | None = None + compositionRevisionSelector: CompositionRevisionSelector | None = None + compositionSelector: CompositionSelector | None = None + compositionUpdatePolicy: Literal['Automatic', 'Manual'] | None = None + resourceRefs: list[ResourceRef] | None = None + + +class Selector(BaseModel): + cel: constr(min_length=1, max_length=10240) | None = None + + +class DeviceRequest(BaseModel): + count: conint(ge=1, le=64) | None = 1 + """ + How many devices to claim. + """ + deviceClassName: constr(min_length=1, max_length=253) + """ + Cluster-scoped DRA DeviceClass to claim through, from the matched InferenceClass device. + """ + name: constr(min_length=1, max_length=63) + """ + Request name; becomes the DeviceRequest name. + """ + selectors: list[Selector] | None = Field(None, max_length=8) + """ + DRA CEL selectors copied verbatim from the nodeSelector request, ANDed in the DeviceRequest. + """ class ModelCacheRef(BaseModel): @@ -47,37 +69,37 @@ class ModelCacheRef(BaseModel): class Metadata(BaseModel): - annotations: Optional[Dict[str, str]] = None - labels: Optional[Dict[str, str]] = None + annotations: dict[str, str] | None = None + labels: dict[str, str] | None = None class ConfigMapKeyRef(BaseModel): key: str name: str - optional: Optional[bool] = None + optional: bool | None = None class SecretKeyRef(BaseModel): key: str name: str - optional: Optional[bool] = None + optional: bool | None = None class ValueFrom(BaseModel): - configMapKeyRef: Optional[ConfigMapKeyRef] = None - secretKeyRef: Optional[SecretKeyRef] = None + configMapKeyRef: ConfigMapKeyRef | None = None + secretKeyRef: SecretKeyRef | None = None class EnvItem(BaseModel): name: str - value: Optional[str] = None - valueFrom: Optional[ValueFrom] = None + value: str | None = None + valueFrom: ValueFrom | None = None class Container(BaseModel): - args: Optional[List[str]] = None - command: Optional[List[str]] = None - env: Optional[List[EnvItem]] = None + args: list[str] | None = None + command: list[str] | None = None + env: list[EnvItem] | None = None image: constr(min_length=1) name: constr(min_length=1) @@ -87,22 +109,22 @@ class ImagePullSecret(BaseModel): class Spec(BaseModel): - containers: List[Container] = Field(..., max_length=1, min_length=1) - imagePullSecrets: Optional[List[ImagePullSecret]] = None + containers: list[Container] = Field(..., max_length=1, min_length=1) + imagePullSecrets: list[ImagePullSecret] | None = None class Template(BaseModel): - metadata: Optional[Metadata] = None - spec: Optional[Spec] = None + metadata: Metadata | None = None + spec: Spec | None = None class Topology(BaseModel): - pipeline: Optional[conint(ge=1)] = 1 + pipeline: conint(ge=1) | None = 1 tensor: conint(ge=1) class Workers(BaseModel): - count: Optional[conint(ge=1)] = 1 + count: conint(ge=1) | None = 1 template: Template topology: Topology @@ -112,64 +134,72 @@ class SpecModel(BaseModel): """ Name of the InferenceCluster this replica is pinned to. Replicas are pinned at creation time. If the cluster is temporarily unavailable the replica stays pinned and the parent ModelDeployment surfaces the degraded state via its conditions. If the cluster is deleted entirely the parent ModelDeployment re-places the replica on another viable cluster. """ - crossplane: Optional[Crossplane] = None + crossplane: Crossplane | None = None """ Configures how Crossplane will reconcile this composite resource """ - modelCacheRef: Optional[ModelCacheRef] = None + deviceRequests: list[DeviceRequest] = Field(..., max_length=16, min_length=1) + """ + Resolved DRA device requests for the matched pool. The parent ModelDeployment's compose function joins the nodeSelector requests with the matched InferenceClass devices and stamps the claim: DRA devices here. This function turns each into a DeviceRequest in a DRA ResourceClaim for the serving pods. At least one request is always present: the scheduler only pins a replica to a pool that yields a claimable device, so the serving workload always has a ResourceClaim to bind through. + """ + modelCacheRef: ModelCacheRef | None = None """ Optional reference to a ModelCache mounted into the engine pod. Inherited verbatim from the parent ModelDeployment. """ + nodePoolName: constr(min_length=1) + """ + Name of the node pool on the pinned InferenceCluster the scheduler selected for this replica. The scheduler pins every replica to a specific matching pool, so this is always set. + """ workers: Workers class Condition(BaseModel): - lastTransitionTime: datetime - message: Optional[str] = None - observedGeneration: Optional[int] = None + lastTransitionTime: AwareDatetime + message: str | None = None + observedGeneration: int | None = None reason: str status: str type: str class Status(BaseModel): - conditions: Optional[List[Condition]] = None + conditions: list[Condition] | None = None """ Conditions of the resource. """ class ModelReplica(BaseModel): - apiVersion: Optional[Literal['modelplane.ai/v1alpha1']] = 'modelplane.ai/v1alpha1' + apiVersion: Literal['modelplane.ai/v1alpha1'] | None = 'modelplane.ai/v1alpha1' """ APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources """ - kind: Optional[Literal['ModelReplica']] = 'ModelReplica' + kind: Literal['ModelReplica'] | None = 'ModelReplica' """ Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds """ - metadata: Optional[v1.ObjectMeta] = None + metadata: v1.ObjectMeta | None = None """ Standard object's metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata """ spec: SpecModel - status: Optional[Status] = None + status: Status | None = None class ModelReplicaList(BaseModel): - apiVersion: Optional[str] = None + apiVersion: str | None = None """ APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources """ - items: List[ModelReplica] + items: list[ModelReplica] """ List of modelreplicas. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md """ - kind: Optional[str] = None + kind: str | None = None """ Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds """ - metadata: Optional[v1.ListMeta] = None + metadata: v1.ListMeta | None = None """ Standard list metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds """ \ No newline at end of file diff --git a/schemas/python/models/ai/modelplane/modelservice/v1alpha1.py b/schemas/python/models/ai/modelplane/modelservice/v1alpha1.py index 17852a019..d53ef646a 100644 --- a/schemas/python/models/ai/modelplane/modelservice/v1alpha1.py +++ b/schemas/python/models/ai/modelplane/modelservice/v1alpha1.py @@ -3,10 +3,9 @@ from __future__ import annotations -from datetime import datetime -from typing import Any, Dict, List, Literal, Optional +from typing import Any, Literal -from pydantic import BaseModel, Field +from pydantic import AwareDatetime, BaseModel, Field from ....io.k8s.apimachinery.pkg.apis.meta import v1 @@ -20,30 +19,30 @@ class CompositionRevisionRef(BaseModel): class CompositionRevisionSelector(BaseModel): - matchLabels: Dict[str, str] + matchLabels: dict[str, str] class CompositionSelector(BaseModel): - matchLabels: Dict[str, str] + matchLabels: dict[str, str] class ResourceRef(BaseModel): apiVersion: str kind: str - name: Optional[str] = None + name: str | None = None class Crossplane(BaseModel): - compositionRef: Optional[CompositionRef] = None - compositionRevisionRef: Optional[CompositionRevisionRef] = None - compositionRevisionSelector: Optional[CompositionRevisionSelector] = None - compositionSelector: Optional[CompositionSelector] = None - compositionUpdatePolicy: Optional[Literal['Automatic', 'Manual']] = None - resourceRefs: Optional[List[ResourceRef]] = None + compositionRef: CompositionRef | None = None + compositionRevisionRef: CompositionRevisionRef | None = None + compositionRevisionSelector: CompositionRevisionSelector | None = None + compositionSelector: CompositionSelector | None = None + compositionUpdatePolicy: Literal['Automatic', 'Manual'] | None = None + resourceRefs: list[ResourceRef] | None = None class Selector(BaseModel): - matchLabels: Dict[str, Any] + matchLabels: dict[str, Any] class Endpoint(BaseModel): @@ -51,67 +50,67 @@ class Endpoint(BaseModel): class Spec(BaseModel): - crossplane: Optional[Crossplane] = None + crossplane: Crossplane | None = None """ Configures how Crossplane will reconcile this composite resource """ - endpoints: List[Endpoint] = Field(..., min_length=1) + endpoints: list[Endpoint] = Field(..., min_length=1) """ Endpoints to route traffic to. Each entry selects a set of ModelEndpoints by label. Matched endpoints are load-balanced equally. """ class Condition(BaseModel): - lastTransitionTime: datetime - message: Optional[str] = None - observedGeneration: Optional[int] = None + lastTransitionTime: AwareDatetime + message: str | None = None + observedGeneration: int | None = None reason: str status: str type: str class Status(BaseModel): - address: Optional[str] = None + address: str | None = None """ Public address where this service is reachable. """ - conditions: Optional[List[Condition]] = None + conditions: list[Condition] | None = None """ Conditions of the resource. """ class ModelService(BaseModel): - apiVersion: Optional[Literal['modelplane.ai/v1alpha1']] = 'modelplane.ai/v1alpha1' + apiVersion: Literal['modelplane.ai/v1alpha1'] | None = 'modelplane.ai/v1alpha1' """ APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources """ - kind: Optional[Literal['ModelService']] = 'ModelService' + kind: Literal['ModelService'] | None = 'ModelService' """ Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds """ - metadata: Optional[v1.ObjectMeta] = None + metadata: v1.ObjectMeta | None = None """ Standard object's metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata """ spec: Spec - status: Optional[Status] = None + status: Status | None = None class ModelServiceList(BaseModel): - apiVersion: Optional[str] = None + apiVersion: str | None = None """ APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources """ - items: List[ModelService] + items: list[ModelService] """ List of modelservices. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md """ - kind: Optional[str] = None + kind: str | None = None """ Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds """ - metadata: Optional[v1.ListMeta] = None + metadata: v1.ListMeta | None = None """ Standard list metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds """ \ No newline at end of file diff --git a/schemas/python/models/io/k8s/apimachinery/pkg/apis/meta/v1.py b/schemas/python/models/io/k8s/apimachinery/pkg/apis/meta/v1.py index 7e0b39c87..b0e48de1a 100644 --- a/schemas/python/models/io/k8s/apimachinery/pkg/apis/meta/v1.py +++ b/schemas/python/models/io/k8s/apimachinery/pkg/apis/meta/v1.py @@ -3,10 +3,7 @@ from __future__ import annotations -from datetime import datetime -from typing import Dict, List, Optional - -from pydantic import BaseModel, Field, RootModel +from pydantic import AwareDatetime, BaseModel, Field, RootModel class FieldsV1(BaseModel): @@ -14,19 +11,19 @@ class FieldsV1(BaseModel): class ListMeta(BaseModel): - continue_: Optional[str] = Field(None, alias='continue') + continue_: str | None = Field(None, alias='continue') """ continue may be set if the user set a limit on the number of items returned, and indicates that the server has more data available. The value is opaque and may be used to issue another request to the endpoint that served this list to retrieve the next set of available objects. Continuing a consistent list may not be possible if the server configuration has changed or more than a few minutes have passed. The resourceVersion field returned when using this continue value will be identical to the value in the first response, unless you have received this token from an error message. """ - remainingItemCount: Optional[int] = None + remainingItemCount: int | None = None """ remainingItemCount is the number of subsequent items in the list which are not included in this list response. If the list request contained label or field selectors, then the number of remaining items is unknown and the field will be left unset and omitted during serialization. If the list is complete (either because it is not chunking or because this is the last chunk), then there are no more remaining items and this field will be left unset and omitted during serialization. Servers older than v1.15 do not set this field. The intended use of the remainingItemCount is *estimating* the size of a collection. Clients should not rely on the remainingItemCount to be set or to be exact. """ - resourceVersion: Optional[str] = None + resourceVersion: str | None = None """ String that identifies the server's internal version of this object that can be used by clients to determine when objects have changed. Value must be treated as opaque by clients and passed unmodified back to the server. Populated by the system. Read-only. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency """ - selfLink: Optional[str] = None + selfLink: str | None = None """ Deprecated: selfLink is a legacy read-only field that is no longer populated by the system. """ @@ -37,11 +34,11 @@ class OwnerReference(BaseModel): """ API version of the referent. """ - blockOwnerDeletion: Optional[bool] = None + blockOwnerDeletion: bool | None = None """ If true, AND if the owner has the "foregroundDeletion" finalizer, then the owner cannot be deleted from the key-value store until this reference is removed. See https://kubernetes.io/docs/concepts/architecture/garbage-collection/#foreground-deletion for how the garbage collector interacts with this field and enforces the foreground deletion. Defaults to false. To set this field, a user needs "delete" permission of the owner, otherwise 422 (Unprocessable Entity) will be returned. """ - controller: Optional[bool] = None + controller: bool | None = None """ If true, this reference points to the managing controller. """ @@ -64,18 +61,18 @@ class Patch(BaseModel): class Preconditions(BaseModel): - resourceVersion: Optional[str] = None + resourceVersion: str | None = None """ Specifies the target ResourceVersion """ - uid: Optional[str] = None + uid: str | None = None """ Specifies the target UID. """ class StatusCause(BaseModel): - field: Optional[str] = None + field: str | None = None """ The field of the resource that has caused this error, as named by its JSON serialization. May include dot and postfix notation for nested attributes. Arrays are zero-indexed. Fields may appear more than once in an array of causes due to fields having multiple errors. Optional. @@ -83,142 +80,142 @@ class StatusCause(BaseModel): "name" - the field "name" on the current resource "items[0].name" - the field "name" on the first array entry in "items" """ - message: Optional[str] = None + message: str | None = None """ A human-readable description of the cause of the error. This field may be presented as-is to a reader. """ - reason: Optional[str] = None + reason: str | None = None """ A machine-readable description of the cause of the error. If this value is empty there is no information available. """ class StatusDetails(BaseModel): - causes: Optional[List[StatusCause]] = None + causes: list[StatusCause] | None = None """ The Causes array includes more details associated with the StatusReason failure. Not all StatusReasons may provide detailed causes. """ - group: Optional[str] = None + group: str | None = None """ The group attribute of the resource associated with the status StatusReason. """ - kind: Optional[str] = None + kind: str | None = None """ The kind attribute of the resource associated with the status StatusReason. On some operations may differ from the requested resource Kind. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds """ - name: Optional[str] = None + name: str | None = None """ The name attribute of the resource associated with the status StatusReason (when there is a single name which can be described). """ - retryAfterSeconds: Optional[int] = None + retryAfterSeconds: int | None = None """ If specified, the time in seconds before the operation should be retried. Some errors may indicate the client must take an alternate action - for those errors this field may indicate how long to wait before taking the alternate action. """ - uid: Optional[str] = None + uid: str | None = None """ UID of the resource. (when there is a single resource which can be described). More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids """ -class Time(RootModel[datetime]): - root: datetime +class Time(RootModel[AwareDatetime]): + root: AwareDatetime """ Time is a wrapper around time.Time which supports correct marshaling to YAML and JSON. Wrappers are provided for many of the factory methods that the time package offers. """ class DeleteOptions(BaseModel): - apiVersion: Optional[str] = None + apiVersion: str | None = None """ APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources """ - dryRun: Optional[List[str]] = None + dryRun: list[str] | None = None """ When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed """ - gracePeriodSeconds: Optional[int] = None + gracePeriodSeconds: int | None = None """ The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately. """ - ignoreStoreReadErrorWithClusterBreakingPotential: Optional[bool] = None + ignoreStoreReadErrorWithClusterBreakingPotential: bool | None = None """ if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it """ - kind: Optional[str] = None + kind: str | None = None """ Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds """ - orphanDependents: Optional[bool] = None + orphanDependents: bool | None = None """ Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the "orphan" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both. """ - preconditions: Optional[Preconditions] = None + preconditions: Preconditions | None = None """ Must be fulfilled before a deletion is carried out. If not possible, a 409 Conflict status will be returned. """ - propagationPolicy: Optional[str] = None + propagationPolicy: str | None = None """ Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground. """ class ManagedFieldsEntry(BaseModel): - apiVersion: Optional[str] = None + apiVersion: str | None = None """ APIVersion defines the version of this resource that this field set applies to. The format is "group/version" just like the top-level APIVersion field. It is necessary to track the version of a field set because it cannot be automatically converted. """ - fieldsType: Optional[str] = None + fieldsType: str | None = None """ FieldsType is the discriminator for the different fields format and version. There is currently only one possible value: "FieldsV1" """ - fieldsV1: Optional[FieldsV1] = None + fieldsV1: FieldsV1 | None = None """ FieldsV1 holds the first JSON version format as described in the "FieldsV1" type. """ - manager: Optional[str] = None + manager: str | None = None """ Manager is an identifier of the workflow managing these fields. """ - operation: Optional[str] = None + operation: str | None = None """ Operation is the type of operation which lead to this ManagedFieldsEntry being created. The only valid values for this field are 'Apply' and 'Update'. """ - subresource: Optional[str] = None + subresource: str | None = None """ Subresource is the name of the subresource used to update that object, or empty string if the object was updated through the main resource. The value of this field is used to distinguish between managers, even if they share the same name. For example, a status update will be distinct from a regular update using the same manager name. Note that the APIVersion field is not related to the Subresource field and it always corresponds to the version of the main resource. """ - time: Optional[Time] = None + time: Time | None = None """ Time is the timestamp of when the ManagedFields entry was added. The timestamp will also be updated if a field is added, the manager changes any of the owned fields value or removes a field. The timestamp does not update when a field is removed from the entry because another manager took it over. """ class ObjectMeta(BaseModel): - annotations: Optional[Dict[str, str]] = None + annotations: dict[str, str] | None = None """ Annotations is an unstructured key value map stored with a resource that may be set by external tools to store and retrieve arbitrary metadata. They are not queryable and should be preserved when modifying objects. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations """ - creationTimestamp: Optional[Time] = None + creationTimestamp: Time | None = None """ CreationTimestamp is a timestamp representing the server time when this object was created. It is not guaranteed to be set in happens-before order across separate operations. Clients may not set this value. It is represented in RFC3339 form and is in UTC. Populated by the system. Read-only. Null for lists. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata """ - deletionGracePeriodSeconds: Optional[int] = None + deletionGracePeriodSeconds: int | None = None """ Number of seconds allowed for this object to gracefully terminate before it will be removed from the system. Only set when deletionTimestamp is also set. May only be shortened. Read-only. """ - deletionTimestamp: Optional[Time] = None + deletionTimestamp: Time | None = None """ DeletionTimestamp is RFC 3339 date and time at which this resource will be deleted. This field is set by the server when a graceful deletion is requested by the user, and is not directly settable by a client. The resource is expected to be deleted (no longer visible from resource lists, and not reachable by name) after the time in this field, once the finalizers list is empty. As long as the finalizers list contains items, deletion is blocked. Once the deletionTimestamp is set, this value may not be unset or be set further into the future, although it may be shortened or the resource may be deleted prior to this time. For example, a user may request that a pod is deleted in 30 seconds. The Kubelet will react by sending a graceful termination signal to the containers in the pod. After that 30 seconds, the Kubelet will send a hard termination signal (SIGKILL) to the container and after cleanup, remove the pod from the API. In the presence of network partitions, this object may still exist after this timestamp, until an administrator or automated process can determine the resource is fully terminated. If not set, graceful deletion of the object has not been requested. Populated by the system when a graceful deletion is requested. Read-only. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata """ - finalizers: Optional[List[str]] = None + finalizers: list[str] | None = None """ Must be empty before the object is deleted from the registry. Each entry is an identifier for the responsible component that will remove the entry from the list. If the deletionTimestamp of the object is non-nil, entries in this list can only be removed. Finalizers may be processed and removed in any order. Order is NOT enforced because it introduces significant risk of stuck finalizers. finalizers is a shared field, any actor with permission can reorder it. If the finalizer list is processed in order, then this can lead to a situation in which the component responsible for the first finalizer in the list is waiting for a signal (field value, external system, or other) produced by a component responsible for a finalizer later in the list, resulting in a deadlock. Without enforced ordering finalizers are free to order amongst themselves and are not vulnerable to ordering changes in the list. """ - generateName: Optional[str] = None + generateName: str | None = None """ GenerateName is an optional prefix, used by the server, to generate a unique name ONLY IF the Name field has not been provided. If this field is used, the name returned to the client will be different than the name passed. This value will also be combined with a unique suffix. The provided value has the same validation rules as the Name field, and may be truncated by the length of the suffix required to make the value unique on the server. @@ -226,43 +223,43 @@ class ObjectMeta(BaseModel): Applied only if Name is not specified. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#idempotency """ - generation: Optional[int] = None + generation: int | None = None """ A sequence number representing a specific generation of the desired state. Populated by the system. Read-only. """ - labels: Optional[Dict[str, str]] = None + labels: dict[str, str] | None = None """ Map of string keys and values that can be used to organize and categorize (scope and select) objects. May match selectors of replication controllers and services. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels """ - managedFields: Optional[List[ManagedFieldsEntry]] = None + managedFields: list[ManagedFieldsEntry] | None = None """ ManagedFields maps workflow-id and version to the set of fields that are managed by that workflow. This is mostly for internal housekeeping, and users typically shouldn't need to set or understand this field. A workflow can be the user's name, a controller's name, or the name of a specific apply path like "ci-cd". The set of fields is always in the version that the workflow used when modifying the object. """ - name: Optional[str] = None + name: str | None = None """ Name must be unique within a namespace. Is required when creating resources, although some resources may allow a client to request the generation of an appropriate name automatically. Name is primarily intended for creation idempotence and configuration definition. Cannot be updated. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#names """ - namespace: Optional[str] = None + namespace: str | None = None """ Namespace defines the space within which each name must be unique. An empty namespace is equivalent to the "default" namespace, but "default" is the canonical representation. Not all objects are required to be scoped to a namespace - the value of this field for those objects will be empty. Must be a DNS_LABEL. Cannot be updated. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces """ - ownerReferences: Optional[List[OwnerReference]] = None + ownerReferences: list[OwnerReference] | None = None """ List of objects depended by this object. If ALL objects in the list have been deleted, this object will be garbage collected. If this object is managed by a controller, then an entry in this list will point to this controller, with the controller field set to true. There cannot be more than one managing controller. """ - resourceVersion: Optional[str] = None + resourceVersion: str | None = None """ An opaque value that represents the internal version of this object that can be used by clients to determine when objects have changed. May be used for optimistic concurrency, change detection, and the watch operation on a resource or set of resources. Clients must treat these values as opaque and passed unmodified back to the server. They may only be valid for a particular resource or set of resources. Populated by the system. Read-only. Value must be treated as opaque by clients and . More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency """ - selfLink: Optional[str] = None + selfLink: str | None = None """ Deprecated: selfLink is a legacy read-only field that is no longer populated by the system. """ - uid: Optional[str] = None + uid: str | None = None """ UID is the unique in time and space value for this object. It is typically generated by the server on successful creation of a resource and is not allowed to change on PUT operations. @@ -271,35 +268,35 @@ class ObjectMeta(BaseModel): class Status(BaseModel): - apiVersion: Optional[str] = None + apiVersion: str | None = None """ APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources """ - code: Optional[int] = None + code: int | None = None """ Suggested HTTP return code for this status, 0 if not set. """ - details: Optional[StatusDetails] = None + details: StatusDetails | None = None """ Extended data associated with the reason. Each reason may define its own extended details. This field is optional and the data returned is not guaranteed to conform to any schema except that defined by the reason type. """ - kind: Optional[str] = None + kind: str | None = None """ Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds """ - message: Optional[str] = None + message: str | None = None """ A human-readable description of the status of this operation. """ - metadata: Optional[ListMeta] = {} + metadata: ListMeta | None = Field({}, validate_default=True) """ Standard list metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds """ - reason: Optional[str] = None + reason: str | None = None """ A machine-readable description of why this operation is in the "Failure" status. If this value is empty there is no information available. A Reason clarifies an HTTP status code but does not override it. """ - status: Optional[str] = None + status: str | None = None """ Status of the operation. One of: "Success" or "Failure". More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status """ diff --git a/uv.lock b/uv.lock index 42901df87..74624d0d8 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,10 @@ version = 1 revision = 3 requires-python = ">=3.11, <3.14" +resolution-markers = [ + "python_full_version >= '3.13'", + "python_full_version < '3.13'", +] [manifest] members = [ @@ -28,6 +32,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] +[[package]] +name = "cel-python" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-re2" }, + { name = "jmespath" }, + { name = "lark" }, + { name = "pendulum" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/4e/f821948a5bbd7a98a218720f831a62216f79a98e43b13d9ab2f98e37c5f8/cel_python-0.5.0.tar.gz", hash = "sha256:3eb0a619e8df0f338d0430cda01427a742e77e3c433a1c7c3ebd409cd804c45a", size = 13364027, upload-time = "2026-01-31T19:07:13.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/f8/38812adc3f787c2c2e8ba56f524185ed379656c10b40347a32796ba61c08/cel_python-0.5.0-py3-none-any.whl", hash = "sha256:d0f85008b89655c2bb18d797d2fa3f96f2ed80f4a3b43b0e8138c6646581e5f6", size = 84950, upload-time = "2026-01-31T19:07:11.821Z" }, +] + [[package]] name = "click" version = "8.4.1" @@ -63,7 +83,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "click", specifier = ">=8.1.0" }, - { name = "crossplane-function-sdk-python", specifier = ">=0.12.0" }, + { name = "crossplane-function-sdk-python", specifier = ">=0.13.0" }, { name = "crossplane-models", editable = "schemas/python" }, { name = "grpcio", specifier = ">=1.73.1" }, ] @@ -82,7 +102,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "click", specifier = ">=8.1.0" }, - { name = "crossplane-function-sdk-python", specifier = ">=0.12.0" }, + { name = "crossplane-function-sdk-python", specifier = ">=0.13.0" }, { name = "crossplane-models", editable = "schemas/python" }, { name = "grpcio", specifier = ">=1.73.1" }, ] @@ -101,7 +121,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "click", specifier = ">=8.1.0" }, - { name = "crossplane-function-sdk-python", specifier = ">=0.12.0" }, + { name = "crossplane-function-sdk-python", specifier = ">=0.13.0" }, { name = "crossplane-models", editable = "schemas/python" }, { name = "grpcio", specifier = ">=1.73.1" }, ] @@ -120,7 +140,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "click", specifier = ">=8.1.0" }, - { name = "crossplane-function-sdk-python", specifier = ">=0.12.0" }, + { name = "crossplane-function-sdk-python", specifier = ">=0.13.0" }, { name = "crossplane-models", editable = "schemas/python" }, { name = "grpcio", specifier = ">=1.73.1" }, ] @@ -140,7 +160,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "click", specifier = ">=8.1.0" }, - { name = "crossplane-function-sdk-python", specifier = ">=0.12.0" }, + { name = "crossplane-function-sdk-python", specifier = ">=0.13.0" }, { name = "crossplane-models", editable = "schemas/python" }, { name = "grpcio", specifier = ">=1.73.1" }, { name = "pyyaml", specifier = ">=6.0" }, @@ -170,6 +190,7 @@ name = "compose-model-deployment" version = "0.0.0" source = { editable = "functions/compose-model-deployment" } dependencies = [ + { name = "cel-python" }, { name = "click" }, { name = "crossplane-function-sdk-python" }, { name = "crossplane-models" }, @@ -178,8 +199,9 @@ dependencies = [ [package.metadata] requires-dist = [ + { name = "cel-python", specifier = ">=0.3.0" }, { name = "click", specifier = ">=8.1.0" }, - { name = "crossplane-function-sdk-python", specifier = ">=0.12.0" }, + { name = "crossplane-function-sdk-python", specifier = ">=0.13.0" }, { name = "crossplane-models", editable = "schemas/python" }, { name = "grpcio", specifier = ">=1.73.1" }, ] @@ -198,7 +220,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "click", specifier = ">=8.1.0" }, - { name = "crossplane-function-sdk-python", specifier = ">=0.12.0" }, + { name = "crossplane-function-sdk-python", specifier = ">=0.13.0" }, { name = "crossplane-models", editable = "schemas/python" }, { name = "grpcio", specifier = ">=1.73.1" }, ] @@ -217,7 +239,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "click", specifier = ">=8.1.0" }, - { name = "crossplane-function-sdk-python", specifier = ">=0.12.0" }, + { name = "crossplane-function-sdk-python", specifier = ">=0.13.0" }, { name = "crossplane-models", editable = "schemas/python" }, { name = "grpcio", specifier = ">=1.73.1" }, ] @@ -236,7 +258,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "click", specifier = ">=8.1.0" }, - { name = "crossplane-function-sdk-python", specifier = ">=0.12.0" }, + { name = "crossplane-function-sdk-python", specifier = ">=0.13.0" }, { name = "crossplane-models", editable = "schemas/python" }, { name = "grpcio", specifier = ">=1.73.1" }, ] @@ -256,7 +278,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "click", specifier = ">=8.1.0" }, - { name = "crossplane-function-sdk-python", specifier = ">=0.12.0" }, + { name = "crossplane-function-sdk-python", specifier = ">=0.13.0" }, { name = "crossplane-models", editable = "schemas/python" }, { name = "grpcio", specifier = ">=1.73.1" }, { name = "pyyaml", specifier = ">=6.0" }, @@ -264,7 +286,7 @@ requires-dist = [ [[package]] name = "crossplane-function-sdk-python" -version = "0.12.0" +version = "0.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "grpcio" }, @@ -273,9 +295,9 @@ dependencies = [ { name = "pydantic" }, { name = "structlog" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d4/e6/560dd1ba24dd4267be4c257319af8e5bcc69ca9e5f022e662e684cbd3183/crossplane_function_sdk_python-0.12.0.tar.gz", hash = "sha256:667801e58c0358bdb0f11bf7ac944c0380372676006b025ff9b80581687bac97", size = 57268, upload-time = "2026-05-21T22:23:03.525Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/6d/2e30bba4bc941a15642ec37317e5b45f80d3a701344994d965b8dcd71bd6/crossplane_function_sdk_python-0.13.0.tar.gz", hash = "sha256:815007769ddeb3e2ea4d2b47f5feeb7b15a4692f052d62151e80ce30138eda2b", size = 60018, upload-time = "2026-06-08T20:46:01.76Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/9c/f7c1ecaa7531d2b668665adb2554f536f5ad7d77ad513c150c913416f77e/crossplane_function_sdk_python-0.12.0-py3-none-any.whl", hash = "sha256:b0c399c94f23f3c658c0013cd1b1f60a03daa05501ebf910be467d29593f89ef", size = 42733, upload-time = "2026-05-21T22:23:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/84/9e/4cfc6a3d45f53a5b43a777b7363a0e83f87e96a03e95afc9169512f42cdb/crossplane_function_sdk_python-0.13.0-py3-none-any.whl", hash = "sha256:89ec8d5e5a66bc0ce4551bafe96546329fcc841e3c04706e5ed5e22a37bf4aad", size = 42972, upload-time = "2026-06-08T20:46:00.76Z" }, ] [[package]] @@ -283,45 +305,86 @@ name = "crossplane-models" version = "0.0.0" source = { editable = "schemas/python" } +[[package]] +name = "google-re2" +version = "1.1.20251105" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/60/805c654ba53d685513df955ee745f71920fe8e6a284faf0f9b9dc19b659c/google_re2-1.1.20251105.tar.gz", hash = "sha256:1db14a292ee8303b91e91e7c37e05ac17d3c467f29416c79ac70a78be3e65bda", size = 11676, upload-time = "2025-11-05T14:58:07.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/4d/203a08dab1bdb5c83b46dd424c01a789ecb5a37dbc80f33d016bd116a9d7/google_re2-1.1.20251105-1-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:329efa209ea7baa44f0facf0402fa34e655dc97fdeb10d0b83fc06354f5575fd", size = 483717, upload-time = "2025-11-05T14:57:04.808Z" }, + { url = "https://files.pythonhosted.org/packages/78/88/466026b43ff5c7d740f5ede090992ec63b60d1810ab14fe35dfc00677e0a/google_re2-1.1.20251105-1-cp311-cp311-macosx_13_0_x86_64.whl", hash = "sha256:aa2ad5f6f48921ec137a7b7f1b1da903ddef8627a2dc30bc878a9a69d9925719", size = 515547, upload-time = "2025-11-05T14:57:06.013Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6a/c6c9fdb00c98990e4f7a6cd650e209d7b5d2754ca0404b72c69ac9909a69/google_re2-1.1.20251105-1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:ac1cb2526cc88f050a0661fc7245ad009ee454bddc541b2e653f1d007585000d", size = 485396, upload-time = "2025-11-05T14:57:07.592Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f6/529c44f607c47f96cfa29c1fe3a690fe75b2fdb48e9b0d6b54e5f0a75e59/google_re2-1.1.20251105-1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:50c7205182ad66c23c07abe8072f720ca2f7d595b61e28fd9b63623614f9afd6", size = 517150, upload-time = "2025-11-05T14:57:09.376Z" }, + { url = "https://files.pythonhosted.org/packages/df/d2/ccc07860e31ab81965c63f9ed4eb69ea0d3449a9b4e1610f71883694bbe8/google_re2-1.1.20251105-1-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:4cb5acee61e35772503b8b1db3c592a46b8e6a9bc0ab54d7d6233654ea2bf93d", size = 482807, upload-time = "2025-11-05T14:57:11.057Z" }, + { url = "https://files.pythonhosted.org/packages/bd/43/5fb20d16664457f61670bdd95f39039d43ee8b7732511c688e2f322a4317/google_re2-1.1.20251105-1-cp311-cp311-macosx_15_0_x86_64.whl", hash = "sha256:1617097d63620c2d46bdfc0e48f24f66cd341664fc75718636d234f67473fe7f", size = 508839, upload-time = "2025-11-05T14:57:12.338Z" }, + { url = "https://files.pythonhosted.org/packages/0e/f2/6e470338271e164dd3c5e508876f99aec3ed23bf419c7d54a5672fd5b05f/google_re2-1.1.20251105-1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18a5610b26742b90cb1d64ead2b16fe0e3bd7e67add03fd3779cd1b85e401661", size = 573718, upload-time = "2025-11-05T14:57:13.635Z" }, + { url = "https://files.pythonhosted.org/packages/91/21/4566fc344c21cf3c49082d13ddab785994b5e3b8b7fd4631242538f698a2/google_re2-1.1.20251105-1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03156291269f145eccddff63118f2df02d395792f51fc039f09955818943815a", size = 590749, upload-time = "2025-11-05T14:57:14.864Z" }, + { url = "https://files.pythonhosted.org/packages/94/19/5981fb798bb8d08933b815b1fd9e55d179c380b9d8c21a49197b9b7c5967/google_re2-1.1.20251105-1-cp311-cp311-win32.whl", hash = "sha256:54f51762b51dc238eceddf49b56cc2b64594fe72d9328c1c39d615aa990e1f87", size = 434066, upload-time = "2025-11-05T14:57:16.22Z" }, + { url = "https://files.pythonhosted.org/packages/49/e5/f83053a36cfc4762d843748e4f7a9c1141937dcf74cd6fc3f4598292dda3/google_re2-1.1.20251105-1-cp311-cp311-win_amd64.whl", hash = "sha256:f5f856ff5036a8f22b3bad57f376d4e3b97b59b64f311bdb1f83c8dabded2492", size = 491025, upload-time = "2025-11-05T14:57:17.746Z" }, + { url = "https://files.pythonhosted.org/packages/56/be/4315c3b38f42f9a2888fa76260545c98547502f1c35aa63a672d39011b2e/google_re2-1.1.20251105-1-cp311-cp311-win_arm64.whl", hash = "sha256:913864f97de4151eaa8bb7746ca230fd193656501e07fb658ce2cd46d4f6efcc", size = 642194, upload-time = "2025-11-05T14:57:19.374Z" }, + { url = "https://files.pythonhosted.org/packages/67/20/73b487538e9107c2fd96aed737e3f3890dfce3e292622e4ffb2f9c810ee5/google_re2-1.1.20251105-1-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:b30f09b4d63249c72e65ccae4cbf6b331b48c22fc7cb439f1d85f347b9d07ceb", size = 485591, upload-time = "2025-11-05T14:57:20.961Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9a/ca3a993bdb5dc6d5b2616b9657b2872a83d1827f8bd3ab50cd629eb751c7/google_re2-1.1.20251105-1-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:9a77892c524b8bdf3d47d7cad1cc2ac3a0108bdd65007ef4c02888fa46baf8ee", size = 518780, upload-time = "2025-11-05T14:57:22.18Z" }, + { url = "https://files.pythonhosted.org/packages/df/37/b2e367987371514253ec9e514637f457deaacb7acc1c900814f3a6421e0f/google_re2-1.1.20251105-1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a3ac51b28cbf25c100dfd8849212d878d7005d1d4a7e129a10789043c56b6021", size = 486966, upload-time = "2025-11-05T14:57:24.575Z" }, + { url = "https://files.pythonhosted.org/packages/d9/69/1db6742943c0ac254bfb7d8a37a5d3f73f016a65cfa1f84fe3a0451820f6/google_re2-1.1.20251105-1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:9f7158afc9825ac2654c6561aea94a1f7edb5b5b88e6e3639bb80bb817d102ac", size = 520225, upload-time = "2025-11-05T14:57:26.039Z" }, + { url = "https://files.pythonhosted.org/packages/f4/0a/0747c92dbebe2c09a26bd7386d372b5c5a9926236b4f3d69bb8f15db05cb/google_re2-1.1.20251105-1-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:5320da07dc3b7ac7f407514f42ac17d67e771ac7c7562d449571185e6fb601b2", size = 482943, upload-time = "2025-11-05T14:57:27.353Z" }, + { url = "https://files.pythonhosted.org/packages/7f/14/6bfc6838bb6cb561824ac03deeab2bd11d5d9a93505f536c8fa2f6bd46c4/google_re2-1.1.20251105-1-cp312-cp312-macosx_15_0_x86_64.whl", hash = "sha256:5a4e5785bc30d52ce655d805b07ad2d8a4905429a5f690ae9c2f1caa76665709", size = 510384, upload-time = "2025-11-05T14:57:29.139Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0a/6add090c917ee39f6f0be753037cafceb3bad904b424efc155fb38082635/google_re2-1.1.20251105-1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b7a3b90f747130310d4b3b8e19ebb845d0d97c1deb63b36f76c7242dacbd736", size = 572446, upload-time = "2025-11-05T14:57:30.495Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1c/8b1ccbeade96a21435d55b5185cd6d9b2ceab5a9af998a4d9099e0540759/google_re2-1.1.20251105-1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:809c5fa5d08279413b29c2e2c5c528e85cd94a0e0fd897db595a0c09eeee2782", size = 591348, upload-time = "2025-11-05T14:57:31.808Z" }, + { url = "https://files.pythonhosted.org/packages/62/cf/7bdd7a1ae7828b613011da808eafec4da3132f43c3be6af5e0bd670ebe8b/google_re2-1.1.20251105-1-cp312-cp312-win32.whl", hash = "sha256:d8424e63a9ec0fe5bde03d97876b2431f8a746af33eb475fa1ae39144bd05b2a", size = 433787, upload-time = "2025-11-05T14:57:33.071Z" }, + { url = "https://files.pythonhosted.org/packages/31/e9/5dd951c35acaabfe87c67228b9af2cdcd7779d9167edbe6b9094b8a8e529/google_re2-1.1.20251105-1-cp312-cp312-win_amd64.whl", hash = "sha256:062313c309f93dfeb6966372f4c446580e98879133ec155522eea8aaf568a5cd", size = 491726, upload-time = "2025-11-05T14:57:34.39Z" }, + { url = "https://files.pythonhosted.org/packages/60/8d/c1afd29fc2cb475fd4c634f3d3c8099c0efb662362c10b27a9eaf11c9357/google_re2-1.1.20251105-1-cp312-cp312-win_arm64.whl", hash = "sha256:558f144b26a9555ae4e9467cc3aa3299a8ce13217f328b21ae326ca0633be19b", size = 642673, upload-time = "2025-11-05T14:57:35.693Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b9/c441722196598fc3de0f654606ad9975a968c71dc27f516b5a4c9ebb94fd/google_re2-1.1.20251105-1-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:9f3cf610e857a7d6f02916cf2b7fc159a5429b8bcb23164500d46e5e233f2924", size = 485549, upload-time = "2025-11-05T14:57:36.939Z" }, + { url = "https://files.pythonhosted.org/packages/ea/87/cf588255e5ada1dfb555cc96de35be78438bb0b6faba64df5fe91cecc224/google_re2-1.1.20251105-1-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:a21c2807bf4d5d00f206a4ecb3b043aad674e28c451b697b740280f608872078", size = 518840, upload-time = "2025-11-05T14:57:38.115Z" }, + { url = "https://files.pythonhosted.org/packages/0d/39/da66e4ca9be0c51546efc6fb39cf1683c4be8245d8199cb54a9808e8d5fa/google_re2-1.1.20251105-1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8314144eefeee7b88b742081c2038418f677e63901039ca9dbfbc0c5bb6d2911", size = 487037, upload-time = "2025-11-05T14:57:39.467Z" }, + { url = "https://files.pythonhosted.org/packages/75/dd/24ba65692dd58dca6ff178428551f4e9b776d1489a1251f5c8539e598baa/google_re2-1.1.20251105-1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:28a46be978e53c772139d0f5c9ba69f53563fcdd4225407e4d34d51208b828f1", size = 520285, upload-time = "2025-11-05T14:57:40.666Z" }, + { url = "https://files.pythonhosted.org/packages/61/12/cfdbb92bed24af6474970a75a26145c424f98cfbcc633fdd185985f0efe0/google_re2-1.1.20251105-1-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:83292e23963aa1b219d5f64a65365b0880448a6a060276027b55270bc5b18c7e", size = 482981, upload-time = "2025-11-05T14:57:41.928Z" }, + { url = "https://files.pythonhosted.org/packages/97/bf/5fc32ded9279e69a87b88d7261e7e77e2e26325d4e27ca1303a3215e430a/google_re2-1.1.20251105-1-cp313-cp313-macosx_15_0_x86_64.whl", hash = "sha256:1920b15dc9b1bdfeca5aa2c60900373c6f27cd1056d53cd299456ea5540a6fff", size = 510366, upload-time = "2025-11-05T14:57:43.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/71/f927ddc7aef1b8d7ccc8a649c335d311f29f3dea658209e30e37720e4891/google_re2-1.1.20251105-1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b1458d9ca588124cd61aa1bf5388a216e1247e7d474f8e5e1530498044f5c87", size = 572390, upload-time = "2025-11-05T14:57:44.422Z" }, + { url = "https://files.pythonhosted.org/packages/f0/8c/23075e589038284c9487f41cde531d35873f9da622fb4ac7d1d97bd9086e/google_re2-1.1.20251105-1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a52cb204e49d20cdbb66faf394d57f476e96c39c23a328442ab0194fc6bd1a2b", size = 591386, upload-time = "2025-11-05T14:57:45.713Z" }, + { url = "https://files.pythonhosted.org/packages/f1/7f/858453ef689f6b9895cd02b466836a9d1a6e4ba535d1a275b01bf73baa1d/google_re2-1.1.20251105-1-cp313-cp313-win32.whl", hash = "sha256:67c5c73d7ebcf3f0e0a3b528b41bd8c6c04900f1598aebf05bbdf15a06cf5f9a", size = 433807, upload-time = "2025-11-05T14:57:46.92Z" }, + { url = "https://files.pythonhosted.org/packages/08/24/6ea87fe682e115ffd296e91eb5c5a266349d1ee8414ce8ece3f99ec1ac84/google_re2-1.1.20251105-1-cp313-cp313-win_amd64.whl", hash = "sha256:0bcba63ad3ea8926fb0c71bb5044e33d405bb9395f5b5444393cd5f28f0bf6d3", size = 491734, upload-time = "2025-11-05T14:57:48.304Z" }, + { url = "https://files.pythonhosted.org/packages/34/85/32ba71b06f3cf5f9856ae95b3d6463b971742453631a5ae2c5be338ea377/google_re2-1.1.20251105-1-cp313-cp313-win_arm64.whl", hash = "sha256:64ee189ea857f2126c5e42073cfa9b03e9f4cbaf073edbedb575059074841aa0", size = 642654, upload-time = "2025-11-05T14:57:49.602Z" }, +] + [[package]] name = "grpcio" -version = "1.80.0" +version = "1.81.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b7/48/af6173dbca4454f4637a4678b67f52ca7e0c1ed7d5894d89d434fecede05/grpcio-1.80.0.tar.gz", hash = "sha256:29aca15edd0688c22ba01d7cc01cb000d72b2033f4a3c72a81a19b56fd143257", size = 12978905, upload-time = "2026-03-30T08:49:10.502Z" } +sdist = { url = "https://files.pythonhosted.org/packages/15/f3/23f47b24f8d8c2028eba501db3acfbb2f592cbb5995eaa6e363a627b74d7/grpcio-1.81.0.tar.gz", hash = "sha256:a5acd7efd3b1fe9b4eb0bcaaa1507eed68a0ad0678b654c3f7b464df9ba9dca5", size = 13032272, upload-time = "2026-06-01T05:56:22.827Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/db/1d56e5f5823257b291962d6c0ce106146c6447f405b60b234c4f222a7cde/grpcio-1.80.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:dfab85db094068ff42e2a3563f60ab3dddcc9d6488a35abf0132daec13209c8a", size = 6055009, upload-time = "2026-03-30T08:46:46.265Z" }, - { url = "https://files.pythonhosted.org/packages/6e/18/c83f3cad64c5ca63bca7e91e5e46b0d026afc5af9d0a9972472ceba294b3/grpcio-1.80.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:5c07e82e822e1161354e32da2662f741a4944ea955f9f580ec8fb409dd6f6060", size = 12035295, upload-time = "2026-03-30T08:46:49.099Z" }, - { url = "https://files.pythonhosted.org/packages/0f/8e/e14966b435be2dda99fbe89db9525ea436edc79780431a1c2875a3582644/grpcio-1.80.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ba0915d51fd4ced2db5ff719f84e270afe0e2d4c45a7bdb1e8d036e4502928c2", size = 6610297, upload-time = "2026-03-30T08:46:52.123Z" }, - { url = "https://files.pythonhosted.org/packages/cc/26/d5eb38f42ce0e3fdc8174ea4d52036ef8d58cc4426cb800f2610f625dd75/grpcio-1.80.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3cb8130ba457d2aa09fa6b7c3ed6b6e4e6a2685fce63cb803d479576c4d80e21", size = 7300208, upload-time = "2026-03-30T08:46:54.859Z" }, - { url = "https://files.pythonhosted.org/packages/25/51/bd267c989f85a17a5b3eea65a6feb4ff672af41ca614e5a0279cc0ea381c/grpcio-1.80.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:09e5e478b3d14afd23f12e49e8b44c8684ac3c5f08561c43a5b9691c54d136ab", size = 6813442, upload-time = "2026-03-30T08:46:57.056Z" }, - { url = "https://files.pythonhosted.org/packages/9e/d9/d80eef735b19e9169e30164bbf889b46f9df9127598a83d174eb13a48b26/grpcio-1.80.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:00168469238b022500e486c1c33916acf2f2a9b2c022202cf8a1885d2e3073c1", size = 7414743, upload-time = "2026-03-30T08:46:59.682Z" }, - { url = "https://files.pythonhosted.org/packages/de/f2/567f5bd5054398ed6b0509b9a30900376dcf2786bd936812098808b49d8d/grpcio-1.80.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8502122a3cc1714038e39a0b071acb1207ca7844208d5ea0d091317555ee7106", size = 8426046, upload-time = "2026-03-30T08:47:02.474Z" }, - { url = "https://files.pythonhosted.org/packages/62/29/73ef0141b4732ff5eacd68430ff2512a65c004696997f70476a83e548e7e/grpcio-1.80.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ce1794f4ea6cc3ca29463f42d665c32ba1b964b48958a66497917fe9069f26e6", size = 7851641, upload-time = "2026-03-30T08:47:05.462Z" }, - { url = "https://files.pythonhosted.org/packages/46/69/abbfa360eb229a8623bab5f5a4f8105e445bd38ce81a89514ba55d281ad0/grpcio-1.80.0-cp311-cp311-win32.whl", hash = "sha256:51b4a7189b0bef2aa30adce3c78f09c83526cf3dddb24c6a96555e3b97340440", size = 4154368, upload-time = "2026-03-30T08:47:08.027Z" }, - { url = "https://files.pythonhosted.org/packages/6f/d4/ae92206d01183b08613e846076115f5ac5991bae358d2a749fa864da5699/grpcio-1.80.0-cp311-cp311-win_amd64.whl", hash = "sha256:02e64bb0bb2da14d947a49e6f120a75e947250aebe65f9629b62bb1f5c14e6e9", size = 4894235, upload-time = "2026-03-30T08:47:10.839Z" }, - { url = "https://files.pythonhosted.org/packages/5c/e8/a2b749265eb3415abc94f2e619bbd9e9707bebdda787e61c593004ec927a/grpcio-1.80.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:c624cc9f1008361014378c9d776de7182b11fe8b2e5a81bc69f23a295f2a1ad0", size = 6015616, upload-time = "2026-03-30T08:47:13.428Z" }, - { url = "https://files.pythonhosted.org/packages/3e/97/b1282161a15d699d1e90c360df18d19165a045ce1c343c7f313f5e8a0b77/grpcio-1.80.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:f49eddcac43c3bf350c0385366a58f36bed8cc2c0ec35ef7b74b49e56552c0c2", size = 12014204, upload-time = "2026-03-30T08:47:15.873Z" }, - { url = "https://files.pythonhosted.org/packages/6e/5e/d319c6e997b50c155ac5a8cb12f5173d5b42677510e886d250d50264949d/grpcio-1.80.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d334591df610ab94714048e0d5b4f3dd5ad1bee74dfec11eee344220077a79de", size = 6563866, upload-time = "2026-03-30T08:47:18.588Z" }, - { url = "https://files.pythonhosted.org/packages/ae/f6/fdd975a2cb4d78eb67769a7b3b3830970bfa2e919f1decf724ae4445f42c/grpcio-1.80.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0cb517eb1d0d0aaf1d87af7cc5b801d686557c1d88b2619f5e31fab3c2315921", size = 7273060, upload-time = "2026-03-30T08:47:21.113Z" }, - { url = "https://files.pythonhosted.org/packages/db/f0/a3deb5feba60d9538a962913e37bd2e69a195f1c3376a3dd44fe0427e996/grpcio-1.80.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4e78c4ac0d97dc2e569b2f4bcbbb447491167cb358d1a389fc4af71ab6f70411", size = 6782121, upload-time = "2026-03-30T08:47:23.827Z" }, - { url = "https://files.pythonhosted.org/packages/ca/84/36c6dcfddc093e108141f757c407902a05085e0c328007cb090d56646cdf/grpcio-1.80.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2ed770b4c06984f3b47eb0517b1c69ad0b84ef3f40128f51448433be904634cd", size = 7383811, upload-time = "2026-03-30T08:47:26.517Z" }, - { url = "https://files.pythonhosted.org/packages/7c/ef/f3a77e3dc5b471a0ec86c564c98d6adfa3510d38f8ee99010410858d591e/grpcio-1.80.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:256507e2f524092f1473071a05e65a5b10d84b82e3ff24c5b571513cfaa61e2f", size = 8393860, upload-time = "2026-03-30T08:47:29.439Z" }, - { url = "https://files.pythonhosted.org/packages/9b/8d/9d4d27ed7f33d109c50d6b5ce578a9914aa68edab75d65869a17e630a8d1/grpcio-1.80.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9a6284a5d907c37db53350645567c522be314bac859a64a7a5ca63b77bb7958f", size = 7830132, upload-time = "2026-03-30T08:47:33.254Z" }, - { url = "https://files.pythonhosted.org/packages/14/e4/9990b41c6d7a44e1e9dee8ac11d7a9802ba1378b40d77468a7761d1ad288/grpcio-1.80.0-cp312-cp312-win32.whl", hash = "sha256:c71309cfce2f22be26aa4a847357c502db6c621f1a49825ae98aa0907595b193", size = 4140904, upload-time = "2026-03-30T08:47:35.319Z" }, - { url = "https://files.pythonhosted.org/packages/2f/2c/296f6138caca1f4b92a31ace4ae1b87dab692fc16a7a3417af3bb3c805bf/grpcio-1.80.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe648599c0e37594c4809d81a9e77bd138cc82eb8baa71b6a86af65426723ff", size = 4880944, upload-time = "2026-03-30T08:47:37.831Z" }, - { url = "https://files.pythonhosted.org/packages/2f/3a/7c3c25789e3f069e581dc342e03613c5b1cb012c4e8c7d9d5cf960a75856/grpcio-1.80.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:e9e408fc016dffd20661f0126c53d8a31c2821b5c13c5d67a0f5ed5de93319ad", size = 6017243, upload-time = "2026-03-30T08:47:40.075Z" }, - { url = "https://files.pythonhosted.org/packages/04/19/21a9806eb8240e174fd1ab0cd5b9aa948bb0e05c2f2f55f9d5d7405e6d08/grpcio-1.80.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:92d787312e613754d4d8b9ca6d3297e69994a7912a32fa38c4c4e01c272974b0", size = 12010840, upload-time = "2026-03-30T08:47:43.11Z" }, - { url = "https://files.pythonhosted.org/packages/18/3a/23347d35f76f639e807fb7a36fad3068aed100996849a33809591f26eca6/grpcio-1.80.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac393b58aa16991a2f1144ec578084d544038c12242da3a215966b512904d0f", size = 6567644, upload-time = "2026-03-30T08:47:46.806Z" }, - { url = "https://files.pythonhosted.org/packages/ff/40/96e07ecb604a6a67ae6ab151e3e35b132875d98bc68ec65f3e5ab3e781d7/grpcio-1.80.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:68e5851ac4b9afe07e7f84483803ad167852570d65326b34d54ca560bfa53fb6", size = 7277830, upload-time = "2026-03-30T08:47:49.643Z" }, - { url = "https://files.pythonhosted.org/packages/9b/e2/da1506ecea1f34a5e365964644b35edef53803052b763ca214ba3870c856/grpcio-1.80.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:873ff5d17d68992ef6605330127425d2fc4e77e612fa3c3e0ed4e668685e3140", size = 6783216, upload-time = "2026-03-30T08:47:52.817Z" }, - { url = "https://files.pythonhosted.org/packages/44/83/3b20ff58d0c3b7f6caaa3af9a4174d4023701df40a3f39f7f1c8e7c48f9d/grpcio-1.80.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2bea16af2750fd0a899bf1abd9022244418b55d1f37da2202249ba4ba673838d", size = 7385866, upload-time = "2026-03-30T08:47:55.687Z" }, - { url = "https://files.pythonhosted.org/packages/47/45/55c507599c5520416de5eefecc927d6a0d7af55e91cfffb2e410607e5744/grpcio-1.80.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba0db34f7e1d803a878284cd70e4c63cb6ae2510ba51937bf8f45ba997cefcf7", size = 8391602, upload-time = "2026-03-30T08:47:58.303Z" }, - { url = "https://files.pythonhosted.org/packages/10/bb/dd06f4c24c01db9cf11341b547d0a016b2c90ed7dbbb086a5710df7dd1d7/grpcio-1.80.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8eb613f02d34721f1acf3626dfdb3545bd3c8505b0e52bf8b5710a28d02e8aa7", size = 7826752, upload-time = "2026-03-30T08:48:01.311Z" }, - { url = "https://files.pythonhosted.org/packages/f9/1e/9d67992ba23371fd63d4527096eb8c6b76d74d52b500df992a3343fd7251/grpcio-1.80.0-cp313-cp313-win32.whl", hash = "sha256:93b6f823810720912fd131f561f91f5fed0fda372b6b7028a2681b8194d5d294", size = 4142310, upload-time = "2026-03-30T08:48:04.594Z" }, - { url = "https://files.pythonhosted.org/packages/cf/e6/283326a27da9e2c3038bc93eeea36fb118ce0b2d03922a9cda6688f53c5b/grpcio-1.80.0-cp313-cp313-win_amd64.whl", hash = "sha256:e172cf795a3ba5246d3529e4d34c53db70e888fa582a8ffebd2e6e48bc0cba50", size = 4882833, upload-time = "2026-03-30T08:48:07.363Z" }, + { url = "https://files.pythonhosted.org/packages/45/a8/9916ab10a0201f4c7afb6918125aa2f38a7626ee18ffbc066dd9cb04a74d/grpcio-1.81.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:794e6aa648e8df47d8f908dc8c3b42347d04ec58438f1dcd4e445f09b4f6b0ce", size = 6093557, upload-time = "2026-06-01T05:54:32.64Z" }, + { url = "https://files.pythonhosted.org/packages/a7/43/99e969a048904a65df3129ee53c5f523b7c4e43127786460cac4bee82470/grpcio-1.81.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:cd78145b7f7784661c524624f3526c9c6f891b30a4b54cb93a40806d0d0d61e9", size = 12075345, upload-time = "2026-06-01T05:54:35.77Z" }, + { url = "https://files.pythonhosted.org/packages/83/70/4c3a204e190333768d4f63f4ff56bd0bf405f05b9188f3a59a8bcf161f8b/grpcio-1.81.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:638ccc1b86f7540170a169cb900799b9296a1381e47879ce60b0de9d3db73d33", size = 6640664, upload-time = "2026-06-01T05:54:38.854Z" }, + { url = "https://files.pythonhosted.org/packages/2e/a9/0fa17ac8b4e29cf59b26915be6cab8c0d4583ce24a6208a287b6e5f6d072/grpcio-1.81.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:21ec30b9ea320c8207ea7cd05873ad64aa69fdd0e81b6758b3347983ba20b50a", size = 7332542, upload-time = "2026-06-01T05:54:41.39Z" }, + { url = "https://files.pythonhosted.org/packages/f4/18/7c8e3d0dda2fb7a17076fcd6c9085209eabad3354696c64230f87b3a14eb/grpcio-1.81.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dbdb99986548a7e87f8343805ef315fd4eb50ffaabf4fb1206e42f2542bb805d", size = 6842564, upload-time = "2026-06-01T05:54:43.57Z" }, + { url = "https://files.pythonhosted.org/packages/f6/19/2f1726c2e03ad3f3fe241e6b41534532ad580d595de14a4054ad84999c80/grpcio-1.81.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c36f5d5e97944cbda2d4096b4ae262e6e68506246b61582acf1b8591607f3ccc", size = 7446236, upload-time = "2026-06-01T05:54:46.042Z" }, + { url = "https://files.pythonhosted.org/packages/a7/dc/0321f892212e2c0bfe248cea24c00d7d7111639688ec5ffd8e36b5c02fe6/grpcio-1.81.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9f355384e5543ab77a755a7085225ecc19f32b76032e851cbd8145715d79dec8", size = 8445633, upload-time = "2026-06-01T05:54:48.809Z" }, + { url = "https://files.pythonhosted.org/packages/e5/20/0e7ea7494955cf1beea3077b2fd2c04c84d4480c2ae85a1e1cfa150c62d7/grpcio-1.81.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:77eb4e9fe61486bd1198cc7236ebb0f70e66234e63c0348f40bc2553ed16a88b", size = 7873958, upload-time = "2026-06-01T05:54:52.135Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/6438e226046c2a0778060e2b1d791a4827277bbd9d223013c2c63ee7435e/grpcio-1.81.0-cp311-cp311-win32.whl", hash = "sha256:7915a2e63acdc05264a206e1bddfd8e1fb8a29e406c18d72d30f8c124e021374", size = 4202110, upload-time = "2026-06-01T05:54:54.134Z" }, + { url = "https://files.pythonhosted.org/packages/42/6b/d0895e93d65b186f5f1737fcc186d7faa487e2d9d934eda111a37a309869/grpcio-1.81.0-cp311-cp311-win_amd64.whl", hash = "sha256:5e925a70fe99fe5794f7beca0ea034c75f068afcc356d79047e73f99cdcca34c", size = 4940942, upload-time = "2026-06-01T05:54:56.749Z" }, + { url = "https://files.pythonhosted.org/packages/82/d5/896a3aaf07068d707d88b282a04914b872db4d32d3c7e6d88e43a3b911fa/grpcio-1.81.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:57b3b0e73a518fa286959b40c3eddd02703504ca186e8b7b2945954519bd8b2c", size = 6053538, upload-time = "2026-06-01T05:54:58.965Z" }, + { url = "https://files.pythonhosted.org/packages/68/6a/7e3eafa4727cd405ff917605ed2949e2af162f233f5cbdd773723a5fea7d/grpcio-1.81.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:8bb1789c94322a13336a2b6c58d9c14d68f8628b6e24205a799c69f5bf8516ce", size = 12053447, upload-time = "2026-06-01T05:55:01.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/79/a4302aa82428de48a922421f522b027a1a727ab4d0926368454aa953d36d/grpcio-1.81.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e4d053900a0d24b75d7521139a3872150301b3d6bde3bed5e12318fb25791e4d", size = 6595872, upload-time = "2026-06-01T05:55:04.946Z" }, + { url = "https://files.pythonhosted.org/packages/b4/1f/7ff2850eaefbecf99af3f624dbb28dd1ad6c5fd4c1d8c26909ed6482673b/grpcio-1.81.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:db217c2e52931719f9937bd12082cd4d7b495b35803d5760686975c285924bf8", size = 7303857, upload-time = "2026-06-01T05:55:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/e2/98/1f3896a9baae1f2aedf4e99c55291d6fa1f30ad9603d63bc18bda967b53e/grpcio-1.81.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:19f201da7b4e5c0559198abe5a97157e726f3abe6e8f5e832d4a50740f6dcc22", size = 6809676, upload-time = "2026-06-01T05:55:09.513Z" }, + { url = "https://files.pythonhosted.org/packages/34/8b/3441983718095208c5d797fd3239882e97ea89a629f41c8df94b4eef4df9/grpcio-1.81.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:275144b0115353339dbb8a6f28a9cf8997b5bf40e37f8f66ac0b0ea57e95b43f", size = 7412654, upload-time = "2026-06-01T05:55:12.777Z" }, + { url = "https://files.pythonhosted.org/packages/3c/98/1eddf07df6e4fe85cf67502a793f7b05468b2dca3d1ef35b972cf5d54468/grpcio-1.81.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5192857589f223e5a98ff0e31f6e551b19040e647d17bfe10116c8a2ce3b8696", size = 8408026, upload-time = "2026-06-01T05:55:15.514Z" }, + { url = "https://files.pythonhosted.org/packages/5c/73/3860341e6a1f5347be6ab35c6c0e1e3a8eb59d010388207fd561dcf01a88/grpcio-1.81.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c6ff087cb1f563f47b504b4e29e684129fc5ae4863faf3ebca08a327764ee6cb", size = 7849498, upload-time = "2026-06-01T05:55:18.078Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3f/0ea06bd85c701966aa3f8f37314f2ed83520d2b7590f42d643d445d8bc8b/grpcio-1.81.0-cp312-cp312-win32.whl", hash = "sha256:98c6240f563178fc5877bd50e6ff274463e53e1472128f4110742450739659fa", size = 4184161, upload-time = "2026-06-01T05:55:20.127Z" }, + { url = "https://files.pythonhosted.org/packages/39/e3/a7c387406827a86f99ad7838b995bf9b4a182ffe2d2c439ed2873efec952/grpcio-1.81.0-cp312-cp312-win_amd64.whl", hash = "sha256:87e33b7afcfb3585121b5f007d2c52b8c534104d18f556e840d35193ca2a9141", size = 4929958, upload-time = "2026-06-01T05:55:22.736Z" }, + { url = "https://files.pythonhosted.org/packages/f3/29/779ee53c931d0fd55c1d459fde43e485172caa3ac87cbd43d003a13a0185/grpcio-1.81.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:62bbe463c9f0f2ff24e31bd25f8dd8b4bae78900e315915a3195a0ef1471a855", size = 6054973, upload-time = "2026-06-01T05:55:25.043Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b6/7211807926b5a17f8d9a5d47c739a163d6812fefe3e4714e81cf92945ed7/grpcio-1.81.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:43c121e135ae44d1559b430db2b2dfad7421cbbe40e1deba506c7dc62b439719", size = 12048662, upload-time = "2026-06-01T05:55:28.453Z" }, + { url = "https://files.pythonhosted.org/packages/64/89/b1b93ef6b34bd20bbaf707fa99133bc9cc302139d5ec6f77a165c7169796/grpcio-1.81.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f345de40ef2e65f63645d53d251824e6070e07804827c5b00ec2e44555f9f901", size = 6599116, upload-time = "2026-06-01T05:55:31.185Z" }, + { url = "https://files.pythonhosted.org/packages/eb/bc/c89f9b9d1c22895715356a1e009554dae66319e97826bb4d30bcda7d29e8/grpcio-1.81.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:8c0855a350886f713b9e458e2a10d208009dcaa849f574e39cd6067db1fe1279", size = 7307591, upload-time = "2026-06-01T05:55:33.463Z" }, + { url = "https://files.pythonhosted.org/packages/65/4a/1df2a4cb4a1386e066ab7e4175e34bb884b35ccb60d3621c09c84af6aabb/grpcio-1.81.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a524cd530900bd24511fcb7f2ed144da4ea37711c4b094475d0bceca7a93a170", size = 6811797, upload-time = "2026-06-01T05:55:36.731Z" }, + { url = "https://files.pythonhosted.org/packages/8d/dc/fa189d20601a1be25b08850cfb733879bbb1047b62a8feec3a60e3e1a87b/grpcio-1.81.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e7746ba3e6efc9e2b748eff59470a2b8684d5a9ec607c6580bcaa5be175820bc", size = 7415131, upload-time = "2026-06-01T05:55:39.451Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a3/5625c48cb48d23c6631b3e5294f88e4c751f22a52591ae78859fab96dca1/grpcio-1.81.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:aaaa4f7f2057d795952e4eacf3f342be8b5b156992f6ac85023c8b98794ebd47", size = 8408398, upload-time = "2026-06-01T05:55:42.219Z" }, + { url = "https://files.pythonhosted.org/packages/75/34/0f8202c6809a46c2b4d69125ef3667c40b1c211f8e19930e5fa1f1197039/grpcio-1.81.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0fba53cb96004b2b7fb758b46b2288cb49d0b658316a4e73f3ef67230616ee65", size = 7844481, upload-time = "2026-06-01T05:55:44.849Z" }, + { url = "https://files.pythonhosted.org/packages/c0/95/c3366b5b5edf4c4adc90f2e29ca16e57965a8e56dc8d2ee89565ba1905bb/grpcio-1.81.0-cp313-cp313-win32.whl", hash = "sha256:c197e2ef75a442528072b29e9755da299110e8610e8bcbb59a6b4cf55384f005", size = 4182777, upload-time = "2026-06-01T05:55:47.459Z" }, + { url = "https://files.pythonhosted.org/packages/a9/a7/932f2f748511a32e641a2aba0d30dded3ed6e8bc330e0924e4d5d86853e6/grpcio-1.81.0-cp313-cp313-win_amd64.whl", hash = "sha256:194eddfacc84d80f50512e9fd4ee851d5f2499f18f299c95aa8fb4748f0537e0", size = 4928085, upload-time = "2026-06-01T05:55:50.158Z" }, ] [[package]] @@ -337,6 +400,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/82/54/acc6a6e684827b0f6bb4e2c27f3d7e25b71322c4078ef5b455c07c43260e/grpcio_reflection-1.62.3-py3-none-any.whl", hash = "sha256:a48ef37df81a3bada78261fc92ef382f061112f989d1312398b945cc69838b9c", size = 22232, upload-time = "2024-08-06T00:30:13.131Z" }, ] +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + +[[package]] +name = "lark" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/34/28fff3ab31ccff1fd4f6c7c7b0ceb2b6968d8ea4950663eadcb5720591a0/lark-1.3.1.tar.gz", hash = "sha256:b426a7a6d6d53189d318f2b6236ab5d6429eaf09259f1ca33eb716eed10d2905", size = 382732, upload-time = "2025-10-27T18:25:56.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl", hash = "sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12", size = 113151, upload-time = "2025-10-27T18:25:54.882Z" }, +] + [[package]] name = "modelplane" version = "0.0.0" @@ -350,7 +431,57 @@ dev = [ [package.metadata] [package.metadata.requires-dev] -dev = [{ name = "crossplane-function-sdk-python", specifier = ">=0.12.0" }] +dev = [{ name = "crossplane-function-sdk-python", specifier = ">=0.13.0" }] + +[[package]] +name = "pendulum" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/72/9a51afa0a822b09e286c4cb827ed7b00bc818dac7bd11a5f161e493a217d/pendulum-3.2.0.tar.gz", hash = "sha256:e80feda2d10fa3ff8b1526715f7d33dcb7e08494b3088f2c8a3ac92d4a4331ce", size = 86912, upload-time = "2026-01-30T11:22:24.093Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/27/a4be6ec12161b503dd036f8d7cc57f8626170ae31bb298038be9af0001ce/pendulum-3.2.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:5d775cc608c909ad415c8e789c84a9f120bb6a794c4215b2d8d910893cf0ec6a", size = 337923, upload-time = "2026-01-30T11:20:51.61Z" }, + { url = "https://files.pythonhosted.org/packages/59/e1/2a214e18355ec2a6ce3f683a97eecdb6050866ff3a6cf165d411450aeb1b/pendulum-3.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8de794a7f665aebc8c1ba4dd4b05ab8fe1a36ce9c0498366adf1d1edd79b2686", size = 327379, upload-time = "2026-01-30T11:20:53.085Z" }, + { url = "https://files.pythonhosted.org/packages/9d/01/7392e58ebc1d9e70b987dc8bb0c89710b47ac8125067efe7aa4c420b616f/pendulum-3.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bac7df7696e1c942e17c0556b3a7bcdd1d7aa5b24faee7620cb071e754a0622", size = 340115, upload-time = "2026-01-30T11:20:54.635Z" }, + { url = "https://files.pythonhosted.org/packages/ef/33/80de84c5ca1a3e4f7f3b75090c9b61b6dbb6d095e302ee592cebbaf0bbfb/pendulum-3.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:db0f6a8a04475d9cba26ce701e7d66d266fd97227f2f5f499270eba04be1c7e9", size = 373969, upload-time = "2026-01-30T11:20:56.209Z" }, + { url = "https://files.pythonhosted.org/packages/75/e4/f7b4c1818927ab394a2a0a9b7011f360a0a75839a22678833c5bc0a84183/pendulum-3.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c352c63c1ff05f2198409b28498d7158547a8be23e1fbd4aa2cf5402fb239b55", size = 379058, upload-time = "2026-01-30T11:20:57.618Z" }, + { url = "https://files.pythonhosted.org/packages/36/94/9947cf710620afcc68751683f2f8de88d902505e7c13c0349d7e9d362f97/pendulum-3.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de8c1ad1d1aa7d4ceae341528bab35a0f8c88a5aa63f2f5d84e16b517d1b32c2", size = 348403, upload-time = "2026-01-30T11:20:59.56Z" }, + { url = "https://files.pythonhosted.org/packages/6f/12/0e6ba0bb00fa57907af2a3fca8643bded5dba1e87072d50673776a0d6ed2/pendulum-3.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1ba955511c12fec2252038b0c866c25c0c30b720bf74d3023710f121e42b1498", size = 517457, upload-time = "2026-01-30T11:21:01.602Z" }, + { url = "https://files.pythonhosted.org/packages/c6/fe/dae5fbfe67bd41d943def0ad8f1e7f6988aa8e527255e433cd7c494f9ad5/pendulum-3.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:4115bf364a2ec6d5ddc476751ceaa4164a04f2c15589f0d29aa210ddb784b15d", size = 561103, upload-time = "2026-01-30T11:21:03.924Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a0/8f646160b98abfc19152505af19bd643a4279ec2bdbe0959f16b7025fc6b/pendulum-3.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:4151a903356413fdd9549de0997b708fb95a214ed97803ffb479ffd834088378", size = 260595, upload-time = "2026-01-30T11:21:05.495Z" }, + { url = "https://files.pythonhosted.org/packages/79/01/feead7af9ded7a13f2d798fb6573e70f469113eafcd8cc8f59671584ca3e/pendulum-3.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:acfdee9ddc56053cb7c8c075afbfde0857322d09e56a56195b9cd127fae87e4c", size = 255382, upload-time = "2026-01-30T11:21:06.847Z" }, + { url = "https://files.pythonhosted.org/packages/41/56/dd0ea9f97d25a0763cda09e2217563b45714786118d8c68b0b745395d6eb/pendulum-3.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bf0b489def51202a39a2a665dcc4162d5e46934a740fe4c4fe3068979610156c", size = 337830, upload-time = "2026-01-30T11:21:08.298Z" }, + { url = "https://files.pythonhosted.org/packages/cf/98/83d62899bf7226fc12396de4bc1fb2b5da27e451c7c60790043aaf8b4731/pendulum-3.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:937a529aa302efa18dcf25e53834964a87ffb2df8f80e3669ab7757a6126beaf", size = 327574, upload-time = "2026-01-30T11:21:09.715Z" }, + { url = "https://files.pythonhosted.org/packages/76/fa/ff2aa992b23f0543c709b1a3f3f9ed760ec71fd02c8bb01f93bf008b52e4/pendulum-3.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85c7689defc65c4dc29bf257f7cca55d210fabb455de9476e1748d2ab2ae80d7", size = 339891, upload-time = "2026-01-30T11:21:11.089Z" }, + { url = "https://files.pythonhosted.org/packages/c5/4e/25b4fa11d19503d50d7b52d7ef943c0f20fd54422aaeb9e38f588c815c50/pendulum-3.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d5e216e5a412563ea2ecf5de467dcf3d02717947fcdabe6811d5ee360726b02b", size = 373726, upload-time = "2026-01-30T11:21:12.493Z" }, + { url = "https://files.pythonhosted.org/packages/4f/30/0acad6396c4e74e5c689aa4f0b0c49e2ecdcfce368e7b5bf35ca1c0fc61a/pendulum-3.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a2af22eeec438fbaac72bb7fba783e0950a514fba980d9a32db394b51afccec", size = 379827, upload-time = "2026-01-30T11:21:14.08Z" }, + { url = "https://files.pythonhosted.org/packages/3a/f7/e6a2fdf2a23d59b4b48b8fa89e8d4bf2dd371aea2c6ba8fcecec20a4acb9/pendulum-3.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3159cceb54f5aa8b85b141c7f0ce3fac8bdd1ffdc7c79e67dca9133eac7c4d11", size = 348921, upload-time = "2026-01-30T11:21:15.816Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f2/c15fa7f9ad4e181aa469b6040b574988bd108ccdf4ae509ad224f9e4db44/pendulum-3.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c39ea5e9ffa20ea8bae986d00e0908bd537c8468b71d6b6503ab0b4c3d76e0ea", size = 517188, upload-time = "2026-01-30T11:21:17.835Z" }, + { url = "https://files.pythonhosted.org/packages/47/c7/5f80b12ee88ec26e930c3a5a602608a63c29cf60c81a0eb066d583772550/pendulum-3.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e5afc753e570cce1f44197676371f68953f7d4f022303d141bb09f804d5fe6d7", size = 561833, upload-time = "2026-01-30T11:21:19.232Z" }, + { url = "https://files.pythonhosted.org/packages/90/15/1ac481626cb63db751f6281e294661947c1f0321ebe5d1c532a3b51a8006/pendulum-3.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:fd55c12560816d9122ca2142d9e428f32c0c083bf77719320b1767539c7a3a3b", size = 258725, upload-time = "2026-01-30T11:21:20.558Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/50b0398d7d027eb70a3e1e336de7b6e599c6b74431cb7d3863287e1292bb/pendulum-3.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:faef52a7ed99729f0838353b956f3fabf6c550c062db247e9e2fc2b48fcb9457", size = 253089, upload-time = "2026-01-30T11:21:22.497Z" }, + { url = "https://files.pythonhosted.org/packages/27/8c/400c8b8dbd7524424f3d9902ded64741e82e5e321d1aabbd68ade89e71cf/pendulum-3.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:addb0512f919fe5b70c8ee534ee71c775630d3efe567ea5763d92acff857cfc3", size = 337820, upload-time = "2026-01-30T11:21:24.305Z" }, + { url = "https://files.pythonhosted.org/packages/59/38/7c16f26cc55d9206d71da294ce6857d0da381e26bc9e0c2a069424c2b173/pendulum-3.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3aaa50342dc174acebdc21089315012e63789353957b39ac83cac9f9fc8d1075", size = 327551, upload-time = "2026-01-30T11:21:25.747Z" }, + { url = "https://files.pythonhosted.org/packages/0b/cd/f36ec5d56d55104232380fdbf84ff53cc05607574af3cbdc8a43991ac8a7/pendulum-3.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:927e9c9ab52ff68e71b76dd410e5f1cd78f5ea6e7f0a9f5eb549aea16a4d5354", size = 339894, upload-time = "2026-01-30T11:21:27.229Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/b9a1e546519c3a92d5bc17787cea925e06a20def2ae344fa136d2fc40338/pendulum-3.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:249d18f5543c9f43aba3bd77b34864ec8cf6f64edbead405f442e23c94fce63d", size = 373766, upload-time = "2026-01-30T11:21:28.642Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a6/6471ab87ae2260594501f071586a765fc894817043b7d2d4b04e2eff4f31/pendulum-3.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c644cc15eec5fb02291f0f193195156780fd5a0affd7a349592403826d1a35e", size = 379837, upload-time = "2026-01-30T11:21:30.637Z" }, + { url = "https://files.pythonhosted.org/packages/0d/79/0ba0c14e862388f7b822626e6e989163c23bebe7f96de5ec4b207cbe7c3d/pendulum-3.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:063ab61af953bb56ad5bc8e131fd0431c915ed766d90ccecd7549c8090b51004", size = 348904, upload-time = "2026-01-30T11:21:32.436Z" }, + { url = "https://files.pythonhosted.org/packages/17/34/df922c7c0b12719589d4954bfa5bdca9e02bcde220f5c5c1838a87118960/pendulum-3.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:26a3ae26c9dd70a4256f1c2f51addc43641813574c0db6ce5664f9861cd93621", size = 517173, upload-time = "2026-01-30T11:21:34.428Z" }, + { url = "https://files.pythonhosted.org/packages/87/ec/3b9e061eeee97b72a47c1434ee03f6d85f0284d9285d92b12b0fff2d19ac/pendulum-3.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:2b10d91dc00f424444a42f47c69e6b3bfd79376f330179dc06bc342184b35f9a", size = 561744, upload-time = "2026-01-30T11:21:35.861Z" }, + { url = "https://files.pythonhosted.org/packages/fd/7e/f12fdb6070b7975c1fcfa5685dbe4ab73c788878a71f4d1d7e3c87979e37/pendulum-3.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:63070ff03e30a57b16c8e793ee27da8dac4123c1d6e0cf74c460ce9ee8a64aa4", size = 258746, upload-time = "2026-01-30T11:21:37.782Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b8/5abd872056357f069ae34a9b24a75ac58e79092d16201d779a8dd31386bb/pendulum-3.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:c8dde63e2796b62070a49ce813ce200aba9186130307f04ec78affcf6c2e8122", size = 253028, upload-time = "2026-01-30T11:21:39.381Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0d/d5ac8468a1b40f09a62d6e91654088de432367907579dd161c0fb1bdf222/pendulum-3.2.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:9585594d32faa71efa5a78f576f1ee4f79e9c5340d7c6f0cd6c5dfe725effaaa", size = 338760, upload-time = "2026-01-30T11:22:12.225Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e5/7fa8c8be6caac8e0be78fbe7668df571f44820ed779cb3736fab645fcba8/pendulum-3.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:26401e2de77c437e8f3b6160c08c6c5d45518d906f8f9b48fd7cb5aa0f4e2aff", size = 328333, upload-time = "2026-01-30T11:22:13.811Z" }, + { url = "https://files.pythonhosted.org/packages/ad/78/73a1031b7d1bf7986e8e655cea3f018164b3470aecfea25a4074e77dda73/pendulum-3.2.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:637e65af042f383a2764a886aa28ccc6f853bf7a142df18e41c720542934c13b", size = 340841, upload-time = "2026-01-30T11:22:15.278Z" }, + { url = "https://files.pythonhosted.org/packages/49/40/4e36e9074e92b0164c088b9ada3c02bfea386d83e24fa98b30fe9b6e61a8/pendulum-3.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d6e46c28f4d067233c4a4c42748f4ffa641d9289c09e0e81488beb6d4b3fab51", size = 348959, upload-time = "2026-01-30T11:22:16.718Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/8bf7fcb91b526e1efe17d047faa845709b88800fff915ff848ff26054293/pendulum-3.2.0-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:71d46bcc86269f97bfd8c5f1475d55e717696a0a010b1871023605ca94624031", size = 518102, upload-time = "2026-01-30T11:22:18.2Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b0/a36c468d2d0dec62ddea7c5e4177e93abb12f48ac90f09f24d0581c5189f/pendulum-3.2.0-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:5cd956d4176afc7bfe8a91bf3f771b46ff8d326f6c5bf778eb5010eb742ebba6", size = 561884, upload-time = "2026-01-30T11:22:19.671Z" }, + { url = "https://files.pythonhosted.org/packages/c5/4d/dad105261898907bf806cabca53d3878529a9fa2c0d5d7f95f2035246fc2/pendulum-3.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:39ef129d7b90aab49708645867abdd207b714ba7bff12dae549975b0aca09716", size = 261236, upload-time = "2026-01-30T11:22:21.059Z" }, + { url = "https://files.pythonhosted.org/packages/02/fb/d65db067a67df7252f18b0cb7420dda84078b9e8bfb375215469c14a50be/pendulum-3.2.0-py3-none-any.whl", hash = "sha256:f3a9c18a89b4d9ef39c5fa6a78722aaff8d5be2597c129a3b16b9f40a561acf3", size = 114111, upload-time = "2026-01-30T11:22:22.361Z" }, +] [[package]] name = "protobuf" @@ -454,6 +585,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -491,13 +634,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + [[package]] name = "structlog" -version = "25.5.0" +version = "26.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ef/52/9ba0f43b686e7f3ddfeaa78ac3af750292662284b3661e91ad5494f21dbc/structlog-25.5.0.tar.gz", hash = "sha256:098522a3bebed9153d4570c6d0288abf80a031dfdb2048d59a49e9dc2190fc98", size = 1460830, upload-time = "2025-10-27T08:28:23.028Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/89/b4a0bcfdf4f71a3dea31379f095929613d7e4528a0996bca6aa964cd0dca/structlog-26.1.0.tar.gz", hash = "sha256:f63a716cbd1b1291cf7661de7794b455acfa4c43c5bcf1630e6ad5ddc1adb3b7", size = 1459881, upload-time = "2026-06-06T07:33:39.348Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510, upload-time = "2025-10-27T08:28:21.535Z" }, + { url = "https://files.pythonhosted.org/packages/a9/18/489c97b834dfff9cf2fc2507cede4bcd4b11e67f84bc462acd1992496f86/structlog-26.1.0-py3-none-any.whl", hash = "sha256:e081a26d6c373e6d201eca24eede26d8ffab07f88f477822e679183428d3d91e", size = 73764, upload-time = "2026-06-06T07:33:38.046Z" }, ] [[package]] @@ -520,3 +672,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] + +[[package]] +name = "tzdata" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, +]