Skip to content

Commit 018aebb

Browse files
committed
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
1 parent bf23b0c commit 018aebb

7 files changed

Lines changed: 779 additions & 0 deletions

File tree

docs.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,22 @@ Both `minAvailable` and `maxUnavailable` must be a string which represents a non
245245
^([0-9]|[1-9][0-9]|100)%$
246246
```
247247

248+
##### Feature: Webhook Deployment Customization
249+
250+
The `WebhookDeploymentCustomization` field configures the rancher-webhook deployment on downstream clusters. The following sub-fields are validated:
251+
252+
- `replicaCount`: If set, must be at least 1.
253+
- `appendTolerations`: Toleration keys are validated against the upstream apimachinery label name regex:
254+
```regex
255+
([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]
256+
```
257+
- `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).
258+
- `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:
259+
```regex
260+
^([0-9]|[1-9][0-9]|100)%$
261+
```
262+
- `overrideResourceRequirements`: Not validated by the webhook — the Kubernetes API server validates `ResourceRequirements` natively.
263+
248264
## ClusterProxyConfig
249265

250266
### Validation Checks
@@ -805,6 +821,22 @@ Both `minAvailable` and `maxUnavailable` must be a string which represents a non
805821
^([0-9]|[1-9][0-9]|100)%$
806822
```
807823

824+
##### cluster.spec.webhookDeploymentCustomization
825+
826+
The `WebhookDeploymentCustomization` field configures the rancher-webhook deployment on downstream clusters. The following sub-fields are validated:
827+
828+
- `replicaCount`: If set, must be at least 1.
829+
- `appendTolerations`: Toleration keys are validated against the same upstream apimachinery label name regex used for agent deployment customizations:
830+
```regex
831+
([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]
832+
```
833+
- `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).
834+
- `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:
835+
```regex
836+
^([0-9]|[1-9][0-9]|100)%$
837+
```
838+
- `overrideResourceRequirements`: Not validated by the webhook — the Kubernetes API server validates `ResourceRequirements` natively.
839+
808840
##### NO_PROXY value
809841

810842
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

pkg/resources/management.cattle.io/v3/cluster/Cluster.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,19 @@ Both `minAvailable` and `maxUnavailable` must be a string which represents a non
4343
```regex
4444
^([0-9]|[1-9][0-9]|100)%$
4545
```
46+
47+
#### Feature: Webhook Deployment Customization
48+
49+
The `WebhookDeploymentCustomization` field configures the rancher-webhook deployment on downstream clusters. The following sub-fields are validated:
50+
51+
- `replicaCount`: If set, must be at least 1.
52+
- `appendTolerations`: Toleration keys are validated against the upstream apimachinery label name regex:
53+
```regex
54+
([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]
55+
```
56+
- `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).
57+
- `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:
58+
```regex
59+
^([0-9]|[1-9][0-9]|100)%$
60+
```
61+
- `overrideResourceRequirements`: Not validated by the webhook — the Kubernetes API server validates `ResourceRequirements` natively.

pkg/resources/management.cattle.io/v3/cluster/validator.go

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"net/http"
77
"reflect"
88
"strconv"
9+
"strings"
910

1011
"github.com/blang/semver"
1112
apisv3 "github.com/rancher/rancher/pkg/apis/management.cattle.io/v3"
@@ -22,7 +23,9 @@ import (
2223
"k8s.io/apimachinery/pkg/api/equality"
2324
apierrors "k8s.io/apimachinery/pkg/api/errors"
2425
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
26+
"k8s.io/apimachinery/pkg/apis/meta/v1/validation"
2527
"k8s.io/apimachinery/pkg/runtime/schema"
28+
"k8s.io/apimachinery/pkg/util/validation/field"
2629
authorizationv1 "k8s.io/client-go/kubernetes/typed/authorization/v1"
2730
)
2831

@@ -137,6 +140,11 @@ func (a *admitter) Admit(request *admission.Request) (*admissionv1.AdmissionResp
137140
return response, err
138141
}
139142

