mirror of
https://github.com/k3s-io/kubernetes.git
synced 2025-07-24 20:24:09 +00:00
Add selectableFields to CRDs
This commit is contained in:
parent
54f9807e1e
commit
291703482d
@ -1234,6 +1234,8 @@ var defaultKubernetesFeatureGates = map[featuregate.Feature]featuregate.FeatureS
|
||||
|
||||
apiextensionsfeatures.CRDValidationRatcheting: {Default: true, PreRelease: featuregate.Beta},
|
||||
|
||||
apiextensionsfeatures.CustomResourceFieldSelectors: {Default: false, PreRelease: featuregate.Alpha},
|
||||
|
||||
// features that enable backwards compatibility but are scheduled to be removed
|
||||
// ...
|
||||
HPAScaleToZero: {Default: false, PreRelease: featuregate.Alpha},
|
||||
|
@ -66,6 +66,7 @@ func Funcs(codecs runtimeserializer.CodecFactory) []interface{} {
|
||||
{Name: "Age", Type: "date", Description: swaggerMetadataDescriptions["creationTimestamp"], JSONPath: ".metadata.creationTimestamp"},
|
||||
}
|
||||
}
|
||||
c.Fuzz(&obj.SelectableFields)
|
||||
if obj.Conversion == nil {
|
||||
obj.Conversion = &apiextensions.CustomResourceConversion{
|
||||
Strategy: apiextensions.NoneConverter,
|
||||
@ -78,7 +79,7 @@ func Funcs(codecs runtimeserializer.CodecFactory) []interface{} {
|
||||
obj.PreserveUnknownFields = pointer.BoolPtr(true)
|
||||
}
|
||||
|
||||
// Move per-version schema, subresources, additionalPrinterColumns to the top-level.
|
||||
// Move per-version schema, subresources, additionalPrinterColumns, selectableFields to the top-level.
|
||||
// This is required by validation in v1beta1, and by round-tripping in v1.
|
||||
if len(obj.Versions) == 1 {
|
||||
if obj.Versions[0].Schema != nil {
|
||||
@ -89,6 +90,10 @@ func Funcs(codecs runtimeserializer.CodecFactory) []interface{} {
|
||||
obj.AdditionalPrinterColumns = obj.Versions[0].AdditionalPrinterColumns
|
||||
obj.Versions[0].AdditionalPrinterColumns = nil
|
||||
}
|
||||
if obj.Versions[0].SelectableFields != nil {
|
||||
obj.SelectableFields = obj.Versions[0].SelectableFields
|
||||
obj.Versions[0].SelectableFields = nil
|
||||
}
|
||||
if obj.Versions[0].Subresources != nil {
|
||||
obj.Subresources = obj.Versions[0].Subresources
|
||||
obj.Versions[0].Subresources = nil
|
||||
|
@ -70,6 +70,12 @@ type CustomResourceDefinitionSpec struct {
|
||||
// Top-level and per-version columns are mutually exclusive.
|
||||
// +optional
|
||||
AdditionalPrinterColumns []CustomResourceColumnDefinition
|
||||
// selectableFields specifies paths to fields that may be used as field selectors.
|
||||
// A maximum of 8 selectable fields are allowed.
|
||||
// See https://kubernetes.io/docs/concepts/overview/working-with-objects/field-selectors
|
||||
// Top-level and per-version columns are mutually exclusive.
|
||||
// +optional
|
||||
SelectableFields []SelectableField
|
||||
|
||||
// `conversion` defines conversion settings for the CRD.
|
||||
Conversion *CustomResourceConversion
|
||||
@ -207,6 +213,25 @@ type CustomResourceDefinitionVersion struct {
|
||||
// be explicitly set to null
|
||||
// +optional
|
||||
AdditionalPrinterColumns []CustomResourceColumnDefinition
|
||||
|
||||
// selectableFields specifies paths to fields that may be used as field selectors.
|
||||
// A maximum of 8 selectable fields are allowed.
|
||||
// See https://kubernetes.io/docs/concepts/overview/working-with-objects/field-selectors
|
||||
// +optional
|
||||
SelectableFields []SelectableField
|
||||
}
|
||||
|
||||
// SelectableField specifies the JSON path of a field that may be used with field selectors.
|
||||
type SelectableField struct {
|
||||
// jsonPath is a simple JSON path which is evaluated against each custom resource to produce a
|
||||
// field selector value.
|
||||
// Only JSON paths without the array notation are allowed.
|
||||
// Must point to a field of type string, boolean or integer. Types with enum values
|
||||
// and strings with formats are allowed.
|
||||
// If jsonPath refers to absent field in a resource, the jsonPath evaluates to an empty string.
|
||||
// Must not point to metdata fields.
|
||||
// Required.
|
||||
JSONPath string
|
||||
}
|
||||
|
||||
// CustomResourceColumnDefinition specifies a column for server side printing.
|
||||
|
@ -80,7 +80,7 @@ func Convert_apiextensions_CustomResourceDefinitionSpec_To_v1_CustomResourceDefi
|
||||
out.Versions = []CustomResourceDefinitionVersion{{Name: in.Version, Served: true, Storage: true}}
|
||||
}
|
||||
|
||||
// If spec.{subresources,validation,additionalPrinterColumns} exists, move to versions
|
||||
// If spec.{subresources,validation,additionalPrinterColumns,selectableFields} exists, move to versions
|
||||
if in.Subresources != nil {
|
||||
subresources := &CustomResourceSubresources{}
|
||||
if err := Convert_apiextensions_CustomResourceSubresources_To_v1_CustomResourceSubresources(in.Subresources, subresources, s); err != nil {
|
||||
@ -110,6 +110,17 @@ func Convert_apiextensions_CustomResourceDefinitionSpec_To_v1_CustomResourceDefi
|
||||
out.Versions[i].AdditionalPrinterColumns = additionalPrinterColumns
|
||||
}
|
||||
}
|
||||
if in.SelectableFields != nil {
|
||||
selectableFields := make([]SelectableField, len(in.SelectableFields))
|
||||
for i := range in.SelectableFields {
|
||||
if err := Convert_apiextensions_SelectableField_To_v1_SelectableField(&in.SelectableFields[i], &selectableFields[i], s); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for i := range out.Versions {
|
||||
out.Versions[i].SelectableFields = selectableFields
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -125,13 +136,15 @@ func Convert_v1_CustomResourceDefinitionSpec_To_apiextensions_CustomResourceDefi
|
||||
// Copy versions[0] to version
|
||||
out.Version = out.Versions[0].Name
|
||||
|
||||
// If versions[*].{subresources,schema,additionalPrinterColumns} are identical, move to spec
|
||||
// If versions[*].{subresources,schema,additionalPrinterColumns,selectableFields} are identical, move to spec
|
||||
subresources := out.Versions[0].Subresources
|
||||
subresourcesIdentical := true
|
||||
validation := out.Versions[0].Schema
|
||||
validationIdentical := true
|
||||
additionalPrinterColumns := out.Versions[0].AdditionalPrinterColumns
|
||||
additionalPrinterColumnsIdentical := true
|
||||
selectableFields := out.Versions[0].SelectableFields
|
||||
selectableFieldsIdentical := true
|
||||
|
||||
// Detect if per-version fields are identical
|
||||
for _, v := range out.Versions {
|
||||
@ -144,6 +157,9 @@ func Convert_v1_CustomResourceDefinitionSpec_To_apiextensions_CustomResourceDefi
|
||||
if additionalPrinterColumnsIdentical && !apiequality.Semantic.DeepEqual(v.AdditionalPrinterColumns, additionalPrinterColumns) {
|
||||
additionalPrinterColumnsIdentical = false
|
||||
}
|
||||
if selectableFieldsIdentical && !apiequality.Semantic.DeepEqual(v.SelectableFields, selectableFields) {
|
||||
selectableFieldsIdentical = false
|
||||
}
|
||||
}
|
||||
|
||||
// If they are, set the top-level fields and clear the per-version fields
|
||||
@ -156,6 +172,9 @@ func Convert_v1_CustomResourceDefinitionSpec_To_apiextensions_CustomResourceDefi
|
||||
if additionalPrinterColumnsIdentical {
|
||||
out.AdditionalPrinterColumns = additionalPrinterColumns
|
||||
}
|
||||
if selectableFieldsIdentical {
|
||||
out.SelectableFields = selectableFields
|
||||
}
|
||||
for i := range out.Versions {
|
||||
if subresourcesIdentical {
|
||||
out.Versions[i].Subresources = nil
|
||||
@ -166,6 +185,9 @@ func Convert_v1_CustomResourceDefinitionSpec_To_apiextensions_CustomResourceDefi
|
||||
if additionalPrinterColumnsIdentical {
|
||||
out.Versions[i].AdditionalPrinterColumns = nil
|
||||
}
|
||||
if selectableFieldsIdentical {
|
||||
out.Versions[i].SelectableFields = nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
@ -28,6 +28,7 @@ import (
|
||||
"k8s.io/apiextensions-apiserver/pkg/apis/apiextensions"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/utils/pointer"
|
||||
"k8s.io/utils/ptr"
|
||||
)
|
||||
|
||||
func TestConversion(t *testing.T) {
|
||||
@ -85,7 +86,7 @@ func TestConversion(t *testing.T) {
|
||||
Out: &apiextensions.CustomResourceDefinition{},
|
||||
ExpectOut: &apiextensions.CustomResourceDefinition{
|
||||
Spec: apiextensions.CustomResourceDefinitionSpec{
|
||||
PreserveUnknownFields: pointer.BoolPtr(false),
|
||||
PreserveUnknownFields: ptr.To(false),
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -101,7 +102,7 @@ func TestConversion(t *testing.T) {
|
||||
Spec: apiextensions.CustomResourceDefinitionSpec{
|
||||
Version: "v1",
|
||||
Versions: []apiextensions.CustomResourceDefinitionVersion{{Name: "v1", Served: true, Storage: true}},
|
||||
PreserveUnknownFields: pointer.BoolPtr(false),
|
||||
PreserveUnknownFields: ptr.To(false),
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -123,7 +124,7 @@ func TestConversion(t *testing.T) {
|
||||
{Name: "v1", Served: true, Storage: true},
|
||||
{Name: "v2", Served: false, Storage: false},
|
||||
},
|
||||
PreserveUnknownFields: pointer.BoolPtr(false),
|
||||
PreserveUnknownFields: ptr.To(false),
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -184,7 +185,7 @@ func TestConversion(t *testing.T) {
|
||||
{Name: "v2", Served: true, Storage: false},
|
||||
},
|
||||
Validation: &apiextensions.CustomResourceValidation{OpenAPIV3Schema: &apiextensions.JSONSchemaProps{Type: "object"}},
|
||||
PreserveUnknownFields: pointer.BoolPtr(false),
|
||||
PreserveUnknownFields: ptr.To(false),
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -206,7 +207,7 @@ func TestConversion(t *testing.T) {
|
||||
{Name: "v1", Served: true, Storage: true, Schema: &apiextensions.CustomResourceValidation{OpenAPIV3Schema: &apiextensions.JSONSchemaProps{Description: "v1", Type: "object"}}},
|
||||
{Name: "v2", Served: true, Storage: false, Schema: &apiextensions.CustomResourceValidation{OpenAPIV3Schema: &apiextensions.JSONSchemaProps{Description: "v2", Type: "object"}}},
|
||||
},
|
||||
PreserveUnknownFields: pointer.BoolPtr(false),
|
||||
PreserveUnknownFields: ptr.To(false),
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -267,7 +268,7 @@ func TestConversion(t *testing.T) {
|
||||
{Name: "v2", Served: true, Storage: false},
|
||||
},
|
||||
Subresources: &apiextensions.CustomResourceSubresources{Scale: &apiextensions.CustomResourceSubresourceScale{SpecReplicasPath: "spec.replicas"}},
|
||||
PreserveUnknownFields: pointer.BoolPtr(false),
|
||||
PreserveUnknownFields: ptr.To(false),
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -289,7 +290,7 @@ func TestConversion(t *testing.T) {
|
||||
{Name: "v1", Served: true, Storage: true, Subresources: &apiextensions.CustomResourceSubresources{Scale: &apiextensions.CustomResourceSubresourceScale{SpecReplicasPath: "spec.replicas1"}}},
|
||||
{Name: "v2", Served: true, Storage: false, Subresources: &apiextensions.CustomResourceSubresources{Scale: &apiextensions.CustomResourceSubresourceScale{SpecReplicasPath: "spec.replicas2"}}},
|
||||
},
|
||||
PreserveUnknownFields: pointer.BoolPtr(false),
|
||||
PreserveUnknownFields: ptr.To(false),
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -350,7 +351,7 @@ func TestConversion(t *testing.T) {
|
||||
{Name: "v2", Served: true, Storage: false},
|
||||
},
|
||||
AdditionalPrinterColumns: []apiextensions.CustomResourceColumnDefinition{{Name: "column1"}},
|
||||
PreserveUnknownFields: pointer.BoolPtr(false),
|
||||
PreserveUnknownFields: ptr.To(false),
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -372,7 +373,114 @@ func TestConversion(t *testing.T) {
|
||||
{Name: "v1", Served: true, Storage: true, AdditionalPrinterColumns: []apiextensions.CustomResourceColumnDefinition{{Name: "column1"}}},
|
||||
{Name: "v2", Served: true, Storage: false, AdditionalPrinterColumns: []apiextensions.CustomResourceColumnDefinition{{Name: "column2"}}},
|
||||
},
|
||||
PreserveUnknownFields: pointer.BoolPtr(false),
|
||||
PreserveUnknownFields: ptr.To(false),
|
||||
},
|
||||
},
|
||||
},
|
||||
// SelectableFields
|
||||
{
|
||||
Name: "internal to v1, top-level selectable fields moves to per-version",
|
||||
In: &apiextensions.CustomResourceDefinition{
|
||||
Spec: apiextensions.CustomResourceDefinitionSpec{
|
||||
Version: "v1",
|
||||
SelectableFields: []apiextensions.SelectableField{{JSONPath: ".spec.x"}},
|
||||
Versions: []apiextensions.CustomResourceDefinitionVersion{
|
||||
{Name: "v1", Served: true, Storage: true},
|
||||
},
|
||||
},
|
||||
},
|
||||
Out: &CustomResourceDefinition{},
|
||||
ExpectOut: &CustomResourceDefinition{
|
||||
Spec: CustomResourceDefinitionSpec{
|
||||
Versions: []CustomResourceDefinitionVersion{
|
||||
{Name: "v1", Served: true, Storage: true, SelectableFields: []SelectableField{{JSONPath: ".spec.x"}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "internal to v1, per-version selectable fields is preserved",
|
||||
In: &apiextensions.CustomResourceDefinition{
|
||||
Spec: apiextensions.CustomResourceDefinitionSpec{
|
||||
Versions: []apiextensions.CustomResourceDefinitionVersion{
|
||||
{Name: "v1", Served: true, Storage: true, SelectableFields: []apiextensions.SelectableField{{JSONPath: ".spec.x"}}},
|
||||
{Name: "v2", Served: false, Storage: false, SelectableFields: []apiextensions.SelectableField{{JSONPath: ".spec.y"}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
Out: &CustomResourceDefinition{},
|
||||
ExpectOut: &CustomResourceDefinition{
|
||||
Spec: CustomResourceDefinitionSpec{
|
||||
Versions: []CustomResourceDefinitionVersion{
|
||||
{Name: "v1", Served: true, Storage: true, SelectableFields: []SelectableField{{JSONPath: ".spec.x"}}},
|
||||
{Name: "v2", Served: false, Storage: false, SelectableFields: []SelectableField{{JSONPath: ".spec.y"}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "v1 to internal, identical selectable fields moves to top-level",
|
||||
In: &CustomResourceDefinition{
|
||||
Spec: CustomResourceDefinitionSpec{
|
||||
Versions: []CustomResourceDefinitionVersion{
|
||||
{Name: "v1", Served: true, Storage: true, SelectableFields: []SelectableField{{JSONPath: ".spec.x"}}},
|
||||
{Name: "v2", Served: true, Storage: false, SelectableFields: []SelectableField{{JSONPath: ".spec.x"}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
Out: &apiextensions.CustomResourceDefinition{},
|
||||
ExpectOut: &apiextensions.CustomResourceDefinition{
|
||||
Spec: apiextensions.CustomResourceDefinitionSpec{
|
||||
Version: "v1",
|
||||
Versions: []apiextensions.CustomResourceDefinitionVersion{
|
||||
{Name: "v1", Served: true, Storage: true},
|
||||
{Name: "v2", Served: true, Storage: false},
|
||||
},
|
||||
SelectableFields: []apiextensions.SelectableField{{JSONPath: ".spec.x"}},
|
||||
PreserveUnknownFields: ptr.To(false),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "v1 to internal, single selectable field moves to top-level",
|
||||
In: &CustomResourceDefinition{
|
||||
Spec: CustomResourceDefinitionSpec{
|
||||
Versions: []CustomResourceDefinitionVersion{
|
||||
{Name: "v1", Served: true, Storage: true, SelectableFields: []SelectableField{{JSONPath: ".spec.x"}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
Out: &apiextensions.CustomResourceDefinition{},
|
||||
ExpectOut: &apiextensions.CustomResourceDefinition{
|
||||
Spec: apiextensions.CustomResourceDefinitionSpec{
|
||||
Version: "v1",
|
||||
Versions: []apiextensions.CustomResourceDefinitionVersion{
|
||||
{Name: "v1", Served: true, Storage: true},
|
||||
},
|
||||
SelectableFields: []apiextensions.SelectableField{{JSONPath: ".spec.x"}},
|
||||
PreserveUnknownFields: ptr.To(false),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "v1 to internal, distinct selectable fields remains per-version",
|
||||
In: &CustomResourceDefinition{
|
||||
Spec: CustomResourceDefinitionSpec{
|
||||
Versions: []CustomResourceDefinitionVersion{
|
||||
{Name: "v1", Served: true, Storage: true, SelectableFields: []SelectableField{{JSONPath: ".spec.x"}}},
|
||||
{Name: "v2", Served: true, Storage: false, SelectableFields: []SelectableField{{JSONPath: ".spec.y"}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
Out: &apiextensions.CustomResourceDefinition{},
|
||||
ExpectOut: &apiextensions.CustomResourceDefinition{
|
||||
Spec: apiextensions.CustomResourceDefinitionSpec{
|
||||
Version: "v1",
|
||||
Versions: []apiextensions.CustomResourceDefinitionVersion{
|
||||
{Name: "v1", Served: true, Storage: true, SelectableFields: []apiextensions.SelectableField{{JSONPath: ".spec.x"}}},
|
||||
{Name: "v2", Served: true, Storage: false, SelectableFields: []apiextensions.SelectableField{{JSONPath: ".spec.y"}}},
|
||||
},
|
||||
PreserveUnknownFields: ptr.To(false),
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -442,7 +550,7 @@ func TestConversion(t *testing.T) {
|
||||
ExpectOut: &apiextensions.CustomResourceDefinition{
|
||||
Spec: apiextensions.CustomResourceDefinitionSpec{
|
||||
Conversion: &apiextensions.CustomResourceConversion{},
|
||||
PreserveUnknownFields: pointer.BoolPtr(false),
|
||||
PreserveUnknownFields: ptr.To(false),
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -463,7 +571,7 @@ func TestConversion(t *testing.T) {
|
||||
Conversion: &apiextensions.CustomResourceConversion{
|
||||
WebhookClientConfig: &apiextensions.WebhookClientConfig{URL: pointer.StringPtr("http://example.com")},
|
||||
},
|
||||
PreserveUnknownFields: pointer.BoolPtr(false),
|
||||
PreserveUnknownFields: ptr.To(false),
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -484,7 +592,7 @@ func TestConversion(t *testing.T) {
|
||||
Conversion: &apiextensions.CustomResourceConversion{
|
||||
ConversionReviewVersions: []string{"v1"},
|
||||
},
|
||||
PreserveUnknownFields: pointer.BoolPtr(false),
|
||||
PreserveUnknownFields: ptr.To(false),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -199,6 +199,28 @@ type CustomResourceDefinitionVersion struct {
|
||||
// +optional
|
||||
// +listType=atomic
|
||||
AdditionalPrinterColumns []CustomResourceColumnDefinition `json:"additionalPrinterColumns,omitempty" protobuf:"bytes,6,rep,name=additionalPrinterColumns"`
|
||||
|
||||
// selectableFields specifies paths to fields that may be used as field selectors.
|
||||
// A maximum of 8 selectable fields are allowed.
|
||||
// See https://kubernetes.io/docs/concepts/overview/working-with-objects/field-selectors
|
||||
//
|
||||
// +featureGate=CustomResourceFieldSelectors
|
||||
// +optional
|
||||
// +listType=atomic
|
||||
SelectableFields []SelectableField `json:"selectableFields,omitempty" protobuf:"bytes,9,rep,name=selectableFields"`
|
||||
}
|
||||
|
||||
// SelectableField specifies the JSON path of a field that may be used with field selectors.
|
||||
type SelectableField struct {
|
||||
// jsonPath is a simple JSON path which is evaluated against each custom resource to produce a
|
||||
// field selector value.
|
||||
// Only JSON paths without the array notation are allowed.
|
||||
// Must point to a field of type string, boolean or integer. Types with enum values
|
||||
// and strings with formats are allowed.
|
||||
// If jsonPath refers to absent field in a resource, the jsonPath evaluates to an empty string.
|
||||
// Must not point to metdata fields.
|
||||
// Required.
|
||||
JSONPath string `json:"jsonPath" protobuf:"bytes,1,opt,name=jsonPath"`
|
||||
}
|
||||
|
||||
// CustomResourceColumnDefinition specifies a column for server side printing.
|
||||
|
@ -87,6 +87,14 @@ type CustomResourceDefinitionSpec struct {
|
||||
// +listType=atomic
|
||||
AdditionalPrinterColumns []CustomResourceColumnDefinition `json:"additionalPrinterColumns,omitempty" protobuf:"bytes,8,rep,name=additionalPrinterColumns"`
|
||||
|
||||
// selectableFields specifies paths to fields that may be used as field selectors.
|
||||
// See https://kubernetes.io/docs/concepts/overview/working-with-objects/field-selectors
|
||||
//
|
||||
// +featureGate=CustomResourceFieldSelectors
|
||||
// +optional
|
||||
// +listType=atomic
|
||||
SelectableFields []SelectableField `json:"selectableFields,omitempty" protobuf:"bytes,11,rep,name=selectableFields"`
|
||||
|
||||
// conversion defines conversion settings for the CRD.
|
||||
// +optional
|
||||
Conversion *CustomResourceConversion `json:"conversion,omitempty" protobuf:"bytes,9,opt,name=conversion"`
|
||||
@ -232,6 +240,27 @@ type CustomResourceDefinitionVersion struct {
|
||||
// +optional
|
||||
// +listType=atomic
|
||||
AdditionalPrinterColumns []CustomResourceColumnDefinition `json:"additionalPrinterColumns,omitempty" protobuf:"bytes,6,rep,name=additionalPrinterColumns"`
|
||||
|
||||
// selectableFields specifies paths to fields that may be used as field selectors.
|
||||
// See https://kubernetes.io/docs/concepts/overview/working-with-objects/field-selectors
|
||||
//
|
||||
// +featureGate=CustomResourceFieldSelectors
|
||||
// +optional
|
||||
// +listType=atomic
|
||||
SelectableFields []SelectableField `json:"selectableFields,omitempty" protobuf:"bytes,9,rep,name=selectableFields"`
|
||||
}
|
||||
|
||||
// SelectableField specifies the JSON path of a field that may be used with field selectors.
|
||||
type SelectableField struct {
|
||||
// jsonPath is a simple JSON path which is evaluated against each custom resource to produce a
|
||||
// field selector value.
|
||||
// Only JSON paths without the array notation are allowed.
|
||||
// Must point to a field of type string, boolean or integer. Types with enum values
|
||||
// and strings with formats are allowed.
|
||||
// If jsonPath refers to absent field in a resource, the jsonPath evaluates to an empty string.
|
||||
// Must not point to metdata fields.
|
||||
// Required.
|
||||
JSONPath string `json:"jsonPath" protobuf:"bytes,1,opt,name=jsonPath"`
|
||||
}
|
||||
|
||||
// CustomResourceColumnDefinition specifies a column for server side printing.
|
||||
|
@ -59,6 +59,8 @@ const (
|
||||
StaticEstimatedCostLimit = 10000000
|
||||
// StaticEstimatedCRDCostLimit represents the largest-allowed total cost for the x-kubernetes-validations rules of a CRD.
|
||||
StaticEstimatedCRDCostLimit = 100000000
|
||||
|
||||
MaxSelectableFields = 8
|
||||
)
|
||||
|
||||
var supportedValidationReason = sets.NewString(
|
||||
@ -291,6 +293,18 @@ func validateCustomResourceDefinitionVersion(ctx context.Context, version *apiex
|
||||
for i := range version.AdditionalPrinterColumns {
|
||||
allErrs = append(allErrs, ValidateCustomResourceColumnDefinition(&version.AdditionalPrinterColumns[i], fldPath.Child("additionalPrinterColumns").Index(i))...)
|
||||
}
|
||||
|
||||
if len(version.SelectableFields) > 0 {
|
||||
if version.Schema == nil || version.Schema.OpenAPIV3Schema == nil {
|
||||
allErrs = append(allErrs, field.Invalid(fldPath.Child("selectableFields"), "", "selectableFields may only be set when version.schema.openAPIV3Schema is not included"))
|
||||
} else {
|
||||
schema, err := structuralschema.NewStructural(version.Schema.OpenAPIV3Schema)
|
||||
if err != nil {
|
||||
allErrs = append(allErrs, field.Invalid(fldPath.Child("schema.openAPIV3Schema"), "", err.Error()))
|
||||
}
|
||||
allErrs = append(allErrs, ValidateCustomResourceSelectableFields(version.SelectableFields, schema, fldPath.Child("selectableFields"))...)
|
||||
}
|
||||
}
|
||||
return allErrs
|
||||
}
|
||||
|
||||
@ -453,6 +467,19 @@ func validateCustomResourceDefinitionSpec(ctx context.Context, spec *apiextensio
|
||||
}
|
||||
}
|
||||
|
||||
if len(spec.SelectableFields) > 0 {
|
||||
if spec.Validation == nil {
|
||||
allErrs = append(allErrs, field.Invalid(fldPath.Child("selectableFields"), "", "selectableFields may only be set when validations.schema is included"))
|
||||
} else {
|
||||
schema, err := structuralschema.NewStructural(spec.Validation.OpenAPIV3Schema)
|
||||
if err != nil {
|
||||
allErrs = append(allErrs, field.Invalid(fldPath.Child("schema.openAPIV3Schema"), "", err.Error()))
|
||||
}
|
||||
|
||||
allErrs = append(allErrs, ValidateCustomResourceSelectableFields(spec.SelectableFields, schema, fldPath.Child("selectableFields"))...)
|
||||
}
|
||||
}
|
||||
|
||||
if (spec.Conversion != nil && spec.Conversion.Strategy != apiextensions.NoneConverter) && (spec.PreserveUnknownFields == nil || *spec.PreserveUnknownFields) {
|
||||
allErrs = append(allErrs, field.Invalid(fldPath.Child("conversion").Child("strategy"), spec.Conversion.Strategy, "must be None if spec.preserveUnknownFields is true"))
|
||||
}
|
||||
@ -766,6 +793,51 @@ func ValidateCustomResourceColumnDefinition(col *apiextensions.CustomResourceCol
|
||||
return allErrs
|
||||
}
|
||||
|
||||
func ValidateCustomResourceSelectableFields(selectableFields []apiextensions.SelectableField, schema *structuralschema.Structural, fldPath *field.Path) (allErrs field.ErrorList) {
|
||||
uniqueSelectableFields := sets.New[string]()
|
||||
for i, selectableField := range selectableFields {
|
||||
indexFldPath := fldPath.Index(i)
|
||||
if len(selectableField.JSONPath) == 0 {
|
||||
allErrs = append(allErrs, field.Required(indexFldPath.Child("jsonPath"), ""))
|
||||
continue
|
||||
}
|
||||
// Leverage the field path validation originally built for use with CEL features
|
||||
path, foundSchema, err := cel.ValidFieldPath(selectableField.JSONPath, schema, cel.WithFieldPathAllowArrayNotation(false))
|
||||
if err != nil {
|
||||
allErrs = append(allErrs, field.Invalid(indexFldPath.Child("jsonPath"), selectableField.JSONPath, fmt.Sprintf("is an invalid path: %v", err)))
|
||||
continue
|
||||
}
|
||||
if path.Root().String() == "metadata" {
|
||||
allErrs = append(allErrs, field.Invalid(indexFldPath, selectableField.JSONPath, "must not point to fields in metadata"))
|
||||
}
|
||||
if !allowedSelectableFieldSchema(foundSchema) {
|
||||
allErrs = append(allErrs, field.Invalid(indexFldPath, selectableField.JSONPath, "must point to a field of type string, boolean or integer. Enum string fields and strings with formats are allowed."))
|
||||
}
|
||||
if uniqueSelectableFields.Has(path.String()) {
|
||||
allErrs = append(allErrs, field.Duplicate(indexFldPath, selectableField.JSONPath))
|
||||
} else {
|
||||
uniqueSelectableFields.Insert(path.String())
|
||||
}
|
||||
}
|
||||
uniqueSelectableFieldCount := uniqueSelectableFields.Len()
|
||||
if uniqueSelectableFieldCount > MaxSelectableFields {
|
||||
allErrs = append(allErrs, field.TooMany(fldPath, uniqueSelectableFieldCount, MaxSelectableFields))
|
||||
}
|
||||
return allErrs
|
||||
}
|
||||
|
||||
func allowedSelectableFieldSchema(schema *structuralschema.Structural) bool {
|
||||
if schema == nil {
|
||||
return false
|
||||
}
|
||||
switch schema.Type {
|
||||
case "string", "boolean", "integer":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// specStandardValidator applies validations for different OpenAPI specification versions.
|
||||
type specStandardValidator interface {
|
||||
validate(spec *apiextensions.JSONSchemaProps, fldPath *field.Path) field.ErrorList
|
||||
@ -1201,7 +1273,7 @@ func ValidateCustomResourceDefinitionOpenAPISchema(schema *apiextensions.JSONSch
|
||||
func pathValid(schema *apiextensions.JSONSchemaProps, path string) bool {
|
||||
// To avoid duplicated code and better maintain, using ValidaFieldPath func to check if the path is valid
|
||||
if ss, err := structuralschema.NewStructural(schema); err == nil {
|
||||
_, err := cel.ValidFieldPath(path, ss)
|
||||
_, _, err := cel.ValidFieldPath(path, ss)
|
||||
return err == nil
|
||||
}
|
||||
return true
|
||||
|
@ -31,6 +31,7 @@ import (
|
||||
apiextensionsv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1"
|
||||
structuralschema "k8s.io/apiextensions-apiserver/pkg/apiserver/schema"
|
||||
celschema "k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel"
|
||||
apiextensionsfeatures "k8s.io/apiextensions-apiserver/pkg/features"
|
||||
"k8s.io/apimachinery/pkg/api/apitesting/fuzzer"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
@ -40,6 +41,8 @@ import (
|
||||
"k8s.io/apimachinery/pkg/util/version"
|
||||
"k8s.io/apiserver/pkg/cel/environment"
|
||||
"k8s.io/apiserver/pkg/cel/library"
|
||||
utilfeature "k8s.io/apiserver/pkg/util/feature"
|
||||
featuregatetesting "k8s.io/component-base/featuregate/testing"
|
||||
"k8s.io/utils/pointer"
|
||||
"k8s.io/utils/ptr"
|
||||
)
|
||||
@ -71,6 +74,12 @@ func immutable(path ...string) validationMatch {
|
||||
func forbidden(path ...string) validationMatch {
|
||||
return validationMatch{path: field.NewPath(path[0], path[1:]...), errorType: field.ErrorTypeForbidden}
|
||||
}
|
||||
func duplicate(path ...string) validationMatch {
|
||||
return validationMatch{path: field.NewPath(path[0], path[1:]...), errorType: field.ErrorTypeDuplicate}
|
||||
}
|
||||
func tooMany(path ...string) validationMatch {
|
||||
return validationMatch{path: field.NewPath(path[0], path[1:]...), errorType: field.ErrorTypeTooMany}
|
||||
}
|
||||
|
||||
func (v validationMatch) matches(err *field.Error) bool {
|
||||
return err.Type == v.errorType && err.Field == v.path.String() && strings.Contains(err.Error(), v.containsString)
|
||||
@ -4363,6 +4372,294 @@ func TestValidateCustomResourceDefinition(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectableFields(t *testing.T) {
|
||||
defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, apiextensionsfeatures.CustomResourceFieldSelectors, true)()
|
||||
singleVersionList := []apiextensions.CustomResourceDefinitionVersion{
|
||||
{
|
||||
Name: "version",
|
||||
Served: true,
|
||||
Storage: true,
|
||||
},
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
resource *apiextensions.CustomResourceDefinition
|
||||
errors []validationMatch
|
||||
}{
|
||||
{
|
||||
name: "selectableFields with jsonPaths that do not refer to a field in the schema are invalid",
|
||||
resource: &apiextensions.CustomResourceDefinition{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "plural.group.com"},
|
||||
Spec: apiextensions.CustomResourceDefinitionSpec{
|
||||
Group: "group.com",
|
||||
Version: "version",
|
||||
Versions: []apiextensions.CustomResourceDefinitionVersion{
|
||||
{Name: "version", Served: true, Storage: true,
|
||||
Schema: &apiextensions.CustomResourceValidation{
|
||||
OpenAPIV3Schema: &apiextensions.JSONSchemaProps{
|
||||
Type: "object",
|
||||
Properties: map[string]apiextensions.JSONSchemaProps{"foo": {Type: "string"}},
|
||||
Required: []string{"foo"},
|
||||
},
|
||||
},
|
||||
SelectableFields: []apiextensions.SelectableField{{JSONPath: ".foo"}, {JSONPath: ".xyz"}},
|
||||
},
|
||||
{Name: "version2", Served: true, Storage: false,
|
||||
Schema: &apiextensions.CustomResourceValidation{
|
||||
OpenAPIV3Schema: &apiextensions.JSONSchemaProps{
|
||||
Type: "object",
|
||||
Properties: map[string]apiextensions.JSONSchemaProps{"foo": {Type: "integer"}},
|
||||
Required: []string{"foo"},
|
||||
},
|
||||
},
|
||||
SelectableFields: []apiextensions.SelectableField{{JSONPath: ".xyz"}, {JSONPath: ".foo"}, {JSONPath: ".abc"}},
|
||||
},
|
||||
},
|
||||
Scope: apiextensions.NamespaceScoped,
|
||||
Names: apiextensions.CustomResourceDefinitionNames{
|
||||
Plural: "plural",
|
||||
Singular: "singular",
|
||||
Kind: "Plural",
|
||||
ListKind: "PluralList",
|
||||
},
|
||||
PreserveUnknownFields: ptr.To(false),
|
||||
},
|
||||
Status: apiextensions.CustomResourceDefinitionStatus{
|
||||
StoredVersions: []string{"version"},
|
||||
},
|
||||
},
|
||||
errors: []validationMatch{
|
||||
invalid("spec", "versions[0]", "selectableFields[1]"),
|
||||
invalid("spec", "versions[1]", "selectableFields[0]"),
|
||||
invalid("spec", "versions[1]", "selectableFields[2]"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "in top level schema, selectableFields with jsonPaths that do not refer to a field in the schema are invalid",
|
||||
resource: &apiextensions.CustomResourceDefinition{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "plural.group.com"},
|
||||
Spec: apiextensions.CustomResourceDefinitionSpec{
|
||||
Group: "group.com",
|
||||
Version: "version",
|
||||
Versions: singleVersionList,
|
||||
Validation: &apiextensions.CustomResourceValidation{
|
||||
OpenAPIV3Schema: &apiextensions.JSONSchemaProps{
|
||||
Type: "object",
|
||||
Properties: map[string]apiextensions.JSONSchemaProps{
|
||||
"spec": {
|
||||
Type: "object",
|
||||
Properties: map[string]apiextensions.JSONSchemaProps{"foo": {Type: "string"}},
|
||||
Required: []string{"foo"},
|
||||
},
|
||||
"status": {
|
||||
Type: "object",
|
||||
Properties: map[string]apiextensions.JSONSchemaProps{"phase": {Type: "string"}},
|
||||
Required: []string{"phase"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
SelectableFields: []apiextensions.SelectableField{{JSONPath: ".spec.foo"}, {JSONPath: ".spec.xyz"}, {JSONPath: ".status.phase"}},
|
||||
Scope: apiextensions.NamespaceScoped,
|
||||
Names: apiextensions.CustomResourceDefinitionNames{
|
||||
Plural: "plural",
|
||||
Singular: "singular",
|
||||
Kind: "Plural",
|
||||
ListKind: "PluralList",
|
||||
},
|
||||
PreserveUnknownFields: ptr.To(false),
|
||||
},
|
||||
Status: apiextensions.CustomResourceDefinitionStatus{
|
||||
StoredVersions: []string{"version"},
|
||||
},
|
||||
},
|
||||
errors: []validationMatch{
|
||||
invalid("spec", "selectableFields[1]"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "selectableFields with jsonPaths that do not refer to fields that are not strings, booleans or integers are invalid",
|
||||
resource: &apiextensions.CustomResourceDefinition{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "plural.group.com"},
|
||||
Spec: apiextensions.CustomResourceDefinitionSpec{
|
||||
Group: "group.com",
|
||||
Version: "version",
|
||||
Versions: []apiextensions.CustomResourceDefinitionVersion{
|
||||
{Name: "version", Served: true, Storage: true,
|
||||
Schema: &apiextensions.CustomResourceValidation{
|
||||
OpenAPIV3Schema: &apiextensions.JSONSchemaProps{
|
||||
Type: "object",
|
||||
Properties: map[string]apiextensions.JSONSchemaProps{"foo": {Type: "string"}, "obj": {Type: "object"}},
|
||||
Required: []string{"foo", "obj"},
|
||||
},
|
||||
},
|
||||
SelectableFields: []apiextensions.SelectableField{{JSONPath: ".foo"}, {JSONPath: ".obj"}},
|
||||
},
|
||||
{Name: "version2", Served: true, Storage: false,
|
||||
Schema: &apiextensions.CustomResourceValidation{
|
||||
OpenAPIV3Schema: &apiextensions.JSONSchemaProps{
|
||||
Type: "object",
|
||||
Properties: map[string]apiextensions.JSONSchemaProps{"foo": {Type: "integer"}, "obj": {Type: "object"}, "bool": {Type: "boolean"}},
|
||||
Required: []string{"foo", "obj", "bool"},
|
||||
},
|
||||
},
|
||||
SelectableFields: []apiextensions.SelectableField{{JSONPath: ".obj"}, {JSONPath: ".foo"}, {JSONPath: ".bool"}},
|
||||
},
|
||||
},
|
||||
Scope: apiextensions.NamespaceScoped,
|
||||
Names: apiextensions.CustomResourceDefinitionNames{
|
||||
Plural: "plural",
|
||||
Singular: "singular",
|
||||
Kind: "Plural",
|
||||
ListKind: "PluralList",
|
||||
},
|
||||
PreserveUnknownFields: ptr.To(false),
|
||||
},
|
||||
Status: apiextensions.CustomResourceDefinitionStatus{
|
||||
StoredVersions: []string{"version"},
|
||||
},
|
||||
},
|
||||
errors: []validationMatch{
|
||||
invalid("spec", "versions[0]", "selectableFields[1]"),
|
||||
invalid("spec", "versions[1]", "selectableFields[0]"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "selectableFields with duplicate jsonPaths are invalid",
|
||||
resource: &apiextensions.CustomResourceDefinition{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "plural.group.com"},
|
||||
Spec: apiextensions.CustomResourceDefinitionSpec{
|
||||
Group: "group.com",
|
||||
Version: "version",
|
||||
Versions: []apiextensions.CustomResourceDefinitionVersion{
|
||||
{Name: "version", Served: true, Storage: true,
|
||||
Schema: &apiextensions.CustomResourceValidation{
|
||||
OpenAPIV3Schema: &apiextensions.JSONSchemaProps{
|
||||
Type: "object",
|
||||
Properties: map[string]apiextensions.JSONSchemaProps{"foo": {Type: "string"}},
|
||||
Required: []string{"foo"},
|
||||
},
|
||||
},
|
||||
SelectableFields: []apiextensions.SelectableField{{JSONPath: ".foo"}, {JSONPath: ".foo"}},
|
||||
},
|
||||
{Name: "version2", Served: true, Storage: false,
|
||||
Schema: &apiextensions.CustomResourceValidation{
|
||||
OpenAPIV3Schema: &apiextensions.JSONSchemaProps{
|
||||
Type: "object",
|
||||
Properties: map[string]apiextensions.JSONSchemaProps{"foo": {Type: "integer"}},
|
||||
Required: []string{"foo"},
|
||||
},
|
||||
},
|
||||
SelectableFields: []apiextensions.SelectableField{{JSONPath: ".foo"}, {JSONPath: ".foo"}},
|
||||
},
|
||||
},
|
||||
Scope: apiextensions.NamespaceScoped,
|
||||
Names: apiextensions.CustomResourceDefinitionNames{
|
||||
Plural: "plural",
|
||||
Singular: "singular",
|
||||
Kind: "Plural",
|
||||
ListKind: "PluralList",
|
||||
},
|
||||
PreserveUnknownFields: ptr.To(false),
|
||||
},
|
||||
Status: apiextensions.CustomResourceDefinitionStatus{
|
||||
StoredVersions: []string{"version"},
|
||||
},
|
||||
},
|
||||
errors: []validationMatch{
|
||||
duplicate("spec", "versions[0]", "selectableFields[1]"),
|
||||
duplicate("spec", "versions[1]", "selectableFields[1]"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "too many selectableFields are not allowed",
|
||||
resource: &apiextensions.CustomResourceDefinition{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "plural.group.com"},
|
||||
Spec: apiextensions.CustomResourceDefinitionSpec{
|
||||
Group: "group.com",
|
||||
Version: "version",
|
||||
Versions: []apiextensions.CustomResourceDefinitionVersion{
|
||||
{Name: "version", Served: true, Storage: true,
|
||||
Schema: &apiextensions.CustomResourceValidation{
|
||||
OpenAPIV3Schema: &apiextensions.JSONSchemaProps{
|
||||
Type: "object",
|
||||
Properties: map[string]apiextensions.JSONSchemaProps{
|
||||
"a1": {Type: "string"}, "a2": {Type: "string"}, "a3": {Type: "string"},
|
||||
"a4": {Type: "string"}, "a5": {Type: "string"}, "a6": {Type: "string"},
|
||||
"a7": {Type: "string"}, "a8": {Type: "string"}, "a9": {Type: "string"},
|
||||
},
|
||||
Required: []string{"a1", "a2", "a3", "a4", "a5", "a6", "a7", "a8", "a9"},
|
||||
},
|
||||
},
|
||||
SelectableFields: []apiextensions.SelectableField{
|
||||
{JSONPath: ".a1"}, {JSONPath: ".a2"}, {JSONPath: ".a3"},
|
||||
{JSONPath: ".a4"}, {JSONPath: ".a5"}, {JSONPath: ".a6"},
|
||||
{JSONPath: ".a7"}, {JSONPath: ".a8"}, {JSONPath: ".a9"},
|
||||
},
|
||||
},
|
||||
{Name: "version2", Served: true, Storage: false,
|
||||
Schema: &apiextensions.CustomResourceValidation{
|
||||
OpenAPIV3Schema: &apiextensions.JSONSchemaProps{
|
||||
Type: "object",
|
||||
Properties: map[string]apiextensions.JSONSchemaProps{"foo": {Type: "integer"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Scope: apiextensions.NamespaceScoped,
|
||||
Names: apiextensions.CustomResourceDefinitionNames{
|
||||
Plural: "plural",
|
||||
Singular: "singular",
|
||||
Kind: "Plural",
|
||||
ListKind: "PluralList",
|
||||
},
|
||||
PreserveUnknownFields: ptr.To(false),
|
||||
},
|
||||
Status: apiextensions.CustomResourceDefinitionStatus{
|
||||
StoredVersions: []string{"version"},
|
||||
},
|
||||
},
|
||||
errors: []validationMatch{
|
||||
tooMany("spec", "versions[0]", "selectableFields"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
||||
// duplicate defaulting behaviour
|
||||
if tc.resource.Spec.Conversion != nil && tc.resource.Spec.Conversion.Strategy == apiextensions.WebhookConverter && len(tc.resource.Spec.Conversion.ConversionReviewVersions) == 0 {
|
||||
tc.resource.Spec.Conversion.ConversionReviewVersions = []string{"v1beta1"}
|
||||
}
|
||||
ctx := context.TODO()
|
||||
errs := ValidateCustomResourceDefinition(ctx, tc.resource)
|
||||
seenErrs := make([]bool, len(errs))
|
||||
|
||||
for _, expectedError := range tc.errors {
|
||||
found := false
|
||||
for i, err := range errs {
|
||||
if expectedError.matches(err) && !seenErrs[i] {
|
||||
found = true
|
||||
seenErrs[i] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
t.Errorf("expected %v at %v, got %v", expectedError.errorType, expectedError.path.String(), errs)
|
||||
}
|
||||
}
|
||||
|
||||
for i, seen := range seenErrs {
|
||||
if !seen {
|
||||
t.Errorf("unexpected error: %v", errs[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFieldPath(t *testing.T) {
|
||||
schema := apiextensions.JSONSchemaProps{
|
||||
Type: "object",
|
||||
@ -4589,7 +4886,7 @@ func TestValidateFieldPath(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("error when converting schema to structural schema: %v", err)
|
||||
}
|
||||
_, err = celschema.ValidFieldPath(tc.fieldPath, ss)
|
||||
_, _, err = celschema.ValidFieldPath(tc.fieldPath, ss)
|
||||
if err == nil && tc.errMsg != "" {
|
||||
t.Errorf("expected err contains: %v but get nil", tc.errMsg)
|
||||
}
|
||||
|
@ -18,12 +18,16 @@ package conversion
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
autoscalingv1 "k8s.io/api/autoscaling/v1"
|
||||
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
|
||||
apiextensionsfeatures "k8s.io/apiextensions-apiserver/pkg/features"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apimachinery/pkg/util/sets"
|
||||
utilfeature "k8s.io/apiserver/pkg/util/feature"
|
||||
"k8s.io/apiserver/pkg/util/webhook"
|
||||
typedscheme "k8s.io/client-go/kubernetes/scheme"
|
||||
)
|
||||
@ -76,10 +80,19 @@ func (m *CRConverterFactory) NewConverter(crd *apiextensionsv1.CustomResourceDef
|
||||
|
||||
// Determine whether we should expect to be asked to "convert" autoscaling/v1 Scale types
|
||||
convertScale := false
|
||||
selectableFields := map[schema.GroupVersion]sets.Set[string]{}
|
||||
for _, version := range crd.Spec.Versions {
|
||||
gv := schema.GroupVersion{Group: crd.Spec.Group, Version: version.Name}
|
||||
if version.Subresources != nil && version.Subresources.Scale != nil {
|
||||
convertScale = true
|
||||
}
|
||||
if utilfeature.DefaultFeatureGate.Enabled(apiextensionsfeatures.CustomResourceFieldSelectors) {
|
||||
fieldPaths := sets.New[string]()
|
||||
for _, sf := range version.SelectableFields {
|
||||
fieldPaths.Insert(strings.TrimPrefix(sf.JSONPath, "."))
|
||||
}
|
||||
selectableFields[gv] = fieldPaths
|
||||
}
|
||||
}
|
||||
|
||||
unsafe = &crConverter{
|
||||
@ -87,6 +100,7 @@ func (m *CRConverterFactory) NewConverter(crd *apiextensionsv1.CustomResourceDef
|
||||
validVersions: validVersions,
|
||||
clusterScoped: crd.Spec.Scope == apiextensionsv1.ClusterScoped,
|
||||
converter: converter,
|
||||
selectableFields: selectableFields,
|
||||
}
|
||||
return &safeConverterWrapper{unsafe}, unsafe, nil
|
||||
}
|
||||
@ -106,16 +120,22 @@ type crConverter struct {
|
||||
converter crConverterInterface
|
||||
validVersions map[schema.GroupVersion]bool
|
||||
clusterScoped bool
|
||||
selectableFields map[schema.GroupVersion]sets.Set[string]
|
||||
}
|
||||
|
||||
func (c *crConverter) ConvertFieldLabel(gvk schema.GroupVersionKind, label, value string) (string, string, error) {
|
||||
// We currently only support metadata.namespace and metadata.name.
|
||||
switch {
|
||||
case label == "metadata.name":
|
||||
return label, value, nil
|
||||
case !c.clusterScoped && label == "metadata.namespace":
|
||||
return label, value, nil
|
||||
default:
|
||||
if utilfeature.DefaultFeatureGate.Enabled(apiextensionsfeatures.CustomResourceFieldSelectors) {
|
||||
groupFields := c.selectableFields[gvk.GroupVersion()]
|
||||
if groupFields != nil && groupFields.Has(label) {
|
||||
return label, value, nil
|
||||
}
|
||||
}
|
||||
return "", "", fmt.Errorf("field label not supported: %s", label)
|
||||
}
|
||||
}
|
||||
|
@ -826,6 +826,7 @@ func (r *crdHandler) getOrCreateServingInfoFor(uid types.UID, name string) (*crd
|
||||
structuralSchemas[v.Name],
|
||||
statusSpec,
|
||||
scaleSpec,
|
||||
v.SelectableFields,
|
||||
),
|
||||
crdConversionRESTOptionsGetter{
|
||||
RESTOptionsGetter: r.restOptionsGetter,
|
||||
|
@ -282,7 +282,7 @@ func compileRule(s *schema.Structural, rule apiextensions.ValidationRule, envSet
|
||||
compilationResult.MessageExpressionMaxCost = costEst.Max
|
||||
}
|
||||
if rule.FieldPath != "" {
|
||||
validFieldPath, err := ValidFieldPath(rule.FieldPath, s)
|
||||
validFieldPath, _, err := ValidFieldPath(rule.FieldPath, s)
|
||||
if err == nil {
|
||||
compilationResult.NormalizedRuleFieldPath = validFieldPath.String()
|
||||
}
|
||||
|
@ -31,9 +31,6 @@ import (
|
||||
"github.com/google/cel-go/common/types/ref"
|
||||
"github.com/google/cel-go/interpreter"
|
||||
|
||||
"k8s.io/klog/v2"
|
||||
"k8s.io/utils/ptr"
|
||||
|
||||
apiextensions "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
|
||||
"k8s.io/apiextensions-apiserver/pkg/apiserver/schema"
|
||||
"k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/model"
|
||||
@ -45,6 +42,8 @@ import (
|
||||
"k8s.io/apiserver/pkg/cel/metrics"
|
||||
utilfeature "k8s.io/apiserver/pkg/util/feature"
|
||||
"k8s.io/apiserver/pkg/warning"
|
||||
"k8s.io/klog/v2"
|
||||
"k8s.io/utils/ptr"
|
||||
|
||||
celconfig "k8s.io/apiserver/pkg/apis/cel"
|
||||
)
|
||||
@ -441,9 +440,30 @@ func unescapeSingleQuote(s string) (string, error) {
|
||||
return unescaped, err
|
||||
}
|
||||
|
||||
type validFieldPathOptions struct {
|
||||
allowArrayNotation bool
|
||||
}
|
||||
|
||||
// ValidFieldPathOption provides vararg options for ValidFieldPath.
|
||||
type ValidFieldPathOption func(*validFieldPathOptions)
|
||||
|
||||
// WithFieldPathAllowArrayNotation sets of array annotation ('[<index or map key>]') is allowed
|
||||
// in field paths.
|
||||
// Defaults to true
|
||||
func WithFieldPathAllowArrayNotation(allow bool) ValidFieldPathOption {
|
||||
return func(options *validFieldPathOptions) {
|
||||
options.allowArrayNotation = allow
|
||||
}
|
||||
}
|
||||
|
||||
// ValidFieldPath validates that jsonPath is a valid JSON Path containing only field and map accessors
|
||||
// that are valid for the given schema, and returns a field.Path representation of the validated jsonPath or an error.
|
||||
func ValidFieldPath(jsonPath string, schema *schema.Structural) (validFieldPath *field.Path, err error) {
|
||||
func ValidFieldPath(jsonPath string, schema *schema.Structural, options ...ValidFieldPathOption) (validFieldPath *field.Path, foundSchema *schema.Structural, err error) {
|
||||
opts := &validFieldPathOptions{allowArrayNotation: true}
|
||||
for _, opt := range options {
|
||||
opt(opts)
|
||||
}
|
||||
|
||||
appendToPath := func(name string, isNamed bool) error {
|
||||
if !isNamed {
|
||||
validFieldPath = validFieldPath.Key(name)
|
||||
@ -504,16 +524,19 @@ func ValidFieldPath(jsonPath string, schema *schema.Structural) (validFieldPath
|
||||
tok = scanner.Text()
|
||||
switch tok {
|
||||
case "[":
|
||||
if !opts.allowArrayNotation {
|
||||
return nil, nil, fmt.Errorf("array notation is not allowed")
|
||||
}
|
||||
if !scanner.Scan() {
|
||||
return nil, fmt.Errorf("unexpected end of JSON path")
|
||||
return nil, nil, fmt.Errorf("unexpected end of JSON path")
|
||||
}
|
||||
tok = scanner.Text()
|
||||
if len(tok) < 2 || tok[0] != '\'' || tok[len(tok)-1] != '\'' {
|
||||
return nil, fmt.Errorf("expected single quoted string but got %s", tok)
|
||||
return nil, nil, fmt.Errorf("expected single quoted string but got %s", tok)
|
||||
}
|
||||
unescaped, err := unescapeSingleQuote(tok[1 : len(tok)-1])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid string literal: %v", err)
|
||||
return nil, nil, fmt.Errorf("invalid string literal: %w", err)
|
||||
}
|
||||
|
||||
if schema.Properties != nil {
|
||||
@ -521,21 +544,21 @@ func ValidFieldPath(jsonPath string, schema *schema.Structural) (validFieldPath
|
||||
} else if schema.AdditionalProperties != nil {
|
||||
isNamed = false
|
||||
} else {
|
||||
return nil, fmt.Errorf("does not refer to a valid field")
|
||||
return nil, nil, fmt.Errorf("does not refer to a valid field")
|
||||
}
|
||||
if err := appendToPath(unescaped, isNamed); err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
if !scanner.Scan() {
|
||||
return nil, fmt.Errorf("unexpected end of JSON path")
|
||||
return nil, nil, fmt.Errorf("unexpected end of JSON path")
|
||||
}
|
||||
tok = scanner.Text()
|
||||
if tok != "]" {
|
||||
return nil, fmt.Errorf("expected ] but got %s", tok)
|
||||
return nil, nil, fmt.Errorf("expected ] but got %s", tok)
|
||||
}
|
||||
case ".":
|
||||
if !scanner.Scan() {
|
||||
return nil, fmt.Errorf("unexpected end of JSON path")
|
||||
return nil, nil, fmt.Errorf("unexpected end of JSON path")
|
||||
}
|
||||
tok = scanner.Text()
|
||||
if schema.Properties != nil {
|
||||
@ -543,16 +566,17 @@ func ValidFieldPath(jsonPath string, schema *schema.Structural) (validFieldPath
|
||||
} else if schema.AdditionalProperties != nil {
|
||||
isNamed = false
|
||||
} else {
|
||||
return nil, fmt.Errorf("does not refer to a valid field")
|
||||
return nil, nil, fmt.Errorf("does not refer to a valid field")
|
||||
}
|
||||
if err := appendToPath(tok, isNamed); err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("expected [ or . but got: %s", tok)
|
||||
return nil, nil, fmt.Errorf("expected [ or . but got: %s", tok)
|
||||
}
|
||||
}
|
||||
return validFieldPath, nil
|
||||
|
||||
return validFieldPath, schema, nil
|
||||
}
|
||||
|
||||
func fieldErrorForReason(fldPath *field.Path, value interface{}, detail string, reason *apiextensions.FieldValueErrorReason) *field.Error {
|
||||
|
@ -3119,7 +3119,7 @@ func TestValidateFieldPath(t *testing.T) {
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
validField, err := ValidFieldPath(tc.fieldPath, tc.schema)
|
||||
validField, _, err := ValidFieldPath(tc.fieldPath, tc.schema)
|
||||
|
||||
if err == nil && tc.errDetail != "" {
|
||||
t.Errorf("expected err contains: %v but get nil", tc.errDetail)
|
||||
|
@ -275,6 +275,7 @@ func TestMetrics(t *testing.T) {
|
||||
sts,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
iters := 1
|
||||
|
@ -27,11 +27,14 @@ import (
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
|
||||
"k8s.io/apiextensions-apiserver/pkg/apiserver"
|
||||
extensionsapiserver "k8s.io/apiextensions-apiserver/pkg/apiserver"
|
||||
"k8s.io/apiextensions-apiserver/pkg/cmd/server/options"
|
||||
generatedopenapi "k8s.io/apiextensions-apiserver/pkg/generated/openapi"
|
||||
"k8s.io/apimachinery/pkg/util/wait"
|
||||
openapinamer "k8s.io/apiserver/pkg/endpoints/openapi"
|
||||
genericapiserver "k8s.io/apiserver/pkg/server"
|
||||
"k8s.io/apiserver/pkg/storage/storagebackend"
|
||||
"k8s.io/apiserver/pkg/util/openapi"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
restclient "k8s.io/client-go/rest"
|
||||
logsapi "k8s.io/component-base/logs/api/v1"
|
||||
@ -58,7 +61,7 @@ type TestServer struct {
|
||||
ServerOpts *options.CustomResourceDefinitionsServerOptions // ServerOpts
|
||||
TearDownFn TearDownFunc // TearDown function
|
||||
TmpDir string // Temp Dir used, by the apiserver
|
||||
CompletedConfig apiserver.CompletedConfig
|
||||
CompletedConfig extensionsapiserver.CompletedConfig
|
||||
}
|
||||
|
||||
// Logger allows t.Testing and b.Testing to be passed to StartTestServer and StartTestServerOrDie
|
||||
@ -151,6 +154,11 @@ func StartTestServer(t Logger, _ *TestServerInstanceOptions, customFlags []strin
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("failed to create config from options: %v", err)
|
||||
}
|
||||
|
||||
getOpenAPIDefinitions := openapi.GetOpenAPIDefinitionsWithoutDisabledFeatures(generatedopenapi.GetOpenAPIDefinitions)
|
||||
namer := openapinamer.NewDefinitionNamer(extensionsapiserver.Scheme)
|
||||
config.GenericConfig.OpenAPIConfig = genericapiserver.DefaultOpenAPIConfig(getOpenAPIDefinitions, namer)
|
||||
|
||||
completedConfig := config.Complete()
|
||||
server, err := completedConfig.New(genericapiserver.NewEmptyDelegate())
|
||||
if err != nil {
|
||||
|
@ -97,6 +97,8 @@ type Options struct {
|
||||
|
||||
// AllowNonStructural indicates swagger should be built for a schema that fits into the structural type but does not meet all structural invariants
|
||||
AllowNonStructural bool
|
||||
|
||||
IncludeSelectableFields bool
|
||||
}
|
||||
|
||||
func generateBuilder(crd *apiextensionsv1.CustomResourceDefinition, version string, opts Options) (*builder, error) {
|
||||
@ -313,12 +315,12 @@ func (b *builder) buildRoute(root, path, httpMethod, actionVerb, operationVerb s
|
||||
Doc(b.descriptionFor(path, operationVerb)).
|
||||
Param(b.ws.QueryParameter("pretty", "If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget).")).
|
||||
Operation(operationVerb+namespaced+b.kind+strings.Title(subresource(path))).
|
||||
Metadata(endpoints.ROUTE_META_GVK, metav1.GroupVersionKind{
|
||||
Metadata(endpoints.RouteMetaGVK, metav1.GroupVersionKind{
|
||||
Group: b.group,
|
||||
Version: b.version,
|
||||
Kind: b.kind,
|
||||
}).
|
||||
Metadata(endpoints.ROUTE_META_ACTION, actionVerb).
|
||||
Metadata(endpoints.RouteMetaAction, actionVerb).
|
||||
Produces("application/json", "application/yaml").
|
||||
Returns(http.StatusOK, "OK", sample).
|
||||
Writes(sample)
|
||||
@ -374,7 +376,7 @@ func (b *builder) buildRoute(root, path, httpMethod, actionVerb, operationVerb s
|
||||
|
||||
// buildKubeNative builds input schema with Kubernetes' native object meta, type meta and
|
||||
// extensions
|
||||
func (b *builder) buildKubeNative(schema *structuralschema.Structural, opts Options, crdPreserveUnknownFields bool) (ret *spec.Schema) {
|
||||
func (b *builder) buildKubeNative(crd *apiextensionsv1.CustomResourceDefinition, schema *structuralschema.Structural, opts Options, crdPreserveUnknownFields bool) (ret *spec.Schema) {
|
||||
// only add properties if we have a schema. Otherwise, kubectl would (wrongly) assume additionalProperties=false
|
||||
// and forbid anything outside of apiVersion, kind and metadata. We have to fix kubectl to stop doing this, e.g. by
|
||||
// adding additionalProperties=true support to explicitly allow additional fields.
|
||||
@ -395,7 +397,7 @@ func (b *builder) buildKubeNative(schema *structuralschema.Structural, opts Opti
|
||||
addTypeMetaProperties(ret, opts.V2)
|
||||
addEmbeddedProperties(ret, opts)
|
||||
}
|
||||
ret.AddExtension(endpoints.ROUTE_META_GVK, []interface{}{
|
||||
ret.AddExtension(endpoints.RouteMetaGVK, []interface{}{
|
||||
map[string]interface{}{
|
||||
"group": b.group,
|
||||
"version": b.version,
|
||||
@ -403,6 +405,12 @@ func (b *builder) buildKubeNative(schema *structuralschema.Structural, opts Opti
|
||||
},
|
||||
})
|
||||
|
||||
if opts.IncludeSelectableFields {
|
||||
if selectableFields := buildSelectableFields(crd, b.version); selectableFields != nil {
|
||||
ret.AddExtension(endpoints.RouteMetaSelectableFields, selectableFields)
|
||||
}
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
@ -486,24 +494,29 @@ func addTypeMetaProperties(s *spec.Schema, v2 bool) {
|
||||
}
|
||||
|
||||
// buildListSchema builds the list kind schema for the CRD
|
||||
func (b *builder) buildListSchema(v2 bool) *spec.Schema {
|
||||
func (b *builder) buildListSchema(crd *apiextensionsv1.CustomResourceDefinition, opts Options) *spec.Schema {
|
||||
name := definitionPrefix + util.ToRESTFriendlyName(fmt.Sprintf("%s/%s/%s", b.group, b.version, b.kind))
|
||||
doc := fmt.Sprintf("List of %s. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md", b.plural)
|
||||
s := new(spec.Schema).
|
||||
Typed("object", "").
|
||||
WithDescription(fmt.Sprintf("%s is a list of %s", b.listKind, b.kind)).
|
||||
WithRequired("items").
|
||||
SetProperty("items", *spec.ArrayProperty(spec.RefSchema(refForOpenAPIVersion(name, v2))).WithDescription(doc)).
|
||||
SetProperty("metadata", *spec.RefSchema(refForOpenAPIVersion(listMetaSchemaRef, v2)).WithDescription(swaggerPartialObjectMetadataListDescriptions["metadata"]))
|
||||
SetProperty("items", *spec.ArrayProperty(spec.RefSchema(refForOpenAPIVersion(name, opts.V2))).WithDescription(doc)).
|
||||
SetProperty("metadata", *spec.RefSchema(refForOpenAPIVersion(listMetaSchemaRef, opts.V2)).WithDescription(swaggerPartialObjectMetadataListDescriptions["metadata"]))
|
||||
|
||||
addTypeMetaProperties(s, v2)
|
||||
s.AddExtension(endpoints.ROUTE_META_GVK, []map[string]string{
|
||||
addTypeMetaProperties(s, opts.V2)
|
||||
s.AddExtension(endpoints.RouteMetaGVK, []map[string]string{
|
||||
{
|
||||
"group": b.group,
|
||||
"version": b.version,
|
||||
"kind": b.listKind,
|
||||
},
|
||||
})
|
||||
if opts.IncludeSelectableFields {
|
||||
if selectableFields := buildSelectableFields(crd, b.version); selectableFields != nil {
|
||||
s.AddExtension(endpoints.RouteMetaSelectableFields, selectableFields)
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
@ -596,8 +609,29 @@ func newBuilder(crd *apiextensionsv1.CustomResourceDefinition, version string, s
|
||||
}
|
||||
|
||||
// Pre-build schema with Kubernetes native properties
|
||||
b.schema = b.buildKubeNative(schema, opts, crd.Spec.PreserveUnknownFields)
|
||||
b.listSchema = b.buildListSchema(opts.V2)
|
||||
b.schema = b.buildKubeNative(crd, schema, opts, crd.Spec.PreserveUnknownFields)
|
||||
b.listSchema = b.buildListSchema(crd, opts)
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
func buildSelectableFields(crd *apiextensionsv1.CustomResourceDefinition, version string) any {
|
||||
var specVersion *apiextensionsv1.CustomResourceDefinitionVersion
|
||||
for _, v := range crd.Spec.Versions {
|
||||
if v.Name == version {
|
||||
specVersion = &v
|
||||
break
|
||||
}
|
||||
}
|
||||
if specVersion == nil && len(specVersion.SelectableFields) == 0 {
|
||||
return nil
|
||||
}
|
||||
selectableFields := make([]any, len(specVersion.SelectableFields))
|
||||
for i, sf := range specVersion.SelectableFields {
|
||||
props := map[string]any{
|
||||
"fieldPath": strings.TrimPrefix(sf.JSONPath, "."),
|
||||
}
|
||||
selectableFields[i] = props
|
||||
}
|
||||
return selectableFields
|
||||
}
|
||||
|
@ -33,6 +33,7 @@ import (
|
||||
"k8s.io/apiserver/pkg/endpoints"
|
||||
"k8s.io/kube-openapi/pkg/validation/spec"
|
||||
utilpointer "k8s.io/utils/pointer"
|
||||
"k8s.io/utils/ptr"
|
||||
)
|
||||
|
||||
func TestNewBuilder(t *testing.T) {
|
||||
@ -353,7 +354,7 @@ func TestCRDRouteParameterBuilder(t *testing.T) {
|
||||
actions := sets.NewString()
|
||||
for _, operation := range []*spec.Operation{path.Get, path.Post, path.Put, path.Patch, path.Delete} {
|
||||
if operation != nil {
|
||||
action, ok := operation.VendorExtensible.Extensions.GetString(endpoints.ROUTE_META_ACTION)
|
||||
action, ok := operation.VendorExtensible.Extensions.GetString(endpoints.RouteMetaAction)
|
||||
if ok {
|
||||
actions.Insert(action)
|
||||
}
|
||||
@ -391,48 +392,57 @@ func TestBuildOpenAPIV2(t *testing.T) {
|
||||
preserveUnknownFields *bool
|
||||
wantedSchema string
|
||||
opts Options
|
||||
selectableFields []apiextensionsv1.SelectableField
|
||||
}{
|
||||
{
|
||||
"nil",
|
||||
"",
|
||||
nil,
|
||||
`{"type":"object","x-kubernetes-group-version-kind":[{"group":"bar.k8s.io","kind":"Foo","version":"v1"}]}`,
|
||||
Options{V2: true},
|
||||
name: "nil",
|
||||
wantedSchema: `{"type":"object","x-kubernetes-group-version-kind":[{"group":"bar.k8s.io","kind":"Foo","version":"v1"}]}`,
|
||||
opts: Options{V2: true},
|
||||
},
|
||||
{
|
||||
"with properties",
|
||||
`{"type":"object","properties":{"spec":{"type":"object"},"status":{"type":"object"}}}`,
|
||||
nil,
|
||||
`{"type":"object","properties":{"apiVersion":{"type":"string"},"kind":{"type":"string"},"metadata":{"$ref":"#/definitions/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta"},"spec":{"type":"object"},"status":{"type":"object"}},"x-kubernetes-group-version-kind":[{"group":"bar.k8s.io","kind":"Foo","version":"v1"}]}`,
|
||||
Options{V2: true},
|
||||
name: "with properties",
|
||||
schema: `{"type":"object","properties":{"spec":{"type":"object"},"status":{"type":"object"}}}`,
|
||||
wantedSchema: `{"type":"object","properties":{"apiVersion":{"type":"string"},"kind":{"type":"string"},"metadata":{"$ref":"#/definitions/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta"},"spec":{"type":"object"},"status":{"type":"object"}},"x-kubernetes-group-version-kind":[{"group":"bar.k8s.io","kind":"Foo","version":"v1"}]}`,
|
||||
opts: Options{V2: true},
|
||||
},
|
||||
{
|
||||
"with invalid-typed properties",
|
||||
`{"type":"object","properties":{"spec":{"type":"bug"},"status":{"type":"object"}}}`,
|
||||
nil,
|
||||
`{"type":"object","x-kubernetes-group-version-kind":[{"group":"bar.k8s.io","kind":"Foo","version":"v1"}]}`,
|
||||
Options{V2: true},
|
||||
name: "with invalid-typed properties",
|
||||
schema: `{"type":"object","properties":{"spec":{"type":"bug"},"status":{"type":"object"}}}`,
|
||||
wantedSchema: `{"type":"object","x-kubernetes-group-version-kind":[{"group":"bar.k8s.io","kind":"Foo","version":"v1"}]}`,
|
||||
opts: Options{V2: true},
|
||||
},
|
||||
{
|
||||
"with non-structural schema",
|
||||
`{"type":"object","properties":{"foo":{"type":"array"}}}`,
|
||||
nil,
|
||||
`{"type":"object","x-kubernetes-group-version-kind":[{"group":"bar.k8s.io","kind":"Foo","version":"v1"}]}`,
|
||||
Options{V2: true},
|
||||
name: "with non-structural schema",
|
||||
schema: `{"type":"object","properties":{"foo":{"type":"array"}}}`,
|
||||
wantedSchema: `{"type":"object","x-kubernetes-group-version-kind":[{"group":"bar.k8s.io","kind":"Foo","version":"v1"}]}`,
|
||||
opts: Options{V2: true},
|
||||
},
|
||||
{
|
||||
"with spec.preseveUnknownFields=true",
|
||||
`{"type":"object","properties":{"foo":{"type":"string"}}}`,
|
||||
utilpointer.BoolPtr(true),
|
||||
`{"type":"object","x-kubernetes-group-version-kind":[{"group":"bar.k8s.io","kind":"Foo","version":"v1"}]}`,
|
||||
Options{V2: true},
|
||||
name: "with spec.preseveUnknownFields=true",
|
||||
schema: `{"type":"object","properties":{"foo":{"type":"string"}}}`,
|
||||
preserveUnknownFields: ptr.To(true),
|
||||
wantedSchema: `{"type":"object","x-kubernetes-group-version-kind":[{"group":"bar.k8s.io","kind":"Foo","version":"v1"}]}`,
|
||||
opts: Options{V2: true},
|
||||
},
|
||||
{
|
||||
"v2",
|
||||
`{"type":"object","properties":{"foo":{"type":"string","oneOf":[{"pattern":"a"},{"pattern":"b"}]}}}`,
|
||||
nil,
|
||||
`{"type":"object","properties":{"apiVersion":{"type":"string"},"kind":{"type":"string"},"metadata":{"$ref":"#/definitions/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta"},"foo":{"type":"string"}},"x-kubernetes-group-version-kind":[{"group":"bar.k8s.io","kind":"Foo","version":"v1"}]}`,
|
||||
Options{V2: true},
|
||||
name: "v2",
|
||||
schema: `{"type":"object","properties":{"foo":{"type":"string","oneOf":[{"pattern":"a"},{"pattern":"b"}]}}}`,
|
||||
wantedSchema: `{"type":"object","properties":{"apiVersion":{"type":"string"},"kind":{"type":"string"},"metadata":{"$ref":"#/definitions/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta"},"foo":{"type":"string"}},"x-kubernetes-group-version-kind":[{"group":"bar.k8s.io","kind":"Foo","version":"v1"}]}`,
|
||||
opts: Options{V2: true},
|
||||
},
|
||||
{
|
||||
name: "with selectable fields enabled",
|
||||
schema: `{"type":"object","properties":{"foo":{"type":"string"}}}`,
|
||||
wantedSchema: `{"type":"object","properties":{"apiVersion":{"type":"string"},"kind":{"type":"string"},"metadata":{"$ref":"#/definitions/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta"},"foo":{"type":"string"}},"x-kubernetes-group-version-kind":[{"group":"bar.k8s.io","kind":"Foo","version":"v1"}], "x-kubernetes-selectable-fields": [{"fieldPath":"foo"}]}`,
|
||||
opts: Options{V2: true, IncludeSelectableFields: true},
|
||||
selectableFields: []apiextensionsv1.SelectableField{{JSONPath: "foo"}},
|
||||
},
|
||||
{
|
||||
name: "with selectable fields disabled",
|
||||
schema: `{"type":"object","properties":{"foo":{"type":"string"}}}`,
|
||||
wantedSchema: `{"type":"object","properties":{"apiVersion":{"type":"string"},"kind":{"type":"string"},"metadata":{"$ref":"#/definitions/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta"},"foo":{"type":"string"}},"x-kubernetes-group-version-kind":[{"group":"bar.k8s.io","kind":"Foo","version":"v1"}]}`,
|
||||
opts: Options{V2: true},
|
||||
selectableFields: []apiextensionsv1.SelectableField{{JSONPath: "foo"}},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
@ -459,6 +469,7 @@ func TestBuildOpenAPIV2(t *testing.T) {
|
||||
{
|
||||
Name: "v1",
|
||||
Schema: validation,
|
||||
SelectableFields: tt.selectableFields,
|
||||
},
|
||||
},
|
||||
Names: apiextensionsv1.CustomResourceDefinitionNames{
|
||||
@ -509,34 +520,39 @@ func TestBuildOpenAPIV3(t *testing.T) {
|
||||
preserveUnknownFields *bool
|
||||
wantedSchema string
|
||||
opts Options
|
||||
selectableFields []apiextensionsv1.SelectableField
|
||||
}{
|
||||
{
|
||||
"nil",
|
||||
"",
|
||||
nil,
|
||||
`{"type":"object","x-kubernetes-group-version-kind":[{"group":"bar.k8s.io","kind":"Foo","version":"v1"}]}`,
|
||||
Options{},
|
||||
name: "nil",
|
||||
wantedSchema: `{"type":"object","x-kubernetes-group-version-kind":[{"group":"bar.k8s.io","kind":"Foo","version":"v1"}]}`,
|
||||
},
|
||||
{
|
||||
"with properties",
|
||||
`{"type":"object","properties":{"spec":{"type":"object"},"status":{"type":"object"}}}`,
|
||||
nil,
|
||||
`{"type":"object","properties":{"apiVersion":{"type":"string"},"kind":{"type":"string"},"metadata":{"allOf":[{"$ref":"#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta"}]},"spec":{"type":"object"},"status":{"type":"object"}},"x-kubernetes-group-version-kind":[{"group":"bar.k8s.io","kind":"Foo","version":"v1"}]}`,
|
||||
Options{},
|
||||
name: "with properties",
|
||||
schema: `{"type":"object","properties":{"spec":{"type":"object"},"status":{"type":"object"}}}`,
|
||||
wantedSchema: `{"type":"object","properties":{"apiVersion":{"type":"string"},"kind":{"type":"string"},"metadata":{"allOf":[{"$ref":"#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta"}]},"spec":{"type":"object"},"status":{"type":"object"}},"x-kubernetes-group-version-kind":[{"group":"bar.k8s.io","kind":"Foo","version":"v1"}]}`,
|
||||
},
|
||||
{
|
||||
"with v3 nullable field",
|
||||
`{"type":"object","properties":{"spec":{"type":"object", "nullable": true},"status":{"type":"object"}}}`,
|
||||
nil,
|
||||
`{"type":"object","properties":{"apiVersion":{"type":"string"},"kind":{"type":"string"},"metadata":{"allOf":[{"$ref":"#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta"}]},"spec":{"type":"object", "nullable": true},"status":{"type":"object"}},"x-kubernetes-group-version-kind":[{"group":"bar.k8s.io","kind":"Foo","version":"v1"}]}`,
|
||||
Options{},
|
||||
name: "with v3 nullable field",
|
||||
schema: `{"type":"object","properties":{"spec":{"type":"object", "nullable": true},"status":{"type":"object"}}}`,
|
||||
wantedSchema: `{"type":"object","properties":{"apiVersion":{"type":"string"},"kind":{"type":"string"},"metadata":{"allOf":[{"$ref":"#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta"}]},"spec":{"type":"object", "nullable": true},"status":{"type":"object"}},"x-kubernetes-group-version-kind":[{"group":"bar.k8s.io","kind":"Foo","version":"v1"}]}`,
|
||||
},
|
||||
{
|
||||
"with default not pruned for v3",
|
||||
`{"type":"object","properties":{"spec":{"type":"object","properties":{"field":{"type":"string","default":"foo"}}},"status":{"type":"object"}}}`,
|
||||
nil,
|
||||
`{"type":"object","properties":{"apiVersion":{"type":"string"},"kind":{"type":"string"},"metadata":{"allOf":[{"$ref":"#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta"}]},"spec":{"type":"object","properties":{"field":{"type":"string","default":"foo"}}},"status":{"type":"object"}},"x-kubernetes-group-version-kind":[{"group":"bar.k8s.io","kind":"Foo","version":"v1"}]}`,
|
||||
Options{},
|
||||
name: "with default not pruned for v3",
|
||||
schema: `{"type":"object","properties":{"spec":{"type":"object","properties":{"field":{"type":"string","default":"foo"}}},"status":{"type":"object"}}}`,
|
||||
wantedSchema: `{"type":"object","properties":{"apiVersion":{"type":"string"},"kind":{"type":"string"},"metadata":{"allOf":[{"$ref":"#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta"}]},"spec":{"type":"object","properties":{"field":{"type":"string","default":"foo"}}},"status":{"type":"object"}},"x-kubernetes-group-version-kind":[{"group":"bar.k8s.io","kind":"Foo","version":"v1"}]}`,
|
||||
},
|
||||
{
|
||||
name: "with selectable fields enabled",
|
||||
schema: `{"type":"object","properties":{"spec":{"type":"object","properties":{"field":{"type":"string","default":"foo"}}},"status":{"type":"object"}}}`,
|
||||
wantedSchema: `{"type":"object","properties":{"apiVersion":{"type":"string"},"kind":{"type":"string"},"metadata":{"allOf":[{"$ref":"#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta"}]},"spec":{"type":"object","properties":{"field":{"type":"string","default":"foo"}}},"status":{"type":"object"}},"x-kubernetes-group-version-kind":[{"group":"bar.k8s.io","kind":"Foo","version":"v1"}], "x-kubernetes-selectable-fields": [{"fieldPath":"spec.field"}]}`,
|
||||
opts: Options{IncludeSelectableFields: true},
|
||||
selectableFields: []apiextensionsv1.SelectableField{{JSONPath: "spec.field"}},
|
||||
},
|
||||
{
|
||||
name: "with selectable fields disabled",
|
||||
schema: `{"type":"object","properties":{"spec":{"type":"object","properties":{"field":{"type":"string","default":"foo"}}},"status":{"type":"object"}}}`,
|
||||
wantedSchema: `{"type":"object","properties":{"apiVersion":{"type":"string"},"kind":{"type":"string"},"metadata":{"allOf":[{"$ref":"#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta"}]},"spec":{"type":"object","properties":{"field":{"type":"string","default":"foo"}}},"status":{"type":"object"}},"x-kubernetes-group-version-kind":[{"group":"bar.k8s.io","kind":"Foo","version":"v1"}]}`,
|
||||
selectableFields: []apiextensionsv1.SelectableField{{JSONPath: "spec.field"}},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
@ -562,6 +578,7 @@ func TestBuildOpenAPIV3(t *testing.T) {
|
||||
{
|
||||
Name: "v1",
|
||||
Schema: validation,
|
||||
SelectableFields: tt.selectableFields,
|
||||
},
|
||||
},
|
||||
Names: apiextensionsv1.CustomResourceDefinitionNames{
|
||||
|
@ -22,10 +22,13 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
apiextensionsfeatures "k8s.io/apiextensions-apiserver/pkg/features"
|
||||
"k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/labels"
|
||||
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
|
||||
"k8s.io/apimachinery/pkg/util/wait"
|
||||
utilfeature "k8s.io/apiserver/pkg/util/feature"
|
||||
"k8s.io/client-go/tools/cache"
|
||||
"k8s.io/client-go/util/workqueue"
|
||||
"k8s.io/klog/v2"
|
||||
@ -88,7 +91,10 @@ func createSpecCache(crd *apiextensionsv1.CustomResourceDefinition) *specCache {
|
||||
if !v.Served {
|
||||
continue
|
||||
}
|
||||
s, err := builder.BuildOpenAPIV2(crd, v.Name, builder.Options{V2: true})
|
||||
s, err := builder.BuildOpenAPIV2(crd, v.Name, builder.Options{
|
||||
V2: true,
|
||||
IncludeSelectableFields: utilfeature.DefaultFeatureGate.Enabled(apiextensionsfeatures.CustomResourceFieldSelectors),
|
||||
})
|
||||
// Defaults must be pruned here for CRDs to cleanly merge with the static
|
||||
// spec that already has defaults pruned
|
||||
if err != nil {
|
||||
|
@ -22,11 +22,13 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
apiextensionsfeatures "k8s.io/apiextensions-apiserver/pkg/features"
|
||||
"k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/labels"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
|
||||
"k8s.io/apimachinery/pkg/util/wait"
|
||||
utilfeature "k8s.io/apiserver/pkg/util/feature"
|
||||
"k8s.io/client-go/tools/cache"
|
||||
"k8s.io/client-go/util/workqueue"
|
||||
"k8s.io/klog/v2"
|
||||
@ -230,7 +232,10 @@ func (c *Controller) updateCRDSpec(crd *apiextensionsv1.CustomResourceDefinition
|
||||
}
|
||||
|
||||
func (c *Controller) buildV3Spec(crd *apiextensionsv1.CustomResourceDefinition, name, versionName string) error {
|
||||
v3, err := builder.BuildOpenAPIV3(crd, versionName, builder.Options{V2: false})
|
||||
v3, err := builder.BuildOpenAPIV3(crd, versionName, builder.Options{
|
||||
V2: false,
|
||||
IncludeSelectableFields: utilfeature.DefaultFeatureGate.Enabled(apiextensionsfeatures.CustomResourceFieldSelectors),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
|
@ -34,6 +34,13 @@ const (
|
||||
// Ignores errors raised on unchanged fields of Custom Resources
|
||||
// across UPDATE/PATCH requests.
|
||||
CRDValidationRatcheting featuregate.Feature = "CRDValidationRatcheting"
|
||||
|
||||
// owner: @jpbetz
|
||||
// alpha: v1.30
|
||||
//
|
||||
// CustomResourceDefinitions may include SelectableFields to declare which fields
|
||||
// may be used as field selectors.
|
||||
CustomResourceFieldSelectors featuregate.Feature = "CustomResourceFieldSelectors"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@ -45,4 +52,5 @@ func init() {
|
||||
// available throughout Kubernetes binaries.
|
||||
var defaultKubernetesFeatureGates = map[featuregate.Feature]featuregate.FeatureSpec{
|
||||
CRDValidationRatcheting: {Default: true, PreRelease: featuregate.Beta},
|
||||
CustomResourceFieldSelectors: {Default: false, PreRelease: featuregate.Alpha},
|
||||
}
|
||||
|
@ -106,6 +106,7 @@ func newStorage(t *testing.T) (customresource.CustomResourceStorage, *etcd3testi
|
||||
nil,
|
||||
status,
|
||||
scale,
|
||||
nil,
|
||||
),
|
||||
restOptions,
|
||||
[]string{"all"},
|
||||
|
@ -19,8 +19,12 @@ package customresource
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"sigs.k8s.io/structured-merge-diff/v4/fieldpath"
|
||||
|
||||
"k8s.io/apiextensions-apiserver/pkg/apis/apiextensions"
|
||||
v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
|
||||
structuralschema "k8s.io/apiextensions-apiserver/pkg/apiserver/schema"
|
||||
"k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel"
|
||||
"k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/model"
|
||||
@ -41,11 +45,11 @@ import (
|
||||
celconfig "k8s.io/apiserver/pkg/apis/cel"
|
||||
"k8s.io/apiserver/pkg/cel/common"
|
||||
"k8s.io/apiserver/pkg/features"
|
||||
"k8s.io/apiserver/pkg/registry/generic"
|
||||
apiserverstorage "k8s.io/apiserver/pkg/storage"
|
||||
"k8s.io/apiserver/pkg/storage/names"
|
||||
utilfeature "k8s.io/apiserver/pkg/util/feature"
|
||||
|
||||
"sigs.k8s.io/structured-merge-diff/v4/fieldpath"
|
||||
"k8s.io/client-go/util/jsonpath"
|
||||
)
|
||||
|
||||
// customResourceStrategy implements behavior for CustomResources for a single
|
||||
@ -61,15 +65,22 @@ type customResourceStrategy struct {
|
||||
status *apiextensions.CustomResourceSubresourceStatus
|
||||
scale *apiextensions.CustomResourceSubresourceScale
|
||||
kind schema.GroupVersionKind
|
||||
selectableFieldSet []selectableField
|
||||
}
|
||||
|
||||
func NewStrategy(typer runtime.ObjectTyper, namespaceScoped bool, kind schema.GroupVersionKind, schemaValidator, statusSchemaValidator validation.SchemaValidator, structuralSchema *structuralschema.Structural, status *apiextensions.CustomResourceSubresourceStatus, scale *apiextensions.CustomResourceSubresourceScale) customResourceStrategy {
|
||||
type selectableField struct {
|
||||
name string
|
||||
fieldPath *jsonpath.JSONPath
|
||||
err error
|
||||
}
|
||||
|
||||
func NewStrategy(typer runtime.ObjectTyper, namespaceScoped bool, kind schema.GroupVersionKind, schemaValidator, statusSchemaValidator validation.SchemaValidator, structuralSchema *structuralschema.Structural, status *apiextensions.CustomResourceSubresourceStatus, scale *apiextensions.CustomResourceSubresourceScale, selectableFields []v1.SelectableField) customResourceStrategy {
|
||||
var celValidator *cel.Validator
|
||||
if utilfeature.DefaultFeatureGate.Enabled(features.CustomResourceValidationExpressions) {
|
||||
celValidator = cel.NewValidator(structuralSchema, true, celconfig.PerCallLimit) // CEL programs are compiled and cached here
|
||||
}
|
||||
|
||||
return customResourceStrategy{
|
||||
strategy := customResourceStrategy{
|
||||
ObjectTyper: typer,
|
||||
NameGenerator: names.SimpleNameGenerator,
|
||||
namespaceScoped: namespaceScoped,
|
||||
@ -85,6 +96,34 @@ func NewStrategy(typer runtime.ObjectTyper, namespaceScoped bool, kind schema.Gr
|
||||
celValidator: celValidator,
|
||||
kind: kind,
|
||||
}
|
||||
if utilfeature.DefaultFeatureGate.Enabled(apiextensionsfeatures.CustomResourceFieldSelectors) {
|
||||
strategy.selectableFieldSet = prepareSelectableFields(selectableFields)
|
||||
}
|
||||
return strategy
|
||||
}
|
||||
|
||||
func prepareSelectableFields(selectableFields []v1.SelectableField) []selectableField {
|
||||
result := make([]selectableField, len(selectableFields))
|
||||
for i, sf := range selectableFields {
|
||||
name := strings.TrimPrefix(sf.JSONPath, ".")
|
||||
|
||||
parser := jsonpath.New("selectableField")
|
||||
parser.AllowMissingKeys(true)
|
||||
err := parser.Parse("{" + sf.JSONPath + "}")
|
||||
if err == nil {
|
||||
result[i] = selectableField{
|
||||
name: name,
|
||||
fieldPath: parser,
|
||||
}
|
||||
} else {
|
||||
result[i] = selectableField{
|
||||
name: name,
|
||||
err: err,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (a customResourceStrategy) NamespaceScoped() bool {
|
||||
@ -293,7 +332,52 @@ func (a customResourceStrategy) GetAttrs(obj runtime.Object) (labels.Set, fields
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return labels.Set(accessor.GetLabels()), objectMetaFieldsSet(accessor, a.namespaceScoped), nil
|
||||
sFields, err := a.selectableFields(obj, accessor)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return accessor.GetLabels(), sFields, nil
|
||||
}
|
||||
|
||||
// selectableFields returns a field set that can be used for filter selection.
|
||||
// This includes metadata.name, metadata.namespace and all custom selectable fields.
|
||||
func (a customResourceStrategy) selectableFields(obj runtime.Object, objectMeta metav1.Object) (fields.Set, error) {
|
||||
objectMetaFields := objectMetaFieldsSet(objectMeta, a.namespaceScoped)
|
||||
var selectableFieldsSet fields.Set
|
||||
|
||||
if utilfeature.DefaultFeatureGate.Enabled(apiextensionsfeatures.CustomResourceFieldSelectors) && len(a.selectableFieldSet) > 0 {
|
||||
us, ok := obj.(runtime.Unstructured)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected error casting a custom resource to unstructured")
|
||||
}
|
||||
uc := us.UnstructuredContent()
|
||||
|
||||
selectableFieldsSet = fields.Set{}
|
||||
for _, sf := range a.selectableFieldSet {
|
||||
if sf.err != nil {
|
||||
return nil, fmt.Errorf("unexpected error parsing jsonPath: %w", sf.err)
|
||||
}
|
||||
results, err := sf.fieldPath.FindResults(uc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unexpected error finding value with jsonPath: %w", err)
|
||||
}
|
||||
var value any
|
||||
|
||||
if len(results) > 0 && len(results[0]) == 1 {
|
||||
if len(results) > 1 || len(results[0]) > 1 {
|
||||
return nil, fmt.Errorf("unexpectedly received more than one JSON path result")
|
||||
}
|
||||
value = results[0][0].Interface()
|
||||
}
|
||||
|
||||
if value != nil {
|
||||
selectableFieldsSet[sf.name] = fmt.Sprint(value)
|
||||
} else {
|
||||
selectableFieldsSet[sf.name] = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
return generic.MergeFieldsSets(objectMetaFields, selectableFieldsSet), nil
|
||||
}
|
||||
|
||||
// objectMetaFieldsSet returns a fields that represent the ObjectMeta.
|
||||
|
@ -22,7 +22,12 @@ import (
|
||||
"testing"
|
||||
|
||||
"k8s.io/apiextensions-apiserver/pkg/apis/apiextensions"
|
||||
v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
|
||||
apiextensionsfeatures "k8s.io/apiextensions-apiserver/pkg/features"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/fields"
|
||||
utilfeature "k8s.io/apiserver/pkg/util/feature"
|
||||
featuregatetesting "k8s.io/component-base/featuregate/testing"
|
||||
)
|
||||
|
||||
func generation1() map[string]interface{} {
|
||||
@ -255,3 +260,64 @@ func TestStrategyPrepareForUpdate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectableFields(t *testing.T) {
|
||||
tcs := []struct {
|
||||
name string
|
||||
selectableFields []v1.SelectableField
|
||||
obj *unstructured.Unstructured
|
||||
expectFields fields.Set
|
||||
}{
|
||||
{
|
||||
name: "valid path",
|
||||
selectableFields: []v1.SelectableField{
|
||||
{JSONPath: ".spec.foo"},
|
||||
},
|
||||
obj: &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "example",
|
||||
"generation": int64(1),
|
||||
"other": "new",
|
||||
},
|
||||
"spec": map[string]interface{}{
|
||||
"foo": "x",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectFields: map[string]string{"spec.foo": "x", "metadata.name": "example"},
|
||||
},
|
||||
{
|
||||
name: "missing value",
|
||||
selectableFields: []v1.SelectableField{
|
||||
{JSONPath: ".spec.foo"},
|
||||
},
|
||||
obj: &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "example",
|
||||
"generation": int64(1),
|
||||
"other": "new",
|
||||
},
|
||||
"spec": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
expectFields: map[string]string{"spec.foo": "", "metadata.name": "example"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tcs {
|
||||
defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, apiextensionsfeatures.CustomResourceFieldSelectors, true)()
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
strategy := customResourceStrategy{selectableFieldSet: prepareSelectableFields(tc.selectableFields)}
|
||||
|
||||
_, fields, err := strategy.GetAttrs(tc.obj)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(tc.expectFields, fields) {
|
||||
t.Errorf("Expected fields '%+#v' but got '%+#v'", tc.expectFields, fields)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -242,6 +242,9 @@ func dropDisabledFields(newCRD *apiextensions.CustomResourceDefinition, oldCRD *
|
||||
}
|
||||
}
|
||||
}
|
||||
if !utilfeature.DefaultFeatureGate.Enabled(apiextensionsfeatures.CustomResourceFieldSelectors) && (oldCRD == nil || (oldCRD != nil && !specHasSelectableFields(&oldCRD.Spec))) {
|
||||
dropSelectableFields(&newCRD.Spec)
|
||||
}
|
||||
}
|
||||
|
||||
// dropOptionalOldSelfField drops field optionalOldSelf from CRD schema
|
||||
@ -284,3 +287,23 @@ func schemaHasOptionalOldSelf(s *apiextensions.JSONSchemaProps) bool {
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
func dropSelectableFields(spec *apiextensions.CustomResourceDefinitionSpec) {
|
||||
spec.SelectableFields = nil
|
||||
for i := range spec.Versions {
|
||||
spec.Versions[i].SelectableFields = nil
|
||||
}
|
||||
}
|
||||
|
||||
func specHasSelectableFields(spec *apiextensions.CustomResourceDefinitionSpec) bool {
|
||||
if spec.SelectableFields != nil {
|
||||
return true
|
||||
}
|
||||
for _, v := range spec.Versions {
|
||||
if v.SelectableFields != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
@ -21,6 +21,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
|
||||
"k8s.io/apiextensions-apiserver/pkg/apis/apiextensions"
|
||||
"k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1"
|
||||
"k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/validation"
|
||||
@ -200,6 +201,7 @@ func TestDropDisabledFields(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
enableRatcheting bool
|
||||
enableSelectableFields bool
|
||||
crd *apiextensions.CustomResourceDefinition
|
||||
oldCRD *apiextensions.CustomResourceDefinition
|
||||
expectedCRD *apiextensions.CustomResourceDefinition
|
||||
@ -693,10 +695,612 @@ func TestDropDisabledFields(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
// SelectableFields
|
||||
{
|
||||
name: "SelectableFields, For create, FG disabled, SelectableFields in update, dropped",
|
||||
enableSelectableFields: false,
|
||||
crd: &apiextensions.CustomResourceDefinition{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "foos.sigs.k8s.io", Annotations: map[string]string{v1beta1.KubeAPIApprovedAnnotation: "valid"}, ResourceVersion: "1"},
|
||||
Spec: apiextensions.CustomResourceDefinitionSpec{
|
||||
Validation: &apiextensions.CustomResourceValidation{
|
||||
OpenAPIV3Schema: &apiextensions.JSONSchemaProps{
|
||||
Type: "object",
|
||||
Properties: map[string]apiextensions.JSONSchemaProps{
|
||||
"field": {
|
||||
Type: "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
SelectableFields: []apiextensions.SelectableField{
|
||||
{
|
||||
JSONPath: ".field",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedCRD: &apiextensions.CustomResourceDefinition{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "foos.sigs.k8s.io", Annotations: map[string]string{v1beta1.KubeAPIApprovedAnnotation: "valid"}, ResourceVersion: "1"},
|
||||
Spec: apiextensions.CustomResourceDefinitionSpec{
|
||||
Validation: &apiextensions.CustomResourceValidation{
|
||||
OpenAPIV3Schema: &apiextensions.JSONSchemaProps{
|
||||
Type: "object",
|
||||
Properties: map[string]apiextensions.JSONSchemaProps{
|
||||
"field": {
|
||||
Type: "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "SelectableFields, For create, FG enabled, no SelectableFields in update, no drop",
|
||||
enableSelectableFields: true,
|
||||
crd: &apiextensions.CustomResourceDefinition{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "foos.sigs.k8s.io", Annotations: map[string]string{v1beta1.KubeAPIApprovedAnnotation: "valid"}, ResourceVersion: "1"},
|
||||
Spec: apiextensions.CustomResourceDefinitionSpec{
|
||||
Validation: &apiextensions.CustomResourceValidation{
|
||||
OpenAPIV3Schema: &apiextensions.JSONSchemaProps{
|
||||
Type: "object",
|
||||
Properties: map[string]apiextensions.JSONSchemaProps{
|
||||
"field": {
|
||||
Type: "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedCRD: &apiextensions.CustomResourceDefinition{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "foos.sigs.k8s.io", Annotations: map[string]string{v1beta1.KubeAPIApprovedAnnotation: "valid"}, ResourceVersion: "1"},
|
||||
Spec: apiextensions.CustomResourceDefinitionSpec{
|
||||
Validation: &apiextensions.CustomResourceValidation{
|
||||
OpenAPIV3Schema: &apiextensions.JSONSchemaProps{
|
||||
Type: "object",
|
||||
Properties: map[string]apiextensions.JSONSchemaProps{
|
||||
"field": {
|
||||
Type: "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "SelectableFields, For create, FG enabled, SelectableFields in update, no drop",
|
||||
enableSelectableFields: true,
|
||||
crd: &apiextensions.CustomResourceDefinition{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "foos.sigs.k8s.io", Annotations: map[string]string{v1beta1.KubeAPIApprovedAnnotation: "valid"}, ResourceVersion: "1"},
|
||||
Spec: apiextensions.CustomResourceDefinitionSpec{
|
||||
Validation: &apiextensions.CustomResourceValidation{
|
||||
OpenAPIV3Schema: &apiextensions.JSONSchemaProps{
|
||||
Type: "object",
|
||||
Properties: map[string]apiextensions.JSONSchemaProps{
|
||||
"field": {
|
||||
Type: "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
SelectableFields: []apiextensions.SelectableField{
|
||||
{
|
||||
JSONPath: ".field",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedCRD: &apiextensions.CustomResourceDefinition{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "foos.sigs.k8s.io", Annotations: map[string]string{v1beta1.KubeAPIApprovedAnnotation: "valid"}, ResourceVersion: "1"},
|
||||
Spec: apiextensions.CustomResourceDefinitionSpec{
|
||||
Validation: &apiextensions.CustomResourceValidation{
|
||||
OpenAPIV3Schema: &apiextensions.JSONSchemaProps{
|
||||
Type: "object",
|
||||
Properties: map[string]apiextensions.JSONSchemaProps{
|
||||
"field": {
|
||||
Type: "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
SelectableFields: []apiextensions.SelectableField{
|
||||
{
|
||||
JSONPath: ".field",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "SelectableFields, For update, FG disabled, oldCRD has SelectableFields, SelectableFields in update, no drop",
|
||||
enableSelectableFields: false,
|
||||
crd: &apiextensions.CustomResourceDefinition{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "foos.sigs.k8s.io", Annotations: map[string]string{v1beta1.KubeAPIApprovedAnnotation: "valid"}, ResourceVersion: "1"},
|
||||
Spec: apiextensions.CustomResourceDefinitionSpec{
|
||||
Validation: &apiextensions.CustomResourceValidation{
|
||||
OpenAPIV3Schema: &apiextensions.JSONSchemaProps{
|
||||
Type: "object",
|
||||
Properties: map[string]apiextensions.JSONSchemaProps{
|
||||
"field1": {
|
||||
Type: "string",
|
||||
},
|
||||
"field2": {
|
||||
Type: "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
SelectableFields: []apiextensions.SelectableField{
|
||||
{
|
||||
JSONPath: ".field1",
|
||||
},
|
||||
{
|
||||
JSONPath: ".field2",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
oldCRD: &apiextensions.CustomResourceDefinition{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "foos.sigs.k8s.io", Annotations: map[string]string{v1beta1.KubeAPIApprovedAnnotation: "valid"}, ResourceVersion: "1"},
|
||||
Spec: apiextensions.CustomResourceDefinitionSpec{
|
||||
Validation: &apiextensions.CustomResourceValidation{
|
||||
OpenAPIV3Schema: &apiextensions.JSONSchemaProps{
|
||||
Type: "object",
|
||||
Properties: map[string]apiextensions.JSONSchemaProps{
|
||||
"field1": {
|
||||
Type: "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
SelectableFields: []apiextensions.SelectableField{
|
||||
{
|
||||
JSONPath: ".field1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedCRD: &apiextensions.CustomResourceDefinition{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "foos.sigs.k8s.io", Annotations: map[string]string{v1beta1.KubeAPIApprovedAnnotation: "valid"}, ResourceVersion: "1"},
|
||||
Spec: apiextensions.CustomResourceDefinitionSpec{
|
||||
Validation: &apiextensions.CustomResourceValidation{
|
||||
OpenAPIV3Schema: &apiextensions.JSONSchemaProps{
|
||||
Type: "object",
|
||||
Properties: map[string]apiextensions.JSONSchemaProps{
|
||||
"field1": {
|
||||
Type: "string",
|
||||
},
|
||||
"field2": {
|
||||
Type: "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
SelectableFields: []apiextensions.SelectableField{
|
||||
{
|
||||
JSONPath: ".field1",
|
||||
},
|
||||
{
|
||||
JSONPath: ".field2",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "SelectableFields, For update, FG disabled, oldCRD does not have SelectableFields, no SelectableFields in update, no drop",
|
||||
enableSelectableFields: false,
|
||||
crd: &apiextensions.CustomResourceDefinition{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "foos.sigs.k8s.io", Annotations: map[string]string{v1beta1.KubeAPIApprovedAnnotation: "valid"}, ResourceVersion: "1"},
|
||||
Spec: apiextensions.CustomResourceDefinitionSpec{
|
||||
Validation: &apiextensions.CustomResourceValidation{
|
||||
OpenAPIV3Schema: &apiextensions.JSONSchemaProps{
|
||||
Type: "object",
|
||||
Properties: map[string]apiextensions.JSONSchemaProps{
|
||||
"field1": {
|
||||
Type: "string",
|
||||
},
|
||||
"field2": {
|
||||
Type: "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
oldCRD: &apiextensions.CustomResourceDefinition{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "foos.sigs.k8s.io", Annotations: map[string]string{v1beta1.KubeAPIApprovedAnnotation: "valid"}, ResourceVersion: "1"},
|
||||
Spec: apiextensions.CustomResourceDefinitionSpec{
|
||||
Validation: &apiextensions.CustomResourceValidation{
|
||||
OpenAPIV3Schema: &apiextensions.JSONSchemaProps{
|
||||
Type: "object",
|
||||
Properties: map[string]apiextensions.JSONSchemaProps{
|
||||
"field1": {
|
||||
Type: "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedCRD: &apiextensions.CustomResourceDefinition{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "foos.sigs.k8s.io", Annotations: map[string]string{v1beta1.KubeAPIApprovedAnnotation: "valid"}, ResourceVersion: "1"},
|
||||
Spec: apiextensions.CustomResourceDefinitionSpec{
|
||||
Validation: &apiextensions.CustomResourceValidation{
|
||||
OpenAPIV3Schema: &apiextensions.JSONSchemaProps{
|
||||
Type: "object",
|
||||
Properties: map[string]apiextensions.JSONSchemaProps{
|
||||
"field1": {
|
||||
Type: "string",
|
||||
},
|
||||
"field2": {
|
||||
Type: "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "SelectableFields, For update, FG disabled, oldCRD does not have SelectableFields, SelectableFields in update, dropped",
|
||||
enableSelectableFields: false,
|
||||
crd: &apiextensions.CustomResourceDefinition{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "foos.sigs.k8s.io", Annotations: map[string]string{v1beta1.KubeAPIApprovedAnnotation: "valid"}, ResourceVersion: "1"},
|
||||
Spec: apiextensions.CustomResourceDefinitionSpec{
|
||||
Validation: &apiextensions.CustomResourceValidation{
|
||||
OpenAPIV3Schema: &apiextensions.JSONSchemaProps{
|
||||
Type: "object",
|
||||
Properties: map[string]apiextensions.JSONSchemaProps{
|
||||
"field1": {
|
||||
Type: "string",
|
||||
},
|
||||
"field2": {
|
||||
Type: "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
SelectableFields: []apiextensions.SelectableField{
|
||||
{
|
||||
JSONPath: ".field1",
|
||||
},
|
||||
{
|
||||
JSONPath: ".field2",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
oldCRD: &apiextensions.CustomResourceDefinition{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "foos.sigs.k8s.io", Annotations: map[string]string{v1beta1.KubeAPIApprovedAnnotation: "valid"}, ResourceVersion: "1"},
|
||||
Spec: apiextensions.CustomResourceDefinitionSpec{
|
||||
Validation: &apiextensions.CustomResourceValidation{
|
||||
OpenAPIV3Schema: &apiextensions.JSONSchemaProps{
|
||||
Type: "object",
|
||||
Properties: map[string]apiextensions.JSONSchemaProps{
|
||||
"field1": {
|
||||
Type: "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedCRD: &apiextensions.CustomResourceDefinition{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "foos.sigs.k8s.io", Annotations: map[string]string{v1beta1.KubeAPIApprovedAnnotation: "valid"}, ResourceVersion: "1"},
|
||||
Spec: apiextensions.CustomResourceDefinitionSpec{
|
||||
Validation: &apiextensions.CustomResourceValidation{
|
||||
OpenAPIV3Schema: &apiextensions.JSONSchemaProps{
|
||||
Type: "object",
|
||||
Properties: map[string]apiextensions.JSONSchemaProps{
|
||||
"field1": {
|
||||
Type: "string",
|
||||
},
|
||||
"field2": {
|
||||
Type: "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "SelectableFields, For update, FG enabled, oldCRD has SelectableFields, SelectableFields in update, no drop",
|
||||
enableSelectableFields: true,
|
||||
crd: &apiextensions.CustomResourceDefinition{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "foos.sigs.k8s.io", Annotations: map[string]string{v1beta1.KubeAPIApprovedAnnotation: "valid"}, ResourceVersion: "1"},
|
||||
Spec: apiextensions.CustomResourceDefinitionSpec{
|
||||
Validation: &apiextensions.CustomResourceValidation{
|
||||
OpenAPIV3Schema: &apiextensions.JSONSchemaProps{
|
||||
Type: "object",
|
||||
Properties: map[string]apiextensions.JSONSchemaProps{
|
||||
"field1": {
|
||||
Type: "string",
|
||||
},
|
||||
"field2": {
|
||||
Type: "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
SelectableFields: []apiextensions.SelectableField{
|
||||
{
|
||||
JSONPath: ".field1",
|
||||
},
|
||||
{
|
||||
JSONPath: ".field2",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
oldCRD: &apiextensions.CustomResourceDefinition{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "foos.sigs.k8s.io", Annotations: map[string]string{v1beta1.KubeAPIApprovedAnnotation: "valid"}, ResourceVersion: "1"},
|
||||
Spec: apiextensions.CustomResourceDefinitionSpec{
|
||||
Validation: &apiextensions.CustomResourceValidation{
|
||||
OpenAPIV3Schema: &apiextensions.JSONSchemaProps{
|
||||
Type: "object",
|
||||
Properties: map[string]apiextensions.JSONSchemaProps{
|
||||
"field1": {
|
||||
Type: "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
SelectableFields: []apiextensions.SelectableField{
|
||||
{
|
||||
JSONPath: ".field1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedCRD: &apiextensions.CustomResourceDefinition{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "foos.sigs.k8s.io", Annotations: map[string]string{v1beta1.KubeAPIApprovedAnnotation: "valid"}, ResourceVersion: "1"},
|
||||
Spec: apiextensions.CustomResourceDefinitionSpec{
|
||||
Validation: &apiextensions.CustomResourceValidation{
|
||||
OpenAPIV3Schema: &apiextensions.JSONSchemaProps{
|
||||
Type: "object",
|
||||
Properties: map[string]apiextensions.JSONSchemaProps{
|
||||
"field1": {
|
||||
Type: "string",
|
||||
},
|
||||
"field2": {
|
||||
Type: "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
SelectableFields: []apiextensions.SelectableField{
|
||||
{
|
||||
JSONPath: ".field1",
|
||||
},
|
||||
{
|
||||
JSONPath: ".field2",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "SelectableFields, For update, FG enabled, oldCRD does not have SelectableFields, SelectableFields in update, no drop",
|
||||
enableSelectableFields: true,
|
||||
crd: &apiextensions.CustomResourceDefinition{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "foos.sigs.k8s.io", Annotations: map[string]string{v1beta1.KubeAPIApprovedAnnotation: "valid"}, ResourceVersion: "1"},
|
||||
Spec: apiextensions.CustomResourceDefinitionSpec{
|
||||
Validation: &apiextensions.CustomResourceValidation{
|
||||
OpenAPIV3Schema: &apiextensions.JSONSchemaProps{
|
||||
Type: "object",
|
||||
Properties: map[string]apiextensions.JSONSchemaProps{
|
||||
"field1": {
|
||||
Type: "string",
|
||||
},
|
||||
"field2": {
|
||||
Type: "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
SelectableFields: []apiextensions.SelectableField{
|
||||
{
|
||||
JSONPath: ".field1",
|
||||
},
|
||||
{
|
||||
JSONPath: ".field2",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
oldCRD: &apiextensions.CustomResourceDefinition{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "foos.sigs.k8s.io", Annotations: map[string]string{v1beta1.KubeAPIApprovedAnnotation: "valid"}, ResourceVersion: "1"},
|
||||
Spec: apiextensions.CustomResourceDefinitionSpec{
|
||||
Validation: &apiextensions.CustomResourceValidation{
|
||||
OpenAPIV3Schema: &apiextensions.JSONSchemaProps{
|
||||
Type: "object",
|
||||
Properties: map[string]apiextensions.JSONSchemaProps{
|
||||
"field1": {
|
||||
Type: "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
SelectableFields: []apiextensions.SelectableField{
|
||||
{
|
||||
JSONPath: ".field1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedCRD: &apiextensions.CustomResourceDefinition{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "foos.sigs.k8s.io", Annotations: map[string]string{v1beta1.KubeAPIApprovedAnnotation: "valid"}, ResourceVersion: "1"},
|
||||
Spec: apiextensions.CustomResourceDefinitionSpec{
|
||||
Validation: &apiextensions.CustomResourceValidation{
|
||||
OpenAPIV3Schema: &apiextensions.JSONSchemaProps{
|
||||
Type: "object",
|
||||
Properties: map[string]apiextensions.JSONSchemaProps{
|
||||
"field1": {
|
||||
Type: "string",
|
||||
},
|
||||
"field2": {
|
||||
Type: "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
SelectableFields: []apiextensions.SelectableField{
|
||||
{
|
||||
JSONPath: ".field1",
|
||||
},
|
||||
{
|
||||
JSONPath: ".field2",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "pre-version SelectableFields, For update, FG disabled, oldCRD does not have SelectableFields, SelectableFields in update, dropped",
|
||||
enableSelectableFields: false,
|
||||
crd: &apiextensions.CustomResourceDefinition{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "foos.sigs.k8s.io", Annotations: map[string]string{v1beta1.KubeAPIApprovedAnnotation: "valid"}, ResourceVersion: "1"},
|
||||
Spec: apiextensions.CustomResourceDefinitionSpec{
|
||||
Versions: []apiextensions.CustomResourceDefinitionVersion{
|
||||
{
|
||||
Name: "v1",
|
||||
Schema: &apiextensions.CustomResourceValidation{
|
||||
OpenAPIV3Schema: &apiextensions.JSONSchemaProps{
|
||||
Type: "object",
|
||||
Properties: map[string]apiextensions.JSONSchemaProps{
|
||||
"field1": {
|
||||
Type: "string",
|
||||
},
|
||||
"field2": {
|
||||
Type: "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
SelectableFields: []apiextensions.SelectableField{
|
||||
{
|
||||
JSONPath: ".field1",
|
||||
},
|
||||
{
|
||||
JSONPath: ".field2",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "v2",
|
||||
Schema: &apiextensions.CustomResourceValidation{
|
||||
OpenAPIV3Schema: &apiextensions.JSONSchemaProps{
|
||||
Type: "object",
|
||||
Properties: map[string]apiextensions.JSONSchemaProps{
|
||||
"field3": {
|
||||
Type: "string",
|
||||
},
|
||||
"field4": {
|
||||
Type: "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
SelectableFields: []apiextensions.SelectableField{
|
||||
{
|
||||
JSONPath: ".field3",
|
||||
},
|
||||
{
|
||||
JSONPath: ".field4",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
oldCRD: &apiextensions.CustomResourceDefinition{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "foos.sigs.k8s.io", Annotations: map[string]string{v1beta1.KubeAPIApprovedAnnotation: "valid"}, ResourceVersion: "1"},
|
||||
Spec: apiextensions.CustomResourceDefinitionSpec{
|
||||
Versions: []apiextensions.CustomResourceDefinitionVersion{
|
||||
{
|
||||
Name: "v1",
|
||||
Schema: &apiextensions.CustomResourceValidation{
|
||||
OpenAPIV3Schema: &apiextensions.JSONSchemaProps{
|
||||
Type: "object",
|
||||
Properties: map[string]apiextensions.JSONSchemaProps{
|
||||
"field1": {
|
||||
Type: "string",
|
||||
},
|
||||
"field2": {
|
||||
Type: "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "v2",
|
||||
Schema: &apiextensions.CustomResourceValidation{
|
||||
OpenAPIV3Schema: &apiextensions.JSONSchemaProps{
|
||||
Type: "object",
|
||||
Properties: map[string]apiextensions.JSONSchemaProps{
|
||||
"field3": {
|
||||
Type: "string",
|
||||
},
|
||||
"field4": {
|
||||
Type: "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedCRD: &apiextensions.CustomResourceDefinition{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "foos.sigs.k8s.io", Annotations: map[string]string{v1beta1.KubeAPIApprovedAnnotation: "valid"}, ResourceVersion: "1"},
|
||||
Spec: apiextensions.CustomResourceDefinitionSpec{
|
||||
Versions: []apiextensions.CustomResourceDefinitionVersion{
|
||||
{
|
||||
Name: "v1",
|
||||
Schema: &apiextensions.CustomResourceValidation{
|
||||
OpenAPIV3Schema: &apiextensions.JSONSchemaProps{
|
||||
Type: "object",
|
||||
Properties: map[string]apiextensions.JSONSchemaProps{
|
||||
"field1": {
|
||||
Type: "string",
|
||||
},
|
||||
"field2": {
|
||||
Type: "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "v2",
|
||||
Schema: &apiextensions.CustomResourceValidation{
|
||||
OpenAPIV3Schema: &apiextensions.JSONSchemaProps{
|
||||
Type: "object",
|
||||
Properties: map[string]apiextensions.JSONSchemaProps{
|
||||
"field3": {
|
||||
Type: "string",
|
||||
},
|
||||
"field4": {
|
||||
Type: "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, apiextensionsfeatures.CRDValidationRatcheting, tc.enableRatcheting)()
|
||||
defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, apiextensionsfeatures.CustomResourceFieldSelectors, tc.enableSelectableFields)()
|
||||
old := tc.oldCRD.DeepCopy()
|
||||
|
||||
dropDisabledFields(tc.crd, tc.oldCRD)
|
||||
|
@ -26,6 +26,8 @@ import (
|
||||
"unicode"
|
||||
|
||||
restful "github.com/emicklei/go-restful/v3"
|
||||
"sigs.k8s.io/structured-merge-diff/v4/fieldpath"
|
||||
|
||||
apidiscoveryv2beta1 "k8s.io/api/apidiscovery/v2beta1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/conversion"
|
||||
@ -46,12 +48,12 @@ import (
|
||||
"k8s.io/apiserver/pkg/storageversion"
|
||||
utilfeature "k8s.io/apiserver/pkg/util/feature"
|
||||
versioninfo "k8s.io/component-base/version"
|
||||
"sigs.k8s.io/structured-merge-diff/v4/fieldpath"
|
||||
)
|
||||
|
||||
const (
|
||||
ROUTE_META_GVK = "x-kubernetes-group-version-kind"
|
||||
ROUTE_META_ACTION = "x-kubernetes-action"
|
||||
RouteMetaGVK = "x-kubernetes-group-version-kind"
|
||||
RouteMetaSelectableFields = "x-kubernetes-selectable-fields"
|
||||
RouteMetaAction = "x-kubernetes-action"
|
||||
)
|
||||
|
||||
type APIInstaller struct {
|
||||
@ -1059,12 +1061,12 @@ func (a *APIInstaller) registerResourceHandlers(path string, storage rest.Storag
|
||||
return nil, nil, fmt.Errorf("unrecognized action verb: %s", action.Verb)
|
||||
}
|
||||
for _, route := range routes {
|
||||
route.Metadata(ROUTE_META_GVK, metav1.GroupVersionKind{
|
||||
route.Metadata(RouteMetaGVK, metav1.GroupVersionKind{
|
||||
Group: reqScope.Kind.Group,
|
||||
Version: reqScope.Kind.Version,
|
||||
Kind: reqScope.Kind.Kind,
|
||||
})
|
||||
route.Metadata(ROUTE_META_ACTION, strings.ToLower(action.Verb))
|
||||
route.Metadata(RouteMetaAction, strings.ToLower(action.Verb))
|
||||
ws.Route(route)
|
||||
}
|
||||
// Note: update GetAuthorizerAttributes() when adding a custom handler.
|
||||
|
Loading…
Reference in New Issue
Block a user