1
16
17 package integration_test
18
19 import (
20 "bytes"
21 "context"
22 "encoding/json"
23 "errors"
24 "fmt"
25 "io"
26 "io/fs"
27 "os"
28 "path/filepath"
29 "strings"
30 "testing"
31 "time"
32
33 jsonpatch "github.com/evanphx/json-patch"
34
35 apiextensionsinternal "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions"
36 apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
37 structuralschema "k8s.io/apiextensions-apiserver/pkg/apiserver/schema"
38 apiservervalidation "k8s.io/apiextensions-apiserver/pkg/apiserver/validation"
39 "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset"
40 "k8s.io/apiextensions-apiserver/pkg/features"
41 "k8s.io/apiextensions-apiserver/pkg/registry/customresource"
42 "k8s.io/apiextensions-apiserver/test/integration/fixtures"
43 apierrors "k8s.io/apimachinery/pkg/api/errors"
44 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
45 "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
46 "k8s.io/apimachinery/pkg/runtime"
47 "k8s.io/apimachinery/pkg/runtime/schema"
48 "k8s.io/apimachinery/pkg/util/uuid"
49 "k8s.io/apimachinery/pkg/util/wait"
50 utilyaml "k8s.io/apimachinery/pkg/util/yaml"
51 utilfeature "k8s.io/apiserver/pkg/util/feature"
52 "k8s.io/client-go/dynamic"
53 featuregatetesting "k8s.io/component-base/featuregate/testing"
54 "k8s.io/kube-openapi/pkg/validation/spec"
55 "k8s.io/kube-openapi/pkg/validation/strfmt"
56 )
57
58 var stringSchema *apiextensionsv1.JSONSchemaProps = &apiextensionsv1.JSONSchemaProps{
59 Type: "string",
60 }
61
62 var stringMapSchema *apiextensionsv1.JSONSchemaProps = &apiextensionsv1.JSONSchemaProps{
63 Type: "object",
64 AdditionalProperties: &apiextensionsv1.JSONSchemaPropsOrBool{
65 Schema: stringSchema,
66 },
67 }
68
69 var numberSchema *apiextensionsv1.JSONSchemaProps = &apiextensionsv1.JSONSchemaProps{
70 Type: "integer",
71 }
72
73 var numbersMapSchema *apiextensionsv1.JSONSchemaProps = &apiextensionsv1.JSONSchemaProps{
74 Type: "object",
75 AdditionalProperties: &apiextensionsv1.JSONSchemaPropsOrBool{
76 Schema: numberSchema,
77 },
78 }
79
80 type ratchetingTestContext struct {
81 *testing.T
82 DynamicClient dynamic.Interface
83 APIExtensionsClient clientset.Interface
84 }
85
86 type ratchetingTestOperation interface {
87 Do(ctx *ratchetingTestContext) error
88 Description() string
89 }
90
91 type expectError struct {
92 op ratchetingTestOperation
93 }
94
95 func (e expectError) Do(ctx *ratchetingTestContext) error {
96 err := e.op.Do(ctx)
97 if err != nil {
98 return nil
99 }
100 return errors.New("expected error")
101 }
102
103 func (e expectError) Description() string {
104 return fmt.Sprintf("Expect Error: %v", e.op.Description())
105 }
106
107
108 var fakeRESTMapper map[schema.GroupVersionResource]string = map[schema.GroupVersionResource]string{
109 myCRDV1Beta1: "MyCoolCRD",
110 }
111
112
113
114
115
116
117
118 func FixTabsOrDie(in string) string {
119 lines := bytes.Split([]byte(in), []byte{'\n'})
120 if len(lines[0]) == 0 && len(lines) > 1 {
121 lines = lines[1:]
122 }
123
124 var prefix []byte
125 for _, c := range lines[0] {
126 if c != '\t' {
127 break
128 }
129 prefix = append(prefix, byte('\t'))
130 }
131
132 for i := range lines {
133 line := lines[i]
134
135 if i == len(lines)-1 && len(line) <= len(prefix) && bytes.TrimSpace(line) == nil {
136 lines[i] = []byte{}
137 break
138 }
139 if !bytes.HasPrefix(line, prefix) {
140 panic(fmt.Errorf("line %d doesn't start with expected number (%d) of tabs: %v", i, len(prefix), string(line)))
141 }
142 lines[i] = line[len(prefix):]
143 }
144 joined := string(bytes.Join(lines, []byte{'\n'}))
145
146
147
148 return strings.ReplaceAll(joined, "\t", " ")
149 }
150
151 type applyPatchOperation struct {
152 description string
153 gvr schema.GroupVersionResource
154 name string
155 patch interface{}
156 }
157
158 func (a applyPatchOperation) Do(ctx *ratchetingTestContext) error {
159
160 kind, ok := fakeRESTMapper[a.gvr]
161 if !ok {
162 return fmt.Errorf("no mapping found for Gvr %v, add entry to fakeRESTMapper", a.gvr)
163 }
164
165 patch := &unstructured.Unstructured{}
166 if obj, ok := a.patch.(map[string]interface{}); ok {
167 patch.Object = obj
168 } else if str, ok := a.patch.(string); ok {
169 str = FixTabsOrDie(str)
170 if err := utilyaml.NewYAMLOrJSONDecoder(strings.NewReader(str), len(str)).Decode(&patch.Object); err != nil {
171 return err
172 }
173 } else {
174 return fmt.Errorf("invalid patch type: %T", a.patch)
175 }
176
177 patch.SetKind(kind)
178 patch.SetAPIVersion(a.gvr.GroupVersion().String())
179 patch.SetName(a.name)
180 patch.SetNamespace("default")
181
182 _, err := ctx.DynamicClient.
183 Resource(a.gvr).
184 Namespace(patch.GetNamespace()).
185 Apply(
186 context.TODO(),
187 patch.GetName(),
188 patch,
189 metav1.ApplyOptions{
190 FieldManager: "manager",
191 })
192
193 return err
194
195 }
196
197 func (a applyPatchOperation) Description() string {
198 return a.description
199 }
200
201
202 type updateMyCRDV1Beta1Schema struct {
203 newSchema *apiextensionsv1.JSONSchemaProps
204 }
205
206 func (u updateMyCRDV1Beta1Schema) Do(ctx *ratchetingTestContext) error {
207 var myCRD *apiextensionsv1.CustomResourceDefinition
208 var err error = apierrors.NewConflict(schema.GroupResource{}, "", nil)
209 for apierrors.IsConflict(err) {
210 myCRD, err = ctx.APIExtensionsClient.ApiextensionsV1().CustomResourceDefinitions().Get(context.TODO(), myCRDV1Beta1.Resource+"."+myCRDV1Beta1.Group, metav1.GetOptions{})
211 if err != nil {
212 return err
213 }
214
215
216
217 sch := u.newSchema.DeepCopy()
218 if sch.Properties == nil {
219 sch.Properties = map[string]apiextensionsv1.JSONSchemaProps{}
220 }
221
222 uuidString := string(uuid.NewUUID())
223 sentinelName := "__ratcheting_sentinel_field__"
224 sch.Properties[sentinelName] = apiextensionsv1.JSONSchemaProps{
225 Type: "string",
226 Enum: []apiextensionsv1.JSON{{
227 Raw: []byte(`"` + uuidString + `"`),
228 }},
229 }
230
231 for _, v := range myCRD.Spec.Versions {
232 if v.Name != myCRDV1Beta1.Version {
233 continue
234 }
235 v.Schema.OpenAPIV3Schema = sch
236 }
237
238 _, err = ctx.APIExtensionsClient.ApiextensionsV1().CustomResourceDefinitions().Update(context.TODO(), myCRD, metav1.UpdateOptions{
239 FieldManager: "manager",
240 })
241 if err != nil {
242 return err
243 }
244
245
246
247
248 counter := 0
249 return wait.PollUntilContextCancel(context.TODO(), 100*time.Millisecond, true, func(_ context.Context) (done bool, err error) {
250 counter += 1
251 err = applyPatchOperation{
252 gvr: myCRDV1Beta1,
253 name: "sentinel-resource",
254 patch: map[string]interface{}{
255 sentinelName: fmt.Sprintf("invalid-%d", counter),
256 }}.Do(ctx)
257
258 if err == nil {
259 return false, errors.New("expected error when creating sentinel resource")
260 }
261
262
263
264
265 if strings.Contains(err.Error(), uuidString) {
266 return true, nil
267 }
268
269 return false, nil
270 })
271 }
272 return err
273 }
274
275 func (u updateMyCRDV1Beta1Schema) Description() string {
276 return "Update CRD schema"
277 }
278
279 type patchMyCRDV1Beta1Schema struct {
280 description string
281 patch map[string]interface{}
282 }
283
284 func (p patchMyCRDV1Beta1Schema) Do(ctx *ratchetingTestContext) error {
285 var err error
286 patchJSON, err := json.Marshal(p.patch)
287 if err != nil {
288 return err
289 }
290
291 myCRD, err := ctx.APIExtensionsClient.ApiextensionsV1().CustomResourceDefinitions().Get(context.TODO(), myCRDV1Beta1.Resource+"."+myCRDV1Beta1.Group, metav1.GetOptions{})
292 if err != nil {
293 return err
294 }
295
296 for _, v := range myCRD.Spec.Versions {
297 if v.Name != myCRDV1Beta1.Version {
298 continue
299 }
300
301 jsonSchema, err := json.Marshal(v.Schema.OpenAPIV3Schema)
302 if err != nil {
303 return err
304 }
305
306 merged, err := jsonpatch.MergePatch(jsonSchema, patchJSON)
307 if err != nil {
308 return err
309 }
310
311 var parsed apiextensionsv1.JSONSchemaProps
312 if err := json.Unmarshal(merged, &parsed); err != nil {
313 return err
314 }
315
316 return updateMyCRDV1Beta1Schema{
317 newSchema: &parsed,
318 }.Do(ctx)
319 }
320
321 return fmt.Errorf("could not find version %v in CRD %v", myCRDV1Beta1.Version, myCRD.Name)
322 }
323
324 func (p patchMyCRDV1Beta1Schema) Description() string {
325 return p.description
326 }
327
328 type ratchetingTestCase struct {
329 Name string
330 Disabled bool
331 Operations []ratchetingTestOperation
332 }
333
334 func runTests(t *testing.T, cases []ratchetingTestCase) {
335 defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.CRDValidationRatcheting, true)()
336 tearDown, apiExtensionClient, dynamicClient, err := fixtures.StartDefaultServerWithClients(t)
337 if err != nil {
338 t.Fatal(err)
339 }
340 defer tearDown()
341
342 group := myCRDV1Beta1.Group
343 version := myCRDV1Beta1.Version
344 resource := myCRDV1Beta1.Resource
345 kind := fakeRESTMapper[myCRDV1Beta1]
346
347 myCRD := &apiextensionsv1.CustomResourceDefinition{
348 ObjectMeta: metav1.ObjectMeta{Name: resource + "." + group},
349 Spec: apiextensionsv1.CustomResourceDefinitionSpec{
350 Group: group,
351 Versions: []apiextensionsv1.CustomResourceDefinitionVersion{{
352 Name: version,
353 Served: true,
354 Storage: true,
355 Schema: &apiextensionsv1.CustomResourceValidation{
356 OpenAPIV3Schema: &apiextensionsv1.JSONSchemaProps{
357 Type: "object",
358 Properties: map[string]apiextensionsv1.JSONSchemaProps{
359 "content": {
360 Type: "object",
361 AdditionalProperties: &apiextensionsv1.JSONSchemaPropsOrBool{
362 Schema: &apiextensionsv1.JSONSchemaProps{
363 Type: "string",
364 },
365 },
366 },
367 "num": {
368 Type: "object",
369 AdditionalProperties: &apiextensionsv1.JSONSchemaPropsOrBool{
370 Schema: &apiextensionsv1.JSONSchemaProps{
371 Type: "integer",
372 },
373 },
374 },
375 },
376 },
377 },
378 }},
379 Names: apiextensionsv1.CustomResourceDefinitionNames{
380 Plural: resource,
381 Kind: kind,
382 ListKind: kind + "List",
383 },
384 Scope: apiextensionsv1.NamespaceScoped,
385 },
386 }
387
388 _, err = fixtures.CreateNewV1CustomResourceDefinition(myCRD, apiExtensionClient, dynamicClient)
389 if err != nil {
390 t.Fatal(err)
391 }
392 for _, c := range cases {
393 if c.Disabled {
394 continue
395 }
396
397 t.Run(c.Name, func(t *testing.T) {
398 ctx := &ratchetingTestContext{
399 T: t,
400 DynamicClient: dynamicClient,
401 APIExtensionsClient: apiExtensionClient,
402 }
403
404 for i, op := range c.Operations {
405 t.Logf("Performing Operation: %v", op.Description())
406 if err := op.Do(ctx); err != nil {
407 t.Fatalf("failed %T operation %v: %v\n%v", op, i, err, op)
408 }
409 }
410
411
412 err := ctx.DynamicClient.Resource(myCRDV1Beta1).Namespace("default").DeleteCollection(context.TODO(), metav1.DeleteOptions{}, metav1.ListOptions{})
413 if err != nil {
414 t.Fatal(err)
415 }
416 })
417 }
418 }
419
420 var myCRDV1Beta1 schema.GroupVersionResource = schema.GroupVersionResource{
421 Group: "mygroup.example.com",
422 Version: "v1beta1",
423 Resource: "mycrds",
424 }
425
426 var myCRDInstanceName string = "mycrdinstance"
427
428 func TestRatchetingFunctionality(t *testing.T) {
429 cases := []ratchetingTestCase{
430 {
431 Name: "Minimum Maximum",
432 Operations: []ratchetingTestOperation{
433 updateMyCRDV1Beta1Schema{&apiextensionsv1.JSONSchemaProps{
434 Type: "object",
435 Properties: map[string]apiextensionsv1.JSONSchemaProps{
436 "hasMinimum": *numberSchema,
437 "hasMaximum": *numberSchema,
438 "hasMinimumAndMaximum": *numberSchema,
439 },
440 }},
441 applyPatchOperation{
442 "Create an object that complies with the schema",
443 myCRDV1Beta1,
444 myCRDInstanceName,
445 map[string]interface{}{
446 "hasMinimum": 0,
447 "hasMaximum": 1000,
448 "hasMinimumAndMaximum": 50,
449 }},
450 patchMyCRDV1Beta1Schema{
451 "Add stricter minimums and maximums that violate the previous object",
452 map[string]interface{}{
453 "properties": map[string]interface{}{
454 "hasMinimum": map[string]interface{}{
455 "minimum": 10,
456 },
457 "hasMaximum": map[string]interface{}{
458 "maximum": 20,
459 },
460 "hasMinimumAndMaximum": map[string]interface{}{
461 "minimum": 10,
462 "maximum": 20,
463 },
464 "noRestrictions": map[string]interface{}{
465 "type": "integer",
466 },
467 },
468 }},
469 applyPatchOperation{
470 "Add new fields that validates successfully without changing old ones",
471 myCRDV1Beta1,
472 myCRDInstanceName,
473 map[string]interface{}{
474 "noRestrictions": 50,
475 }},
476 expectError{
477 applyPatchOperation{
478 "Change a single old field to be invalid",
479 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
480 "hasMinimum": 5,
481 }},
482 },
483 expectError{
484 applyPatchOperation{
485 "Change multiple old fields to be invalid",
486 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
487 "hasMinimum": 5,
488 "hasMaximum": 21,
489 }},
490 },
491 applyPatchOperation{
492 "Change single old field to be valid",
493 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
494 "hasMinimum": 11,
495 }},
496 applyPatchOperation{
497 "Change multiple old fields to be valid",
498 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
499 "hasMaximum": 19,
500 "hasMinimumAndMaximum": 15,
501 }},
502 },
503 },
504 {
505 Name: "Enum",
506 Operations: []ratchetingTestOperation{
507
508 updateMyCRDV1Beta1Schema{&apiextensionsv1.JSONSchemaProps{
509 Type: "object",
510 Properties: map[string]apiextensionsv1.JSONSchemaProps{
511 "enumField": *stringSchema,
512 },
513 }},
514 applyPatchOperation{
515 "Create an instance with a soon-to-be-invalid value",
516 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
517 "enumField": "okValueNowBadValueLater",
518 }},
519 patchMyCRDV1Beta1Schema{
520 "restrict `enumField` to an enum of A, B, or C",
521 map[string]interface{}{
522 "properties": map[string]interface{}{
523 "enumField": map[string]interface{}{
524 "enum": []interface{}{
525 "A", "B", "C",
526 },
527 },
528 "otherField": map[string]interface{}{
529 "type": "string",
530 },
531 },
532 }},
533 applyPatchOperation{
534 "An invalid patch with no changes is a noop",
535 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
536 "enumField": "okValueNowBadValueLater",
537 }},
538 applyPatchOperation{
539 "Add a new field, and include old value in our patch",
540 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
541 "enumField": "okValueNowBadValueLater",
542 "otherField": "anythingGoes",
543 }},
544 expectError{
545 applyPatchOperation{
546 "Set enumField to invalid value D",
547 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
548 "enumField": "D",
549 }},
550 },
551 applyPatchOperation{
552 "Set to a valid value",
553 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
554 "enumField": "A",
555 }},
556 expectError{
557 applyPatchOperation{
558 "After setting a valid value, return to the old, accepted value",
559 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
560 "enumField": "okValueNowBadValueLater",
561 }},
562 },
563 },
564 },
565 {
566 Name: "AdditionalProperties",
567 Operations: []ratchetingTestOperation{
568 updateMyCRDV1Beta1Schema{&apiextensionsv1.JSONSchemaProps{
569 Type: "object",
570 Properties: map[string]apiextensionsv1.JSONSchemaProps{
571 "nums": *numbersMapSchema,
572 "content": *stringMapSchema,
573 },
574 }},
575 applyPatchOperation{
576 "Create an instance",
577 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
578 "nums": map[string]interface{}{
579 "num1": 1,
580 "num2": 1000000,
581 },
582 "content": map[string]interface{}{
583 "k1": "some content",
584 "k2": "other content",
585 },
586 }},
587 patchMyCRDV1Beta1Schema{
588 "set minimum value for fields with additionalProperties",
589 map[string]interface{}{
590 "properties": map[string]interface{}{
591 "nums": map[string]interface{}{
592 "additionalProperties": map[string]interface{}{
593 "minimum": 1000,
594 },
595 },
596 },
597 }},
598 applyPatchOperation{
599 "updating validating field num2 to another validating value, but rachet invalid field num1",
600 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
601 "nums": map[string]interface{}{
602 "num1": 1,
603 "num2": 2000,
604 },
605 }},
606 expectError{applyPatchOperation{
607 "update field num1 to different invalid value",
608 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
609 "nums": map[string]interface{}{
610 "num1": 2,
611 "num2": 2000,
612 },
613 }}},
614 },
615 },
616 {
617 Name: "MinProperties MaxProperties",
618 Operations: []ratchetingTestOperation{
619 updateMyCRDV1Beta1Schema{&apiextensionsv1.JSONSchemaProps{
620 Type: "object",
621 Properties: map[string]apiextensionsv1.JSONSchemaProps{
622 "restricted": {
623 Type: "object",
624 AdditionalProperties: &apiextensionsv1.JSONSchemaPropsOrBool{
625 Schema: stringSchema,
626 },
627 },
628 "unrestricted": {
629 Type: "object",
630 AdditionalProperties: &apiextensionsv1.JSONSchemaPropsOrBool{
631 Schema: stringSchema,
632 },
633 },
634 },
635 }},
636 applyPatchOperation{
637 "Create instance",
638 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
639 "restricted": map[string]interface{}{
640 "key1": "hi",
641 "key2": "there",
642 },
643 }},
644 patchMyCRDV1Beta1Schema{
645 "set both minProperties and maxProperties to 1 to violate the previous object",
646 map[string]interface{}{
647 "properties": map[string]interface{}{
648 "restricted": map[string]interface{}{
649 "minProperties": 1,
650 "maxProperties": 1,
651 },
652 },
653 }},
654 applyPatchOperation{
655 "ratchet violating object 'restricted' around changes to unrelated field",
656 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
657 "restricted": map[string]interface{}{
658 "key1": "hi",
659 "key2": "there",
660 },
661 "unrestricted": map[string]interface{}{
662 "key1": "yo",
663 },
664 }},
665 expectError{applyPatchOperation{
666 "make invalid changes to previously ratcheted invalid field",
667 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
668 "restricted": map[string]interface{}{
669 "key1": "changed",
670 "key2": "there",
671 },
672 "unrestricted": map[string]interface{}{
673 "key1": "yo",
674 },
675 }}},
676
677 patchMyCRDV1Beta1Schema{
678 "remove maxProeprties, set minProperties to 2",
679 map[string]interface{}{
680 "properties": map[string]interface{}{
681 "restricted": map[string]interface{}{
682 "minProperties": 2,
683 "maxProperties": nil,
684 },
685 },
686 }},
687 applyPatchOperation{
688 "a new value",
689 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
690 "restricted": map[string]interface{}{
691 "key1": "hi",
692 "key2": "there",
693 "key3": "buddy",
694 },
695 }},
696
697 expectError{applyPatchOperation{
698 "violate new validation by removing keys",
699 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
700 "restricted": map[string]interface{}{
701 "key1": "hi",
702 "key2": nil,
703 "key3": nil,
704 },
705 }}},
706 patchMyCRDV1Beta1Schema{
707 "remove minProperties, set maxProperties to 1",
708 map[string]interface{}{
709 "properties": map[string]interface{}{
710 "restricted": map[string]interface{}{
711 "minProperties": nil,
712 "maxProperties": 1,
713 },
714 },
715 }},
716 applyPatchOperation{
717 "modify only the other key, ratcheting maxProperties for field `restricted`",
718 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
719 "restricted": map[string]interface{}{
720 "key1": "hi",
721 "key2": "there",
722 "key3": "buddy",
723 },
724 "unrestricted": map[string]interface{}{
725 "key1": "value",
726 "key2": "value",
727 },
728 }},
729 expectError{
730 applyPatchOperation{
731 "modifying one value in the object with maxProperties restriction, but keeping old fields",
732 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
733 "restricted": map[string]interface{}{
734 "key1": "hi",
735 "key2": "theres",
736 "key3": "buddy",
737 },
738 }}},
739 },
740 },
741 {
742 Name: "MinItems",
743 Operations: []ratchetingTestOperation{
744 updateMyCRDV1Beta1Schema{&apiextensionsv1.JSONSchemaProps{
745 Type: "object",
746 Properties: map[string]apiextensionsv1.JSONSchemaProps{
747 "field": *stringSchema,
748 "array": {
749 Type: "array",
750 Items: &apiextensionsv1.JSONSchemaPropsOrArray{
751 Schema: stringSchema,
752 },
753 },
754 },
755 }},
756 applyPatchOperation{
757 "Create instance",
758 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
759 "array": []interface{}{"value1", "value2", "value3"},
760 }},
761 patchMyCRDV1Beta1Schema{
762 "change minItems on array to 10, invalidates previous object",
763 map[string]interface{}{
764 "properties": map[string]interface{}{
765 "array": map[string]interface{}{
766 "minItems": 10,
767 },
768 },
769 }},
770 applyPatchOperation{
771 "keep invalid field `array` unchanged, add new field with ratcheting",
772 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
773 "array": []interface{}{"value1", "value2", "value3"},
774 "field": "value",
775 }},
776 expectError{
777 applyPatchOperation{
778 "modify array element without satisfying property",
779 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
780 "array": []interface{}{"value2", "value2", "value3"},
781 }}},
782
783 expectError{
784 applyPatchOperation{
785 "add array element without satisfying proeprty",
786 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
787 "array": []interface{}{"value1", "value2", "value3", "value4"},
788 }}},
789
790 applyPatchOperation{
791 "make array valid",
792 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
793 "array": []interface{}{"value1", "value2", "value3", "4", "5", "6", "7", "8", "9", "10"},
794 }},
795 expectError{
796 applyPatchOperation{
797 "revert to original value",
798 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
799 "array": []interface{}{"value1", "value2", "value3"},
800 }}},
801 },
802 },
803 {
804 Name: "MaxItems",
805 Operations: []ratchetingTestOperation{
806 updateMyCRDV1Beta1Schema{&apiextensionsv1.JSONSchemaProps{
807 Type: "object",
808 Properties: map[string]apiextensionsv1.JSONSchemaProps{
809 "field": *stringSchema,
810 "array": {
811 Type: "array",
812 Items: &apiextensionsv1.JSONSchemaPropsOrArray{
813 Schema: stringSchema,
814 },
815 },
816 },
817 }},
818 applyPatchOperation{
819 "create instance",
820 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
821 "array": []interface{}{"value1", "value2", "value3"},
822 }},
823 patchMyCRDV1Beta1Schema{
824 "change maxItems on array to 1, invalidates previous object",
825 map[string]interface{}{
826 "properties": map[string]interface{}{
827 "array": map[string]interface{}{
828 "maxItems": 1,
829 },
830 },
831 }},
832 applyPatchOperation{
833 "ratchet old value of array through an update to another field",
834 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
835 "array": []interface{}{"value1", "value2", "value3"},
836 "field": "value",
837 }},
838 expectError{
839 applyPatchOperation{
840 "modify array element without satisfying property",
841 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
842 "array": []interface{}{"value2", "value2", "value3"},
843 }}},
844
845 expectError{
846 applyPatchOperation{
847 "remove array element without satisfying proeprty",
848 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
849 "array": []interface{}{"value1", "value2"},
850 }}},
851
852 applyPatchOperation{
853 "change array to valid value that satisfies maxItems",
854 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
855 "array": []interface{}{"value1"},
856 }},
857 expectError{
858 applyPatchOperation{
859 "revert to previous invalid ratcheted value",
860 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
861 "array": []interface{}{"value1", "value2", "value3"},
862 }}},
863 },
864 },
865 {
866 Name: "MinLength MaxLength",
867 Operations: []ratchetingTestOperation{
868 updateMyCRDV1Beta1Schema{&apiextensionsv1.JSONSchemaProps{
869 Type: "object",
870 Properties: map[string]apiextensionsv1.JSONSchemaProps{
871 "minField": *stringSchema,
872 "maxField": *stringSchema,
873 "otherField": *stringSchema,
874 },
875 }},
876 applyPatchOperation{
877 "create instance",
878 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
879 "minField": "value",
880 "maxField": "valueThatsVeryLongSee",
881 }},
882 patchMyCRDV1Beta1Schema{
883 "set minField maxLength to 10, and maxField's minLength to 15",
884 map[string]interface{}{
885 "properties": map[string]interface{}{
886 "minField": map[string]interface{}{
887 "minLength": 10,
888 },
889 "maxField": map[string]interface{}{
890 "maxLength": 15,
891 },
892 },
893 }},
894 applyPatchOperation{
895 "add new field `otherField`, ratcheting `minField` and `maxField`",
896 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
897 "minField": "value",
898 "maxField": "valueThatsVeryLongSee",
899 "otherField": "otherValue",
900 }},
901 applyPatchOperation{
902 "make minField valid, ratcheting old value for maxField",
903 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
904 "minField": "valuelength13",
905 "maxField": "valueThatsVeryLongSee",
906 "otherField": "otherValue",
907 }},
908 applyPatchOperation{
909 "make maxField shorter",
910 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
911 "maxField": "l2",
912 }},
913 expectError{
914 applyPatchOperation{
915 "make maxField too long",
916 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
917 "maxField": "valuewithlength17",
918 }}},
919 expectError{
920 applyPatchOperation{
921 "revert minFIeld to previously ratcheted value",
922 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
923 "minField": "value",
924 }}},
925 expectError{
926 applyPatchOperation{
927 "revert maxField to previously ratcheted value",
928 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
929 "maxField": "valueThatsVeryLongSee",
930 }}},
931 },
932 },
933 {
934 Name: "Pattern",
935 Operations: []ratchetingTestOperation{
936 updateMyCRDV1Beta1Schema{&apiextensionsv1.JSONSchemaProps{
937 Type: "object",
938 Properties: map[string]apiextensionsv1.JSONSchemaProps{
939 "field": *stringSchema,
940 },
941 }},
942 applyPatchOperation{
943 "create instance",
944 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
945 "field": "doesnt abide pattern",
946 }},
947 patchMyCRDV1Beta1Schema{
948 "add pattern validation on `field`",
949 map[string]interface{}{
950 "properties": map[string]interface{}{
951 "field": map[string]interface{}{
952 "pattern": "^[1-9]+$",
953 },
954 "otherField": map[string]interface{}{
955 "type": "string",
956 },
957 },
958 }},
959 applyPatchOperation{
960 "add unrelated field, ratcheting old invalid field",
961 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
962 "field": "doesnt abide pattern",
963 "otherField": "added",
964 }},
965 expectError{applyPatchOperation{
966 "change field to invalid value",
967 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
968 "field": "w123",
969 "otherField": "added",
970 }}},
971 applyPatchOperation{
972 "change field to a valid value",
973 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
974 "field": "123",
975 "otherField": "added",
976 }},
977 },
978 },
979 {
980 Name: "Format Addition and Change",
981 Operations: []ratchetingTestOperation{
982 updateMyCRDV1Beta1Schema{&apiextensionsv1.JSONSchemaProps{
983 Type: "object",
984 Properties: map[string]apiextensionsv1.JSONSchemaProps{
985 "field": *stringSchema,
986 },
987 }},
988 applyPatchOperation{
989 "create instance",
990 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
991 "field": "doesnt abide any format",
992 }},
993 patchMyCRDV1Beta1Schema{
994 "change `field`'s format to `byte",
995 map[string]interface{}{
996 "properties": map[string]interface{}{
997 "field": map[string]interface{}{
998 "format": "byte",
999 },
1000 "otherField": map[string]interface{}{
1001 "type": "string",
1002 },
1003 },
1004 }},
1005 applyPatchOperation{
1006 "add unrelated otherField, ratchet invalid old field format",
1007 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
1008 "field": "doesnt abide any format",
1009 "otherField": "value",
1010 }},
1011 expectError{applyPatchOperation{
1012 "change field to an invalid string",
1013 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
1014 "field": "asd",
1015 }}},
1016 applyPatchOperation{
1017 "change field to a valid byte string",
1018 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
1019 "field": "dGhpcyBpcyBwYXNzd29yZA==",
1020 }},
1021 patchMyCRDV1Beta1Schema{
1022 "change `field`'s format to date-time",
1023 map[string]interface{}{
1024 "properties": map[string]interface{}{
1025 "field": map[string]interface{}{
1026 "format": "date-time",
1027 },
1028 },
1029 }},
1030 applyPatchOperation{
1031 "change otherField, ratchet `field`'s invalid byte format",
1032 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
1033 "field": "dGhpcyBpcyBwYXNzd29yZA==",
1034 "otherField": "value2",
1035 }},
1036 applyPatchOperation{
1037 "change `field` to a valid value",
1038 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
1039 "field": "2018-11-13T20:20:39+00:00",
1040 "otherField": "value2",
1041 }},
1042 expectError{
1043 applyPatchOperation{
1044 "revert `field` to previously ratcheted value",
1045 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
1046 "field": "dGhpcyBpcyBwYXNzd29yZA==",
1047 "otherField": "value2",
1048 }}},
1049 expectError{
1050 applyPatchOperation{
1051 "revert `field` to its initial value from creation",
1052 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
1053 "field": "doesnt abide any format",
1054 }}},
1055 },
1056 },
1057 {
1058 Name: "Map Type List Reordering Grandfathers Invalid Key",
1059 Operations: []ratchetingTestOperation{
1060 updateMyCRDV1Beta1Schema{&apiextensionsv1.JSONSchemaProps{
1061 Type: "object",
1062 Properties: map[string]apiextensionsv1.JSONSchemaProps{
1063 "field": {
1064 Type: "array",
1065 XListType: ptr("map"),
1066 XListMapKeys: []string{"name", "port"},
1067 Items: &apiextensionsv1.JSONSchemaPropsOrArray{
1068 Schema: &apiextensionsv1.JSONSchemaProps{
1069 Type: "object",
1070 Required: []string{"name", "port"},
1071 Properties: map[string]apiextensionsv1.JSONSchemaProps{
1072 "name": *stringSchema,
1073 "port": *numberSchema,
1074 "field": *stringSchema,
1075 },
1076 },
1077 },
1078 },
1079 },
1080 }},
1081 applyPatchOperation{
1082 "create instance with three soon-to-be-invalid keys",
1083 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
1084 "field": []interface{}{
1085 map[string]interface{}{
1086 "name": "nginx",
1087 "port": 443,
1088 "field": "value",
1089 },
1090 map[string]interface{}{
1091 "name": "etcd",
1092 "port": 2379,
1093 "field": "value",
1094 },
1095 map[string]interface{}{
1096 "name": "kube-apiserver",
1097 "port": 6443,
1098 "field": "value",
1099 },
1100 },
1101 }},
1102 patchMyCRDV1Beta1Schema{
1103 "set `field`'s maxItems to 2, which is exceeded by all of previous object's elements",
1104 map[string]interface{}{
1105 "properties": map[string]interface{}{
1106 "field": map[string]interface{}{
1107 "maxItems": 2,
1108 },
1109 },
1110 }},
1111 applyPatchOperation{
1112 "reorder invalid objects which have too many properties, but do not modify them or change keys",
1113 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
1114 "field": []interface{}{
1115 map[string]interface{}{
1116 "name": "kube-apiserver",
1117 "port": 6443,
1118 "field": "value",
1119 },
1120 map[string]interface{}{
1121 "name": "nginx",
1122 "port": 443,
1123 "field": "value",
1124 },
1125 map[string]interface{}{
1126 "name": "etcd",
1127 "port": 2379,
1128 "field": "value",
1129 },
1130 },
1131 }},
1132 expectError{
1133 applyPatchOperation{
1134 "attempt to change one of the fields of the items which exceed maxItems",
1135 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
1136 "field": []interface{}{
1137 map[string]interface{}{
1138 "name": "kube-apiserver",
1139 "port": 6443,
1140 "field": "value",
1141 },
1142 map[string]interface{}{
1143 "name": "nginx",
1144 "port": 443,
1145 "field": "value",
1146 },
1147 map[string]interface{}{
1148 "name": "etcd",
1149 "port": 2379,
1150 "field": "value",
1151 },
1152 map[string]interface{}{
1153 "name": "dev",
1154 "port": 8080,
1155 "field": "value",
1156 },
1157 },
1158 }}},
1159 patchMyCRDV1Beta1Schema{
1160 "Require even numbered port in key, remove maxItems requirement",
1161 map[string]interface{}{
1162 "properties": map[string]interface{}{
1163 "field": map[string]interface{}{
1164 "maxItems": nil,
1165 "items": map[string]interface{}{
1166 "properties": map[string]interface{}{
1167 "port": map[string]interface{}{
1168 "multipleOf": 2,
1169 },
1170 },
1171 },
1172 },
1173 },
1174 }},
1175
1176 applyPatchOperation{
1177 "reorder fields without changing anything",
1178 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
1179 "field": []interface{}{
1180 map[string]interface{}{
1181 "name": "nginx",
1182 "port": 443,
1183 "field": "value",
1184 },
1185 map[string]interface{}{
1186 "name": "etcd",
1187 "port": 2379,
1188 "field": "value",
1189 },
1190 map[string]interface{}{
1191 "name": "kube-apiserver",
1192 "port": 6443,
1193 "field": "value",
1194 },
1195 },
1196 }},
1197
1198 applyPatchOperation{
1199 `use "invalid" keys despite changing order and changing sibling fields to the key`,
1200 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
1201 "field": []interface{}{
1202 map[string]interface{}{
1203 "name": "nginx",
1204 "port": 443,
1205 "field": "value",
1206 },
1207 map[string]interface{}{
1208 "name": "etcd",
1209 "port": 2379,
1210 "field": "value",
1211 },
1212 map[string]interface{}{
1213 "name": "kube-apiserver",
1214 "port": 6443,
1215 "field": "this is a changed value for an an invalid but grandfathered key",
1216 },
1217 map[string]interface{}{
1218 "name": "dev",
1219 "port": 8080,
1220 "field": "value",
1221 },
1222 },
1223 }},
1224 },
1225 },
1226 {
1227 Name: "ArrayItems do not correlate by index",
1228 Operations: []ratchetingTestOperation{
1229 updateMyCRDV1Beta1Schema{&apiextensionsv1.JSONSchemaProps{
1230 Type: "object",
1231 Properties: map[string]apiextensionsv1.JSONSchemaProps{
1232 "values": {
1233 Type: "array",
1234 Items: &apiextensionsv1.JSONSchemaPropsOrArray{
1235 Schema: stringMapSchema,
1236 },
1237 },
1238 "otherField": *stringSchema,
1239 },
1240 }},
1241 applyPatchOperation{
1242 "create instance with length 5 values",
1243 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
1244 "values": []interface{}{
1245 map[string]interface{}{
1246 "name": "1",
1247 "key": "value",
1248 },
1249 map[string]interface{}{
1250 "name": "2",
1251 "key": "value",
1252 },
1253 },
1254 }},
1255 patchMyCRDV1Beta1Schema{
1256 "Set minimum length of 6 for values of elements in the items array",
1257 map[string]interface{}{
1258 "properties": map[string]interface{}{
1259 "values": map[string]interface{}{
1260 "items": map[string]interface{}{
1261 "additionalProperties": map[string]interface{}{
1262 "minLength": 6,
1263 },
1264 },
1265 },
1266 },
1267 }},
1268 expectError{
1269 applyPatchOperation{
1270 "change value to one that exceeds minLength",
1271 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
1272 "values": []interface{}{
1273 map[string]interface{}{
1274 "name": "1",
1275 "key": "value",
1276 },
1277 map[string]interface{}{
1278 "name": "2",
1279 "key": "bad",
1280 },
1281 },
1282 }}},
1283 applyPatchOperation{
1284 "add new fields without touching the map",
1285 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
1286 "values": []interface{}{
1287 map[string]interface{}{
1288 "name": "1",
1289 "key": "value",
1290 },
1291 map[string]interface{}{
1292 "name": "2",
1293 "key": "value",
1294 },
1295 },
1296 "otherField": "hello world",
1297 }},
1298
1299 expectError{applyPatchOperation{
1300 "add new, valid fields to elements of the array, failing to ratchet unchanged old fields within the array elements by correlating by index due to atomic list",
1301 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
1302 "values": []interface{}{
1303 map[string]interface{}{
1304 "name": "1",
1305 "key": "value",
1306 },
1307 map[string]interface{}{
1308 "name": "2",
1309 "key": "value",
1310 "key2": "valid value",
1311 },
1312 },
1313 }}},
1314 expectError{
1315 applyPatchOperation{
1316 "reorder the array, preventing index correlation",
1317 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
1318 "values": []interface{}{
1319 map[string]interface{}{
1320 "name": "2",
1321 "key": "value",
1322 "key2": "valid value",
1323 },
1324 map[string]interface{}{
1325 "name": "1",
1326 "key": "value",
1327 },
1328 },
1329 }}},
1330 },
1331 },
1332 {
1333 Name: "CEL Optional OldSelf",
1334 Operations: []ratchetingTestOperation{
1335 updateMyCRDV1Beta1Schema{&apiextensionsv1.JSONSchemaProps{
1336 Type: "object",
1337 Properties: map[string]apiextensionsv1.JSONSchemaProps{
1338 "field": {
1339 Type: "string",
1340 XValidations: []apiextensionsv1.ValidationRule{
1341 {
1342 Rule: "!oldSelf.hasValue()",
1343 Message: "oldSelf must be null",
1344 OptionalOldSelf: ptr(true),
1345 },
1346 },
1347 },
1348 },
1349 }},
1350
1351 applyPatchOperation{
1352 "create instance passes since oldself is null",
1353 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
1354 "field": "value",
1355 }},
1356
1357 expectError{
1358 applyPatchOperation{
1359 "update field fails, since oldself is not null",
1360 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
1361 "field": "value2",
1362 },
1363 },
1364 },
1365
1366 expectError{
1367 applyPatchOperation{
1368 "noop update field fails, since oldself is not null and transition rules are not ratcheted",
1369 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
1370 "field": "value",
1371 },
1372 },
1373 },
1374 },
1375 },
1376
1377 {
1378 Name: "AllOf_should_not_ratchet",
1379 },
1380 {
1381 Name: "OneOf_should_not_ratchet",
1382 },
1383 {
1384 Name: "AnyOf_should_not_ratchet",
1385 },
1386 {
1387 Name: "Not_should_not_ratchet",
1388 },
1389 {
1390 Name: "CEL_transition_rules_should_not_ratchet",
1391 Operations: []ratchetingTestOperation{
1392 updateMyCRDV1Beta1Schema{&apiextensionsv1.JSONSchemaProps{
1393 Type: "object",
1394 XPreserveUnknownFields: ptr(true),
1395 }},
1396 applyPatchOperation{
1397 "create instance with strings that do not start with k8s",
1398 myCRDV1Beta1, myCRDInstanceName,
1399 `
1400 myStringField: myStringValue
1401 myOtherField: myOtherField
1402 `,
1403 },
1404 updateMyCRDV1Beta1Schema{&apiextensionsv1.JSONSchemaProps{
1405 Type: "object",
1406 XPreserveUnknownFields: ptr(true),
1407 Properties: map[string]apiextensionsv1.JSONSchemaProps{
1408 "myStringField": {
1409 Type: "string",
1410 XValidations: apiextensionsv1.ValidationRules{
1411 {
1412 Rule: "oldSelf != 'myStringValue' || self == 'validstring'",
1413 },
1414 },
1415 },
1416 },
1417 }},
1418 expectError{applyPatchOperation{
1419 "try to change one field to valid value, but unchanged field fails to be ratcheted by transition rule",
1420 myCRDV1Beta1, myCRDInstanceName,
1421 `
1422 myOtherField: myNewOtherField
1423 myStringField: myStringValue
1424 `,
1425 }},
1426 applyPatchOperation{
1427 "change both fields to valid values",
1428 myCRDV1Beta1, myCRDInstanceName,
1429 `
1430 myStringField: validstring
1431 myOtherField: myNewOtherField
1432 `,
1433 },
1434 },
1435 },
1436
1437 {
1438 Name: "CEL Add Change Rule",
1439 Operations: []ratchetingTestOperation{
1440 updateMyCRDV1Beta1Schema{&apiextensionsv1.JSONSchemaProps{
1441 Type: "object",
1442 Properties: map[string]apiextensionsv1.JSONSchemaProps{
1443 "field": {
1444 Type: "object",
1445 AdditionalProperties: &apiextensionsv1.JSONSchemaPropsOrBool{
1446 Schema: &apiextensionsv1.JSONSchemaProps{
1447 Type: "object",
1448 Properties: map[string]apiextensionsv1.JSONSchemaProps{
1449 "stringField": *stringSchema,
1450 "intField": *numberSchema,
1451 "otherIntField": *numberSchema,
1452 },
1453 },
1454 },
1455 },
1456 },
1457 }},
1458 applyPatchOperation{
1459 "create instance with strings that do not start with k8s",
1460 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
1461 "field": map[string]interface{}{
1462 "object1": map[string]interface{}{
1463 "stringField": "a string",
1464 "intField": 5,
1465 },
1466 "object2": map[string]interface{}{
1467 "stringField": "another string",
1468 "intField": 15,
1469 },
1470 "object3": map[string]interface{}{
1471 "stringField": "a third string",
1472 "intField": 7,
1473 },
1474 },
1475 }},
1476 patchMyCRDV1Beta1Schema{
1477 "require that stringField value start with `k8s`",
1478 map[string]interface{}{
1479 "properties": map[string]interface{}{
1480 "field": map[string]interface{}{
1481 "additionalProperties": map[string]interface{}{
1482 "properties": map[string]interface{}{
1483 "stringField": map[string]interface{}{
1484 "x-kubernetes-validations": []interface{}{
1485 map[string]interface{}{
1486 "rule": "self.startsWith('k8s')",
1487 "message": "strings must have k8s prefix",
1488 },
1489 },
1490 },
1491 },
1492 },
1493 },
1494 },
1495 }},
1496 applyPatchOperation{
1497 "add a new entry that follows the new rule, ratchet old values",
1498 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
1499 "field": map[string]interface{}{
1500 "object1": map[string]interface{}{
1501 "stringField": "a string",
1502 "intField": 5,
1503 },
1504 "object2": map[string]interface{}{
1505 "stringField": "another string",
1506 "intField": 15,
1507 },
1508 "object3": map[string]interface{}{
1509 "stringField": "a third string",
1510 "intField": 7,
1511 },
1512 "object4": map[string]interface{}{
1513 "stringField": "k8s third string",
1514 "intField": 7,
1515 },
1516 },
1517 }},
1518 applyPatchOperation{
1519 "modify a sibling to an invalid value, ratcheting the unchanged invalid value",
1520 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
1521 "field": map[string]interface{}{
1522 "object1": map[string]interface{}{
1523 "stringField": "a string",
1524 "intField": 15,
1525 },
1526 "object2": map[string]interface{}{
1527 "stringField": "another string",
1528 "intField": 10,
1529 "otherIntField": 20,
1530 },
1531 },
1532 }},
1533 expectError{
1534 applyPatchOperation{
1535 "change a previously ratcheted field to an invalid value",
1536 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
1537 "field": map[string]interface{}{
1538 "object2": map[string]interface{}{
1539 "stringField": "a changed string",
1540 },
1541 "object3": map[string]interface{}{
1542 "stringField": "a changed third string",
1543 },
1544 },
1545 }}},
1546 patchMyCRDV1Beta1Schema{
1547 "require that stringField values are also odd length",
1548 map[string]interface{}{
1549 "properties": map[string]interface{}{
1550 "field": map[string]interface{}{
1551 "additionalProperties": map[string]interface{}{
1552 "stringField": map[string]interface{}{
1553 "x-kubernetes-validations": []interface{}{
1554 map[string]interface{}{
1555 "rule": "self.startsWith('k8s')",
1556 "message": "strings must have k8s prefix",
1557 },
1558 map[string]interface{}{
1559 "rule": "len(self) % 2 == 1",
1560 "message": "strings must have odd length",
1561 },
1562 },
1563 },
1564 },
1565 },
1566 },
1567 }},
1568 applyPatchOperation{
1569 "have mixed ratcheting of one or two CEL rules, object4 is ratcheted by one rule, object1 is ratcheting 2 rules",
1570 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
1571 "field": map[string]interface{}{
1572 "object1": map[string]interface{}{
1573 "stringField": "a string",
1574 "intField": 1000,
1575 },
1576 "object4": map[string]interface{}{
1577 "stringField": "k8s third string",
1578 "intField": 7000,
1579 },
1580 },
1581 }},
1582 expectError{
1583 applyPatchOperation{
1584 "swap keys between valuesin the map",
1585 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
1586 "field": map[string]interface{}{
1587 "object1": map[string]interface{}{
1588 "stringField": "k8s third string",
1589 "intField": 1000,
1590 },
1591 "object4": map[string]interface{}{
1592 "stringField": "a string",
1593 "intField": 7000,
1594 },
1595 },
1596 }}},
1597 applyPatchOperation{
1598 "fix keys",
1599 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
1600 "field": map[string]interface{}{
1601 "object1": map[string]interface{}{
1602 "stringField": "k8s a stringy",
1603 "intField": 1000,
1604 },
1605 "object4": map[string]interface{}{
1606 "stringField": "k8s third stringy",
1607 "intField": 7000,
1608 },
1609 },
1610 }},
1611 },
1612 },
1613 {
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623 Name: "Change list to set",
1624 Disabled: true,
1625 Operations: []ratchetingTestOperation{
1626 updateMyCRDV1Beta1Schema{&apiextensionsv1.JSONSchemaProps{
1627 Type: "object",
1628 Properties: map[string]apiextensionsv1.JSONSchemaProps{
1629 "values": {
1630 Type: "object",
1631 AdditionalProperties: &apiextensionsv1.JSONSchemaPropsOrBool{
1632 Schema: &apiextensionsv1.JSONSchemaProps{
1633 Type: "array",
1634 Items: &apiextensionsv1.JSONSchemaPropsOrArray{
1635 Schema: numberSchema,
1636 },
1637 },
1638 },
1639 },
1640 },
1641 }},
1642 applyPatchOperation{
1643 "reate a list of numbers with duplicates using the old simple schema",
1644 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
1645 "values": map[string]interface{}{
1646 "dups": []interface{}{1, 2, 2, 3, 1000, 2000},
1647 },
1648 }},
1649 patchMyCRDV1Beta1Schema{
1650 "change list type to set",
1651 map[string]interface{}{
1652 "properties": map[string]interface{}{
1653 "values": map[string]interface{}{
1654 "additionalProperties": map[string]interface{}{
1655 "x-kubernetes-list-type": "set",
1656 },
1657 },
1658 },
1659 }},
1660 expectError{
1661 applyPatchOperation{
1662 "change original without removing duplicates",
1663 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
1664 "values": map[string]interface{}{
1665 "dups": []interface{}{1, 2, 2, 3, 1000, 2000, 3},
1666 },
1667 }}},
1668 expectError{applyPatchOperation{
1669 "add another list with duplicates",
1670 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
1671 "values": map[string]interface{}{
1672 "dups": []interface{}{1, 2, 2, 3, 1000, 2000},
1673 "dups2": []interface{}{1, 2, 2, 3, 1000, 2000},
1674 },
1675 }}},
1676
1677
1678
1679 applyPatchOperation{
1680 "add a valid sibling field",
1681 myCRDV1Beta1, myCRDInstanceName, map[string]interface{}{
1682 "values": map[string]interface{}{
1683 "dups": []interface{}{1, 2, 2, 3, 1000, 2000},
1684 "otherField": []interface{}{1, 2, 3},
1685 },
1686 }},
1687
1688
1689
1690
1691 applyPatchOperation{
1692 "remove dups to make list valid",
1693 myCRDV1Beta1,
1694 myCRDInstanceName,
1695 map[string]interface{}{
1696 "values": map[string]interface{}{
1697 "dups": []interface{}{1, 3, 1000, 2000},
1698 "otherField": []interface{}{1, 2, 3},
1699 },
1700 }},
1701 },
1702 },
1703 }
1704
1705 runTests(t, cases)
1706 }
1707
1708 func ptr[T any](v T) *T {
1709 return &v
1710 }
1711
1712 type validator func(new, old *unstructured.Unstructured)
1713
1714 func newValidator(customResourceValidation *apiextensionsinternal.JSONSchemaProps, kind schema.GroupVersionKind, namespaceScoped bool) (validator, error) {
1715
1716 openapiSchema := &spec.Schema{}
1717 if customResourceValidation != nil {
1718
1719 if err := apiservervalidation.ConvertJSONSchemaPropsWithPostProcess(customResourceValidation, openapiSchema, apiservervalidation.StripUnsupportedFormatsPostProcess); err != nil {
1720 return nil, err
1721 }
1722 }
1723
1724 schemaValidator := apiservervalidation.NewRatchetingSchemaValidator(
1725 openapiSchema,
1726 nil,
1727 "",
1728 strfmt.Default)
1729 sts, err := structuralschema.NewStructural(customResourceValidation)
1730 if err != nil {
1731 return nil, err
1732 }
1733
1734 strategy := customresource.NewStrategy(
1735 nil,
1736 namespaceScoped,
1737 kind,
1738 schemaValidator,
1739 nil,
1740 sts,
1741 nil,
1742 nil,
1743 nil,
1744 )
1745
1746 return func(new, old *unstructured.Unstructured) {
1747 _ = strategy.ValidateUpdate(context.TODO(), new, old)
1748 }, nil
1749 }
1750
1751
1752
1753
1754 func loadObjects(dir string) []*unstructured.Unstructured {
1755 result := []*unstructured.Unstructured{}
1756 err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
1757 if err != nil {
1758 return err
1759 } else if d.IsDir() {
1760 return nil
1761 } else if filepath.Ext(d.Name()) != ".yaml" {
1762 return nil
1763 }
1764
1765 data, err := os.ReadFile(path)
1766 if err != nil {
1767 return err
1768 }
1769
1770 decoder := utilyaml.NewYAMLOrJSONDecoder(bytes.NewReader(data), 4096)
1771
1772
1773 for {
1774 parsed := &unstructured.Unstructured{}
1775 if err := decoder.Decode(parsed); err != nil {
1776 if errors.Is(err, io.EOF) {
1777 break
1778 }
1779 return err
1780 }
1781
1782 result = append(result, parsed)
1783 }
1784
1785 return nil
1786 })
1787 if err != nil {
1788 panic(err)
1789 }
1790 return result
1791 }
1792
1793 func BenchmarkRatcheting(b *testing.B) {
1794
1795
1796 crdObjects := loadObjects("ratcheting_test_cases/crds")
1797 invalidFiles := loadObjects("ratcheting_test_cases/invalid")
1798 validFiles := loadObjects("ratcheting_test_cases/valid")
1799
1800
1801 validators := map[schema.GroupVersionKind]validator{}
1802 for _, crd := range crdObjects {
1803 parsed := apiextensionsv1.CustomResourceDefinition{}
1804 if err := runtime.DefaultUnstructuredConverter.FromUnstructured(crd.Object, &parsed); err != nil {
1805 b.Fatalf("Failed to parse CRD %v", err)
1806 return
1807 }
1808
1809 for _, v := range parsed.Spec.Versions {
1810 gvk := schema.GroupVersionKind{
1811 Group: parsed.Spec.Group,
1812 Version: v.Name,
1813 Kind: parsed.Spec.Names.Kind,
1814 }
1815
1816
1817 internalValidation := &apiextensionsinternal.CustomResourceValidation{}
1818 if err := apiextensionsv1.Convert_v1_CustomResourceValidation_To_apiextensions_CustomResourceValidation(v.Schema, internalValidation, nil); err != nil {
1819 b.Fatal(fmt.Errorf("failed converting CRD validation to internal version: %v", err))
1820 return
1821 }
1822
1823 validator, err := newValidator(internalValidation.OpenAPIV3Schema, gvk, parsed.Spec.Scope == apiextensionsv1.NamespaceScoped)
1824 if err != nil {
1825 b.Fatal(err)
1826 return
1827 }
1828 validators[gvk] = validator
1829 }
1830
1831 }
1832
1833
1834 gvksToValidFiles := map[schema.GroupVersionKind][]*unstructured.Unstructured{}
1835 gvksToInvalidFiles := map[schema.GroupVersionKind][]*unstructured.Unstructured{}
1836
1837 for _, valid := range validFiles {
1838 gvk := valid.GroupVersionKind()
1839 gvksToValidFiles[gvk] = append(gvksToValidFiles[gvk], valid)
1840 }
1841
1842 for _, invalid := range invalidFiles {
1843 gvk := invalid.GroupVersionKind()
1844 gvksToInvalidFiles[gvk] = append(gvksToInvalidFiles[gvk], invalid)
1845 }
1846
1847
1848 for gvk := range gvksToValidFiles {
1849 if _, ok := gvksToInvalidFiles[gvk]; !ok {
1850 delete(gvksToValidFiles, gvk)
1851 }
1852 }
1853
1854 for gvk := range gvksToInvalidFiles {
1855 if _, ok := gvksToValidFiles[gvk]; !ok {
1856 delete(gvksToInvalidFiles, gvk)
1857 }
1858 }
1859
1860 type pair struct {
1861 old *unstructured.Unstructured
1862 new *unstructured.Unstructured
1863 }
1864
1865
1866 validXValidPairs := []pair{}
1867 validXInvalidPairs := []pair{}
1868 invalidXInvalidPairs := []pair{}
1869
1870 for gvk, valids := range gvksToValidFiles {
1871 for _, validOld := range valids {
1872 for _, validNew := range gvksToValidFiles[gvk] {
1873 validXValidPairs = append(validXValidPairs, pair{old: validOld, new: validNew})
1874 }
1875 }
1876 }
1877
1878 for gvk, valids := range gvksToValidFiles {
1879 for _, valid := range valids {
1880 for _, invalid := range gvksToInvalidFiles[gvk] {
1881 validXInvalidPairs = append(validXInvalidPairs, pair{old: valid, new: invalid})
1882 }
1883 }
1884 }
1885
1886
1887
1888 for gvk, invalids := range gvksToInvalidFiles {
1889 for _, invalid := range invalids {
1890 for _, invalid2 := range gvksToInvalidFiles[gvk] {
1891 invalidXInvalidPairs = append(invalidXInvalidPairs, pair{old: invalid, new: invalid2})
1892 }
1893 }
1894 }
1895
1896
1897
1898 for _, ratchetingEnabled := range []bool{true, false} {
1899 name := "RatchetingEnabled"
1900 if !ratchetingEnabled {
1901 name = "RatchetingDisabled"
1902 }
1903 b.Run(name, func(b *testing.B) {
1904 defer featuregatetesting.SetFeatureGateDuringTest(b, utilfeature.DefaultFeatureGate, features.CRDValidationRatcheting, ratchetingEnabled)()
1905 b.ResetTimer()
1906
1907 do := func(pairs []pair) {
1908 for _, pair := range pairs {
1909
1910 validator, ok := validators[pair.old.GroupVersionKind()]
1911 if !ok {
1912 b.Log("No validator for GVK", pair.old.GroupVersionKind())
1913 continue
1914 }
1915
1916
1917
1918 validator(pair.old, pair.new)
1919 }
1920 }
1921
1922 b.Run("ValidXValid", func(b *testing.B) {
1923 for i := 0; i < b.N; i++ {
1924 do(validXValidPairs)
1925 }
1926 })
1927
1928 b.Run("ValidXInvalid", func(b *testing.B) {
1929 for i := 0; i < b.N; i++ {
1930 do(validXInvalidPairs)
1931 }
1932 })
1933
1934 b.Run("InvalidXInvalid", func(b *testing.B) {
1935 for i := 0; i < b.N; i++ {
1936 do(invalidXInvalidPairs)
1937 }
1938 })
1939 })
1940 }
1941 }
1942
1943 func TestRatchetingDropFields(t *testing.T) {
1944
1945 defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.CRDValidationRatcheting, false)()
1946 tearDown, apiExtensionClient, _, err := fixtures.StartDefaultServerWithClients(t)
1947 if err != nil {
1948 t.Fatal(err)
1949 }
1950 defer tearDown()
1951
1952 group := myCRDV1Beta1.Group
1953 version := myCRDV1Beta1.Version
1954 resource := myCRDV1Beta1.Resource
1955 kind := fakeRESTMapper[myCRDV1Beta1]
1956
1957 myCRD := &apiextensionsv1.CustomResourceDefinition{
1958 ObjectMeta: metav1.ObjectMeta{Name: resource + "." + group},
1959 Spec: apiextensionsv1.CustomResourceDefinitionSpec{
1960 Group: group,
1961 Versions: []apiextensionsv1.CustomResourceDefinitionVersion{{
1962 Name: version,
1963 Served: true,
1964 Storage: true,
1965 Schema: &apiextensionsv1.CustomResourceValidation{
1966 OpenAPIV3Schema: &apiextensionsv1.JSONSchemaProps{
1967 Type: "object",
1968 Properties: map[string]apiextensionsv1.JSONSchemaProps{
1969 "spec": {
1970 Type: "object",
1971 Properties: map[string]apiextensionsv1.JSONSchemaProps{
1972 "field": {
1973 Type: "string",
1974 XValidations: []apiextensionsv1.ValidationRule{
1975 {
1976
1977 Rule: "self == oldSelf",
1978 OptionalOldSelf: ptr(true),
1979 },
1980 },
1981 },
1982 },
1983 },
1984 },
1985 },
1986 },
1987 }},
1988 Names: apiextensionsv1.CustomResourceDefinitionNames{
1989 Plural: resource,
1990 Kind: kind,
1991 ListKind: kind + "List",
1992 },
1993 Scope: apiextensionsv1.NamespaceScoped,
1994 },
1995 }
1996
1997 created, err := apiExtensionClient.ApiextensionsV1().CustomResourceDefinitions().Create(context.TODO(), myCRD, metav1.CreateOptions{})
1998 if err != nil {
1999 t.Fatal(err)
2000 }
2001 if created.Spec.Versions[0].Schema.OpenAPIV3Schema.Properties["spec"].Properties["field"].XValidations[0].OptionalOldSelf != nil {
2002 t.Errorf("Expected OpeiontalOldSelf field to be dropped for create when feature gate is disabled")
2003 }
2004
2005 var updated *apiextensionsv1.CustomResourceDefinition
2006 err = wait.PollUntilContextTimeout(context.TODO(), 100*time.Millisecond, 5*time.Second, true, func(ctx context.Context) (bool, error) {
2007 existing, err := apiExtensionClient.ApiextensionsV1().CustomResourceDefinitions().Get(context.TODO(), created.Name, metav1.GetOptions{})
2008 if err != nil {
2009 return false, err
2010 }
2011 existing.Spec.Versions[0].Schema.OpenAPIV3Schema.Properties["spec"].Properties["field"].XValidations[0].OptionalOldSelf = ptr(true)
2012 updated, err = apiExtensionClient.ApiextensionsV1().CustomResourceDefinitions().Update(context.TODO(), existing, metav1.UpdateOptions{})
2013 if err != nil {
2014 if apierrors.IsConflict(err) {
2015 return false, nil
2016 }
2017 return false, err
2018 }
2019 return true, nil
2020 })
2021 if err != nil {
2022 t.Fatalf("unexpected error waiting for CRD update: %v", err)
2023 }
2024
2025 if updated.Spec.Versions[0].Schema.OpenAPIV3Schema.Properties["spec"].Properties["field"].XValidations[0].OptionalOldSelf != nil {
2026 t.Errorf("Expected OpeiontalOldSelf field to be dropped for update when feature gate is disabled")
2027 }
2028 }
2029
View as plain text