From 792da571e5bf902c5d0a810345e87c4f8683c9d6 Mon Sep 17 00:00:00 2001 From: Anthony Hausman Date: Thu, 6 Aug 2026 06:49:26 +0200 Subject: [PATCH] feat(helm): add optional PodDisruptionBudget for the controller Node drains and cluster upgrades can evict every controller replica at once. An opt-in PodDisruptionBudget lets operators keep a minimum number of replicas scheduled during voluntary disruptions. Disabled by default, so existing installs render an identical manifest set. The template rejects setting both minAvailable and maxUnavailable, or neither, because the API server accepts exactly one of them. Assisted-by: Claude Opus 5 Signed-off-by: Anthony Hausman --- helm/kmcp/README.md | 27 +++ helm/kmcp/templates/_helpers.tpl | 32 ++++ helm/kmcp/templates/poddisruptionbudget.yaml | 30 ++++ .../poddisruptionbudget_test.yaml.snap | 104 +++++++++++ helm/kmcp/tests/poddisruptionbudget_test.yaml | 167 ++++++++++++++++++ helm/kmcp/values.yaml | 23 +++ 6 files changed, 383 insertions(+) create mode 100644 helm/kmcp/templates/poddisruptionbudget.yaml create mode 100644 helm/kmcp/tests/__snapshot__/poddisruptionbudget_test.yaml.snap create mode 100644 helm/kmcp/tests/poddisruptionbudget_test.yaml diff --git a/helm/kmcp/README.md b/helm/kmcp/README.md index d2cb7bd..e13cc6f 100644 --- a/helm/kmcp/README.md +++ b/helm/kmcp/README.md @@ -84,6 +84,33 @@ The following table lists the configurable parameters of the KMCP chart and thei | `securityContext.allowPrivilegeEscalation` | Allow privilege escalation | `false` | | `securityContext.capabilities.drop` | Capabilities to drop | `["ALL"]` | +### Pod Disruption Budget + +Creates a `PodDisruptionBudget` for the controller pods so that voluntary disruptions (node drains, cluster upgrades) keep a minimum number of replicas running. Disabled by default. + +| Parameter | Description | Default | +|-----------|-------------|---------| +| `podDisruptionBudget.enabled` | Create a PodDisruptionBudget | `false` | +| `podDisruptionBudget.minAvailable` | Minimum available pods, as an integer or percentage string | `1` | +| `podDisruptionBudget.maxUnavailable` | Maximum unavailable pods, as an integer or percentage string | `""` | +| `podDisruptionBudget.unhealthyPodEvictionPolicy` | `IfHealthyBudget` or `AlwaysAllow` | `""` (omitted) | +| `podDisruptionBudget.annotations` | Extra annotations for the PodDisruptionBudget | `{}` | +| `podDisruptionBudget.labels` | Extra labels for the PodDisruptionBudget | `{}` | + +Set exactly one of `minAvailable` and `maxUnavailable`; the chart rejects a configuration that sets both or neither. + +```bash +helm install kmcp kmcp/kmcp \ + --set controller.replicaCount=2 \ + --set podDisruptionBudget.enabled=true \ + --set podDisruptionBudget.minAvailable=1 \ + --set podDisruptionBudget.maxUnavailable="" +``` + +**Note**: `minAvailable: 1` together with the default `controller.replicaCount: 1` blocks node drains, because evicting the only replica would breach the budget. Raise `controller.replicaCount` to at least 2 before enabling the budget. Leader election keeps a single controller active across the replicas. + +**Note**: The `policy/v1` API requires Kubernetes 1.21+, and `unhealthyPodEvictionPolicy` requires 1.27+ (beta) or 1.31+ (stable). + ### Service Configuration | Parameter | Description | Default | diff --git a/helm/kmcp/templates/_helpers.tpl b/helm/kmcp/templates/_helpers.tpl index e71ec96..475544e 100644 --- a/helm/kmcp/templates/_helpers.tpl +++ b/helm/kmcp/templates/_helpers.tpl @@ -84,6 +84,38 @@ Guards on the rbac block {{- end -}} {{- end -}} +{{/* +Report whether a PodDisruptionBudget field is set. +Outputs "true" when the value is neither nil nor an empty string, otherwise the +empty string. The integer 0 counts as set, so it is not silently dropped. +Usage: {{ eq (include "kmcp.pdb.isSet" .Values.podDisruptionBudget.minAvailable) "true" }} +*/}} +{{- define "kmcp.pdb.isSet" -}} +{{- if and (not (kindIs "invalid" .)) (ne (toString .) "") -}} +true +{{- end -}} +{{- end -}} + +{{/* +Guards on the podDisruptionBudget block +*/}} +{{- define "kmcp.pdb.validate" -}} +{{- $pdb := .Values.podDisruptionBudget -}} +{{- $hasMin := eq (include "kmcp.pdb.isSet" $pdb.minAvailable) "true" -}} +{{- $hasMax := eq (include "kmcp.pdb.isSet" $pdb.maxUnavailable) "true" -}} +{{- if and $hasMin $hasMax -}} +{{- fail "podDisruptionBudget.minAvailable and podDisruptionBudget.maxUnavailable are mutually exclusive. Set exactly one of them." -}} +{{- end -}} +{{- if and (not $hasMin) (not $hasMax) -}} +{{- fail "podDisruptionBudget.enabled is true but neither podDisruptionBudget.minAvailable nor podDisruptionBudget.maxUnavailable is set. Set exactly one of them." -}} +{{- end -}} +{{- if eq (include "kmcp.pdb.isSet" $pdb.unhealthyPodEvictionPolicy) "true" -}} +{{- if not (has $pdb.unhealthyPodEvictionPolicy (list "AlwaysAllow" "IfHealthyBudget")) -}} +{{- fail (printf "podDisruptionBudget.unhealthyPodEvictionPolicy must be either \"AlwaysAllow\" or \"IfHealthyBudget\", got %q" (toString $pdb.unhealthyPodEvictionPolicy)) -}} +{{- end -}} +{{- end -}} +{{- end -}} + {{/* Create controller manager container args */}} diff --git a/helm/kmcp/templates/poddisruptionbudget.yaml b/helm/kmcp/templates/poddisruptionbudget.yaml new file mode 100644 index 0000000..118d511 --- /dev/null +++ b/helm/kmcp/templates/poddisruptionbudget.yaml @@ -0,0 +1,30 @@ +{{- if .Values.podDisruptionBudget.enabled }} +{{- include "kmcp.pdb.validate" . }} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "kmcp.fullname" . }}-controller-manager + namespace: {{ include "kmcp.namespace" . }} + labels: + {{- include "kmcp.labels" . | nindent 4 }} + {{- with .Values.podDisruptionBudget.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .Values.podDisruptionBudget.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if eq (include "kmcp.pdb.isSet" .Values.podDisruptionBudget.minAvailable) "true" }} + minAvailable: {{ .Values.podDisruptionBudget.minAvailable }} + {{- end }} + {{- if eq (include "kmcp.pdb.isSet" .Values.podDisruptionBudget.maxUnavailable) "true" }} + maxUnavailable: {{ .Values.podDisruptionBudget.maxUnavailable }} + {{- end }} + {{- if eq (include "kmcp.pdb.isSet" .Values.podDisruptionBudget.unhealthyPodEvictionPolicy) "true" }} + unhealthyPodEvictionPolicy: {{ .Values.podDisruptionBudget.unhealthyPodEvictionPolicy }} + {{- end }} + selector: + matchLabels: + {{- include "kmcp.selectorLabels" . | nindent 6 }} +{{- end }} diff --git a/helm/kmcp/tests/__snapshot__/poddisruptionbudget_test.yaml.snap b/helm/kmcp/tests/__snapshot__/poddisruptionbudget_test.yaml.snap new file mode 100644 index 0000000..01fc273 --- /dev/null +++ b/helm/kmcp/tests/__snapshot__/poddisruptionbudget_test.yaml.snap @@ -0,0 +1,104 @@ +should accept a percentage for minAvailable: + 1: | + apiVersion: policy/v1 + kind: PodDisruptionBudget + metadata: + labels: + app.kubernetes.io/instance: RELEASE-NAME + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: kmcp + control-plane: controller-manager + helm.sh/chart: kmcp-1.0.0 + name: RELEASE-NAME-controller-manager + namespace: NAMESPACE + spec: + minAvailable: 50% + selector: + matchLabels: + app.kubernetes.io/instance: RELEASE-NAME + app.kubernetes.io/name: kmcp + control-plane: controller-manager +should create a pdb with maxUnavailable when enabled: + 1: | + apiVersion: policy/v1 + kind: PodDisruptionBudget + metadata: + labels: + app.kubernetes.io/instance: RELEASE-NAME + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: kmcp + control-plane: controller-manager + helm.sh/chart: kmcp-1.0.0 + name: RELEASE-NAME-controller-manager + namespace: NAMESPACE + spec: + maxUnavailable: 1 + selector: + matchLabels: + app.kubernetes.io/instance: RELEASE-NAME + app.kubernetes.io/name: kmcp + control-plane: controller-manager +should create a pdb with minAvailable when enabled: + 1: | + apiVersion: policy/v1 + kind: PodDisruptionBudget + metadata: + labels: + app.kubernetes.io/instance: RELEASE-NAME + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: kmcp + control-plane: controller-manager + helm.sh/chart: kmcp-1.0.0 + name: RELEASE-NAME-controller-manager + namespace: NAMESPACE + spec: + minAvailable: 1 + selector: + matchLabels: + app.kubernetes.io/instance: RELEASE-NAME + app.kubernetes.io/name: kmcp + control-plane: controller-manager +should render custom labels and annotations: + 1: | + apiVersion: policy/v1 + kind: PodDisruptionBudget + metadata: + annotations: + custom-annotation: annotation-value + labels: + app.kubernetes.io/instance: RELEASE-NAME + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: kmcp + control-plane: controller-manager + custom-label: custom-value + helm.sh/chart: kmcp-1.0.0 + name: RELEASE-NAME-controller-manager + namespace: NAMESPACE + spec: + minAvailable: 1 + selector: + matchLabels: + app.kubernetes.io/instance: RELEASE-NAME + app.kubernetes.io/name: kmcp + control-plane: controller-manager +should render unhealthyPodEvictionPolicy when set: + 1: | + apiVersion: policy/v1 + kind: PodDisruptionBudget + metadata: + labels: + app.kubernetes.io/instance: RELEASE-NAME + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: kmcp + control-plane: controller-manager + helm.sh/chart: kmcp-1.0.0 + name: RELEASE-NAME-controller-manager + namespace: NAMESPACE + spec: + minAvailable: 1 + selector: + matchLabels: + app.kubernetes.io/instance: RELEASE-NAME + app.kubernetes.io/name: kmcp + control-plane: controller-manager + unhealthyPodEvictionPolicy: AlwaysAllow diff --git a/helm/kmcp/tests/poddisruptionbudget_test.yaml b/helm/kmcp/tests/poddisruptionbudget_test.yaml new file mode 100644 index 0000000..5c29a19 --- /dev/null +++ b/helm/kmcp/tests/poddisruptionbudget_test.yaml @@ -0,0 +1,167 @@ +suite: Test poddisruptionbudget template +templates: + - poddisruptionbudget.yaml + +tests: + - it: should not create a pdb by default + template: poddisruptionbudget.yaml + asserts: + - hasDocuments: + count: 0 + + - it: should not create a pdb when disabled + template: poddisruptionbudget.yaml + set: + podDisruptionBudget.enabled: false + asserts: + - hasDocuments: + count: 0 + + - it: should create a pdb with minAvailable when enabled + template: poddisruptionbudget.yaml + set: + podDisruptionBudget.enabled: true + podDisruptionBudget.minAvailable: 1 + podDisruptionBudget.maxUnavailable: "" + asserts: + - hasDocuments: + count: 1 + - isKind: + of: PodDisruptionBudget + - isAPIVersion: + of: policy/v1 + - equal: + path: spec.minAvailable + value: 1 + - notExists: + path: spec.maxUnavailable + - matchSnapshot: {} + + - it: should accept a percentage for minAvailable + template: poddisruptionbudget.yaml + set: + podDisruptionBudget.enabled: true + podDisruptionBudget.minAvailable: 50% + podDisruptionBudget.maxUnavailable: "" + asserts: + - hasDocuments: + count: 1 + - equal: + path: spec.minAvailable + value: 50% + - matchSnapshot: {} + + - it: should create a pdb with maxUnavailable when enabled + template: poddisruptionbudget.yaml + set: + podDisruptionBudget.enabled: true + podDisruptionBudget.minAvailable: "" + podDisruptionBudget.maxUnavailable: 1 + asserts: + - hasDocuments: + count: 1 + - equal: + path: spec.maxUnavailable + value: 1 + - notExists: + path: spec.minAvailable + - matchSnapshot: {} + + - it: should select the controller manager pods + template: poddisruptionbudget.yaml + set: + podDisruptionBudget.enabled: true + podDisruptionBudget.minAvailable: 1 + podDisruptionBudget.maxUnavailable: "" + asserts: + - equal: + path: spec.selector.matchLabels["control-plane"] + value: controller-manager + - equal: + path: spec.selector.matchLabels["app.kubernetes.io/name"] + value: kmcp + + - it: should not render unhealthyPodEvictionPolicy by default + template: poddisruptionbudget.yaml + set: + podDisruptionBudget.enabled: true + podDisruptionBudget.minAvailable: 1 + podDisruptionBudget.maxUnavailable: "" + asserts: + - notExists: + path: spec.unhealthyPodEvictionPolicy + + - it: should render unhealthyPodEvictionPolicy when set + template: poddisruptionbudget.yaml + set: + podDisruptionBudget.enabled: true + podDisruptionBudget.minAvailable: 1 + podDisruptionBudget.maxUnavailable: "" + podDisruptionBudget.unhealthyPodEvictionPolicy: AlwaysAllow + asserts: + - equal: + path: spec.unhealthyPodEvictionPolicy + value: AlwaysAllow + - matchSnapshot: {} + + - it: should render custom labels and annotations + template: poddisruptionbudget.yaml + set: + podDisruptionBudget.enabled: true + podDisruptionBudget.minAvailable: 1 + podDisruptionBudget.maxUnavailable: "" + podDisruptionBudget.labels: + custom-label: custom-value + podDisruptionBudget.annotations: + custom-annotation: annotation-value + asserts: + - equal: + path: metadata.labels["custom-label"] + value: custom-value + - equal: + path: metadata.annotations["custom-annotation"] + value: annotation-value + - matchSnapshot: {} + + - it: should use the release namespace override + template: poddisruptionbudget.yaml + set: + podDisruptionBudget.enabled: true + podDisruptionBudget.minAvailable: 1 + podDisruptionBudget.maxUnavailable: "" + namespaceOverride: custom-namespace + asserts: + - equal: + path: metadata.namespace + value: custom-namespace + + - it: should fail when both minAvailable and maxUnavailable are set + template: poddisruptionbudget.yaml + set: + podDisruptionBudget.enabled: true + podDisruptionBudget.minAvailable: 1 + podDisruptionBudget.maxUnavailable: 1 + asserts: + - failedTemplate: + errorMessage: podDisruptionBudget.minAvailable and podDisruptionBudget.maxUnavailable are mutually exclusive. Set exactly one of them. + + - it: should fail when neither minAvailable nor maxUnavailable is set + template: poddisruptionbudget.yaml + set: + podDisruptionBudget.enabled: true + podDisruptionBudget.minAvailable: "" + podDisruptionBudget.maxUnavailable: "" + asserts: + - failedTemplate: + errorMessage: podDisruptionBudget.enabled is true but neither podDisruptionBudget.minAvailable nor podDisruptionBudget.maxUnavailable is set. Set exactly one of them. + + - it: should fail on an invalid unhealthyPodEvictionPolicy + template: poddisruptionbudget.yaml + set: + podDisruptionBudget.enabled: true + podDisruptionBudget.minAvailable: 1 + podDisruptionBudget.maxUnavailable: "" + podDisruptionBudget.unhealthyPodEvictionPolicy: Whenever + asserts: + - failedTemplate: + errorMessage: podDisruptionBudget.unhealthyPodEvictionPolicy must be either "AlwaysAllow" or "IfHealthyBudget", got "Whenever" diff --git a/helm/kmcp/values.yaml b/helm/kmcp/values.yaml index efd8b7d..e4d2cd6 100644 --- a/helm/kmcp/values.yaml +++ b/helm/kmcp/values.yaml @@ -47,6 +47,29 @@ controller: env: [] +# PodDisruptionBudget for the controller pods +podDisruptionBudget: + # Specifies whether a PodDisruptionBudget should be created + enabled: false + + # Exactly one of minAvailable / maxUnavailable must be set. + # Both accept an integer or a percentage string (e.g. 1 or "50%"). + # Note: minAvailable: 1 combined with controller.replicaCount: 1 blocks + # voluntary node drains, so increase the replica count when enabling this. + minAvailable: 1 + maxUnavailable: "" + + # Eviction policy for unhealthy pods: "IfHealthyBudget" or "AlwaysAllow". + # Requires Kubernetes 1.27+ (beta) or 1.31+ (stable). + # Left out of the manifest when empty. + unhealthyPodEvictionPolicy: "" + + # Extra annotations for the PodDisruptionBudget + annotations: {} + + # Extra labels for the PodDisruptionBudget + labels: {} + # Pod annotations podAnnotations: {}