diff --git a/pkg/utils/validator/clusterresourceplacement.go b/pkg/utils/validator/resourceplacement.go similarity index 94% rename from pkg/utils/validator/clusterresourceplacement.go rename to pkg/utils/validator/resourceplacement.go index 29fa95e4f..84d84669c 100644 --- a/pkg/utils/validator/clusterresourceplacement.go +++ b/pkg/utils/validator/resourceplacement.go @@ -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 ( @@ -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)) @@ -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 { @@ -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. diff --git a/pkg/utils/validator/clusterresourceplacement_test.go b/pkg/utils/validator/resourceplacement_test.go similarity index 95% rename from pkg/utils/validator/clusterresourceplacement_test.go rename to pkg/utils/validator/resourceplacement_test.go index aa92c5b15..1003ded9c 100644 --- a/pkg/utils/validator/clusterresourceplacement_test.go +++ b/pkg/utils/validator/resourceplacement_test.go @@ -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) + } + }) + } +} diff --git a/pkg/webhook/add_handler.go b/pkg/webhook/add_handler.go index e25d92255..08380e6b8 100644 --- a/pkg/webhook/add_handler.go +++ b/pkg/webhook/add_handler.go @@ -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() { @@ -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) diff --git a/pkg/webhook/resourceplacement/v1beta1_resourceplacement_validating_webhook.go b/pkg/webhook/resourceplacement/v1beta1_resourceplacement_validating_webhook.go new file mode 100644 index 000000000..e50d53659 --- /dev/null +++ b/pkg/webhook/resourceplacement/v1beta1_resourceplacement_validating_webhook.go @@ -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") +} diff --git a/pkg/webhook/resourceplacement/v1beta1_resourceplacement_validating_webhook_test.go b/pkg/webhook/resourceplacement/v1beta1_resourceplacement_validating_webhook_test.go new file mode 100644 index 000000000..1237dd231 --- /dev/null +++ b/pkg/webhook/resourceplacement/v1beta1_resourceplacement_validating_webhook_test.go @@ -0,0 +1,432 @@ +package resourceplacement + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "testing" + "time" + + admissionv1 "k8s.io/api/admission/v1" + authenticationv1 "k8s.io/api/authentication/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/utils/ptr" + "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/informer" + "github.com/kubefleet-dev/kubefleet/pkg/utils/validator" + testinformer "github.com/kubefleet-dev/kubefleet/test/utils/informer" + "github.com/stretchr/testify/assert" +) + +var ( + resourceSelector = placementv1beta1.ResourceSelectorTerm{ + Group: "rbac.authorization.k8s.io", + Version: "v1", + Kind: "ClusterRole", + Name: "test-cluster-role", + } + errString = "the rollout Strategy field is invalid: maxUnavailable must be greater than or equal to 0, got `-1`" +) + +func TestHandle(t *testing.T) { + invalidRPObject := &placementv1beta1.ClusterResourcePlacement{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rp", + Finalizers: []string{placementv1beta1.PlacementCleanupFinalizer}, + }, + Spec: placementv1beta1.PlacementSpec{ + Policy: &placementv1beta1.PlacementPolicy{ + PlacementType: placementv1beta1.PickAllPlacementType, + }, + ResourceSelectors: []placementv1beta1.ResourceSelectorTerm{resourceSelector}, + Strategy: placementv1beta1.RolloutStrategy{ + Type: placementv1beta1.RollingUpdateRolloutStrategyType, + RollingUpdate: &placementv1beta1.RollingUpdateConfig{ + MaxUnavailable: &intstr.IntOrString{Type: intstr.Int, IntVal: -1}, + }, + }, + }, + } + + invalidRPObjectDeletingFinalizersRemoved := &placementv1beta1.ClusterResourcePlacement{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rp", + Finalizers: []string{}, + DeletionTimestamp: ptr.To(metav1.NewTime(time.Now())), + }, + Spec: placementv1beta1.PlacementSpec{ + Policy: &placementv1beta1.PlacementPolicy{ + PlacementType: placementv1beta1.PickAllPlacementType, + }, + ResourceSelectors: []placementv1beta1.ResourceSelectorTerm{resourceSelector}, + Strategy: placementv1beta1.RolloutStrategy{ + Type: placementv1beta1.RollingUpdateRolloutStrategyType, + RollingUpdate: &placementv1beta1.RollingUpdateConfig{ + MaxUnavailable: &intstr.IntOrString{Type: intstr.Int, IntVal: -1}, + }, + }, + }, + } + + invalidRPObjectFinalizersRemoved := &placementv1beta1.ClusterResourcePlacement{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rp", + Finalizers: []string{}, + }, + Spec: placementv1beta1.PlacementSpec{ + Policy: &placementv1beta1.PlacementPolicy{ + PlacementType: placementv1beta1.PickAllPlacementType, + }, + ResourceSelectors: []placementv1beta1.ResourceSelectorTerm{resourceSelector}, + Strategy: placementv1beta1.RolloutStrategy{ + Type: placementv1beta1.RollingUpdateRolloutStrategyType, + RollingUpdate: &placementv1beta1.RollingUpdateConfig{ + MaxUnavailable: &intstr.IntOrString{Type: intstr.Int, IntVal: -1}, + }, + }, + }, + } + + validRPObject := &placementv1beta1.ClusterResourcePlacement{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rp", + Finalizers: []string{placementv1beta1.PlacementCleanupFinalizer}, + }, + Spec: placementv1beta1.PlacementSpec{ + Policy: &placementv1beta1.PlacementPolicy{ + PlacementType: placementv1beta1.PickAllPlacementType, + }, + ResourceSelectors: []placementv1beta1.ResourceSelectorTerm{resourceSelector}, + Strategy: placementv1beta1.RolloutStrategy{ + Type: placementv1beta1.RollingUpdateRolloutStrategyType, + }, + }, + } + + validRPObjectWithTolerations := &placementv1beta1.ClusterResourcePlacement{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rp", + }, + Spec: placementv1beta1.PlacementSpec{ + ResourceSelectors: []placementv1beta1.ResourceSelectorTerm{resourceSelector}, + Policy: &placementv1beta1.PlacementPolicy{ + PlacementType: placementv1beta1.PickAllPlacementType, + Tolerations: []placementv1beta1.Toleration{ + { + Key: "key1", + Value: "value1", + }, + }, + }, + Strategy: placementv1beta1.RolloutStrategy{ + Type: placementv1beta1.RollingUpdateRolloutStrategyType, + }, + }, + } + + updatedPlacementTypeRPObject := &placementv1beta1.ClusterResourcePlacement{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rp", + }, + Spec: placementv1beta1.PlacementSpec{ + Policy: &placementv1beta1.PlacementPolicy{ + PlacementType: placementv1beta1.PickNPlacementType, + NumberOfClusters: ptr.To(int32(2)), + }, + ResourceSelectors: []placementv1beta1.ResourceSelectorTerm{resourceSelector}, + Strategy: placementv1beta1.RolloutStrategy{ + Type: placementv1beta1.RollingUpdateRolloutStrategyType, + }, + }, + } + + validRPObjectBytes, err := json.Marshal(validRPObject) + assert.Nil(t, err) + invalidRPObjectBytes, err := json.Marshal(invalidRPObject) + assert.Nil(t, err) + invalidRPObjectDeletingFinalizersRemovedBytes, err := json.Marshal(invalidRPObjectDeletingFinalizersRemoved) + assert.Nil(t, err) + invalidRPObjectFinalizersRemovedBytes, err := json.Marshal(invalidRPObjectFinalizersRemoved) + assert.Nil(t, err) + updatedPlacementTypeRPObjectBytes, err := json.Marshal(updatedPlacementTypeRPObject) + assert.Nil(t, err) + validRPObjectWithTolerationsBytes, err := json.Marshal(validRPObjectWithTolerations) + assert.Nil(t, err) + + scheme := runtime.NewScheme() + err = placementv1beta1.AddToScheme(scheme) + assert.Nil(t, err) + decoder := admission.NewDecoder(scheme) + assert.Nil(t, err) + + testCases := map[string]struct { + req admission.Request + resourceValidator resourcePlacementValidator + resourceInformer informer.Manager + wantResponse admission.Response + }{ + "allow RP create": { + req: admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Name: "test-rp", + Object: runtime.RawExtension{ + Raw: validRPObjectBytes, + Object: validRPObject, + }, + UserInfo: authenticationv1.UserInfo{ + Username: "test-user", + Groups: []string{"system:masters"}, + }, + RequestKind: &utils.ClusterResourcePlacementMetaGVK, + Operation: admissionv1.Create, + }, + }, + resourceInformer: &testinformer.FakeManager{ + APIResources: map[schema.GroupVersionKind]bool{utils.ClusterRoleGVK: true}, + IsClusterScopedResource: true, + }, + resourceValidator: resourcePlacementValidator{ + decoder: decoder, + }, + wantResponse: admission.Allowed("any user is allowed to modify v1beta1 RP"), + }, + "deny RP create - invalid RP object": { + req: admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Name: "test-rp", + Object: runtime.RawExtension{ + Raw: invalidRPObjectBytes, + Object: invalidRPObject, + }, + UserInfo: authenticationv1.UserInfo{ + Username: "test-user", + Groups: []string{"system:masters"}, + }, + RequestKind: &utils.ClusterResourcePlacementMetaGVK, + Operation: admissionv1.Create, + }, + }, + resourceInformer: &testinformer.FakeManager{ + APIResources: map[schema.GroupVersionKind]bool{utils.ClusterRoleGVK: true}, + IsClusterScopedResource: true, + }, + resourceValidator: resourcePlacementValidator{ + decoder: decoder, + }, + wantResponse: admission.Denied(fmt.Sprintf(denyCreateUpdateInvalidRPFmt, errString)), + }, + "allow RP update - invalid old RP object, invalid new RP is deleting, finalizer removed": { + req: admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Name: "test-rp", + OldObject: runtime.RawExtension{ + Raw: invalidRPObjectBytes, + Object: invalidRPObject, + }, + Object: runtime.RawExtension{ + Raw: invalidRPObjectDeletingFinalizersRemovedBytes, + Object: invalidRPObjectDeletingFinalizersRemoved, + }, + UserInfo: authenticationv1.UserInfo{ + Username: "test-user", + Groups: []string{"system:masters"}, + }, + RequestKind: &utils.ClusterResourcePlacementMetaGVK, + Operation: admissionv1.Update, + }, + }, + resourceInformer: &testinformer.FakeManager{ + APIResources: map[schema.GroupVersionKind]bool{utils.ClusterRoleGVK: true}, + IsClusterScopedResource: true, + }, + resourceValidator: resourcePlacementValidator{ + decoder: decoder, + }, + wantResponse: admission.Allowed(allowUpdateOldInvalidRPFmt), + }, + "deny RP update - invalid old RP, invalid new RP is not deleting, finalizer removed": { + req: admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Name: "test-rp", + OldObject: runtime.RawExtension{ + Raw: invalidRPObjectBytes, + Object: invalidRPObject, + }, + Object: runtime.RawExtension{ + Raw: invalidRPObjectFinalizersRemovedBytes, + Object: invalidRPObjectFinalizersRemoved, + }, + UserInfo: authenticationv1.UserInfo{ + Username: "test-user", + Groups: []string{"system:masters"}, + }, + RequestKind: &utils.ClusterResourcePlacementMetaGVK, + Operation: admissionv1.Update, + }, + }, + resourceInformer: &testinformer.FakeManager{ + APIResources: map[schema.GroupVersionKind]bool{utils.ClusterRoleGVK: true}, + IsClusterScopedResource: true, + }, + resourceValidator: resourcePlacementValidator{ + decoder: decoder, + }, + wantResponse: admission.Denied(fmt.Sprintf(denyUpdateOldInvalidRPFmt, errString)), + }, + "deny RP update - valid old RP, invalid new RP, spec updated": { + req: admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Name: "test-rp", + OldObject: runtime.RawExtension{ + Raw: validRPObjectBytes, + Object: validRPObject, + }, + Object: runtime.RawExtension{ + Raw: invalidRPObjectBytes, + Object: invalidRPObject, + }, + UserInfo: authenticationv1.UserInfo{ + Username: "test-user", + Groups: []string{"system:masters"}, + }, + RequestKind: &utils.ClusterResourcePlacementMetaGVK, + Operation: admissionv1.Update, + }, + }, + resourceInformer: &testinformer.FakeManager{ + APIResources: map[schema.GroupVersionKind]bool{utils.ClusterRoleGVK: true}, + IsClusterScopedResource: true, + }, + resourceValidator: resourcePlacementValidator{ + decoder: decoder, + }, + wantResponse: admission.Denied(fmt.Sprintf(denyCreateUpdateInvalidRPFmt, errString)), + }, + "deny RP update - new RP immutable placement type": { + req: admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Name: "test-rp", + OldObject: runtime.RawExtension{ + Raw: validRPObjectBytes, + Object: validRPObject, + }, + Object: runtime.RawExtension{ + Raw: updatedPlacementTypeRPObjectBytes, + Object: updatedPlacementTypeRPObject, + }, + UserInfo: authenticationv1.UserInfo{ + Username: "test-user", + Groups: []string{"system:masters"}, + }, + RequestKind: &utils.ClusterResourcePlacementMetaGVK, + Operation: admissionv1.Update, + }, + }, + resourceInformer: &testinformer.FakeManager{ + APIResources: map[schema.GroupVersionKind]bool{utils.ClusterRoleGVK: true}, + IsClusterScopedResource: true, + }, + resourceValidator: resourcePlacementValidator{ + decoder: decoder, + }, + wantResponse: admission.Denied("placement type is immutable"), + }, + "deny RP update - new RP tolerations updated": { + req: admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Name: "test-rp", + OldObject: runtime.RawExtension{ + Raw: validRPObjectWithTolerationsBytes, + Object: validRPObjectWithTolerations, + }, + Object: runtime.RawExtension{ + Raw: validRPObjectBytes, + Object: validRPObject, + }, + UserInfo: authenticationv1.UserInfo{ + Username: "test-user", + Groups: []string{"system:masters"}, + }, + RequestKind: &utils.ClusterResourcePlacementMetaGVK, + Operation: admissionv1.Update, + }, + }, + resourceInformer: &testinformer.FakeManager{ + APIResources: map[schema.GroupVersionKind]bool{utils.ClusterRoleGVK: true}, + IsClusterScopedResource: true, + }, + resourceValidator: resourcePlacementValidator{ + decoder: decoder, + }, + wantResponse: admission.Denied("tolerations have been updated/deleted, only additions to tolerations are allowed"), + }, + "decode error for main object": { + req: admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Name: "test-rp", + Object: runtime.RawExtension{ + Raw: []byte(`{"invalid": "json"`), // Invalid JSON + }, + Operation: admissionv1.Create, + }, + }, + resourceInformer: &testinformer.FakeManager{ + APIResources: map[schema.GroupVersionKind]bool{utils.ClusterRoleGVK: true}, + IsClusterScopedResource: true, + }, + resourceValidator: resourcePlacementValidator{ + decoder: decoder, + }, + wantResponse: admission.Errored(http.StatusBadRequest, fmt.Errorf("couldn't get version/kind; json parse error: unexpected end of JSON input")), + }, + "decode error for old object during update": { + req: admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Name: "test-rp", + Object: runtime.RawExtension{ + Raw: func() []byte { + validRP := &placementv1beta1.ResourcePlacement{ + ObjectMeta: metav1.ObjectMeta{Name: "test-rp"}, + Spec: placementv1beta1.PlacementSpec{ + ResourceSelectors: []placementv1beta1.ResourceSelectorTerm{ + {Group: "apps", Version: "v1", Kind: "Deployment", Name: "test"}, + }, + }, + } + validRPBytes, _ := json.Marshal(validRP) + return validRPBytes + }(), + }, + OldObject: runtime.RawExtension{ + Raw: []byte(`{"invalid": "json"`), // Invalid JSON for old object + }, + Operation: admissionv1.Update, + }, + }, + resourceInformer: &testinformer.FakeManager{ + APIResources: map[schema.GroupVersionKind]bool{utils.ClusterRoleGVK: true}, + IsClusterScopedResource: true, + }, + resourceValidator: resourcePlacementValidator{ + decoder: decoder, + }, + wantResponse: admission.Errored(http.StatusBadRequest, fmt.Errorf("couldn't get version/kind; json parse error: unexpected end of JSON input")), + }, + } + + for testName, testCase := range testCases { + t.Run(testName, func(t *testing.T) { + validator.RestMapper = utils.TestMapper{} + validator.ResourceInformer = testCase.resourceInformer + gotResult := testCase.resourceValidator.Handle(context.Background(), testCase.req) + assert.Equal(t, testCase.wantResponse, gotResult, utils.TestCaseMsg, testName) + }) + } +}