Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions internal/controller/pod_placement_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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"
Expand Down
12 changes: 8 additions & 4 deletions internal/controller/pod_placement_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading