Imported from gpustack/gpustack-operator (
.agents/skills/gpustack-operator-e2e/SKILL.md). Install upstream withnpx skills add gpustack/gpustack-operator --skill gpustack-operator-e2e. Copyright stays with the author.
GPUStack Operator — local E2E verification
Deploy the operator to a Kubernetes cluster — the user's own by default, or one provisioned for the run and destroyed afterwards — and verify the scheduling chain end to end:
NFD labels nodes → DeviceManager detects accelerators → Worker profiles capacity
→ NodeFlavor/InstanceType reconcilers → ResourceFlavor
→ InstanceType (real CRD; .status = four-view EX/SH/SL/PT)
→ ClusterQueue (exactly one isolated CQ per pool — NO Cohort) → LocalQueue
⊢ node-devices AdmissionCheck gates per-card feasibility
- Chain detail → architecture.md (overview), then scheduling-chain.md for the reconcilers and admission.md for the five gates / four-view math these cases assert; the unified-pool refactor this suite tracks →
specs/2026-06-29-instancetype-unified-pool-refactor.md. - Accelerated cases run GPU-less by approximation: mock a fake accelerator NodeFeature + a per-card
Devicesledger. The derivation and the four-view/AdmissionCheck math are NOT mocked — that is the verification.
Orchestration
Run as a test-orchestration lead (main agent) coordinating read-only domain specialists (Agent tool). Read orchestration.md before Phase 0 — it owns roles, phases, parallelism/rendezvous rules, report layout, and the fix-and-retest loop. Below are the operator-e2e specifics.
Hard rules (full list in orchestration.md; operator-e2e specifics):
- Pin the run to a user-confirmed context — confirm the active context is the intended cluster. Never switch on your own judgement. When the confirmed cluster is not the active context, do not switch: take its isolated kubeconfig with
bash ../_e2e-lib/scripts/kube-context.sh <ctx>and prefix every command of the run with theKUBECONFIG=<path>it prints (details inorchestration.md). On the user's explicit say-so you may switch instead — then record the context to return to as an outstanding environment mutation and restore it in Phase 8. Adopting the context a cluster this run provisioned merged in itself needs no such record. - Prefer the user's own cluster; provisioning is a separate opt-in and a cluster this run provisioned must be destroyed in Phase 8 — see
cluster-provisioning.md. - Offer a context checkpoint at the trigger points (see Flow); compacting is the user's action, and the lead hands over a focus block that carries the teardown obligation.
- Build locally when the nodes share this machine's image store; otherwise build remote and push —
build-load.shkeepsPACKAGE_PUSH=falselocally, and switches to a builder host + registry push whenE2E_BUILDER_SSH/E2E_IMAGE_NAMESPACEare set inline (never written into a file). - Touch only what this run creates — the Helm release, injected labels/NodeFeatures, mocked
Devices, test workloads. Never touch the user's pre-existing resources. - Confirm every mutating step (
build-load.sh,deploy.sh, mutating cases,teardown.sh); read-only steps (preflight.sh,assert-core.sh, CASE 1) run unprompted. - Specialists are read-only — the lead is the sole writer.
Layout:
../_e2e-lib/scripts/—preflight.sh,build-load.sh <TAG>,deploy.sh <NS> <TAG>(installs only — it refuses an existing release; upgrade in place withhelm upgrade, orteardown.shfirst),assert-core.sh <NS>,teardown.sh <NS>(custom-resource drain, test artifacts, release uninstall and a CRD-drain verdict; the drain and the cleanup are both delegated to the chart'sfiles/drain.shandfiles/cleanup.sh), plus the cluster-lifecycle triocluster-auth.sh <modality>(read-only),provision.sh <modality>anddestroy.sh <modality>, andkube-context.sh <ctx>(read-only; targets a context that is not the current one).cases/case-N.sh <NS>— one scenario each; ends in aSTATUS | CHECK | OBJECTtable, exits non-zero on any FAIL.cases/_quota-lib.sh— sourced by the cases that short a pool by occupying it (50, 95):workload_chargereads what Kueue charged one replica's Workload, andpool_replica_boundturns it into an upper bound on the replicas the queue's quota holds, summed over every flavor. Not a case; never run it directly.cases/_rows-lib.sh—print_rows, the printer for that table. It splits a row on its first two|only, so an OBJECT that carries|prints whole; source it rather than writing another loop. Not a case; never run it directly.cases/_partition-lib.sh— sourced by the hardware-partition cases (24–32, 34) for node correlation, theMIG_NODE_SSHgate, profile/key discovery and pod plumbing. Not a case; never run it directly.cases/run-partition-block.sh <RAW_DIR> [NS] [CASES...]— runs that family in its required order, writing each case's raw log and exit code. Drive 24–32/34 with it rather than by hand: the ordering is a real constraint and the block outlives a context, so a step held only in conversation is one a compaction drops.cases/_rdma-lib.sh— sourced by the RDMA cases (80–85) for the precondition gate, the host-probe Pod and the ledger facts. Not a case; never run it directly.cases/run-rdma-block.sh [--report-only] <RAW_DIR> [NS] [CASES...]—--report-onlyprints, without touching the cluster, which of those readings this cluster can answer and what each unmet requirement is missing. A run prints the same matrix, then each case's exit code and whether its output carriesNOTHING WAS VERIFIED— which is how a skip is told from a pass, since both exit 0.cases/_topology-tas-lib.sh— sourced by the TAS cases (87) for the zone-shape measurement, the apply and name-scoped teardown of a case-prefixed Topology/ResourceFlavor/ClusterQueue/LocalQueue set, the Workload admission readers, and the Workload→owner-Pod→ModelDeployment join. Not a case; never run it directly.cases/_serving-lib.sh— sourced by the serving cases (90, 91, 94):serving_profile <vendor> <engine>is the one place a vendor or engine difference is written — where a transfer is counted, how the leg is pinned, which routers render it — so aligning a vendor is an edit there, not in each case. Not a case; never run it directly.references/—drain-recycle.md(per-case rationale + mock recipes),packaged-image-deploy.md(image-ref ↔ chart-values contract),rdma-host-shapes.md(which host shape answers which RDMA reading, and the one nothing answers); shared../_e2e-lib/references/{orchestration,troubleshooting}.md.
Cases (locked titles)
Each case is self-contained; its header (see Case header contract) states goal / environment (incl. auto-skip) / inputs (MOCKED vs real) / expected / cleanup. Needs = the hardware/tooling the case requires; the exact auto-skip conditions live in the case's own Environment: field, which is what the script actually prints.
| Case | Title | Run when these change (git diff --name-only origin/main...HEAD) |
Mutates | Needs |
|---|---|---|---|---|
| 1 | CPU-only scheduling chain materializes — zero Cohort, InstanceType Active | always (mandatory) | no | any |
| 2 | Running Instance admits, then drain stops it (not recreate) | pkg/worker/controllers/worker/instance.go, pkg/worker/webhooks/worker/instance.go, pkg/worker/kuberess/apps_kueue.go |
yes (confirm) | any |
| 3 | Managed-toggle scopes node onboarding | pkg/worker/controllers/worker/{node_flavor,instance_type}.go, pkg/nodefeature/helper.go |
yes (confirm) | any |
| 4 | AdmissionCheck holds exclusive over-admit | pkg/worker/controllers/worker/{node_devices_admission,node_devices,instance_type}.go, pkg/worker/kuberess/apps_kueue.go |
yes (confirm) | any |
| 5 | Pod webhook folds slice-by-memory-% into units | pkg/worker/webhooks/worker/pod.go, pkg/nodefeature/knowns.go |
yes (confirm) | any |
| 6 | Pooled four-view + watch freshness | pkg/worker/controllers/worker/instance_type.go, pkg/worker/webhooks/worker/instance_type.go, api/worker/v1alpha1/{instance_type,devices}.go |
yes (confirm) | any |
| 7 | Portless Instance reaches Ready, creates no Service | pkg/worker/controllers/worker/instance.go |
yes (confirm) | any |
| 8 | Real accelerator slicing runtime isolation | pkg/deviceplugin/**, pkg/devicemanager/**, pkg/worker/webhooks/worker/pod.go |
yes (confirm) | real GPU, logical slicing |
| 9 | Instance lifecycle survives an InstanceType unit-spec change | pkg/worker/webhooks/worker/instance.go, pkg/worker/webhooks/worker/instance_type.go |
yes (confirm) | any. The running-Instance half always SKIPs, because its state cannot be entered (both routes are refused, and the case asks rather than assumes); the stopped-Instance half moves to a sibling type with a smaller unit RAM and needs the general unit RAM to be at least 2Gi |
| 10 | Start re-validates a resized-while-stopped Instance (no create-check bypass) | pkg/worker/webhooks/worker/instance.go |
yes (confirm) | any |
| 11 | Per-card logical-slice accounting: slices pack, and no card is over-committed (SL view + per-card OnceMax) | pkg/deviceplugin/{server,helper}.go, pkg/worker/controllers/worker/{node_devices_admission,instance_type}.go |
yes (confirm) | real GPU, >=2 logically sliceable cards |
| 12 | Logically sliceable Instance webhook: slice-% sizes CPU/RAM, accelerator pinned to 1 | pkg/worker/webhooks/worker/instance.go, pkg/utils/quantityx/quantity.go |
yes (confirm) | real GPU, logically sliceable pool |
| 13 | SSH-enabled sliced Instance: slice visible over SSH + confined shell | pkg/worker/controllers/worker/instance.go, pack/ssh-server/rootfs/chroot.sh, pkg/deviceplugin/**, pkg/devicemanager/allocator/** |
yes (confirm) | real GPU, logical slicing + ssh |
| 14 | Multiple slices coexist on one physical card within budget, each reporting its own share | pkg/worker/controllers/worker/{node_devices_admission,instance_type}.go, pkg/deviceplugin/**, pkg/devicemanager/detector/slice.go, pkg/devicemanager/procattr/**, pkg/kubemetrics/** |
yes (confirm) | real GPU, logical slicing |
| 15 | Exclusive whole-card SSH Instance still works (regression) | pkg/worker/controllers/worker/instance.go, pack/ssh-server/rootfs/chroot.sh |
yes (confirm) | real GPU, logical slicing + ssh |
| 16 | InstanceTypeFlavor catalog + declarative queue ownership (recreate-on-delete, delete-then-wait teardown) | pkg/worker/controllers/worker/{instance_type,node_queue,node_flavor}.go, pkg/worker/extensionapis/worker/instance_type_flavor.go, pkg/worker/settings/value.go |
yes (confirm) | any |
| 17 | InstanceType declarative admission (require + freeze inputs; Default stamps schedule + entrance labels) | pkg/worker/webhooks/worker/instance_type.go, api/worker/v1alpha1/instance_type.go |
yes (confirm) | any |
| 18 | CPU-manufacturer awareness reshapes the catalog (finest RF + cpuDetail; collapse↔split by setting) | pkg/nodefeature/helper.go, pkg/worker/settings/value.go, pkg/worker/extensionapis/worker/instance_type_flavor.go, pkg/worker/webhooks/worker/instance_type.go, pkg/worker/controllers/worker/node_flavor.go |
yes (confirm) | any |
| 19 | Awareness on: accelerated type carries real GPU + folded CPU descriptors; a real GPU Instance runs on it | pkg/worker/controllers/worker/{node_flavor,instance_type}.go, pkg/worker/webhooks/worker/instance_type.go, pkg/nodefeature/helper.go |
yes (confirm) | real GPU |
| 20 | Sibling InstanceTypes on one pool stay status-consistent (Devices-watch re-enqueues all) | pkg/worker/controllers/worker/instance_type.go |
yes (confirm) | real GPU, logically sliceable pool |
| 21 | SSH Instance serves non-interactive SSH (exec channel) + interactive login unchanged | pack/ssh-server/rootfs/chroot.sh, pack/ssh-server/Dockerfile, pkg/worker/settings/value.go |
yes (confirm) | ssh client (ssh-keygen, sftp) |
| 22 | Cross-mode claims never co-locate on one physical card (exclusive/shared/sliced; free-card placement + held-when-full) | pkg/deviceplugin/{server,controller,helper}.go, pkg/devicemanager/allocator/**, pkg/worker/webhooks/worker/pod.go, pkg/worker/controllers/worker/node_devices_admission.go |
yes (confirm) | real accelerator, exclusive + shared (C/D also sliced) |
| 23 | NVIDIA MIG dynamic-allocation lifecycle (logical→enable→carve→exclusion→reuse→reclaim→disable) | pkg/devicemanager/allocator/nvidia/**, pkg/devicemanager/detector/nvidia/device.go, binding/nvml/**, pkg/deviceplugin/**, pkg/device/population.go, pkg/nodefeature/knowns.go, pkg/worker/controllers/worker/node_capacity.go, pkg/worker/webhooks/worker/pod.go |
yes (confirm) | MIG-capable NVIDIA card + node SSH |
| 24 | Mixed node: a partition lands on a partitioned card, a logical slice on a whole one (zero UnexpectedAdmissionError) |
pkg/deviceplugin/{server,helper,controller}.go, pkg/device/{population,physical_placement}.go, pkg/devicemanager/allocator/nvidia/**, pkg/nodefeature/knowns.go |
yes (confirm) | partition-capable NVIDIA node, >=2 cards + node SSH |
| 25 | Per-profile capacity is derived from the live ledger, not from a static ceiling (+ node-status write volume) | pkg/worker/controllers/worker/node_capacity.go, pkg/device/physical_placement.go, pkg/nodefeature/knowns.go |
yes (confirm) | partition-capable NVIDIA card + node SSH |
| 26 | Partition token health is a node-level count: allocated + remaining | pkg/deviceplugin/{server,controller}.go, pkg/device/physical_placement.go |
yes (confirm) | partition-capable NVIDIA card + node SSH |
| 27 | A partitioned card is never judged feasible for an exclusive or shared claim | pkg/deviceplugin/helper.go, pkg/device/population.go, pkg/worker/controllers/worker/{instance_type,node_devices_admission}.go |
yes (confirm) | partition-capable NVIDIA node, >=2 cards none partitioned at start + node SSH |
| 28 | The SSH sidecar of a partition-backed workload is confined to that same partition | pkg/deviceplugin/server.go, pkg/devicemanager/allocator/nvidia/**, pkg/worker/controllers/worker/instance.go, pack/ssh-server/** |
yes (confirm) | partition-capable NVIDIA card + node SSH |
| 29 | Two concurrent requests for different profiles each get their own instance | pkg/deviceplugin/{controller,server}.go, pkg/device/physical_placement.go |
yes (confirm) | partition-capable NVIDIA card + node SSH |
| 30 | A terminated init container still charges the card its instance occupies | pkg/deviceplugin/controller.go, pkg/worker/controllers/worker/node_capacity.go, pkg/worker/webhooks/worker/pod.go |
yes (confirm) | partition-capable NVIDIA card + node SSH |
| 31 | A same-profile replacement scheduled inside the reclaim window (observation) | pkg/deviceplugin/reclaim.go, pkg/devicemanager/allocator/nvidia/mig.go, pkg/worker/controllers/worker/node_capacity.go |
yes (confirm) | partition-capable NVIDIA card + node SSH |
| 32 | An instance carved outside GPUStack: placement sees it, the node keys never do (observation) | pkg/device/physical_placement.go, pkg/devicemanager/allocator/nvidia/mig.go, pkg/worker/controllers/worker/node_capacity.go |
yes (confirm) | partition-capable NVIDIA card, exactly one partitioned + node SSH (MIG instance mgmt) |
| 34 | single-numa-node topology with partition capacity only on the far socket (observation) |
pkg/deviceplugin/server.go (the NUMA topology reported per family) |
yes (confirm) | dual-socket node, single-numa-node policy, partition-capable card + node SSH |
| 35 | Ascend logical-slice placement: claims pack, spill only on a misfit, and never cross into an exclusive card | pkg/deviceplugin/{server,helper}.go, pkg/devicemanager/allocator/ascend/**, pkg/worker/webhooks/worker/pod.go |
yes (confirm) | real Ascend, >=3 logically sliceable cards (>=1 free) + CANN-family image |
| 36 | Node-pinned Instance with additional volumes, and the host-access gates | pkg/worker/controllers/worker/instance.go, pkg/worker/webhooks/worker/instance.go, pkg/worker/settings/value.go, api/worker/v1alpha1/instance.go |
yes (confirm) | any + default StorageClass |
| 37 | Instance metrics subresource serves the current instance-scoped utilization, and answers a stopped Instance | pkg/worker/extensionapis/worker/**, pkg/kubemetrics/**, pkg/devicemanager/**, pkg/utils/datax/snapshot.go, api/worker/v1/instance.metrics.go |
yes (confirm) | any (chain materialized) |
| 38 | AMD accelerator claims over both carriers: exclusive whole cards, logical slices, and the Instance metrics array | pkg/devicemanager/allocator/amd/**, pkg/devicemanager/detector/amd/device.go, binding/amdsmi/**, csrc/amd/rocm-slicing-shim/**, pkg/worker/extensionapis/worker/instance.metrics.go |
yes (confirm) | real AMD, logical slicing |
| 39 | T-Head PPU: a pinned claim lands exactly where it was told, and a logical slice is capped inside the container | pkg/devicemanager/allocator/thead/**, pkg/devicemanager/detector/thead/device.go, binding/hgml/**, pkg/deviceplugin/{controller,server}.go, csrc/thead/ppu-slicing-shim/** |
yes (confirm) | real T-Head, >=2 accelerators idle + node host context (else PPU_NODE_SSH) |
| 40 | The device manager exports this node's Instances as Prometheus gauges, from exactly one target | pkg/devicemanager/exporter/**, pkg/devicemanager/detector/snapshot.go, pkg/kubemetrics/**, pkg/manager/metrics.go |
yes (confirm) | any node running a device manager |
| 41 | The slice pass reads only the carved cards: a whole card on the same node is never queried | pkg/devicemanager/detector/slice.go, pkg/devicemanager/detector/snapshot.go, pkg/devicemanager/snapshot.go |
yes (confirm) | real GPU, >=2 logically sliceable cards on one node |
| 42 | Hygon DCU partitions: what a grant carries, what one card holds at once, and what is refused | pkg/devicemanager/allocator/hygon/**, pkg/deviceplugin/**, pkg/device/physical_placement.go |
yes (confirm) | REAL Hygon DCU hardware on one node ALREADY in Multi-Instance mode + SSH to that node (MIG_NODE_SSH); auto-skips otherwise |
| 43 | Two namespaces share one KV cache pool: proportional quota, and both backend preconditions fail loudly | pkg/worker/controllers/worker/kv_cache_pool.go, pkg/worker/webhooks/worker/kv_cache_pool{,_binding}.go, pkg/worker/kvcache/**, api/worker/v1alpha1/kv_cache_pool{,_binding}.go |
yes (confirm) | any (no GPU, no RDMA) + a registry the cluster can pull the Mooncake image from |
| 44 | What an effective quota enforces: a refusal when nothing can be evicted, an eviction of the domain's own objects when something can | pkg/worker/kvcache/mooncake/tenant_metrics.go, pkg/worker/controllers/worker/kv_cache_pool.go (the Binding pass) |
yes (confirm) | CASE 43 passing + a registry the cluster can pull the Mooncake image from |
| 45 | The ModelDeployment admission surface: every refusal fires, and each one from the layer that owns it (schema / webhook / controller) | pkg/worker/webhooks/worker/model_deployment.go, pkg/worker/controllers/worker/model_deployment{,_binding}.go, api/worker/v1alpha1/model_deployment.go |
yes (confirm) | any (no GPU), and deliberately no KVCachePoolBinding — the case names one that does not exist. Optionally E2E_MD_INSTANCE_TYPE to pick the InstanceType |
| 47 | Two ModelDeployments on one Binding are rendered one reuse domain and a third on another Binding a different one, and the rendered value is what the store partitions on. Uses sglang because its tenant is one environment variable on the Pod spec, while the vLLM family carries the same domain as tenant_id inside a projected client config (case 55 reads both carriers) |
pkg/worker/controllers/worker/model_deployment_connector.go, pkg/worker/controllers/worker/model_deployment_binding.go, pkg/worker/kvcache/inject/** |
yes (confirm) | any (no GPU, no engine image) + a registry the cluster can pull the Mooncake image from; the replicas are never expected to start, every asserted value is on the Pod spec |
| 48 | The failure side: a Binding whose pool cannot be reached renders no connector AT ALL — measured against a control on a Ready Binding — and a rendered connector is never reported as an attached cache. Uses vllm deliberately: its connector travels on four separate carriers, so "no connector" is four independent absences and a PARTIAL render is visible. CacheAttached != True is true of every deployment on a cluster with no engine image, so no row asserts it alone |
pkg/worker/controllers/worker/model_deployment_binding.go (resolveModelDeploymentConnection), pkg/worker/controllers/worker/model_deployment_cache_attached.go, pkg/worker/kvcache/inject/vllm.go |
yes (confirm) | any (no GPU, no engine image) + a registry the cluster can pull the Mooncake image from + an InstanceType (E2E_MD_INSTANCE_TYPE to pick one); the replicas are never expected to start, and one row SKIPs — which shape the engine takes when the connector cannot initialize needs an image that serves |
| 49 | Every replica of a multi-role ModelDeployment becomes its own Kueue Workload, and deleting the deployment completes | pkg/worker/controllers/worker/model_deployment{,_pod_group}.go, api/worker/v1alpha1/model_deployment.go |
yes (confirm) | any (no GPU, no engine image) + an InstanceType and, in <NS>, the pool's entrance LocalQueue. The replicas are placeholders that never serve, so the two serving rows SKIP, and a third SKIP records what the replica whose Workload was deleted did next — a reading this suite has not turned into an expectation. Optionally E2E_MD_INSTANCE_TYPE / E2E_MD_IMAGE |
| 50 | A short pool starves a P/D deployment WHOLE: the role that would have fit alone waits too | pkg/worker/controllers/worker/model_deployment_pod_group.go, pkg/worker/controllers/worker/model_deployment.go |
yes (confirm) | as CASE 49, on a pool of any number of flavors whose queue is in no cohort. The shortage is made by OCCUPYING the quota, never by editing the ClusterQueue: a probe measures how many replicas Kueue reserves across every flavor, the filler leaves one, and the case SKIPs, printing the numbers, when the probe would exceed E2E_C50_MAX_REPLICAS (default 128) or the subject does not show exactly one role holding quota |
| 51 | Every P/D refusal fires, from the layer that owns it, and a replicas change replaces nobody | pkg/worker/webhooks/worker/model_deployment.go, pkg/worker/controllers/worker/model_deployment{,_pod_group}.go, api/worker/v1alpha1/model_deployment.go |
yes (confirm) | as CASE 49; the two-instanceType row SKIPs unless a second InstanceType exists that a deployment can name (not inactive, not terminating, and holding a whole card if accelerated) |
| 52 | The NIC/RDMA inventory is published, is stable across passes, and the rdma.* labels agree with it in both directions. A detector restart is driven but its outcome is not claimed — an unchanged record is also what a replacement that never reported produces, so that check SKIPs |
pkg/devicemanager/detector/{network,link,detector}.go, pkg/devicemanager/detector/network_linux.go, pkg/nodefeature/rdma.go, pkg/device/topology_distance.go, api/worker/v1alpha1/devices.go, binding/helper*.go |
yes (confirm) | any node running a device manager; the RDMA-positive checks skip individually without RDMA hardware |
| 53 | The headline: a plain Deployment reads and writes a KV cache pool with one label | pkg/worker/webhooks/worker/pod_kv_cache*.go, pkg/worker/kvcache/inject/** |
yes (confirm) | any (no GPU, no RDMA) + a registry the cluster can pull the Mooncake image from |
| 54 | No workload lock-in: an LWS Pod and a bare Pod get the identical injection | pkg/worker/webhooks/worker/pod_kv_cache*.go, pkg/worker/kvcache/inject/** |
yes (confirm) | as CASE 53; the LWS half needs the leaderworkerset.x-k8s.io CRD, and self-skips without it |
| 55 | The stamp that replaces isolation: a declared domain is injected and the ACTION is recorded, per engine | pkg/worker/kvcache/inject/engine.go, pkg/worker/webhooks/worker/pod_kv_cache_resolve.go, pkg/worker/webhooks/worker/pod_kv_cache_inject.go |
yes (confirm) | as CASE 53 |
| 56 | A Pod that did not opt in comes back byte-identical | pkg/worker/webhooks/worker/pod_kv_cache.go, pkg/worker/webhooks/worker/zz_generated.webhooks.go |
yes (confirm) | as CASE 53 |
| 57 | The refusals on a live API server, each naming what its fix depends on | pkg/worker/webhooks/worker/pod_kv_cache*.go, pkg/worker/kvcache/inject/**, pkg/worker/webhooks/worker/kv_cache_backend.go |
yes (confirm) | as CASE 53 |
| 58 | The INSTALLED selector is the inject label, not the queue-name label | pkg/worker/webhooks/worker/pod_kv_cache.go, pkg/worker/webhooks/setup.go, pkg/worker/webhooks/worker/zz_generated.webhooks.go |
no | any |
| 59 | Each rendered artifact is one its own engine accepts: the vLLM file and the SGLang environment | pkg/worker/kvcache/inject/client_config.go, pkg/worker/kvcache/inject/vllm.go, pkg/worker/kvcache/inject/sglang.go |
yes (confirm) | as CASE 53, plus E2E_VLLM_IMAGE / E2E_SGLANG_IMAGE; each half self-skips without its image |
| 60 | The connector name we render is one that engine's own factory can resolve, for both vLLM-family engines. Two halves, and only one is still open: registry membership is ANSWERED off-cluster — one docker run per image lists the whole registry, which a cluster run does not improve on — while whether either engine RUNS with what we inject is NOT EXERCISED and needs the accelerator ⇒ the case header says what does not count as filling it |
pkg/worker/kvcache/inject/vllm.go (vllmConnectorFor), pkg/worker/kvcache/inject/engine.go |
yes (confirm) | as CASE 53, plus E2E_VLLM_IMAGE / E2E_VLLM_ASCEND_IMAGE; each engine row self-skips without its image, and the vLLM-Ascend row skips whatever the image, because that runtime needs an ascend-transport pool this fixture does not build |
| 61 | A Service deleted out of band comes back, and the watch is what brought it | pkg/worker/controllers/worker/model_deployment.go (the Owns(&core.Service{}) clause), pkg/worker/controllers/worker/model_deployment_service.go |
yes (confirm) | any (no GPU, no engine image, no KV cache backend) + an InstanceType; the deployment names a KVCachePoolBinding that deliberately does not exist and its replica never serves. Optionally E2E_MD_INSTANCE_TYPE / E2E_MD_IMAGE / E2E_SVC_RECREATE_BOUND. The attribution row SKIPs unless every other object the controller watches is unchanged across the window and a replica was observed at all, which leaves the uid row passing unattributed |
| 62 | A replicated KV cache leader elects exactly one of three through a Lease, the backend's ElectionObserved condition reads True/Electing, the two rendered accounts are sufficient and no more, and an induced graceful failover moves the Lease within a 120s bound without touching a member | pkg/worker/kvcache/mooncake/leader_workload.go, pkg/worker/kvcache/mooncake/ha_rbac.go, pkg/worker/kvcache/mooncake/leader_flags.go, pkg/worker/controllers/worker/kv_cache_backend_handover.go |
yes (confirm) | any (no GPU, no RDMA; members are DRAM over TCP, one node is enough) + a store image built with the Kubernetes Lease leadership backend (E2E_MOONCAKE_IMAGE); with a published upstream image every replica campaigns unanswered and the "exactly one ready" wait times out — that timeout is the wrong-image signature, not a flake |
| 63 | How a member finds the leader under high availability: reading the Lease (k8s://) versus keeping the plain Service address — measured over several failover rounds (E2E_C63_ROUNDS, default 3) from deletion to the first put that completes within the put bound after the path became unreachable, every store call bounded, each interval split into old-Pod-gone / Lease-moved / endpoint-ready |
pkg/worker/kvcache/mooncake/member_workload.go (the MOONCAKE_MASTER render), pkg/worker/kvcache/mooncake/leader_workload.go (the readiness gate that keeps standbys out of the Service) |
yes (confirm) | as CASE 62 (same image need); two probe Pods, one bound to the member Role for get leases, one with no API access at all |
| 64 | The rude half of the same failover: a hard-killed leader (--force --grace-period=0, holder never releases the Lease) still yields the Lease within the same 120s bound, and the members come through untouched |
pkg/worker/kvcache/mooncake/leader_workload.go (the readiness gate), the store's k8s Lease campaign/expiry path |
yes (confirm) | as CASE 62 (same image need); complements the graceful-delete failover, which is the friendly path |
| 65 | A member group's local disk tier takes real bytes on the tier's one write path — eviction-time, with writes pushed past aggregate memory so eviction is forced: a key the leader reports as holding a disk replica but no memory one reads back byte-identical, master_allocated_file_size_bytes (not the declared status.capacity) reads above zero, and files exist in the host directory |
pkg/worker/kvcache/mooncake/member_workload.go (the localDisks env render), pkg/worker/kvcache/mooncake/leader_flags.go (the offload flags), api/worker/v1alpha1 (members[].localDisks) |
yes (confirm) | as CASE 62 (same image need) + E2E_LOCALDISK_HOST_PATH pointing at a prepared writable directory on EVERY Ready node (e.g. an i7ie's NVMe at /mnt/kvcache-localdisk), else AUTO-SKIP. NO LONGER a known-failure detector: the metric and host-directory checks were expected to FAIL while the store ran on its own 256 MB bucket default, which the old write set never closed; with the bucket limit rendered they pass, so a FAIL is now a regression to act on |
| 66 | A stored pre-identity member listing is omitted during a Kubernetes 1.23 CRD/controller upgrade while the leader listing fails, then resumes as identified rows | pkg/worker/controllers/worker/{kv_cache_backend,kv_cache_pool}.go, api/worker/v1alpha1/kvcache_backend.go |
yes (confirm) | Kubernetes 1.23, helm and jq; old/new CRD manifests plus old/new operator images that every node can use (E2E_CASE66_*). The case AUTO-SKIPS if those historical upgrade assets are absent; it does not build or load them |
| 67 | A drain between store members on two different nodes moves the bytes its report claims (read back at the target with the source deleted), and a disk-resident key is neither moved nor counted — the counter gap and the 404s are KNOWN-FAILURE DETECTORs that must go red the day the drain reaches the tier | api/worker/v1alpha1/kv_cache_backend.go (the scale-in drain premise), the Mooncake store image pin |
yes (confirm) | >=2 Ready schedulable nodes (no GPU, no RDMA; members are DRAM over TCP) + a registry the nodes can pull the Mooncake image from. Creates NO operator objects: the store runs as three node-pinned Pods and the drain is driven against the master's own HTTP API |
| 68 | A deployment whose roles sit on TWO InstanceTypes is admitted as a set or not at all, parks when the set never assembles, and scales one role without touching anything already running | pkg/worker/controllers/worker/model_deployment{,_joint_admission,_pod_group,_rollout}.go, pkg/worker/kuberess/apps_kueue_admission_check.go |
yes (confirm) | any (no GPU; the infeasible group comes from an Inactive InstanceType the case creates on the working type's CPU pool, not from a shortage) + an InstanceType and the pool's entrance LocalQueue in <NS>. Optionally E2E_MD_BINDING |
| 69 | A live ModelDeployment refuses an edit to a field that says WHICH deployment it is, and accepts one that says how it is being run | pkg/worker/webhooks/worker/model_deployment.go, api/worker/v1alpha1/model_deployment.go |
yes (confirm) | any cluster with the operator deployed; no accelerator and no second InstanceType, since nothing here has to be schedulable |
| 70 | A routed P/D deployment owns and garbage-collects all six router objects, converges both spec.router transitions, remains Starting without an accelerator, and reports every cluster-observable KVEventsPublishing status/reason pair |
pkg/worker/controllers/worker/model_deployment_router.go, model_deployment_kv_events.go, model_deployment_status.go |
yes (confirm) | Single Ready node with no accelerator; CASE 1 has materialized one usable general InstanceType, and the cluster can pull the Mooncake fixture image so a real Ready Binding can make Publishing reachable. The router image is deliberately unpullable because readiness is isolated in CASE 71 |
| 71 | A Ready router becomes status.endpoint; inability to pull the upstream router image is a stated SKIP rather than loss of CASE 70's lifecycle coverage |
pkg/worker/controllers/worker/model_deployment_status.go, the default llm-d-router image contract |
yes (confirm) | As CASE 70 plus pull access to the default llm-d-router and Envoy images; AUTO-SKIP only for an image-pull reason |
| 72 | A pool's bindings oversubscribing its total is a report, not a refusal: QuotaWithinTotal True while the ceilings fit, False with the sum and the total named once they do not, and neither the pool's phase nor any binding's own status degrades |
pkg/worker/controllers/worker/kv_cache_pool.go (the QuotaWithinTotal pass), api/worker/v1alpha1/kv_cache_pool{,_binding}.go (the quota pair) |
yes (confirm) | Any cluster with a materialized scheduling chain (CASE 1), no GPU and no RDMA; a registry the cluster can pull the Mooncake image from (E2E_MOONCAKE_IMAGE to override). No workload runs — the verdict reads declarations, and so does the case |
| 73 | An engine under KV turnover writes the shared store, and the other replica's replay of the same prefixes is the reuse the chain exists for — the write half is a guard, the read half a KNOWN-FAILURE DETECTOR pair (case-67 polarity) that FAILS the day cross-replica reuse starts working, and must then be inverted into positive guards | pkg/worker/controllers/worker/model_deployment_connector.go, pkg/worker/controllers/worker/model_deployment_binding.go, pkg/worker/kvcache/inject/**, the engine image pin |
yes (confirm) | A real accelerator pool with at least TWO free exclusive cards, model weights hostPath-staged on the accelerator nodes (E2E_VB_WEIGHTS, default /mnt/kvcache-weights), E2E_VB_INSTANCE_TYPE naming the accelerated InstanceType (exit 2 without it), and a registry the cluster can pull the Mooncake image from — a CUDA-only tag crashes members on CPU-only nodes, so the image must be CPU-capable. Where no two cards are free, E2E_VB_EXISTING_BACKEND + E2E_VB_EXISTING_DOMAIN + E2E_VB_EXISTING_SERVICES together run every verdict row against an existing engine pair and create nothing. The first case in the suite to run real vLLM engines; every verdict rides on counters (master_key_count, vllm:external_prefix_cache_hits_total, mem_cache_hit_nums_), never on TTFT |
| 74 | leader.highAvailability.snapshot is refused at admission under one replica and under three, and an update adding it to a running backend is refused too — each refusal on the field itself with its reason (a restore can serve another key's bytes); the same manifests without the field, and an image-only update, are accepted as the positive baseline |
pkg/worker/webhooks/worker/kv_cache_backend.go |
yes (confirm) | any (no GPU, no RDMA, no storage class); the creates and the updates are server-side dry runs, and the one backend the case persists selects no node, so it renders a leader and no member Pod |
| 75 | An image bump on a replicated leader emits the KVCacheLeaderHandover event — read in namespace default, where a cluster-scoped backend's events land — with its cumulative count equal to the lease's leaseTransitions, every leader pod a replacement on the new image, exactly one ready, and the backend settling Ready with every health condition True (PoolWrites reports write activity, not health, and reads Unknown on this idle backend); the only mutation is the spec patch, no pod is deleted by hand |
pkg/worker/controllers/worker/kv_cache_backend_handover.go |
yes (confirm) | any (no GPU, no RDMA) + a registry the cluster can pull BOTH pinned Mooncake tags from (E2E_MOONCAKE_IMAGE start / E2E_MOONCAKE_ROLLOUT_IMAGE target; the pair must each parse this operator's argv, carry the lease backend, and run on CPU-only nodes) |
| 76 | RolloutComplete stays truthful through a second mid-update (every sample is Unknown/UpdateNotObserved, False/Progressing or True/Complete, never a deadline-ish stall), the update converges with the election gate intact, and the member-re-registration dip clears within its window; the case never gates on kubectl rollout status, which times out on every multi-replica leader rollout by construction |
pkg/worker/controllers/worker/kv_cache_backend_rollout.go |
yes (confirm) | as CASE 75 (the same image pair and clauses) |
| 77 | The multi-tenant ledger gate: an unregistered tenant's put is refused -1701 while the client itself stays healthy, and a Pool+Binding whose domain.name is the tenant id admits the identical put; the tenant rides the keyword tenant_id= (the next positional slot is a TransferEngine pointer and raises), and the teardown drains the domain because a held domain blocks pool deletion open-ended |
pkg/worker/controllers/worker/kv_cache_pool.go (the domain registration pass), pkg/worker/kvcache/mooncake/** (the master argv and lease render) |
yes (confirm) | any (no GPU, no RDMA) + a registry the cluster can pull the Mooncake image from (E2E_MOONCAKE_IMAGE, CPU-capable, carrying the python client); the backend must be a replicated HA leader — the k8s:// master address and the probe's member Role exist only above one replica |
| 78 | Scaling a role moves its queue's admitted quota by exactly one replica in each direction, and touches no replica it did not add or remove | pkg/worker/controllers/worker/model_deployment{,_pod_group,_rollout}.go, api/worker/v1alpha1/model_deployment.go |
yes (confirm) | any (no GPU) + an InstanceType, the pool's entrance LocalQueue in <NS>, and room in the pool for three replicas of one role. Optionally E2E_MD_INSTANCE_TYPE / E2E_MD_IMAGE / E2E_MD_SETTLE |
| 79 | An instance of several Pods is admitted as ONE Kueue group with one PodSet of size, each member addressable by a derived name behind a headless Service of its own, and the role's Service fronts only the leader; adding an instance leaves the first one's members untouched |
pkg/worker/controllers/worker/model_deployment{,_render,_pod_group,_service}.go, api/worker/v1alpha1/model_deployment.go (size) |
yes (confirm) | any (no GPU) + an InstanceType, the pool's entrance LocalQueue in <NS>, and room in the pool for FOUR Pods of it -- two instances of two. The image must carry a shell and nslookup or the two DNS rows SKIP, which is why it defaults to busybox rather than pause. Optionally E2E_MD_INSTANCE_TYPE / E2E_MD_IMAGE / E2E_MD_SETTLE |
| 80 | The node advertises exactly the RDMA endpoints its inventory says it has, sized per mode | pkg/deviceplugin/rdma_{server,devices,endpoint}.go, pkg/nodefeature/rdma.go, pkg/devicemanager/detector/network*.go |
yes (confirm) | a node running a device manager with at least one RDMA endpoint. The correspondence and SR-IOV checks additionally need a host-probe Pod mounting /sys read-only, which a restricted PodSecurity namespace refuses — there those checks skip and the count checks still run. The SR-IOV checks also need virtual functions configured and the unhealthy-token check a failed link already present; each skips individually and the case never induces either |
| 81 | A granted RDMA endpoint opens inside an ordinary container, and an ungranted one does not | pkg/deviceplugin/rdma_allocate.go, pkg/deviceplugin/rdma_endpoint.go |
yes (confirm) | as CASE 80 plus a WHOLE-FUNCTION endpoint (the shared key is served from those). The verbs half needs E2E_RDMA_IMAGE shipping ibv_devinfo — the image is probed, not trusted, and a plain base image makes that half skip naming the packages; the open() halves need only a shell, because exec <> is an open |
| 82 | An accelerator and an RDMA endpoint in one container land on one NUMA node, or the container is refused | pkg/deviceplugin/rdma_endpoint.go (the NUMA hint), pkg/deviceplugin/server.go (the accelerator hint) |
yes (confirm) | a node whose accelerators straddle its RDMA endpoints' NUMA nodes AND whose kubelet reports single-numa-node or restricted from its own configz, plus an accelerated InstanceType with an entrance LocalQueue. All three or the case answers nothing. The partition observation additionally needs a card already in a partitioning mode and is a RECORDING that can never pass — no machine yet satisfies its three properties at once |
| 83 | Preflight reports the TopologyManager policy the node's kubelet is actually running | pkg/devicemanager/preflight/topology*.go, pkg/devicemanager/preflight/hostexec.go |
yes (confirm) | any node running a device manager (the image is taken from that DaemonSet) plus a readable nodes/proxy/configz. No RDMA hardware needed — but the policy must have been SET BY SOMEBODY: the endpoint reports the effective configuration including defaults, so on a node nobody configured it says none while preflight correctly declines to publish a default nobody wrote, and that check SKIPS rather than failing. A node on single-numa-node or restricted is the one that answers it |
| 84 | On classic InfiniBand, the injected set carries a real transport, not just verbs | pkg/deviceplugin/rdma_allocate.go (the injected device set) |
yes (confirm) | an endpoint whose port link_layer reads InfiniBand — a RoCE or EFA host is a SKIP and not a pass, which is the whole gate — plus E2E_RDMA_PERFTEST_IMAGE shipping ib_write_bw, probed in the running container rather than trusted. It deliberately uses perftest and not a collective library: that library falls back to TCP and SUCCEEDS on an image lacking ibverbs-providers, so job success is not a transport reading |
| 85 | A host-fabric member is granted its device by protocol, and mounts no device tree | pkg/worker/kvcache/mooncake/member_workload.go (applyMemberFabric, fabricDeviceResource) |
yes (confirm) | as CASE 80 plus a WHOLE-FUNCTION endpoint. It reads the RENDERED DaemonSet only: no store image is pulled and no member Pod has to reach Running, so it needs no working fabric and no arch-matched store build. The other end of the same chain is CASE 81 — a granted endpoint opening inside a container — and neither covers the chain alone: this one proves the operator asks, that one proves the ask is what admits the open |
| 86 | A prefill/decode pair's transfer document follows the tensor parallelism its roles declare in extraArgs, and the pair actually runs at that width | pkg/worker/controllers/worker/model_deployment_parallelism.go, pkg/worker/controllers/worker/model_deployment_connector.go, pkg/worker/kvcache/inject/{types,vllm}.go, pkg/worker/webhooks/worker/model_deployment.go |
yes (confirm) | real Ascend, enough free exclusive cards for two per role (E2E_RP_TP×2, so four at the default) + node SSH (E2E_RP_NODE_SSH) + the vllm-ascend runner and llm-d-router images pullable + weights at a hostPath; AUTO-SKIPS on capacity |
| 87 | Topology sources drive TAS placement and joint ModelDeployment admission | pkg/worker/controllers/worker/topology_source*.go, pkg/worker/controllers/worker/node_{topology,flavor,queue}.go, pkg/worker/controllers/worker/model_deployment{,_joint_admission,_pod_group}.go, testing/infra/clusters/eks/** |
yes (confirm) | exactly four schedulable CPU Nodes split two per zone (a Node that is tainted NoSchedule/NoExecute or carries no zone label, such as a kind control-plane, is left out of the count); the operator chart with Kueue TAS enabled; an InstanceType and its entrance LocalQueue in <NS>; pull access for the case's HTTPS webhook image; no accelerator or engine image required |
| 88 | A topology-aware ModelDeployment passes per-card admission on the accelerator Node Kueue assigned | pkg/worker/controllers/worker/node_devices.go, node_devices_admission.go, node_{topology,flavor,queue}.go |
yes (confirm) | one Ready schedulable Node with a free real accelerator, an Active accelerated InstanceType and its entrance LocalQueue in <NS>; Kueue TAS enabled; pull access to the pause image |
| 89 | A ModelDeployment role's rendered Pod requests the selected fabric device | api/worker/v1alpha1/model_deployment.go, pkg/worker/controllers/worker/model_deployment_render.go, pkg/worker/webhooks/worker/model_deployment.go |
no | an already reconciled ModelDeployment fixture for each requested count and protocol; inspect Pods even when Pending |
| 90 | A ModelDeployment metrics snapshot reflects served traffic and Pod endpoints | pkg/worker/extensionapis/worker/model_deployment.metrics.go, pkg/worker/controllers/worker/model_deployment_{render,router}.go, pkg/worker/kvcache/router/** |
yes (confirm) | a serving ModelDeployment under representative traffic, and a pullable curl probe image; with a router, the rows for its contract and its endpoints read cases/_serving-lib.sh; the probe Pod is removed by a trap |
| 91 | A routed prefill/decode pair moves real KV bytes over its direct leg, and writes the store when it has one | pkg/worker/kvcache/inject/{vllm,sglang,types}.go, pkg/worker/controllers/worker/model_deployment_render.go, cases/_serving-lib.sh |
yes (confirm) | a Ready vLLM or SGLang P/D ModelDeployment with a router on real accelerators, one Pod per half on two nodes, with or without a bound store; on NVIDIA a tcp leg, vLLM 0.29.0 (an older CUDA image embeds a client without the pin and exits 2), and no role setting MC_FORCE_TCP or an SGLang cache switch; a store on tcp and on the client's minor line, and for SGLang a prefill node with more than 10 GiB of host memory to spare beyond its host pool; plus E2E_PD_PROBE_IMAGE and E2E_PD_MODEL; the probe Pod is removed by a trap |
| 93 | A replica shed as surplus while it runs admitted releases Kueue's finalizer and takes its Workload with it, the replica beside it keeps its Pod and its Workload, and a twin in the seated group is removed without touching either — against a baseline showing an admitted Pod nobody releases stays held | pkg/worker/controllers/worker/model_deployment{,_pod_group}.go |
yes (confirm) | any (no GPU, no engine image) + an InstanceType and, in <NS>, the pool's entrance LocalQueue; room for three single-replica placeholder Pods. Optionally E2E_MD_INSTANCE_TYPE / E2E_MD_IMAGE, E2E_C93_HOLD / E2E_C93_BOUND |
| 94 | Scaling the router or a role under continuous traffic drops no request and puts the new Pod to work | pkg/worker/controllers/worker/model_deployment{,_router,_pod_group}.go, pkg/worker/kvcache/router/**, cases/_serving-lib.sh |
yes (confirm) | a Ready routed ModelDeployment on real accelerators whose target (router, prefill, decode or server) runs one replica, room for one more, E2E_PD_PROBE_IMAGE and E2E_PD_MODEL; optionally E2E_SCALE_READY_BOUND, E2E_SCALE_SETTLE, E2E_SCALE_LOG_DIR; the trap restores the replica count and removes the probe Pod |
| 95 | A change on a waiting replica's Workload alone reaches the deployment's QuotaReserved condition within a bounded window, with no Pod of the deployment moving — against a control showing a Pod event makes the status quote it | pkg/worker/controllers/worker/model_deployment.go (the Watches(&kueue.Workload{}) clause, mapModelDeploymentWorkload, modelDeploymentWorkloadPredicate), pkg/worker/controllers/worker/model_deployment_status.go |
yes (confirm) | any (no GPU, no engine image) + an InstanceType and, in <NS>, the pool's entrance LocalQueue; the case sizes one role one replica past the quota of every flavor of the pool summed, so the pool must hold that many placeholder Pods' worth of objects, and SKIPs past E2E_C95_MAX_REPLICAS (default 128). Optionally E2E_MD_INSTANCE_TYPE / E2E_MD_IMAGE, E2E_C95_WINDOW |
| 96 | A logical slice skips a fragmented node through the per-card fit labels, and livelocks there with the pin switched off | pkg/worker/controllers/worker/{node_fit_label,fit_demand,node_devices_admission}.go, pkg/worker/webhooks/worker/workload.go, pkg/nodefeature/fit.go, pkg/worker/settings/value.go |
yes (confirm) | two plain schedulable nodes |
Each note below is something the lead must act on before or around a run. What a case does — its goal, environment, inputs, assertions and cleanup — lives in its own header, which the Case header contract below requires to be readable on its own; the index never restates it.
The
Needscolumn is the one thing the index duplicates from a case header, so it is the one thing that can go stale. Change what a case requires and you must change that column in the same commit — nothing checks it.make lint docsverifies links, page structure and index labels; it never reads aNeedscell against the case it describes. Measured: case-45 was rewritten to name a Binding that deliberately does not exist, and its row went on demanding a ReadyKVCachePoolBindingnamed byE2E_MD_BINDINGuntil someone happened to read both. An index row is an assertion about code, and this one has no guard.
-
Two disjoint accelerator families, two disjoint card populations. Logical slicing (software; the vendor preload library) is
<base>.slicedplus its sizing keys, served only by a card that is not in a hardware partitioning mode. Physical partitioning (hardware; NVIDIA MIG) is<base>.partitionedplus one<base>.partitioned.<kind>-<profile>key per profile, served only by a card that is. A card serves exactly one family — which is whyInstanceTypecarries four views (Accelerator(EX/SH/SL/PT)), and why a case deploying a logical slice must select a pool with a non-zero logical slice count, never merely a "sliceable" one. Normative reference:docs/accelerator-requests.md. -
Ask whether a pool BACKS the node; never match its rendered name. The webhook stamps each
InstanceTypewith its own schedule labels —nodefeature.PoolScheduleLabels, viapkg/worker/webhooks/worker/instance_type.go— expressly "so it is selectable by the same discriminators itsDevicesandResourceFlavors carry". So the pool backing a node is the one whose every label the node also carries, and the recipe is one predicate:# every discriminator the pool carries is a label the node carries too d = {k: v for k, v in pool_labels.items() if not k.startswith('schedule.gpustack.ai/')} backs = bool(d) and all(node_labels.get(k) == v for k, v in d.items())That covers the whole identity at once —
kubernetes.io/os/arch,feature.gpustack.ai/acceleratable,acceleratable.feature.gpustack.ai/<accelerator group>and, only underinstance-type-aware-cpu-manufacturer,general.feature.gpustack.ai/<general group>. Enumeratingspecfields instead means getting that last one conditionally right: with CPU awareness off every pool'sspec.generalGroupisgeneric, and with it on the pools split by CPU key, so a hand-written tuple is wrong in one mode or the other.schedule.gpustack.ai/*is the pool's own bookkeeping and never a node label, so it is excluded. Add the accelerator group the case targets — as an anchored suffix,acceleratorGroup.endswith('-' + groupID)— only to choose among the pools of a node carrying more than one accelerator group. The per-node group id fromDevices.spec.groups[].idcannot carry the match on its own: it has no manufacturer, no os/arch and no CPU group.Three ways a looser match picks the wrong pool, all silent:
- A substring of the InstanceType name. Real product tokens nest —
a10insidea100,l40insidel40s,h20insideh200— so an A10 node's group id also matches the A100 pool's name. Which one wins then depends on API list order, not on anything the case controls. The anchored suffix above is what closes this. - Ignoring os/arch. The name carries
-<os>-<arch>and the group id does not, so on a mixed-arch cluster with one accelerator model a node's group id matches both arch pools, andlinux-amd64sorts ahead oflinux-arm64. - Ignoring the general group. Under CPU awareness two nodes with the same accelerator and os/arch but different CPUs are different pools, and a tuple that stops at the accelerator matches both.
The damage is not a clean error: the claim is submitted through the wrong pool's entrance LocalQueue, whose ResourceFlavor selects other nodes, so a node-pinned Pod is simply never admitted and the case fails on a timeout that reads like a placement defect. Worse where a ceiling comes from the pool — CASE 11 reads its over-commit limit from
Status.Detail.Memory— because a mismatched pool with larger cards makes a real over-commit pass vacuously. - A substring of the InstanceType name. Real product tokens nest —
-
spec.os/spec.archhas no case of its own — CASE 1 (cpu pool) and CASE 6 (accelerated) assert it inline. -
CASE 4 is safe on a real-accelerator cluster: its mock uses a fake product key (
nvidia-e2emock) that never collides with a real GPU pool. -
CASES 23–32, 34 and 39 need a node address you must ask the user for. 23–32 and 34 read
MIG_NODE_SSH=<user@host>; CASE 39 readsPPU_NODE_SSH=<user@host>, and only when it is not already running on the node. Each exits 2 (input required), going no further, when its address is unset — ask for it and pass it inline at run time, never hardcoded. All auto-skip (exit 0) when the hardware itself is missing. -
Vendor cases carry a vendor image, because every in-container reading is that vendor's own tool: CASE 35 a CANN-family image (a bare one exits 127 on an Ascend slice), CASE 38 a ROCm-family image, CASE 39 any image shipping
ppu-smi. Each takes anE2E_*_IMAGE=<ref>override, and all are large — pre-pull on the node, or the first claim spends its whole wait pulling. -
CASE 23 owns the whole MIG mode transition — enabling MIG moves a card out of the logical family and into the partition one entirely — and is self-recovering: its trap restores the card's original mode on pass AND fail.
-
CASES 24–32 and 34 are the hardware-partition family, sharing
cases/_partition-lib.sh(node correlation, the SSH gate, profile/key discovery, pod plumbing, therecordidiom). Its leading underscore marks it not a case; reuse it when adding another partition case rather than copying the discovery block.- Each partitions a card only if none is partitioned yet and restores exactly the card it toggled, so one up-front
nvidia-smi -i 0 -mig 1plus a Device Manager rollout restart lets most of them run back to back — but CASE 27 requires the opposite and skips if any card is already partitioned. That ordering — 27 first, then 24, then the rest — is whatrun-partition-block.shencodes; drive the family with it. - Optional environment, all with defaults:
MIG_NODE_NAME,MIG_NODE_SSH_OPTS,MIG_GPU_INDEX(0),MIG_SSH_TIMEOUT(90),IMAGE,MIG_MIXED_INDEXES/MIG_MIXED_ROUNDS(CASE 24),MIG_WRITE_IDLE_WINDOW(CASE 25),MIG_MAX_FILL(CASE 27),F11_IMAGE/F11_EXPECT(CASE 28),MIG_RECLAIM_BOUND(CASE 31),MIG_OOB_WINDOW(CASE 32).
- Each partitions a card only if none is partitioned yet and restores exactly the card it toggled, so one up-front
-
CASE 24 is the headline regression guard for the failure the two-family split exists to remove: a single token pool used to let the kubelet hand a partition request a token from a card that cannot be partitioned, and the Pod died with a terminal
UnexpectedAdmissionError. -
CASE 14 and CASES 11/22/41 want opposite POOL shapes. CASE 14's over-budget assertion needs a pool of a single logically sliceable card — with a free sibling the third slice simply lands there instead of being held — so it auto-skips on a multi-card pool. CASES 11, 22 and 41 need two or more. The incompatibility is between pools, not nodes: one node carrying a single-card pool of one accelerator model beside a multi-card pool of another satisfies both. But CASE 14 takes the first sliceable pool it finds rather than seeking a single-card one, so even that node can leave it skipping — check which pool it picked before reading the skip as a hardware verdict. What genuinely cannot be arranged is hiding a card: the
*_VISIBLE_DEVICESvariables are what the allocators inject into a workload container, not a filter on what the device manager detects. -
CASE 40's accelerator sub-check cannot execute on its own Instance. The case creates a CPU Instance on the general pool, which holds no accelerator, so the accelerator-family label set — including
mode— always skips, on any hardware. Reading it needs an accelerated Instance, and no case does it on this surface: CASE 14 asserts themodespelling on the Instance metrics subresource, but the Prometheus accelerator-label path is uncovered — CASE 41 and CASE 37 read neithermodenor those labels. -
CASE 40's "exactly one target" only bites on a multi-vendor node. A node running one device manager satisfies it trivially; the rule it guards — that a node's Instances are published by one device manager, never by each — is only exercised where two manufacturers are present. Read a PASS there as "not double-counted on this node", not as "the election was tested".
-
CASES 25, 31, 32 and 34 record observations, not thresholds — accepted consequences with stated containment, measured and printed as a copyable block for the design record; each still carries one hard assertion where a real regression would hide. CASE 28 prints the same block but is a guard, and only an explicit
F11_EXPECT=observedemotes its verdict to a recording. -
CASES 49–51 carry a placeholder image, and it is plumbing rather than a mock. A ModelDeployment role names no image of its own; the operator synthesizes one from what the InstanceType has observed, and a CPU-only InstanceType has observed no accelerator — so on the cluster these three are meant to run on, every role would be refused by the CONTROLLER for a reason none of them is about (
names no image and none could be synthesized). Each role therefore carries an explicitimage,E2E_MD_IMAGEoverrides it, and no assertion in any of the three reads anything a replica runs. Cases 47 and 48 take the same escape with a real Mooncake image, because theirs read the rendered Pod spec. -
Run CASES 49–51 in a namespace that carries the pool's entrance LocalQueue, which is what routes a group into its ClusterQueue —
kubectl -n <NS> get localqueuebefore deciding. It is not created everywhere: on a fresh docker-desktop the operator materialized one indefault,kube-publicandkube-node-leaseand none ingpustack-system, which is the namespace
Truncated - read the full file at https://github.com/gpustack/gpustack-operator/blob/8d7d8ca0945afb74133f0c7cde9a7123cd2f89de/.agents/skills/gpustack-operator-e2e/SKILL.md.