1
16
17 package integration_test
18
19 import (
20 "context"
21 "encoding/json"
22 "fmt"
23 "net/http"
24 "reflect"
25 "sync"
26 "testing"
27 "time"
28
29 openapi_v2 "github.com/google/gnostic-models/openapiv2"
30 "sigs.k8s.io/yaml"
31
32 apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
33 clientset "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset"
34 apiextensionsfeatures "k8s.io/apiextensions-apiserver/pkg/features"
35 "k8s.io/apiextensions-apiserver/test/integration/conversion"
36 "k8s.io/apiextensions-apiserver/test/integration/fixtures"
37 apierrors "k8s.io/apimachinery/pkg/api/errors"
38 "k8s.io/apimachinery/pkg/api/meta"
39 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
40 "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
41 "k8s.io/apimachinery/pkg/runtime"
42 "k8s.io/apimachinery/pkg/runtime/schema"
43 "k8s.io/apimachinery/pkg/util/sets"
44 "k8s.io/apimachinery/pkg/util/wait"
45 "k8s.io/apimachinery/pkg/watch"
46 utilfeature "k8s.io/apiserver/pkg/util/feature"
47 "k8s.io/client-go/discovery"
48 "k8s.io/client-go/dynamic"
49 "k8s.io/client-go/openapi3"
50 featuregatetesting "k8s.io/component-base/featuregate/testing"
51 "k8s.io/klog/v2/ktesting"
52 "k8s.io/kube-openapi/pkg/spec3"
53 )
54
55 var selectableFieldFixture = &apiextensionsv1.CustomResourceDefinition{
56 ObjectMeta: metav1.ObjectMeta{Name: "shirts.tests.example.com"},
57 Spec: apiextensionsv1.CustomResourceDefinitionSpec{
58 Group: "tests.example.com",
59 Versions: []apiextensionsv1.CustomResourceDefinitionVersion{
60 {
61 Name: "v1",
62 Storage: true,
63 Served: true,
64 Schema: &apiextensionsv1.CustomResourceValidation{
65 OpenAPIV3Schema: &apiextensionsv1.JSONSchemaProps{
66 Type: "object",
67 Properties: map[string]apiextensionsv1.JSONSchemaProps{
68 "spec": {
69 Type: "object",
70 Properties: map[string]apiextensionsv1.JSONSchemaProps{
71 "color": {
72 Type: "string",
73 },
74 "quantity": {
75 Type: "integer",
76 },
77 "size": {
78 Type: "string",
79 Enum: []apiextensionsv1.JSON{
80 {Raw: []byte(`"S"`)},
81 {Raw: []byte(`"M"`)},
82 {Raw: []byte(`"L"`)},
83 {Raw: []byte(`"XL"`)},
84 },
85 },
86 "branded": {
87 Type: "boolean",
88 },
89 },
90 },
91 },
92 },
93 },
94 SelectableFields: []apiextensionsv1.SelectableField{
95 {JSONPath: ".spec.color"},
96 {JSONPath: ".spec.quantity"},
97 {JSONPath: ".spec.size"},
98 {JSONPath: ".spec.branded"},
99 },
100 },
101 {
102 Name: "v1beta1",
103 Storage: false,
104 Served: true,
105 Schema: &apiextensionsv1.CustomResourceValidation{
106 OpenAPIV3Schema: &apiextensionsv1.JSONSchemaProps{
107 Type: "object",
108 Properties: map[string]apiextensionsv1.JSONSchemaProps{
109 "spec": {
110 Type: "object",
111 Properties: map[string]apiextensionsv1.JSONSchemaProps{
112 "hue": {
113 Type: "string",
114 },
115 "quantity": {
116 Type: "integer",
117 },
118 "size": {
119 Type: "string",
120 Enum: []apiextensionsv1.JSON{
121 {Raw: []byte(`"S"`)},
122 {Raw: []byte(`"M"`)},
123 {Raw: []byte(`"L"`)},
124 {Raw: []byte(`"XL"`)},
125 },
126 },
127 "branded": {
128 Type: "boolean",
129 },
130 },
131 },
132 },
133 },
134 },
135 SelectableFields: []apiextensionsv1.SelectableField{
136 {JSONPath: ".spec.hue"},
137 {JSONPath: ".spec.quantity"},
138 {JSONPath: ".spec.size"},
139 {JSONPath: ".spec.branded"},
140 },
141 },
142 },
143 Names: apiextensionsv1.CustomResourceDefinitionNames{
144 Plural: "shirts",
145 Singular: "shirt",
146 Kind: "Shirt",
147 ListKind: "ShirtList",
148 },
149 Scope: apiextensionsv1.ClusterScoped,
150 PreserveUnknownFields: false,
151 },
152 }
153
154 const shirtInstance1 = `
155 kind: Shirt
156 apiVersion: tests.example.com/v1
157 metadata:
158 name: shirt1
159 spec:
160 color: blue
161 quantity: 2
162 size: S
163 branded: true
164 `
165
166 const shirtInstance2 = `
167 kind: Shirt
168 apiVersion: tests.example.com/v1
169 metadata:
170 name: shirt2
171 spec:
172 color: blue
173 quantity: 3
174 size: M
175 branded: false
176 `
177
178 const shirtInstance3 = `
179 kind: Shirt
180 apiVersion: tests.example.com/v1
181 metadata:
182 name: shirt3
183 spec:
184 color: green
185 quantity: 2
186 branded: false
187 `
188
189 type selectableFieldTestCase struct {
190 version string
191 fieldSelector string
192 expectedByName sets.Set[string]
193 expectObserveRemoval sets.Set[string]
194 expectError string
195 }
196
197 func (sf selectableFieldTestCase) Name() string {
198 return fmt.Sprintf("%s/%s", sf.version, sf.fieldSelector)
199 }
200
201 func TestSelectableFields(t *testing.T) {
202 _, ctx := ktesting.NewTestContext(t)
203
204 defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, apiextensionsfeatures.CustomResourceFieldSelectors, true)()
205 tearDown, apiExtensionClient, dynamicClient, err := fixtures.StartDefaultServerWithClients(t)
206 if err != nil {
207 t.Fatal(err)
208 }
209 defer tearDown()
210
211 crd := selectableFieldFixture.DeepCopy()
212
213
214 handler := conversion.NewObjectConverterWebhookHandler(t, crdConverter)
215 upCh, handler := closeOnCall(handler)
216 tearDown, webhookClientConfig, err := conversion.StartConversionWebhookServer(handler)
217 if err != nil {
218 t.Fatal(err)
219 }
220 defer tearDown()
221
222 if webhookClientConfig != nil {
223 crd.Spec.Conversion = &apiextensionsv1.CustomResourceConversion{
224 Strategy: apiextensionsv1.WebhookConverter,
225 Webhook: &apiextensionsv1.WebhookConversion{
226 ClientConfig: webhookClientConfig,
227 ConversionReviewVersions: []string{"v1", "v1beta1"},
228 },
229 }
230 }
231
232
233 crd, err = fixtures.CreateNewV1CustomResourceDefinition(crd, apiExtensionClient, dynamicClient)
234 if err != nil {
235 t.Fatal(err)
236 }
237
238
239 shirtv1Client := dynamicClient.Resource(schema.GroupVersionResource{Group: crd.Spec.Group, Version: crd.Spec.Versions[0].Name, Resource: crd.Spec.Names.Plural})
240 for _, instance := range []string{shirtInstance1} {
241 shirt := &unstructured.Unstructured{}
242 if err := yaml.Unmarshal([]byte(instance), &shirt.Object); err != nil {
243 t.Fatal(err)
244 }
245
246 _, err = shirtv1Client.Create(ctx, shirt, metav1.CreateOptions{})
247 if err != nil {
248 t.Fatalf("Unable to create CR: %v", err)
249 }
250 }
251
252 shirtv1beta1Client := dynamicClient.Resource(schema.GroupVersionResource{Group: crd.Spec.Group, Version: crd.Spec.Versions[0].Name, Resource: crd.Spec.Names.Plural})
253
254
255
256 if err := wait.PollUntilContextTimeout(ctx, time.Millisecond*100, wait.ForeverTestTimeout, true, func(ctx context.Context) (bool, error) {
257 _, err := shirtv1beta1Client.Get(ctx, shirtInstance1, metav1.GetOptions{})
258 select {
259 case <-upCh:
260 return true, nil
261 default:
262 t.Logf("Waiting for webhook to become effective, getting marker object: %v", err)
263 return false, nil
264 }
265 }); err != nil {
266 t.Fatal(err)
267 }
268
269 var tcs []selectableFieldTestCase
270 for _, version := range []string{"v1", "v1beta1"} {
271 var colorSelector string
272 switch version {
273 case "v1":
274 colorSelector = "spec.color"
275 case "v1beta1":
276 colorSelector = "spec.hue"
277 }
278
279 tcs = append(tcs, []selectableFieldTestCase{
280 {
281 version: version,
282 fieldSelector: fmt.Sprintf("%s=blue", colorSelector),
283 expectedByName: sets.New("shirt1", "shirt2"),
284 expectObserveRemoval: sets.New("shirt1", "shirt2"),
285 },
286 {
287 version: version,
288 fieldSelector: "spec.quantity=2",
289 expectedByName: sets.New("shirt1", "shirt3"),
290 expectObserveRemoval: sets.New("shirt1"),
291 },
292 {
293 version: version,
294 fieldSelector: "spec.size=M",
295 expectedByName: sets.New("shirt2"),
296 },
297 {
298 version: version,
299 fieldSelector: "spec.branded=false",
300 expectedByName: sets.New("shirt2", "shirt3"),
301 },
302 {
303 version: version,
304 fieldSelector: fmt.Sprintf("%s=blue,spec.quantity=2", colorSelector),
305 expectedByName: sets.New("shirt1"),
306 expectObserveRemoval: sets.New("shirt1"),
307 },
308 {
309 version: version,
310 fieldSelector: fmt.Sprintf("%s=blue,spec.branded=false", colorSelector),
311 expectedByName: sets.New("shirt2"),
312 expectObserveRemoval: sets.New("shirt2"),
313 },
314 {
315 version: version,
316 fieldSelector: "spec.nosuchfield=xyz",
317 expectedByName: sets.New[string](),
318 expectError: "field label not supported: spec.nosuchfield",
319 },
320 }...)
321 }
322
323 t.Run("watch", func(t *testing.T) {
324 testWatch(ctx, t, tcs, dynamicClient)
325 })
326 t.Run("list", func(t *testing.T) {
327 testList(ctx, t, tcs, dynamicClient)
328 })
329 t.Run("deleteCollection", func(t *testing.T) {
330 testDeleteCollection(ctx, t, tcs, dynamicClient)
331 })
332 }
333
334 func testWatch(ctx context.Context, t *testing.T, tcs []selectableFieldTestCase, dynamicClient dynamic.Interface) {
335 clients := map[string]dynamic.NamespaceableResourceInterface{}
336 for _, version := range []string{"v1", "v1beta1"} {
337 clients[version] = dynamicClient.Resource(schema.GroupVersionResource{Group: "tests.example.com", Version: version, Resource: "shirts"})
338 }
339
340 deleteTestResources(ctx, t, dynamicClient)
341 watches := map[string]watch.Interface{}
342 for _, tc := range tcs {
343 shirtClient := clients[tc.version]
344 w, err := shirtClient.Watch(ctx, metav1.ListOptions{FieldSelector: tc.fieldSelector})
345 if len(tc.expectError) > 0 {
346 if err == nil {
347 t.Errorf("Expected error but got none while creating watch for %s", tc.Name())
348 }
349 continue
350 }
351 if err != nil {
352 t.Fatalf("failed to create watch for %s: %v", tc.Name(), err)
353 } else {
354 watches[tc.Name()] = w
355 }
356 }
357 defer func() {
358 for _, w := range watches {
359 w.Stop()
360 }
361 }()
362
363 createTestResources(ctx, t, dynamicClient)
364
365
366 toDelete := "shirt1"
367 var gracePeriod int64 = 0
368 err := clients["v1"].Delete(ctx, toDelete, metav1.DeleteOptions{GracePeriodSeconds: &gracePeriod})
369 if err != nil {
370 t.Fatal(err)
371 }
372
373
374 toUpdate := "shirt2"
375 u, err := clients["v1"].Get(ctx, toUpdate, metav1.GetOptions{})
376 if err != nil {
377 t.Fatal(err)
378 }
379 u.Object["spec"].(map[string]any)["color"] = "green"
380 _, err = clients["v1"].Update(ctx, u, metav1.UpdateOptions{})
381 if err != nil {
382 t.Fatal(err)
383 }
384
385 for _, tc := range tcs {
386 t.Run(tc.Name(), func(t *testing.T) {
387 added := sets.New[string]()
388 deleted := sets.New[string]()
389 if len(tc.expectError) > 0 {
390 return
391 }
392 w := watches[tc.Name()]
393 for {
394 select {
395 case <-time.After(100 * time.Millisecond):
396
397
398 if added.Equal(tc.expectedByName) && deleted.Equal(tc.expectObserveRemoval) {
399 return
400 } else {
401 t.Fatalf("Timed out waiting for watch events, expected added: %v, removed: %v, but got added: %v, removed: %v", tc.expectedByName, tc.expectObserveRemoval, added, deleted)
402 }
403 case event := <-w.ResultChan():
404 obj, err := meta.Accessor(event.Object)
405 if err != nil {
406 t.Fatal(err)
407 }
408 switch event.Type {
409 case watch.Added:
410 added.Insert(obj.GetName())
411 case watch.Deleted:
412 deleted.Insert(obj.GetName())
413 default:
414
415 }
416 }
417 }
418 })
419 }
420 }
421
422 func testList(ctx context.Context, t *testing.T, tcs []selectableFieldTestCase, dynamicClient dynamic.Interface) {
423 clients := map[string]dynamic.NamespaceableResourceInterface{}
424 for _, version := range []string{"v1", "v1beta1"} {
425 clients[version] = dynamicClient.Resource(schema.GroupVersionResource{Group: "tests.example.com", Version: version, Resource: "shirts"})
426 }
427
428 deleteTestResources(ctx, t, dynamicClient)
429 createTestResources(ctx, t, dynamicClient)
430
431 for _, tc := range tcs {
432 t.Run(tc.Name(), func(t *testing.T) {
433 shirtClient := clients[tc.version]
434 list, err := shirtClient.List(ctx, metav1.ListOptions{FieldSelector: tc.fieldSelector})
435 if len(tc.expectError) > 0 {
436 if err == nil {
437 t.Fatal("Expected error but got none")
438 }
439 if tc.expectError != err.Error() {
440 t.Errorf("Expected error '%s' but got '%s'", tc.expectError, err.Error())
441 }
442 return
443 }
444 if err != nil {
445 t.Fatal(err)
446 }
447 found := sets.New[string]()
448 for _, i := range list.Items {
449 found.Insert(i.GetName())
450 }
451 if !found.Equal(tc.expectedByName) {
452 t.Errorf("Expected %v but got %v", tc.expectedByName, found)
453 }
454 })
455 }
456 }
457
458 func testDeleteCollection(ctx context.Context, t *testing.T, tcs []selectableFieldTestCase, dynamicClient dynamic.Interface) {
459 clients := map[string]dynamic.NamespaceableResourceInterface{}
460 for _, version := range []string{"v1", "v1beta1"} {
461 clients[version] = dynamicClient.Resource(schema.GroupVersionResource{Group: "tests.example.com", Version: version, Resource: "shirts"})
462 }
463
464 for _, tc := range tcs {
465 t.Run(tc.Name(), func(t *testing.T) {
466 deleteTestResources(ctx, t, dynamicClient)
467 createTestResources(ctx, t, dynamicClient)
468 shirtClient := clients[tc.version]
469 var gracePeriod int64 = 0
470 err := shirtClient.DeleteCollection(ctx, metav1.DeleteOptions{GracePeriodSeconds: &gracePeriod}, metav1.ListOptions{FieldSelector: tc.fieldSelector})
471 if len(tc.expectError) > 0 {
472 if err == nil {
473 t.Fatal("Expected error but got none")
474 }
475 if tc.expectError != err.Error() {
476 t.Errorf("Expected error '%s' but got '%s'", tc.expectError, err.Error())
477 }
478 return
479 }
480 if err != nil {
481 t.Fatal(err)
482 }
483 list, err := shirtClient.List(ctx, metav1.ListOptions{})
484 if err != nil {
485 t.Fatal(err)
486 }
487 removed := sets.New[string]("shirt1", "shirt2", "shirt3")
488 for _, i := range list.Items {
489 removed.Delete(i.GetName())
490 }
491 if !removed.Equal(tc.expectedByName) {
492 t.Errorf("Expected %v but got %v", tc.expectedByName, removed)
493 }
494 })
495 }
496 }
497
498 func TestFieldSelectorOpenAPI(t *testing.T) {
499 _, ctx := ktesting.NewTestContext(t)
500 defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, apiextensionsfeatures.CustomResourceFieldSelectors, true)()
501 tearDown, config, _, err := fixtures.StartDefaultServer(t)
502 if err != nil {
503 t.Fatal(err)
504 }
505 defer tearDown()
506
507 apiExtensionsClient, err := clientset.NewForConfig(config)
508 if err != nil {
509 t.Fatal(err)
510 }
511
512 discoveryClient, err := discovery.NewDiscoveryClientForConfig(config)
513 if err != nil {
514 t.Fatal(err)
515 }
516
517 crd := selectableFieldFixture.DeepCopy()
518 crd, err = fixtures.CreateNewV1CustomResourceDefinitionWatchUnsafe(crd, apiExtensionsClient)
519 if err != nil {
520 t.Fatal(err)
521 }
522
523 t.Run("OpenAPIv3", func(t *testing.T) {
524 var spec *spec3.OpenAPI
525 err = wait.PollUntilContextCancel(ctx, 100*time.Millisecond, true, func(ctx context.Context) (done bool, err error) {
526
527 root := openapi3.NewRoot(discoveryClient.OpenAPIV3())
528 spec, err = root.GVSpec(schema.GroupVersion{Group: crd.Spec.Group, Version: "v1"})
529 return err == nil, nil
530 })
531 if err != nil {
532 t.Fatal(err)
533 }
534 shirtSchema, ok := spec.Components.Schemas["com.example.tests.v1.Shirt"]
535 if !ok {
536 t.Fatal("Expected com.example.tests.v1.Shirt in discovery schemas")
537 }
538 selectableFields, ok := shirtSchema.VendorExtensible.Extensions["x-kubernetes-selectable-fields"]
539 if !ok {
540 t.Fatal("Expected x-kubernetes-selectable-fields in extensions")
541 }
542
543 expected := []any{
544 map[string]any{
545 "fieldPath": "spec.color",
546 },
547 map[string]any{
548 "fieldPath": "spec.quantity",
549 },
550 map[string]any{
551 "fieldPath": "spec.size",
552 },
553 map[string]any{
554 "fieldPath": "spec.branded",
555 },
556 }
557 if !reflect.DeepEqual(selectableFields, expected) {
558 t.Errorf("expected %v but got %v", selectableFields, expected)
559 }
560 })
561
562 t.Run("OpenAPIv2", func(t *testing.T) {
563 v2, err := discoveryClient.OpenAPISchema()
564 if err != nil {
565 t.Fatal(err)
566 }
567 var v2Prop *openapi_v2.NamedSchema
568 for _, prop := range v2.Definitions.AdditionalProperties {
569 if prop.Name == "com.example.tests.v1.Shirt" {
570 v2Prop = prop
571 }
572 }
573 if v2Prop == nil {
574 t.Fatal("Expected com.example.tests.v1.Shirt definition")
575 }
576 var v2selectableFields *openapi_v2.NamedAny
577 for _, ve := range v2Prop.Value.VendorExtension {
578 if ve.Name == "x-kubernetes-selectable-fields" {
579 v2selectableFields = ve
580 }
581 }
582 if v2selectableFields == nil {
583 t.Fatal("Expected x-kubernetes-selectable-fields")
584 }
585 expected := `- fieldPath: spec.color
586 - fieldPath: spec.quantity
587 - fieldPath: spec.size
588 - fieldPath: spec.branded
589 `
590 if v2selectableFields.Value.Yaml != expected {
591 t.Errorf("Expected %s but got %s", v2selectableFields.Value.Yaml, expected)
592 }
593 })
594 }
595
596 func TestFieldSelectorDropFields(t *testing.T) {
597 _, ctx := ktesting.NewTestContext(t)
598 tearDown, apiExtensionClient, _, err := fixtures.StartDefaultServerWithClients(t)
599 if err != nil {
600 t.Fatal(err)
601 }
602 defer tearDown()
603
604 group := myCRDV1Beta1.Group
605 version := myCRDV1Beta1.Version
606 resource := myCRDV1Beta1.Resource
607 kind := fakeRESTMapper[myCRDV1Beta1]
608
609 myCRD := &apiextensionsv1.CustomResourceDefinition{
610 ObjectMeta: metav1.ObjectMeta{Name: resource + "." + group},
611 Spec: apiextensionsv1.CustomResourceDefinitionSpec{
612 Group: group,
613 Versions: []apiextensionsv1.CustomResourceDefinitionVersion{{
614 Name: version,
615 Served: true,
616 Storage: true,
617 Schema: &apiextensionsv1.CustomResourceValidation{
618 OpenAPIV3Schema: &apiextensionsv1.JSONSchemaProps{
619 Type: "object",
620 Properties: map[string]apiextensionsv1.JSONSchemaProps{
621 "spec": {
622 Type: "object",
623 Properties: map[string]apiextensionsv1.JSONSchemaProps{
624 "field": {Type: "string"},
625 },
626 Required: []string{"field"},
627 },
628 },
629 },
630 },
631 SelectableFields: []apiextensionsv1.SelectableField{
632 {JSONPath: ".spec.field"},
633 },
634 }},
635 Names: apiextensionsv1.CustomResourceDefinitionNames{
636 Plural: resource,
637 Kind: kind,
638 ListKind: kind + "List",
639 },
640 Scope: apiextensionsv1.NamespaceScoped,
641 },
642 }
643
644 created, err := apiExtensionClient.ApiextensionsV1().CustomResourceDefinitions().Create(ctx, myCRD, metav1.CreateOptions{})
645 if err != nil {
646 t.Fatal(err)
647 }
648 if created.Spec.Versions[0].SelectableFields != nil {
649 t.Errorf("Expected SelectableFields field to be dropped for create when feature gate is disabled")
650 }
651
652 var updated *apiextensionsv1.CustomResourceDefinition
653 err = wait.PollUntilContextTimeout(ctx, 100*time.Millisecond, 5*time.Second, true, func(ctx context.Context) (bool, error) {
654 existing, err := apiExtensionClient.ApiextensionsV1().CustomResourceDefinitions().Get(ctx, created.Name, metav1.GetOptions{})
655 if err != nil {
656 return false, err
657 }
658 existing.Spec.Versions[0].SelectableFields = []apiextensionsv1.SelectableField{{JSONPath: ".spec.field"}}
659 updated, err = apiExtensionClient.ApiextensionsV1().CustomResourceDefinitions().Update(ctx, existing, metav1.UpdateOptions{})
660 if err != nil {
661 if apierrors.IsConflict(err) {
662 return false, nil
663 }
664 return false, err
665 }
666 return true, nil
667 })
668 if err != nil {
669 t.Fatalf("unexpected error waiting for CRD update: %v", err)
670 }
671
672 if updated.Spec.Versions[0].SelectableFields != nil {
673 t.Errorf("Expected SelectableFields field to be dropped for create when feature gate is disabled")
674 }
675 }
676
677 func TestFieldSelectorDisablement(t *testing.T) {
678 _, ctx := ktesting.NewTestContext(t)
679 tearDown, config, _, err := fixtures.StartDefaultServer(t)
680 if err != nil {
681 t.Fatal(err)
682 }
683 defer tearDown()
684
685 apiExtensionClient, err := clientset.NewForConfig(config)
686 if err != nil {
687 t.Fatal(err)
688 }
689
690 dynamicClient, err := dynamic.NewForConfig(config)
691 if err != nil {
692 t.Fatal(err)
693 }
694
695 discoveryClient, err := discovery.NewDiscoveryClientForConfig(config)
696 if err != nil {
697 t.Fatal(err)
698 }
699
700 crd := selectableFieldFixture.DeepCopy()
701
702 t.Run("CustomResourceFieldSelectors", func(t *testing.T) {
703 defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, apiextensionsfeatures.CustomResourceFieldSelectors, true)()
704 crd, err = fixtures.CreateNewV1CustomResourceDefinition(crd, apiExtensionClient, dynamicClient)
705 if err != nil {
706 t.Fatal(err)
707 }
708 })
709
710
711 crd, err = apiExtensionClient.ApiextensionsV1().CustomResourceDefinitions().Get(ctx, crd.Name, metav1.GetOptions{})
712 crd.Spec.Versions[0].SelectableFields = []apiextensionsv1.SelectableField{
713 {JSONPath: ".spec.color"},
714 {JSONPath: ".spec.quantity"},
715 }
716 crd, err = apiExtensionClient.ApiextensionsV1().CustomResourceDefinitions().Update(ctx, crd, metav1.UpdateOptions{})
717 if err != nil {
718 t.Fatal(err)
719 }
720
721 shirtClient := dynamicClient.Resource(schema.GroupVersionResource{Group: crd.Spec.Group, Version: crd.Spec.Versions[0].Name, Resource: crd.Spec.Names.Plural})
722
723 invalidRequestCases := []struct {
724 fieldSelector string
725 }{
726 {
727 fieldSelector: "spec.color=blue",
728 },
729 }
730
731 t.Run("watch", func(t *testing.T) {
732 for _, tc := range invalidRequestCases {
733 t.Run(tc.fieldSelector, func(t *testing.T) {
734 w, err := shirtClient.Watch(ctx, metav1.ListOptions{FieldSelector: tc.fieldSelector})
735 if err == nil {
736 w.Stop()
737 t.Fatal("Expected error but got none")
738 }
739 if !apierrors.IsBadRequest(err) {
740 t.Errorf("Expected BadRequest but got %v", err)
741 }
742 })
743 }
744 })
745
746 for _, instance := range []string{shirtInstance1, shirtInstance2, shirtInstance3} {
747 shirt := &unstructured.Unstructured{}
748 if err := yaml.Unmarshal([]byte(instance), &shirt.Object); err != nil {
749 t.Fatal(err)
750 }
751
752 _, err = shirtClient.Create(ctx, shirt, metav1.CreateOptions{})
753 if err != nil {
754 t.Fatalf("Unable to create CR: %v", err)
755 }
756 }
757
758 t.Run("list", func(t *testing.T) {
759 for _, tc := range invalidRequestCases {
760 t.Run(tc.fieldSelector, func(t *testing.T) {
761 _, err := shirtClient.List(ctx, metav1.ListOptions{FieldSelector: tc.fieldSelector})
762 if err == nil {
763 t.Error("Expected error but got none")
764 }
765 if !apierrors.IsBadRequest(err) {
766 t.Errorf("Expected BadRequest but got %v", err)
767 }
768 expected := "field label not supported: spec.color"
769 if err.Error() != expected {
770 t.Errorf("Expected '%s' but got '%s'", expected, err.Error())
771 }
772 })
773 }
774 })
775
776 t.Run("OpenAPIv3", func(t *testing.T) {
777 var spec *spec3.OpenAPI
778 err = wait.PollUntilContextCancel(ctx, 100*time.Millisecond, true, func(ctx context.Context) (done bool, err error) {
779
780 root := openapi3.NewRoot(discoveryClient.OpenAPIV3())
781 spec, err = root.GVSpec(schema.GroupVersion{Group: crd.Spec.Group, Version: "v1"})
782 if err != nil {
783 return false, nil
784 }
785 shirtSchema, ok := spec.Components.Schemas["com.example.tests.v1.Shirt"]
786 if !ok {
787 return false, nil
788 }
789 _, found := shirtSchema.VendorExtensible.Extensions["x-kubernetes-selectable-fields"]
790 return !found, nil
791 })
792 if err != nil {
793 t.Fatal(err)
794 }
795 })
796
797 t.Run("OpenAPIv2", func(t *testing.T) {
798 v2, err := discoveryClient.OpenAPISchema()
799 if err != nil {
800 t.Fatal(err)
801 }
802 var v2Prop *openapi_v2.NamedSchema
803 for _, prop := range v2.Definitions.AdditionalProperties {
804 if prop.Name == "com.example.tests.v1.Shirt" {
805 v2Prop = prop
806 }
807 }
808 if v2Prop == nil {
809 t.Fatal("Expected com.example.tests.v1.Shirt definition")
810 }
811 var v2selectableFields *openapi_v2.NamedAny
812 for _, ve := range v2Prop.Value.VendorExtension {
813 if ve.Name == "x-kubernetes-selectable-fields" {
814 v2selectableFields = ve
815 }
816 }
817 if v2selectableFields != nil {
818 t.Fatal("Did not expect to find x-kubernetes-selectable-fields")
819 }
820 })
821 }
822
823 func createTestResources(ctx context.Context, t *testing.T, dynamicClient dynamic.Interface) {
824 v1Client := dynamicClient.Resource(schema.GroupVersionResource{Group: "tests.example.com", Version: "v1", Resource: "shirts"})
825 for _, instance := range []string{shirtInstance1, shirtInstance2, shirtInstance3} {
826 shirt := &unstructured.Unstructured{}
827 if err := yaml.Unmarshal([]byte(instance), &shirt.Object); err != nil {
828 t.Fatal(err)
829 }
830
831 _, err := v1Client.Create(ctx, shirt, metav1.CreateOptions{})
832 if err != nil {
833 t.Fatalf("Unable to create CR: %v", err)
834 }
835 }
836 }
837
838 func deleteTestResources(ctx context.Context, t *testing.T, dynamicClient dynamic.Interface) {
839 v1Client := dynamicClient.Resource(schema.GroupVersionResource{Group: "tests.example.com", Version: "v1", Resource: "shirts"})
840
841 var gracePeriod int64 = 0
842 err := v1Client.DeleteCollection(ctx, metav1.DeleteOptions{GracePeriodSeconds: &gracePeriod}, metav1.ListOptions{})
843 if err != nil {
844 t.Fatal(err)
845 }
846 }
847
848 func closeOnCall(h http.Handler) (chan struct{}, http.Handler) {
849 ch := make(chan struct{})
850 once := sync.Once{}
851 return ch, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
852 once.Do(func() {
853 close(ch)
854 })
855 h.ServeHTTP(w, r)
856 })
857 }
858
859 func crdConverter(desiredAPIVersion string, obj runtime.RawExtension) (runtime.RawExtension, error) {
860 u := &unstructured.Unstructured{Object: map[string]interface{}{}}
861 if err := json.Unmarshal(obj.Raw, u); err != nil {
862 return runtime.RawExtension{}, fmt.Errorf("failed to deserialize object: %s with error: %w", string(obj.Raw), err)
863 }
864
865 currentAPIVersion := u.GetAPIVersion()
866
867 if currentAPIVersion == "tests.example.com/v1beta1" && desiredAPIVersion == "tests.example.com/v1" {
868 spec := u.Object["spec"].(map[string]any)
869 spec["color"] = spec["hue"]
870 delete(spec, "hue")
871 } else if currentAPIVersion == "tests.example.com/v1" && desiredAPIVersion == "tests.example.com/v1beta1" {
872 spec := u.Object["spec"].(map[string]any)
873 spec["hue"] = spec["color"]
874 delete(spec, "color")
875 } else if currentAPIVersion != desiredAPIVersion {
876 return runtime.RawExtension{}, fmt.Errorf("cannot convert from %s to %s", currentAPIVersion, desiredAPIVersion)
877 }
878 u.Object["apiVersion"] = desiredAPIVersion
879 raw, err := json.Marshal(u)
880 if err != nil {
881 return runtime.RawExtension{}, fmt.Errorf("failed to serialize object: %v with error: %w", u, err)
882 }
883 return runtime.RawExtension{Raw: raw}, nil
884 }
885
View as plain text