diff --git a/internal/controller/pod_placement_controller_test.go b/internal/controller/pod_placement_controller_test.go index ccf6b24..0cd2965 100644 --- a/internal/controller/pod_placement_controller_test.go +++ b/internal/controller/pod_placement_controller_test.go @@ -22,6 +22,7 @@ import ( "time" corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" clientgoscheme "k8s.io/client-go/kubernetes/scheme" @@ -32,6 +33,7 @@ import ( nebulav1alpha1 "github.com/InftyAI/Nebula/api/v1alpha1" "github.com/InftyAI/Nebula/pkg/failover" "github.com/InftyAI/Nebula/pkg/provider" + "github.com/InftyAI/Nebula/pkg/util" ) // fakeBlocklist is a test Blocklister that reports a fixed set of candidates as @@ -261,6 +263,32 @@ func TestPlacement_CPUOnlyPodMatchesAnyProvider(t *testing.T) { } } +func TestPlacement_GPUCountWithoutAcceleratorTypeLeavesPodGated(t *testing.T) { + // A Pod that requests nvidia.com/gpu but omits accelerator-type is malformed, + // not CPU-only. Placement must not silently route it as a CPU-only workload. + pod := gatedPod("p1", "default", "uid-1", "pool-a", "") + pod.Spec.Containers[0].Resources.Limits = corev1.ResourceList{ + util.NvidiaGPUResource: resource.MustParse("1"), + } + pool := poolWith("pool-a", []nebulav1alpha1.CapacityType{nebulav1alpha1.CapacityOnDemand}, provider.ProviderModal) + modal := &fakeProvider{name: provider.ProviderModal, gpus: []string{"H100"}} + r, c := newPlacementReconciler(t, []client.Object{pod, pool}, modal) + + reconcilePod(t, r, "default", "p1") + + got := getPod(t, c, "default", "p1") + if !hasGateNamed(got) { + t.Fatal("expected malformed GPU Pod to stay gated") + } + if got.Spec.NodeSelector[nebulav1alpha1.ProviderLabel] != "" { + t.Fatalf("expected no provider nodeSelector for malformed GPU Pod, got %v", got.Spec.NodeSelector) + } + var nc nebulav1alpha1.NodeClaim + if err := c.Get(context.Background(), types.NamespacedName{Name: "default-p1"}, &nc); err == nil { + t.Fatal("expected no claim for malformed GPU Pod") + } +} + func TestPlacement_SkipsPodWithoutOptInLabel(t *testing.T) { pod := gatedPod("p1", "default", "uid-1", "pool-a", "H100") pod.Labels[nebulav1alpha1.EnabledLabel] = "false" diff --git a/internal/controller/pod_placement_helpers.go b/internal/controller/pod_placement_helpers.go index a12c823..33d5b66 100644 --- a/internal/controller/pod_placement_helpers.go +++ b/internal/controller/pod_placement_helpers.go @@ -96,10 +96,14 @@ func (r *PodPlacementReconciler) selectPlacement(ctx context.Context, pod *corev // The (type, count) together select the concrete offering: a provider resolves // them through MapAccelerator to its own id (an EC2 instance type on AWS), which // is what the blocklist keys on so L4x1 and L4x8 (distinct instance types) block - // independently. A malformed request is treated as "no accelerator" so placement - // stays a no-op rather than erroring the reconcile — provisioning would surface - // the real error. - accel, count, _ := util.AcceleratorRequest(pod) + // independently. A malformed request is left gated rather than silently routed as + // CPU-only; the user must fix the Pod spec (or a controller-generated Pod's + // source object) before placement can proceed. + accel, count, err := util.AcceleratorRequest(pod) + if err != nil { + log.Info("invalid accelerator request; leaving Pod gated", "error", err.Error()) + return placement{}, false, 0 + } var soonest time.Duration // 0 = no blocked-but-servable candidate seen for _, tier := range capacityTiers(pool) { // outer: capacity