...

Source file src/k8s.io/apiextensions-apiserver/test/integration/fieldselector_test.go

Documentation: k8s.io/apiextensions-apiserver/test/integration

     1  /*
     2  Copyright 2023 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 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": { // color is renamed as "hue" in this version
   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  	// start a conversion webhook
   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  	// create the CRD
   233  	crd, err = fixtures.CreateNewV1CustomResourceDefinition(crd, apiExtensionClient, dynamicClient)
   234  	if err != nil {
   235  		t.Fatal(err)
   236  	}
   237  
   238  	// use the v1 client to create a resource, stored at v1
   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  	// read CRs with the v1beta1 client and
   255  	// wait until conversion webhook is called the first time
   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"), // shirt 1 is deleted, shirt 2 is updated to not match the selector
   285  			},
   286  			{
   287  				version:              version,
   288  				fieldSelector:        "spec.quantity=2",
   289  				expectedByName:       sets.New("shirt1", "shirt3"),
   290  				expectObserveRemoval: sets.New("shirt1"), // shirt 1 is deleted
   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"), // shirt 1 is deleted
   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"), // shirt 2 is updated to not match the selector
   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  	// after creating resources, delete one to make sure deletions can be observed
   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  	// after creating resources, update the color of one CR to longer appear in a field selected watch.
   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 // No watch events to check for error cases. The failure happens at watch creation.
   391  			}
   392  			w := watches[tc.Name()]
   393  			for {
   394  				select {
   395  				case <-time.After(100 * time.Millisecond):
   396  					// Check after a wait to ensure we don't eagerly assume
   397  					// the right watch events were received.
   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  						// ignore everything else
   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()) // drop remaining CRs from removed set
   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  			// wait for the CRD to be published.
   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  	// Write a field that uses the feature while the feature gate is enabled
   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  	// Now that the feature gate is disabled again, update the CRD to trigger an openAPI update
   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  			// wait for the CRD to be published.
   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 // the feature gate is disabled, so selectable fields should be absent
   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