143+
if response.Result = errorListToStatus(validateWebhookDeploymentCustomization(newCluster.Spec.WebhookDeploymentCustomization,
144+
field.NewPath("spec", "webhookDeploymentCustomization"))); response.Result != nil {
145+
return response, nil
146+
}
147+
140148
response, err = a.validatePSACT(oldCluster, newCluster, request.Operation)
141149
if err != nil {
142150
return nil, fmt.Errorf("failed to validate PodSecurityAdmissionConfigurationTemplate(PSACT): %w", err)
@@ -620,3 +628,177 @@ func (a *admitter) versionManagementEnabled(cluster *apisv3.Cluster) (bool, erro
620628
}
621629
return false, fmt.Errorf("the value of the %s annotation is invalid", VersionManagementAnno)
622630
}
631+
632+
func validateWebhookDeploymentCustomization(customization *apisv3.WebhookDeploymentCustomization, path *field.Path) field.ErrorList {
633+
if customization == nil {
634+
return nil
635+
}
636+
var errList field.ErrorList
637+
638+
if customization.ReplicaCount != nil && *customization.ReplicaCount < 1 {
639+
errList = append(errList, field.Invalid(path.Child("replicaCount"), *customization.ReplicaCount, "must be at least 1"))
640+
}
641+
642+
errList = append(errList, validateAppendToleration(customization.AppendTolerations, path.Child("appendTolerations"))...)
643+
errList = append(errList, validateAffinity(customization.OverrideAffinity, path.Child("overrideAffinity"))...)
644+
errList = append(errList, validateWebhookPDB(customization.PodDisruptionBudget, path.Child("podDisruptionBudget"))...)
645+
646+
return errList
647+
}
648+
649+
func validateWebhookPDB(pdb *apisv3.PodDisruptionBudgetSpec, path *field.Path) field.ErrorList {
650+
if pdb == nil {
651+
return nil
652+
}
653+
var errList field.ErrorList
654+
655+
minAvailStr := pdb.MinAvailable
656+
maxUnavailStr := pdb.MaxUnavailable
657+
658+
if (minAvailStr == "" && maxUnavailStr == "") ||
659+
(minAvailStr == "0" && maxUnavailStr == "0") ||
660+
(minAvailStr != "" && minAvailStr != "0") && (maxUnavailStr != "" && maxUnavailStr != "0") {
661+
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"))
662+
return errList
663+
}
664+
665+
if minAvailStr != "" {
666+
minAvailInt, err := strconv.Atoi(minAvailStr)
667+
if err != nil {
668+
if !common.PdbPercentageRegex.MatchString(minAvailStr) {
669+
errList = append(errList, field.Invalid(path.Child("minAvailable"), minAvailStr,
670+
fmt.Sprintf("must be a non-negative whole integer or a percentage value between 0 and 100, regex used is '%s'", common.PdbPercentageRegex.String())))
671+
}
672+
} else if minAvailInt < 0 {
673+
errList = append(errList, field.Invalid(path.Child("minAvailable"), minAvailStr, "cannot be a negative integer"))
674+
}
675+
}
676+
677+
if maxUnavailStr != "" {
678+
maxUnavailInt, err := strconv.Atoi(maxUnavailStr)
679+
if err != nil {
680+
if !common.PdbPercentageRegex.MatchString(maxUnavailStr) {
681+
errList = append(errList, field.Invalid(path.Child("maxUnavailable"), maxUnavailStr,
682+
fmt.Sprintf("must be a non-negative whole integer or a percentage value between 0 and 100, regex used is '%s'", common.PdbPercentageRegex.String())))
683+
}
684+
} else if maxUnavailInt < 0 {
685+
errList = append(errList, field.Invalid(path.Child("maxUnavailable"), maxUnavailStr, "cannot be a negative integer"))
686+
}
687+
}
688+
689+
return errList
690+
}
691+
692+
func validateAppendToleration(tolerations []corev1.Toleration, path *field.Path) field.ErrorList {
693+
var errList field.ErrorList
694+
for k, s := range tolerations {
695+
errList = append(errList, validation.ValidateLabelName(s.Key, path.Index(k))...)
696+
}
697+
return errList
698+
}
699+
700+
func validateAffinity(overrideAffinity *corev1.Affinity, path *field.Path) field.ErrorList {
701+
if overrideAffinity == nil {
702+
return nil
703+
}
704+
var errList field.ErrorList
705+
706+
if affinity := overrideAffinity.NodeAffinity; affinity != nil {
707+
errList = append(errList, validateNodeAffinity(affinity, path.Child("nodeAffinity"))...)
708+
}
709+
if affinity := overrideAffinity.PodAffinity; affinity != nil {
710+
errList = append(errList, validatePodAffinity(affinity, path.Child("podAffinity"))...)
711+
}
712+
if affinity := overrideAffinity.PodAntiAffinity; affinity != nil {
713+
errList = append(errList, validatePodAntiAffinity(affinity, path.Child("podAntiAffinity"))...)
714+
}
715+
716+
return errList
717+
}
718+
719+
func validateNodeAffinity(affinity *corev1.NodeAffinity, path *field.Path) field.ErrorList {
720+
var errList field.ErrorList
721+
if affinity.RequiredDuringSchedulingIgnoredDuringExecution != nil {
722+
for i, term := range affinity.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms {
723+
for j, expr := range term.MatchExpressions {
724+
errList = append(errList, validation.ValidateLabelName(expr.Key,
725+
path.Child("requiredDuringSchedulingIgnoredDuringExecution", "nodeSelectorTerms").Index(i).Child("matchExpressions").Index(j).Child("key"))...)
726+
}
727+
for j, f := range term.MatchFields {
728+
errList = append(errList, validation.ValidateLabelName(f.Key,
729+
path.Child("requiredDuringSchedulingIgnoredDuringExecution", "nodeSelectorTerms").Index(i).Child("matchFields").Index(j).Child("key"))...)
730+
}
731+
}
732+
}
733+
for i, preferred := range affinity.PreferredDuringSchedulingIgnoredDuringExecution {
734+
for j, expr := range preferred.Preference.MatchExpressions {
735+
errList = append(errList, validation.ValidateLabelName(expr.Key,
736+
path.Child("preferredDuringSchedulingIgnoredDuringExecution").Index(i).Child("preference", "matchExpressions").Index(j).Child("key"))...)
737+
}
738+
for j, f := range preferred.Preference.MatchFields {
739+
errList = append(errList, validation.ValidateLabelName(f.Key,
740+
path.Child("preferredDuringSchedulingIgnoredDuringExecution").Index(i).Child("preference", "matchFields").Index(j).Child("key"))...)
741+
}
742+
}
743+
return errList
744+
}
745+
746+
func validatePodAffinity(affinity *corev1.PodAffinity, path *field.Path) field.ErrorList {
747+
var errList field.ErrorList
748+
for i, term := range affinity.RequiredDuringSchedulingIgnoredDuringExecution {
749+
errList = append(errList, validateLabelSelector(term.LabelSelector,
750+
path.Child("requiredDuringSchedulingIgnoredDuringExecution").Index(i).Child("labelSelector"))...)
751+
}
752+
for i, term := range affinity.PreferredDuringSchedulingIgnoredDuringExecution {
753+
errList = append(errList, validateLabelSelector(term.PodAffinityTerm.LabelSelector,
754+
path.Child("preferredDuringSchedulingIgnoredDuringExecution").Index(i).Child("podAffinityTerm", "labelSelector"))...)
755+
}
756+
return errList
757+
}
758+
759+
func validatePodAntiAffinity(affinity *corev1.PodAntiAffinity, path *field.Path) field.ErrorList {
760+
var errList field.ErrorList
761+
for i, term := range affinity.RequiredDuringSchedulingIgnoredDuringExecution {
762+
errList = append(errList, validateLabelSelector(term.LabelSelector,
763+
path.Child("requiredDuringSchedulingIgnoredDuringExecution").Index(i).Child("labelSelector"))...)
764+
}
765+
for i, term := range affinity.PreferredDuringSchedulingIgnoredDuringExecution {
766+
errList = append(errList, validateLabelSelector(term.PodAffinityTerm.LabelSelector,
767+
path.Child("preferredDuringSchedulingIgnoredDuringExecution").Index(i).Child("podAffinityTerm", "labelSelector"))...)
768+
}
769+
return errList
770+
}
771+
772+
func validateLabelSelector(selector *metav1.LabelSelector, path *field.Path) field.ErrorList {
773+
if selector == nil {
774+
return nil
775+
}
776+
var errList field.ErrorList
777+
for key := range selector.MatchLabels {
778+
errList = append(errList, validation.ValidateLabelName(key, path.Child("matchLabels"))...)
779+
}
780+
for i, expr := range selector.MatchExpressions {
781+
errList = append(errList, validation.ValidateLabelName(expr.Key, path.Child("matchExpressions").Index(i))...)
782+
}
783+
return errList
784+
}
785+
786+
func errorListToStatus(errList field.ErrorList) *metav1.Status {
787+
if len(errList) == 0 {
788+
return nil
789+
}
790+
var builder strings.Builder
791+
builder.WriteString("* ")
792+
for i, fieldErr := range errList {
793+
builder.WriteString(fieldErr.Error())
794+
if i != len(errList)-1 {
795+
builder.WriteString("\n* ")
796+
}
797+
}
798+
return &metav1.Status{
799+
Status: "Failure",
800+
Message: builder.String(),
801+
Reason: metav1.StatusReasonInvalid,
802+
Code: http.StatusUnprocessableEntity,
803+
}
804+
}

0 commit comments

Comments
 (0)