diff --git a/.changes/unreleased/Added-20260802-185014.yaml b/.changes/unreleased/Added-20260802-185014.yaml new file mode 100644 index 000000000..19b657ee5 --- /dev/null +++ b/.changes/unreleased/Added-20260802-185014.yaml @@ -0,0 +1,6 @@ +kind: Added +body: Added support for configuring binder Pod Disruption Budget via Helm values (`binder.podDisruptionBudget`) when running multiple replicas +time: 2026-08-02T18:50:14.477675704+01:00 +custom: + Author: dttung2905 + Issue: "1477" diff --git a/deployments/kai-scheduler/templates/_helpers.tpl b/deployments/kai-scheduler/templates/_helpers.tpl index 21a79011f..77dc241ef 100644 --- a/deployments/kai-scheduler/templates/_helpers.tpl +++ b/deployments/kai-scheduler/templates/_helpers.tpl @@ -125,6 +125,15 @@ spec: affinity: {{- toYaml .Values.binder.affinity | nindent 8 }} {{- end }} + {{- if .Values.binder.podDisruptionBudget }} + podDisruptionBudget: + {{- if hasKey .Values.binder.podDisruptionBudget "enabled" }} + enabled: {{ .Values.binder.podDisruptionBudget.enabled }} + {{- end }} + {{- if hasKey .Values.binder.podDisruptionBudget "maxUnavailable" }} + maxUnavailable: {{ .Values.binder.podDisruptionBudget.maxUnavailable }} + {{- end }} + {{- end }} metricsPort: {{ .Values.binder.ports.metricsPort }} resourceReservation: {{- if .Values.global.resourceReservation.namespace }} diff --git a/deployments/kai-scheduler/tests/binder_pdb_test.yaml b/deployments/kai-scheduler/tests/binder_pdb_test.yaml new file mode 100644 index 000000000..4a51361e2 --- /dev/null +++ b/deployments/kai-scheduler/tests/binder_pdb_test.yaml @@ -0,0 +1,68 @@ +# Copyright 2025 NVIDIA CORPORATION +# SPDX-License-Identifier: Apache-2.0 + +suite: binder podDisruptionBudget in kai-config + +set: + kaiConfigDeployer: + enabled: false + kaiConfig: + render: true + +templates: + - kai-config.yaml +tests: + - it: renders binder podDisruptionBudget from values + set: + binder.podDisruptionBudget: + enabled: true + maxUnavailable: 1 + asserts: + - equal: + path: spec.binder.service.podDisruptionBudget.enabled + value: true + - equal: + path: spec.binder.service.podDisruptionBudget.maxUnavailable + value: 1 + + - it: renders disabled PDB when enabled is false + set: + binder.podDisruptionBudget: + enabled: false + asserts: + - equal: + path: spec.binder.service.podDisruptionBudget.enabled + value: false + + - it: omits podDisruptionBudget when the values map is absent + set: + binder.podDisruptionBudget: null + asserts: + - isNull: + path: spec.binder.service.podDisruptionBudget + + - it: renders explicit zero maxUnavailable + set: + binder.podDisruptionBudget: + enabled: true + maxUnavailable: 0 + asserts: + - equal: + path: spec.binder.service.podDisruptionBudget.enabled + value: true + - equal: + path: spec.binder.service.podDisruptionBudget.maxUnavailable + value: 0 + + - it: renders custom maxUnavailable + set: + binder.podDisruptionBudget: + enabled: true + maxUnavailable: 2 + asserts: + - equal: + path: spec.binder.service.podDisruptionBudget.enabled + value: true + - equal: + path: spec.binder.service.podDisruptionBudget.maxUnavailable + value: 2 diff --git a/deployments/kai-scheduler/values.yaml b/deployments/kai-scheduler/values.yaml index 5a1207cb1..d5ba64316 100644 --- a/deployments/kai-scheduler/values.yaml +++ b/deployments/kai-scheduler/values.yaml @@ -112,6 +112,11 @@ binder: ports: metricsPort: 8080 affinity: {} + # PDB is created only when binder replicas > 1 (operator.replicaCount / binder.replicas). + # For HA: set operator.replicaCount: 2 (or override binder.replicas in Config). + podDisruptionBudget: + enabled: true + maxUnavailable: 1 # plugins allows overriding binder plugin configuration. # Built-in plugins can be disabled, reordered, or have their arguments changed. # Default plugins and priorities: volumebinding=300, dynamicresources=200, gpusharing=100. diff --git a/pkg/operator/operands/binder/binder.go b/pkg/operator/operands/binder/binder.go index fe189d441..d2067851d 100644 --- a/pkg/operator/operands/binder/binder.go +++ b/pkg/operator/operands/binder/binder.go @@ -36,6 +36,7 @@ func (b *Binder) DesiredState( objects := []client.Object{} for _, resourceFunc := range []resourceForKAIConfig{ b.deploymentForKAIConfig, + b.podDisruptionBudgetForKAIConfig, b.serviceAccountForKAIConfig, b.serviceForKAIConfig, resourceReservationServiceAccount, diff --git a/pkg/operator/operands/binder/binder_test.go b/pkg/operator/operands/binder/binder_test.go index a7f4633d5..4c21eae3d 100644 --- a/pkg/operator/operands/binder/binder_test.go +++ b/pkg/operator/operands/binder/binder_test.go @@ -19,11 +19,14 @@ import ( kaiv1 "github.com/kai-scheduler/KAI-scheduler/pkg/apis/kai/v1" kaiv1binder "github.com/kai-scheduler/KAI-scheduler/pkg/apis/kai/v1/binder" + "github.com/kai-scheduler/KAI-scheduler/pkg/apis/kai/v1/common" binderplugins "github.com/kai-scheduler/KAI-scheduler/pkg/binder/plugins" + "github.com/kai-scheduler/KAI-scheduler/pkg/common/constants" "github.com/kai-scheduler/KAI-scheduler/pkg/operator/operands/common/test_utils" appsv1 "k8s.io/api/apps/v1" v1 "k8s.io/api/core/v1" + policyv1 "k8s.io/api/policy/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" @@ -328,6 +331,133 @@ var _ = Describe("Binder", func() { Expect(newReservationSA.ImagePullSecrets).To(ContainElement(v1.LocalObjectReference{Name: "test-secret"})) }) }) + + Context("PodDisruptionBudget", func() { + It("includes PDB when HA and enabled with matching deployment selector", func(ctx context.Context) { + kaiConfig.Spec.Binder.Replicas = ptr.To(int32(2)) + kaiConfig.Spec.Binder.Service.PodDisruptionBudget = &common.PodDisruptionBudget{ + Enabled: ptr.To(true), + MaxUnavailable: ptr.To(int32(1)), + } + + objects, err := b.DesiredState(ctx, fakeKubeClient, kaiConfig) + Expect(err).To(BeNil()) + + pdbs := test_utils.FindTypesInObjects[*policyv1.PodDisruptionBudget](objects) + Expect(pdbs).To(HaveLen(1)) + Expect(pdbs[0].Name).To(Equal(defaultResourceName)) + Expect(pdbs[0].Namespace).To(Equal(constants.DefaultKAINamespace)) + Expect(pdbs[0].Spec.MaxUnavailable).NotTo(BeNil()) + Expect(pdbs[0].Spec.MaxUnavailable.IntVal).To(Equal(int32(1))) + Expect(pdbs[0].Spec.Selector.MatchLabels["app"]).To(Equal(defaultResourceName)) + + deploymentT := test_utils.FindTypeInObjects[*appsv1.Deployment](objects) + Expect(deploymentT).NotTo(BeNil()) + Expect(pdbs[0].Spec.Selector.MatchLabels["app"]).To(Equal((*deploymentT).Spec.Template.Labels["app"])) + }) + + It("uses custom maxUnavailable", func(ctx context.Context) { + b.BaseResourceName = defaultResourceName + kaiConfig.Spec.Binder.Replicas = ptr.To(int32(2)) + kaiConfig.Spec.Binder.Service.PodDisruptionBudget = &common.PodDisruptionBudget{ + Enabled: ptr.To(true), + MaxUnavailable: ptr.To(int32(2)), + } + + objects, err := b.podDisruptionBudgetForKAIConfig(ctx, fakeKubeClient, kaiConfig) + Expect(err).To(BeNil()) + Expect(objects).To(HaveLen(1)) + + pdb := objects[0].(*policyv1.PodDisruptionBudget) + Expect(pdb.Spec.MaxUnavailable).NotTo(BeNil()) + Expect(pdb.Spec.MaxUnavailable.IntVal).To(Equal(int32(2))) + }) + + It("preserves resourceVersion from an existing PDB", func(ctx context.Context) { + existing := &policyv1.PodDisruptionBudget{ + ObjectMeta: metav1.ObjectMeta{ + Name: defaultResourceName, + Namespace: constants.DefaultKAINamespace, + ResourceVersion: "42", + Labels: map[string]string{ + "app": defaultResourceName, + }, + }, + } + fakeKubeClient = fake.NewClientBuilder().WithObjects(existing).Build() + b.BaseResourceName = defaultResourceName + + kaiConfig.Spec.Binder.Replicas = ptr.To(int32(2)) + kaiConfig.Spec.Binder.Service.PodDisruptionBudget = &common.PodDisruptionBudget{ + Enabled: ptr.To(true), + MaxUnavailable: ptr.To(int32(1)), + } + + objects, err := b.podDisruptionBudgetForKAIConfig(ctx, fakeKubeClient, kaiConfig) + Expect(err).To(BeNil()) + Expect(objects).To(HaveLen(1)) + + pdb := objects[0].(*policyv1.PodDisruptionBudget) + Expect(pdb.ResourceVersion).To(Equal("42")) + Expect(pdb.Spec.MaxUnavailable).NotTo(BeNil()) + Expect(pdb.Spec.MaxUnavailable.IntVal).To(Equal(int32(1))) + }) + + It("omits PDB when HA but disabled", func(ctx context.Context) { + kaiConfig.Spec.Binder.Replicas = ptr.To(int32(2)) + kaiConfig.Spec.Binder.Service.PodDisruptionBudget = &common.PodDisruptionBudget{ + Enabled: ptr.To(false), + } + + objects, err := b.DesiredState(ctx, fakeKubeClient, kaiConfig) + Expect(err).To(BeNil()) + + for _, obj := range objects { + Expect(obj).NotTo(BeAssignableToTypeOf(&policyv1.PodDisruptionBudget{})) + } + }) + + It("omits PDB when single replica even if enabled", func(ctx context.Context) { + kaiConfig.Spec.Binder.Replicas = ptr.To(int32(1)) + kaiConfig.Spec.Binder.Service.PodDisruptionBudget = &common.PodDisruptionBudget{ + Enabled: ptr.To(true), + } + + objects, err := b.DesiredState(ctx, fakeKubeClient, kaiConfig) + Expect(err).To(BeNil()) + + for _, obj := range objects { + Expect(obj).NotTo(BeAssignableToTypeOf(&policyv1.PodDisruptionBudget{})) + } + }) + + It("omits PDB when PDB config is missing and defaults apply", func(ctx context.Context) { + kaiConfig.Spec.Binder.Replicas = ptr.To(int32(2)) + kaiConfig.Spec.Binder.Service.PodDisruptionBudget = nil + kaiConfig.Spec.Binder.Service.SetDefaultsWhereNeeded("") + + objects, err := b.DesiredState(ctx, fakeKubeClient, kaiConfig) + Expect(err).To(BeNil()) + Expect(kaiConfig.Spec.Binder.Service.PodDisruptionBudget).NotTo(BeNil()) + Expect(*kaiConfig.Spec.Binder.Service.PodDisruptionBudget.Enabled).To(BeFalse()) + + for _, obj := range objects { + Expect(obj).NotTo(BeAssignableToTypeOf(&policyv1.PodDisruptionBudget{})) + } + }) + + It("returns empty desired state when binder is disabled", func(ctx context.Context) { + kaiConfig.Spec.Binder.Service.Enabled = ptr.To(false) + kaiConfig.Spec.Binder.Replicas = ptr.To(int32(2)) + kaiConfig.Spec.Binder.Service.PodDisruptionBudget = &common.PodDisruptionBudget{ + Enabled: ptr.To(true), + } + + objects, err := b.DesiredState(ctx, fakeKubeClient, kaiConfig) + Expect(err).To(BeNil()) + Expect(objects).To(BeEmpty()) + }) + }) }) }) diff --git a/pkg/operator/operands/binder/resources.go b/pkg/operator/operands/binder/resources.go index 028d506df..8d29a09fc 100644 --- a/pkg/operator/operands/binder/resources.go +++ b/pkg/operator/operands/binder/resources.go @@ -81,6 +81,28 @@ func (b *Binder) serviceAccountForKAIConfig( return []client.Object{sa}, err } +func (b *Binder) podDisruptionBudgetForKAIConfig( + ctx context.Context, runtimeClient client.Reader, kaiConfig *kaiv1.Config, +) ([]client.Object, error) { + config := kaiConfig.Spec.Binder + pdbObj, err := common.PodDisruptionBudgetForKAIConfig( + ctx, + runtimeClient, + kaiConfig.Spec.Namespace, + b.BaseResourceName, + config.Replicas, + config.Service, + ) + if err != nil { + return nil, err + } + if pdbObj == nil { + return nil, nil + } + + return []client.Object{pdbObj}, nil +} + func (b *Binder) serviceForKAIConfig( ctx context.Context, runtimeClient client.Reader, kaiConfig *kaiv1.Config, ) ([]client.Object, error) { diff --git a/pkg/operator/operands/common/common.go b/pkg/operator/operands/common/common.go index b31da9055..02b73b929 100644 --- a/pkg/operator/operands/common/common.go +++ b/pkg/operator/operands/common/common.go @@ -38,6 +38,7 @@ var PodDisruptionBudgetImplementedServices = map[string]struct{}{ "admission": {}, "scheduler": {}, "pod-grouper": {}, + "binder": {}, } func PodDisruptionBudgetImplemented(serviceName string) bool { diff --git a/pkg/operator/operands/common/common_test.go b/pkg/operator/operands/common/common_test.go index f0b9e1ba5..0f24db887 100644 --- a/pkg/operator/operands/common/common_test.go +++ b/pkg/operator/operands/common/common_test.go @@ -576,10 +576,11 @@ var _ = Describe("PodDisruptionBudgetForKAIConfig", func() { var _ = Describe("PodDisruptionBudgetImplementedServices", func() { It("only lists operands with operator-side PDB creation", func() { - Expect(PodDisruptionBudgetImplementedServices).To(HaveLen(3)) + Expect(PodDisruptionBudgetImplementedServices).To(HaveLen(4)) Expect(PodDisruptionBudgetImplemented("admission")).To(BeTrue()) Expect(PodDisruptionBudgetImplemented("scheduler")).To(BeTrue()) Expect(PodDisruptionBudgetImplemented("pod-grouper")).To(BeTrue()) - Expect(PodDisruptionBudgetImplemented("binder")).To(BeFalse()) + Expect(PodDisruptionBudgetImplemented("binder")).To(BeTrue()) + Expect(PodDisruptionBudgetImplemented("queue-controller")).To(BeFalse()) }) })