diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/deepcopy.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/deepcopy.go index 51fb72df3cf..3c7ac0060f1 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/deepcopy.go +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/deepcopy.go @@ -268,5 +268,21 @@ func (in *JSONSchemaProps) DeepCopy() *JSONSchemaProps { } } + if in.XListMapKeys != nil { + in, out := &in.XListMapKeys, &out.XListMapKeys + *out = make([]string, len(*in)) + copy(*out, *in) + } + + if in.XListType != nil { + in, out := &in.XListType, &out.XListType + if *in == nil { + *out = nil + } else { + *out = new(string) + **out = **in + } + } + return out } diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/types_jsonschema.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/types_jsonschema.go index 78223934628..06380a4e8e0 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/types_jsonschema.go +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/types_jsonschema.go @@ -86,6 +86,29 @@ type JSONSchemaProps struct { // - type: string // - ... zero or more XIntOrString bool + + // x-kubernetes-list-map-keys annotates lists with the x-kubernetes-list-type `map` by specifying the keys used + // as the index of the map. + // + // This tag MUST only be used on lists that have the "x-kubernetes-list-type" + // extension set to "map". Also, the values specified for this attribute must + // be a scalar typed field of the child structure (no nesting is supported). + XListMapKeys []string + + // x-kubernetes-list-type annotates a list to further describe its topology. + // This extension must only be used on lists and may have 3 possible values: + // + // 1) `atomic`: the list is treated as a single entity, like a scalar. + // Atomic lists will be entirely replaced when updated. This extension + // may be used on any type of list (struct, scalar, ...). + // 2) `set`: + // Sets are lists that must not have multiple times the same value. Each + // value must be a scalar (or another atomic type). + // 3) `map`: + // These lists are like maps in that their elements have a non-index key + // used to identify them. Order is preserved upon merge. The map tag + // must only be used on a list with struct elements. + XListType *string } // JSON represents any valid JSON value. diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1/types_jsonschema.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1/types_jsonschema.go index 06c63625091..1d3727fed87 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1/types_jsonschema.go +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1/types_jsonschema.go @@ -90,6 +90,29 @@ type JSONSchemaProps struct { // - type: string // - ... zero or more XIntOrString bool `json:"x-kubernetes-int-or-string,omitempty" protobuf:"bytes,40,opt,name=xKubernetesIntOrString"` + + // x-kubernetes-list-map-keys annotates lists with the x-kubernetes-list-type `map` by specifying the keys used + // as the index of the map. + // + // This tag MUST only be used on lists that have the "x-kubernetes-list-type" + // extension set to "map". Also, the values specified for this attribute must + // be a scalar typed field of the child structure (no nesting is supported). + XListMapKeys []string `json:"x-kubernetes-list-map-keys,omitempty" protobuf:"bytes,41,rep,name=xKubernetesListMapKeys"` + + // x-kubernetes-list-type annotates a list to further describe its topology. + // This extension must only be used on lists and may have 3 possible values: + // + // 1) `atomic`: the list is treated as a single entity, like a scalar. + // Atomic lists will be entirely replaced when updated. This extension + // may be used on any type of list (struct, scalar, ...). + // 2) `set`: + // Sets are lists that must not have multiple times the same value. Each + // value must be a scalar (or another atomic type). + // 3) `map`: + // These lists are like maps in that their elements have a non-index key + // used to identify them. Order is preserved upon merge. The map tag + // must only be used on a list with struct elements. + XListType *string `json:"x-kubernetes-list-type,omitempty" protobuf:"bytes,42,opt,name=xKubernetesListType"` } // JSON represents any valid JSON value. diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1/deepcopy.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1/deepcopy.go index f67f4418125..a4560dc5f6c 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1/deepcopy.go +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1/deepcopy.go @@ -244,5 +244,21 @@ func (in *JSONSchemaProps) DeepCopy() *JSONSchemaProps { } } + if in.XListMapKeys != nil { + in, out := &in.XListMapKeys, &out.XListMapKeys + *out = make([]string, len(*in)) + copy(*out, *in) + } + + if in.XListType != nil { + in, out := &in.XListType, &out.XListType + if *in == nil { + *out = nil + } else { + *out = new(string) + **out = **in + } + } + return out } diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1/types_jsonschema.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1/types_jsonschema.go index ed893bdff57..9ef486747d0 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1/types_jsonschema.go +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1/types_jsonschema.go @@ -90,6 +90,29 @@ type JSONSchemaProps struct { // - type: string // - ... zero or more XIntOrString bool `json:"x-kubernetes-int-or-string,omitempty" protobuf:"bytes,40,opt,name=xKubernetesIntOrString"` + + // x-kubernetes-list-map-keys annotates lists with the x-kubernetes-list-type `map` by specifying the keys used + // as the index of the map. + // + // This tag MUST only be used on lists that have the "x-kubernetes-list-type" + // extension set to "map". Also, the values specified for this attribute must + // be a scalar typed field of the child structure (no nesting is supported). + XListMapKeys []string `json:"x-kubernetes-list-map-keys,omitempty" protobuf:"bytes,41,rep,name=xKubernetesListMapKeys"` + + // x-kubernetes-list-type annotates a list to further describe its topology. + // This extension must only be used on lists and may have 3 possible values: + // + // 1) `atomic`: the list is treated as a single entity, like a scalar. + // Atomic lists will be entirely replaced when updated. This extension + // may be used on any type of list (struct, scalar, ...). + // 2) `set`: + // Sets are lists that must not have multiple times the same value. Each + // value must be a scalar (or another atomic type). + // 3) `map`: + // These lists are like maps in that their elements have a non-index key + // used to identify them. Order is preserved upon merge. The map tag + // must only be used on a list with struct elements. + XListType *string `json:"x-kubernetes-list-type,omitempty" protobuf:"bytes,42,opt,name=xKubernetesListType"` } // JSON represents any valid JSON value. diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/validation/validation.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/validation/validation.go index fa98c93c870..defc42e1914 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/validation/validation.go +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/validation/validation.go @@ -788,6 +788,26 @@ func ValidateCustomResourceDefinitionOpenAPISchema(schema *apiextensions.JSONSch allErrs = append(allErrs, field.Invalid(fldPath.Child("x-kubernetes-preserve-unknown-fields"), *schema.XPreserveUnknownFields, "must be true or undefined")) } + if schema.XListType != nil && schema.Type != "array" { + if len(schema.Type) == 0 { + allErrs = append(allErrs, field.Required(fldPath.Child("type"), "must be array if x-kubernetes-list-type is set")) + } else { + allErrs = append(allErrs, field.Invalid(fldPath.Child("type"), schema.Type, "must be array if x-kubernetes-list-type is set")) + } + } + + if schema.XListType != nil && *schema.XListType != "atomic" && *schema.XListType != "set" && *schema.XListType != "map" { + allErrs = append(allErrs, field.Invalid(fldPath.Child("x-kubernetes-list-type"), *schema.XListType, "must be one of 'atomic', 'set', 'map', or unset")) + } + + if len(schema.XListMapKeys) > 0 && (schema.XListType == nil || *schema.XListType != "map") { + if schema.XListType == nil { + allErrs = append(allErrs, field.Required(fldPath.Child("x-kubernetes-list-type"), "must be map if x-kubernetes-list-map-keys is non-empty")) + } else { + allErrs = append(allErrs, field.Invalid(fldPath.Child("x-kubernetes-list-type"), *schema.XListType, "must be map if x-kubernetes-list-map-keys is non-empty")) + } + } + return allErrs } diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/validation/validation_test.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/validation/validation_test.go index dac9a6aed7f..11a94aea1be 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/validation/validation_test.go +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/validation/validation_test.go @@ -6572,6 +6572,56 @@ func TestValidateCustomResourceDefinitionValidation(t *testing.T) { opts: validationOptions{requireValidPropertyType: true, requireStructuralSchema: true}, wantError: true, }, + { + name: "invalid type with list type extension set", + input: apiextensions.CustomResourceValidation{ + OpenAPIV3Schema: &apiextensions.JSONSchemaProps{ + Type: "object", + XListType: strPtr("map"), + }, + }, + wantError: true, + }, + { + name: "unset type with list type extension set", + input: apiextensions.CustomResourceValidation{ + OpenAPIV3Schema: &apiextensions.JSONSchemaProps{ + XListType: strPtr("map"), + }, + }, + wantError: true, + }, + { + name: "invalid list type extension with list map keys extension set", + input: apiextensions.CustomResourceValidation{ + OpenAPIV3Schema: &apiextensions.JSONSchemaProps{ + Type: "array", + XListType: strPtr("set"), + XListMapKeys: []string{"key"}, + }, + }, + wantError: true, + }, + { + name: "unset list type extension with list map keys extension set", + input: apiextensions.CustomResourceValidation{ + OpenAPIV3Schema: &apiextensions.JSONSchemaProps{ + Type: "array", + XListMapKeys: []string{"key"}, + }, + }, + wantError: true, + }, + { + name: "invalid list type", + input: apiextensions.CustomResourceValidation{ + OpenAPIV3Schema: &apiextensions.JSONSchemaProps{ + Type: "array", + XListType: strPtr("invalid"), + }, + }, + wantError: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/apiserver.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/apiserver.go index fb15693722b..e207ba65863 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/apiserver.go +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/apiserver.go @@ -200,6 +200,7 @@ func (c completedConfig) New(delegationTarget genericapiserver.DelegationTarget) s.GenericAPIServer.Authorizer, c.GenericConfig.RequestTimeout, time.Duration(c.GenericConfig.MinRequestTimeout)*time.Second, + apiGroupInfo.StaticOpenAPISpec, ) if err != nil { return nil, err diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/customresource_handler.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/customresource_handler.go index d72dc1014c5..bf0d809fec8 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/customresource_handler.go +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/customresource_handler.go @@ -40,6 +40,7 @@ import ( listers "k8s.io/apiextensions-apiserver/pkg/client/listers/apiextensions/internalversion" "k8s.io/apiextensions-apiserver/pkg/controller/establish" "k8s.io/apiextensions-apiserver/pkg/controller/finalizer" + "k8s.io/apiextensions-apiserver/pkg/controller/openapi/builder" "k8s.io/apiextensions-apiserver/pkg/crdserverscheme" apiextensionsfeatures "k8s.io/apiextensions-apiserver/pkg/features" "k8s.io/apiextensions-apiserver/pkg/registry/customresource" @@ -74,11 +75,13 @@ import ( genericfilters "k8s.io/apiserver/pkg/server/filters" "k8s.io/apiserver/pkg/storage/storagebackend" utilfeature "k8s.io/apiserver/pkg/util/feature" + utilopenapi "k8s.io/apiserver/pkg/util/openapi" "k8s.io/apiserver/pkg/util/webhook" "k8s.io/client-go/scale" "k8s.io/client-go/scale/scheme/autoscalingv1" "k8s.io/client-go/tools/cache" "k8s.io/klog" + "k8s.io/kube-openapi/pkg/util/proto" ) // crdHandler serves the `/apis` endpoint. @@ -117,6 +120,11 @@ type crdHandler struct { // minRequestTimeout applies to CR's list/watch calls minRequestTimeout time.Duration + + // staticOpenAPISpec is used as a base for the schema of CR's for the + // purpose of managing fields, it is how CR handlers get the structure + // of TypeMeta and ObjectMeta + staticOpenAPISpec *spec.Swagger } // crdInfo stores enough information to serve the storage for the custom resource @@ -160,7 +168,8 @@ func NewCustomResourceDefinitionHandler( masterCount int, authorizer authorizer.Authorizer, requestTimeout time.Duration, - minRequestTimeout time.Duration) (*crdHandler, error) { + minRequestTimeout time.Duration, + staticOpenAPISpec *spec.Swagger) (*crdHandler, error) { ret := &crdHandler{ versionDiscoveryHandler: versionDiscoveryHandler, groupDiscoveryHandler: groupDiscoveryHandler, @@ -175,6 +184,7 @@ func NewCustomResourceDefinitionHandler( authorizer: authorizer, requestTimeout: requestTimeout, minRequestTimeout: minRequestTimeout, + staticOpenAPISpec: staticOpenAPISpec, } crdInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ AddFunc: ret.createCustomResourceDefinition, @@ -634,6 +644,25 @@ func (r *crdHandler) getOrCreateServingInfoFor(uid types.UID, name string) (*crd structuralSchemas[v.Name] = s } + var openAPIModels proto.Models + if utilfeature.DefaultFeatureGate.Enabled(features.ServerSideApply) && r.staticOpenAPISpec != nil { + specs := []*spec.Swagger{} + for _, v := range crd.Spec.Versions { + s, err := builder.BuildSwagger(crd, v.Name, builder.Options{V2: false, StripDefaults: true, StripValueValidation: true}) + if err != nil { + utilruntime.HandleError(err) + return nil, fmt.Errorf("the server could not properly serve the CR schema") + } + specs = append(specs, s) + } + mergedOpenAPI := builder.MergeSpecs(r.staticOpenAPISpec, specs...) + openAPIModels, err = utilopenapi.ToProtoModels(mergedOpenAPI) + if err != nil { + utilruntime.HandleError(err) + return nil, fmt.Errorf("the server could not properly serve the CR schema") + } + } + for _, v := range crd.Spec.Versions { safeConverter, unsafeConverter, err := r.converterFactory.NewConverter(crd) if err != nil { @@ -799,12 +828,17 @@ func (r *crdHandler) getOrCreateServingInfoFor(uid types.UID, name string) (*crd } if utilfeature.DefaultFeatureGate.Enabled(features.ServerSideApply) { reqScope := *requestScopes[v.Name] - reqScope.FieldManager = fieldmanager.NewCRDFieldManager( + reqScope.FieldManager, err = fieldmanager.NewCRDFieldManager( + openAPIModels, reqScope.Convertor, reqScope.Defaulter, reqScope.Kind.GroupVersion(), reqScope.HubGroupVersion, + *crd.Spec.PreserveUnknownFields, ) + if err != nil { + return nil, err + } requestScopes[v.Name] = &reqScope } diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/convert.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/convert.go index 3025d39e004..6f0e3f6bc1c 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/convert.go +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/convert.go @@ -244,6 +244,8 @@ func newExtensions(s *apiextensions.JSONSchemaProps) (*Extensions, error) { ret := &Extensions{ XEmbeddedResource: s.XEmbeddedResource, XIntOrString: s.XIntOrString, + XListMapKeys: s.XListMapKeys, + XListType: s.XListType, } if s.XPreserveUnknownFields != nil { diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/goopenapi.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/goopenapi.go index 9fbf02c8f93..d9bc667c66c 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/goopenapi.go +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/goopenapi.go @@ -78,6 +78,12 @@ func (x *Extensions) toGoOpenAPI(ret *spec.Schema) { if x.XIntOrString { ret.VendorExtensible.AddExtension("x-kubernetes-int-or-string", true) } + if len(x.XListMapKeys) > 0 { + ret.VendorExtensible.AddExtension("x-kubernetes-list-map-keys", x.XListMapKeys) + } + if x.XListType != nil { + ret.VendorExtensible.AddExtension("x-kubernetes-list-type", x.XListType) + } } func (v *ValueValidation) toGoOpenAPI(ret *spec.Schema) { diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/structural.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/structural.go index a060644a7cd..0d57200d086 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/structural.go +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/structural.go @@ -92,6 +92,29 @@ type Extensions struct { // - type: string // - ... zero or more XIntOrString bool + + // x-kubernetes-list-map-keys annotates lists with the x-kubernetes-list-type `map` by specifying the keys used + // as the index of the map. + // + // This tag MUST only be used on lists that have the "x-kubernetes-list-type" + // extension set to "map". Also, the values specified for this attribute must + // be a scalar typed field of the child structure (no nesting is supported). + XListMapKeys []string + + // x-kubernetes-list-type annotates a list to further describe its topology. + // This extension must only be used on lists and may have 3 possible values: + // + // 1) `atomic`: the list is treated as a single entity, like a scalar. + // Atomic lists will be entirely replaced when updated. This extension + // may be used on any type of list (struct, scalar, ...). + // 2) `set`: + // Sets are lists that must not have multiple times the same value. Each + // value must be a scalar (or another atomic type). + // 3) `map`: + // These lists are like maps in that their elements have a non-index key + // used to identify them. Order is preserved upon merge. The map tag + // must only be used on a list with struct elements. + XListType *string } // +k8s:deepcopy-gen=true diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/validation.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/validation.go index 487731fd887..56d5f5a0816 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/validation.go +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/validation.go @@ -305,6 +305,12 @@ func validateNestedValueValidation(v *NestedValueValidation, skipAnyOf, skipAllO if v.ForbiddenExtensions.XIntOrString { allErrs = append(allErrs, field.Forbidden(fldPath.Child("x-kubernetes-int-or-string"), "must be false to be structural")) } + if len(v.ForbiddenExtensions.XListMapKeys) > 0 { + allErrs = append(allErrs, field.Forbidden(fldPath.Child("x-kubernetes-list-map-keys"), "must be empty to be structural")) + } + if v.ForbiddenExtensions.XListType != nil { + allErrs = append(allErrs, field.Forbidden(fldPath.Child("x-kubernetes-list-type"), "must be undefined to be structural")) + } // forbid reasoning about metadata because it can lead to metadata restriction we don't want if _, found := v.Properties["metadata"]; found { diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/validation_test.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/validation_test.go index 3067f672f9d..15c7118e49b 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/validation_test.go +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/validation_test.go @@ -21,8 +21,6 @@ import ( "testing" fuzz "github.com/google/gofuzz" - - "k8s.io/apimachinery/pkg/util/rand" ) func TestValidateNestedValueValidationComplete(t *testing.T) { @@ -46,7 +44,6 @@ func TestValidateNestedValueValidationComplete(t *testing.T) { for i := 0; i < tt.NumField(); i++ { vv := &NestedValueValidation{} x := reflect.ValueOf(&vv.ForbiddenGenerics).Elem() - i := rand.Intn(x.NumField()) fuzzer.Fuzz(x.Field(i).Addr().Interface()) errs := validateNestedValueValidation(vv, false, false, fieldLevel, nil) @@ -60,7 +57,6 @@ func TestValidateNestedValueValidationComplete(t *testing.T) { for i := 0; i < tt.NumField(); i++ { vv := &NestedValueValidation{} x := reflect.ValueOf(&vv.ForbiddenExtensions).Elem() - i := rand.Intn(x.NumField()) fuzzer.Fuzz(x.Field(i).Addr().Interface()) errs := validateNestedValueValidation(vv, false, false, fieldLevel, nil) diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/validation/validation.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/validation/validation.go index a45fe7a0acd..58f72fe2a11 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/validation/validation.go +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/validation/validation.go @@ -246,6 +246,12 @@ func ConvertJSONSchemaPropsWithPostProcess(in *apiextensions.JSONSchemaProps, ou if in.XEmbeddedResource { out.VendorExtensible.AddExtension("x-kubernetes-embedded-resource", true) } + if len(in.XListMapKeys) != 0 { + out.VendorExtensible.AddExtension("x-kubernetes-list-map-keys", in.XListMapKeys) + } + if in.XListType != nil { + out.VendorExtensible.AddExtension("x-kubernetes-list-type", in.XListType) + } return nil } diff --git a/staging/src/k8s.io/apiserver/pkg/endpoints/handlers/fieldmanager/fieldmanager.go b/staging/src/k8s.io/apiserver/pkg/endpoints/handlers/fieldmanager/fieldmanager.go index 6a0bc579f87..54a4eae00f6 100644 --- a/staging/src/k8s.io/apiserver/pkg/endpoints/handlers/fieldmanager/fieldmanager.go +++ b/staging/src/k8s.io/apiserver/pkg/endpoints/handlers/fieldmanager/fieldmanager.go @@ -48,7 +48,7 @@ type FieldManager struct { // NewFieldManager creates a new FieldManager that merges apply requests // and update managed fields for other types of requests. func NewFieldManager(models openapiproto.Models, objectConverter runtime.ObjectConvertor, objectDefaulter runtime.ObjectDefaulter, gv schema.GroupVersion, hub schema.GroupVersion) (*FieldManager, error) { - typeConverter, err := internal.NewTypeConverter(models) + typeConverter, err := internal.NewTypeConverter(models, false) if err != nil { return nil, err } @@ -66,19 +66,26 @@ func NewFieldManager(models openapiproto.Models, objectConverter runtime.ObjectC } // NewCRDFieldManager creates a new FieldManager specifically for -// CRDs. This doesn't use openapi models (and it doesn't support the -// validation field right now). -func NewCRDFieldManager(objectConverter runtime.ObjectConvertor, objectDefaulter runtime.ObjectDefaulter, gv schema.GroupVersion, hub schema.GroupVersion) *FieldManager { +// CRDs. This allows for the possibility of fields which are not defined +// in models, as well as having no models defined at all. +func NewCRDFieldManager(models openapiproto.Models, objectConverter runtime.ObjectConvertor, objectDefaulter runtime.ObjectDefaulter, gv schema.GroupVersion, hub schema.GroupVersion, preserveUnknownFields bool) (_ *FieldManager, err error) { + var typeConverter internal.TypeConverter = internal.DeducedTypeConverter{} + if models != nil { + typeConverter, err = internal.NewTypeConverter(models, preserveUnknownFields) + if err != nil { + return nil, err + } + } return &FieldManager{ - typeConverter: internal.DeducedTypeConverter{}, + typeConverter: typeConverter, objectConverter: objectConverter, objectDefaulter: objectDefaulter, groupVersion: gv, hubVersion: hub, updater: merge.Updater{ - Converter: internal.NewCRDVersionConverter(internal.DeducedTypeConverter{}, objectConverter, hub), + Converter: internal.NewCRDVersionConverter(typeConverter, objectConverter, hub), }, - } + }, nil } // Update is used when the object has already been merged (non-apply diff --git a/staging/src/k8s.io/apiserver/pkg/endpoints/handlers/fieldmanager/fieldmanager_test.go b/staging/src/k8s.io/apiserver/pkg/endpoints/handlers/fieldmanager/fieldmanager_test.go index f4dd58b40f6..3a3fded4ef4 100644 --- a/staging/src/k8s.io/apiserver/pkg/endpoints/handlers/fieldmanager/fieldmanager_test.go +++ b/staging/src/k8s.io/apiserver/pkg/endpoints/handlers/fieldmanager/fieldmanager_test.go @@ -58,12 +58,15 @@ func NewTestFieldManager() *fieldmanager.FieldManager { Version: "v1", } - return fieldmanager.NewCRDFieldManager( + f, _ := fieldmanager.NewCRDFieldManager( + nil, &fakeObjectConvertor{}, &fakeObjectDefaulter{}, gv, gv, + true, ) + return f } func TestFieldManagerCreation(t *testing.T) { diff --git a/staging/src/k8s.io/apiserver/pkg/endpoints/handlers/fieldmanager/internal/gvkparser.go b/staging/src/k8s.io/apiserver/pkg/endpoints/handlers/fieldmanager/internal/gvkparser.go index ff4022c8863..4d6ed52d803 100644 --- a/staging/src/k8s.io/apiserver/pkg/endpoints/handlers/fieldmanager/internal/gvkparser.go +++ b/staging/src/k8s.io/apiserver/pkg/endpoints/handlers/fieldmanager/internal/gvkparser.go @@ -44,8 +44,8 @@ func (p *gvkParser) Type(gvk schema.GroupVersionKind) *typed.ParseableType { return &t } -func newGVKParser(models proto.Models) (*gvkParser, error) { - typeSchema, err := schemaconv.ToSchema(models) +func newGVKParser(models proto.Models, preserveUnknownFields bool) (*gvkParser, error) { + typeSchema, err := schemaconv.ToSchemaWithPreserveUnknownFields(models, preserveUnknownFields) if err != nil { return nil, fmt.Errorf("failed to convert models to schema: %v", err) } diff --git a/staging/src/k8s.io/apiserver/pkg/endpoints/handlers/fieldmanager/internal/typeconverter.go b/staging/src/k8s.io/apiserver/pkg/endpoints/handlers/fieldmanager/internal/typeconverter.go index c9e329d65c4..44ad55916fa 100644 --- a/staging/src/k8s.io/apiserver/pkg/endpoints/handlers/fieldmanager/internal/typeconverter.go +++ b/staging/src/k8s.io/apiserver/pkg/endpoints/handlers/fieldmanager/internal/typeconverter.go @@ -78,8 +78,8 @@ var _ TypeConverter = &typeConverter{} // NewTypeConverter builds a TypeConverter from a proto.Models. This // will automatically find the proper version of the object, and the // corresponding schema information. -func NewTypeConverter(models proto.Models) (TypeConverter, error) { - parser, err := newGVKParser(models) +func NewTypeConverter(models proto.Models, preserveUnknownFields bool) (TypeConverter, error) { + parser, err := newGVKParser(models, preserveUnknownFields) if err != nil { return nil, err } diff --git a/staging/src/k8s.io/apiserver/pkg/endpoints/handlers/fieldmanager/internal/typeconverter_test.go b/staging/src/k8s.io/apiserver/pkg/endpoints/handlers/fieldmanager/internal/typeconverter_test.go index 700abb90c50..7ca4969d852 100644 --- a/staging/src/k8s.io/apiserver/pkg/endpoints/handlers/fieldmanager/internal/typeconverter_test.go +++ b/staging/src/k8s.io/apiserver/pkg/endpoints/handlers/fieldmanager/internal/typeconverter_test.go @@ -48,7 +48,7 @@ func TestTypeConverter(t *testing.T) { t.Fatalf("Failed to build OpenAPI models: %v", err) } - tc, err := internal.NewTypeConverter(m) + tc, err := internal.NewTypeConverter(m, false) if err != nil { t.Fatalf("Failed to build TypeConverter: %v", err) } @@ -214,7 +214,7 @@ spec: b.Fatalf("Failed to build OpenAPI models: %v", err) } - tc, err := internal.NewTypeConverter(m) + tc, err := internal.NewTypeConverter(m, false) if err != nil { b.Fatalf("Failed to build TypeConverter: %v", err) } diff --git a/staging/src/k8s.io/apiserver/pkg/endpoints/handlers/fieldmanager/internal/versionconverter_test.go b/staging/src/k8s.io/apiserver/pkg/endpoints/handlers/fieldmanager/internal/versionconverter_test.go index 18365f6d3a1..214b416d8bb 100644 --- a/staging/src/k8s.io/apiserver/pkg/endpoints/handlers/fieldmanager/internal/versionconverter_test.go +++ b/staging/src/k8s.io/apiserver/pkg/endpoints/handlers/fieldmanager/internal/versionconverter_test.go @@ -39,7 +39,7 @@ func TestVersionConverter(t *testing.T) { if err != nil { t.Fatalf("Failed to build OpenAPI models: %v", err) } - tc, err := internal.NewTypeConverter(m) + tc, err := internal.NewTypeConverter(m, false) if err != nil { t.Fatalf("Failed to build TypeConverter: %v", err) } diff --git a/staging/src/k8s.io/apiserver/pkg/server/genericapiserver.go b/staging/src/k8s.io/apiserver/pkg/server/genericapiserver.go index 53b770c93d6..b1be70831c8 100644 --- a/staging/src/k8s.io/apiserver/pkg/server/genericapiserver.go +++ b/staging/src/k8s.io/apiserver/pkg/server/genericapiserver.go @@ -77,6 +77,10 @@ type APIGroupInfo struct { NegotiatedSerializer runtime.NegotiatedSerializer // ParameterCodec performs conversions for query parameters passed to API calls ParameterCodec runtime.ParameterCodec + + // StaticOpenAPISpec is the spec derived from the definitions of all resources installed together. + // It is set during InstallAPIGroups, InstallAPIGroup, and InstallLegacyAPIGroup. + StaticOpenAPISpec *spec.Swagger } // GenericAPIServer contains state for a Kubernetes cluster api server. @@ -552,6 +556,9 @@ func (s *GenericAPIServer) getOpenAPIModels(apiPrefix string, apiGroupInfos ...* if err != nil { return nil, err } + for _, apiGroupInfo := range apiGroupInfos { + apiGroupInfo.StaticOpenAPISpec = openAPISpec + } return utilopenapi.ToProtoModels(openAPISpec) } diff --git a/test/integration/apiserver/apply/apply_crd_test.go b/test/integration/apiserver/apply/apply_crd_test.go new file mode 100644 index 00000000000..ccb56cbaeae --- /dev/null +++ b/test/integration/apiserver/apply/apply_crd_test.go @@ -0,0 +1,494 @@ +/* +Copyright 2019 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package apiserver + +import ( + "encoding/json" + "fmt" + "testing" + + apiextensionsv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" + "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset" + "k8s.io/apiextensions-apiserver/test/integration/fixtures" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + genericfeatures "k8s.io/apiserver/pkg/features" + utilfeature "k8s.io/apiserver/pkg/util/feature" + "k8s.io/client-go/dynamic" + featuregatetesting "k8s.io/component-base/featuregate/testing" + apiservertesting "k8s.io/kubernetes/cmd/kube-apiserver/app/testing" + "k8s.io/kubernetes/test/integration/framework" +) + +// TestApplyCRDNoSchema tests that CRDs and CRs can both be applied to with a PATCH request with the apply content type +// when there is no validation field provided. +func TestApplyCRDNoSchema(t *testing.T) { + defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, genericfeatures.ServerSideApply, true)() + + server, err := apiservertesting.StartTestServer(t, apiservertesting.NewDefaultTestServerOptions(), nil, framework.SharedEtcd()) + if err != nil { + t.Fatal(err) + } + defer server.TearDownFn() + config := server.ClientConfig + + apiExtensionClient, err := clientset.NewForConfig(config) + if err != nil { + t.Fatal(err) + } + dynamicClient, err := dynamic.NewForConfig(config) + if err != nil { + t.Fatal(err) + } + + noxuDefinition := fixtures.NewMultipleVersionNoxuCRD(apiextensionsv1beta1.ClusterScoped) + + noxuDefinition, err = fixtures.CreateNewCustomResourceDefinition(noxuDefinition, apiExtensionClient, dynamicClient) + if err != nil { + t.Fatal(err) + } + + kind := noxuDefinition.Spec.Names.Kind + apiVersion := noxuDefinition.Spec.Group + "/" + noxuDefinition.Spec.Version + name := "mytest" + + rest := apiExtensionClient.Discovery().RESTClient() + yamlBody := []byte(fmt.Sprintf(` +apiVersion: %s +kind: %s +metadata: + name: %s +spec: + replicas: 1`, apiVersion, kind, name)) + result, err := rest.Patch(types.ApplyPatchType). + AbsPath("/apis", noxuDefinition.Spec.Group, noxuDefinition.Spec.Version, noxuDefinition.Spec.Names.Plural). + Name(name). + Param("fieldManager", "apply_test"). + Body(yamlBody). + DoRaw() + if err != nil { + t.Fatalf("failed to create custom resource with apply: %v:\n%v", err, string(result)) + } + + // Patch object to change the number of replicas + result, err = rest.Patch(types.MergePatchType). + AbsPath("/apis", noxuDefinition.Spec.Group, noxuDefinition.Spec.Version, noxuDefinition.Spec.Names.Plural). + Name(name). + Body([]byte(`{"spec":{"replicas": 5}}`)). + DoRaw() + if err != nil { + t.Fatalf("failed to update number of replicas with merge patch: %v:\n%v", err, string(result)) + } + + // Re-apply, we should get conflicts now, since the number of replicas was changed. + result, err = rest.Patch(types.ApplyPatchType). + AbsPath("/apis", noxuDefinition.Spec.Group, noxuDefinition.Spec.Version, noxuDefinition.Spec.Names.Plural). + Name(name). + Param("fieldManager", "apply_test"). + Body(yamlBody). + DoRaw() + if err == nil { + t.Fatalf("Expecting to get conflicts when applying object after updating replicas, got no error: %s", result) + } + status, ok := err.(*errors.StatusError) + if !ok { + t.Fatalf("Expecting to get conflicts as API error") + } + if len(status.Status().Details.Causes) < 1 { + t.Fatalf("Expecting to get at least one conflict when applying object after updating replicas, got: %v", status.Status().Details.Causes) + } + + // Re-apply with force, should work fine. + result, err = rest.Patch(types.ApplyPatchType). + AbsPath("/apis", noxuDefinition.Spec.Group, noxuDefinition.Spec.Version, noxuDefinition.Spec.Names.Plural). + Name(name). + Param("force", "true"). + Param("fieldManager", "apply_test"). + Body(yamlBody). + DoRaw() + if err != nil { + t.Fatalf("failed to apply object with force after updating replicas: %v:\n%v", err, string(result)) + } +} + +// TestApplyCRDStructuralSchema tests that when a CRD has a structural schema in its validation field, +// it will be used to construct the CR schema used by apply. +func TestApplyCRDStructuralSchema(t *testing.T) { + defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, genericfeatures.ServerSideApply, true)() + + server, err := apiservertesting.StartTestServer(t, apiservertesting.NewDefaultTestServerOptions(), nil, framework.SharedEtcd()) + if err != nil { + t.Fatal(err) + } + defer server.TearDownFn() + config := server.ClientConfig + + apiExtensionClient, err := clientset.NewForConfig(config) + if err != nil { + t.Fatal(err) + } + dynamicClient, err := dynamic.NewForConfig(config) + if err != nil { + t.Fatal(err) + } + + noxuDefinition := fixtures.NewMultipleVersionNoxuCRD(apiextensionsv1beta1.ClusterScoped) + + var c apiextensionsv1beta1.CustomResourceValidation + err = json.Unmarshal([]byte(`{ + "openAPIV3Schema": { + "type": "object", + "properties": { + "spec": { + "type": "object", + "x-kubernetes-preserve-unknown-fields": true, + "properties": { + "cronSpec": { + "type": "string", + "pattern": "^(\\d+|\\*)(/\\d+)?(\\s+(\\d+|\\*)(/\\d+)?){4}$" + }, + "ports": { + "type": "array", + "x-kubernetes-list-map-keys": [ + "containerPort", + "protocol" + ], + "x-kubernetes-list-type": "map", + "items": { + "properties": { + "containerPort": { + "format": "int32", + "type": "integer" + }, + "hostIP": { + "type": "string" + }, + "hostPort": { + "format": "int32", + "type": "integer" + }, + "name": { + "type": "string" + }, + "protocol": { + "type": "string" + } + }, + "required": [ + "containerPort" + ], + "type": "object" + } + } + } + } + } + } + }`), &c) + if err != nil { + t.Fatal(err) + } + noxuDefinition.Spec.Validation = &c + falseBool := false + noxuDefinition.Spec.PreserveUnknownFields = &falseBool + + noxuDefinition, err = fixtures.CreateNewCustomResourceDefinition(noxuDefinition, apiExtensionClient, dynamicClient) + if err != nil { + t.Fatal(err) + } + + kind := noxuDefinition.Spec.Names.Kind + apiVersion := noxuDefinition.Spec.Group + "/" + noxuDefinition.Spec.Version + name := "mytest" + + rest := apiExtensionClient.Discovery().RESTClient() + yamlBody := []byte(fmt.Sprintf(` +apiVersion: %s +kind: %s +metadata: + name: %s + finalizers: + - test-finalizer +spec: + cronSpec: "* * * * */5" + replicas: 1 + ports: + - name: x + containerPort: 80 + protocol: TCP`, apiVersion, kind, name)) + result, err := rest.Patch(types.ApplyPatchType). + AbsPath("/apis", noxuDefinition.Spec.Group, noxuDefinition.Spec.Version, noxuDefinition.Spec.Names.Plural). + Name(name). + Param("fieldManager", "apply_test"). + Body(yamlBody). + DoRaw() + if err != nil { + t.Fatalf("failed to create custom resource with apply: %v:\n%v", err, string(result)) + } + + // Patch object to add another finalizer to the finalizers list + result, err = rest.Patch(types.MergePatchType). + AbsPath("/apis", noxuDefinition.Spec.Group, noxuDefinition.Spec.Version, noxuDefinition.Spec.Names.Plural). + Name(name). + Body([]byte(`{"metadata":{"finalizers":["another-one"]}}`)). + DoRaw() + if err != nil { + t.Fatalf("failed to add finalizer with merge patch: %v:\n%v", err, string(result)) + } + + // Re-apply the same config, should work fine, since finalizers should have the list-type extension 'set'. + result, err = rest.Patch(types.ApplyPatchType). + AbsPath("/apis", noxuDefinition.Spec.Group, noxuDefinition.Spec.Version, noxuDefinition.Spec.Names.Plural). + Name(name). + Param("fieldManager", "apply_test"). + Body(yamlBody). + DoRaw() + if err != nil { + t.Fatalf("failed to apply same config after adding a finalizer: %v:\n%v", err, string(result)) + } + + // Patch object to change the number of replicas + result, err = rest.Patch(types.MergePatchType). + AbsPath("/apis", noxuDefinition.Spec.Group, noxuDefinition.Spec.Version, noxuDefinition.Spec.Names.Plural). + Name(name). + Body([]byte(`{"spec":{"replicas": 5}}`)). + DoRaw() + if err != nil { + t.Fatalf("failed to update number of replicas with merge patch: %v:\n%v", err, string(result)) + } + + // Re-apply, we should get conflicts now, since the number of replicas was changed. + result, err = rest.Patch(types.ApplyPatchType). + AbsPath("/apis", noxuDefinition.Spec.Group, noxuDefinition.Spec.Version, noxuDefinition.Spec.Names.Plural). + Name(name). + Param("fieldManager", "apply_test"). + Body(yamlBody). + DoRaw() + if err == nil { + t.Fatalf("Expecting to get conflicts when applying object after updating replicas, got no error: %s", result) + } + status, ok := err.(*errors.StatusError) + if !ok { + t.Fatalf("Expecting to get conflicts as API error") + } + if len(status.Status().Details.Causes) < 1 { + t.Fatalf("Expecting to get at least one conflict when applying object after updating replicas, got: %v", status.Status().Details.Causes) + } + + // Re-apply with force, should work fine. + result, err = rest.Patch(types.ApplyPatchType). + AbsPath("/apis", noxuDefinition.Spec.Group, noxuDefinition.Spec.Version, noxuDefinition.Spec.Names.Plural). + Name(name). + Param("force", "true"). + Param("fieldManager", "apply_test"). + Body(yamlBody). + DoRaw() + if err != nil { + t.Fatalf("failed to apply object with force after updating replicas: %v:\n%v", err, string(result)) + } + + // New applier tries to edit an existing list item, we should get conflicts. + result, err = rest.Patch(types.ApplyPatchType). + AbsPath("/apis", noxuDefinition.Spec.Group, noxuDefinition.Spec.Version, noxuDefinition.Spec.Names.Plural). + Name(name). + Param("fieldManager", "apply_test_2"). + Body([]byte(fmt.Sprintf(` +apiVersion: %s +kind: %s +metadata: + name: %s +spec: + ports: + - name: "y" + containerPort: 80 + protocol: TCP`, apiVersion, kind, name))). + DoRaw() + if err == nil { + t.Fatalf("Expecting to get conflicts when a different applier updates existing list item, got no error: %s", result) + } + status, ok = err.(*errors.StatusError) + if !ok { + t.Fatalf("Expecting to get conflicts as API error") + } + if len(status.Status().Details.Causes) < 1 { + t.Fatalf("Expecting to get at least one conflict when a different applier updates existing list item, got: %v", status.Status().Details.Causes) + } + + // New applier tries to add a new list item, should work fine. + result, err = rest.Patch(types.ApplyPatchType). + AbsPath("/apis", noxuDefinition.Spec.Group, noxuDefinition.Spec.Version, noxuDefinition.Spec.Names.Plural). + Name(name). + Param("fieldManager", "apply_test_2"). + Body([]byte(fmt.Sprintf(` +apiVersion: %s +kind: %s +metadata: + name: %s +spec: + ports: + - name: "y" + containerPort: 8080 + protocol: TCP`, apiVersion, kind, name))). + DoRaw() + if err != nil { + t.Fatalf("failed to add a new list item to the object as a different applier: %v:\n%v", err, string(result)) + } +} + +// TestApplyCRDNonStructuralSchema tests that when a CRD has a non-structural schema in its validation field, +// it will be used to construct the CR schema used by apply, but any non-structural parts of the schema will be treated as +// nested maps (same as a CRD without a schema) +func TestApplyCRDNonStructuralSchema(t *testing.T) { + defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, genericfeatures.ServerSideApply, true)() + + server, err := apiservertesting.StartTestServer(t, apiservertesting.NewDefaultTestServerOptions(), nil, framework.SharedEtcd()) + if err != nil { + t.Fatal(err) + } + defer server.TearDownFn() + config := server.ClientConfig + + apiExtensionClient, err := clientset.NewForConfig(config) + if err != nil { + t.Fatal(err) + } + dynamicClient, err := dynamic.NewForConfig(config) + if err != nil { + t.Fatal(err) + } + + noxuDefinition := fixtures.NewNoxuCustomResourceDefinition(apiextensionsv1beta1.ClusterScoped) + + var c apiextensionsv1beta1.CustomResourceValidation + err = json.Unmarshal([]byte(`{ + "openAPIV3Schema": { + "type": "object", + "properties": { + "spec": { + "anyOf": [ + { + "type": "object", + "properties": { + "cronSpec": { + "type": "string", + "pattern": "^(\\d+|\\*)(/\\d+)?(\\s+(\\d+|\\*)(/\\d+)?){4}$" + } + } + }, { + "type": "string" + } + ] + } + } + } + }`), &c) + if err != nil { + t.Fatal(err) + } + noxuDefinition.Spec.Validation = &c + + noxuDefinition, err = fixtures.CreateNewCustomResourceDefinition(noxuDefinition, apiExtensionClient, dynamicClient) + if err != nil { + t.Fatal(err) + } + + kind := noxuDefinition.Spec.Names.Kind + apiVersion := noxuDefinition.Spec.Group + "/" + noxuDefinition.Spec.Version + name := "mytest" + + rest := apiExtensionClient.Discovery().RESTClient() + yamlBody := []byte(fmt.Sprintf(` +apiVersion: %s +kind: %s +metadata: + name: %s + finalizers: + - test-finalizer +spec: + cronSpec: "* * * * */5" + replicas: 1`, apiVersion, kind, name)) + result, err := rest.Patch(types.ApplyPatchType). + AbsPath("/apis", noxuDefinition.Spec.Group, noxuDefinition.Spec.Version, noxuDefinition.Spec.Names.Plural). + Name(name). + Param("fieldManager", "apply_test"). + Body(yamlBody). + DoRaw() + if err != nil { + t.Fatalf("failed to create custom resource with apply: %v:\n%v", err, string(result)) + } + + // Patch object to add another finalizer to the finalizers list + result, err = rest.Patch(types.MergePatchType). + AbsPath("/apis", noxuDefinition.Spec.Group, noxuDefinition.Spec.Version, noxuDefinition.Spec.Names.Plural). + Name(name). + Body([]byte(`{"metadata":{"finalizers":["another-one"]}}`)). + DoRaw() + if err != nil { + t.Fatalf("failed to add finalizer with merge patch: %v:\n%v", err, string(result)) + } + + // Re-apply the same config, should work fine, since finalizers should have the list-type extension 'set'. + result, err = rest.Patch(types.ApplyPatchType). + AbsPath("/apis", noxuDefinition.Spec.Group, noxuDefinition.Spec.Version, noxuDefinition.Spec.Names.Plural). + Name(name). + Param("fieldManager", "apply_test"). + Body(yamlBody). + DoRaw() + if err != nil { + t.Fatalf("failed to apply same config after adding a finalizer: %v:\n%v", err, string(result)) + } + + // Patch object to change the number of replicas + result, err = rest.Patch(types.MergePatchType). + AbsPath("/apis", noxuDefinition.Spec.Group, noxuDefinition.Spec.Version, noxuDefinition.Spec.Names.Plural). + Name(name). + Body([]byte(`{"spec":{"replicas": 5}}`)). + DoRaw() + if err != nil { + t.Fatalf("failed to update number of replicas with merge patch: %v:\n%v", err, string(result)) + } + + // Re-apply, we should get conflicts now, since the number of replicas was changed. + result, err = rest.Patch(types.ApplyPatchType). + AbsPath("/apis", noxuDefinition.Spec.Group, noxuDefinition.Spec.Version, noxuDefinition.Spec.Names.Plural). + Name(name). + Param("fieldManager", "apply_test"). + Body(yamlBody). + DoRaw() + if err == nil { + t.Fatalf("Expecting to get conflicts when applying object after updating replicas, got no error: %s", result) + } + status, ok := err.(*errors.StatusError) + if !ok { + t.Fatalf("Expecting to get conflicts as API error") + } + if len(status.Status().Details.Causes) < 1 { + t.Fatalf("Expecting to get at least one conflict when applying object after updating replicas, got: %v", status.Status().Details.Causes) + } + + // Re-apply with force, should work fine. + result, err = rest.Patch(types.ApplyPatchType). + AbsPath("/apis", noxuDefinition.Spec.Group, noxuDefinition.Spec.Version, noxuDefinition.Spec.Names.Plural). + Name(name). + Param("force", "true"). + Param("fieldManager", "apply_test"). + Body(yamlBody). + DoRaw() + if err != nil { + t.Fatalf("failed to apply object with force after updating replicas: %v:\n%v", err, string(result)) + } +}