diff --git a/api/v1/installation_types.go b/api/v1/installation_types.go index b64227400d..b4f124b3ce 100644 --- a/api/v1/installation_types.go +++ b/api/v1/installation_types.go @@ -201,6 +201,12 @@ type InstallationSpec struct { // +optional TyphaDeployment *TyphaDeployment `json:"typhaDeployment,omitempty"` + // TyphaPodDisruptionBudget configures the PodDisruptionBudget for the calico-typha + // Deployment. Fields left unset fall back to the operator's defaults. The PDB's + // selector is managed by the operator and cannot be overridden. + // +optional + TyphaPodDisruptionBudget *PodDisruptionBudgetOverride `json:"typhaPodDisruptionBudget,omitempty"` + // Deprecated. The CalicoWindowsUpgradeDaemonSet is deprecated and will be removed from the API in the future. // CalicoWindowsUpgradeDaemonSet configures the calico-windows-upgrade DaemonSet. CalicoWindowsUpgradeDaemonSet *CalicoWindowsUpgradeDaemonSet `json:"calicoWindowsUpgradeDaemonSet,omitempty"` diff --git a/api/v1/poddisruptionbudget_types.go b/api/v1/poddisruptionbudget_types.go new file mode 100644 index 0000000000..6ea75db972 --- /dev/null +++ b/api/v1/poddisruptionbudget_types.go @@ -0,0 +1,56 @@ +// Copyright (c) 2026 Tigera, Inc. All rights reserved. +// +// 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 v1 + +import ( + policyv1 "k8s.io/api/policy/v1" + "k8s.io/apimachinery/pkg/util/intstr" +) + +// PodDisruptionBudgetOverride allows overriding select fields on an operator-managed +// PodDisruptionBudget. The PDB's selector, name, and namespace are managed by the +// operator and cannot be overridden. +type PodDisruptionBudgetOverride struct { + // Metadata is a subset of a Kubernetes object's metadata that is added to the PodDisruptionBudget. + // +optional + Metadata *Metadata `json:"metadata,omitempty"` + + // Spec is the specification of the PodDisruptionBudget. + // +optional + Spec *PodDisruptionBudgetOverrideSpec `json:"spec,omitempty"` +} + +// PodDisruptionBudgetOverrideSpec defines the desired state of an operator-managed PodDisruptionBudget. +// +kubebuilder:validation:XValidation:rule="!(has(self.minAvailable) && has(self.maxUnavailable))",message="minAvailable and maxUnavailable are mutually exclusive" +type PodDisruptionBudgetOverrideSpec struct { + // MinAvailable is the minimum number of pods (as an integer or percentage) that + // must remain available during a disruption. Mutually exclusive with MaxUnavailable. + // +optional + MinAvailable *intstr.IntOrString `json:"minAvailable,omitempty"` + + // MaxUnavailable is the maximum number of pods (as an integer or percentage) that + // can be unavailable during a disruption. Mutually exclusive with MinAvailable. + // If neither MinAvailable nor MaxUnavailable is set, the operator applies its + // default (MaxUnavailable=1 for calico-typha). + // +optional + MaxUnavailable *intstr.IntOrString `json:"maxUnavailable,omitempty"` + + // UnhealthyPodEvictionPolicy defines when unhealthy pods should be considered + // for eviction. Defaults to IfHealthyBudget (the Kubernetes default) when unset. + // See https://kubernetes.io/docs/tasks/run-application/configure-pdb/#unhealthy-pod-eviction-policy. + // +kubebuilder:validation:Enum=IfHealthyBudget;AlwaysAllow + // +optional + UnhealthyPodEvictionPolicy *policyv1.UnhealthyPodEvictionPolicyType `json:"unhealthyPodEvictionPolicy,omitempty"` +} diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go index 0bade7102c..1cbaa85413 100644 --- a/api/v1/zz_generated.deepcopy.go +++ b/api/v1/zz_generated.deepcopy.go @@ -25,8 +25,10 @@ import ( "github.com/tigera/api/pkg/lib/numorstring" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" + policyv1 "k8s.io/api/policy/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/intstr" ) // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. @@ -6015,6 +6017,11 @@ func (in *InstallationSpec) DeepCopyInto(out *InstallationSpec) { *out = new(TyphaDeployment) (*in).DeepCopyInto(*out) } + if in.TyphaPodDisruptionBudget != nil { + in, out := &in.TyphaPodDisruptionBudget, &out.TyphaPodDisruptionBudget + *out = new(PodDisruptionBudgetOverride) + (*in).DeepCopyInto(*out) + } if in.CalicoWindowsUpgradeDaemonSet != nil { in, out := &in.CalicoWindowsUpgradeDaemonSet, &out.CalicoWindowsUpgradeDaemonSet *out = new(CalicoWindowsUpgradeDaemonSet) @@ -8500,6 +8507,61 @@ func (in *PathMatch) DeepCopy() *PathMatch { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PodDisruptionBudgetOverride) DeepCopyInto(out *PodDisruptionBudgetOverride) { + *out = *in + if in.Metadata != nil { + in, out := &in.Metadata, &out.Metadata + *out = new(Metadata) + (*in).DeepCopyInto(*out) + } + if in.Spec != nil { + in, out := &in.Spec, &out.Spec + *out = new(PodDisruptionBudgetOverrideSpec) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PodDisruptionBudgetOverride. +func (in *PodDisruptionBudgetOverride) DeepCopy() *PodDisruptionBudgetOverride { + if in == nil { + return nil + } + out := new(PodDisruptionBudgetOverride) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PodDisruptionBudgetOverrideSpec) DeepCopyInto(out *PodDisruptionBudgetOverrideSpec) { + *out = *in + if in.MinAvailable != nil { + in, out := &in.MinAvailable, &out.MinAvailable + *out = new(intstr.IntOrString) + **out = **in + } + if in.MaxUnavailable != nil { + in, out := &in.MaxUnavailable, &out.MaxUnavailable + *out = new(intstr.IntOrString) + **out = **in + } + if in.UnhealthyPodEvictionPolicy != nil { + in, out := &in.UnhealthyPodEvictionPolicy, &out.UnhealthyPodEvictionPolicy + *out = new(policyv1.UnhealthyPodEvictionPolicyType) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PodDisruptionBudgetOverrideSpec. +func (in *PodDisruptionBudgetOverrideSpec) DeepCopy() *PodDisruptionBudgetOverrideSpec { + if in == nil { + return nil + } + out := new(PodDisruptionBudgetOverrideSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PolicyRecommendation) DeepCopyInto(out *PolicyRecommendation) { *out = *in diff --git a/pkg/controller/utils/merge.go b/pkg/controller/utils/merge.go index 03b78a6856..1c372ade80 100644 --- a/pkg/controller/utils/merge.go +++ b/pkg/controller/utils/merge.go @@ -185,6 +185,11 @@ func OverrideInstallationSpec(cfg, override operatorv1.InstallationSpec) operato inst.TyphaDeployment = mergeTyphaDeployment(inst.TyphaDeployment, override.TyphaDeployment) } + switch compareFields(inst.TyphaPodDisruptionBudget, override.TyphaPodDisruptionBudget) { + case BOnlySet, Different: + inst.TyphaPodDisruptionBudget = override.TyphaPodDisruptionBudget.DeepCopy() + } + switch compareFields(inst.CalicoWindowsUpgradeDaemonSet, override.CalicoWindowsUpgradeDaemonSet) { case BOnlySet: inst.CalicoWindowsUpgradeDaemonSet = override.CalicoWindowsUpgradeDaemonSet.DeepCopy() diff --git a/pkg/imports/crds/operator/operator.tigera.io_installations.yaml b/pkg/imports/crds/operator/operator.tigera.io_installations.yaml index 3e6fa9d2ea..778a9fb014 100644 --- a/pkg/imports/crds/operator/operator.tigera.io_installations.yaml +++ b/pkg/imports/crds/operator/operator.tigera.io_installations.yaml @@ -9177,6 +9177,69 @@ spec: prometheus metrics on. By default, metrics are not enabled. format: int32 type: integer + typhaPodDisruptionBudget: + description: |- + TyphaPodDisruptionBudget configures the PodDisruptionBudget for the calico-typha + Deployment. Fields left unset fall back to the operator's defaults. The PDB's + selector is managed by the operator and cannot be overridden. + properties: + metadata: + description: + Metadata is a subset of a Kubernetes object's metadata + that is added to the PodDisruptionBudget. + properties: + annotations: + additionalProperties: + type: string + description: |- + Annotations is a map of arbitrary non-identifying metadata. Each of these + key/value pairs are added to the object's annotations provided the key does not + already exist in the object's annotations. + type: object + labels: + additionalProperties: + type: string + description: |- + Labels is a map of string keys and values that may match replicaset and + service selectors. Each of these key/value pairs are added to the + object's labels provided the key does not already exist in the object's labels. + type: object + type: object + spec: + description: Spec is the specification of the PodDisruptionBudget. + properties: + maxUnavailable: + anyOf: + - type: integer + - type: string + description: |- + MaxUnavailable is the maximum number of pods (as an integer or percentage) that + can be unavailable during a disruption. Mutually exclusive with MinAvailable. + If neither MinAvailable nor MaxUnavailable is set, the operator applies its + default (MaxUnavailable=1 for calico-typha). + x-kubernetes-int-or-string: true + minAvailable: + anyOf: + - type: integer + - type: string + description: |- + MinAvailable is the minimum number of pods (as an integer or percentage) that + must remain available during a disruption. Mutually exclusive with MaxUnavailable. + x-kubernetes-int-or-string: true + unhealthyPodEvictionPolicy: + description: |- + UnhealthyPodEvictionPolicy defines when unhealthy pods should be considered + for eviction. Defaults to IfHealthyBudget (the Kubernetes default) when unset. + See https://kubernetes.io/docs/tasks/run-application/configure-pdb/#unhealthy-pod-eviction-policy. + enum: + - IfHealthyBudget + - AlwaysAllow + type: string + type: object + x-kubernetes-validations: + - message: minAvailable and maxUnavailable are mutually exclusive + rule: "!(has(self.minAvailable) && has(self.maxUnavailable))" + type: object variant: description: |- Variant is the product to install - one of Calico or CalicoEnterprise. @@ -18529,6 +18592,69 @@ spec: serves prometheus metrics on. By default, metrics are not enabled. format: int32 type: integer + typhaPodDisruptionBudget: + description: |- + TyphaPodDisruptionBudget configures the PodDisruptionBudget for the calico-typha + Deployment. Fields left unset fall back to the operator's defaults. The PDB's + selector is managed by the operator and cannot be overridden. + properties: + metadata: + description: + Metadata is a subset of a Kubernetes object's + metadata that is added to the PodDisruptionBudget. + properties: + annotations: + additionalProperties: + type: string + description: |- + Annotations is a map of arbitrary non-identifying metadata. Each of these + key/value pairs are added to the object's annotations provided the key does not + already exist in the object's annotations. + type: object + labels: + additionalProperties: + type: string + description: |- + Labels is a map of string keys and values that may match replicaset and + service selectors. Each of these key/value pairs are added to the + object's labels provided the key does not already exist in the object's labels. + type: object + type: object + spec: + description: Spec is the specification of the PodDisruptionBudget. + properties: + maxUnavailable: + anyOf: + - type: integer + - type: string + description: |- + MaxUnavailable is the maximum number of pods (as an integer or percentage) that + can be unavailable during a disruption. Mutually exclusive with MinAvailable. + If neither MinAvailable nor MaxUnavailable is set, the operator applies its + default (MaxUnavailable=1 for calico-typha). + x-kubernetes-int-or-string: true + minAvailable: + anyOf: + - type: integer + - type: string + description: |- + MinAvailable is the minimum number of pods (as an integer or percentage) that + must remain available during a disruption. Mutually exclusive with MaxUnavailable. + x-kubernetes-int-or-string: true + unhealthyPodEvictionPolicy: + description: |- + UnhealthyPodEvictionPolicy defines when unhealthy pods should be considered + for eviction. Defaults to IfHealthyBudget (the Kubernetes default) when unset. + See https://kubernetes.io/docs/tasks/run-application/configure-pdb/#unhealthy-pod-eviction-policy. + enum: + - IfHealthyBudget + - AlwaysAllow + type: string + type: object + x-kubernetes-validations: + - message: minAvailable and maxUnavailable are mutually exclusive + rule: "!(has(self.minAvailable) && has(self.maxUnavailable))" + type: object variant: description: |- Variant is the product to install - one of Calico or CalicoEnterprise. diff --git a/pkg/render/common/components/components.go b/pkg/render/common/components/components.go index e45b4c53fd..93c3445e61 100644 --- a/pkg/render/common/components/components.go +++ b/pkg/render/common/components/components.go @@ -28,6 +28,7 @@ import ( appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" + policyv1 "k8s.io/api/policy/v1" rbacv1 "k8s.io/api/rbac/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -540,6 +541,43 @@ func ApplyJobOverrides(job *batchv1.Job, overrides any) { job.Spec.Template = *r.podTemplateSpec } +// ApplyPodDisruptionBudgetOverrides applies the overrides to the given PodDisruptionBudget. +// Overrides that are nil leave the corresponding field on the PDB untouched, preserving +// the operator's default. Setting Spec.MinAvailable clears Spec.MaxUnavailable and vice +// versa (the PDB API mandates these are mutually exclusive). The PDB's selector is never +// modified. Labels and annotations from Metadata are merged into the PDB's existing +// labels and annotations. +func ApplyPodDisruptionBudgetOverrides(pdb *policyv1.PodDisruptionBudget, overrides *operator.PodDisruptionBudgetOverride) { + if pdb == nil || overrides == nil { + return + } + if md := overrides.Metadata; md != nil { + if len(md.Labels) > 0 { + pdb.Labels = common.MapExistsOrInitialize(pdb.Labels) + common.MergeMaps(md.Labels, pdb.Labels) + } + if len(md.Annotations) > 0 { + pdb.Annotations = common.MapExistsOrInitialize(pdb.Annotations) + common.MergeMaps(md.Annotations, pdb.Annotations) + } + } + spec := overrides.Spec + if spec == nil { + return + } + if spec.MinAvailable != nil { + pdb.Spec.MinAvailable = spec.MinAvailable + pdb.Spec.MaxUnavailable = nil + } + if spec.MaxUnavailable != nil { + pdb.Spec.MaxUnavailable = spec.MaxUnavailable + pdb.Spec.MinAvailable = nil + } + if spec.UnhealthyPodEvictionPolicy != nil { + pdb.Spec.UnhealthyPodEvictionPolicy = spec.UnhealthyPodEvictionPolicy + } +} + // ApplyStatefulSetOverrides applies the overrides to the given DaemonSet. // Note: overrides must not be nil pointer. func ApplyStatefulSetOverrides(s *appsv1.StatefulSet, overrides any) { diff --git a/pkg/render/common/components/components_test.go b/pkg/render/common/components/components_test.go index 08adc81231..235c0f365f 100644 --- a/pkg/render/common/components/components_test.go +++ b/pkg/render/common/components/components_test.go @@ -23,7 +23,9 @@ import ( v1 "github.com/tigera/operator/api/v1" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" + policyv1 "k8s.io/api/policy/v1" "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/intstr" "k8s.io/utils/ptr" @@ -1262,6 +1264,119 @@ var _ = Describe("Common components render tests", func() { }) }) +var _ = Describe("ApplyPodDisruptionBudgetOverrides", func() { + var pdb *policyv1.PodDisruptionBudget + + BeforeEach(func() { + pdb = &policyv1.PodDisruptionBudget{ + ObjectMeta: metav1.ObjectMeta{Name: "calico-typha", Namespace: "calico-system"}, + Spec: policyv1.PodDisruptionBudgetSpec{ + MaxUnavailable: ptr.To(intstr.FromInt(1)), + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"k8s-app": "calico-typha"}, + }, + }, + } + }) + + It("does nothing when overrides is nil", func() { + ApplyPodDisruptionBudgetOverrides(pdb, nil) + Expect(pdb.Spec.MaxUnavailable).To(Equal(ptr.To(intstr.FromInt(1)))) + Expect(pdb.Spec.MinAvailable).To(BeNil()) + Expect(pdb.Spec.UnhealthyPodEvictionPolicy).To(BeNil()) + }) + + It("does not panic on nil PDB", func() { + Expect(func() { + ApplyPodDisruptionBudgetOverrides(nil, &v1.PodDisruptionBudgetOverride{ + Spec: &v1.PodDisruptionBudgetOverrideSpec{ + MinAvailable: ptr.To(intstr.FromInt(2)), + }, + }) + }).ToNot(Panic()) + }) + + It("applies only UnhealthyPodEvictionPolicy and preserves default MaxUnavailable", func() { + policy := policyv1.AlwaysAllow + ApplyPodDisruptionBudgetOverrides(pdb, &v1.PodDisruptionBudgetOverride{ + Spec: &v1.PodDisruptionBudgetOverrideSpec{ + UnhealthyPodEvictionPolicy: &policy, + }, + }) + Expect(pdb.Spec.MaxUnavailable).To(Equal(ptr.To(intstr.FromInt(1)))) + Expect(pdb.Spec.MinAvailable).To(BeNil()) + Expect(*pdb.Spec.UnhealthyPodEvictionPolicy).To(Equal(policyv1.AlwaysAllow)) + }) + + It("clears MaxUnavailable when MinAvailable is set", func() { + ApplyPodDisruptionBudgetOverrides(pdb, &v1.PodDisruptionBudgetOverride{ + Spec: &v1.PodDisruptionBudgetOverrideSpec{ + MinAvailable: ptr.To(intstr.FromInt(2)), + }, + }) + Expect(pdb.Spec.MinAvailable).To(Equal(ptr.To(intstr.FromInt(2)))) + Expect(pdb.Spec.MaxUnavailable).To(BeNil()) + }) + + It("clears MinAvailable when MaxUnavailable is set to a percentage", func() { + pdb.Spec.MaxUnavailable = nil + pdb.Spec.MinAvailable = ptr.To(intstr.FromInt(3)) + ApplyPodDisruptionBudgetOverrides(pdb, &v1.PodDisruptionBudgetOverride{ + Spec: &v1.PodDisruptionBudgetOverrideSpec{ + MaxUnavailable: ptr.To(intstr.FromString("50%")), + }, + }) + Expect(pdb.Spec.MaxUnavailable).To(Equal(ptr.To(intstr.FromString("50%")))) + Expect(pdb.Spec.MinAvailable).To(BeNil()) + }) + + It("applies MinAvailable + UnhealthyPodEvictionPolicy together", func() { + policy := policyv1.AlwaysAllow + ApplyPodDisruptionBudgetOverrides(pdb, &v1.PodDisruptionBudgetOverride{ + Spec: &v1.PodDisruptionBudgetOverrideSpec{ + MinAvailable: ptr.To(intstr.FromInt(2)), + UnhealthyPodEvictionPolicy: &policy, + }, + }) + Expect(pdb.Spec.MinAvailable).To(Equal(ptr.To(intstr.FromInt(2)))) + Expect(pdb.Spec.MaxUnavailable).To(BeNil()) + Expect(*pdb.Spec.UnhealthyPodEvictionPolicy).To(Equal(policyv1.AlwaysAllow)) + }) + + It("never mutates the selector", func() { + original := pdb.Spec.Selector.DeepCopy() + ApplyPodDisruptionBudgetOverrides(pdb, &v1.PodDisruptionBudgetOverride{ + Spec: &v1.PodDisruptionBudgetOverrideSpec{ + MinAvailable: ptr.To(intstr.FromInt(2)), + }, + }) + Expect(pdb.Spec.Selector).To(Equal(original)) + }) + + It("merges metadata labels and annotations onto the PDB", func() { + pdb.Labels = map[string]string{"existing": "label"} + pdb.Annotations = map[string]string{"existing": "ann"} + ApplyPodDisruptionBudgetOverrides(pdb, &v1.PodDisruptionBudgetOverride{ + Metadata: &v1.Metadata{ + Labels: map[string]string{"new": "label"}, + Annotations: map[string]string{"new": "ann"}, + }, + }) + Expect(pdb.Labels).To(Equal(map[string]string{"existing": "label", "new": "label"})) + Expect(pdb.Annotations).To(Equal(map[string]string{"existing": "ann", "new": "ann"})) + }) + + It("treats a nil Spec as no spec override", func() { + ApplyPodDisruptionBudgetOverrides(pdb, &v1.PodDisruptionBudgetOverride{ + Metadata: &v1.Metadata{Labels: map[string]string{"a": "b"}}, + }) + Expect(pdb.Spec.MaxUnavailable).To(Equal(ptr.To(intstr.FromInt(1)))) + Expect(pdb.Spec.MinAvailable).To(BeNil()) + Expect(pdb.Spec.UnhealthyPodEvictionPolicy).To(BeNil()) + Expect(pdb.Labels).To(HaveKeyWithValue("a", "b")) + }) +}) + func addContainer(cs []corev1.Container) []corev1.Container { // Add another container and rename them to "not-zero1" and "not-zero2". containers := make([]corev1.Container, 0, 2) diff --git a/pkg/render/typha.go b/pkg/render/typha.go index 56d1c4d80c..5b4ecf5acb 100644 --- a/pkg/render/typha.go +++ b/pkg/render/typha.go @@ -119,11 +119,15 @@ func (c *typhaComponent) SupportedOSType() rmeta.OSType { } func (c *typhaComponent) Objects() ([]client.Object, []client.Object) { + pdb := c.typhaPodDisruptionBudget() + if overrides := c.cfg.Installation.TyphaPodDisruptionBudget; overrides != nil { + rcomp.ApplyPodDisruptionBudgetOverrides(pdb, overrides) + } objs := []client.Object{ c.typhaServiceAccount(), c.typhaRole(), c.typhaRoleBinding(), - c.typhaPodDisruptionBudget(), + pdb, } objs = append(objs, c.typhaServices()...) diff --git a/pkg/render/typha_test.go b/pkg/render/typha_test.go index 67151e91f1..70335dd116 100644 --- a/pkg/render/typha_test.go +++ b/pkg/render/typha_test.go @@ -23,6 +23,7 @@ import ( appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" + policyv1 "k8s.io/api/policy/v1" rbacv1 "k8s.io/api/rbac/v1" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -794,4 +795,71 @@ var _ = Describe("Typha rendering tests", func() { Expect(d.Spec.Template.Spec.Tolerations).To(ConsistOf(tol)) }) }) + + Describe("PodDisruptionBudget", func() { + getPDB := func() *policyv1.PodDisruptionBudget { + component := render.Typha(&cfg) + resources, _ := component.Objects() + res := rtest.GetResource(resources, "calico-typha", "calico-system", "policy", "v1", "PodDisruptionBudget") + Expect(res).ToNot(BeNil()) + return res.(*policyv1.PodDisruptionBudget) + } + + It("renders the default PDB when no override is set", func() { + pdb := getPDB() + Expect(pdb.Spec.MaxUnavailable).To(Equal(ptr.To(intstr.FromInt(1)))) + Expect(pdb.Spec.MinAvailable).To(BeNil()) + Expect(pdb.Spec.UnhealthyPodEvictionPolicy).To(BeNil()) + Expect(pdb.Spec.Selector).To(Equal(&metav1.LabelSelector{ + MatchLabels: map[string]string{"k8s-app": "calico-typha"}, + })) + }) + + It("applies UnhealthyPodEvictionPolicy override and preserves default MaxUnavailable", func() { + policy := policyv1.AlwaysAllow + cfg.Installation.TyphaPodDisruptionBudget = &operatorv1.PodDisruptionBudgetOverride{ + Spec: &operatorv1.PodDisruptionBudgetOverrideSpec{ + UnhealthyPodEvictionPolicy: &policy, + }, + } + pdb := getPDB() + Expect(pdb.Spec.MaxUnavailable).To(Equal(ptr.To(intstr.FromInt(1)))) + Expect(pdb.Spec.MinAvailable).To(BeNil()) + Expect(*pdb.Spec.UnhealthyPodEvictionPolicy).To(Equal(policyv1.AlwaysAllow)) + }) + + It("applies MinAvailable override and clears MaxUnavailable", func() { + cfg.Installation.TyphaPodDisruptionBudget = &operatorv1.PodDisruptionBudgetOverride{ + Spec: &operatorv1.PodDisruptionBudgetOverrideSpec{ + MinAvailable: ptr.To(intstr.FromInt(2)), + }, + } + pdb := getPDB() + Expect(pdb.Spec.MinAvailable).To(Equal(ptr.To(intstr.FromInt(2)))) + Expect(pdb.Spec.MaxUnavailable).To(BeNil()) + }) + + It("applies MaxUnavailable percentage override", func() { + cfg.Installation.TyphaPodDisruptionBudget = &operatorv1.PodDisruptionBudgetOverride{ + Spec: &operatorv1.PodDisruptionBudgetOverrideSpec{ + MaxUnavailable: ptr.To(intstr.FromString("50%")), + }, + } + pdb := getPDB() + Expect(pdb.Spec.MaxUnavailable).To(Equal(ptr.To(intstr.FromString("50%")))) + Expect(pdb.Spec.MinAvailable).To(BeNil()) + }) + + It("applies metadata labels and annotations", func() { + cfg.Installation.TyphaPodDisruptionBudget = &operatorv1.PodDisruptionBudgetOverride{ + Metadata: &operatorv1.Metadata{ + Labels: map[string]string{"custom": "label"}, + Annotations: map[string]string{"custom": "ann"}, + }, + } + pdb := getPDB() + Expect(pdb.Labels).To(HaveKeyWithValue("custom", "label")) + Expect(pdb.Annotations).To(HaveKeyWithValue("custom", "ann")) + }) + }) })