Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/

// Package validator provides utils to validate cluster resource placement resource.
// Package validator provides utils to validate both cluster resource placement and resource placement resource.
package validator

import (
Expand Down Expand Up @@ -53,15 +53,15 @@ var (
resourceCapacityTypes = supportedResourceCapacityTypes()
)

// ValidateClusterResourcePlacement validates a ClusterResourcePlacement object.
func ValidateClusterResourcePlacement(clusterResourcePlacement *placementv1beta1.ClusterResourcePlacement) error {
// validatePlacement validates a placement object (either ClusterResourcePlacement or ResourcePlacement).
func validatePlacement(name string, resourceSelectors []placementv1beta1.ResourceSelectorTerm, policy *placementv1beta1.PlacementPolicy, strategy placementv1beta1.RolloutStrategy, isClusterScoped bool) error {
allErr := make([]error, 0)

if len(clusterResourcePlacement.Name) > validation.DNS1035LabelMaxLength {
if len(name) > validation.DNS1035LabelMaxLength {
allErr = append(allErr, fmt.Errorf("the name field cannot have length exceeding %d", validation.DNS1035LabelMaxLength))
}

for _, selector := range clusterResourcePlacement.Spec.ResourceSelectors {
for _, selector := range resourceSelectors {
if selector.LabelSelector != nil {
if len(selector.Name) != 0 {
allErr = append(allErr, fmt.Errorf("the labelSelector and name fields are mutually exclusive in selector %+v", selector))
Expand All @@ -84,7 +84,8 @@ func ValidateClusterResourcePlacement(clusterResourcePlacement *placementv1beta1
Version: selector.Version,
Kind: selector.Kind,
}
if !ResourceInformer.IsClusterScopedResources(gvk) {
// Only check cluster scope for ClusterResourcePlacement
if isClusterScoped && !ResourceInformer.IsClusterScopedResources(gvk) {
allErr = append(allErr, fmt.Errorf("the resource is not found in schema (please retry) or it is not a cluster scoped resource: %v", gvk))
}
} else {
Expand All @@ -94,19 +95,41 @@ func ValidateClusterResourcePlacement(clusterResourcePlacement *placementv1beta1
}
}

if clusterResourcePlacement.Spec.Policy != nil {
if err := validatePlacementPolicy(clusterResourcePlacement.Spec.Policy); err != nil {
if policy != nil {
if err := validatePlacementPolicy(policy); err != nil {
allErr = append(allErr, fmt.Errorf("the placement policy field is invalid: %w", err))
}
}

if err := validateRolloutStrategy(clusterResourcePlacement.Spec.Strategy); err != nil {
if err := validateRolloutStrategy(strategy); err != nil {
allErr = append(allErr, fmt.Errorf("the rollout Strategy field is invalid: %w", err))
}

return apiErrors.NewAggregate(allErr)
}

// ValidateClusterResourcePlacement validates a ClusterResourcePlacement object.
func ValidateClusterResourcePlacement(clusterResourcePlacement *placementv1beta1.ClusterResourcePlacement) error {
return validatePlacement(
clusterResourcePlacement.Name,
clusterResourcePlacement.Spec.ResourceSelectors,
clusterResourcePlacement.Spec.Policy,
clusterResourcePlacement.Spec.Strategy,
true, // isClusterScoped
)
}

// ValidateResourcePlacement validates a ResourcePlacement object.
func ValidateResourcePlacement(resourcePlacement *placementv1beta1.ResourcePlacement) error {
return validatePlacement(
resourcePlacement.Name,
resourcePlacement.Spec.ResourceSelectors,
resourcePlacement.Spec.Policy,
resourcePlacement.Spec.Strategy,
false, // isClusterScoped
)
}

func IsPlacementPolicyTypeUpdated(oldPolicy, currentPolicy *placementv1beta1.PlacementPolicy) bool {
if oldPolicy == nil && currentPolicy != nil {
// if placement policy is left blank, by default PickAll is chosen.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1719,3 +1719,83 @@ func TestIsTolerationsUpdatedOrDeleted(t *testing.T) {
})
}
}

func TestValidateResourcePlacement(t *testing.T) {
tests := map[string]struct {
rp *placementv1beta1.ResourcePlacement
resourceInformer informer.Manager
wantErr bool
wantErrMsg string
}{
"RP with invalid placement policy": {
rp: &placementv1beta1.ResourcePlacement{
ObjectMeta: metav1.ObjectMeta{
Name: "test-rp",
},
Spec: placementv1beta1.PlacementSpec{
ResourceSelectors: []placementv1beta1.ResourceSelectorTerm{
{
Group: "apps",
Version: "v1",
Kind: "Deployment",
Name: "test-deployment",
},
},
Policy: &placementv1beta1.PlacementPolicy{
PlacementType: placementv1beta1.PickFixedPlacementType,
ClusterNames: []string{}, // Empty cluster names for PickFixed type
},
},
},
resourceInformer: &testinformer.FakeManager{
APIResources: map[schema.GroupVersionKind]bool{utils.DeploymentGVK: true},
IsClusterScopedResource: false,
},
wantErr: true,
wantErrMsg: "cluster names cannot be empty for policy type PickFixed",
},
"RP with invalid rollout strategy": {
rp: &placementv1beta1.ResourcePlacement{
ObjectMeta: metav1.ObjectMeta{
Name: "test-rp",
},
Spec: placementv1beta1.PlacementSpec{
ResourceSelectors: []placementv1beta1.ResourceSelectorTerm{
{
Group: "apps",
Version: "v1",
Kind: "Deployment",
Name: "test-deployment",
},
},
Strategy: placementv1beta1.RolloutStrategy{
Type: placementv1beta1.RollingUpdateRolloutStrategyType,
RollingUpdate: &placementv1beta1.RollingUpdateConfig{
MaxUnavailable: &intstr.IntOrString{Type: intstr.Int, IntVal: -1}, // Negative value
},
},
},
},
resourceInformer: &testinformer.FakeManager{
APIResources: map[schema.GroupVersionKind]bool{utils.DeploymentGVK: true},
IsClusterScopedResource: false,
},
wantErr: true,
wantErrMsg: "maxUnavailable must be greater than or equal to 0",
},
}

for testName, testCase := range tests {
t.Run(testName, func(t *testing.T) {
RestMapper = utils.TestMapper{}
ResourceInformer = testCase.resourceInformer
gotErr := ValidateResourcePlacement(testCase.rp)
if (gotErr != nil) != testCase.wantErr {
t.Errorf("ValidateResourcePlacement() error = %v, wantErr %v", gotErr, testCase.wantErr)
}
if testCase.wantErr && !strings.Contains(gotErr.Error(), testCase.wantErrMsg) {
t.Errorf("ValidateResourcePlacement() got %v, should contain want %s", gotErr, testCase.wantErrMsg)
}
})
}
}
2 changes: 2 additions & 0 deletions pkg/webhook/add_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"github.com/kubefleet-dev/kubefleet/pkg/webhook/pod"
"github.com/kubefleet-dev/kubefleet/pkg/webhook/replicaset"
"github.com/kubefleet-dev/kubefleet/pkg/webhook/resourceoverride"
"github.com/kubefleet-dev/kubefleet/pkg/webhook/resourceplacement"
)

func init() {
Expand All @@ -18,6 +19,7 @@ func init() {
// AddToManagerFuncs is a list of functions to register webhook validators and mutators to the webhook server
AddToManagerFuncs = append(AddToManagerFuncs, clusterresourceplacement.AddMutating)
AddToManagerFuncs = append(AddToManagerFuncs, clusterresourceplacement.Add)
AddToManagerFuncs = append(AddToManagerFuncs, resourceplacement.Add)
AddToManagerFuncs = append(AddToManagerFuncs, pod.Add)
AddToManagerFuncs = append(AddToManagerFuncs, replicaset.Add)
AddToManagerFuncs = append(AddToManagerFuncs, membercluster.Add)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/*
Copyright 2025 The KubeFleet Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

// Package resourceplacement implements the webhook for v1beta1 ResourcePlacement.
package resourceplacement

import (
"context"
"fmt"
"net/http"

admissionv1 "k8s.io/api/admission/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/klog/v2"
"sigs.k8s.io/controller-runtime/pkg/manager"
"sigs.k8s.io/controller-runtime/pkg/webhook"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"

placementv1beta1 "github.com/kubefleet-dev/kubefleet/apis/placement/v1beta1"
"github.com/kubefleet-dev/kubefleet/pkg/utils"
"github.com/kubefleet-dev/kubefleet/pkg/utils/validator"
)

const (
allowUpdateOldInvalidRPFmt = "allow update on old invalid v1beta1 RP with DeletionTimestamp set"
denyUpdateOldInvalidRPFmt = "deny update on old invalid v1beta1 RP with DeletionTimestamp not set %s"
denyCreateUpdateInvalidRPFmt = "deny create/update v1beta1 RP has invalid fields %s"
)

var (
// ValidationPath is the webhook service path which admission requests are routed to for validating v1beta1 RP resources.
ValidationPath = fmt.Sprintf(utils.ValidationPathFmt, placementv1beta1.GroupVersion.Group, placementv1beta1.GroupVersion.Version, "resourceplacement")
)

type resourcePlacementValidator struct {
decoder webhook.AdmissionDecoder
}

// Add registers the webhook for K8s bulit-in object types.
func Add(mgr manager.Manager) error {
hookServer := mgr.GetWebhookServer()
hookServer.Register(ValidationPath, &webhook.Admission{Handler: &resourcePlacementValidator{admission.NewDecoder(mgr.GetScheme())}})
return nil
}

// Handle resourcePlacementValidator handles create, update RP requests.
func (v *resourcePlacementValidator) Handle(_ context.Context, req admission.Request) admission.Response {
var rp placementv1beta1.ResourcePlacement
if req.Operation == admissionv1.Create || req.Operation == admissionv1.Update {
klog.V(2).InfoS("handling RP", "operation", req.Operation, "namespacedName", types.NamespacedName{Name: req.Name})
if err := v.decoder.Decode(req, &rp); err != nil {
klog.ErrorS(err, "failed to decode v1beta1 RP object for create/update operation", "userName", req.UserInfo.Username, "groups", req.UserInfo.Groups)
return admission.Errored(http.StatusBadRequest, err)
}
if req.Operation == admissionv1.Update {
var oldRP placementv1beta1.ResourcePlacement
if err := v.decoder.DecodeRaw(req.OldObject, &oldRP); err != nil {
return admission.Errored(http.StatusBadRequest, err)
}
// this is a special case where we allow updates to old v1beta1 RP with invalid fields so that we can
// update the RP to remove finalizer then delete RP.
if err := validator.ValidateResourcePlacement(&oldRP); err != nil {
if rp.DeletionTimestamp != nil {
return admission.Allowed(allowUpdateOldInvalidRPFmt)
}
return admission.Denied(fmt.Sprintf(denyUpdateOldInvalidRPFmt, err))
}
// handle update case where placement type should be immutable.
if validator.IsPlacementPolicyTypeUpdated(oldRP.Spec.Policy, rp.Spec.Policy) {
return admission.Denied("placement type is immutable")
}
// handle update case where existing tolerations were updated/deleted
if validator.IsTolerationsUpdatedOrDeleted(oldRP.Spec.Tolerations(), rp.Spec.Tolerations()) {
return admission.Denied("tolerations have been updated/deleted, only additions to tolerations are allowed")
}
}
if err := validator.ValidateResourcePlacement(&rp); err != nil {
klog.V(2).InfoS("v1beta1 resource placement has invalid fields, request is denied", "operation", req.Operation, "namespacedName", types.NamespacedName{Name: rp.Name})
return admission.Denied(fmt.Sprintf(denyCreateUpdateInvalidRPFmt, err))
}
}
klog.V(2).InfoS("user is allowed to modify v1beta1 resource placement", "operation", req.Operation, "user", req.UserInfo.Username, "group", req.UserInfo.Groups, "namespacedName", types.NamespacedName{Name: rp.Name})
return admission.Allowed("any user is allowed to modify v1beta1 RP")
}
Loading
Loading