From 10905340f0e0c054c949abc42179f93701353120 Mon Sep 17 00:00:00 2001 From: Chad Roberts Date: Thu, 4 Jun 2026 14:55:09 -0400 Subject: [PATCH 1/4] Add admission validation for WebhookDeploymentCustomization Validate WebhookDeploymentCustomization fields on both provisioning.cattle.io/v1 and management.cattle.io/v3 Cluster resources: - replicaCount must be >= 1 - appendTolerations keys validated against k8s label name rules - overrideAffinity label selectors validated - PDB minAvailable/maxUnavailable: non-negative int or 0-100% string, cannot both be non-zero simultaneously Shared validation logic lives in pkg/resources/common/deployment_customization.go to avoid duplication across API groups. --- docs.md | 32 +++ .../common/deployment_customization.go | 207 ++++++++++++++ .../v3/cluster/Cluster.md | 16 ++ .../v3/cluster/validator.go | 13 + .../v3/cluster/validator_test.go | 259 ++++++++++++++++++ .../v1/cluster/Cluster.md | 16 ++ .../v1/cluster/validator.go | 154 ++--------- .../v1/cluster/validator_test.go | 238 ++++++++++++++++ 8 files changed, 797 insertions(+), 138 deletions(-) create mode 100644 pkg/resources/common/deployment_customization.go diff --git a/docs.md b/docs.md index 15b7000600..294632f4f9 100644 --- a/docs.md +++ b/docs.md @@ -245,6 +245,22 @@ Both `minAvailable` and `maxUnavailable` must be a string which represents a non ^([0-9]|[1-9][0-9]|100)%$ ``` +##### Feature: Webhook Deployment Customization + +The `WebhookDeploymentCustomization` field configures the rancher-webhook deployment on downstream clusters. The following sub-fields are validated: + +- `replicaCount`: If set, must be at least 1. +- `appendTolerations`: Toleration keys are validated against the upstream apimachinery label name regex: + ```regex + ([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9] + ``` +- `overrideAffinity`: Node affinity `nodeSelectorTerms` are validated via label name validation. Pod affinity and pod anti-affinity are validated via label selectors using the [apimachinery label selector validation](https://github.com/kubernetes/apimachinery/blob/02a41040d88da08de6765573ae2b1a51f424e1ca/pkg/apis/meta/v1/validation/validation.go#L56). +- `podDisruptionBudget.minAvailable` and `podDisruptionBudget.maxUnavailable`: Each must be a non-negative whole integer or a whole number percentage between `0%` and `100%`. Only one of the two fields can have a non-zero or non-empty value at a given time. These fields use the following regex when assessing if a given percentage value is valid: + ```regex + ^([0-9]|[1-9][0-9]|100)%$ + ``` +- `overrideResourceRequirements`: Not validated by the webhook — the Kubernetes API server validates `ResourceRequirements` natively. + ## ClusterProxyConfig ### Validation Checks @@ -809,6 +825,22 @@ Both `minAvailable` and `maxUnavailable` must be a string which represents a non ^([0-9]|[1-9][0-9]|100)%$ ``` +##### cluster.spec.webhookDeploymentCustomization + +The `WebhookDeploymentCustomization` field configures the rancher-webhook deployment on downstream clusters. The following sub-fields are validated: + +- `replicaCount`: If set, must be at least 1. +- `appendTolerations`: Toleration keys are validated against the same upstream apimachinery label name regex used for agent deployment customizations: + ```regex + ([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9] + ``` +- `overrideAffinity`: Node affinity `nodeSelectorTerms` are validated via label name validation. Pod affinity and pod anti-affinity are validated via label selectors using the [apimachinery label selector validation](https://github.com/kubernetes/apimachinery/blob/02a41040d88da08de6765573ae2b1a51f424e1ca/pkg/apis/meta/v1/validation/validation.go#L56). +- `podDisruptionBudget.minAvailable` and `podDisruptionBudget.maxUnavailable`: Each must be a non-negative whole integer or a whole number percentage between `0%` and `100%`. Only one of the two fields can have a non-zero or non-empty value at a given time. These fields use the following regex when assessing if a given percentage value is valid: + ```regex + ^([0-9]|[1-9][0-9]|100)%$ + ``` +- `overrideResourceRequirements`: Not validated by the webhook — the Kubernetes API server validates `ResourceRequirements` natively. + ##### NO_PROXY value Prevent the update of objects with an env var (under `spec.agentEnvVars`) with a name of `NO_PROXY` if its value contains one or more spaces. This ensures that the provided value adheres to diff --git a/pkg/resources/common/deployment_customization.go b/pkg/resources/common/deployment_customization.go new file mode 100644 index 0000000000..3abbb68f35 --- /dev/null +++ b/pkg/resources/common/deployment_customization.go @@ -0,0 +1,207 @@ +package common + +import ( + "fmt" + "net/http" + "strconv" + "strings" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/validation" + "k8s.io/apimachinery/pkg/util/validation/field" +) + +// PDB holds PodDisruptionBudget string fields, decoupled from API-group-specific types. +type PDB struct { + MinAvailable string + MaxUnavailable string +} + +// ValidateWebhookDeploymentCustomization validates the fields of a webhook deployment +// customization spec using standard k8s types. +func ValidateWebhookDeploymentCustomization(replicaCount *int32, tolerations []corev1.Toleration, affinity *corev1.Affinity, pdb *PDB, path *field.Path) field.ErrorList { + var errList field.ErrorList + + if replicaCount != nil && *replicaCount < 1 { + errList = append(errList, field.Invalid(path.Child("replicaCount"), *replicaCount, "must be at least 1")) + } + + errList = append(errList, ValidateAppendTolerations(tolerations, path.Child("appendTolerations"))...) + errList = append(errList, ValidateAffinity(affinity, path.Child("overrideAffinity"))...) + errList = append(errList, ValidatePDB(pdb, path.Child("podDisruptionBudget"))...) + + return errList +} + +// ValidatePDB validates PodDisruptionBudget minAvailable/maxUnavailable values. +func ValidatePDB(pdb *PDB, path *field.Path) field.ErrorList { + if pdb == nil { + return nil + } + var errList field.ErrorList + + minAvailStr := pdb.MinAvailable + maxUnavailStr := pdb.MaxUnavailable + + if (minAvailStr == "" && maxUnavailStr == "") || + (minAvailStr == "0" && maxUnavailStr == "0") || + (minAvailStr != "" && minAvailStr != "0") && (maxUnavailStr != "" && maxUnavailStr != "0") { + errList = append(errList, field.Invalid(path, pdb, "both minAvailable and maxUnavailable cannot be set to a non-zero value, at least one must be omitted or set to zero")) + return errList + } + + if minAvailStr != "" { + minAvailInt, err := strconv.Atoi(minAvailStr) + if err != nil { + if !PdbPercentageRegex.MatchString(minAvailStr) { + errList = append(errList, field.Invalid(path.Child("minAvailable"), minAvailStr, + fmt.Sprintf("must be a non-negative whole integer or a percentage value between 0 and 100, regex used is '%s'", PdbPercentageRegex.String()))) + } + } else if minAvailInt < 0 { + errList = append(errList, field.Invalid(path.Child("minAvailable"), minAvailStr, "cannot be a negative integer")) + } + } + + if maxUnavailStr != "" { + maxUnavailInt, err := strconv.Atoi(maxUnavailStr) + if err != nil { + if !PdbPercentageRegex.MatchString(maxUnavailStr) { + errList = append(errList, field.Invalid(path.Child("maxUnavailable"), maxUnavailStr, + fmt.Sprintf("must be a non-negative whole integer or a percentage value between 0 and 100, regex used is '%s'", PdbPercentageRegex.String()))) + } + } else if maxUnavailInt < 0 { + errList = append(errList, field.Invalid(path.Child("maxUnavailable"), maxUnavailStr, "cannot be a negative integer")) + } + } + + return errList +} + +// ValidateAppendTolerations validates that toleration keys follow k8s label name rules. +func ValidateAppendTolerations(tolerations []corev1.Toleration, path *field.Path) field.ErrorList { + var errList field.ErrorList + for k, s := range tolerations { + errList = append(errList, validation.ValidateLabelName(s.Key, path.Index(k))...) + } + return errList +} + +// ValidateAffinity validates an Affinity spec including node, pod, and pod anti-affinity rules. +func ValidateAffinity(overrideAffinity *corev1.Affinity, path *field.Path) field.ErrorList { + if overrideAffinity == nil { + return nil + } + var errList field.ErrorList + + if affinity := overrideAffinity.NodeAffinity; affinity != nil { + errList = append(errList, + validatePreferredSchedulingTerms(affinity.PreferredDuringSchedulingIgnoredDuringExecution, + path.Child("nodeAffinity").Child("preferredDuringSchedulingIgnoredDuringExecution"))..., + ) + errList = append(errList, + validateNodeSelector(affinity.RequiredDuringSchedulingIgnoredDuringExecution, + path.Child("nodeAffinity").Child("requiredDuringSchedulingIgnoredDuringExecution"))..., + ) + } + + if podAffinity := overrideAffinity.PodAffinity; podAffinity != nil { + errList = append(errList, validatePodAffinityTerms(podAffinity.RequiredDuringSchedulingIgnoredDuringExecution, + path.Child("podAffinity").Child("requiredDuringSchedulingIgnoredDuringExecution"))...) + + errList = append(errList, validateWeightedPodAffinityTerms(podAffinity.PreferredDuringSchedulingIgnoredDuringExecution, + path.Child("podAffinity").Child("preferredDuringSchedulingIgnoredDuringExecution"))...) + } + + if podAntiAffinity := overrideAffinity.PodAntiAffinity; podAntiAffinity != nil { + errList = append(errList, validatePodAffinityTerms(podAntiAffinity.RequiredDuringSchedulingIgnoredDuringExecution, + path.Child("podAntiAffinity").Child("requiredDuringSchedulingIgnoredDuringExecution"))...) + + errList = append(errList, validateWeightedPodAffinityTerms(podAntiAffinity.PreferredDuringSchedulingIgnoredDuringExecution, + path.Child("podAntiAffinity").Child("preferredDuringSchedulingIgnoredDuringExecution"))...) + } + return errList +} + +// ErrorListToStatus converts a field.ErrorList to a metav1.Status with bullet-pointed messages. +func ErrorListToStatus(errList field.ErrorList) *metav1.Status { + if len(errList) == 0 { + return nil + } + var builder strings.Builder + builder.WriteString("* ") + for i, fieldErr := range errList { + builder.WriteString(fieldErr.Error()) + if i != len(errList)-1 { + builder.WriteString("\n* ") + } + } + return &metav1.Status{ + Status: "Failure", + Message: builder.String(), + Reason: metav1.StatusReasonInvalid, + Code: http.StatusUnprocessableEntity, + } +} + +func validatePodAffinityTerms(terms []corev1.PodAffinityTerm, path *field.Path) field.ErrorList { + var errList field.ErrorList + for k, v := range terms { + errList = append(errList, validatePodAffinityTerm(v, path.Index(k))...) + } + return errList +} + +func validateWeightedPodAffinityTerms(terms []corev1.WeightedPodAffinityTerm, path *field.Path) field.ErrorList { + var errList field.ErrorList + for k, v := range terms { + errList = append(errList, validatePodAffinityTerm(v.PodAffinityTerm, path.Index(k).Child("podAffinityTerm"))...) + } + return errList +} + +func validatePodAffinityTerm(term corev1.PodAffinityTerm, path *field.Path) field.ErrorList { + var errList field.ErrorList + errList = append(errList, validateLabelSelector(term.LabelSelector, path.Child("labelSelector"))...) + errList = append(errList, validateLabelSelector(term.NamespaceSelector, path.Child("namespaceSelector"))...) + return errList +} + +func validateLabelSelector(labelSelector *metav1.LabelSelector, path *field.Path) field.ErrorList { + return validation.ValidateLabelSelector(labelSelector, validation.LabelSelectorValidationOptions{}, path) +} + +func validatePreferredSchedulingTerms(terms []corev1.PreferredSchedulingTerm, path *field.Path) field.ErrorList { + var errList field.ErrorList + for k, v := range terms { + errList = append(errList, validateNodeSelectorTerm(v.Preference, path.Index(k).Child("preferences"))...) + } + return errList +} + +func validateNodeSelector(nodeSelector *corev1.NodeSelector, path *field.Path) field.ErrorList { + if nodeSelector == nil { + return nil + } + var errList field.ErrorList + nodeSelectorPath := path.Child("nodeSelectorTerms") + for k, v := range nodeSelector.NodeSelectorTerms { + errList = append(errList, validateNodeSelectorTerm(v, nodeSelectorPath.Index(k))...) + } + return errList +} + +func validateNodeSelectorTerm(term corev1.NodeSelectorTerm, path *field.Path) field.ErrorList { + var errList field.ErrorList + errList = append(errList, validateNodeSelectorRequirements(term.MatchFields, path.Child("matchFields"))...) + errList = append(errList, validateNodeSelectorRequirements(term.MatchExpressions, path.Child("matchExpressions"))...) + return errList +} + +func validateNodeSelectorRequirements(selector []corev1.NodeSelectorRequirement, path *field.Path) field.ErrorList { + var errList field.ErrorList + for k, s := range selector { + errList = append(errList, validation.ValidateLabelName(s.Key, path.Index(k).Child("key"))...) + } + return errList +} diff --git a/pkg/resources/management.cattle.io/v3/cluster/Cluster.md b/pkg/resources/management.cattle.io/v3/cluster/Cluster.md index 40928bdb7e..8c7729ba8b 100644 --- a/pkg/resources/management.cattle.io/v3/cluster/Cluster.md +++ b/pkg/resources/management.cattle.io/v3/cluster/Cluster.md @@ -43,3 +43,19 @@ Both `minAvailable` and `maxUnavailable` must be a string which represents a non ```regex ^([0-9]|[1-9][0-9]|100)%$ ``` + +#### Feature: Webhook Deployment Customization + +The `WebhookDeploymentCustomization` field configures the rancher-webhook deployment on downstream clusters. The following sub-fields are validated: + +- `replicaCount`: If set, must be at least 1. +- `appendTolerations`: Toleration keys are validated against the upstream apimachinery label name regex: + ```regex + ([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9] + ``` +- `overrideAffinity`: Node affinity `nodeSelectorTerms` are validated via label name validation. Pod affinity and pod anti-affinity are validated via label selectors using the [apimachinery label selector validation](https://github.com/kubernetes/apimachinery/blob/02a41040d88da08de6765573ae2b1a51f424e1ca/pkg/apis/meta/v1/validation/validation.go#L56). +- `podDisruptionBudget.minAvailable` and `podDisruptionBudget.maxUnavailable`: Each must be a non-negative whole integer or a whole number percentage between `0%` and `100%`. Only one of the two fields can have a non-zero or non-empty value at a given time. These fields use the following regex when assessing if a given percentage value is valid: + ```regex + ^([0-9]|[1-9][0-9]|100)%$ + ``` +- `overrideResourceRequirements`: Not validated by the webhook — the Kubernetes API server validates `ResourceRequirements` natively. diff --git a/pkg/resources/management.cattle.io/v3/cluster/validator.go b/pkg/resources/management.cattle.io/v3/cluster/validator.go index ff25c81582..1d78b0a191 100644 --- a/pkg/resources/management.cattle.io/v3/cluster/validator.go +++ b/pkg/resources/management.cattle.io/v3/cluster/validator.go @@ -18,6 +18,7 @@ import ( corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/validation/field" authorizationv1 "k8s.io/client-go/kubernetes/typed/authorization/v1" ) @@ -130,6 +131,18 @@ func (a *admitter) Admit(request *admission.Request) (*admissionv1.AdmissionResp return response, err } + if wdc := newCluster.Spec.WebhookDeploymentCustomization; wdc != nil { + var pdb *common.PDB + if wdc.PodDisruptionBudget != nil { + pdb = &common.PDB{MinAvailable: wdc.PodDisruptionBudget.MinAvailable, MaxUnavailable: wdc.PodDisruptionBudget.MaxUnavailable} + } + if response.Result = common.ErrorListToStatus(common.ValidateWebhookDeploymentCustomization( + wdc.ReplicaCount, wdc.AppendTolerations, wdc.OverrideAffinity, pdb, + field.NewPath("spec", "webhookDeploymentCustomization"))); response.Result != nil { + return response, nil + } + } + if a.settingCache != nil { // The following checks don't make sense for downstream clusters (settingCache == nil) response, err = a.validateVersionManagementFeature(oldCluster, newCluster, request.Operation) diff --git a/pkg/resources/management.cattle.io/v3/cluster/validator_test.go b/pkg/resources/management.cattle.io/v3/cluster/validator_test.go index 86b8847d00..1cd34d1b39 100644 --- a/pkg/resources/management.cattle.io/v3/cluster/validator_test.go +++ b/pkg/resources/management.cattle.io/v3/cluster/validator_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "strings" "testing" v3 "github.com/rancher/rancher/pkg/apis/management.cattle.io/v3" @@ -19,6 +20,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/validation/field" v1 "k8s.io/client-go/kubernetes/typed/authorization/v1" ) @@ -1050,3 +1052,260 @@ func createMockFeatureCache(ctrl *gomock.Controller, featureName string, enabled }).AnyTimes() return featureCache } + +func validateFailedPaths(expected []string) func(t *testing.T, errList field.ErrorList) { + return func(t *testing.T, errList field.ErrorList) { + t.Helper() + actual := make([]string, len(errList)) + for i := range errList { + actual[i] = errList[i].Field + } + if !assert.ElementsMatch(t, expected, actual) { + var b strings.Builder + b.WriteString("Failed Fields and reasons: ") + for _, v := range errList { + b.WriteString("\n* ") + b.WriteString(v.Error()) + } + fmt.Println(b.String()) + } + } +} + +func Test_validateWebhookDeploymentCustomization(t *testing.T) { + replicaCount := func(n int32) *int32 { return &n } + + tests := []struct { + name string + customization *v3.WebhookDeploymentCustomization + validateFunc func(t *testing.T, err field.ErrorList) + }{ + { + name: "nil customization", + customization: nil, + validateFunc: validateFailedPaths([]string{}), + }, + { + name: "empty customization", + customization: &v3.WebhookDeploymentCustomization{}, + validateFunc: validateFailedPaths([]string{}), + }, + { + name: "valid replicaCount", + customization: &v3.WebhookDeploymentCustomization{ + ReplicaCount: replicaCount(3), + }, + validateFunc: validateFailedPaths([]string{}), + }, + { + name: "replicaCount of 1 is valid", + customization: &v3.WebhookDeploymentCustomization{ + ReplicaCount: replicaCount(1), + }, + validateFunc: validateFailedPaths([]string{}), + }, + { + name: "replicaCount of 0 is invalid", + customization: &v3.WebhookDeploymentCustomization{ + ReplicaCount: replicaCount(0), + }, + validateFunc: validateFailedPaths([]string{"test.replicaCount"}), + }, + { + name: "negative replicaCount is invalid", + customization: &v3.WebhookDeploymentCustomization{ + ReplicaCount: replicaCount(-1), + }, + validateFunc: validateFailedPaths([]string{"test.replicaCount"}), + }, + { + name: "valid tolerations", + customization: &v3.WebhookDeploymentCustomization{ + AppendTolerations: []k8sv1.Toleration{ + {Key: "cattle.io/node", Operator: k8sv1.TolerationOpExists}, + }, + }, + validateFunc: validateFailedPaths([]string{}), + }, + { + name: "invalid toleration key", + customization: &v3.WebhookDeploymentCustomization{ + AppendTolerations: []k8sv1.Toleration{ + {Key: "-invalid-key"}, + }, + }, + validateFunc: validateFailedPaths([]string{"test.appendTolerations[0]"}), + }, + { + name: "valid affinity", + customization: &v3.WebhookDeploymentCustomization{ + OverrideAffinity: &k8sv1.Affinity{ + NodeAffinity: &k8sv1.NodeAffinity{ + RequiredDuringSchedulingIgnoredDuringExecution: &k8sv1.NodeSelector{ + NodeSelectorTerms: []k8sv1.NodeSelectorTerm{ + { + MatchExpressions: []k8sv1.NodeSelectorRequirement{ + {Key: "kubernetes.io/arch", Operator: k8sv1.NodeSelectorOpIn, Values: []string{"amd64"}}, + }, + }, + }, + }, + }, + }, + }, + validateFunc: validateFailedPaths([]string{}), + }, + { + name: "invalid affinity label key", + customization: &v3.WebhookDeploymentCustomization{ + OverrideAffinity: &k8sv1.Affinity{ + NodeAffinity: &k8sv1.NodeAffinity{ + RequiredDuringSchedulingIgnoredDuringExecution: &k8sv1.NodeSelector{ + NodeSelectorTerms: []k8sv1.NodeSelectorTerm{ + { + MatchExpressions: []k8sv1.NodeSelectorRequirement{ + {Key: "-bad-key"}, + }, + }, + }, + }, + }, + }, + }, + validateFunc: validateFailedPaths([]string{ + "test.overrideAffinity.nodeAffinity.requiredDuringSchedulingIgnoredDuringExecution.nodeSelectorTerms[0].matchExpressions[0].key", + }), + }, + { + name: "valid PDB with minAvailable only", + customization: &v3.WebhookDeploymentCustomization{ + PodDisruptionBudget: &v3.PodDisruptionBudgetSpec{ + MinAvailable: "1", + }, + }, + validateFunc: validateFailedPaths([]string{}), + }, + { + name: "valid PDB with maxUnavailable only", + customization: &v3.WebhookDeploymentCustomization{ + PodDisruptionBudget: &v3.PodDisruptionBudgetSpec{ + MaxUnavailable: "1", + }, + }, + validateFunc: validateFailedPaths([]string{}), + }, + { + name: "valid PDB with percentage", + customization: &v3.WebhookDeploymentCustomization{ + PodDisruptionBudget: &v3.PodDisruptionBudgetSpec{ + MinAvailable: "50%", + }, + }, + validateFunc: validateFailedPaths([]string{}), + }, + { + name: "PDB both minAvailable and maxUnavailable set", + customization: &v3.WebhookDeploymentCustomization{ + PodDisruptionBudget: &v3.PodDisruptionBudgetSpec{ + MinAvailable: "1", + MaxUnavailable: "1", + }, + }, + validateFunc: validateFailedPaths([]string{"test.podDisruptionBudget"}), + }, + { + name: "PDB both set to zero", + customization: &v3.WebhookDeploymentCustomization{ + PodDisruptionBudget: &v3.PodDisruptionBudgetSpec{ + MinAvailable: "0", + MaxUnavailable: "0", + }, + }, + validateFunc: validateFailedPaths([]string{"test.podDisruptionBudget"}), + }, + { + name: "PDB both empty", + customization: &v3.WebhookDeploymentCustomization{ + PodDisruptionBudget: &v3.PodDisruptionBudgetSpec{}, + }, + validateFunc: validateFailedPaths([]string{"test.podDisruptionBudget"}), + }, + { + name: "PDB negative minAvailable", + customization: &v3.WebhookDeploymentCustomization{ + PodDisruptionBudget: &v3.PodDisruptionBudgetSpec{ + MinAvailable: "-1", + }, + }, + validateFunc: validateFailedPaths([]string{"test.podDisruptionBudget.minAvailable"}), + }, + { + name: "PDB invalid percentage", + customization: &v3.WebhookDeploymentCustomization{ + PodDisruptionBudget: &v3.PodDisruptionBudgetSpec{ + MinAvailable: "200%", + }, + }, + validateFunc: validateFailedPaths([]string{"test.podDisruptionBudget.minAvailable"}), + }, + { + name: "PDB negative maxUnavailable", + customization: &v3.WebhookDeploymentCustomization{ + PodDisruptionBudget: &v3.PodDisruptionBudgetSpec{ + MaxUnavailable: "-5", + }, + }, + validateFunc: validateFailedPaths([]string{"test.podDisruptionBudget.maxUnavailable"}), + }, + { + name: "full valid customization", + customization: &v3.WebhookDeploymentCustomization{ + ReplicaCount: replicaCount(3), + AppendTolerations: []k8sv1.Toleration{ + {Key: "cattle.io/node", Operator: k8sv1.TolerationOpExists}, + }, + OverrideAffinity: &k8sv1.Affinity{ + PodAntiAffinity: &k8sv1.PodAntiAffinity{ + PreferredDuringSchedulingIgnoredDuringExecution: []k8sv1.WeightedPodAffinityTerm{ + { + Weight: 100, + PodAffinityTerm: k8sv1.PodAffinityTerm{ + LabelSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "rancher-webhook"}, + }, + TopologyKey: "kubernetes.io/hostname", + }, + }, + }, + }, + }, + OverrideResourceRequirements: &k8sv1.ResourceRequirements{}, + PodDisruptionBudget: &v3.PodDisruptionBudgetSpec{ + MinAvailable: "1", + }, + }, + validateFunc: validateFailedPaths([]string{}), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var replicaCount *int32 + var tolerations []k8sv1.Toleration + var affinity *k8sv1.Affinity + var pdb *common.PDB + if tt.customization != nil { + replicaCount = tt.customization.ReplicaCount + tolerations = tt.customization.AppendTolerations + affinity = tt.customization.OverrideAffinity + if tt.customization.PodDisruptionBudget != nil { + pdb = &common.PDB{ + MinAvailable: tt.customization.PodDisruptionBudget.MinAvailable, + MaxUnavailable: tt.customization.PodDisruptionBudget.MaxUnavailable, + } + } + } + got := common.ValidateWebhookDeploymentCustomization(replicaCount, tolerations, affinity, pdb, field.NewPath("test")) + tt.validateFunc(t, got) + }) + } +} diff --git a/pkg/resources/provisioning.cattle.io/v1/cluster/Cluster.md b/pkg/resources/provisioning.cattle.io/v1/cluster/Cluster.md index 2fd8cdad88..31e4a26f20 100644 --- a/pkg/resources/provisioning.cattle.io/v1/cluster/Cluster.md +++ b/pkg/resources/provisioning.cattle.io/v1/cluster/Cluster.md @@ -86,6 +86,22 @@ Both `minAvailable` and `maxUnavailable` must be a string which represents a non ^([0-9]|[1-9][0-9]|100)%$ ``` +#### cluster.spec.webhookDeploymentCustomization + +The `WebhookDeploymentCustomization` field configures the rancher-webhook deployment on downstream clusters. The following sub-fields are validated: + +- `replicaCount`: If set, must be at least 1. +- `appendTolerations`: Toleration keys are validated against the same upstream apimachinery label name regex used for agent deployment customizations: + ```regex + ([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9] + ``` +- `overrideAffinity`: Node affinity `nodeSelectorTerms` are validated via label name validation. Pod affinity and pod anti-affinity are validated via label selectors using the [apimachinery label selector validation](https://github.com/kubernetes/apimachinery/blob/02a41040d88da08de6765573ae2b1a51f424e1ca/pkg/apis/meta/v1/validation/validation.go#L56). +- `podDisruptionBudget.minAvailable` and `podDisruptionBudget.maxUnavailable`: Each must be a non-negative whole integer or a whole number percentage between `0%` and `100%`. Only one of the two fields can have a non-zero or non-empty value at a given time. These fields use the following regex when assessing if a given percentage value is valid: + ```regex + ^([0-9]|[1-9][0-9]|100)%$ + ``` +- `overrideResourceRequirements`: Not validated by the webhook — the Kubernetes API server validates `ResourceRequirements` natively. + #### NO_PROXY value Prevent the update of objects with an env var (under `spec.agentEnvVars`) with a name of `NO_PROXY` if its value contains one or more spaces. This ensures that the provided value adheres to diff --git a/pkg/resources/provisioning.cattle.io/v1/cluster/validator.go b/pkg/resources/provisioning.cattle.io/v1/cluster/validator.go index 62bd78015b..d7f0e2861d 100644 --- a/pkg/resources/provisioning.cattle.io/v1/cluster/validator.go +++ b/pkg/resources/provisioning.cattle.io/v1/cluster/validator.go @@ -30,7 +30,6 @@ import ( "k8s.io/apimachinery/pkg/api/equality" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1/validation" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/util/validation/field" authorizationv1 "k8s.io/client-go/kubernetes/typed/authorization/v1" @@ -135,16 +134,28 @@ func (p *provisioningAdmitter) Admit(request *admission.Request) (*admissionv1.A return response, nil } - if response.Result = errorListToStatus(validateAgentDeploymentCustomization(cluster.Spec.ClusterAgentDeploymentCustomization, + if response.Result = common.ErrorListToStatus(validateAgentDeploymentCustomization(cluster.Spec.ClusterAgentDeploymentCustomization, field.NewPath("spec", "clusterAgentDeploymentCustomization"))); response.Result != nil { return response, nil } - if response.Result = errorListToStatus(validateAgentDeploymentCustomization(cluster.Spec.FleetAgentDeploymentCustomization, + if response.Result = common.ErrorListToStatus(validateAgentDeploymentCustomization(cluster.Spec.FleetAgentDeploymentCustomization, field.NewPath("spec", "fleetAgentDeploymentCustomization"))); response.Result != nil { return response, nil } + if wdc := cluster.Spec.WebhookDeploymentCustomization; wdc != nil { + var pdb *common.PDB + if wdc.PodDisruptionBudget != nil { + pdb = &common.PDB{MinAvailable: wdc.PodDisruptionBudget.MinAvailable, MaxUnavailable: wdc.PodDisruptionBudget.MaxUnavailable} + } + if response.Result = common.ErrorListToStatus(common.ValidateWebhookDeploymentCustomization( + wdc.ReplicaCount, wdc.AppendTolerations, wdc.OverrideAffinity, pdb, + field.NewPath("spec", "webhookDeploymentCustomization"))); response.Result != nil { + return response, nil + } + } + if err := p.validateCloudCredentialAccess(request, response, oldCluster, cluster); err != nil || response.Result != nil { return response, err } @@ -874,44 +885,9 @@ func validateAgentDeploymentCustomization(customization *v1.AgentDeploymentCusto } var errList field.ErrorList - errList = append(errList, validateAppendToleration(customization.AppendTolerations, path.Child("appendTolerations"))...) - errList = append(errList, validateAffinity(customization.OverrideAffinity, path.Child("overrideAffinity"))...) - - return errList -} -func validateAffinity(overrideAffinity *k8sv1.Affinity, path *field.Path) field.ErrorList { - if overrideAffinity == nil { - return nil - } - var errList field.ErrorList - - if affinity := overrideAffinity.NodeAffinity; affinity != nil { - errList = append(errList, - validatePreferredSchedulingTerms(affinity.PreferredDuringSchedulingIgnoredDuringExecution, - path.Child("nodeAffinity").Child("preferredDuringSchedulingIgnoredDuringExecution"))..., - ) - errList = append(errList, - validateNodeSelector(affinity.RequiredDuringSchedulingIgnoredDuringExecution, - path.Child("nodeAffinity").Child("requiredDuringSchedulingIgnoredDuringExecution"))..., - ) - } + errList = append(errList, common.ValidateAppendTolerations(customization.AppendTolerations, path.Child("appendTolerations"))...) + errList = append(errList, common.ValidateAffinity(customization.OverrideAffinity, path.Child("overrideAffinity"))...) - if podAffinity := overrideAffinity.PodAffinity; podAffinity != nil { - errList = append(errList, validatePodAffinityTerms(podAffinity.RequiredDuringSchedulingIgnoredDuringExecution, - path.Child("podAffinity").Child("requiredDuringSchedulingIgnoredDuringExecution"))...) - - errList = append(errList, validateWeightedPodAffinityTerms(podAffinity.PreferredDuringSchedulingIgnoredDuringExecution, - path.Child("podAffinity").Child("preferredDuringSchedulingIgnoredDuringExecution"))...) - } - - if podAntiAffinity := overrideAffinity.PodAntiAffinity; podAntiAffinity != nil { - errList = append(errList, validatePodAffinityTerms(podAntiAffinity.RequiredDuringSchedulingIgnoredDuringExecution, - path.Child("podAntiAffinity").Child("requiredDuringSchedulingIgnoredDuringExecution"))...) - - errList = append(errList, validateWeightedPodAffinityTerms(podAntiAffinity.PreferredDuringSchedulingIgnoredDuringExecution, - path.Child("podAntiAffinity").Child("preferredDuringSchedulingIgnoredDuringExecution"))...) - - } return errList } @@ -946,104 +922,6 @@ func getSchedulingCustomization(cluster *v1.Cluster, agentType common.AgentType) return nil } -func validatePodAffinityTerms(terms []k8sv1.PodAffinityTerm, path *field.Path) field.ErrorList { - var errList field.ErrorList - - for k, v := range terms { - errList = append(errList, validatePodAffinityTerm(v, path.Index(k))...) - } - return errList -} - -func validateWeightedPodAffinityTerms(weightedPodAffinityTerm []k8sv1.WeightedPodAffinityTerm, path *field.Path) field.ErrorList { - var errList field.ErrorList - for k, v := range weightedPodAffinityTerm { - errList = append(errList, validatePodAffinityTerm(v.PodAffinityTerm, path.Index(k).Child("podAffinityTerm"))...) - } - return errList -} - -func validatePodAffinityTerm(podAffinityTerm k8sv1.PodAffinityTerm, path *field.Path) field.ErrorList { - var errList field.ErrorList - errList = append(errList, validateLabelSelector(podAffinityTerm.LabelSelector, path.Child("labelSelector"))...) - errList = append(errList, validateLabelSelector(podAffinityTerm.NamespaceSelector, path.Child("namespaceSelector"))...) - return errList -} - -func validateLabelSelector(labelSelector *metav1.LabelSelector, path *field.Path) field.ErrorList { - return validation.ValidateLabelSelector(labelSelector, validation.LabelSelectorValidationOptions{}, path) - -} - -func validatePreferredSchedulingTerms(schedulingTerms []k8sv1.PreferredSchedulingTerm, path *field.Path) field.ErrorList { - var errList field.ErrorList - - for k, v := range schedulingTerms { - errList = append(errList, validateNodeSelectorTerm(v.Preference, path.Index(k).Child("preferences"))...) - } - return errList -} - -func validateNodeSelector(nodeSelector *k8sv1.NodeSelector, path *field.Path) field.ErrorList { - if nodeSelector == nil { - return nil - } - var errList field.ErrorList - nodeSelectorPath := path.Child("nodeSelectorTerms") - for k, v := range nodeSelector.NodeSelectorTerms { - errList = append(errList, validateNodeSelectorTerm(v, nodeSelectorPath.Index(k))...) - } - return errList -} - -func validateNodeSelectorTerm(term k8sv1.NodeSelectorTerm, path *field.Path) field.ErrorList { - var errList field.ErrorList - errList = append(errList, validateNodeSelectorRequirements(term.MatchFields, path.Child("matchFields"))...) - errList = append(errList, validateNodeSelectorRequirements(term.MatchExpressions, path.Child("matchExpressions"))...) - return errList -} - -// validateNodeSelectorRequirements Validates the NodeSelectors -// at the moment it only validates the key by calling validation.ValidateLabelName. -func validateNodeSelectorRequirements(selector []k8sv1.NodeSelectorRequirement, path *field.Path) field.ErrorList { - var errList field.ErrorList - for k, s := range selector { - errList = append(errList, validation.ValidateLabelName(s.Key, path.Index(k).Child("key"))...) - } - return errList -} - -// validateAppendToleration validate if tolerations follows the k8s standards -// at the moment it only validates the key by calling validation.ValidateLabelName. -func validateAppendToleration(toleration []k8sv1.Toleration, path *field.Path) field.ErrorList { - var errList field.ErrorList - for k, s := range toleration { - errList = append(errList, validation.ValidateLabelName(s.Key, path.Index(k))...) - } - return errList -} - -// errorListToStatus convert an errorList to failure status, it breaks a line for each entry and adds a * in front -func errorListToStatus(errList field.ErrorList) *metav1.Status { - if len(errList) == 0 { - return nil - } - var builder strings.Builder - builder.WriteString("* ") - for i, fieldErr := range errList { - builder.WriteString(fieldErr.Error()) - if i != len(errList)-1 { - builder.WriteString("\n* ") - } - } - return &metav1.Status{ - Status: failureStatus, - Message: builder.String(), - Reason: metav1.StatusReasonInvalid, - Code: http.StatusUnprocessableEntity, - } -} - func validateACEConfig(cluster *v1.Cluster) *metav1.Status { if cluster.Spec.RKEConfig != nil && cluster.Spec.LocalClusterAuthEndpoint.Enabled && cluster.Spec.LocalClusterAuthEndpoint.CACerts != "" && cluster.Spec.LocalClusterAuthEndpoint.FQDN == "" { return &metav1.Status{ diff --git a/pkg/resources/provisioning.cattle.io/v1/cluster/validator_test.go b/pkg/resources/provisioning.cattle.io/v1/cluster/validator_test.go index 0199307a4b..1d62ff11cc 100644 --- a/pkg/resources/provisioning.cattle.io/v1/cluster/validator_test.go +++ b/pkg/resources/provisioning.cattle.io/v1/cluster/validator_test.go @@ -3010,3 +3010,241 @@ func TestValidateETCDSnapshotRestore(t *testing.T) { }) } } + +func Test_validateWebhookDeploymentCustomization(t *testing.T) { + replicaCount := func(n int32) *int32 { return &n } + + tests := []struct { + name string + customization *v1.WebhookDeploymentCustomization + validateFunc func(t *testing.T, err field.ErrorList) + }{ + { + name: "nil customization", + customization: nil, + validateFunc: validateFailedPaths([]string{}), + }, + { + name: "empty customization", + customization: &v1.WebhookDeploymentCustomization{}, + validateFunc: validateFailedPaths([]string{}), + }, + { + name: "valid replicaCount", + customization: &v1.WebhookDeploymentCustomization{ + ReplicaCount: replicaCount(3), + }, + validateFunc: validateFailedPaths([]string{}), + }, + { + name: "replicaCount of 1 is valid", + customization: &v1.WebhookDeploymentCustomization{ + ReplicaCount: replicaCount(1), + }, + validateFunc: validateFailedPaths([]string{}), + }, + { + name: "replicaCount of 0 is invalid", + customization: &v1.WebhookDeploymentCustomization{ + ReplicaCount: replicaCount(0), + }, + validateFunc: validateFailedPaths([]string{"test.replicaCount"}), + }, + { + name: "negative replicaCount is invalid", + customization: &v1.WebhookDeploymentCustomization{ + ReplicaCount: replicaCount(-1), + }, + validateFunc: validateFailedPaths([]string{"test.replicaCount"}), + }, + { + name: "valid tolerations", + customization: &v1.WebhookDeploymentCustomization{ + AppendTolerations: []k8sv1.Toleration{ + {Key: "cattle.io/node", Operator: k8sv1.TolerationOpExists}, + }, + }, + validateFunc: validateFailedPaths([]string{}), + }, + { + name: "invalid toleration key", + customization: &v1.WebhookDeploymentCustomization{ + AppendTolerations: []k8sv1.Toleration{ + {Key: "-invalid-key"}, + }, + }, + validateFunc: validateFailedPaths([]string{"test.appendTolerations[0]"}), + }, + { + name: "valid affinity", + customization: &v1.WebhookDeploymentCustomization{ + OverrideAffinity: &k8sv1.Affinity{ + NodeAffinity: &k8sv1.NodeAffinity{ + RequiredDuringSchedulingIgnoredDuringExecution: &k8sv1.NodeSelector{ + NodeSelectorTerms: []k8sv1.NodeSelectorTerm{ + { + MatchExpressions: []k8sv1.NodeSelectorRequirement{ + {Key: "kubernetes.io/arch", Operator: k8sv1.NodeSelectorOpIn, Values: []string{"amd64"}}, + }, + }, + }, + }, + }, + }, + }, + validateFunc: validateFailedPaths([]string{}), + }, + { + name: "invalid affinity label key", + customization: &v1.WebhookDeploymentCustomization{ + OverrideAffinity: &k8sv1.Affinity{ + NodeAffinity: &k8sv1.NodeAffinity{ + RequiredDuringSchedulingIgnoredDuringExecution: &k8sv1.NodeSelector{ + NodeSelectorTerms: []k8sv1.NodeSelectorTerm{ + { + MatchExpressions: []k8sv1.NodeSelectorRequirement{ + {Key: "-bad-key"}, + }, + }, + }, + }, + }, + }, + }, + validateFunc: validateFailedPaths([]string{ + "test.overrideAffinity.nodeAffinity.requiredDuringSchedulingIgnoredDuringExecution.nodeSelectorTerms[0].matchExpressions[0].key", + }), + }, + { + name: "valid PDB with minAvailable only", + customization: &v1.WebhookDeploymentCustomization{ + PodDisruptionBudget: &v1.PodDisruptionBudgetSpec{ + MinAvailable: "1", + }, + }, + validateFunc: validateFailedPaths([]string{}), + }, + { + name: "valid PDB with maxUnavailable only", + customization: &v1.WebhookDeploymentCustomization{ + PodDisruptionBudget: &v1.PodDisruptionBudgetSpec{ + MaxUnavailable: "1", + }, + }, + validateFunc: validateFailedPaths([]string{}), + }, + { + name: "valid PDB with percentage", + customization: &v1.WebhookDeploymentCustomization{ + PodDisruptionBudget: &v1.PodDisruptionBudgetSpec{ + MinAvailable: "50%", + }, + }, + validateFunc: validateFailedPaths([]string{}), + }, + { + name: "PDB both minAvailable and maxUnavailable set", + customization: &v1.WebhookDeploymentCustomization{ + PodDisruptionBudget: &v1.PodDisruptionBudgetSpec{ + MinAvailable: "1", + MaxUnavailable: "1", + }, + }, + validateFunc: validateFailedPaths([]string{"test.podDisruptionBudget"}), + }, + { + name: "PDB both set to zero", + customization: &v1.WebhookDeploymentCustomization{ + PodDisruptionBudget: &v1.PodDisruptionBudgetSpec{ + MinAvailable: "0", + MaxUnavailable: "0", + }, + }, + validateFunc: validateFailedPaths([]string{"test.podDisruptionBudget"}), + }, + { + name: "PDB both empty", + customization: &v1.WebhookDeploymentCustomization{ + PodDisruptionBudget: &v1.PodDisruptionBudgetSpec{}, + }, + validateFunc: validateFailedPaths([]string{"test.podDisruptionBudget"}), + }, + { + name: "PDB negative minAvailable", + customization: &v1.WebhookDeploymentCustomization{ + PodDisruptionBudget: &v1.PodDisruptionBudgetSpec{ + MinAvailable: "-1", + }, + }, + validateFunc: validateFailedPaths([]string{"test.podDisruptionBudget.minAvailable"}), + }, + { + name: "PDB invalid percentage", + customization: &v1.WebhookDeploymentCustomization{ + PodDisruptionBudget: &v1.PodDisruptionBudgetSpec{ + MinAvailable: "200%", + }, + }, + validateFunc: validateFailedPaths([]string{"test.podDisruptionBudget.minAvailable"}), + }, + { + name: "PDB negative maxUnavailable", + customization: &v1.WebhookDeploymentCustomization{ + PodDisruptionBudget: &v1.PodDisruptionBudgetSpec{ + MaxUnavailable: "-5", + }, + }, + validateFunc: validateFailedPaths([]string{"test.podDisruptionBudget.maxUnavailable"}), + }, + { + name: "full valid customization", + customization: &v1.WebhookDeploymentCustomization{ + ReplicaCount: replicaCount(3), + AppendTolerations: []k8sv1.Toleration{ + {Key: "cattle.io/node", Operator: k8sv1.TolerationOpExists}, + }, + OverrideAffinity: &k8sv1.Affinity{ + PodAntiAffinity: &k8sv1.PodAntiAffinity{ + PreferredDuringSchedulingIgnoredDuringExecution: []k8sv1.WeightedPodAffinityTerm{ + { + Weight: 100, + PodAffinityTerm: k8sv1.PodAffinityTerm{ + LabelSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "rancher-webhook"}, + }, + TopologyKey: "kubernetes.io/hostname", + }, + }, + }, + }, + }, + OverrideResourceRequirements: &k8sv1.ResourceRequirements{}, + PodDisruptionBudget: &v1.PodDisruptionBudgetSpec{ + MinAvailable: "1", + }, + }, + validateFunc: validateFailedPaths([]string{}), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var replicaCount *int32 + var tolerations []k8sv1.Toleration + var affinity *k8sv1.Affinity + var pdb *common.PDB + if tt.customization != nil { + replicaCount = tt.customization.ReplicaCount + tolerations = tt.customization.AppendTolerations + affinity = tt.customization.OverrideAffinity + if tt.customization.PodDisruptionBudget != nil { + pdb = &common.PDB{ + MinAvailable: tt.customization.PodDisruptionBudget.MinAvailable, + MaxUnavailable: tt.customization.PodDisruptionBudget.MaxUnavailable, + } + } + } + got := common.ValidateWebhookDeploymentCustomization(replicaCount, tolerations, affinity, pdb, field.NewPath("test")) + tt.validateFunc(t, got) + }) + } +} From fed25b1e87dd5cf36cf36edfb25d67aa9250df48 Mon Sep 17 00:00:00 2001 From: Chad Roberts Date: Tue, 9 Jun 2026 13:36:33 -0400 Subject: [PATCH 2/4] Extract validateWebhookDeploymentCustomization to fix Allowed field bug --- .../v3/cluster/validator.go | 35 +++++++++++++------ 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/pkg/resources/management.cattle.io/v3/cluster/validator.go b/pkg/resources/management.cattle.io/v3/cluster/validator.go index 1d78b0a191..416bf0e34a 100644 --- a/pkg/resources/management.cattle.io/v3/cluster/validator.go +++ b/pkg/resources/management.cattle.io/v3/cluster/validator.go @@ -131,16 +131,8 @@ func (a *admitter) Admit(request *admission.Request) (*admissionv1.AdmissionResp return response, err } - if wdc := newCluster.Spec.WebhookDeploymentCustomization; wdc != nil { - var pdb *common.PDB - if wdc.PodDisruptionBudget != nil { - pdb = &common.PDB{MinAvailable: wdc.PodDisruptionBudget.MinAvailable, MaxUnavailable: wdc.PodDisruptionBudget.MaxUnavailable} - } - if response.Result = common.ErrorListToStatus(common.ValidateWebhookDeploymentCustomization( - wdc.ReplicaCount, wdc.AppendTolerations, wdc.OverrideAffinity, pdb, - field.NewPath("spec", "webhookDeploymentCustomization"))); response.Result != nil { - return response, nil - } + if response, err = a.validateWebhookDeploymentCustomization(newCluster); err != nil || !response.Allowed { + return response, err } if a.settingCache != nil { @@ -400,6 +392,29 @@ func (a *admitter) validateSinglePodDisruptionBudget(oldPDB, newPDB *apisv3.PodD return admission.ResponseAllowed(), nil } +func (a *admitter) validateWebhookDeploymentCustomization(cluster *apisv3.Cluster) (*admissionv1.AdmissionResponse, error) { + wdc := cluster.Spec.WebhookDeploymentCustomization + if wdc == nil { + return admission.ResponseAllowed(), nil + } + + var pdb *common.PDB + if wdc.PodDisruptionBudget != nil { + pdb = &common.PDB{MinAvailable: wdc.PodDisruptionBudget.MinAvailable, MaxUnavailable: wdc.PodDisruptionBudget.MaxUnavailable} + } + + if errStatus := common.ErrorListToStatus(common.ValidateWebhookDeploymentCustomization( + wdc.ReplicaCount, wdc.AppendTolerations, wdc.OverrideAffinity, pdb, + field.NewPath("spec", "webhookDeploymentCustomization"))); errStatus != nil { + return &admissionv1.AdmissionResponse{ + Result: errStatus, + Allowed: false, + }, nil + } + + return admission.ResponseAllowed(), nil +} + func getSchedulingCustomization(cluster *apisv3.Cluster, agent common.AgentType) *apisv3.AgentSchedulingCustomization { if cluster == nil { return nil From d51c813b075c1f6267f32a48984d0a7e22b488bd Mon Sep 17 00:00:00 2001 From: Chad Roberts Date: Tue, 9 Jun 2026 13:47:17 -0400 Subject: [PATCH 3/4] Add Admit-level tests for WebhookDeploymentCustomization --- .../v3/cluster/validator_test.go | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/pkg/resources/management.cattle.io/v3/cluster/validator_test.go b/pkg/resources/management.cattle.io/v3/cluster/validator_test.go index 1cd34d1b39..1b287db013 100644 --- a/pkg/resources/management.cattle.io/v3/cluster/validator_test.go +++ b/pkg/resources/management.cattle.io/v3/cluster/validator_test.go @@ -483,6 +483,35 @@ func TestAdmit(t *testing.T) { }, expectAllowed: false, }, + { + name: "Create with invalid WebhookDeploymentCustomization", + operation: admissionv1.Create, + newCluster: v3.Cluster{ + Spec: v3.ClusterSpec{ + ClusterSpecBase: v3.ClusterSpecBase{ + WebhookDeploymentCustomization: &v3.WebhookDeploymentCustomization{ + ReplicaCount: &[]int32{0}[0], + }, + }, + }, + }, + expectAllowed: false, + expectedReason: metav1.StatusReasonInvalid, + }, + { + name: "Create with valid WebhookDeploymentCustomization", + operation: admissionv1.Create, + newCluster: v3.Cluster{ + Spec: v3.ClusterSpec{ + ClusterSpecBase: v3.ClusterSpecBase{ + WebhookDeploymentCustomization: &v3.WebhookDeploymentCustomization{ + ReplicaCount: &[]int32{3}[0], + }, + }, + }, + }, + expectAllowed: true, + }, } for _, tt := range tests { From 6f9d85bec346ab7efbc4f4144ba1fdcd8a580976 Mon Sep 17 00:00:00 2001 From: Chad Roberts Date: Tue, 9 Jun 2026 14:18:59 -0400 Subject: [PATCH 4/4] Extract validateWebhookDeploymentCustomization in provisioning validator --- .../v1/cluster/validator.go | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/pkg/resources/provisioning.cattle.io/v1/cluster/validator.go b/pkg/resources/provisioning.cattle.io/v1/cluster/validator.go index d7f0e2861d..134c82ba2e 100644 --- a/pkg/resources/provisioning.cattle.io/v1/cluster/validator.go +++ b/pkg/resources/provisioning.cattle.io/v1/cluster/validator.go @@ -144,16 +144,8 @@ func (p *provisioningAdmitter) Admit(request *admission.Request) (*admissionv1.A return response, nil } - if wdc := cluster.Spec.WebhookDeploymentCustomization; wdc != nil { - var pdb *common.PDB - if wdc.PodDisruptionBudget != nil { - pdb = &common.PDB{MinAvailable: wdc.PodDisruptionBudget.MinAvailable, MaxUnavailable: wdc.PodDisruptionBudget.MaxUnavailable} - } - if response.Result = common.ErrorListToStatus(common.ValidateWebhookDeploymentCustomization( - wdc.ReplicaCount, wdc.AppendTolerations, wdc.OverrideAffinity, pdb, - field.NewPath("spec", "webhookDeploymentCustomization"))); response.Result != nil { - return response, nil - } + if response.Result = validateWebhookDeploymentCustomization(cluster); response.Result != nil { + return response, nil } if err := p.validateCloudCredentialAccess(request, response, oldCluster, cluster); err != nil || response.Result != nil { @@ -922,6 +914,22 @@ func getSchedulingCustomization(cluster *v1.Cluster, agentType common.AgentType) return nil } +func validateWebhookDeploymentCustomization(cluster *v1.Cluster) *metav1.Status { + wdc := cluster.Spec.WebhookDeploymentCustomization + if wdc == nil { + return nil + } + + var pdb *common.PDB + if wdc.PodDisruptionBudget != nil { + pdb = &common.PDB{MinAvailable: wdc.PodDisruptionBudget.MinAvailable, MaxUnavailable: wdc.PodDisruptionBudget.MaxUnavailable} + } + + return common.ErrorListToStatus(common.ValidateWebhookDeploymentCustomization( + wdc.ReplicaCount, wdc.AppendTolerations, wdc.OverrideAffinity, pdb, + field.NewPath("spec", "webhookDeploymentCustomization"))) +} + func validateACEConfig(cluster *v1.Cluster) *metav1.Status { if cluster.Spec.RKEConfig != nil && cluster.Spec.LocalClusterAuthEndpoint.Enabled && cluster.Spec.LocalClusterAuthEndpoint.CACerts != "" && cluster.Spec.LocalClusterAuthEndpoint.FQDN == "" { return &metav1.Status{