Skip to content

Commit 8aefc88

Browse files
feat: add rp validating webhook (#312)
1 parent d0d5da4 commit 8aefc88

File tree

7 files changed

+789
-65
lines changed

7 files changed

+789
-65
lines changed

pkg/utils/validator/clusterresourceplacement.go renamed to pkg/utils/validator/placement.go

Lines changed: 104 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,24 +14,30 @@ See the License for the specific language governing permissions and
1414
limitations under the License.
1515
*/
1616

17-
// Package validator provides utils to validate cluster resource placement resource.
17+
// Package validator provides utils to validate all fleet custom resources.
1818
package validator
1919

2020
import (
21+
"context"
2122
"errors"
2223
"fmt"
24+
"net/http"
2325
"sort"
2426
"strings"
2527

28+
admissionv1 "k8s.io/api/admission/v1"
2629
corev1 "k8s.io/api/core/v1"
2730
"k8s.io/apimachinery/pkg/api/meta"
2831
"k8s.io/apimachinery/pkg/api/resource"
2932
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
3033
"k8s.io/apimachinery/pkg/runtime/schema"
34+
"k8s.io/apimachinery/pkg/types"
3135
apiErrors "k8s.io/apimachinery/pkg/util/errors"
3236
"k8s.io/apimachinery/pkg/util/intstr"
3337
"k8s.io/apimachinery/pkg/util/validation"
3438
"k8s.io/klog/v2"
39+
"sigs.k8s.io/controller-runtime/pkg/webhook"
40+
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
3541

3642
placementv1beta1 "github.com/kubefleet-dev/kubefleet/apis/placement/v1beta1"
3743
"github.com/kubefleet-dev/kubefleet/pkg/propertyprovider"
@@ -48,20 +54,26 @@ var (
4854
invalidTolerationValueErrFmt = "invalid toleration value %+v: %s"
4955
uniqueTolerationErrFmt = "toleration %+v already exists, tolerations must be unique"
5056

57+
// Webhook validation message format strings
58+
AllowUpdateOldInvalidFmt = "allow update on old invalid v1beta1 %s with DeletionTimestamp set"
59+
DenyUpdateOldInvalidFmt = "deny update on old invalid v1beta1 %s with DeletionTimestamp not set %s"
60+
DenyCreateUpdateInvalidFmt = "deny create/update v1beta1 %s has invalid fields %s"
61+
AllowModifyFmt = "any user is allowed to modify v1beta1 %s"
62+
5163
// Below is the map of supported capacity types.
5264
supportedResourceCapacityTypesMap = map[string]bool{propertyprovider.AllocatableCapacityName: true, propertyprovider.AvailableCapacityName: true, propertyprovider.TotalCapacityName: true}
5365
resourceCapacityTypes = supportedResourceCapacityTypes()
5466
)
5567

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

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

64-
for _, selector := range clusterResourcePlacement.Spec.ResourceSelectors {
76+
for _, selector := range resourceSelectors {
6577
if selector.LabelSelector != nil {
6678
if len(selector.Name) != 0 {
6779
allErr = append(allErr, fmt.Errorf("the labelSelector and name fields are mutually exclusive in selector %+v", selector))
@@ -84,29 +96,57 @@ func ValidateClusterResourcePlacement(clusterResourcePlacement *placementv1beta1
8496
Version: selector.Version,
8597
Kind: selector.Kind,
8698
}
87-
if !ResourceInformer.IsClusterScopedResources(gvk) {
99+
// Only check cluster scope for ClusterResourcePlacement
100+
if isClusterScoped && !ResourceInformer.IsClusterScopedResources(gvk) {
88101
allErr = append(allErr, fmt.Errorf("the resource is not found in schema (please retry) or it is not a cluster scoped resource: %v", gvk))
89102
}
103+
104+
// Only check namespace scope for ResourcePlacement
105+
if !isClusterScoped && ResourceInformer.IsClusterScopedResources(gvk) {
106+
allErr = append(allErr, fmt.Errorf("the resource is not found in schema (please retry) or it is a cluster scoped resource: %v", gvk))
107+
}
90108
} else {
91109
err := fmt.Errorf("cannot perform resource scope check for now, please retry")
92110
klog.ErrorS(controller.NewUnexpectedBehaviorError(err), "resource informer is nil")
93111
allErr = append(allErr, fmt.Errorf("cannot perform resource scope check for now, please retry"))
94112
}
95113
}
96114

97-
if clusterResourcePlacement.Spec.Policy != nil {
98-
if err := validatePlacementPolicy(clusterResourcePlacement.Spec.Policy); err != nil {
115+
if policy != nil {
116+
if err := validatePlacementPolicy(policy); err != nil {
99117
allErr = append(allErr, fmt.Errorf("the placement policy field is invalid: %w", err))
100118
}
101119
}
102120

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

107125
return apiErrors.NewAggregate(allErr)
108126
}
109127

128+
// ValidateClusterResourcePlacement validates a ClusterResourcePlacement object.
129+
func ValidateClusterResourcePlacement(clusterResourcePlacement *placementv1beta1.ClusterResourcePlacement) error {
130+
return validatePlacement(
131+
clusterResourcePlacement.Name,
132+
clusterResourcePlacement.Spec.ResourceSelectors,
133+
clusterResourcePlacement.Spec.Policy,
134+
clusterResourcePlacement.Spec.Strategy,
135+
true, // isClusterScoped
136+
)
137+
}
138+
139+
// ValidateResourcePlacement validates a ResourcePlacement object.
140+
func ValidateResourcePlacement(resourcePlacement *placementv1beta1.ResourcePlacement) error {
141+
return validatePlacement(
142+
resourcePlacement.Name,
143+
resourcePlacement.Spec.ResourceSelectors,
144+
resourcePlacement.Spec.Policy,
145+
resourcePlacement.Spec.Strategy,
146+
false, // isClusterScoped
147+
)
148+
}
149+
110150
func IsPlacementPolicyTypeUpdated(oldPolicy, currentPolicy *placementv1beta1.PlacementPolicy) bool {
111151
if oldPolicy == nil && currentPolicy != nil {
112152
// if placement policy is left blank, by default PickAll is chosen.
@@ -509,3 +549,58 @@ func supportedResourceCapacityTypes() []string {
509549
sort.Strings(capacityTypes)
510550
return capacityTypes
511551
}
552+
553+
// HandlePlacementValidation provides consolidated webhook validation logic for placement objects.
554+
// This function accepts higher-order functions for type-specific operations.
555+
func HandlePlacementValidation(
556+
ctx context.Context,
557+
req admission.Request,
558+
decoder webhook.AdmissionDecoder,
559+
resourceType string,
560+
decodeFunc func(admission.Request, webhook.AdmissionDecoder) (placementv1beta1.PlacementObj, error),
561+
decodeOldFunc func(admission.Request, webhook.AdmissionDecoder) (placementv1beta1.PlacementObj, error),
562+
validateFunc func(placementv1beta1.PlacementObj) error,
563+
) admission.Response {
564+
if req.Operation == admissionv1.Create || req.Operation == admissionv1.Update {
565+
klog.V(2).InfoS("handling placement", "resourceType", resourceType, "operation", req.Operation, "namespacedName", types.NamespacedName{Name: req.Name, Namespace: req.Namespace})
566+
567+
placement, err := decodeFunc(req, decoder)
568+
if err != nil {
569+
klog.ErrorS(err, "failed to decode v1beta1 placement object for create/update operation", "resourceType", resourceType, "userName", req.UserInfo.Username, "groups", req.UserInfo.Groups)
570+
return admission.Errored(http.StatusBadRequest, err)
571+
}
572+
573+
if req.Operation == admissionv1.Update {
574+
oldPlacement, err := decodeOldFunc(req, decoder)
575+
if err != nil {
576+
return admission.Errored(http.StatusBadRequest, err)
577+
}
578+
579+
// Special case: allow updates to old placement objects with invalid fields so that we can
580+
// update the placement to remove finalizer then delete it.
581+
if err := validateFunc(oldPlacement); err != nil {
582+
if placement.GetDeletionTimestamp() != nil {
583+
return admission.Allowed(fmt.Sprintf(AllowUpdateOldInvalidFmt, resourceType))
584+
}
585+
return admission.Denied(fmt.Sprintf(DenyUpdateOldInvalidFmt, resourceType, err))
586+
}
587+
588+
// Handle update case where placement type should be immutable.
589+
if IsPlacementPolicyTypeUpdated(oldPlacement.GetPlacementSpec().Policy, placement.GetPlacementSpec().Policy) {
590+
return admission.Denied("placement type is immutable")
591+
}
592+
593+
// Handle update case where existing tolerations were updated/deleted
594+
if IsTolerationsUpdatedOrDeleted(oldPlacement.GetPlacementSpec().Tolerations(), placement.GetPlacementSpec().Tolerations()) {
595+
return admission.Denied("tolerations have been updated/deleted, only additions to tolerations are allowed")
596+
}
597+
}
598+
599+
if err := validateFunc(placement); err != nil {
600+
klog.V(2).InfoS("v1beta1 placement has invalid fields, request is denied", "resourceType", resourceType, "operation", req.Operation, "namespacedName", types.NamespacedName{Name: placement.GetName(), Namespace: req.Namespace})
601+
return admission.Denied(fmt.Sprintf(DenyCreateUpdateInvalidFmt, resourceType, err))
602+
}
603+
}
604+
605+
return admission.Allowed(fmt.Sprintf(AllowModifyFmt, resourceType))
606+
}

pkg/utils/validator/clusterresourceplacement_test.go renamed to pkg/utils/validator/placement_test.go

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,29 @@ func TestValidateClusterResourcePlacement(t *testing.T) {
167167
wantErr: true,
168168
wantErrMsg: "cannot perform resource scope check for now, please retry",
169169
},
170+
"CRP with namespaced resource should fail": {
171+
crp: &placementv1beta1.ClusterResourcePlacement{
172+
ObjectMeta: metav1.ObjectMeta{
173+
Name: "test-crp",
174+
},
175+
Spec: placementv1beta1.PlacementSpec{
176+
ResourceSelectors: []placementv1beta1.ResourceSelectorTerm{
177+
{
178+
Group: "apps",
179+
Version: "v1",
180+
Kind: "Deployment",
181+
Name: "test-deployment",
182+
},
183+
},
184+
},
185+
},
186+
resourceInformer: &testinformer.FakeManager{
187+
APIResources: map[schema.GroupVersionKind]bool{utils.DeploymentGVK: true},
188+
IsClusterScopedResource: false, // Deployment is namespaced
189+
},
190+
wantErr: true,
191+
wantErrMsg: "resource is not found in schema (please retry) or it is not a cluster scoped resource",
192+
},
170193
}
171194
for testName, testCase := range tests {
172195
t.Run(testName, func(t *testing.T) {
@@ -1719,3 +1742,130 @@ func TestIsTolerationsUpdatedOrDeleted(t *testing.T) {
17191742
})
17201743
}
17211744
}
1745+
1746+
func TestValidateResourcePlacement(t *testing.T) {
1747+
tests := map[string]struct {
1748+
rp *placementv1beta1.ResourcePlacement
1749+
resourceInformer informer.Manager
1750+
wantErr bool
1751+
wantErrMsg string
1752+
}{
1753+
"RP with invalid placement policy": {
1754+
rp: &placementv1beta1.ResourcePlacement{
1755+
ObjectMeta: metav1.ObjectMeta{
1756+
Name: "test-rp",
1757+
},
1758+
Spec: placementv1beta1.PlacementSpec{
1759+
ResourceSelectors: []placementv1beta1.ResourceSelectorTerm{
1760+
{
1761+
Group: "apps",
1762+
Version: "v1",
1763+
Kind: "Deployment",
1764+
Name: "test-deployment",
1765+
},
1766+
},
1767+
Policy: &placementv1beta1.PlacementPolicy{
1768+
PlacementType: placementv1beta1.PickFixedPlacementType,
1769+
ClusterNames: []string{}, // Empty cluster names for PickFixed type
1770+
},
1771+
},
1772+
},
1773+
resourceInformer: &testinformer.FakeManager{
1774+
APIResources: map[schema.GroupVersionKind]bool{utils.DeploymentGVK: true},
1775+
IsClusterScopedResource: false,
1776+
},
1777+
wantErr: true,
1778+
wantErrMsg: "cluster names cannot be empty for policy type PickFixed",
1779+
},
1780+
"RP with invalid rollout strategy": {
1781+
rp: &placementv1beta1.ResourcePlacement{
1782+
ObjectMeta: metav1.ObjectMeta{
1783+
Name: "test-rp",
1784+
},
1785+
Spec: placementv1beta1.PlacementSpec{
1786+
ResourceSelectors: []placementv1beta1.ResourceSelectorTerm{
1787+
{
1788+
Group: "apps",
1789+
Version: "v1",
1790+
Kind: "Deployment",
1791+
Name: "test-deployment",
1792+
},
1793+
},
1794+
Strategy: placementv1beta1.RolloutStrategy{
1795+
Type: placementv1beta1.RollingUpdateRolloutStrategyType,
1796+
RollingUpdate: &placementv1beta1.RollingUpdateConfig{
1797+
MaxUnavailable: &intstr.IntOrString{Type: intstr.Int, IntVal: -1}, // Negative value
1798+
},
1799+
},
1800+
},
1801+
},
1802+
resourceInformer: &testinformer.FakeManager{
1803+
APIResources: map[schema.GroupVersionKind]bool{utils.DeploymentGVK: true},
1804+
IsClusterScopedResource: false,
1805+
},
1806+
wantErr: true,
1807+
wantErrMsg: "maxUnavailable must be greater than or equal to 0",
1808+
},
1809+
"RP with cluster scoped resource should fail": {
1810+
rp: &placementv1beta1.ResourcePlacement{
1811+
ObjectMeta: metav1.ObjectMeta{
1812+
Name: "test-rp",
1813+
Namespace: "test-namespace",
1814+
},
1815+
Spec: placementv1beta1.PlacementSpec{
1816+
ResourceSelectors: []placementv1beta1.ResourceSelectorTerm{
1817+
{
1818+
Group: "rbac.authorization.k8s.io",
1819+
Version: "v1",
1820+
Kind: "ClusterRole",
1821+
Name: "test-cluster-role",
1822+
},
1823+
},
1824+
},
1825+
},
1826+
resourceInformer: &testinformer.FakeManager{
1827+
APIResources: map[schema.GroupVersionKind]bool{utils.ClusterRoleGVK: true},
1828+
IsClusterScopedResource: true, // ClusterRole is cluster-scoped
1829+
},
1830+
wantErr: true,
1831+
wantErrMsg: "resource is not found in schema (please retry) or it is a cluster scoped resource",
1832+
},
1833+
"RP with namespaced resource should succeed": {
1834+
rp: &placementv1beta1.ResourcePlacement{
1835+
ObjectMeta: metav1.ObjectMeta{
1836+
Name: "test-rp",
1837+
Namespace: "test-namespace",
1838+
},
1839+
Spec: placementv1beta1.PlacementSpec{
1840+
ResourceSelectors: []placementv1beta1.ResourceSelectorTerm{
1841+
{
1842+
Group: "apps",
1843+
Version: "v1",
1844+
Kind: "Deployment",
1845+
Name: "test-deployment",
1846+
},
1847+
},
1848+
},
1849+
},
1850+
resourceInformer: &testinformer.FakeManager{
1851+
APIResources: map[schema.GroupVersionKind]bool{utils.DeploymentGVK: true},
1852+
IsClusterScopedResource: false, // Deployment is namespaced
1853+
},
1854+
wantErr: false,
1855+
},
1856+
}
1857+
1858+
for testName, testCase := range tests {
1859+
t.Run(testName, func(t *testing.T) {
1860+
RestMapper = utils.TestMapper{}
1861+
ResourceInformer = testCase.resourceInformer
1862+
gotErr := ValidateResourcePlacement(testCase.rp)
1863+
if (gotErr != nil) != testCase.wantErr {
1864+
t.Errorf("ValidateResourcePlacement() error = %v, wantErr %v", gotErr, testCase.wantErr)
1865+
}
1866+
if testCase.wantErr && !strings.Contains(gotErr.Error(), testCase.wantErrMsg) {
1867+
t.Errorf("ValidateResourcePlacement() got %v, should contain want %s", gotErr, testCase.wantErrMsg)
1868+
}
1869+
})
1870+
}
1871+
}

pkg/webhook/add_handler.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"github.com/kubefleet-dev/kubefleet/pkg/webhook/pod"
1111
"github.com/kubefleet-dev/kubefleet/pkg/webhook/replicaset"
1212
"github.com/kubefleet-dev/kubefleet/pkg/webhook/resourceoverride"
13+
"github.com/kubefleet-dev/kubefleet/pkg/webhook/resourceplacement"
1314
)
1415

1516
func init() {
@@ -18,6 +19,7 @@ func init() {
1819
// AddToManagerFuncs is a list of functions to register webhook validators and mutators to the webhook server
1920
AddToManagerFuncs = append(AddToManagerFuncs, clusterresourceplacement.AddMutating)
2021
AddToManagerFuncs = append(AddToManagerFuncs, clusterresourceplacement.Add)
22+
AddToManagerFuncs = append(AddToManagerFuncs, resourceplacement.Add)
2123
AddToManagerFuncs = append(AddToManagerFuncs, pod.Add)
2224
AddToManagerFuncs = append(AddToManagerFuncs, replicaset.Add)
2325
AddToManagerFuncs = append(AddToManagerFuncs, membercluster.Add)

0 commit comments

Comments
 (0)