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: {}