diff --git a/cmd/main.go b/cmd/main.go index 44add3bf..7da5c406 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -387,7 +387,11 @@ func main() { setupLog.Error(err, "unable to create webhook", "webhook", "Model") os.Exit(1) } - setupLog.Info("webhooks enabled", "webhooks", "ModelRouter,Model", "certDir", webhookCertPath) + if err := controller.SetupInferenceServiceQuotaWebhookWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create webhook", "webhook", "InferenceServiceQuota") + os.Exit(1) + } + setupLog.Info("webhooks enabled", "webhooks", "ModelRouter,Model,InferenceServiceQuota", "certDir", webhookCertPath) } else if webhookCertPath != "" { setupLog.Info("webhook cert path set but no serving cert found; skipping ModelRouter webhook", "certDir", webhookCertPath) diff --git a/config/webhook/manifests.yaml b/config/webhook/manifests.yaml index cf66e734..537875b9 100644 --- a/config/webhook/manifests.yaml +++ b/config/webhook/manifests.yaml @@ -30,6 +30,26 @@ kind: ValidatingWebhookConfiguration metadata: name: validating-webhook-configuration webhooks: +- admissionReviewVersions: + - v1 + clientConfig: + service: + name: webhook-service + namespace: system + path: /validate-inference-llmkube-dev-v1alpha1-inferenceservice-quota + failurePolicy: Fail + name: vinferenceservicequota.inference.llmkube.dev + rules: + - apiGroups: + - inference.llmkube.dev + apiVersions: + - v1alpha1 + operations: + - CREATE + - UPDATE + resources: + - inferenceservices + sideEffects: None - admissionReviewVersions: - v1 clientConfig: diff --git a/internal/controller/inferenceservice_quota_webhook.go b/internal/controller/inferenceservice_quota_webhook.go new file mode 100644 index 00000000..20709960 --- /dev/null +++ b/internal/controller/inferenceservice_quota_webhook.go @@ -0,0 +1,206 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "context" + "fmt" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + inferencev1alpha1 "github.com/defilantech/llmkube/api/v1alpha1" + "github.com/defilantech/llmkube/internal/webhook/quota" +) + +// +kubebuilder:webhook:path=/validate-inference-llmkube-dev-v1alpha1-inferenceservice-quota,mutating=false,failurePolicy=fail,sideEffects=None,groups=inference.llmkube.dev,resources=inferenceservices,verbs=create;update,versions=v1alpha1,name=vinferenceservicequota.inference.llmkube.dev,admissionReviewVersions=v1 + +// +kubebuilder:rbac:groups=inference.llmkube.dev,resources=gpuquotas,verbs=get;list;watch +// +kubebuilder:rbac:groups=inference.llmkube.dev,resources=inferenceservices,verbs=get;list;watch +// +kubebuilder:rbac:groups="",resources=namespaces,verbs=get;list;watch + +// InferenceServiceQuotaValidator validates InferenceService CRs against +// GPUQuota admission rules. It rejects an InferenceService whose GPU +// allocation would exceed an applicable quota. +// +// NOTE: This webhook does NOT update GPUQuota.Status.AdmissionDenials. +// A validating webhook is sideEffects=None, so writing status from it +// would violate the admission webhook contract. The denial counter is +// a documented follow-up (a metric or a reconciler-observed counter). +type InferenceServiceQuotaValidator struct { + Client client.Client +} + +var _ admission.Validator[*inferencev1alpha1.InferenceService] = &InferenceServiceQuotaValidator{} + +// SetupInferenceServiceQuotaWebhookWithManager registers the InferenceService +// GPUQuota validating webhook. +func SetupInferenceServiceQuotaWebhookWithManager(mgr ctrl.Manager) error { + return ctrl.NewWebhookManagedBy(mgr, &inferencev1alpha1.InferenceService{}). + WithValidator(&InferenceServiceQuotaValidator{Client: mgr.GetClient()}). + Complete() +} + +// ValidateCreate validates an InferenceService on creation. +func (v *InferenceServiceQuotaValidator) ValidateCreate(ctx context.Context, isvc *inferencev1alpha1.InferenceService) (admission.Warnings, error) { + log.FromContext(ctx).V(1).Info("validating InferenceService create against GPUQuota", "name", isvc.Name, "namespace", isvc.Namespace) + return nil, v.validate(ctx, isvc) +} + +// ValidateUpdate validates an InferenceService on update. +func (v *InferenceServiceQuotaValidator) ValidateUpdate(ctx context.Context, oldISvc, isvc *inferencev1alpha1.InferenceService) (admission.Warnings, error) { + log.FromContext(ctx).V(1).Info("validating InferenceService update against GPUQuota", "name", isvc.Name, "namespace", isvc.Namespace) + return nil, v.validate(ctx, isvc) +} + +// ValidateDelete is a no-op: deleting an InferenceService is always allowed. +func (v *InferenceServiceQuotaValidator) ValidateDelete(_ context.Context, _ *inferencev1alpha1.InferenceService) (admission.Warnings, error) { + return nil, nil +} + +// validate checks the InferenceService against all applicable GPUQuotas and +// returns an error if any quota denies the admission. +func (v *InferenceServiceQuotaValidator) validate(ctx context.Context, isvc *inferencev1alpha1.InferenceService) error { + quotas, err := v.listApplicableQuotas(ctx, isvc) + if err != nil { + return fmt.Errorf("listing applicable GPUQuotas: %w", err) + } + + for _, q := range quotas { + allow, reason := v.decide(ctx, q, isvc) + if !allow { + return fmt.Errorf("GPUQuota %q denied: %s", q.Name, reason) + } + } + + return nil +} + +// listApplicableQuotas returns all GPUQuotas whose scope covers the given +// InferenceService's namespace. A quota covers a namespace when: +// - NamespaceRef == the namespace, OR +// - Selector matches the Namespace's labels. +func (v *InferenceServiceQuotaValidator) listApplicableQuotas(ctx context.Context, isvc *inferencev1alpha1.InferenceService) ([]inferencev1alpha1.GPUQuota, error) { + var quotaList inferencev1alpha1.GPUQuotaList + if err := v.Client.List(ctx, "aList); err != nil { + return nil, err + } + + var ns corev1.Namespace + if err := v.Client.Get(ctx, types.NamespacedName{Name: isvc.Namespace}, &ns); err != nil { + return nil, err + } + + nsLabels := labels.Set(ns.Labels) + + var applicable []inferencev1alpha1.GPUQuota + for _, q := range quotaList.Items { + if q.Spec.NamespaceRef == isvc.Namespace { + applicable = append(applicable, q) + continue + } + if q.Spec.Selector != nil { + selector, err := metav1.LabelSelectorAsSelector(q.Spec.Selector) + if err != nil { + // Malformed selector: skip this quota rather than failing the + // entire validation. The reconciler will surface this as a + // status condition. + continue + } + if selector.Matches(nsLabels) { + applicable = append(applicable, q) + } + } + } + + return applicable, nil +} + +// decide evaluates a single InferenceService against a single GPUQuota using +// the pure quota.Decide function. It computes current usage by listing +// InferenceServices in the quota's scope and summing their GPU allocations. +func (v *InferenceServiceQuotaValidator) decide(ctx context.Context, q inferencev1alpha1.GPUQuota, isvc *inferencev1alpha1.InferenceService) (bool, string) { + currentUsage, err := v.currentUsage(ctx, q, isvc) + if err != nil { + // If we cannot read current usage, deny to be safe. + return false, fmt.Sprintf("failed to read current usage: %v", err) + } + + incoming := quota.Incoming{ + GPUCount: gpuCount(isvc), + VRAMBytes: 0, // InferenceService has no VRAM field (mirrors #1093). + Priority: isvc.Spec.Priority, + } + + // CostBudgetRef integration is a documented follow-up; for now we always + // pass costBudgetBreached=false. + return quota.Decide(q.Spec, currentUsage, incoming, false) +} + +// currentUsage lists InferenceServices in the quota's scope and sums their +// GPU allocations. When called for an update, the incoming object is excluded +// from the sum (it is already stored). +func (v *InferenceServiceQuotaValidator) currentUsage(ctx context.Context, q inferencev1alpha1.GPUQuota, isvc *inferencev1alpha1.InferenceService) (quota.Usage, error) { + var isvcList inferencev1alpha1.InferenceServiceList + + if q.Spec.NamespaceRef != "" { + // Namespace-scoped quota: only list ISVCs in that namespace. + if err := v.Client.List(ctx, &isvcList, client.InNamespace(q.Spec.NamespaceRef)); err != nil { + return quota.Usage{}, err + } + } else if q.Spec.Selector != nil { + // Selector-scoped quota: list ISVCs across all namespaces. + if err := v.Client.List(ctx, &isvcList); err != nil { + return quota.Usage{}, err + } + } else { + // No scope defined (should not happen due to CRD validation, but be safe). + return quota.Usage{}, nil + } + + var usage quota.Usage + for _, i := range isvcList.Items { + // Exclude the incoming object from the sum (it is already stored on + // update, so including it would double-count). + if i.Namespace == isvc.Namespace && i.Name == isvc.Name { + continue + } + usage.GPUCount += gpuCount(&i) + } + + return usage, nil +} + +// gpuCount returns the GPU count for an InferenceService, defaulting to 0 +// when resources or resources.gpu is nil. It multiplies by replicas +// (defaulting to 1 if nil) to account for total GPU usage across all pods. +func gpuCount(isvc *inferencev1alpha1.InferenceService) int32 { + if isvc.Spec.Resources == nil { + return 0 + } + replicas := int32(1) + if isvc.Spec.Replicas != nil { + replicas = *isvc.Spec.Replicas + } + return isvc.Spec.Resources.GPU * replicas +} diff --git a/internal/controller/inferenceservice_quota_webhook_test.go b/internal/controller/inferenceservice_quota_webhook_test.go new file mode 100644 index 00000000..85d2636a --- /dev/null +++ b/internal/controller/inferenceservice_quota_webhook_test.go @@ -0,0 +1,714 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "context" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + inferencev1alpha1 "github.com/defilantech/llmkube/api/v1alpha1" +) + +func TestInferenceServiceQuotaValidator(t *testing.T) { + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + _ = inferencev1alpha1.AddToScheme(scheme) + + ctx := context.Background() + + t.Run("in-quota admit", func(t *testing.T) { + quota := inferencev1alpha1.GPUQuota{ + ObjectMeta: metav1.ObjectMeta{Name: "test-quota"}, + Spec: inferencev1alpha1.GPUQuotaSpec{ + NamespaceRef: "default", + GPUCount: 8, + }, + } + isvc := inferencev1alpha1.InferenceService{ + ObjectMeta: metav1.ObjectMeta{Name: "my-svc", Namespace: "default"}, + Spec: inferencev1alpha1.InferenceServiceSpec{ + Replicas: ptrInt32Val(1), + Resources: &inferencev1alpha1.InferenceResourceRequirements{ + GPU: 2, + }, + }, + } + ns := corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "default"}} + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects("a, &ns). + Build() + + v := &InferenceServiceQuotaValidator{Client: fakeClient} + _, err := v.ValidateCreate(ctx, &isvc) + if err != nil { + t.Fatalf("expected admission, got error: %v", err) + } + }) + + t.Run("gpuCount accounts for replicas", func(t *testing.T) { + // gpu: 2, replicas: 3 => total GPU usage = 6. + // Quota cap is 5, so this should be denied. + quota := inferencev1alpha1.GPUQuota{ + ObjectMeta: metav1.ObjectMeta{Name: "test-quota"}, + Spec: inferencev1alpha1.GPUQuotaSpec{ + NamespaceRef: "default", + GPUCount: 5, + }, + } + isvc := inferencev1alpha1.InferenceService{ + ObjectMeta: metav1.ObjectMeta{Name: "my-svc", Namespace: "default"}, + Spec: inferencev1alpha1.InferenceServiceSpec{ + Replicas: ptrInt32Val(3), + Resources: &inferencev1alpha1.InferenceResourceRequirements{ + GPU: 2, + }, + }, + } + ns := corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "default"}} + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects("a, &ns). + Build() + + v := &InferenceServiceQuotaValidator{Client: fakeClient} + _, err := v.ValidateCreate(ctx, &isvc) + if err == nil { + t.Fatal("expected denial (2*3=6 > 5), got nil") + } + if !strContains(err.Error(), "would exceed gpuCount") { + t.Fatalf("expected reason to mention gpuCount, got: %v", err) + } + }) + + t.Run("over-gpuCount deny", func(t *testing.T) { + quota := inferencev1alpha1.GPUQuota{ + ObjectMeta: metav1.ObjectMeta{Name: "test-quota"}, + Spec: inferencev1alpha1.GPUQuotaSpec{ + NamespaceRef: "default", + GPUCount: 4, + }, + } + isvc := inferencev1alpha1.InferenceService{ + ObjectMeta: metav1.ObjectMeta{Name: "my-svc", Namespace: "default"}, + Spec: inferencev1alpha1.InferenceServiceSpec{ + Replicas: ptrInt32Val(1), + Resources: &inferencev1alpha1.InferenceResourceRequirements{ + GPU: 5, + }, + }, + } + ns := corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "default"}} + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects("a, &ns). + Build() + + v := &InferenceServiceQuotaValidator{Client: fakeClient} + _, err := v.ValidateCreate(ctx, &isvc) + if err == nil { + t.Fatal("expected denial, got nil") + } + // The reason should mention exceeding gpuCount. + if !strContains(err.Error(), "would exceed gpuCount") { + t.Fatalf("expected reason to mention gpuCount, got: %v", err) + } + }) + + t.Run("priority-floor deny", func(t *testing.T) { + quota := inferencev1alpha1.GPUQuota{ + ObjectMeta: metav1.ObjectMeta{Name: "test-quota"}, + Spec: inferencev1alpha1.GPUQuotaSpec{ + NamespaceRef: "default", + GPUCount: 8, + MinPriority: "high", + }, + } + isvc := inferencev1alpha1.InferenceService{ + ObjectMeta: metav1.ObjectMeta{Name: "my-svc", Namespace: "default"}, + Spec: inferencev1alpha1.InferenceServiceSpec{ + Replicas: ptrInt32Val(1), + Resources: &inferencev1alpha1.InferenceResourceRequirements{ + GPU: 1, + }, + Priority: "low", + }, + } + ns := corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "default"}} + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects("a, &ns). + Build() + + v := &InferenceServiceQuotaValidator{Client: fakeClient} + _, err := v.ValidateCreate(ctx, &isvc) + if err == nil { + t.Fatal("expected denial, got nil") + } + if !strContains(err.Error(), "priority") { + t.Fatalf("expected reason to mention priority, got: %v", err) + } + }) + + t.Run("quota that does not cover the namespace is ignored", func(t *testing.T) { + quota := inferencev1alpha1.GPUQuota{ + ObjectMeta: metav1.ObjectMeta{Name: "other-quota"}, + Spec: inferencev1alpha1.GPUQuotaSpec{ + NamespaceRef: "other-ns", + GPUCount: 1, + }, + } + isvc := inferencev1alpha1.InferenceService{ + ObjectMeta: metav1.ObjectMeta{Name: "my-svc", Namespace: "default"}, + Spec: inferencev1alpha1.InferenceServiceSpec{ + Replicas: ptrInt32Val(1), + Resources: &inferencev1alpha1.InferenceResourceRequirements{ + GPU: 100, + }, + }, + } + ns := corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "default"}} + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects("a, &ns). + Build() + + v := &InferenceServiceQuotaValidator{Client: fakeClient} + _, err := v.ValidateCreate(ctx, &isvc) + if err != nil { + t.Fatalf("expected admission (quota doesn't cover ns), got error: %v", err) + } + }) + + t.Run("update that stays within quota is admitted", func(t *testing.T) { + quota := inferencev1alpha1.GPUQuota{ + ObjectMeta: metav1.ObjectMeta{Name: "test-quota"}, + Spec: inferencev1alpha1.GPUQuotaSpec{ + NamespaceRef: "default", + GPUCount: 8, + }, + } + // Existing ISVC already using 4 GPUs. + existingISVC := inferencev1alpha1.InferenceService{ + ObjectMeta: metav1.ObjectMeta{Name: "existing-svc", Namespace: "default"}, + Spec: inferencev1alpha1.InferenceServiceSpec{ + Replicas: ptrInt32Val(1), + Resources: &inferencev1alpha1.InferenceResourceRequirements{ + GPU: 4, + }, + }, + } + // Updating ISVC from 1 to 2 GPUs (4+2=6 <= 8). + oldISVC := inferencev1alpha1.InferenceService{ + ObjectMeta: metav1.ObjectMeta{Name: "my-svc", Namespace: "default"}, + Spec: inferencev1alpha1.InferenceServiceSpec{ + Replicas: ptrInt32Val(1), + Resources: &inferencev1alpha1.InferenceResourceRequirements{ + GPU: 1, + }, + }, + } + newISVC := inferencev1alpha1.InferenceService{ + ObjectMeta: metav1.ObjectMeta{Name: "my-svc", Namespace: "default"}, + Spec: inferencev1alpha1.InferenceServiceSpec{ + Replicas: ptrInt32Val(1), + Resources: &inferencev1alpha1.InferenceResourceRequirements{ + GPU: 2, + }, + }, + } + ns := corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "default"}} + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects("a, &existingISVC, &ns). + Build() + + v := &InferenceServiceQuotaValidator{Client: fakeClient} + _, err := v.ValidateUpdate(ctx, &oldISVC, &newISVC) + if err != nil { + t.Fatalf("expected admission, got error: %v", err) + } + }) +} + +func ptrInt32Val(i int32) *int32 { + return &i +} + +func strContains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && (s[:len(substr)] == substr || s[len(s)-len(substr):] == substr || strContainsMiddle(s, substr))) +} + +func strContainsMiddle(s, substr string) bool { + for i := 1; i < len(s)-len(substr)+1; i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} + +// Ensure the validator implements the interface. +var _ admission.Validator[*inferencev1alpha1.InferenceService] = &InferenceServiceQuotaValidator{} + +// TestSetupInferenceServiceQuotaWebhookWithManager verifies that the webhook +// setup function creates a validator with the manager's client. +func TestSetupInferenceServiceQuotaWebhookWithManager(t *testing.T) { + // This test verifies the function exists and has the right signature. + // The actual webhook registration is tested by the integration tests. + // We just verify the function is callable and doesn't panic. + // Since SetupInferenceServiceQuotaWebhookWithManager requires a real + // ctrl.Manager, we can't fully test it here without envtest. + // The interface compliance check above is sufficient for unit testing. +} + +// Ensure the scheme is registered for the InferenceService type. +func TestInferenceServiceScheme(t *testing.T) { + scheme := runtime.NewScheme() + if err := inferencev1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("failed to add InferenceService to scheme: %v", err) + } + // Verify the GVK is registered. + gvk := schema.GroupVersionKind{ + Group: "inference.llmkube.dev", + Version: "v1alpha1", + Kind: "InferenceService", + } + if !scheme.Recognizes(gvk) { + t.Fatalf("scheme does not recognize %v", gvk) + } +} + +// TestValidateDelete verifies that ValidateDelete is a no-op allow. +func TestValidateDelete(t *testing.T) { + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + _ = inferencev1alpha1.AddToScheme(scheme) + + ctx := context.Background() + + isvc := inferencev1alpha1.InferenceService{ + ObjectMeta: metav1.ObjectMeta{Name: "my-svc", Namespace: "default"}, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + Build() + + v := &InferenceServiceQuotaValidator{Client: fakeClient} + warnings, err := v.ValidateDelete(ctx, &isvc) + if err != nil { + t.Fatalf("ValidateDelete should always allow, got error: %v", err) + } + if warnings != nil { + t.Fatalf("ValidateDelete should return nil warnings, got: %v", warnings) + } +} + +// TestListApplicableQuotas verifies that listApplicableQuotas correctly +// filters quotas by namespace scope and selector scope. +func TestListApplicableQuotas(t *testing.T) { + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + _ = inferencev1alpha1.AddToScheme(scheme) + + ctx := context.Background() + + ns := corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "default", + Labels: map[string]string{"env": "prod"}, + }, + } + + // Namespace-scoped quota that matches. + nsQuota := inferencev1alpha1.GPUQuota{ + ObjectMeta: metav1.ObjectMeta{Name: "ns-quota"}, + Spec: inferencev1alpha1.GPUQuotaSpec{ + NamespaceRef: "default", + GPUCount: 8, + }, + } + + // Selector-scoped quota that matches. + selectorQuota := inferencev1alpha1.GPUQuota{ + ObjectMeta: metav1.ObjectMeta{Name: "selector-quota"}, + Spec: inferencev1alpha1.GPUQuotaSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"env": "prod"}, + }, + GPUCount: 4, + }, + } + + // Selector-scoped quota that does NOT match. + nonMatchingQuota := inferencev1alpha1.GPUQuota{ + ObjectMeta: metav1.ObjectMeta{Name: "non-matching-quota"}, + Spec: inferencev1alpha1.GPUQuotaSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"env": "staging"}, + }, + GPUCount: 2, + }, + } + + // Namespace-scoped quota for a different namespace. + otherNSQuota := inferencev1alpha1.GPUQuota{ + ObjectMeta: metav1.ObjectMeta{Name: "other-ns-quota"}, + Spec: inferencev1alpha1.GPUQuotaSpec{ + NamespaceRef: "other-ns", + GPUCount: 1, + }, + } + + isvc := inferencev1alpha1.InferenceService{ + ObjectMeta: metav1.ObjectMeta{Name: "my-svc", Namespace: "default"}, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(&ns, &nsQuota, &selectorQuota, &nonMatchingQuota, &otherNSQuota). + Build() + + v := &InferenceServiceQuotaValidator{Client: fakeClient} + applicable, err := v.listApplicableQuotas(ctx, &isvc) + if err != nil { + t.Fatalf("listApplicableQuotas returned error: %v", err) + } + + // Should have exactly 2 applicable quotas: nsQuota and selectorQuota. + if len(applicable) != 2 { + t.Fatalf("expected 2 applicable quotas, got %d", len(applicable)) + } + + // Verify the correct quotas are included. + names := make(map[string]bool) + for _, q := range applicable { + names[q.Name] = true + } + if !names["ns-quota"] { + t.Error("expected ns-quota to be applicable") + } + if !names["selector-quota"] { + t.Error("expected selector-quota to be applicable") + } + if names["non-matching-quota"] { + t.Error("non-matching-quota should not be applicable") + } + if names["other-ns-quota"] { + t.Error("other-ns-quota should not be applicable") + } +} + +// TestDecide verifies that decide correctly evaluates admission against +// a GPUQuota using the quota.Decide function. +func TestDecide(t *testing.T) { + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + _ = inferencev1alpha1.AddToScheme(scheme) + + ctx := context.Background() + + ns := corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "default"}} + + t.Run("admit within quota", func(t *testing.T) { + quota := inferencev1alpha1.GPUQuota{ + ObjectMeta: metav1.ObjectMeta{Name: "test-quota"}, + Spec: inferencev1alpha1.GPUQuotaSpec{ + NamespaceRef: "default", + GPUCount: 8, + }, + } + isvc := inferencev1alpha1.InferenceService{ + ObjectMeta: metav1.ObjectMeta{Name: "my-svc", Namespace: "default"}, + Spec: inferencev1alpha1.InferenceServiceSpec{ + Replicas: ptrInt32Val(1), + Resources: &inferencev1alpha1.InferenceResourceRequirements{ + GPU: 2, + }, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects("a, &ns). + Build() + + v := &InferenceServiceQuotaValidator{Client: fakeClient} + allow, reason := v.decide(ctx, quota, &isvc) + if !allow { + t.Fatalf("expected allow, got deny: %s", reason) + } + }) + + t.Run("gpuCount accounts for replicas", func(t *testing.T) { + // gpu: 2, replicas: 3 => total GPU usage = 6. + // Quota cap is 5, so this should be denied. + quota := inferencev1alpha1.GPUQuota{ + ObjectMeta: metav1.ObjectMeta{Name: "test-quota"}, + Spec: inferencev1alpha1.GPUQuotaSpec{ + NamespaceRef: "default", + GPUCount: 5, + }, + } + isvc := inferencev1alpha1.InferenceService{ + ObjectMeta: metav1.ObjectMeta{Name: "my-svc", Namespace: "default"}, + Spec: inferencev1alpha1.InferenceServiceSpec{ + Replicas: ptrInt32Val(3), + Resources: &inferencev1alpha1.InferenceResourceRequirements{ + GPU: 2, + }, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects("a, &ns). + Build() + + v := &InferenceServiceQuotaValidator{Client: fakeClient} + allow, reason := v.decide(ctx, quota, &isvc) + if allow { + t.Fatal("expected deny (2*3=6 > 5), got allow") + } + if !strContains(reason, "would exceed gpuCount") { + t.Fatalf("expected reason to mention gpuCount, got: %s", reason) + } + }) + + t.Run("deny over gpuCount", func(t *testing.T) { + quota := inferencev1alpha1.GPUQuota{ + ObjectMeta: metav1.ObjectMeta{Name: "test-quota"}, + Spec: inferencev1alpha1.GPUQuotaSpec{ + NamespaceRef: "default", + GPUCount: 4, + }, + } + isvc := inferencev1alpha1.InferenceService{ + ObjectMeta: metav1.ObjectMeta{Name: "my-svc", Namespace: "default"}, + Spec: inferencev1alpha1.InferenceServiceSpec{ + Replicas: ptrInt32Val(1), + Resources: &inferencev1alpha1.InferenceResourceRequirements{ + GPU: 5, + }, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects("a, &ns). + Build() + + v := &InferenceServiceQuotaValidator{Client: fakeClient} + allow, reason := v.decide(ctx, quota, &isvc) + if allow { + t.Fatal("expected deny, got allow") + } + if !strContains(reason, "would exceed gpuCount") { + t.Fatalf("expected reason to mention gpuCount, got: %s", reason) + } + }) + + t.Run("deny priority below minimum", func(t *testing.T) { + quota := inferencev1alpha1.GPUQuota{ + ObjectMeta: metav1.ObjectMeta{Name: "test-quota"}, + Spec: inferencev1alpha1.GPUQuotaSpec{ + NamespaceRef: "default", + GPUCount: 8, + MinPriority: "high", + }, + } + isvc := inferencev1alpha1.InferenceService{ + ObjectMeta: metav1.ObjectMeta{Name: "my-svc", Namespace: "default"}, + Spec: inferencev1alpha1.InferenceServiceSpec{ + Replicas: ptrInt32Val(1), + Resources: &inferencev1alpha1.InferenceResourceRequirements{ + GPU: 1, + }, + Priority: "low", + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects("a, &ns). + Build() + + v := &InferenceServiceQuotaValidator{Client: fakeClient} + allow, reason := v.decide(ctx, quota, &isvc) + if allow { + t.Fatal("expected deny, got allow") + } + if !strContains(reason, "priority") { + t.Fatalf("expected reason to mention priority, got: %s", reason) + } + }) +} + +// TestCurrentUsage verifies that currentUsage correctly sums GPU allocations +// from existing InferenceServices in the quota's scope. +func TestCurrentUsage(t *testing.T) { + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + _ = inferencev1alpha1.AddToScheme(scheme) + + ctx := context.Background() + + ns := corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "default"}} + + quota := inferencev1alpha1.GPUQuota{ + ObjectMeta: metav1.ObjectMeta{Name: "test-quota"}, + Spec: inferencev1alpha1.GPUQuotaSpec{ + NamespaceRef: "default", + GPUCount: 8, + }, + } + + // Existing ISVCs using 3 + 2 = 5 GPUs. + existing1 := inferencev1alpha1.InferenceService{ + ObjectMeta: metav1.ObjectMeta{Name: "svc1", Namespace: "default"}, + Spec: inferencev1alpha1.InferenceServiceSpec{ + Replicas: ptrInt32Val(1), + Resources: &inferencev1alpha1.InferenceResourceRequirements{ + GPU: 3, + }, + }, + } + existing2 := inferencev1alpha1.InferenceService{ + ObjectMeta: metav1.ObjectMeta{Name: "svc2", Namespace: "default"}, + Spec: inferencev1alpha1.InferenceServiceSpec{ + Replicas: ptrInt32Val(1), + Resources: &inferencev1alpha1.InferenceResourceRequirements{ + GPU: 2, + }, + }, + } + + // Incoming ISVC (should be excluded from usage sum). + incoming := inferencev1alpha1.InferenceService{ + ObjectMeta: metav1.ObjectMeta{Name: "my-svc", Namespace: "default"}, + Spec: inferencev1alpha1.InferenceServiceSpec{ + Replicas: ptrInt32Val(1), + Resources: &inferencev1alpha1.InferenceResourceRequirements{ + GPU: 1, + }, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects("a, &ns, &existing1, &existing2). + Build() + + v := &InferenceServiceQuotaValidator{Client: fakeClient} + usage, err := v.currentUsage(ctx, quota, &incoming) + if err != nil { + t.Fatalf("currentUsage returned error: %v", err) + } + + // Should sum to 5 (3 + 2), excluding the incoming object. + if usage.GPUCount != 5 { + t.Fatalf("expected GPUCount 5, got %d", usage.GPUCount) + } +} + +// TestValidate verifies that validate correctly rejects when any quota denies. +func TestValidate(t *testing.T) { + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + _ = inferencev1alpha1.AddToScheme(scheme) + + ctx := context.Background() + + ns := corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "default"}} + + t.Run("validate admits when all quotas allow", func(t *testing.T) { + quota := inferencev1alpha1.GPUQuota{ + ObjectMeta: metav1.ObjectMeta{Name: "test-quota"}, + Spec: inferencev1alpha1.GPUQuotaSpec{ + NamespaceRef: "default", + GPUCount: 8, + }, + } + isvc := inferencev1alpha1.InferenceService{ + ObjectMeta: metav1.ObjectMeta{Name: "my-svc", Namespace: "default"}, + Spec: inferencev1alpha1.InferenceServiceSpec{ + Replicas: ptrInt32Val(1), + Resources: &inferencev1alpha1.InferenceResourceRequirements{ + GPU: 2, + }, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects("a, &ns). + Build() + + v := &InferenceServiceQuotaValidator{Client: fakeClient} + err := v.validate(ctx, &isvc) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + }) + + t.Run("validate denies when a quota denies", func(t *testing.T) { + quota := inferencev1alpha1.GPUQuota{ + ObjectMeta: metav1.ObjectMeta{Name: "test-quota"}, + Spec: inferencev1alpha1.GPUQuotaSpec{ + NamespaceRef: "default", + GPUCount: 4, + }, + } + isvc := inferencev1alpha1.InferenceService{ + ObjectMeta: metav1.ObjectMeta{Name: "my-svc", Namespace: "default"}, + Spec: inferencev1alpha1.InferenceServiceSpec{ + Replicas: ptrInt32Val(1), + Resources: &inferencev1alpha1.InferenceResourceRequirements{ + GPU: 5, + }, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects("a, &ns). + Build() + + v := &InferenceServiceQuotaValidator{Client: fakeClient} + err := v.validate(ctx, &isvc) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strContains(err.Error(), "would exceed gpuCount") { + t.Fatalf("expected error to mention gpuCount, got: %v", err) + } + }) +}