Add selectableFields to CRDs

This commit is contained in:
Joe Betz 2024-02-28 14:06:06 -05:00
parent 54f9807e1e
commit 291703482d
27 changed files with 1623 additions and 137 deletions

View File

@ -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},

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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),
},
},
},

View File

@ -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.

View File

@ -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.

View File

@ -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

View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -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,

View File

@ -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()
}

View File

@ -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 {

View File

@ -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)

View File

@ -275,6 +275,7 @@ func TestMetrics(t *testing.T) {
sts,
nil,
nil,
nil,
)
iters := 1

View File

@ -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 {

View File

@ -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
}

View File

@ -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{

View File

@ -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 {

View File

@ -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

View File

@ -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},
}

View File

@ -106,6 +106,7 @@ func newStorage(t *testing.T) (customresource.CustomResourceStorage, *etcd3testi
nil,
status,
scale,
nil,
),
restOptions,
[]string{"all"},

View File

@ -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.

View File

@ -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)
}
})
}
}

View File

@ -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
}

View File

@ -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)

View File

@ -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.