From cec8f3f751bb64080516347d123560ba51a0c9c6 Mon Sep 17 00:00:00 2001 From: Anthony Hausman Date: Tue, 4 Aug 2026 19:01:07 +0200 Subject: [PATCH] feat(helm): add PodDisruptionBudget for controller and UI Opt-in per component via controller.pdb / ui.pdb. Disabled by default because replicas is 1, where a minAvailable:1 budget refuses every voluntary eviction and hangs node drains. Selector reuses kagent..selectorLabels, the same helper the Deployment spec.selector uses, so a budget cannot drift from the pods it protects. Setting both minAvailable and maxUnavailable, or neither, fails at template time instead of surfacing as an opaque API server error. Assisted-by: Claude Opus 5 Signed-off-by: Anthony Hausman --- helm/kagent/templates/NOTES.txt | 18 ++ helm/kagent/templates/_helpers.tpl | 38 ++++ helm/kagent/templates/controller-pdb.yaml | 32 ++++ helm/kagent/templates/ui-pdb.yaml | 32 ++++ helm/kagent/tests/pdb_test.yaml | 218 ++++++++++++++++++++++ helm/kagent/values.yaml | 53 ++++++ 6 files changed, 391 insertions(+) create mode 100644 helm/kagent/templates/controller-pdb.yaml create mode 100644 helm/kagent/templates/ui-pdb.yaml create mode 100644 helm/kagent/tests/pdb_test.yaml diff --git a/helm/kagent/templates/NOTES.txt b/helm/kagent/templates/NOTES.txt index b19066a56..0624a5ba4 100644 --- a/helm/kagent/templates/NOTES.txt +++ b/helm/kagent/templates/NOTES.txt @@ -103,6 +103,24 @@ DOCUMENTATION: Ensure migrations are applied out-of-band before installing or upgrading. {{- end }} +{{- range $component := (list "controller" "ui") }} +{{- $cfg := index $.Values $component }} +{{- $pdb := $cfg.pdb | default dict }} +{{- if and $pdb.enabled (include "kagent.pdb.isSet" $pdb.minAvailable) (le (int $cfg.replicas) (int $pdb.minAvailable)) }} +################################################################################ +{{ printf "# WARNING: %s PODDISRUPTIONBUDGET WILL BLOCK NODE DRAINS" (upper $component) | printf "%-79s" }}# +################################################################################ + {{ $component }}.pdb.minAvailable is {{ $pdb.minAvailable }} but {{ $component }}.replicas is {{ $cfg.replicas }}. + The budget can never be satisfied while a pod is evicted, so every voluntary + eviction is refused and node drains and cluster upgrades will hang. + + Either raise the replica count: + {{ $component }}.replicas={{ add (int $pdb.minAvailable) 1 }} + or switch to maxUnavailable: + {{ $component }}.pdb.minAvailable=null {{ $component }}.pdb.maxUnavailable=1 + +{{ end }} +{{- end }} {{- if .Values.substrate.enabled }} ################################################################################ # WARNING: SUBSTRATE IS EXPERIMENTAL, USE AT OWN RISK # diff --git a/helm/kagent/templates/_helpers.tpl b/helm/kagent/templates/_helpers.tpl index ff2c792a2..d570e067f 100644 --- a/helm/kagent/templates/_helpers.tpl +++ b/helm/kagent/templates/_helpers.tpl @@ -88,6 +88,44 @@ Guards on the rbac block {{- end -}} {{- end -}} +{{/* +Returns "1" when a PodDisruptionBudget threshold is explicitly set, empty otherwise. + +Uses `kindIs "invalid"` rather than `default ""` so that an explicit `0` counts as +set: Helm's `default` treats 0 as empty, which would silently drop a +`maxUnavailable: 0` budget and render a manifest the user never asked for. +An empty string is also treated as unset, so `minAvailable: ""` disables the field. +*/}} +{{- define "kagent.pdb.isSet" -}} +{{- if not (kindIs "invalid" .) -}} +{{- if ne (toString .) "" -}}1{{- end -}} +{{- end -}} +{{- end -}} + +{{/* +Guards on a component `pdb` block. + +Kubernetes rejects a PodDisruptionBudget that sets both `minAvailable` and +`maxUnavailable`, and a budget that sets neither is meaningless, so both cases +fail at template time with a message naming the offending values path rather +than surfacing later as an opaque API server error. + +Call with a dict: (dict "pdb" .Values.controller.pdb "path" "controller.pdb") +*/}} +{{- define "kagent.pdb.validate" -}} +{{- $pdb := .pdb | default dict -}} +{{- if $pdb.enabled -}} +{{- $hasMin := include "kagent.pdb.isSet" $pdb.minAvailable -}} +{{- $hasMax := include "kagent.pdb.isSet" $pdb.maxUnavailable -}} +{{- if and $hasMin $hasMax -}} +{{- fail (printf "%s: minAvailable and maxUnavailable are mutually exclusive. Set exactly one (to use minAvailable, set %s.maxUnavailable=null)." .path .path) -}} +{{- end -}} +{{- if not (or $hasMin $hasMax) -}} +{{- fail (printf "%s is enabled but neither minAvailable nor maxUnavailable is set. Set exactly one." .path) -}} +{{- end -}} +{{- end -}} +{{- end -}} + {{/* UI selector labels */}} diff --git a/helm/kagent/templates/controller-pdb.yaml b/helm/kagent/templates/controller-pdb.yaml new file mode 100644 index 000000000..e562a0362 --- /dev/null +++ b/helm/kagent/templates/controller-pdb.yaml @@ -0,0 +1,32 @@ +{{- $pdb := .Values.controller.pdb | default dict }} +{{- include "kagent.pdb.validate" (dict "pdb" $pdb "path" "controller.pdb") }} +{{- if $pdb.enabled }} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "kagent.fullname" . }}-controller + namespace: {{ include "kagent.namespace" . }} + {{- with $pdb.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} + labels: + {{- include "kagent.controller.labels" . | nindent 4 }} + {{- with $pdb.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if include "kagent.pdb.isSet" $pdb.minAvailable }} + minAvailable: {{ $pdb.minAvailable }} + {{- else }} + maxUnavailable: {{ $pdb.maxUnavailable }} + {{- end }} + {{- with $pdb.unhealthyPodEvictionPolicy }} + unhealthyPodEvictionPolicy: {{ . }} + {{- end }} + selector: + matchLabels: + {{- /* Same helper the controller Deployment uses for spec.selector, so the + budget can never drift from the pods it is meant to protect. */}} + {{- include "kagent.controller.selectorLabels" . | nindent 6 }} +{{- end }} diff --git a/helm/kagent/templates/ui-pdb.yaml b/helm/kagent/templates/ui-pdb.yaml new file mode 100644 index 000000000..bd3bab99f --- /dev/null +++ b/helm/kagent/templates/ui-pdb.yaml @@ -0,0 +1,32 @@ +{{- $pdb := .Values.ui.pdb | default dict }} +{{- include "kagent.pdb.validate" (dict "pdb" $pdb "path" "ui.pdb") }} +{{- if $pdb.enabled }} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "kagent.fullname" . }}-ui + namespace: {{ include "kagent.namespace" . }} + {{- with $pdb.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} + labels: + {{- include "kagent.ui.labels" . | nindent 4 }} + {{- with $pdb.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if include "kagent.pdb.isSet" $pdb.minAvailable }} + minAvailable: {{ $pdb.minAvailable }} + {{- else }} + maxUnavailable: {{ $pdb.maxUnavailable }} + {{- end }} + {{- with $pdb.unhealthyPodEvictionPolicy }} + unhealthyPodEvictionPolicy: {{ . }} + {{- end }} + selector: + matchLabels: + {{- /* Same helper the UI Deployment uses for spec.selector, so the budget + can never drift from the pods it is meant to protect. */}} + {{- include "kagent.ui.selectorLabels" . | nindent 6 }} +{{- end }} diff --git a/helm/kagent/tests/pdb_test.yaml b/helm/kagent/tests/pdb_test.yaml new file mode 100644 index 000000000..d468b063a --- /dev/null +++ b/helm/kagent/tests/pdb_test.yaml @@ -0,0 +1,218 @@ +suite: test controller and UI PodDisruptionBudgets +templates: + - controller-pdb.yaml + - ui-pdb.yaml +tests: + - it: should not render a controller PDB by default + template: controller-pdb.yaml + asserts: + - hasDocuments: + count: 0 + + - it: should not render a UI PDB by default + template: ui-pdb.yaml + asserts: + - hasDocuments: + count: 0 + + - it: should render the controller PDB with maxUnavailable when enabled + template: controller-pdb.yaml + set: + controller: + pdb: + enabled: true + asserts: + - hasDocuments: + count: 1 + - isKind: + of: PodDisruptionBudget + - isAPIVersion: + of: policy/v1 + - equal: + path: metadata.name + value: RELEASE-NAME-controller + - equal: + path: spec.maxUnavailable + value: 1 + - notExists: + path: spec.minAvailable + - notExists: + path: spec.unhealthyPodEvictionPolicy + + - it: should render the UI PDB with maxUnavailable when enabled + template: ui-pdb.yaml + set: + ui: + pdb: + enabled: true + asserts: + - hasDocuments: + count: 1 + - equal: + path: metadata.name + value: RELEASE-NAME-ui + - equal: + path: spec.maxUnavailable + value: 1 + + - it: should render minAvailable when maxUnavailable is cleared + template: controller-pdb.yaml + set: + controller: + replicas: 3 + pdb: + enabled: true + minAvailable: 2 + maxUnavailable: null + asserts: + - equal: + path: spec.minAvailable + value: 2 + - notExists: + path: spec.maxUnavailable + + # Helm's `default` treats 0 as empty, so an explicit 0 must still be honoured + # rather than silently falling through to the other threshold. + - it: should honour an explicit maxUnavailable of 0 + template: controller-pdb.yaml + set: + controller: + pdb: + enabled: true + maxUnavailable: 0 + asserts: + - equal: + path: spec.maxUnavailable + value: 0 + - notExists: + path: spec.minAvailable + + - it: should honour an explicit minAvailable of 0 + template: controller-pdb.yaml + set: + controller: + pdb: + enabled: true + minAvailable: 0 + maxUnavailable: null + asserts: + - equal: + path: spec.minAvailable + value: 0 + - notExists: + path: spec.maxUnavailable + + - it: should support percentage thresholds + template: ui-pdb.yaml + set: + ui: + pdb: + enabled: true + minAvailable: 50% + maxUnavailable: null + asserts: + - equal: + path: spec.minAvailable + value: 50% + + - it: should render unhealthyPodEvictionPolicy when set + template: controller-pdb.yaml + set: + controller: + pdb: + enabled: true + unhealthyPodEvictionPolicy: AlwaysAllow + asserts: + - equal: + path: spec.unhealthyPodEvictionPolicy + value: AlwaysAllow + + # The budget selector must match the Deployment selector exactly, otherwise it + # protects nothing. Both are rendered from kagent..selectorLabels. + - it: should select the controller pods via the component selector labels + template: controller-pdb.yaml + set: + controller: + pdb: + enabled: true + asserts: + - equal: + path: spec.selector.matchLabels + value: + app.kubernetes.io/name: kagent + app.kubernetes.io/instance: RELEASE-NAME + app.kubernetes.io/component: controller + + - it: should select the UI pods via the component selector labels + template: ui-pdb.yaml + set: + ui: + pdb: + enabled: true + asserts: + - equal: + path: spec.selector.matchLabels + value: + app.kubernetes.io/name: kagent + app.kubernetes.io/instance: RELEASE-NAME + app.kubernetes.io/component: ui + + - it: should merge extra labels and annotations into the controller PDB + template: controller-pdb.yaml + set: + controller: + pdb: + enabled: true + labels: + team: platform + annotations: + owner: sre + asserts: + - equal: + path: metadata.labels.team + value: platform + - equal: + path: metadata.annotations.owner + value: sre + # chart labels survive the merge + - equal: + path: metadata.labels["app.kubernetes.io/component"] + value: controller + + - it: should fail when both minAvailable and maxUnavailable are set + template: controller-pdb.yaml + set: + controller: + pdb: + enabled: true + minAvailable: 1 + maxUnavailable: 1 + asserts: + - failedTemplate: + errorMessage: "controller.pdb: minAvailable and maxUnavailable are mutually exclusive. Set exactly one (to use minAvailable, set controller.pdb.maxUnavailable=null)." + + - it: should fail when neither minAvailable nor maxUnavailable is set + template: ui-pdb.yaml + set: + ui: + pdb: + enabled: true + minAvailable: null + maxUnavailable: null + asserts: + - failedTemplate: + errorMessage: "ui.pdb is enabled but neither minAvailable nor maxUnavailable is set. Set exactly one." + + # A disabled block is never validated, so a half-configured pdb block that is + # switched off must not break rendering. + - it: should not validate a disabled pdb block + template: controller-pdb.yaml + set: + controller: + pdb: + enabled: false + minAvailable: 1 + maxUnavailable: 1 + asserts: + - hasDocuments: + count: 0 diff --git a/helm/kagent/values.yaml b/helm/kagent/values.yaml index d6a3204c8..3f8fcf45b 100644 --- a/helm/kagent/values.yaml +++ b/helm/kagent/values.yaml @@ -271,6 +271,32 @@ controller: # -- [Topology spread constraints](https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/#pod-topology-spread-constraints) for the controller pod. topologySpreadConstraints: [] + # -- [PodDisruptionBudget](https://kubernetes.io/docs/tasks/run-application/configure-pdb/) + # for the controller pods. Disabled by default: `controller.replicas` is 1, and a + # `minAvailable: 1` budget on a single-replica Deployment blocks every voluntary + # eviction, so node drains and cluster upgrades hang indefinitely. Raise + # `controller.replicas` before switching to `minAvailable`. + pdb: + # -- Set to true to create the PodDisruptionBudget. + enabled: false + # -- Minimum number of pods that must remain available. Int or percentage string + # (e.g. `1` or `"50%"`). Mutually exclusive with `maxUnavailable`. + # @default -- unset (`maxUnavailable` is used instead) + minAvailable: null + # -- Maximum number of pods that may be unavailable. Int or percentage string + # (e.g. `1` or `"50%"`). Mutually exclusive with `minAvailable`. + maxUnavailable: 1 + # -- `spec.unhealthyPodEvictionPolicy`, one of `IfHealthyBudget` (the Kubernetes + # default) or `AlwaysAllow`. `AlwaysAllow` lets unhealthy pods be evicted even when + # the budget is exhausted, which avoids drains wedging on a crash-looping pod. + # Requires Kubernetes >= 1.27. Omitted from the manifest when empty. + # @default -- "" (defer to the Kubernetes default) + unhealthyPodEvictionPolicy: "" + # -- Additional labels for the controller PodDisruptionBudget. + labels: {} + # -- Annotations for the controller PodDisruptionBudget. + annotations: {} + image: registry: "" repository: kagent-dev/kagent/controller @@ -487,6 +513,33 @@ ui: # -- [Topology spread constraints](https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/#pod-topology-spread-constraints) for the UI pod. topologySpreadConstraints: [] + + # -- [PodDisruptionBudget](https://kubernetes.io/docs/tasks/run-application/configure-pdb/) + # for the UI pods. Disabled by default: `ui.replicas` is 1, and a `minAvailable: 1` + # budget on a single-replica Deployment blocks every voluntary eviction, so node + # drains and cluster upgrades hang indefinitely. Raise `ui.replicas` before + # switching to `minAvailable`. + pdb: + # -- Set to true to create the PodDisruptionBudget. + enabled: false + # -- Minimum number of pods that must remain available. Int or percentage string + # (e.g. `1` or `"50%"`). Mutually exclusive with `maxUnavailable`. + # @default -- unset (`maxUnavailable` is used instead) + minAvailable: null + # -- Maximum number of pods that may be unavailable. Int or percentage string + # (e.g. `1` or `"50%"`). Mutually exclusive with `minAvailable`. + maxUnavailable: 1 + # -- `spec.unhealthyPodEvictionPolicy`, one of `IfHealthyBudget` (the Kubernetes + # default) or `AlwaysAllow`. `AlwaysAllow` lets unhealthy pods be evicted even when + # the budget is exhausted, which avoids drains wedging on a crash-looping pod. + # Requires Kubernetes >= 1.27. Omitted from the manifest when empty. + # @default -- "" (defer to the Kubernetes default) + unhealthyPodEvictionPolicy: "" + # -- Additional labels for the UI PodDisruptionBudget. + labels: {} + # -- Annotations for the UI PodDisruptionBudget. + annotations: {} + # -- Custom startup probe for the UI container. # Override to adjust thresholds, use exec-based probes, or change the health path. # @default -- httpGet /health on port http, periodSeconds=1, initialDelaySeconds=1