Skip to content

Commit a84bc6f

Browse files
authored
Merge pull request #3624 from k8s-infra-cherrypick-robot/cherry-pick-3621-to-release-1.14
[release-1.14] 🐛Remove the CEL validation on VSphereMachineTemplate.spec.network
2 parents 63f70e9 + eca48a3 commit a84bc6f

File tree

8 files changed

+277
-27
lines changed

8 files changed

+277
-27
lines changed

apis/vmware/v1beta1/vspheremachine_types.go

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@ type VSphereMachineVolume struct {
3838
}
3939

4040
// VSphereMachineSpec defines the desired state of VSphereMachine.
41-
// +kubebuilder:validation:XValidation:rule="has(self.network) == has(oldSelf.network)",message="field 'network' cannot be added or removed after creation"
4241
type VSphereMachineSpec struct {
4342
// ProviderID is the virtual machine's BIOS UUID formatted as
4443
// vsphere://12345678-1234-1234-1234-123456789abc.
@@ -102,7 +101,6 @@ type VSphereMachineSpec struct {
102101
}
103102

104103
// VSphereMachineNetworkSpec defines the network configuration of a VSphereMachine.
105-
// +kubebuilder:validation:XValidation:rule="has(self.interfaces) == has(oldSelf.interfaces)",message="field 'interfaces' cannot be added or removed after creation"
106104
// +kubebuilder:validation:MinProperties=1
107105
type VSphereMachineNetworkSpec struct {
108106
// interfaces is the list of network interfaces attached to this VSphereMachine.
@@ -117,7 +115,6 @@ func (r *VSphereMachineNetworkSpec) IsDefined() bool {
117115
}
118116

119117
// InterfacesSpec defines all the network interfaces of a VSphereMachine from Kubernetes perspective.
120-
// +kubebuilder:validation:XValidation:rule="has(self.primary) == has(oldSelf.primary)",message="field 'primary' cannot be added or removed after creation"
121118
// +kubebuilder:validation:MinProperties=1
122119
type InterfacesSpec struct {
123120
// primary is the primary network interface.

config/supervisor/crd/bases/vmware.infrastructure.cluster.x-k8s.io_vspheremachines.yaml

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -298,13 +298,7 @@ spec:
298298
type: array
299299
x-kubernetes-list-type: atomic
300300
type: object
301-
x-kubernetes-validations:
302-
- message: field 'primary' cannot be added or removed after creation
303-
rule: has(self.primary) == has(oldSelf.primary)
304301
type: object
305-
x-kubernetes-validations:
306-
- message: field 'interfaces' cannot be added or removed after creation
307-
rule: has(self.interfaces) == has(oldSelf.interfaces)
308302
powerOffMode:
309303
default: hard
310304
description: |-
@@ -366,9 +360,6 @@ spec:
366360
- className
367361
- imageName
368362
type: object
369-
x-kubernetes-validations:
370-
- message: field 'network' cannot be added or removed after creation
371-
rule: has(self.network) == has(oldSelf.network)
372363
status:
373364
description: VSphereMachineStatus defines the observed state of VSphereMachine.
374365
properties:

config/supervisor/crd/bases/vmware.infrastructure.cluster.x-k8s.io_vspheremachinetemplates.yaml

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -295,15 +295,7 @@ spec:
295295
type: array
296296
x-kubernetes-list-type: atomic
297297
type: object
298-
x-kubernetes-validations:
299-
- message: field 'primary' cannot be added or removed
300-
after creation
301-
rule: has(self.primary) == has(oldSelf.primary)
302298
type: object
303-
x-kubernetes-validations:
304-
- message: field 'interfaces' cannot be added or removed after
305-
creation
306-
rule: has(self.interfaces) == has(oldSelf.interfaces)
307299
powerOffMode:
308300
default: hard
309301
description: |-
@@ -365,9 +357,6 @@ spec:
365357
- className
366358
- imageName
367359
type: object
368-
x-kubernetes-validations:
369-
- message: field 'network' cannot be added or removed after creation
370-
rule: has(self.network) == has(oldSelf.network)
371360
required:
372361
- spec
373362
type: object

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ require (
105105
github.com/gobuffalo/flect v1.0.3 // indirect
106106
github.com/gogo/protobuf v1.3.2 // indirect
107107
github.com/google/cel-go v0.23.2 // indirect
108-
github.com/google/go-cmp v0.7.0 // indirect
108+
github.com/google/go-cmp v0.7.0
109109
github.com/inconshreveable/mousetrap v1.1.0 // indirect
110110
github.com/josharian/intern v1.0.0 // indirect
111111
github.com/json-iterator/go v1.1.12 // indirect

internal/webhooks/vmware/vspheremachinetemplate.go

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,14 @@ import (
2525
"k8s.io/apimachinery/pkg/runtime"
2626
"k8s.io/apimachinery/pkg/util/validation"
2727
"k8s.io/apimachinery/pkg/util/validation/field"
28+
"sigs.k8s.io/cluster-api/util/topology"
2829
ctrl "sigs.k8s.io/controller-runtime"
2930
"sigs.k8s.io/controller-runtime/pkg/webhook"
3031
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
3132

3233
vmwarev1 "sigs.k8s.io/cluster-api-provider-vsphere/apis/vmware/v1beta1"
3334
"sigs.k8s.io/cluster-api-provider-vsphere/pkg/services/vmoperator"
35+
"sigs.k8s.io/cluster-api-provider-vsphere/pkg/util"
3436
)
3537

3638
// +kubebuilder:webhook:verbs=create;update,path=/validate-vmware-infrastructure-cluster-x-k8s-io-v1beta1-vspheremachinetemplate,mutating=false,failurePolicy=fail,matchPolicy=Equivalent,groups=vmware.infrastructure.cluster.x-k8s.io,resources=vspheremachinetemplates,versions=v1beta1,name=validation.vspheremachinetemplate.vmware.infrastructure.cluster.x-k8s.io,sideEffects=None,admissionReviewVersions=v1beta1
@@ -60,12 +62,33 @@ func (webhook *VSphereMachineTemplate) ValidateCreate(ctx context.Context, obj r
6062
}
6163

6264
// ValidateUpdate implements webhook.Validator so a webhook will be registered for the type.
63-
func (webhook *VSphereMachineTemplate) ValidateUpdate(ctx context.Context, _, newRaw runtime.Object) (admission.Warnings, error) {
64-
vSphereMachineTemplate, ok := newRaw.(*vmwarev1.VSphereMachineTemplate)
65+
func (webhook *VSphereMachineTemplate) ValidateUpdate(ctx context.Context, oldRaw runtime.Object, newRaw runtime.Object) (admission.Warnings, error) {
66+
newObj, ok := newRaw.(*vmwarev1.VSphereMachineTemplate)
6567
if !ok {
6668
return nil, apierrors.NewBadRequest(fmt.Sprintf("expected a VSphereMachineTemplate but got a %T", newRaw))
6769
}
68-
return webhook.validate(ctx, nil, vSphereMachineTemplate)
70+
oldObj, ok := oldRaw.(*vmwarev1.VSphereMachineTemplate)
71+
if !ok {
72+
return nil, apierrors.NewBadRequest(fmt.Sprintf("expected a VSphereMachineTemplate but got a %T", oldRaw))
73+
}
74+
75+
req, err := admission.RequestFromContext(ctx)
76+
if err != nil {
77+
return nil, apierrors.NewBadRequest(fmt.Sprintf("expected a admission.Request inside context: %v", err))
78+
}
79+
if !topology.IsDryRunRequest(req, newObj) {
80+
equal, diff, err := util.Diff(oldObj.Spec.Template.Spec, newObj.Spec.Template.Spec)
81+
if err != nil {
82+
return nil, apierrors.NewBadRequest(fmt.Sprintf("failed to compare old and new VSphereMachineTemplate: %v", err))
83+
}
84+
if !equal {
85+
return nil, apierrors.NewInvalid(vmwarev1.GroupVersion.WithKind("VSphereMachineTemplate").GroupKind(), newObj.Name, field.ErrorList{
86+
field.Invalid(field.NewPath("spec", "template", "spec"), newObj, fmt.Sprintf("VSphereMachineTemplate spec.template.spec field is immutable. Please create a new resource instead. Diff: %s", diff)),
87+
})
88+
}
89+
}
90+
91+
return webhook.validate(ctx, nil, newObj)
6992
}
7093

7194
func (webhook *VSphereMachineTemplate) validate(_ context.Context, _, newVSphereMachineTemplate *vmwarev1.VSphereMachineTemplate) (admission.Warnings, error) {

internal/webhooks/vmware/vspheremachinetemplate_test.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,12 @@ import (
2121
"testing"
2222

2323
. "github.com/onsi/gomega"
24+
admissionv1 "k8s.io/api/admission/v1"
25+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2426
featuregatetesting "k8s.io/component-base/featuregate/testing"
2527
"k8s.io/utils/ptr"
28+
clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2"
29+
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
2630

2731
vmwarev1 "sigs.k8s.io/cluster-api-provider-vsphere/apis/vmware/v1beta1"
2832
"sigs.k8s.io/cluster-api-provider-vsphere/feature"
@@ -303,3 +307,96 @@ func TestVSphereMachineTemplate_ValidateInterfaces(t *testing.T) {
303307
})
304308
}
305309
}
310+
311+
func TestVSphereMachineTemplate_ValidateUpdate_Immutability(t *testing.T) {
312+
oldTemplate := vmwarev1.VSphereMachineTemplate{
313+
ObjectMeta: metav1.ObjectMeta{
314+
Name: "test-template",
315+
Namespace: "default",
316+
},
317+
Spec: vmwarev1.VSphereMachineTemplateSpec{
318+
Template: vmwarev1.VSphereMachineTemplateResource{
319+
Spec: vmwarev1.VSphereMachineSpec{
320+
ImageName: "ubuntu-20.04",
321+
ClassName: "best-effort-small",
322+
StorageClass: "fast-storage",
323+
},
324+
},
325+
},
326+
}
327+
328+
newTemplate := oldTemplate.DeepCopy()
329+
newTemplate.Spec.Template.Spec.ImageName = "ubuntu-22.04"
330+
331+
newTemplateSkipImmutabilityAnnotationSet := newTemplate.DeepCopy()
332+
newTemplateSkipImmutabilityAnnotationSet.SetAnnotations(map[string]string{clusterv1.TopologyDryRunAnnotation: ""})
333+
334+
tests := []struct {
335+
name string
336+
newTemplate *vmwarev1.VSphereMachineTemplate
337+
oldTemplate *vmwarev1.VSphereMachineTemplate
338+
req *admission.Request
339+
wantError bool
340+
wantErrMsg string
341+
}{
342+
{
343+
name: "return no error if no modification",
344+
newTemplate: &oldTemplate,
345+
oldTemplate: &oldTemplate,
346+
req: &admission.Request{AdmissionRequest: admissionv1.AdmissionRequest{DryRun: ptr.To(false)}},
347+
wantError: false,
348+
},
349+
{
350+
name: "don't allow modification of spec.template.spec",
351+
newTemplate: newTemplate,
352+
oldTemplate: &oldTemplate,
353+
req: &admission.Request{AdmissionRequest: admissionv1.AdmissionRequest{DryRun: ptr.To(false)}},
354+
wantError: true,
355+
wantErrMsg: "VSphereMachineTemplate spec.template.spec field is immutable",
356+
},
357+
{
358+
name: "don't allow modification even with skip immutability annotation when not dry run",
359+
newTemplate: newTemplateSkipImmutabilityAnnotationSet,
360+
oldTemplate: &oldTemplate,
361+
req: &admission.Request{AdmissionRequest: admissionv1.AdmissionRequest{DryRun: ptr.To(false)}},
362+
wantError: true,
363+
wantErrMsg: "VSphereMachineTemplate spec.template.spec field is immutable",
364+
},
365+
{
366+
name: "don't allow modification when dry run but no skip immutability annotation",
367+
newTemplate: newTemplate,
368+
oldTemplate: &oldTemplate,
369+
req: &admission.Request{AdmissionRequest: admissionv1.AdmissionRequest{DryRun: ptr.To(true)}},
370+
wantError: true,
371+
wantErrMsg: "VSphereMachineTemplate spec.template.spec field is immutable",
372+
},
373+
{
374+
name: "skip immutability check when dry run and skip immutability annotation set",
375+
newTemplate: newTemplateSkipImmutabilityAnnotationSet,
376+
oldTemplate: &oldTemplate,
377+
req: &admission.Request{AdmissionRequest: admissionv1.AdmissionRequest{DryRun: ptr.To(true)}},
378+
wantError: false,
379+
},
380+
}
381+
382+
for _, tc := range tests {
383+
t.Run(tc.name, func(t *testing.T) {
384+
g := NewWithT(t)
385+
webhook := &VSphereMachineTemplate{}
386+
ctx := context.Background()
387+
if tc.req != nil {
388+
ctx = admission.NewContextWithRequest(ctx, *tc.req)
389+
}
390+
warnings, err := webhook.ValidateUpdate(ctx, tc.oldTemplate, tc.newTemplate)
391+
if tc.wantError {
392+
g.Expect(err).To(HaveOccurred())
393+
if tc.wantErrMsg != "" {
394+
g.Expect(err.Error()).To(ContainSubstring(tc.wantErrMsg))
395+
}
396+
} else {
397+
g.Expect(err).NotTo(HaveOccurred())
398+
}
399+
g.Expect(warnings).To(BeEmpty())
400+
})
401+
}
402+
}

pkg/util/equal.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/*
2+
Copyright 2024 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
// Package util contains utils to compare objects.
18+
package util
19+
20+
import (
21+
"strings"
22+
23+
"github.com/google/go-cmp/cmp"
24+
"github.com/pkg/errors"
25+
)
26+
27+
// Diff uses cmp.Diff to compare x and y.
28+
// If cmp.Diff panics, Equal returns an error.
29+
func Diff(x any, y any) (equal bool, diff string, matchErr error) {
30+
defer func() {
31+
if r := recover(); r != nil {
32+
equal = false
33+
if err, ok := r.(error); ok {
34+
matchErr = errors.Wrapf(err, "error diffing objects")
35+
return
36+
}
37+
38+
if errMsg, ok := r.(string); ok {
39+
matchErr = errors.Errorf("error diffing objects: %s", errMsg)
40+
return
41+
}
42+
43+
matchErr = errors.Errorf("error diffing objects: panic of unknown type %T", r)
44+
}
45+
}()
46+
47+
diff = cmp.Diff(x, y)
48+
49+
if diff != "" {
50+
// Replace non-breaking space (NBSP) through a regular space.
51+
// This prevents output like this: "\u00a0\u00a0int(\n-\u00a0\t1,\n+\u00a0\t2,\n\u00a0\u00a0)\n"
52+
diff = strings.ReplaceAll(diff, "\u00a0", " ")
53+
// Replace \t through " " because it's easier to read in log output
54+
diff = strings.ReplaceAll(diff, "\t", " ")
55+
diff = strings.TrimSpace(diff)
56+
}
57+
58+
return diff == "", diff, nil
59+
}

pkg/util/equal_test.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/*
2+
Copyright 2024 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package util
18+
19+
import (
20+
"testing"
21+
22+
. "github.com/onsi/gomega"
23+
clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2"
24+
)
25+
26+
func TestDiff(t *testing.T) {
27+
type test struct {
28+
name string
29+
x, y interface{}
30+
wantEqual bool
31+
wantDiff string
32+
wantError string
33+
}
34+
35+
tests := []test{
36+
{
37+
name: "Equal integers, no diff",
38+
x: 1,
39+
y: 1,
40+
wantEqual: true,
41+
},
42+
{
43+
name: "Different integers, diff",
44+
x: 1,
45+
y: 2,
46+
wantEqual: false,
47+
wantDiff: `int(
48+
- 1,
49+
+ 2,
50+
)`,
51+
},
52+
{
53+
name: "Different labels, diff",
54+
x: map[string]string{
55+
clusterv1.ClusterNameLabel: "cluster-1",
56+
clusterv1.ClusterTopologyOwnedLabel: "",
57+
},
58+
y: map[string]string{
59+
clusterv1.ClusterNameLabel: "cluster-2",
60+
},
61+
wantEqual: false,
62+
wantDiff: `map[string]string{
63+
- "cluster.x-k8s.io/cluster-name": "cluster-1",
64+
+ "cluster.x-k8s.io/cluster-name": "cluster-2",
65+
- "topology.cluster.x-k8s.io/owned": "",
66+
}`,
67+
},
68+
{
69+
name: "Diff unexported fields, error",
70+
x: struct{ a, b, c int }{1, 2, 3},
71+
y: struct{ a, b, c int }{1, 2, 4},
72+
wantError: `error diffing objects: cannot handle unexported field at root.a:
73+
"sigs.k8s.io/cluster-api-provider-vsphere/pkg/util".(struct { a int; b int; c int })
74+
consider using a custom Comparer; if you control the implementation of type, you can also consider using an Exporter, AllowUnexported, or cmpopts.IgnoreUnexported`,
75+
},
76+
}
77+
78+
for _, tt := range tests {
79+
t.Run(tt.name, func(t *testing.T) {
80+
g := NewWithT(t)
81+
82+
gotEqual, gotDiff, err := Diff(tt.x, tt.y)
83+
84+
if tt.wantError != "" {
85+
g.Expect(err.Error()).To(BeComparableTo(tt.wantError))
86+
} else {
87+
g.Expect(err).ToNot(HaveOccurred())
88+
89+
g.Expect(gotEqual).To(Equal(tt.wantEqual))
90+
g.Expect(gotDiff).To(BeComparableTo(tt.wantDiff))
91+
}
92+
})
93+
}
94+
}

0 commit comments

Comments
 (0)