From 75d5961df0cc9675dcbbf00fe5f38d9c5c7f54d8 Mon Sep 17 00:00:00 2001 From: Andy Goldstein Date: Fri, 14 Oct 2022 17:29:17 -0400 Subject: [PATCH] Add APIConversion support Signed-off-by: Andy Goldstein --- config/crds/apis.kcp.io_apiconversions.yaml | 124 +++++++ .../crds/apis.kcp.io_apiresourceschemas.yaml | 2 +- config/system-crds/bootstrap.go | 1 + go.mod | 4 +- .../apiconversion/apiconversion_admission.go | 144 +++++++++ .../apiresourceschema/admission_test.go | 2 +- pkg/admission/apiresourceschema/validation.go | 3 +- pkg/admission/plugins.go | 4 + pkg/apis/apis/v1alpha1/register.go | 3 + .../apis/v1alpha1/types_apiresourceschema.go | 103 +++++- .../apis/v1alpha1/zz_generated.deepcopy.go | 125 ++++++++ pkg/cache/server/bootstrap/bootstrap.go | 1 + pkg/cache/server/config.go | 29 +- pkg/cache/server/server.go | 2 + .../typed/apis/v1alpha1/apiconversion.go | 72 +++++ .../typed/apis/v1alpha1/apis_client.go | 5 + .../typed/apis/v1alpha1/fake/apiconversion.go | 161 ++++++++++ .../typed/apis/v1alpha1/fake/apis_client.go | 8 + .../typed/apis/v1alpha1/apiconversion.go | 169 ++++++++++ .../typed/apis/v1alpha1/apis_client.go | 5 + .../apis/v1alpha1/fake/fake_apiconversion.go | 123 +++++++ .../apis/v1alpha1/fake/fake_apis_client.go | 4 + .../apis/v1alpha1/generated_expansion.go | 2 + .../apis/v1alpha1/apiconversion.go | 179 +++++++++++ .../apis/v1alpha1/interface.go | 14 + .../informers/externalversions/generic.go | 5 + .../listers/apis/v1alpha1/apiconversion.go | 143 +++++++++ .../apis/v1alpha1/apiconversion_expansion.go | 28 ++ pkg/conversion/conversion_factory.go | 76 +++++ pkg/conversion/conversion_factory_test.go | 50 +++ pkg/conversion/conversion_rules.go | 236 ++++++++++++++ pkg/conversion/conversion_rules_test.go | 152 +++++++++ pkg/conversion/converter.go | 261 +++++++++++++++ pkg/conversion/converter_test.go | 295 +++++++++++++++++ pkg/conversion/deferred_converter.go | 115 +++++++ pkg/conversion/deferred_converter_test.go | 157 +++++++++ pkg/conversion/embedded_test.go | 22 ++ pkg/conversion/widgets-crd.yaml | 83 +++++ pkg/openapi/zz_generated.openapi.go | 227 ++++++++++++- .../apis/apibinding/apibinding_controller.go | 303 +++++++++++------- .../apis/apibinding/apibinding_reconcile.go | 23 ++ .../replication/replication_controller.go | 5 + pkg/server/config.go | 23 +- pkg/server/controllers.go | 2 + pkg/server/handler.go | 12 - pkg/server/options/flags.go | 23 +- pkg/server/options/options.go | 45 +-- test/e2e/conversion/conversion_test.go | 175 ++++++++++ test/e2e/conversion/resources.yaml | 125 ++++++++ .../conversion/v1-widget-no-last-name.yaml | 6 + test/e2e/conversion/v1-widget.yaml | 7 + test/e2e/conversion/v2-widget.yaml | 10 + 52 files changed, 3695 insertions(+), 203 deletions(-) create mode 100644 config/crds/apis.kcp.io_apiconversions.yaml create mode 100644 pkg/admission/apiconversion/apiconversion_admission.go create mode 100644 pkg/client/clientset/versioned/cluster/typed/apis/v1alpha1/apiconversion.go create mode 100644 pkg/client/clientset/versioned/cluster/typed/apis/v1alpha1/fake/apiconversion.go create mode 100644 pkg/client/clientset/versioned/typed/apis/v1alpha1/apiconversion.go create mode 100644 pkg/client/clientset/versioned/typed/apis/v1alpha1/fake/fake_apiconversion.go create mode 100644 pkg/client/informers/externalversions/apis/v1alpha1/apiconversion.go create mode 100644 pkg/client/listers/apis/v1alpha1/apiconversion.go create mode 100644 pkg/client/listers/apis/v1alpha1/apiconversion_expansion.go create mode 100644 pkg/conversion/conversion_factory.go create mode 100644 pkg/conversion/conversion_factory_test.go create mode 100644 pkg/conversion/conversion_rules.go create mode 100644 pkg/conversion/conversion_rules_test.go create mode 100644 pkg/conversion/converter.go create mode 100644 pkg/conversion/converter_test.go create mode 100644 pkg/conversion/deferred_converter.go create mode 100644 pkg/conversion/deferred_converter_test.go create mode 100644 pkg/conversion/embedded_test.go create mode 100644 pkg/conversion/widgets-crd.yaml create mode 100644 test/e2e/conversion/conversion_test.go create mode 100644 test/e2e/conversion/resources.yaml create mode 100644 test/e2e/conversion/v1-widget-no-last-name.yaml create mode 100644 test/e2e/conversion/v1-widget.yaml create mode 100644 test/e2e/conversion/v2-widget.yaml diff --git a/config/crds/apis.kcp.io_apiconversions.yaml b/config/crds/apis.kcp.io_apiconversions.yaml new file mode 100644 index 000000000000..19061676f710 --- /dev/null +++ b/config/crds/apis.kcp.io_apiconversions.yaml @@ -0,0 +1,124 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.10.0 + creationTimestamp: null + name: apiconversions.apis.kcp.io +spec: + group: apis.kcp.io + names: + categories: + - kcp + kind: APIConversion + listKind: APIConversionList + plural: apiconversions + singular: apiconversion + scope: Cluster + versions: + - additionalPrinterColumns: + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: APIConversion contains rules to convert between different API + versions in an APIResourceSchema. The name must match the name of the APIResourceSchema + for the conversions to take effect. + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation + of an object. Servers should convert recognized schemas to the latest + internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + kind: + description: 'Kind is a string value representing the REST resource this + object represents. Servers may infer this from the endpoint the client + submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + spec: + description: Spec holds the desired state. + properties: + conversions: + description: conversions specify rules to convert between different + API versions in an APIResourceSchema. + items: + description: APIVersionConversion contains rules to convert between + two specific API versions in an APIResourceSchema. Additionally, + to avoid data loss when round-tripping from a version that contains + a new field to one that doesn't and back again, you can specify + a list of fields to preserve (these are stored in annotations). + properties: + from: + description: from is the source version. + minLength: 1 + pattern: ^v[1-9][0-9]*([a-z]+[1-9][0-9]*)?$ + type: string + preserve: + description: preserve contains a list of JSONPath expressions + to fields to preserve in the originating version of the object, + relative to its root, such as '.spec.name.first'. + items: + type: string + type: array + rules: + description: rules contains field-specific conversion expressions. + items: + description: APIConversionRule specifies how to convert a + single field. + properties: + destination: + description: destination is a JSONPath expression to the + field in the target version of the object, relative + to its root, such as '.spec.name.first'. + minLength: 1 + type: string + field: + description: field is a JSONPath expression to the field + in the originating version of the object, relative to + its root, such as '.spec.name.first'. + minLength: 1 + type: string + transformation: + description: transformation is an optional CEL expression + used to execute user-specified rules to transform the + originating field -- identified by 'self' -- to the + destination field. + type: string + required: + - destination + - field + type: object + type: array + x-kubernetes-list-map-keys: + - destination + x-kubernetes-list-type: map + to: + description: to is the target version. + minLength: 1 + pattern: ^v[1-9][0-9]*([a-z]+[1-9][0-9]*)?$ + type: string + required: + - from + - rules + - to + type: object + type: array + x-kubernetes-list-map-keys: + - from + - to + x-kubernetes-list-type: map + required: + - conversions + type: object + required: + - metadata + - spec + type: object + served: true + storage: true + subresources: {} diff --git a/config/crds/apis.kcp.io_apiresourceschemas.yaml b/config/crds/apis.kcp.io_apiresourceschemas.yaml index 7cf315ed25ff..718cd4392bab 100644 --- a/config/crds/apis.kcp.io_apiresourceschemas.yaml +++ b/config/crds/apis.kcp.io_apiresourceschemas.yaml @@ -25,7 +25,7 @@ spec: schema: openAPIV3Schema: description: "APIResourceSchema describes a resource, identified by (group, - version, resource, schema). \n A APIResourceSchema is immutable and cannot + version, resource, schema). \n An APIResourceSchema is immutable and cannot be deleted if they are referenced by an APIExport in the same workspace." properties: apiVersion: diff --git a/config/system-crds/bootstrap.go b/config/system-crds/bootstrap.go index 9b3127eef093..cc03a9272e85 100644 --- a/config/system-crds/bootstrap.go +++ b/config/system-crds/bootstrap.go @@ -55,6 +55,7 @@ func Bootstrap(ctx context.Context, crdClient apiextensionsclient.Interface, dis {Group: apis.GroupName, Resource: "apiresourceschemas"}, {Group: apis.GroupName, Resource: "apiexportendpointslices"}, {Group: core.GroupName, Resource: "logicalclusters"}, + {Group: apis.GroupName, Resource: "apiconversions"}, } if err := wait.PollImmediateInfiniteWithContext(ctx, time.Second, func(ctx context.Context) (bool, error) { diff --git a/go.mod b/go.mod index 5d1af690019d..7bc39c83c6af 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/evanphx/json-patch v5.6.0+incompatible github.com/fatih/color v1.12.0 github.com/go-logr/logr v1.2.3 + github.com/google/cel-go v0.12.6 github.com/google/go-cmp v0.5.8 github.com/google/uuid v1.3.0 github.com/kcp-dev/apimachinery/v2 v2.0.0-alpha.0 @@ -34,6 +35,7 @@ require ( go.etcd.io/etcd/server/v3 v3.5.0 go.uber.org/multierr v1.7.0 golang.org/x/net v0.0.0-20220722155237-a158d28d115b + google.golang.org/genproto v0.0.0-20220519153652-3a47de7e79bd gopkg.in/square/go-jose.v2 v2.2.2 k8s.io/api v0.24.3 k8s.io/apiextensions-apiserver v0.24.3 @@ -92,7 +94,6 @@ require ( github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.2 // indirect github.com/google/btree v1.0.1 // indirect - github.com/google/cel-go v0.12.6 // indirect github.com/google/gnostic v0.5.7-v3refs // indirect github.com/google/gofuzz v1.2.0 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect @@ -165,7 +166,6 @@ require ( golang.org/x/tools v0.1.12 // indirect gonum.org/v1/gonum v0.6.2 // indirect google.golang.org/appengine v1.6.7 // indirect - google.golang.org/genproto v0.0.0-20220519153652-3a47de7e79bd // indirect google.golang.org/grpc v1.46.2 // indirect google.golang.org/protobuf v1.28.1 // indirect gopkg.in/inf.v0 v0.9.1 // indirect diff --git a/pkg/admission/apiconversion/apiconversion_admission.go b/pkg/admission/apiconversion/apiconversion_admission.go new file mode 100644 index 000000000000..bffd0529f177 --- /dev/null +++ b/pkg/admission/apiconversion/apiconversion_admission.go @@ -0,0 +1,144 @@ +/* +Copyright 2022 The KCP 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 apiconversion + +import ( + "context" + "fmt" + "io" + + "github.com/kcp-dev/logicalcluster/v3" + + apiextensionsinternal "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + structuralschema "k8s.io/apiextensions-apiserver/pkg/apiserver/schema" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/apiserver/pkg/admission" + genericapirequest "k8s.io/apiserver/pkg/endpoints/request" + + "github.com/kcp-dev/kcp/pkg/admission/initializers" + apisv1alpha1 "github.com/kcp-dev/kcp/pkg/apis/apis/v1alpha1" + kcpinformers "github.com/kcp-dev/kcp/pkg/client/informers/externalversions" + "github.com/kcp-dev/kcp/pkg/conversion" + "github.com/kcp-dev/kcp/pkg/reconciler/apis/apibinding" +) + +const ( + PluginName = "apis.kcp.io/APIConversion" +) + +func Register(plugins *admission.Plugins) { + plugins.Register(PluginName, + func(_ io.Reader) (admission.Interface, error) { + return &apiConversionAdmission{ + Handler: admission.NewHandler(admission.Create, admission.Update), + }, nil + }) +} + +type apiConversionAdmission struct { + *admission.Handler + + getAPIResourceSchema func(clusterName logicalcluster.Name, name string) (*apisv1alpha1.APIResourceSchema, error) +} + +// Ensure that the required admission interfaces are implemented. +var ( + _ admission.ValidationInterface = (*apiConversionAdmission)(nil) + _ admission.InitializationValidator = (*apiConversionAdmission)(nil) + _ initializers.WantsKcpInformers = (*apiConversionAdmission)(nil) +) + +// Validate ensures all the conversion rules specified in an APIConversion are correct. +func (o *apiConversionAdmission) Validate(ctx context.Context, a admission.Attributes, _ admission.ObjectInterfaces) error { + if a.GetResource().GroupResource() != apisv1alpha1.Resource("apiconversions") { + return nil + } + + cluster, err := genericapirequest.ValidClusterFrom(ctx) + if err != nil { + return admission.NewForbidden(a, fmt.Errorf("error determining workspace: %w", err)) + } + + if cluster.Name == apibinding.SystemBoundCRDsClusterName { + // TODO(ncdc): do we also want to validate these conversions? They've already been validated once (in their + // original logical cluster). + return nil + } + + u, ok := a.GetObject().(*unstructured.Unstructured) + if !ok { + return fmt.Errorf("unexpected type %T", a.GetObject()) + } + + apiResourceSchema, err := o.getAPIResourceSchema(cluster.Name, u.GetName()) + if err != nil { + return admission.NewForbidden(a, fmt.Errorf("error getting APIResourceSchema %s: %w", u.GetName(), err)) + } + + apiConversion := &apisv1alpha1.APIConversion{} + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, apiConversion); err != nil { + return fmt.Errorf("failed to convert unstructured to APIConversion: %w", err) + } + + structuralSchemas := map[string]*structuralschema.Structural{} + for i, v := range apiResourceSchema.Spec.Versions { + schema, err := v.GetSchema() + if err != nil { + return admission.NewForbidden(a, field.Required(field.NewPath("spec", "versions").Index(i).Child("schema"), "is required")) + } + + internalJSONSchemaProps := &apiextensionsinternal.JSONSchemaProps{} + if err := apiextensionsv1.Convert_v1_JSONSchemaProps_To_apiextensions_JSONSchemaProps(schema, internalJSONSchemaProps, nil); err != nil { + return fmt.Errorf("failed converting version %s validation to internal version: %w", v.Name, err) + } + + structuralSchema, err := structuralschema.NewStructural(internalJSONSchemaProps) + if err != nil { + return fmt.Errorf("error getting structural schema for version %s: %w", v.Name, err) + } + + structuralSchemas[v.Name] = structuralSchema + } + + if _, err := conversion.Compile(apiConversion, structuralSchemas); err != nil { + return fmt.Errorf("error compiling conversion rules: %w", err) + } + + return nil +} + +// ValidateInitialization ensures the required injected fields are set. +func (o *apiConversionAdmission) ValidateInitialization() error { + if o.getAPIResourceSchema == nil { + return fmt.Errorf("getAPIResourceSchema is unset") + } + + return nil +} + +func (o *apiConversionAdmission) SetKcpInformers(local, global kcpinformers.SharedInformerFactory) { + o.getAPIResourceSchema = func(clusterName logicalcluster.Name, name string) (*apisv1alpha1.APIResourceSchema, error) { + apiResourceSchema, err := local.Apis().V1alpha1().APIResourceSchemas().Lister().Cluster(clusterName).Get(name) + if err == nil { + return apiResourceSchema, nil + } + return global.Apis().V1alpha1().APIResourceSchemas().Lister().Cluster(clusterName).Get(name) + } +} diff --git a/pkg/admission/apiresourceschema/admission_test.go b/pkg/admission/apiresourceschema/admission_test.go index 00649893b8d7..83bbb735c355 100644 --- a/pkg/admission/apiresourceschema/admission_test.go +++ b/pkg/admission/apiresourceschema/admission_test.go @@ -105,7 +105,7 @@ spec: `)), expectedErrors: []string{ "metadata.name: Invalid value: \"may.2022.cowboys.wild.west\": must match ^[a-z]([-a-z0-9]*[a-z0-9])?$ in front of .cowboys.wild.west", - "spec.versions[0].schema: Required value: schemas are required", + "spec.versions[0].schema: Required value", "spec.versions[1].schema.openAPIV3Schema.type: Unsupported value: \"thing\": supported values: \"array\", \"boolean\", \"integer\", \"number\", \"object\", \"string\"", "spec.versions[1].schema.openAPIV3Schema.type: Invalid value: \"thing\": must be object at the root", "spec.names.singular: Required value", diff --git a/pkg/admission/apiresourceschema/validation.go b/pkg/admission/apiresourceschema/validation.go index 11a05f520e72..4b5d6b0815a9 100644 --- a/pkg/admission/apiresourceschema/validation.go +++ b/pkg/admission/apiresourceschema/validation.go @@ -158,7 +158,6 @@ func ValidateAPIResourceSchemaSpec(ctx context.Context, spec *apisv1alpha1.APIRe } // TODO(sttts): validate predecessors - // TODO(sttts): validate conversions return allErrs } @@ -187,7 +186,7 @@ func ValidateAPIResourceVersion(ctx context.Context, version *apisv1alpha1.APIRe } if len(version.Schema.Raw) == 0 || string(version.Schema.Raw) == "null" { - allErrs = append(allErrs, field.Required(fldPath.Child("schema"), "schemas are required")) + allErrs = append(allErrs, field.Required(fldPath.Child("schema"), "")) } else { statusEnabled := version.Subresources.Status != nil var crdSchemaV1 apiextensionsv1.CustomResourceValidation diff --git a/pkg/admission/plugins.go b/pkg/admission/plugins.go index 9e6a61254fe0..518763f69449 100644 --- a/pkg/admission/plugins.go +++ b/pkg/admission/plugins.go @@ -41,6 +41,7 @@ import ( "github.com/kcp-dev/kcp/pkg/admission/apibinding" "github.com/kcp-dev/kcp/pkg/admission/apibindingfinalizer" + "github.com/kcp-dev/kcp/pkg/admission/apiconversion" "github.com/kcp-dev/kcp/pkg/admission/apiexport" "github.com/kcp-dev/kcp/pkg/admission/apiexportendpointslice" "github.com/kcp-dev/kcp/pkg/admission/apiresourceschema" @@ -68,6 +69,7 @@ import ( var AllOrderedPlugins = beforeWebhooks(kubeapiserveroptions.AllOrderedPlugins, workspacenamespacelifecycle.PluginName, apiresourceschema.PluginName, + apiconversion.PluginName, workspace.PluginName, logicalclusterfinalizer.PluginName, shard.PluginName, @@ -114,6 +116,7 @@ func RegisterAllKcpAdmissionPlugins(plugins *admission.Plugins) { logicalcluster.Register(plugins) apiresourceschema.Register(plugins) apiexport.Register(plugins) + apiconversion.Register(plugins) apibinding.Register(plugins) apibindingfinalizer.Register(plugins) apiexportendpointslice.Register(plugins) @@ -147,6 +150,7 @@ var defaultOnPluginsInKcp = sets.NewString( logicalcluster.PluginName, apiresourceschema.PluginName, apiexport.PluginName, + apiconversion.PluginName, apibinding.PluginName, apibindingfinalizer.PluginName, apiexportendpointslice.PluginName, diff --git a/pkg/apis/apis/v1alpha1/register.go b/pkg/apis/apis/v1alpha1/register.go index 0f9b1f7d96ba..b05bfd69d76a 100644 --- a/pkg/apis/apis/v1alpha1/register.go +++ b/pkg/apis/apis/v1alpha1/register.go @@ -56,6 +56,9 @@ func addKnownTypes(scheme *runtime.Scheme) error { &APIExportEndpointSlice{}, &APIExportEndpointSliceList{}, + + &APIConversion{}, + &APIConversionList{}, ) metav1.AddToGroupVersion(scheme, SchemeGroupVersion) return nil diff --git a/pkg/apis/apis/v1alpha1/types_apiresourceschema.go b/pkg/apis/apis/v1alpha1/types_apiresourceschema.go index d4b4d30efab0..8a6df057db96 100644 --- a/pkg/apis/apis/v1alpha1/types_apiresourceschema.go +++ b/pkg/apis/apis/v1alpha1/types_apiresourceschema.go @@ -26,7 +26,7 @@ import ( // APIResourceSchema describes a resource, identified by (group, version, resource, schema). // -// A APIResourceSchema is immutable and cannot be deleted if they are referenced by +// An APIResourceSchema is immutable and cannot be deleted if they are referenced by // an APIExport in the same workspace. // // +crd @@ -163,3 +163,104 @@ func (v *APIResourceVersion) SetSchema(schema *apiextensionsv1.JSONSchemaProps) v.Schema.Raw = raw return nil } + +const ( + // VersionPreservationAnnotationKeyPrefix is the prefix for the annotation key used to preserve fields from an API + // version that would otherwise be lost during round-tripping to a different API version. An example key and value + // might look like this: preserve.conversion.apis.kcp.io/v2: {"spec.someNewField": "someValue"}. + VersionPreservationAnnotationKeyPrefix = "preserve.conversion.apis.kcp.io/" +) + +// +crd +// +genclient +// +genclient:nonNamespaced +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +// +kubebuilder:resource:scope=Cluster,categories=kcp +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" + +// APIConversion contains rules to convert between different API versions in an APIResourceSchema. The name must match +// the name of the APIResourceSchema for the conversions to take effect. +type APIConversion struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata"` + + // Spec holds the desired state. + Spec APIConversionSpec `json:"spec"` +} + +// APIConversionSpec contains rules to convert between different API versions in an APIResourceSchema. +type APIConversionSpec struct { + // conversions specify rules to convert between different API versions in an APIResourceSchema. + // + // +required + // +listType=map + // +listMapKey=from + // +listMapKey=to + Conversions []APIVersionConversion `json:"conversions"` +} + +// APIVersionConversion contains rules to convert between two specific API versions in an +// APIResourceSchema. Additionally, to avoid data loss when round-tripping from a version that +// contains a new field to one that doesn't and back again, you can specify a list of fields to +// preserve (these are stored in annotations). +type APIVersionConversion struct { + // from is the source version. + // + // +required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:Pattern=^v[1-9][0-9]*([a-z]+[1-9][0-9]*)?$ + From string `json:"from"` + + // to is the target version. + // + // +required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:Pattern=^v[1-9][0-9]*([a-z]+[1-9][0-9]*)?$ + To string `json:"to"` + + // rules contains field-specific conversion expressions. + // + // +required + // +listType=map + // +listMapKey=destination + Rules []APIConversionRule `json:"rules"` + + // preserve contains a list of JSONPath expressions to fields to preserve in the originating version + // of the object, relative to its root, such as '.spec.name.first'. + // + // +optional + Preserve []string `json:"preserve,omitempty"` +} + +// APIConversionRule specifies how to convert a single field. +type APIConversionRule struct { + // field is a JSONPath expression to the field in the originating version of the object, relative to its root, such + // as '.spec.name.first'. + // + // +required + // +kubebuilder:validation:MinLength=1 + Field string `json:"field"` + + // destination is a JSONPath expression to the field in the target version of the object, relative to + // its root, such as '.spec.name.first'. + // + // +required + // +kubebuilder:validation:MinLength=1 + Destination string `json:"destination"` + + // transformation is an optional CEL expression used to execute user-specified rules to transform the + // originating field -- identified by 'self' -- to the destination field. + // + // +optional + Transformation string `json:"transformation,omitempty"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +// APIConversionList is a list of APIConversion resources. +type APIConversionList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata"` + + Items []APIConversion `json:"items"` +} diff --git a/pkg/apis/apis/v1alpha1/zz_generated.deepcopy.go b/pkg/apis/apis/v1alpha1/zz_generated.deepcopy.go index bfad59cdd68e..60066d9bfe43 100644 --- a/pkg/apis/apis/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/apis/v1alpha1/zz_generated.deepcopy.go @@ -158,6 +158,105 @@ func (in *APIBindingStatus) DeepCopy() *APIBindingStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *APIConversion) DeepCopyInto(out *APIConversion) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new APIConversion. +func (in *APIConversion) DeepCopy() *APIConversion { + if in == nil { + return nil + } + out := new(APIConversion) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *APIConversion) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *APIConversionList) DeepCopyInto(out *APIConversionList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]APIConversion, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new APIConversionList. +func (in *APIConversionList) DeepCopy() *APIConversionList { + if in == nil { + return nil + } + out := new(APIConversionList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *APIConversionList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *APIConversionRule) DeepCopyInto(out *APIConversionRule) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new APIConversionRule. +func (in *APIConversionRule) DeepCopy() *APIConversionRule { + if in == nil { + return nil + } + out := new(APIConversionRule) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *APIConversionSpec) DeepCopyInto(out *APIConversionSpec) { + *out = *in + if in.Conversions != nil { + in, out := &in.Conversions, &out.Conversions + *out = make([]APIVersionConversion, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new APIConversionSpec. +func (in *APIConversionSpec) DeepCopy() *APIConversionSpec { + if in == nil { + return nil + } + out := new(APIConversionSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *APIExport) DeepCopyInto(out *APIExport) { *out = *in @@ -519,6 +618,32 @@ func (in *APIResourceVersion) DeepCopy() *APIResourceVersion { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *APIVersionConversion) DeepCopyInto(out *APIVersionConversion) { + *out = *in + if in.Rules != nil { + in, out := &in.Rules, &out.Rules + *out = make([]APIConversionRule, len(*in)) + copy(*out, *in) + } + if in.Preserve != nil { + in, out := &in.Preserve, &out.Preserve + *out = make([]string, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new APIVersionConversion. +func (in *APIVersionConversion) DeepCopy() *APIVersionConversion { + if in == nil { + return nil + } + out := new(APIVersionConversion) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *AcceptablePermissionClaim) DeepCopyInto(out *AcceptablePermissionClaim) { *out = *in diff --git a/pkg/cache/server/bootstrap/bootstrap.go b/pkg/cache/server/bootstrap/bootstrap.go index 572c4a50d6d9..7299c7ab271a 100644 --- a/pkg/cache/server/bootstrap/bootstrap.go +++ b/pkg/cache/server/bootstrap/bootstrap.go @@ -45,6 +45,7 @@ func Bootstrap(ctx context.Context, apiExtensionsClusterClient kcpapiextensionsc crds := []*apiextensionsv1.CustomResourceDefinition{} for _, gr := range []struct{ group, resource string }{ {"apis.kcp.io", "apiresourceschemas"}, + {"apis.kcp.io", "apiconversions"}, {"apis.kcp.io", "apiexports"}, {"core.kcp.io", "shards"}, {"tenancy.kcp.io", "workspacetypes"}, diff --git a/pkg/cache/server/config.go b/pkg/cache/server/config.go index 7a29c94102a4..35b99b7786a1 100644 --- a/pkg/cache/server/config.go +++ b/pkg/cache/server/config.go @@ -17,16 +17,15 @@ limitations under the License. package server import ( - "errors" "fmt" "net/http" - "net/url" "strings" "time" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" apiextensionsv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" apiextensionsapiserver "k8s.io/apiextensions-apiserver/pkg/apiserver" + "k8s.io/apiextensions-apiserver/pkg/apiserver/conversion" kcpapiextensionsclientset "k8s.io/apiextensions-apiserver/pkg/client/kcp/clientset/versioned" kcpapiextensionsinformers "k8s.io/apiextensions-apiserver/pkg/client/kcp/informers/externalversions" apiextensionsoptions "k8s.io/apiextensions-apiserver/pkg/cmd/server/options" @@ -36,7 +35,6 @@ import ( genericapiserver "k8s.io/apiserver/pkg/server" genericoptions "k8s.io/apiserver/pkg/server/options" utilfeature "k8s.io/apiserver/pkg/util/feature" - "k8s.io/apiserver/pkg/util/webhook" "k8s.io/client-go/rest" cacheclient "github.com/kcp-dev/kcp/pkg/cache/client" @@ -65,8 +63,7 @@ type completedConfig struct { } type ExtraConfig struct { - ApiExtensionsClusterClient kcpapiextensionsclientset.ClusterInterface - + ApiExtensionsClusterClient kcpapiextensionsclientset.ClusterInterface ApiExtensionsSharedInformerFactory kcpapiextensionsinformers.SharedInformerFactory } @@ -203,29 +200,25 @@ func NewConfig(opts *cacheserveroptions.CompletedOptions, optionalLocalShardRest c.ApiExtensions = &apiextensionsapiserver.Config{ GenericConfig: serverConfig, ExtraConfig: apiextensionsapiserver.ExtraConfig{ - CRDRESTOptionsGetter: apiextensionsoptions.NewCRDRESTOptionsGetter(*opts.Etcd), - // Wire in a ServiceResolver that always returns an error that ResolveEndpoint is not yet - // supported. The effect is that CRD webhook conversions are not supported and will always get an - // error. - ServiceResolver: &unimplementedServiceResolver{}, + CRDRESTOptionsGetter: apiextensionsoptions.NewCRDRESTOptionsGetter(*opts.Etcd), MasterCount: 1, - AuthResolverWrapper: webhook.NewDefaultAuthenticationInfoResolverWrapper(nil, nil, rt, nil), Client: c.ApiExtensionsClusterClient, Informers: c.ApiExtensionsSharedInformerFactory, ClusterAwareCRDLister: &crdClusterLister{lister: c.ApiExtensionsSharedInformerFactory.Apiextensions().V1().CustomResourceDefinitions().Lister()}, DisableServerSideApply: true, + ConversionFactory: &nopCRConversionFactory{}, }, } return c, nil } -// unimplementedServiceResolver is a webhook.ServiceResolver that always returns an error, because -// we have not implemented support for this yet. As a result, CRD webhook conversions are not -// supported. -type unimplementedServiceResolver struct{} +// nopCRConversionFactory implements conversion.Factory and always returns a no-op converter because we currently have +// no need to perform CR conversions in the cache server. +type nopCRConversionFactory struct{} -// ResolveEndpoint always returns an error that this is not yet supported. -func (r *unimplementedServiceResolver) ResolveEndpoint(namespace string, name string, port int32) (*url.URL, error) { - return nil, errors.New("CRD webhook conversions are not supported") +// NewConverter always returns a no-op converter because we currently have no need to perform CR conversions in the +// cache server. +func (n nopCRConversionFactory) NewConverter(_ *apiextensionsv1.CustomResourceDefinition) (conversion.CRConverter, error) { + return conversion.NewNOPConverter(), nil } diff --git a/pkg/cache/server/server.go b/pkg/cache/server/server.go index 5cf9de354169..53a9d3424966 100644 --- a/pkg/cache/server/server.go +++ b/pkg/cache/server/server.go @@ -67,11 +67,13 @@ func (s *Server) PrepareRun(ctx context.Context) (preparedServer, error) { if err := s.apiextensions.GenericAPIServer.AddPostStartHook("cache-server-start-informers", func(hookContext genericapiserver.PostStartHookContext) error { logger := logger.WithValues("postStartHook", "cache-server-start-informers") s.ApiExtensionsSharedInformerFactory.Start(hookContext.StopCh) + select { case <-hookContext.StopCh: return nil // context closed, avoid reporting success below default: } + logger.Info("finished starting kube informers") return nil }); err != nil { diff --git a/pkg/client/clientset/versioned/cluster/typed/apis/v1alpha1/apiconversion.go b/pkg/client/clientset/versioned/cluster/typed/apis/v1alpha1/apiconversion.go new file mode 100644 index 000000000000..9587e29f8a33 --- /dev/null +++ b/pkg/client/clientset/versioned/cluster/typed/apis/v1alpha1/apiconversion.go @@ -0,0 +1,72 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +/* +Copyright The KCP 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. +*/ + +// Code generated by kcp code-generator. DO NOT EDIT. + +package v1alpha1 + +import ( + "context" + + kcpclient "github.com/kcp-dev/apimachinery/v2/pkg/client" + "github.com/kcp-dev/logicalcluster/v3" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/watch" + + apisv1alpha1 "github.com/kcp-dev/kcp/pkg/apis/apis/v1alpha1" + apisv1alpha1client "github.com/kcp-dev/kcp/pkg/client/clientset/versioned/typed/apis/v1alpha1" +) + +// APIConversionsClusterGetter has a method to return a APIConversionClusterInterface. +// A group's cluster client should implement this interface. +type APIConversionsClusterGetter interface { + APIConversions() APIConversionClusterInterface +} + +// APIConversionClusterInterface can operate on APIConversions across all clusters, +// or scope down to one cluster and return a apisv1alpha1client.APIConversionInterface. +type APIConversionClusterInterface interface { + Cluster(logicalcluster.Path) apisv1alpha1client.APIConversionInterface + List(ctx context.Context, opts metav1.ListOptions) (*apisv1alpha1.APIConversionList, error) + Watch(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) +} + +type aPIConversionsClusterInterface struct { + clientCache kcpclient.Cache[*apisv1alpha1client.ApisV1alpha1Client] +} + +// Cluster scopes the client down to a particular cluster. +func (c *aPIConversionsClusterInterface) Cluster(clusterPath logicalcluster.Path) apisv1alpha1client.APIConversionInterface { + if clusterPath == logicalcluster.Wildcard { + panic("A specific cluster must be provided when scoping, not the wildcard.") + } + + return c.clientCache.ClusterOrDie(clusterPath).APIConversions() +} + +// List returns the entire collection of all APIConversions across all clusters. +func (c *aPIConversionsClusterInterface) List(ctx context.Context, opts metav1.ListOptions) (*apisv1alpha1.APIConversionList, error) { + return c.clientCache.ClusterOrDie(logicalcluster.Wildcard).APIConversions().List(ctx, opts) +} + +// Watch begins to watch all APIConversions across all clusters. +func (c *aPIConversionsClusterInterface) Watch(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) { + return c.clientCache.ClusterOrDie(logicalcluster.Wildcard).APIConversions().Watch(ctx, opts) +} diff --git a/pkg/client/clientset/versioned/cluster/typed/apis/v1alpha1/apis_client.go b/pkg/client/clientset/versioned/cluster/typed/apis/v1alpha1/apis_client.go index 290da3304139..5e48b203d259 100644 --- a/pkg/client/clientset/versioned/cluster/typed/apis/v1alpha1/apis_client.go +++ b/pkg/client/clientset/versioned/cluster/typed/apis/v1alpha1/apis_client.go @@ -38,6 +38,7 @@ type ApisV1alpha1ClusterInterface interface { APIExportsClusterGetter APIExportEndpointSlicesClusterGetter APIResourceSchemasClusterGetter + APIConversionsClusterGetter } type ApisV1alpha1ClusterScoper interface { @@ -71,6 +72,10 @@ func (c *ApisV1alpha1ClusterClient) APIResourceSchemas() APIResourceSchemaCluste return &aPIResourceSchemasClusterInterface{clientCache: c.clientCache} } +func (c *ApisV1alpha1ClusterClient) APIConversions() APIConversionClusterInterface { + return &aPIConversionsClusterInterface{clientCache: c.clientCache} +} + // NewForConfig creates a new ApisV1alpha1ClusterClient for the given config. // NewForConfig is equivalent to NewForConfigAndClient(c, httpClient), // where httpClient was generated with rest.HTTPClientFor(c). diff --git a/pkg/client/clientset/versioned/cluster/typed/apis/v1alpha1/fake/apiconversion.go b/pkg/client/clientset/versioned/cluster/typed/apis/v1alpha1/fake/apiconversion.go new file mode 100644 index 000000000000..944c2cee9511 --- /dev/null +++ b/pkg/client/clientset/versioned/cluster/typed/apis/v1alpha1/fake/apiconversion.go @@ -0,0 +1,161 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +/* +Copyright The KCP 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. +*/ + +// Code generated by kcp code-generator. DO NOT EDIT. + +package v1alpha1 + +import ( + "context" + + "github.com/kcp-dev/logicalcluster/v3" + + kcptesting "github.com/kcp-dev/client-go/third_party/k8s.io/client-go/testing" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/testing" + + apisv1alpha1 "github.com/kcp-dev/kcp/pkg/apis/apis/v1alpha1" + apisv1alpha1client "github.com/kcp-dev/kcp/pkg/client/clientset/versioned/typed/apis/v1alpha1" +) + +var aPIConversionsResource = schema.GroupVersionResource{Group: "apis.kcp.io", Version: "v1alpha1", Resource: "apiconversions"} +var aPIConversionsKind = schema.GroupVersionKind{Group: "apis.kcp.io", Version: "v1alpha1", Kind: "APIConversion"} + +type aPIConversionsClusterClient struct { + *kcptesting.Fake +} + +// Cluster scopes the client down to a particular cluster. +func (c *aPIConversionsClusterClient) Cluster(clusterPath logicalcluster.Path) apisv1alpha1client.APIConversionInterface { + if clusterPath == logicalcluster.Wildcard { + panic("A specific cluster must be provided when scoping, not the wildcard.") + } + + return &aPIConversionsClient{Fake: c.Fake, ClusterPath: clusterPath} +} + +// List takes label and field selectors, and returns the list of APIConversions that match those selectors across all clusters. +func (c *aPIConversionsClusterClient) List(ctx context.Context, opts metav1.ListOptions) (*apisv1alpha1.APIConversionList, error) { + obj, err := c.Fake.Invokes(kcptesting.NewRootListAction(aPIConversionsResource, aPIConversionsKind, logicalcluster.Wildcard, opts), &apisv1alpha1.APIConversionList{}) + if obj == nil { + return nil, err + } + + label, _, _ := testing.ExtractFromListOptions(opts) + if label == nil { + label = labels.Everything() + } + list := &apisv1alpha1.APIConversionList{ListMeta: obj.(*apisv1alpha1.APIConversionList).ListMeta} + for _, item := range obj.(*apisv1alpha1.APIConversionList).Items { + if label.Matches(labels.Set(item.Labels)) { + list.Items = append(list.Items, item) + } + } + return list, err +} + +// Watch returns a watch.Interface that watches the requested APIConversions across all clusters. +func (c *aPIConversionsClusterClient) Watch(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) { + return c.Fake.InvokesWatch(kcptesting.NewRootWatchAction(aPIConversionsResource, logicalcluster.Wildcard, opts)) +} + +type aPIConversionsClient struct { + *kcptesting.Fake + ClusterPath logicalcluster.Path +} + +func (c *aPIConversionsClient) Create(ctx context.Context, aPIConversion *apisv1alpha1.APIConversion, opts metav1.CreateOptions) (*apisv1alpha1.APIConversion, error) { + obj, err := c.Fake.Invokes(kcptesting.NewRootCreateAction(aPIConversionsResource, c.ClusterPath, aPIConversion), &apisv1alpha1.APIConversion{}) + if obj == nil { + return nil, err + } + return obj.(*apisv1alpha1.APIConversion), err +} + +func (c *aPIConversionsClient) Update(ctx context.Context, aPIConversion *apisv1alpha1.APIConversion, opts metav1.UpdateOptions) (*apisv1alpha1.APIConversion, error) { + obj, err := c.Fake.Invokes(kcptesting.NewRootUpdateAction(aPIConversionsResource, c.ClusterPath, aPIConversion), &apisv1alpha1.APIConversion{}) + if obj == nil { + return nil, err + } + return obj.(*apisv1alpha1.APIConversion), err +} + +func (c *aPIConversionsClient) UpdateStatus(ctx context.Context, aPIConversion *apisv1alpha1.APIConversion, opts metav1.UpdateOptions) (*apisv1alpha1.APIConversion, error) { + obj, err := c.Fake.Invokes(kcptesting.NewRootUpdateSubresourceAction(aPIConversionsResource, c.ClusterPath, "status", aPIConversion), &apisv1alpha1.APIConversion{}) + if obj == nil { + return nil, err + } + return obj.(*apisv1alpha1.APIConversion), err +} + +func (c *aPIConversionsClient) Delete(ctx context.Context, name string, opts metav1.DeleteOptions) error { + _, err := c.Fake.Invokes(kcptesting.NewRootDeleteActionWithOptions(aPIConversionsResource, c.ClusterPath, name, opts), &apisv1alpha1.APIConversion{}) + return err +} + +func (c *aPIConversionsClient) DeleteCollection(ctx context.Context, opts metav1.DeleteOptions, listOpts metav1.ListOptions) error { + action := kcptesting.NewRootDeleteCollectionAction(aPIConversionsResource, c.ClusterPath, listOpts) + + _, err := c.Fake.Invokes(action, &apisv1alpha1.APIConversionList{}) + return err +} + +func (c *aPIConversionsClient) Get(ctx context.Context, name string, options metav1.GetOptions) (*apisv1alpha1.APIConversion, error) { + obj, err := c.Fake.Invokes(kcptesting.NewRootGetAction(aPIConversionsResource, c.ClusterPath, name), &apisv1alpha1.APIConversion{}) + if obj == nil { + return nil, err + } + return obj.(*apisv1alpha1.APIConversion), err +} + +// List takes label and field selectors, and returns the list of APIConversions that match those selectors. +func (c *aPIConversionsClient) List(ctx context.Context, opts metav1.ListOptions) (*apisv1alpha1.APIConversionList, error) { + obj, err := c.Fake.Invokes(kcptesting.NewRootListAction(aPIConversionsResource, aPIConversionsKind, c.ClusterPath, opts), &apisv1alpha1.APIConversionList{}) + if obj == nil { + return nil, err + } + + label, _, _ := testing.ExtractFromListOptions(opts) + if label == nil { + label = labels.Everything() + } + list := &apisv1alpha1.APIConversionList{ListMeta: obj.(*apisv1alpha1.APIConversionList).ListMeta} + for _, item := range obj.(*apisv1alpha1.APIConversionList).Items { + if label.Matches(labels.Set(item.Labels)) { + list.Items = append(list.Items, item) + } + } + return list, err +} + +func (c *aPIConversionsClient) Watch(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) { + return c.Fake.InvokesWatch(kcptesting.NewRootWatchAction(aPIConversionsResource, c.ClusterPath, opts)) +} + +func (c *aPIConversionsClient) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts metav1.PatchOptions, subresources ...string) (*apisv1alpha1.APIConversion, error) { + obj, err := c.Fake.Invokes(kcptesting.NewRootPatchSubresourceAction(aPIConversionsResource, c.ClusterPath, name, pt, data, subresources...), &apisv1alpha1.APIConversion{}) + if obj == nil { + return nil, err + } + return obj.(*apisv1alpha1.APIConversion), err +} diff --git a/pkg/client/clientset/versioned/cluster/typed/apis/v1alpha1/fake/apis_client.go b/pkg/client/clientset/versioned/cluster/typed/apis/v1alpha1/fake/apis_client.go index cef0276dc3fc..622eba8a7dcf 100644 --- a/pkg/client/clientset/versioned/cluster/typed/apis/v1alpha1/fake/apis_client.go +++ b/pkg/client/clientset/versioned/cluster/typed/apis/v1alpha1/fake/apis_client.go @@ -60,6 +60,10 @@ func (c *ApisV1alpha1ClusterClient) APIResourceSchemas() kcpapisv1alpha1.APIReso return &aPIResourceSchemasClusterClient{Fake: c.Fake} } +func (c *ApisV1alpha1ClusterClient) APIConversions() kcpapisv1alpha1.APIConversionClusterInterface { + return &aPIConversionsClusterClient{Fake: c.Fake} +} + var _ apisv1alpha1.ApisV1alpha1Interface = (*ApisV1alpha1Client)(nil) type ApisV1alpha1Client struct { @@ -87,3 +91,7 @@ func (c *ApisV1alpha1Client) APIExportEndpointSlices() apisv1alpha1.APIExportEnd func (c *ApisV1alpha1Client) APIResourceSchemas() apisv1alpha1.APIResourceSchemaInterface { return &aPIResourceSchemasClient{Fake: c.Fake, ClusterPath: c.ClusterPath} } + +func (c *ApisV1alpha1Client) APIConversions() apisv1alpha1.APIConversionInterface { + return &aPIConversionsClient{Fake: c.Fake, ClusterPath: c.ClusterPath} +} diff --git a/pkg/client/clientset/versioned/typed/apis/v1alpha1/apiconversion.go b/pkg/client/clientset/versioned/typed/apis/v1alpha1/apiconversion.go new file mode 100644 index 000000000000..abdc032edf03 --- /dev/null +++ b/pkg/client/clientset/versioned/typed/apis/v1alpha1/apiconversion.go @@ -0,0 +1,169 @@ +/* +Copyright The KCP 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. +*/ + +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + "context" + "time" + + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + rest "k8s.io/client-go/rest" + + v1alpha1 "github.com/kcp-dev/kcp/pkg/apis/apis/v1alpha1" + scheme "github.com/kcp-dev/kcp/pkg/client/clientset/versioned/scheme" +) + +// APIConversionsGetter has a method to return a APIConversionInterface. +// A group's client should implement this interface. +type APIConversionsGetter interface { + APIConversions() APIConversionInterface +} + +// APIConversionInterface has methods to work with APIConversion resources. +type APIConversionInterface interface { + Create(ctx context.Context, aPIConversion *v1alpha1.APIConversion, opts v1.CreateOptions) (*v1alpha1.APIConversion, error) + Update(ctx context.Context, aPIConversion *v1alpha1.APIConversion, opts v1.UpdateOptions) (*v1alpha1.APIConversion, error) + Delete(ctx context.Context, name string, opts v1.DeleteOptions) error + DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error + Get(ctx context.Context, name string, opts v1.GetOptions) (*v1alpha1.APIConversion, error) + List(ctx context.Context, opts v1.ListOptions) (*v1alpha1.APIConversionList, error) + Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) + Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *v1alpha1.APIConversion, err error) + APIConversionExpansion +} + +// aPIConversions implements APIConversionInterface +type aPIConversions struct { + client rest.Interface +} + +// newAPIConversions returns a APIConversions +func newAPIConversions(c *ApisV1alpha1Client) *aPIConversions { + return &aPIConversions{ + client: c.RESTClient(), + } +} + +// Get takes name of the aPIConversion, and returns the corresponding aPIConversion object, and an error if there is any. +func (c *aPIConversions) Get(ctx context.Context, name string, options v1.GetOptions) (result *v1alpha1.APIConversion, err error) { + result = &v1alpha1.APIConversion{} + err = c.client.Get(). + Resource("apiconversions"). + Name(name). + VersionedParams(&options, scheme.ParameterCodec). + Do(ctx). + Into(result) + return +} + +// List takes label and field selectors, and returns the list of APIConversions that match those selectors. +func (c *aPIConversions) List(ctx context.Context, opts v1.ListOptions) (result *v1alpha1.APIConversionList, err error) { + var timeout time.Duration + if opts.TimeoutSeconds != nil { + timeout = time.Duration(*opts.TimeoutSeconds) * time.Second + } + result = &v1alpha1.APIConversionList{} + err = c.client.Get(). + Resource("apiconversions"). + VersionedParams(&opts, scheme.ParameterCodec). + Timeout(timeout). + Do(ctx). + Into(result) + return +} + +// Watch returns a watch.Interface that watches the requested aPIConversions. +func (c *aPIConversions) Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) { + var timeout time.Duration + if opts.TimeoutSeconds != nil { + timeout = time.Duration(*opts.TimeoutSeconds) * time.Second + } + opts.Watch = true + return c.client.Get(). + Resource("apiconversions"). + VersionedParams(&opts, scheme.ParameterCodec). + Timeout(timeout). + Watch(ctx) +} + +// Create takes the representation of a aPIConversion and creates it. Returns the server's representation of the aPIConversion, and an error, if there is any. +func (c *aPIConversions) Create(ctx context.Context, aPIConversion *v1alpha1.APIConversion, opts v1.CreateOptions) (result *v1alpha1.APIConversion, err error) { + result = &v1alpha1.APIConversion{} + err = c.client.Post(). + Resource("apiconversions"). + VersionedParams(&opts, scheme.ParameterCodec). + Body(aPIConversion). + Do(ctx). + Into(result) + return +} + +// Update takes the representation of a aPIConversion and updates it. Returns the server's representation of the aPIConversion, and an error, if there is any. +func (c *aPIConversions) Update(ctx context.Context, aPIConversion *v1alpha1.APIConversion, opts v1.UpdateOptions) (result *v1alpha1.APIConversion, err error) { + result = &v1alpha1.APIConversion{} + err = c.client.Put(). + Resource("apiconversions"). + Name(aPIConversion.Name). + VersionedParams(&opts, scheme.ParameterCodec). + Body(aPIConversion). + Do(ctx). + Into(result) + return +} + +// Delete takes name of the aPIConversion and deletes it. Returns an error if one occurs. +func (c *aPIConversions) Delete(ctx context.Context, name string, opts v1.DeleteOptions) error { + return c.client.Delete(). + Resource("apiconversions"). + Name(name). + Body(&opts). + Do(ctx). + Error() +} + +// DeleteCollection deletes a collection of objects. +func (c *aPIConversions) DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error { + var timeout time.Duration + if listOpts.TimeoutSeconds != nil { + timeout = time.Duration(*listOpts.TimeoutSeconds) * time.Second + } + return c.client.Delete(). + Resource("apiconversions"). + VersionedParams(&listOpts, scheme.ParameterCodec). + Timeout(timeout). + Body(&opts). + Do(ctx). + Error() +} + +// Patch applies the patch and returns the patched aPIConversion. +func (c *aPIConversions) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *v1alpha1.APIConversion, err error) { + result = &v1alpha1.APIConversion{} + err = c.client.Patch(pt). + Resource("apiconversions"). + Name(name). + SubResource(subresources...). + VersionedParams(&opts, scheme.ParameterCodec). + Body(data). + Do(ctx). + Into(result) + return +} diff --git a/pkg/client/clientset/versioned/typed/apis/v1alpha1/apis_client.go b/pkg/client/clientset/versioned/typed/apis/v1alpha1/apis_client.go index 0ff87edc786e..14d34af215ba 100644 --- a/pkg/client/clientset/versioned/typed/apis/v1alpha1/apis_client.go +++ b/pkg/client/clientset/versioned/typed/apis/v1alpha1/apis_client.go @@ -30,6 +30,7 @@ import ( type ApisV1alpha1Interface interface { RESTClient() rest.Interface APIBindingsGetter + APIConversionsGetter APIExportsGetter APIExportEndpointSlicesGetter APIResourceSchemasGetter @@ -44,6 +45,10 @@ func (c *ApisV1alpha1Client) APIBindings() APIBindingInterface { return newAPIBindings(c) } +func (c *ApisV1alpha1Client) APIConversions() APIConversionInterface { + return newAPIConversions(c) +} + func (c *ApisV1alpha1Client) APIExports() APIExportInterface { return newAPIExports(c) } diff --git a/pkg/client/clientset/versioned/typed/apis/v1alpha1/fake/fake_apiconversion.go b/pkg/client/clientset/versioned/typed/apis/v1alpha1/fake/fake_apiconversion.go new file mode 100644 index 000000000000..3858117838c1 --- /dev/null +++ b/pkg/client/clientset/versioned/typed/apis/v1alpha1/fake/fake_apiconversion.go @@ -0,0 +1,123 @@ +/* +Copyright The KCP 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. +*/ + +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + "context" + + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + labels "k8s.io/apimachinery/pkg/labels" + schema "k8s.io/apimachinery/pkg/runtime/schema" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + testing "k8s.io/client-go/testing" + + v1alpha1 "github.com/kcp-dev/kcp/pkg/apis/apis/v1alpha1" +) + +// FakeAPIConversions implements APIConversionInterface +type FakeAPIConversions struct { + Fake *FakeApisV1alpha1 +} + +var apiconversionsResource = schema.GroupVersionResource{Group: "apis.kcp.io", Version: "v1alpha1", Resource: "apiconversions"} + +var apiconversionsKind = schema.GroupVersionKind{Group: "apis.kcp.io", Version: "v1alpha1", Kind: "APIConversion"} + +// Get takes name of the aPIConversion, and returns the corresponding aPIConversion object, and an error if there is any. +func (c *FakeAPIConversions) Get(ctx context.Context, name string, options v1.GetOptions) (result *v1alpha1.APIConversion, err error) { + obj, err := c.Fake. + Invokes(testing.NewRootGetAction(apiconversionsResource, name), &v1alpha1.APIConversion{}) + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.APIConversion), err +} + +// List takes label and field selectors, and returns the list of APIConversions that match those selectors. +func (c *FakeAPIConversions) List(ctx context.Context, opts v1.ListOptions) (result *v1alpha1.APIConversionList, err error) { + obj, err := c.Fake. + Invokes(testing.NewRootListAction(apiconversionsResource, apiconversionsKind, opts), &v1alpha1.APIConversionList{}) + if obj == nil { + return nil, err + } + + label, _, _ := testing.ExtractFromListOptions(opts) + if label == nil { + label = labels.Everything() + } + list := &v1alpha1.APIConversionList{ListMeta: obj.(*v1alpha1.APIConversionList).ListMeta} + for _, item := range obj.(*v1alpha1.APIConversionList).Items { + if label.Matches(labels.Set(item.Labels)) { + list.Items = append(list.Items, item) + } + } + return list, err +} + +// Watch returns a watch.Interface that watches the requested aPIConversions. +func (c *FakeAPIConversions) Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) { + return c.Fake. + InvokesWatch(testing.NewRootWatchAction(apiconversionsResource, opts)) +} + +// Create takes the representation of a aPIConversion and creates it. Returns the server's representation of the aPIConversion, and an error, if there is any. +func (c *FakeAPIConversions) Create(ctx context.Context, aPIConversion *v1alpha1.APIConversion, opts v1.CreateOptions) (result *v1alpha1.APIConversion, err error) { + obj, err := c.Fake. + Invokes(testing.NewRootCreateAction(apiconversionsResource, aPIConversion), &v1alpha1.APIConversion{}) + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.APIConversion), err +} + +// Update takes the representation of a aPIConversion and updates it. Returns the server's representation of the aPIConversion, and an error, if there is any. +func (c *FakeAPIConversions) Update(ctx context.Context, aPIConversion *v1alpha1.APIConversion, opts v1.UpdateOptions) (result *v1alpha1.APIConversion, err error) { + obj, err := c.Fake. + Invokes(testing.NewRootUpdateAction(apiconversionsResource, aPIConversion), &v1alpha1.APIConversion{}) + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.APIConversion), err +} + +// Delete takes name of the aPIConversion and deletes it. Returns an error if one occurs. +func (c *FakeAPIConversions) Delete(ctx context.Context, name string, opts v1.DeleteOptions) error { + _, err := c.Fake. + Invokes(testing.NewRootDeleteActionWithOptions(apiconversionsResource, name, opts), &v1alpha1.APIConversion{}) + return err +} + +// DeleteCollection deletes a collection of objects. +func (c *FakeAPIConversions) DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error { + action := testing.NewRootDeleteCollectionAction(apiconversionsResource, listOpts) + + _, err := c.Fake.Invokes(action, &v1alpha1.APIConversionList{}) + return err +} + +// Patch applies the patch and returns the patched aPIConversion. +func (c *FakeAPIConversions) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *v1alpha1.APIConversion, err error) { + obj, err := c.Fake. + Invokes(testing.NewRootPatchSubresourceAction(apiconversionsResource, name, pt, data, subresources...), &v1alpha1.APIConversion{}) + if obj == nil { + return nil, err + } + return obj.(*v1alpha1.APIConversion), err +} diff --git a/pkg/client/clientset/versioned/typed/apis/v1alpha1/fake/fake_apis_client.go b/pkg/client/clientset/versioned/typed/apis/v1alpha1/fake/fake_apis_client.go index 5852e129fa4d..c3a574243d65 100644 --- a/pkg/client/clientset/versioned/typed/apis/v1alpha1/fake/fake_apis_client.go +++ b/pkg/client/clientset/versioned/typed/apis/v1alpha1/fake/fake_apis_client.go @@ -33,6 +33,10 @@ func (c *FakeApisV1alpha1) APIBindings() v1alpha1.APIBindingInterface { return &FakeAPIBindings{c} } +func (c *FakeApisV1alpha1) APIConversions() v1alpha1.APIConversionInterface { + return &FakeAPIConversions{c} +} + func (c *FakeApisV1alpha1) APIExports() v1alpha1.APIExportInterface { return &FakeAPIExports{c} } diff --git a/pkg/client/clientset/versioned/typed/apis/v1alpha1/generated_expansion.go b/pkg/client/clientset/versioned/typed/apis/v1alpha1/generated_expansion.go index 15fb1ac0dd4f..98bef60385fc 100644 --- a/pkg/client/clientset/versioned/typed/apis/v1alpha1/generated_expansion.go +++ b/pkg/client/clientset/versioned/typed/apis/v1alpha1/generated_expansion.go @@ -20,6 +20,8 @@ package v1alpha1 type APIBindingExpansion interface{} +type APIConversionExpansion interface{} + type APIExportExpansion interface{} type APIExportEndpointSliceExpansion interface{} diff --git a/pkg/client/informers/externalversions/apis/v1alpha1/apiconversion.go b/pkg/client/informers/externalversions/apis/v1alpha1/apiconversion.go new file mode 100644 index 000000000000..4c5fb5fd7b37 --- /dev/null +++ b/pkg/client/informers/externalversions/apis/v1alpha1/apiconversion.go @@ -0,0 +1,179 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +/* +Copyright The KCP 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. +*/ + +// Code generated by kcp code-generator. DO NOT EDIT. + +package v1alpha1 + +import ( + "context" + "time" + + kcpcache "github.com/kcp-dev/apimachinery/v2/pkg/cache" + kcpinformers "github.com/kcp-dev/apimachinery/v2/third_party/informers" + "github.com/kcp-dev/logicalcluster/v3" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/tools/cache" + + apisv1alpha1 "github.com/kcp-dev/kcp/pkg/apis/apis/v1alpha1" + scopedclientset "github.com/kcp-dev/kcp/pkg/client/clientset/versioned" + clientset "github.com/kcp-dev/kcp/pkg/client/clientset/versioned/cluster" + "github.com/kcp-dev/kcp/pkg/client/informers/externalversions/internalinterfaces" + apisv1alpha1listers "github.com/kcp-dev/kcp/pkg/client/listers/apis/v1alpha1" +) + +// APIConversionClusterInformer provides access to a shared informer and lister for +// APIConversions. +type APIConversionClusterInformer interface { + Cluster(logicalcluster.Name) APIConversionInformer + Informer() kcpcache.ScopeableSharedIndexInformer + Lister() apisv1alpha1listers.APIConversionClusterLister +} + +type aPIConversionClusterInformer struct { + factory internalinterfaces.SharedInformerFactory + tweakListOptions internalinterfaces.TweakListOptionsFunc +} + +// NewAPIConversionClusterInformer constructs a new informer for APIConversion type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewAPIConversionClusterInformer(client clientset.ClusterInterface, resyncPeriod time.Duration, indexers cache.Indexers) kcpcache.ScopeableSharedIndexInformer { + return NewFilteredAPIConversionClusterInformer(client, resyncPeriod, indexers, nil) +} + +// NewFilteredAPIConversionClusterInformer constructs a new informer for APIConversion type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewFilteredAPIConversionClusterInformer(client clientset.ClusterInterface, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) kcpcache.ScopeableSharedIndexInformer { + return kcpinformers.NewSharedIndexInformer( + &cache.ListWatch{ + ListFunc: func(options metav1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.ApisV1alpha1().APIConversions().List(context.TODO(), options) + }, + WatchFunc: func(options metav1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.ApisV1alpha1().APIConversions().Watch(context.TODO(), options) + }, + }, + &apisv1alpha1.APIConversion{}, + resyncPeriod, + indexers, + ) +} + +func (f *aPIConversionClusterInformer) defaultInformer(client clientset.ClusterInterface, resyncPeriod time.Duration) kcpcache.ScopeableSharedIndexInformer { + return NewFilteredAPIConversionClusterInformer(client, resyncPeriod, cache.Indexers{ + kcpcache.ClusterIndexName: kcpcache.ClusterIndexFunc, + }, + f.tweakListOptions, + ) +} + +func (f *aPIConversionClusterInformer) Informer() kcpcache.ScopeableSharedIndexInformer { + return f.factory.InformerFor(&apisv1alpha1.APIConversion{}, f.defaultInformer) +} + +func (f *aPIConversionClusterInformer) Lister() apisv1alpha1listers.APIConversionClusterLister { + return apisv1alpha1listers.NewAPIConversionClusterLister(f.Informer().GetIndexer()) +} + +// APIConversionInformer provides access to a shared informer and lister for +// APIConversions. +type APIConversionInformer interface { + Informer() cache.SharedIndexInformer + Lister() apisv1alpha1listers.APIConversionLister +} + +func (f *aPIConversionClusterInformer) Cluster(clusterName logicalcluster.Name) APIConversionInformer { + return &aPIConversionInformer{ + informer: f.Informer().Cluster(clusterName), + lister: f.Lister().Cluster(clusterName), + } +} + +type aPIConversionInformer struct { + informer cache.SharedIndexInformer + lister apisv1alpha1listers.APIConversionLister +} + +func (f *aPIConversionInformer) Informer() cache.SharedIndexInformer { + return f.informer +} + +func (f *aPIConversionInformer) Lister() apisv1alpha1listers.APIConversionLister { + return f.lister +} + +type aPIConversionScopedInformer struct { + factory internalinterfaces.SharedScopedInformerFactory + tweakListOptions internalinterfaces.TweakListOptionsFunc +} + +func (f *aPIConversionScopedInformer) Informer() cache.SharedIndexInformer { + return f.factory.InformerFor(&apisv1alpha1.APIConversion{}, f.defaultInformer) +} + +func (f *aPIConversionScopedInformer) Lister() apisv1alpha1listers.APIConversionLister { + return apisv1alpha1listers.NewAPIConversionLister(f.Informer().GetIndexer()) +} + +// NewAPIConversionInformer constructs a new informer for APIConversion type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewAPIConversionInformer(client scopedclientset.Interface, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { + return NewFilteredAPIConversionInformer(client, resyncPeriod, indexers, nil) +} + +// NewFilteredAPIConversionInformer constructs a new informer for APIConversion type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewFilteredAPIConversionInformer(client scopedclientset.Interface, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer { + return cache.NewSharedIndexInformer( + &cache.ListWatch{ + ListFunc: func(options metav1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.ApisV1alpha1().APIConversions().List(context.TODO(), options) + }, + WatchFunc: func(options metav1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.ApisV1alpha1().APIConversions().Watch(context.TODO(), options) + }, + }, + &apisv1alpha1.APIConversion{}, + resyncPeriod, + indexers, + ) +} + +func (f *aPIConversionScopedInformer) defaultInformer(client scopedclientset.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { + return NewFilteredAPIConversionInformer(client, resyncPeriod, cache.Indexers{}, f.tweakListOptions) +} diff --git a/pkg/client/informers/externalversions/apis/v1alpha1/interface.go b/pkg/client/informers/externalversions/apis/v1alpha1/interface.go index ffadf9ca898e..d7beb6c55747 100644 --- a/pkg/client/informers/externalversions/apis/v1alpha1/interface.go +++ b/pkg/client/informers/externalversions/apis/v1alpha1/interface.go @@ -34,6 +34,8 @@ type ClusterInterface interface { APIExportEndpointSlices() APIExportEndpointSliceClusterInformer // APIResourceSchemas returns a APIResourceSchemaClusterInformer APIResourceSchemas() APIResourceSchemaClusterInformer + // APIConversions returns a APIConversionClusterInformer + APIConversions() APIConversionClusterInformer } type version struct { @@ -66,6 +68,11 @@ func (v *version) APIResourceSchemas() APIResourceSchemaClusterInformer { return &aPIResourceSchemaClusterInformer{factory: v.factory, tweakListOptions: v.tweakListOptions} } +// APIConversions returns a APIConversionClusterInformer +func (v *version) APIConversions() APIConversionClusterInformer { + return &aPIConversionClusterInformer{factory: v.factory, tweakListOptions: v.tweakListOptions} +} + type Interface interface { // APIBindings returns a APIBindingInformer APIBindings() APIBindingInformer @@ -75,6 +82,8 @@ type Interface interface { APIExportEndpointSlices() APIExportEndpointSliceInformer // APIResourceSchemas returns a APIResourceSchemaInformer APIResourceSchemas() APIResourceSchemaInformer + // APIConversions returns a APIConversionInformer + APIConversions() APIConversionInformer } type scopedVersion struct { @@ -107,3 +116,8 @@ func (v *scopedVersion) APIExportEndpointSlices() APIExportEndpointSliceInformer func (v *scopedVersion) APIResourceSchemas() APIResourceSchemaInformer { return &aPIResourceSchemaScopedInformer{factory: v.factory, tweakListOptions: v.tweakListOptions} } + +// APIConversions returns a APIConversionInformer +func (v *scopedVersion) APIConversions() APIConversionInformer { + return &aPIConversionScopedInformer{factory: v.factory, tweakListOptions: v.tweakListOptions} +} diff --git a/pkg/client/informers/externalversions/generic.go b/pkg/client/informers/externalversions/generic.go index 0cc9d517ae93..10cfc82b42bd 100644 --- a/pkg/client/informers/externalversions/generic.go +++ b/pkg/client/informers/externalversions/generic.go @@ -106,6 +106,8 @@ func (f *sharedInformerFactory) ForResource(resource schema.GroupVersionResource return &genericClusterInformer{resource: resource.GroupResource(), informer: f.Apis().V1alpha1().APIExportEndpointSlices().Informer()}, nil case apisv1alpha1.SchemeGroupVersion.WithResource("apiresourceschemas"): return &genericClusterInformer{resource: resource.GroupResource(), informer: f.Apis().V1alpha1().APIResourceSchemas().Informer()}, nil + case apisv1alpha1.SchemeGroupVersion.WithResource("apiconversions"): + return &genericClusterInformer{resource: resource.GroupResource(), informer: f.Apis().V1alpha1().APIConversions().Informer()}, nil // Group=core.kcp.io, Version=V1alpha1 case corev1alpha1.SchemeGroupVersion.WithResource("logicalclusters"): return &genericClusterInformer{resource: resource.GroupResource(), informer: f.Core().V1alpha1().LogicalClusters().Informer()}, nil @@ -158,6 +160,9 @@ func (f *sharedScopedInformerFactory) ForResource(resource schema.GroupVersionRe case apisv1alpha1.SchemeGroupVersion.WithResource("apiresourceschemas"): informer := f.Apis().V1alpha1().APIResourceSchemas().Informer() return &genericInformer{lister: cache.NewGenericLister(informer.GetIndexer(), resource.GroupResource()), informer: informer}, nil + case apisv1alpha1.SchemeGroupVersion.WithResource("apiconversions"): + informer := f.Apis().V1alpha1().APIConversions().Informer() + return &genericInformer{lister: cache.NewGenericLister(informer.GetIndexer(), resource.GroupResource()), informer: informer}, nil // Group=core.kcp.io, Version=V1alpha1 case corev1alpha1.SchemeGroupVersion.WithResource("logicalclusters"): informer := f.Core().V1alpha1().LogicalClusters().Informer() diff --git a/pkg/client/listers/apis/v1alpha1/apiconversion.go b/pkg/client/listers/apis/v1alpha1/apiconversion.go new file mode 100644 index 000000000000..ca35c8b5c6ed --- /dev/null +++ b/pkg/client/listers/apis/v1alpha1/apiconversion.go @@ -0,0 +1,143 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +/* +Copyright The KCP 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. +*/ + +// Code generated by kcp code-generator. DO NOT EDIT. + +package v1alpha1 + +import ( + kcpcache "github.com/kcp-dev/apimachinery/v2/pkg/cache" + "github.com/kcp-dev/logicalcluster/v3" + + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/client-go/tools/cache" + + apisv1alpha1 "github.com/kcp-dev/kcp/pkg/apis/apis/v1alpha1" +) + +// APIConversionClusterLister can list APIConversions across all workspaces, or scope down to a APIConversionLister for one workspace. +// All objects returned here must be treated as read-only. +type APIConversionClusterLister interface { + // List lists all APIConversions in the indexer. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*apisv1alpha1.APIConversion, err error) + // Cluster returns a lister that can list and get APIConversions in one workspace. + Cluster(clusterName logicalcluster.Name) APIConversionLister + APIConversionClusterListerExpansion +} + +type aPIConversionClusterLister struct { + indexer cache.Indexer +} + +// NewAPIConversionClusterLister returns a new APIConversionClusterLister. +// We assume that the indexer: +// - is fed by a cross-workspace LIST+WATCH +// - uses kcpcache.MetaClusterNamespaceKeyFunc as the key function +// - has the kcpcache.ClusterIndex as an index +func NewAPIConversionClusterLister(indexer cache.Indexer) *aPIConversionClusterLister { + return &aPIConversionClusterLister{indexer: indexer} +} + +// List lists all APIConversions in the indexer across all workspaces. +func (s *aPIConversionClusterLister) List(selector labels.Selector) (ret []*apisv1alpha1.APIConversion, err error) { + err = cache.ListAll(s.indexer, selector, func(m interface{}) { + ret = append(ret, m.(*apisv1alpha1.APIConversion)) + }) + return ret, err +} + +// Cluster scopes the lister to one workspace, allowing users to list and get APIConversions. +func (s *aPIConversionClusterLister) Cluster(clusterName logicalcluster.Name) APIConversionLister { + return &aPIConversionLister{indexer: s.indexer, clusterName: clusterName} +} + +// APIConversionLister can list all APIConversions, or get one in particular. +// All objects returned here must be treated as read-only. +type APIConversionLister interface { + // List lists all APIConversions in the workspace. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*apisv1alpha1.APIConversion, err error) + // Get retrieves the APIConversion from the indexer for a given workspace and name. + // Objects returned here must be treated as read-only. + Get(name string) (*apisv1alpha1.APIConversion, error) + APIConversionListerExpansion +} + +// aPIConversionLister can list all APIConversions inside a workspace. +type aPIConversionLister struct { + indexer cache.Indexer + clusterName logicalcluster.Name +} + +// List lists all APIConversions in the indexer for a workspace. +func (s *aPIConversionLister) List(selector labels.Selector) (ret []*apisv1alpha1.APIConversion, err error) { + err = kcpcache.ListAllByCluster(s.indexer, s.clusterName, selector, func(i interface{}) { + ret = append(ret, i.(*apisv1alpha1.APIConversion)) + }) + return ret, err +} + +// Get retrieves the APIConversion from the indexer for a given workspace and name. +func (s *aPIConversionLister) Get(name string) (*apisv1alpha1.APIConversion, error) { + key := kcpcache.ToClusterAwareKey(s.clusterName.String(), "", name) + obj, exists, err := s.indexer.GetByKey(key) + if err != nil { + return nil, err + } + if !exists { + return nil, errors.NewNotFound(apisv1alpha1.Resource("APIConversion"), name) + } + return obj.(*apisv1alpha1.APIConversion), nil +} + +// NewAPIConversionLister returns a new APIConversionLister. +// We assume that the indexer: +// - is fed by a workspace-scoped LIST+WATCH +// - uses cache.MetaNamespaceKeyFunc as the key function +func NewAPIConversionLister(indexer cache.Indexer) *aPIConversionScopedLister { + return &aPIConversionScopedLister{indexer: indexer} +} + +// aPIConversionScopedLister can list all APIConversions inside a workspace. +type aPIConversionScopedLister struct { + indexer cache.Indexer +} + +// List lists all APIConversions in the indexer for a workspace. +func (s *aPIConversionScopedLister) List(selector labels.Selector) (ret []*apisv1alpha1.APIConversion, err error) { + err = cache.ListAll(s.indexer, selector, func(i interface{}) { + ret = append(ret, i.(*apisv1alpha1.APIConversion)) + }) + return ret, err +} + +// Get retrieves the APIConversion from the indexer for a given workspace and name. +func (s *aPIConversionScopedLister) Get(name string) (*apisv1alpha1.APIConversion, error) { + key := name + obj, exists, err := s.indexer.GetByKey(key) + if err != nil { + return nil, err + } + if !exists { + return nil, errors.NewNotFound(apisv1alpha1.Resource("APIConversion"), name) + } + return obj.(*apisv1alpha1.APIConversion), nil +} diff --git a/pkg/client/listers/apis/v1alpha1/apiconversion_expansion.go b/pkg/client/listers/apis/v1alpha1/apiconversion_expansion.go new file mode 100644 index 000000000000..10edd8109f55 --- /dev/null +++ b/pkg/client/listers/apis/v1alpha1/apiconversion_expansion.go @@ -0,0 +1,28 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +/* +Copyright The KCP 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. +*/ + +// Code generated by kcp code-generator. DO NOT EDIT. + +package v1alpha1 + +// APIConversionClusterListerExpansion allows custom methods to be added to APIConversionClusterLister. +type APIConversionClusterListerExpansion interface{} + +// APIConversionListerExpansion allows custom methods to be added to APIConversionLister. +type APIConversionListerExpansion interface{} diff --git a/pkg/conversion/conversion_factory.go b/pkg/conversion/conversion_factory.go new file mode 100644 index 000000000000..73beff8e72f7 --- /dev/null +++ b/pkg/conversion/conversion_factory.go @@ -0,0 +1,76 @@ +/* +Copyright 2023 The KCP 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 conversion + +import ( + "strings" + "time" + + "github.com/kcp-dev/logicalcluster/v3" + + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apiextensions-apiserver/pkg/apiserver/conversion" + + apisv1alpha1 "github.com/kcp-dev/kcp/pkg/apis/apis/v1alpha1" + apisinformers "github.com/kcp-dev/kcp/pkg/client/informers/externalversions/apis/v1alpha1" +) + +// CRConverterFactory instantiates converters that are capable of converting custom resources between different API +// versions. It supports CEL-based conversion rules from APIConversion resources and the "none" conversion strategy. +type CRConverterFactory struct { + getAPIConversion func(clusterName logicalcluster.Name, name string) (*apisv1alpha1.APIConversion, error) + objectCELTransformationsTimeout time.Duration +} + +var _ conversion.Factory = &CRConverterFactory{} + +// NewCRConverterFactory returns a CRConverterFactory that supports APIConversion-based conversions and the "none" +// conversion strategy. +func NewCRConverterFactory( + apiConversionInformer apisinformers.APIConversionClusterInformer, + objectCELTransformationsTimeout time.Duration, +) *CRConverterFactory { + return &CRConverterFactory{ + getAPIConversion: func(clusterName logicalcluster.Name, name string) (*apisv1alpha1.APIConversion, error) { + return apiConversionInformer.Lister().Cluster(clusterName).Get(name) + }, + objectCELTransformationsTimeout: objectCELTransformationsTimeout, + } +} + +// NewConverter returns the appropriate conversion.Converter based on the CRD. If the CRD identifies as for a "wildcard +// partial metadata request", the nop converter is used. Otherwise, it returns a CEL-based converter if there is an +// associated APIConversion, a nop converter if the strategy is "none", or an error otherwise. +func (f *CRConverterFactory) NewConverter(crd *apiextensionsv1.CustomResourceDefinition) (conversion.CRConverter, error) { + // Wildcard, partial metadata requests never need conversion + if strings.HasSuffix(string(crd.UID), ".wildcard.partial-metadata") { + return conversion.NewNOPConverter(), nil + } + + // We have to return a deferredConverter here instead of an actual one because apiextensions-apiserver creates the + // converter once, when it creates serving info for a CRD. Because we aren't currently guaranteeing order (or cache + // freshness), there is always a chance that serving info is created for a CRD before an associated APIConversion + // exists and is present in the informer cache. + return &deferredConverter{ + crd: crd, + + getAPIConversion: f.getAPIConversion, + newConverter: func(crd *apiextensionsv1.CustomResourceDefinition, apiConversion *apisv1alpha1.APIConversion) (conversion.CRConverter, error) { + return NewConverter(crd, apiConversion, f.objectCELTransformationsTimeout) + }, + }, nil +} diff --git a/pkg/conversion/conversion_factory_test.go b/pkg/conversion/conversion_factory_test.go new file mode 100644 index 000000000000..bef5638b995c --- /dev/null +++ b/pkg/conversion/conversion_factory_test.go @@ -0,0 +1,50 @@ +/* +Copyright 2023 The KCP 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 conversion + +import ( + "testing" + + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/util/wait" + + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apiextensions-apiserver/pkg/apiserver/conversion" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +func TestNewCRConverterFactory(t *testing.T) { + f := NewCRConverterFactory(nil, wait.ForeverTestTimeout) + + crd := &apiextensionsv1.CustomResourceDefinition{ + ObjectMeta: metav1.ObjectMeta{ + UID: types.UID("foo.wildcard.partial-metadata"), + }, + } + + // Partial metadata CRDs must use a nopConverter + converter, err := f.NewConverter(crd) + require.NoError(t, err) + require.IsType(t, conversion.NewNOPConverter(), converter) + + // All other CRDs use a real converter + crd.UID = "" + converter, err = f.NewConverter(crd) + require.NoError(t, err) + require.IsType(t, &deferredConverter{}, converter) +} diff --git a/pkg/conversion/conversion_rules.go b/pkg/conversion/conversion_rules.go new file mode 100644 index 000000000000..ea01e9a069bf --- /dev/null +++ b/pkg/conversion/conversion_rules.go @@ -0,0 +1,236 @@ +/* +Copyright 2023 The KCP 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 conversion + +import ( + "fmt" + "strings" + "time" + + "github.com/google/cel-go/cel" + "github.com/google/cel-go/checker/decls" + expr "google.golang.org/genproto/googleapis/api/expr/v1alpha1" + + structuralschema "k8s.io/apiextensions-apiserver/pkg/apiserver/schema" + "k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/library" + "k8s.io/apiextensions-apiserver/third_party/forked/celopenapi/model" + kerrors "k8s.io/apimachinery/pkg/util/errors" + "k8s.io/apimachinery/pkg/util/validation/field" + + apisv1alpha1 "github.com/kcp-dev/kcp/pkg/apis/apis/v1alpha1" +) + +const celCheckFrequency = 100 + +// CompiledRule contains the compiled cel.Program to convert a single field. +type CompiledRule struct { + // FromPath is an expression specifying the field in the source version, such as spec.firstName. + FromPath string + // FromFields is FromPath split by '.', with any leading '.' removed. + FromFields []string + // ToPath is an expression specifying the destination field in the target version, such as spec.name.first. + ToPath string + // ToFields is ToPath split by '.', with any leading '.' removed. + ToFields []string + // Program is a compiled set of instructions for a conversion transformation. + Program cel.Program +} + +// Compile compiles conversion rules. +func Compile( + apiConversion *apisv1alpha1.APIConversion, + structuralSchemas map[string]*structuralschema.Structural, +) (map[string][]*CompiledRule, error) { + compiledRules := make(map[string][]*CompiledRule) + var errs []error + + for i := range apiConversion.Spec.Conversions { + c := apiConversion.Spec.Conversions[i] + path := field.NewPath("spec", "conversions").Index(i) + rulesForVersion, err := compileConversion(path, &c, structuralSchemas) + if err != nil { + errs = append(errs, err) + continue + } + + compiledRules[c.From] = rulesForVersion + } + + return compiledRules, kerrors.NewAggregate(errs) +} + +// compileConversion processes an APIVersionConversion, compiles any CEL transformations, and returns a slice of +// CompiledRule objects that the converter uses to perform conversions. +func compileConversion(path *field.Path, c *apisv1alpha1.APIVersionConversion, structuralSchemas map[string]*structuralschema.Structural) ([]*CompiledRule, error) { + rulesForVersion := make([]*CompiledRule, 0, len(c.Rules)) + + schema, exists := structuralSchemas[c.From] + if !exists { + return nil, field.Invalid(path.Child("from"), c.From, "unable to find structural schema for version") + } + + var errs []error + for j, rule := range c.Rules { + rulePath := path.Child("rules").Index(j) + cr, err := compileRule(rulePath, rule, schema) + if err != nil { + errs = append(errs, err) + continue + } + rulesForVersion = append(rulesForVersion, cr) + } + + return rulesForVersion, kerrors.NewAggregate(errs) +} + +// compileRule takes a single APIConversionRule, compiles the CEL transformation (if any), and converts the rule to a +// CompiledRule that the converter uses to perform conversions. +func compileRule(rulePath *field.Path, rule apisv1alpha1.APIConversionRule, schema *structuralschema.Structural) (*CompiledRule, error) { + // if rule.Field starts with a ".", such as .spec.fieldName, strip the leading "." + fromPath := rule.Field + if fromPath[0] == '.' { + fromPath = fromPath[1:] + } + + // if rule.Destination starts with a ".", such as .spec.fieldName, strip the leading "." + toPath := rule.Destination + if toPath[0] == '.' { + toPath = toPath[1:] + } + + cr := &CompiledRule{ + FromPath: fromPath, + FromFields: strings.Split(fromPath, "."), + ToPath: rule.Destination, + ToFields: strings.Split(toPath, "."), + } + + schemaForField, err := getStructuralSchemaForField(schema, cr.FromFields...) + if err != nil { + return nil, field.Invalid(rulePath.Child("field"), rule.Field, err.Error()) + } + + if rule.Transformation == "" { + return cr, nil + } + + env, err := createCELEnv(schemaForField) + if err != nil { + return nil, field.InternalError(rulePath.Child("field"), fmt.Errorf("error creating CEL environment: %w", err)) + } + + ast, issues := env.Compile(rule.Transformation) + if issues != nil { + return nil, field.Invalid(rulePath.Child("transformation"), rule.Transformation, fmt.Sprintf("error compiling CEL program: %v", issues.Err())) + } + + program, err := env.Program(ast, + cel.EvalOptions(cel.OptOptimize), + cel.OptimizeRegex(library.ExtensionLibRegexOptimizations...), + cel.InterruptCheckFrequency(celCheckFrequency), + ) + if err != nil { + return nil, field.Invalid(rulePath.Child("transformation"), rule.Transformation, fmt.Sprintf("error creating CEL program: %v", err)) + } + + cr.Program = program + + return cr, nil +} + +func createCELEnv(structuralSchema *structuralschema.Structural) (*cel.Env, error) { + env, err := cel.NewEnv(cel.HomogeneousAggregateLiterals()) + if err != nil { + return nil, fmt.Errorf("error creating CEL environment: %w", err) + } + + registry := model.NewRegistry(env) + + // inline local copy of upstream's generateUniqueSelfTypeName() + scopedTypeName := fmt.Sprintf("selfType%d", time.Now().Nanosecond()) + + ruleTypes, err := model.NewRuleTypes(scopedTypeName, structuralSchema, true, registry) + if err != nil { + return nil, fmt.Errorf("error creating rule types: %w", err) + } + if ruleTypes == nil { + return nil, fmt.Errorf("unexpected nil rule types") + } + + opts, err := ruleTypes.EnvOptions(env.TypeProvider()) + if err != nil { + return nil, fmt.Errorf("error getting CEL environment options: %w", err) + } + + root, ok := ruleTypes.FindDeclType(scopedTypeName) + if !ok { + rootDecl := model.SchemaDeclType(structuralSchema, true) + if rootDecl == nil { + return nil, fmt.Errorf("unable to find CEL decl type for %s", structuralSchema.Type) + } + root = rootDecl.MaybeAssignTypeName(scopedTypeName) + } + + var propDecls []*expr.Decl + propDecls = append(propDecls, decls.NewVar("self", root.ExprType())) + + opts = append(opts, cel.Declarations(propDecls...), cel.HomogeneousAggregateLiterals()) + opts = append(opts, library.ExtensionLibs...) + return env.Extend(opts...) +} + +func getStructuralSchemaForField(s *structuralschema.Structural, fields ...string) (*structuralschema.Structural, error) { + // Cursor keeps track of the current schema subtree as we navigate down through each field segment. We start at the + // root of the object (which has apiVersion, metadata, spec, status, etc.). + cursor := s + + // Keep track of which fields we've already visited so we can be specific in our errors + visited := make([]string, 0, len(fields)) + + // Starting with the initial field (e.g. "spec"), try to resolve the next segment (e.g. "name") until we get + // to the desired field (e.g. "first"). + for _, f := range fields { + // Verify that each intermediate field is an object. + if cursor.Type != "object" { + return nil, fmt.Errorf("expected field %q to be an object", strings.Join(visited, ".")) + } + + visited = append(visited, f) + + // First, check properties + if property, exists := cursor.Properties[f]; exists { + // If we found the field, update cursor to point at its schema, and continue to the next field segment + cursor = &property + continue + } + + // Second, check additional properties + if cursor.AdditionalProperties != nil && cursor.AdditionalProperties.Structural != nil { + if property, exists := cursor.AdditionalProperties.Structural.Properties[f]; exists { + // If we found the field, update cursor to point at its schema, and continue to the next field segment + cursor = &property + continue + } + } + + // The field didn't exist in either properties or additional properties + return nil, fmt.Errorf("field %q doesn't exist", strings.Join(visited, ".")) + } + + // Cursor is now set to the schema representing the desired field. + return cursor, nil +} diff --git a/pkg/conversion/conversion_rules_test.go b/pkg/conversion/conversion_rules_test.go new file mode 100644 index 000000000000..4d8107140088 --- /dev/null +++ b/pkg/conversion/conversion_rules_test.go @@ -0,0 +1,152 @@ +/* +Copyright 2023 The KCP 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 conversion + +import ( + "testing" + + "github.com/stretchr/testify/require" + + apiextensionsinternal "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + structuralschema "k8s.io/apiextensions-apiserver/pkg/apiserver/schema" + structuraldefaulting "k8s.io/apiextensions-apiserver/pkg/apiserver/schema/defaulting" + "k8s.io/apimachinery/pkg/util/validation/field" + "sigs.k8s.io/yaml" + + apisv1alpha1 "github.com/kcp-dev/kcp/pkg/apis/apis/v1alpha1" +) + +func TestCompileConversion(t *testing.T) { + t.Parallel() + + crdYAML, err := embeddedResources.ReadFile("widgets-crd.yaml") + require.NoError(t, err, "error reading widgets yaml") + + var crd apiextensionsv1.CustomResourceDefinition + err = yaml.Unmarshal(crdYAML, &crd) + require.NoError(t, err, "error unmarshalling crd") + + structuralSchemas := schemasForCRD(t, crd) + + tests := map[string]struct { + conversion apisv1alpha1.APIVersionConversion + expectedError string + }{ + "unable to find version": { + conversion: apisv1alpha1.APIVersionConversion{ + From: "vNotHere", + }, + expectedError: field.Invalid(field.NewPath("foo").Child("from"), "vNotHere", "unable to find structural schema for version").Error(), + }, + } + + for testName, tc := range tests { + // Needed to avoid t.Parallel() races + tc := tc + + t.Run(testName, func(t *testing.T) { + t.Parallel() + + _, err = compileConversion(field.NewPath("foo"), &tc.conversion, structuralSchemas) + if tc.expectedError != "" { + require.ErrorContains(t, err, tc.expectedError) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestCompileRule(t *testing.T) { + t.Parallel() + + crdYAML, err := embeddedResources.ReadFile("widgets-crd.yaml") + require.NoError(t, err, "error reading widgets yaml") + + var crd apiextensionsv1.CustomResourceDefinition + err = yaml.Unmarshal(crdYAML, &crd) + require.NoError(t, err, "error unmarshalling crd") + + structuralSchemas := schemasForCRD(t, crd) + + tests := map[string]struct { + field string + transformation string + want error + }{ + "invalid field path - root doesn't exist": { + field: "noFieldHere", + want: field.Invalid(field.NewPath("foo").Child("field"), "noFieldHere", `field "noFieldHere" doesn't exist`), + }, + "invalid field path - subpath doesn't exist": { + field: "spec.some.unknown.field", + want: field.Invalid(field.NewPath("foo").Child("field"), "spec.some.unknown.field", `field "spec.some" doesn't exist`), + }, + "invalid field path - parent not an object": { + field: "apiVersion.foo", + want: field.Invalid(field.NewPath("foo").Child("field"), "apiVersion.foo", `expected field "apiVersion" to be an object`), + }, + "invalid transformation": { + field: "spec.firstName", + transformation: "self.foo was here", + want: field.Invalid(field.NewPath("foo").Child("transformation"), "self.foo was here", "error compiling CEL program: ERROR: "), + }, + } + + for testName, tc := range tests { + tc := tc + + t.Run(testName, func(t *testing.T) { + t.Parallel() + + rule := apisv1alpha1.APIConversionRule{ + Field: tc.field, + Destination: "spec.someField", + Transformation: tc.transformation, + } + + cr, err := compileRule(field.NewPath("foo"), rule, structuralSchemas["v1"]) + require.Nil(t, cr) + require.Error(t, err) + require.ErrorContains(t, err, tc.want.Error()) + }) + } +} + +func schemasForCRD(t *testing.T, crd apiextensionsv1.CustomResourceDefinition) map[string]*structuralschema.Structural { + t.Helper() + + structuralSchemas := make(map[string]*structuralschema.Structural) + for _, v := range crd.Spec.Versions { + if v.Schema == nil { + continue + } + + internalJSONSchemaProps := &apiextensionsinternal.JSONSchemaProps{} + err := apiextensionsv1.Convert_v1_JSONSchemaProps_To_apiextensions_JSONSchemaProps(v.Schema.OpenAPIV3Schema, internalJSONSchemaProps, nil) + require.NoError(t, err, "failed converting version %s validation to internal version", v.Name) + + structuralSchema, err := structuralschema.NewStructural(internalJSONSchemaProps) + require.NoError(t, err, "error getting structural schema for version %s", v.Name) + + err = structuraldefaulting.PruneDefaults(structuralSchema) + require.NoError(t, err, "error pruning defaults for version %s", v.Name) + structuralSchemas[v.Name] = structuralSchema + } + return structuralSchemas +} diff --git a/pkg/conversion/converter.go b/pkg/conversion/converter.go new file mode 100644 index 000000000000..7337c91760a7 --- /dev/null +++ b/pkg/conversion/converter.go @@ -0,0 +1,261 @@ +/* +Copyright 2023 The KCP 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 conversion + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/google/cel-go/interpreter" + "github.com/prometheus/client_golang/prometheus" + + apiextensionsinternal "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + structuralschema "k8s.io/apiextensions-apiserver/pkg/apiserver/schema" + structuraldefaulting "k8s.io/apiextensions-apiserver/pkg/apiserver/schema/defaulting" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + compbasemetrics "k8s.io/component-base/metrics" + "k8s.io/component-base/metrics/legacyregistry" + + apisv1alpha1 "github.com/kcp-dev/kcp/pkg/apis/apis/v1alpha1" +) + +func init() { + legacyregistry.MustRegister(celTransformationDuration) +} + +var ( + celTransformationDuration = compbasemetrics.NewHistogramVec( + &compbasemetrics.HistogramOpts{ + Name: "conversion_cel_transformation_duration_seconds", + Help: "CEL transformation execution time distribution in seconds", + // From .001 to 16.384 seconds + Buckets: prometheus.ExponentialBuckets(.001, 2, 15), + StabilityLevel: compbasemetrics.ALPHA, + }, + []string{}, + ) +) + +func NewConverter( + crd *apiextensionsv1.CustomResourceDefinition, + apiConversion *apisv1alpha1.APIConversion, + objectCELTransformationsTimeout time.Duration, +) (*Converter, error) { + structuralSchemas := make(map[string]*structuralschema.Structural) + + // Gather all the structural schemas for easy map-based indexing + for _, v := range crd.Spec.Versions { + if v.Schema == nil { + continue + } + + internalJSONSchemaProps := &apiextensionsinternal.JSONSchemaProps{} + if err := apiextensionsv1.Convert_v1_JSONSchemaProps_To_apiextensions_JSONSchemaProps(v.Schema.OpenAPIV3Schema, internalJSONSchemaProps, nil); err != nil { + return nil, fmt.Errorf("failed converting version %s validation to internal version: %w", v.Name, err) + } + + structuralSchema, err := structuralschema.NewStructural(internalJSONSchemaProps) + if err != nil { + return nil, fmt.Errorf("error getting structural schema for version %s: %w", v.Name, err) + } + + if err := structuraldefaulting.PruneDefaults(structuralSchema); err != nil { + return nil, fmt.Errorf("error pruning defaults for version %s: %w", v.Name, err) + } + + structuralSchemas[v.Name] = structuralSchema + } + + compiledRules, err := Compile(apiConversion, structuralSchemas) + if err != nil { + return nil, fmt.Errorf("error compiling conversion rules: %w", err) + } + + return &Converter{ + apiConversion: apiConversion, + objectCELTransformationsTimeout: objectCELTransformationsTimeout, + + compiledRules: compiledRules, + }, nil +} + +type Converter struct { + apiConversion *apisv1alpha1.APIConversion + objectCELTransformationsTimeout time.Duration + + compiledRules map[string][]*CompiledRule +} + +func (c *Converter) Convert(list *unstructured.UnstructuredList, targetGV schema.GroupVersion) (*unstructured.UnstructuredList, error) { + // It would be ideal to have ctx passed in, but that is a major change to Kubernetes. + ctx := context.TODO() + + convertedList := &unstructured.UnstructuredList{} + for i := range list.Items { + original := &list.Items[i] + + converted, err := c.convert(ctx, original, targetGV) + if err != nil { + return nil, err + } + + convertedList.Items = append(convertedList.Items, *converted) + } + + return convertedList, nil +} + +func (c *Converter) convert(ctx context.Context, original *unstructured.Unstructured, targetGV schema.GroupVersion) (*unstructured.Unstructured, error) { + converted := original.DeepCopy() + converted.SetAPIVersion(targetGV.String()) + + originalVersion := original.GetObjectKind().GroupVersionKind().Version + compiledRules := c.compiledRules[originalVersion] + + ctx, cancel := context.WithTimeout(ctx, c.objectCELTransformationsTimeout) + defer cancel() + + start := time.Now() + for _, rule := range compiledRules { + value, err := evaluateRule(ctx, original, rule) + if err != nil { + return nil, fmt.Errorf("error converting: %w", err) + } + + if err := unstructured.SetNestedField(converted.Object, value, rule.ToFields...); err != nil { + return nil, fmt.Errorf("error setting destination field %q: %w", rule.ToPath, err) + } + } + celTransformationDuration.WithLabelValues().Observe(time.Since(start).Seconds()) + + annotations := converted.GetAnnotations() + if annotations == nil { + annotations = make(map[string]string) + } + + preserveAnnotation := annotations[preserveAnnotationKey(targetGV.Version)] + if preserveAnnotation != "" { + // If we're going to a version that already has the preserve annotation, restore from it + m := map[string]interface{}{} + if err := json.Unmarshal([]byte(preserveAnnotation), &m); err != nil { + return nil, fmt.Errorf("error unmarshaling preserve annotation: %v", err) + } + if err := restoreFromPreserveMap(m, converted.Object); err != nil { + return nil, fmt.Errorf("error processing preserve map: %w", err) + } + + // Don't save the preserve annotation + delete(annotations, preserveAnnotationKey(targetGV.Version)) + if len(annotations) == 0 { + converted.SetAnnotations(nil) + } else { + converted.SetAnnotations(annotations) + } + } else { + // Otherwise, store it + m, err := generatePreserveMap(original, c.apiConversion.Spec.Conversions) + if err != nil { + return nil, fmt.Errorf("error preserving fields: %w", err) + } + + if len(m) > 0 { + encoded, err := json.Marshal(m) + if err != nil { + return nil, fmt.Errorf("error encoding preserve map: %v", err) + } else { + annotations[preserveAnnotationKey(originalVersion)] = string(encoded) + converted.SetAnnotations(annotations) + } + } + } + + return converted, nil +} + +func preserveAnnotationKey(version string) string { + return apisv1alpha1.VersionPreservationAnnotationKeyPrefix + version +} + +func evaluateRule(ctx context.Context, original *unstructured.Unstructured, rule *CompiledRule) (interface{}, error) { + fromValue, exists, err := unstructured.NestedFieldNoCopy(original.Object, rule.FromFields...) + if err != nil { + return nil, fmt.Errorf("error getting source field %q: %w", rule.FromPath, err) + } + if !exists { + return nil, nil + } + + if rule.Program == nil { + return fromValue, nil + } + + bindings := map[string]interface{}{ + "self": fromValue, + } + activation, err := interpreter.NewActivation(bindings) + if err != nil { + return nil, fmt.Errorf("error creating CEL interpreter activation: %w", err) + } + + evalResult, _, err := rule.Program.ContextEval(ctx, activation) + if err != nil { + return nil, fmt.Errorf("error executing transformation: %w", err) + } + + return evalResult.Value(), nil +} + +func generatePreserveMap(original *unstructured.Unstructured, conversions []apisv1alpha1.APIVersionConversion) (map[string]interface{}, error) { + m := map[string]interface{}{} + originalVersion := original.GetObjectKind().GroupVersionKind().Version + + for _, conversion := range conversions { + if conversion.From != originalVersion { + continue + } + for _, field := range conversion.Preserve { + if field[0] == '.' { + field = field[1:] + } + v, exists, err := unstructured.NestedFieldNoCopy(original.Object, strings.Split(field, ".")...) + if err != nil { + return nil, fmt.Errorf("error getting field %q to preserve: %v", field, err) + } + if !exists { + continue + } + m[field] = v + } + } + + return m, nil +} + +func restoreFromPreserveMap(m map[string]interface{}, obj map[string]interface{}) error { + for field, val := range m { + if err := unstructured.SetNestedField(obj, val, strings.Split(field, ".")...); err != nil { + return fmt.Errorf("error setting nested field %q, value %q: %v", field, val, err) + } + } + + return nil +} diff --git a/pkg/conversion/converter_test.go b/pkg/conversion/converter_test.go new file mode 100644 index 000000000000..51da2ed21870 --- /dev/null +++ b/pkg/conversion/converter_test.go @@ -0,0 +1,295 @@ +/* +Copyright 2023 The KCP 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 conversion + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/util/wait" + + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/yaml" + + apisv1alpha1 "github.com/kcp-dev/kcp/pkg/apis/apis/v1alpha1" +) + +func TestPreserveMap(t *testing.T) { + originalYAML := ` +apiVersion: test.kcp.io/v2 +spec: + sub: + one: two + list: + - a + - b + ignore: this +` + originalMap := make(map[string]interface{}) + err := yaml.Unmarshal([]byte(originalYAML), &originalMap) + require.NoError(t, err) + original := &unstructured.Unstructured{Object: originalMap} + + conversions := []apisv1alpha1.APIVersionConversion{ + { + From: "v1", + Preserve: []string{"ignore"}, + }, + { + From: "v2", + Preserve: []string{"spec.sub.one", "spec.list"}, + }, + { + From: "v3", + Preserve: []string{"ignore"}, + }, + } + + m, err := generatePreserveMap(original, conversions) + require.NoError(t, err) + + restored := make(map[string]interface{}) + err = restoreFromPreserveMap(m, restored) + require.NoError(t, err) + + expectedYAML := ` +spec: + sub: + one: two + list: + - a + - b +` + + expected := make(map[string]interface{}) + err = yaml.Unmarshal([]byte(expectedYAML), &expected) + require.NoError(t, err) + require.Empty(t, cmp.Diff(expected, restored)) +} + +func Test_convert_noTransformations(t *testing.T) { + crd := decode[*apiextensionsv1.CustomResourceDefinition](t, ` +spec: + group: test.kcp.io + names: + kind: Widget + listKind: WidgetList + plural: widgets + singular: widget + scope: Cluster + versions: + - name: v1 + schema: + openAPIV3Schema: + type: object + properties: + apiVersion: + type: string + spec: + type: object + properties: + unmodifiedV1Struct: + type: object + properties: + a: + type: string + b: + type: string + originalV1Name: + type: string + f1: + type: string + f2: + type: string + - name: v2 + schema: + openAPIV3Schema: + type: object + properties: + apiVersion: + type: string + spec: + type: object + properties: + unmodifiedV1Struct: + type: object + properties: + a: + type: string + b: + type: string + renamedInV2: + type: string + splitInV2: + type: object + properties: + f1: + type: string + f2: + type: string +`) + + // NOTE: this has both v1 and v2 fields because the converter does not do any pruning - that is handled by other + // server machinery. + expectedV1 := toUnstructured(t, ` +apiVersion: test.kcp.io/v1 +metadata: + name: foo + annotations: + preserve.conversion.apis.kcp.io/v2: "{\"spec.newInV2String\":\"new1\",\"spec.newInV2Struct\":{\"c\":\"d\"}}" +spec: + unmodifiedV1Struct: + a: b + originalV1Name: rename-1 + f1: v1 + f2: v2 + newInV2String: new1 + newInV2Struct: + c: d + renamedInV2: rename-1 + splitInV2: + f1: v1 + f2: v2 +`) + + v2 := toUnstructured(t, ` +apiVersion: test.kcp.io/v2 +metadata: + name: foo +spec: + unmodifiedV1Struct: + a: b + renamedInV2: rename-1 + splitInV2: + f1: v1 + f2: v2 + newInV2String: new1 + newInV2Struct: + c: d +`) + + conversion := &apisv1alpha1.APIConversion{ + Spec: apisv1alpha1.APIConversionSpec{ + Conversions: []apisv1alpha1.APIVersionConversion{ + { + From: "v1", + To: "v2", + Rules: []apisv1alpha1.APIConversionRule{ + { + Field: "spec.originalV1Name", + Destination: "spec.renamedInV2", + }, + { + Field: "spec.f1", + Destination: "spec.splitInV2.f1", + }, + { + Field: "spec.f2", + Destination: "spec.splitInV2.f2", + }, + }, + }, + { + From: "v2", + To: "v1", + Rules: []apisv1alpha1.APIConversionRule{ + { + Field: "spec.renamedInV2", + Destination: "spec.originalV1Name", + }, + { + Field: "spec.splitInV2.f1", + Destination: "spec.f1", + }, + { + Field: "spec.splitInV2.f2", + Destination: "spec.f2", + }, + }, + Preserve: []string{"spec.newInV2String", "spec.newInV2Struct"}, + }, + }, + }, + } + + converter, err := NewConverter(crd, conversion, wait.ForeverTestTimeout) + require.NoError(t, err, "error creating converter") + + ctx := context.Background() + converted, err := converter.convert(ctx, v2, schema.GroupVersion{Group: "test.kcp.io", Version: "v1"}) + require.NoError(t, err) + require.Empty(t, cmp.Diff(expectedV1, converted)) + + v1WithPreserveAnnotation := toUnstructured(t, ` +apiVersion: test.kcp.io/v1 +metadata: + name: foo + annotations: + preserve.conversion.apis.kcp.io/v2: "{\"spec.newInV2String\":\"new1\",\"spec.newInV2Struct\":{\"c\":\"d\"}}" +spec: + unmodifiedV1Struct: + a: b + originalV1Name: rename-1 + f1: v1 + f2: v2 +`) + + // NOTE: this has both v1 and v2 fields because the converter does not do any pruning - that is handled by other + // server machinery. + expectedV2 := toUnstructured(t, ` +apiVersion: test.kcp.io/v2 +metadata: + name: foo +spec: + unmodifiedV1Struct: + a: b + originalV1Name: rename-1 + f1: v1 + f2: v2 + newInV2String: new1 + newInV2Struct: + c: d + renamedInV2: rename-1 + splitInV2: + f1: v1 + f2: v2 +`) + + converted, err = converter.convert(ctx, v1WithPreserveAnnotation, schema.GroupVersion{Group: "test.kcp.io", Version: "v2"}) + require.NoError(t, err) + require.Empty(t, cmp.Diff(expectedV2, converted)) +} + +func toUnstructured(t *testing.T, data string) *unstructured.Unstructured { + t.Helper() + m := map[string]interface{}{} + err := yaml.Unmarshal([]byte(data), &m) + require.NoError(t, err, "error unmarshalling") + return &unstructured.Unstructured{Object: m} +} + +func decode[T runtime.Object](t *testing.T, data string) (ret T) { + t.Helper() + err := yaml.Unmarshal([]byte(data), &ret) + require.NoError(t, err) + return ret +} diff --git a/pkg/conversion/deferred_converter.go b/pkg/conversion/deferred_converter.go new file mode 100644 index 000000000000..a9c306edc09a --- /dev/null +++ b/pkg/conversion/deferred_converter.go @@ -0,0 +1,115 @@ +/* +Copyright 2023 The KCP 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 conversion + +import ( + "fmt" + "sync" + + "github.com/kcp-dev/logicalcluster/v3" + + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apiextensions-apiserver/pkg/apiserver/conversion" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/klog/v2" + + apisv1alpha1 "github.com/kcp-dev/kcp/pkg/apis/apis/v1alpha1" + "github.com/kcp-dev/kcp/pkg/logging" +) + +// deferredConverter implements conversion.Converter and determines on the fly what type of converter to use. +type deferredConverter struct { + crd *apiextensionsv1.CustomResourceDefinition + + // lock guards delegate + lock sync.Mutex + // delegate is the actual converter + delegate conversion.CRConverter + + getAPIConversion func(clusterName logicalcluster.Name, name string) (*apisv1alpha1.APIConversion, error) + newConverter func(crd *apiextensionsv1.CustomResourceDefinition, apiConversion *apisv1alpha1.APIConversion) (conversion.CRConverter, error) +} + +// Convert converts in to targetGV. If there is an APIConversion for this CRD, a CEL-based converter is used. Otherwise, +// a nop converter is used. +func (f *deferredConverter) Convert(in *unstructured.UnstructuredList, targetGV schema.GroupVersion) (*unstructured.UnstructuredList, error) { + converter, err := f.getConverter() + if err != nil { + return nil, fmt.Errorf("error getting converter: %w", err) + } + + return converter.Convert(in, targetGV) +} + +func (f *deferredConverter) getConverter() (conversion.CRConverter, error) { + f.lock.Lock() + defer f.lock.Unlock() + + if f.delegate != nil { + return f.delegate, nil + } + + var clusterName logicalcluster.Name + var conversionName string + _, boundCRD := f.crd.Annotations[apisv1alpha1.AnnotationBoundCRDKey] + clusterNameAnnotation := f.crd.Annotations[apisv1alpha1.AnnotationSchemaClusterKey] + schemaNameAnnotation := f.crd.Annotations[apisv1alpha1.AnnotationSchemaNameKey] + + switch { + case boundCRD && clusterNameAnnotation != "" && schemaNameAnnotation != "": + // If this is a bound CRD, we need to look up the APIConversion in the workspace where the APIResourceSchema + // lives. + clusterName = logicalcluster.Name(clusterNameAnnotation) + conversionName = schemaNameAnnotation + case !boundCRD: + // Otherwise, it has to be in the same place as the CRD + clusterName = logicalcluster.From(f.crd) + conversionName = f.crd.Name + default: + logging.WithObject(klog.Background(), f.crd).Error(nil, "unable to determine APIConversion name", + "boundCRD", boundCRD, + "crdSchemaClusterNameAnnotation", clusterNameAnnotation, + "crdSchemaNameAnnotation", schemaNameAnnotation, + ) + return nil, fmt.Errorf("unable to get converter") + } + + apiConversion, err := f.getAPIConversion(clusterName, conversionName) + if err != nil && !errors.IsNotFound(err) { + return nil, fmt.Errorf("error checking for APIConversion for CRD %s|%s: %w", clusterNameAnnotation, f.crd.Name, err) + } + if errors.IsNotFound(err) { + switch f.crd.Spec.Conversion.Strategy { + case apiextensionsv1.NoneConverter: + return conversion.NewNOPConverter(), nil + case apiextensionsv1.WebhookConverter: + return nil, fmt.Errorf("conversion strategy %q is not supported for CRD %s", f.crd.Spec.Conversion.Strategy, f.crd.Name) + default: + return nil, fmt.Errorf("unknown conversion strategy %q for CRD %s", f.crd.Spec.Conversion.Strategy, f.crd.Name) + } + } + + converter, err := f.newConverter(f.crd, apiConversion) + if err != nil { + return nil, fmt.Errorf("error creating converter for CRD %s|%s: %w", clusterNameAnnotation, f.crd.Name, err) + } + f.delegate = converter + + return converter, nil +} diff --git a/pkg/conversion/deferred_converter_test.go b/pkg/conversion/deferred_converter_test.go new file mode 100644 index 000000000000..e5e20efd4a61 --- /dev/null +++ b/pkg/conversion/deferred_converter_test.go @@ -0,0 +1,157 @@ +/* +Copyright 2023 The KCP 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 conversion + +import ( + "testing" + + "github.com/kcp-dev/logicalcluster/v3" + "github.com/stretchr/testify/require" + + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apiextensions-apiserver/pkg/apiserver/conversion" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/yaml" + + apisv1alpha1 "github.com/kcp-dev/kcp/pkg/apis/apis/v1alpha1" +) + +func TestDeferredConverter_getConverter(t *testing.T) { + t.Parallel() + + crdYAML, err := embeddedResources.ReadFile("widgets-crd.yaml") + require.NoError(t, err, "error reading widgets yaml") + + var crd apiextensionsv1.CustomResourceDefinition + err = yaml.Unmarshal(crdYAML, &crd) + require.NoError(t, err, "error unmarshalling crd") + + apiConversionYAML := ` +spec: + conversions: + - from: v1 + to: v2 + rules: + - field: .spec.firstName + destination: .spec.name.first + - field: .spec.lastName + destination: .spec.name.last + transformation: self + - field: .spec.lastName + destination: .spec.name.lastUpper + transformation: self.upperAscii() + - from: v2 + to: v1 + rules: + - field: .spec.name.first + destination: .spec.firstName + - field: .spec.name.last + destination: .spec.lastName + preserve: + - .spec.someNewField +` + var apiConversion apisv1alpha1.APIConversion + err = yaml.Unmarshal([]byte(apiConversionYAML), &apiConversion) + require.NoError(t, err, "error unmarshalling APIConversion") + + tests := map[string]struct { + apiConversionNotFound bool + strategy apiextensionsv1.ConversionStrategyType + expectedType interface{} + wantErrorMatching string + wantNewConverterCalls int + }{ + "APIConversion not found, strategy=none": { + apiConversionNotFound: true, + strategy: apiextensionsv1.NoneConverter, + expectedType: conversion.NewNOPConverter(), + wantNewConverterCalls: 0, + }, + "APIConversion not found, strategy=webhook": { + apiConversionNotFound: true, + strategy: apiextensionsv1.WebhookConverter, + wantErrorMatching: "is not supported for CRD", + wantNewConverterCalls: 0, + }, + "APIConversion not found, strategy=unknown": { + apiConversionNotFound: true, + strategy: "something", + wantErrorMatching: "unknown conversion strategy", + wantNewConverterCalls: 0, + }, + "APIConversion exists": { + expectedType: &fakeConverter{}, + wantNewConverterCalls: 1, + }, + } + + for testName, tc := range tests { + tc := tc + + t.Run(testName, func(t *testing.T) { + t.Parallel() + + crd := crd.DeepCopy() + crd.Spec.Conversion.Strategy = tc.strategy + + newConverterCalls := 0 + + c := &deferredConverter{ + crd: crd, + getAPIConversion: func(clusterName logicalcluster.Name, name string) (*apisv1alpha1.APIConversion, error) { + require.Equal(t, clusterName, logicalcluster.Name("root")) + require.Equal(t, name, "v1.widgets.kcp.io") + + if tc.apiConversionNotFound { + return nil, apierrors.NewNotFound(apisv1alpha1.Resource("apiconversions"), name) + } + + return &apiConversion, nil + }, + newConverter: func(crd *apiextensionsv1.CustomResourceDefinition, apiConversion *apisv1alpha1.APIConversion) (conversion.CRConverter, error) { + newConverterCalls++ + return &fakeConverter{}, nil + }, + } + + converter, err := c.getConverter() + if tc.wantErrorMatching == "" { + require.NoError(t, err) + } else { + require.ErrorContains(t, err, tc.wantErrorMatching) + return + } + + require.IsType(t, tc.expectedType, converter) + require.Equal(t, tc.wantNewConverterCalls, newConverterCalls) + + // Make sure we return the cached converter if we try to get it again + converter, err = c.getConverter() + require.NoError(t, err) + require.IsType(t, tc.expectedType, converter) + require.Equal(t, tc.wantNewConverterCalls, newConverterCalls) + }) + } +} + +type fakeConverter struct{} + +func (f *fakeConverter) Convert(in *unstructured.UnstructuredList, _ schema.GroupVersion) (*unstructured.UnstructuredList, error) { + return in, nil +} diff --git a/pkg/conversion/embedded_test.go b/pkg/conversion/embedded_test.go new file mode 100644 index 000000000000..38eb1f3db01e --- /dev/null +++ b/pkg/conversion/embedded_test.go @@ -0,0 +1,22 @@ +/* +Copyright 2023 The KCP 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 conversion + +import "embed" + +//go:embed *.yaml +var embeddedResources embed.FS diff --git a/pkg/conversion/widgets-crd.yaml b/pkg/conversion/widgets-crd.yaml new file mode 100644 index 000000000000..03636b01a1a0 --- /dev/null +++ b/pkg/conversion/widgets-crd.yaml @@ -0,0 +1,83 @@ +metadata: + uid: 1234 + annotations: + apis.kcp.io/bound-crd: "" + apis.kcp.io/schema-cluster: root + apis.kcp.io/schema-name: v1.widgets.kcp.io +spec: + conversion: {} + group: example.io + names: + kind: Widget + listKind: WidgetList + plural: widgets + singular: widget + scope: Cluster + versions: + - name: v1 + schema: + openAPIV3Schema: + type: object + description: Widgets do things + properties: + apiVersion: + type: string + kind: + type: string + metadata: + type: object + spec: + type: object + properties: + firstName: + type: string + lastName: + type: string + status: + type: object + properties: + phase: + type: string + served: true + storage: true + subresources: + status: {} + - name: v2 + schema: + openAPIV3Schema: + type: object + description: Widgets do things + properties: + apiVersion: + type: string + kind: + type: string + metadata: + type: object + spec: + type: object + properties: + someNewField: + type: object + properties: + hello: + type: string + name: + type: object + properties: + first: + type: string + last: + type: string + lastUpper: + type: string + status: + type: object + properties: + type: object + phase: + type: string + served: true + storage: false + subresources: + status: {} diff --git a/pkg/openapi/zz_generated.openapi.go b/pkg/openapi/zz_generated.openapi.go index 1e73307bbbc9..1c438e8f24b5 100644 --- a/pkg/openapi/zz_generated.openapi.go +++ b/pkg/openapi/zz_generated.openapi.go @@ -49,6 +49,10 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA "github.com/kcp-dev/kcp/pkg/apis/apis/v1alpha1.APIBindingList": schema_pkg_apis_apis_v1alpha1_APIBindingList(ref), "github.com/kcp-dev/kcp/pkg/apis/apis/v1alpha1.APIBindingSpec": schema_pkg_apis_apis_v1alpha1_APIBindingSpec(ref), "github.com/kcp-dev/kcp/pkg/apis/apis/v1alpha1.APIBindingStatus": schema_pkg_apis_apis_v1alpha1_APIBindingStatus(ref), + "github.com/kcp-dev/kcp/pkg/apis/apis/v1alpha1.APIConversion": schema_pkg_apis_apis_v1alpha1_APIConversion(ref), + "github.com/kcp-dev/kcp/pkg/apis/apis/v1alpha1.APIConversionList": schema_pkg_apis_apis_v1alpha1_APIConversionList(ref), + "github.com/kcp-dev/kcp/pkg/apis/apis/v1alpha1.APIConversionRule": schema_pkg_apis_apis_v1alpha1_APIConversionRule(ref), + "github.com/kcp-dev/kcp/pkg/apis/apis/v1alpha1.APIConversionSpec": schema_pkg_apis_apis_v1alpha1_APIConversionSpec(ref), "github.com/kcp-dev/kcp/pkg/apis/apis/v1alpha1.APIExport": schema_pkg_apis_apis_v1alpha1_APIExport(ref), "github.com/kcp-dev/kcp/pkg/apis/apis/v1alpha1.APIExportEndpoint": schema_pkg_apis_apis_v1alpha1_APIExportEndpoint(ref), "github.com/kcp-dev/kcp/pkg/apis/apis/v1alpha1.APIExportEndpointSlice": schema_pkg_apis_apis_v1alpha1_APIExportEndpointSlice(ref), @@ -62,6 +66,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA "github.com/kcp-dev/kcp/pkg/apis/apis/v1alpha1.APIResourceSchemaList": schema_pkg_apis_apis_v1alpha1_APIResourceSchemaList(ref), "github.com/kcp-dev/kcp/pkg/apis/apis/v1alpha1.APIResourceSchemaSpec": schema_pkg_apis_apis_v1alpha1_APIResourceSchemaSpec(ref), "github.com/kcp-dev/kcp/pkg/apis/apis/v1alpha1.APIResourceVersion": schema_pkg_apis_apis_v1alpha1_APIResourceVersion(ref), + "github.com/kcp-dev/kcp/pkg/apis/apis/v1alpha1.APIVersionConversion": schema_pkg_apis_apis_v1alpha1_APIVersionConversion(ref), "github.com/kcp-dev/kcp/pkg/apis/apis/v1alpha1.AcceptablePermissionClaim": schema_pkg_apis_apis_v1alpha1_AcceptablePermissionClaim(ref), "github.com/kcp-dev/kcp/pkg/apis/apis/v1alpha1.BindingReference": schema_pkg_apis_apis_v1alpha1_BindingReference(ref), "github.com/kcp-dev/kcp/pkg/apis/apis/v1alpha1.BoundAPIResource": schema_pkg_apis_apis_v1alpha1_BoundAPIResource(ref), @@ -1293,6 +1298,165 @@ func schema_pkg_apis_apis_v1alpha1_APIBindingStatus(ref common.ReferenceCallback } } +func schema_pkg_apis_apis_v1alpha1_APIConversion(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "APIConversion contains rules to convert between different API versions in an APIResourceSchema. The name must match the name of the APIResourceSchema for the conversions to take effect.", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"), + }, + }, + "spec": { + SchemaProps: spec.SchemaProps{ + Description: "Spec holds the desired state.", + Default: map[string]interface{}{}, + Ref: ref("github.com/kcp-dev/kcp/pkg/apis/apis/v1alpha1.APIConversionSpec"), + }, + }, + }, + Required: []string{"metadata", "spec"}, + }, + }, + Dependencies: []string{ + "github.com/kcp-dev/kcp/pkg/apis/apis/v1alpha1.APIConversionSpec", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, + } +} + +func schema_pkg_apis_apis_v1alpha1_APIConversionList(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "APIConversionList is a list of APIConversion resources.", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"), + }, + }, + "items": { + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/kcp-dev/kcp/pkg/apis/apis/v1alpha1.APIConversion"), + }, + }, + }, + }, + }, + }, + Required: []string{"metadata", "items"}, + }, + }, + Dependencies: []string{ + "github.com/kcp-dev/kcp/pkg/apis/apis/v1alpha1.APIConversion", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"}, + } +} + +func schema_pkg_apis_apis_v1alpha1_APIConversionRule(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "APIConversionRule specifies how to convert a single field.", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "field": { + SchemaProps: spec.SchemaProps{ + Description: "field is a JSONPath expression to the field in the originating version of the object, relative to its root, such as '.spec.name.first'.", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "destination": { + SchemaProps: spec.SchemaProps{ + Description: "destination is a JSONPath expression to the field in the target version of the object, relative to its root, such as '.spec.name.first'.", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "transformation": { + SchemaProps: spec.SchemaProps{ + Description: "transformation is an optional CEL expression used to execute user-specified rules to transform the originating field -- identified by 'self' -- to the destination field.", + Type: []string{"string"}, + Format: "", + }, + }, + }, + Required: []string{"field", "destination"}, + }, + }, + } +} + +func schema_pkg_apis_apis_v1alpha1_APIConversionSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "APIConversionSpec contains rules to convert between different API versions in an APIResourceSchema.", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "conversions": { + SchemaProps: spec.SchemaProps{ + Description: "conversions specify rules to convert between different API versions in an APIResourceSchema.", + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/kcp-dev/kcp/pkg/apis/apis/v1alpha1.APIVersionConversion"), + }, + }, + }, + }, + }, + }, + Required: []string{"conversions"}, + }, + }, + Dependencies: []string{ + "github.com/kcp-dev/kcp/pkg/apis/apis/v1alpha1.APIVersionConversion"}, + } +} + func schema_pkg_apis_apis_v1alpha1_APIExport(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ @@ -1708,7 +1872,7 @@ func schema_pkg_apis_apis_v1alpha1_APIResourceSchema(ref common.ReferenceCallbac return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ - Description: "APIResourceSchema describes a resource, identified by (group, version, resource, schema).\n\nA APIResourceSchema is immutable and cannot be deleted if they are referenced by an APIExport in the same workspace.", + Description: "APIResourceSchema describes a resource, identified by (group, version, resource, schema).\n\nAn APIResourceSchema is immutable and cannot be deleted if they are referenced by an APIExport in the same workspace.", Type: []string{"object"}, Properties: map[string]spec.Schema{ "kind": { @@ -1951,6 +2115,67 @@ func schema_pkg_apis_apis_v1alpha1_APIResourceVersion(ref common.ReferenceCallba } } +func schema_pkg_apis_apis_v1alpha1_APIVersionConversion(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "APIVersionConversion contains rules to convert between two specific API versions in an APIResourceSchema. Additionally, to avoid data loss when round-tripping from a version that contains a new field to one that doesn't and back again, you can specify a list of fields to preserve (these are stored in annotations).", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "from": { + SchemaProps: spec.SchemaProps{ + Description: "from is the source version.", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "to": { + SchemaProps: spec.SchemaProps{ + Description: "to is the target version.", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "rules": { + SchemaProps: spec.SchemaProps{ + Description: "rules contains field-specific conversion expressions.", + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/kcp-dev/kcp/pkg/apis/apis/v1alpha1.APIConversionRule"), + }, + }, + }, + }, + }, + "preserve": { + SchemaProps: spec.SchemaProps{ + Description: "preserve contains a list of JSONPath expressions to fields to preserve in the originating version of the object, relative to its root, such as '.spec.name.first'.", + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + }, + Required: []string{"from", "to", "rules"}, + }, + }, + Dependencies: []string{ + "github.com/kcp-dev/kcp/pkg/apis/apis/v1alpha1.APIConversionRule"}, + } +} + func schema_pkg_apis_apis_v1alpha1_AcceptablePermissionClaim(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ diff --git a/pkg/reconciler/apis/apibinding/apibinding_controller.go b/pkg/reconciler/apis/apibinding/apibinding_controller.go index b52b26d54ff9..4d7ad4826de3 100644 --- a/pkg/reconciler/apis/apibinding/apibinding_controller.go +++ b/pkg/reconciler/apis/apibinding/apibinding_controller.go @@ -31,11 +31,11 @@ import ( kcpapiextensionsclientset "k8s.io/apiextensions-apiserver/pkg/client/kcp/clientset/versioned" kcpapiextensionsv1informers "k8s.io/apiextensions-apiserver/pkg/client/kcp/informers/externalversions/apiextensions/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" utilerrors "k8s.io/apimachinery/pkg/util/errors" - "k8s.io/apimachinery/pkg/util/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" "k8s.io/apimachinery/pkg/util/sets" "k8s.io/apimachinery/pkg/util/wait" "k8s.io/client-go/tools/cache" @@ -47,7 +47,6 @@ import ( kcpclientset "github.com/kcp-dev/kcp/pkg/client/clientset/versioned/cluster" apisv1alpha1client "github.com/kcp-dev/kcp/pkg/client/clientset/versioned/typed/apis/v1alpha1" apisv1alpha1informers "github.com/kcp-dev/kcp/pkg/client/informers/externalversions/apis/v1alpha1" - apisv1alpha1listers "github.com/kcp-dev/kcp/pkg/client/listers/apis/v1alpha1" "github.com/kcp-dev/kcp/pkg/indexers" "github.com/kcp-dev/kcp/pkg/informer" "github.com/kcp-dev/kcp/pkg/logging" @@ -71,8 +70,10 @@ func NewController( apiBindingInformer apisv1alpha1informers.APIBindingClusterInformer, apiExportInformer apisv1alpha1informers.APIExportClusterInformer, apiResourceSchemaInformer apisv1alpha1informers.APIResourceSchemaClusterInformer, + apiConversionInformer apisv1alpha1informers.APIConversionClusterInformer, globalAPIExportInformer apisv1alpha1informers.APIExportClusterInformer, globalAPIResourceSchemaInformer apisv1alpha1informers.APIResourceSchemaClusterInformer, + globalAPIConversionInformer apisv1alpha1informers.APIConversionClusterInformer, crdInformer kcpapiextensionsv1informers.CustomResourceDefinitionClusterInformer, ) (*controller, error) { queue := workqueue.NewNamedRateLimitingQueue(workqueue.DefaultControllerRateLimiter(), ControllerName) @@ -84,7 +85,6 @@ func NewController( dynamicClusterClient: dynamicClusterClient, ddsif: dynamicDiscoverySharedInformerFactory, - apiBindingsLister: apiBindingInformer.Lister(), listAPIBindings: func(clusterName logicalcluster.Name) ([]*apisv1alpha1.APIBinding, error) { list, err := apiBindingInformer.Lister().List(labels.Everything()) if err != nil { @@ -103,7 +103,40 @@ func NewController( return ret, nil }, - apiBindingsIndexer: apiBindingInformer.Informer().GetIndexer(), + listAPIBindingsByAPIExport: func(export *apisv1alpha1.APIExport) ([]*apisv1alpha1.APIBinding, error) { + // binding keys by full path + keys := sets.NewString() + if path := logicalcluster.NewPath(export.Annotations[core.LogicalClusterPathAnnotationKey]); !path.Empty() { + pathKeys, err := apiBindingInformer.Informer().GetIndexer().IndexKeys(indexers.APIBindingsByAPIExport, path.Join(export.Name).String()) + if err != nil { + return nil, err + } + keys.Insert(pathKeys...) + } + + clusterKeys, err := apiBindingInformer.Informer().GetIndexer().IndexKeys(indexers.APIBindingsByAPIExport, logicalcluster.From(export).Path().Join(export.Name).String()) + if err != nil { + return nil, err + } + keys.Insert(clusterKeys...) + + bindings := make([]*apisv1alpha1.APIBinding, 0, keys.Len()) + for _, key := range keys.List() { + binding, exists, err := apiBindingInformer.Informer().GetIndexer().GetByKey(key) + if err != nil { + utilruntime.HandleError(err) + continue + } else if !exists { + utilruntime.HandleError(fmt.Errorf("APIBinding %q does not exist", key)) + continue + } + bindings = append(bindings, binding.(*apisv1alpha1.APIBinding)) + } + return bindings, nil + }, + getAPIBinding: func(clusterName logicalcluster.Name, name string) (*apisv1alpha1.APIBinding, error) { + return apiBindingInformer.Lister().Cluster(clusterName).Get(name) + }, getAPIExport: func(path logicalcluster.Path, name string) (*apisv1alpha1.APIExport, error) { // Try local informer first @@ -119,8 +152,20 @@ func NewController( // Didn't find it locally - try remote return indexers.ByPathAndName[*apisv1alpha1.APIExport](apisv1alpha1.Resource("apiexports"), globalAPIExportInformer.Informer().GetIndexer(), path, name) }, - apiExportsIndexer: apiExportInformer.Informer().GetIndexer(), - globalAPIExportsIndexer: globalAPIExportInformer.Informer().GetIndexer(), + getAPIExportsBySchema: func(schema *apisv1alpha1.APIResourceSchema) ([]*apisv1alpha1.APIExport, error) { + key, err := kcpcache.DeletionHandlingMetaClusterNamespaceKeyFunc(schema) + if err != nil { + return nil, err + } + exports, err := indexers.ByIndex[*apisv1alpha1.APIExport](apiExportInformer.Informer().GetIndexer(), indexAPIExportsByAPIResourceSchema, key) + if err != nil { + return nil, err + } + if len(exports) > 0 { + return exports, nil + } + return indexers.ByIndex[*apisv1alpha1.APIExport](globalAPIExportInformer.Informer().GetIndexer(), indexAPIExportsByAPIResourceSchema, key) + }, getAPIResourceSchema: func(clusterName logicalcluster.Name, name string) (*apisv1alpha1.APIResourceSchema, error) { apiResourceSchema, err := apiResourceSchemaInformer.Lister().Cluster(clusterName).Get(name) @@ -130,6 +175,14 @@ func NewController( return apiResourceSchema, err }, + getAPIConversion: func(clusterName logicalcluster.Name, name string) (*apisv1alpha1.APIConversion, error) { + apiConversion, err := apiConversionInformer.Cluster(clusterName).Lister().Get(name) + if apierrors.IsNotFound(err) { + return globalAPIConversionInformer.Lister().Cluster(clusterName).Get(name) + } + return apiConversion, err + }, + createCRD: func(ctx context.Context, clusterName logicalcluster.Path, crd *apiextensionsv1.CustomResourceDefinition) (*apiextensionsv1.CustomResourceDefinition, error) { return crdClusterClient.Cluster(clusterName).ApiextensionsV1().CustomResourceDefinitions().Create(ctx, crd, metav1.CreateOptions{}) }, @@ -145,12 +198,12 @@ func NewController( logger := logging.WithReconciler(klog.Background(), ControllerName) - if err := apiBindingInformer.Informer().AddIndexers(cache.Indexers{ + // APIBinding indexers + indexers.AddIfNotPresentOrDie(apiBindingInformer.Informer().GetIndexer(), cache.Indexers{ indexers.APIBindingsByAPIExport: indexers.IndexAPIBindingByAPIExport, - }); err != nil { - return nil, err - } + }) + // APIExport indexers indexers.AddIfNotPresentOrDie(apiExportInformer.Informer().GetIndexer(), cache.Indexers{ indexers.ByLogicalClusterPathAndName: indexers.IndexByLogicalClusterPathAndName, indexAPIExportsByAPIResourceSchema: indexAPIExportsByAPIResourceSchemasFunc, @@ -160,66 +213,108 @@ func NewController( indexAPIExportsByAPIResourceSchema: indexAPIExportsByAPIResourceSchemasFunc, }) + // APIBinding handlers apiBindingInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ - AddFunc: func(obj interface{}) { c.enqueueAPIBinding(obj, logger, "") }, - UpdateFunc: func(_, obj interface{}) { c.enqueueAPIBinding(obj, logger, "") }, - DeleteFunc: func(obj interface{}) { c.enqueueAPIBinding(obj, logger, "") }, + AddFunc: func(obj interface{}) { c.enqueueAPIBinding(objOrTombstone[*apisv1alpha1.APIBinding](obj), logger, "") }, + UpdateFunc: func(_, obj interface{}) { + c.enqueueAPIBinding(objOrTombstone[*apisv1alpha1.APIBinding](obj), logger, "") + }, + DeleteFunc: func(obj interface{}) { c.enqueueAPIBinding(objOrTombstone[*apisv1alpha1.APIBinding](obj), logger, "") }, }) + // CRD handlers crdInformer.Informer().AddEventHandler(cache.FilteringResourceEventHandler{ FilterFunc: func(obj interface{}) bool { - crd, ok := obj.(*apiextensionsv1.CustomResourceDefinition) - if !ok { - return false - } - + crd := obj.(*apiextensionsv1.CustomResourceDefinition) return logicalcluster.From(crd) == SystemBoundCRDsClusterName }, Handler: cache.ResourceEventHandlerFuncs{ - AddFunc: func(obj interface{}) { c.enqueueCRD(obj, logger) }, - UpdateFunc: func(_, obj interface{}) { c.enqueueCRD(obj, logger) }, + AddFunc: func(obj interface{}) { + c.enqueueCRD(objOrTombstone[*apiextensionsv1.CustomResourceDefinition](obj), logger) + }, + UpdateFunc: func(_, obj interface{}) { + c.enqueueCRD(objOrTombstone[*apiextensionsv1.CustomResourceDefinition](obj), logger) + }, DeleteFunc: func(obj interface{}) { - meta, err := meta.Accessor(obj) - if err != nil { - runtime.HandleError(err) - return - } + crd := objOrTombstone[*apiextensionsv1.CustomResourceDefinition](obj) // If something deletes one of our bound CRDs, we need to keep track of it so when we're reconciling, // we know we need to recreate it. This set is there to fight against stale informers still seeing // the deleted CRD. - c.deletedCRDTracker.Add(meta.GetName()) + c.deletedCRDTracker.Add(crd.Name) - c.enqueueCRD(obj, logger) + c.enqueueCRD(crd, logger) }, }, }) - apiResourceSchemaInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ - AddFunc: func(obj interface{}) { c.enqueueAPIResourceSchema(obj, logger, "") }, - UpdateFunc: func(_, obj interface{}) { c.enqueueAPIResourceSchema(obj, logger, "") }, - DeleteFunc: func(obj interface{}) { c.enqueueAPIResourceSchema(obj, logger, "") }, - }) - + // APIExport handlers apiExportInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ - AddFunc: func(obj interface{}) { c.enqueueAPIExport(obj, logger, "") }, - UpdateFunc: func(_, obj interface{}) { c.enqueueAPIExport(obj, logger, "") }, - DeleteFunc: func(obj interface{}) { c.enqueueAPIExport(obj, logger, "") }, + AddFunc: func(obj interface{}) { c.enqueueAPIExport(objOrTombstone[*apisv1alpha1.APIExport](obj), logger, "") }, + UpdateFunc: func(_, obj interface{}) { c.enqueueAPIExport(objOrTombstone[*apisv1alpha1.APIExport](obj), logger, "") }, + DeleteFunc: func(obj interface{}) { c.enqueueAPIExport(objOrTombstone[*apisv1alpha1.APIExport](obj), logger, "") }, }) globalAPIExportInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ - AddFunc: func(obj interface{}) { c.enqueueAPIExport(obj, logger, "") }, - UpdateFunc: func(_, obj interface{}) { c.enqueueAPIExport(obj, logger, "") }, - DeleteFunc: func(obj interface{}) { c.enqueueAPIExport(obj, logger, "") }, + AddFunc: func(obj interface{}) { c.enqueueAPIExport(objOrTombstone[*apisv1alpha1.APIExport](obj), logger, "") }, + UpdateFunc: func(_, obj interface{}) { c.enqueueAPIExport(objOrTombstone[*apisv1alpha1.APIExport](obj), logger, "") }, + DeleteFunc: func(obj interface{}) { c.enqueueAPIExport(objOrTombstone[*apisv1alpha1.APIExport](obj), logger, "") }, + }) + + // APIResourceSchema handlers + apiResourceSchemaInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ + AddFunc: func(obj interface{}) { + c.enqueueAPIResourceSchema(objOrTombstone[*apisv1alpha1.APIResourceSchema](obj), logger, "") + }, + UpdateFunc: func(_, obj interface{}) { + c.enqueueAPIResourceSchema(objOrTombstone[*apisv1alpha1.APIResourceSchema](obj), logger, "") + }, + DeleteFunc: func(obj interface{}) { + c.enqueueAPIResourceSchema(objOrTombstone[*apisv1alpha1.APIResourceSchema](obj), logger, "") + }, }) globalAPIResourceSchemaInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ - AddFunc: func(obj interface{}) { c.enqueueAPIResourceSchema(obj, logger, "") }, - UpdateFunc: func(_, obj interface{}) { c.enqueueAPIResourceSchema(obj, logger, "") }, - DeleteFunc: func(obj interface{}) { c.enqueueAPIResourceSchema(obj, logger, "") }, + AddFunc: func(obj interface{}) { + c.enqueueAPIResourceSchema(objOrTombstone[*apisv1alpha1.APIResourceSchema](obj), logger, "") + }, + UpdateFunc: func(_, obj interface{}) { + c.enqueueAPIResourceSchema(objOrTombstone[*apisv1alpha1.APIResourceSchema](obj), logger, "") + }, + DeleteFunc: func(obj interface{}) { + c.enqueueAPIResourceSchema(objOrTombstone[*apisv1alpha1.APIResourceSchema](obj), logger, "") + }, + }) + + // APIConversion handlers + apiConversionInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ + AddFunc: func(obj interface{}) { + c.enqueueAPIConversion(objOrTombstone[*apisv1alpha1.APIConversion](obj), logger) + }, + UpdateFunc: func(_, obj interface{}) { + c.enqueueAPIConversion(objOrTombstone[*apisv1alpha1.APIConversion](obj), logger) + }, + DeleteFunc: func(obj interface{}) { + c.enqueueAPIConversion(objOrTombstone[*apisv1alpha1.APIConversion](obj), logger) + }, }) return c, nil } +func objOrTombstone[T runtime.Object](obj any) T { + if t, ok := obj.(T); ok { + return t + } + if tombstone, ok := obj.(cache.DeletedFinalStateUnknown); ok { + if t, ok := tombstone.Obj.(T); ok { + return t + } + + panic(fmt.Errorf("tombstone %T is not a %T", tombstone, new(T))) + } + + panic(fmt.Errorf("%T is not a %T", obj, new(T))) +} + type APIBinding = apisv1alpha1.APIBinding type APIBindingSpec = apisv1alpha1.APIBindingSpec type APIBindingStatus = apisv1alpha1.APIBindingStatus @@ -238,16 +333,17 @@ type controller struct { dynamicClusterClient kcpdynamic.ClusterInterface ddsif *informer.DiscoveringDynamicSharedInformerFactory - apiBindingsLister apisv1alpha1listers.APIBindingClusterLister - listAPIBindings func(clusterName logicalcluster.Name) ([]*apisv1alpha1.APIBinding, error) - apiBindingsIndexer cache.Indexer + listAPIBindings func(clusterName logicalcluster.Name) ([]*apisv1alpha1.APIBinding, error) + listAPIBindingsByAPIExport func(apiExport *apisv1alpha1.APIExport) ([]*apisv1alpha1.APIBinding, error) + getAPIBinding func(clusterName logicalcluster.Name, name string) (*apisv1alpha1.APIBinding, error) - getAPIExport func(path logicalcluster.Path, name string) (*apisv1alpha1.APIExport, error) - apiExportsIndexer cache.Indexer - globalAPIExportsIndexer cache.Indexer + getAPIExport func(path logicalcluster.Path, name string) (*apisv1alpha1.APIExport, error) + getAPIExportsBySchema func(schema *apisv1alpha1.APIResourceSchema) ([]*apisv1alpha1.APIExport, error) getAPIResourceSchema func(clusterName logicalcluster.Name, name string) (*apisv1alpha1.APIResourceSchema, error) + getAPIConversion func(clusterName logicalcluster.Name, name string) (*apisv1alpha1.APIConversion, error) + createCRD func(ctx context.Context, clusterName logicalcluster.Path, crd *apiextensionsv1.CustomResourceDefinition) (*apiextensionsv1.CustomResourceDefinition, error) getCRD func(clusterName logicalcluster.Name, name string) (*apiextensionsv1.CustomResourceDefinition, error) listCRDs func(clusterName logicalcluster.Name) ([]*apiextensionsv1.CustomResourceDefinition, error) @@ -257,10 +353,10 @@ type controller struct { } // enqueueAPIBinding enqueues an APIBinding . -func (c *controller) enqueueAPIBinding(obj interface{}, logger logr.Logger, logSuffix string) { - key, err := kcpcache.DeletionHandlingMetaClusterNamespaceKeyFunc(obj) +func (c *controller) enqueueAPIBinding(apiBinding *apisv1alpha1.APIBinding, logger logr.Logger, logSuffix string) { + key, err := kcpcache.DeletionHandlingMetaClusterNamespaceKeyFunc(apiBinding) if err != nil { - runtime.HandleError(err) + utilruntime.HandleError(err) return } @@ -269,55 +365,20 @@ func (c *controller) enqueueAPIBinding(obj interface{}, logger logr.Logger, logS } // enqueueAPIExport enqueues maps an APIExport to APIBindings for enqueuing. -func (c *controller) enqueueAPIExport(obj interface{}, logger logr.Logger, logSuffix string) { - if d, ok := obj.(cache.DeletedFinalStateUnknown); ok { - obj = d.Obj - } - - export, ok := obj.(*apisv1alpha1.APIExport) - if !ok { - runtime.HandleError(fmt.Errorf("obj is supposed to be a APIExport, but is %T", obj)) - return - } - - // binding keys by full path - keys := sets.NewString() - if path := logicalcluster.NewPath(export.Annotations[core.LogicalClusterPathAnnotationKey]); !path.Empty() { - pathKeys, err := c.apiBindingsIndexer.IndexKeys(indexers.APIBindingsByAPIExport, path.Join(export.Name).String()) - if err != nil { - runtime.HandleError(err) - return - } - keys.Insert(pathKeys...) - } - - clusterKeys, err := c.apiBindingsIndexer.IndexKeys(indexers.APIBindingsByAPIExport, logicalcluster.From(export).Path().Join(export.Name).String()) +func (c *controller) enqueueAPIExport(export *apisv1alpha1.APIExport, logger logr.Logger, logSuffix string) { + bindings, err := c.listAPIBindingsByAPIExport(export) if err != nil { - runtime.HandleError(err) + utilruntime.HandleError(err) return } - keys.Insert(clusterKeys...) - - for _, key := range keys.List() { - binding, exists, err := c.apiBindingsIndexer.GetByKey(key) - if err != nil { - runtime.HandleError(err) - continue - } else if !exists { - runtime.HandleError(fmt.Errorf("APIBinding %q does not exist", key)) - continue - } - c.enqueueAPIBinding(binding, logging.WithObject(logger, obj.(*apisv1alpha1.APIExport)), fmt.Sprintf(" because of APIExport%s", logSuffix)) + + for _, binding := range bindings { + c.enqueueAPIBinding(binding, logging.WithObject(logger, export), fmt.Sprintf(" because of APIExport%s", logSuffix)) } } // enqueueCRD maps a CRD to APIResourceSchema for enqueuing. -func (c *controller) enqueueCRD(obj interface{}, logger logr.Logger) { - crd, ok := obj.(*apiextensionsv1.CustomResourceDefinition) - if !ok { - runtime.HandleError(fmt.Errorf("obj is supposed to be a CustomResourceDefinition, but is %T", obj)) - return - } +func (c *controller) enqueueCRD(crd *apiextensionsv1.CustomResourceDefinition, logger logr.Logger) { logger = logging.WithObject(logger, crd).WithValues( "groupResource", fmt.Sprintf("%s.%s", crd.Spec.Names.Plural, crd.Spec.Group), "established", apihelpers.IsCRDConditionTrue(crd, apiextensionsv1.Established), @@ -331,46 +392,48 @@ func (c *controller) enqueueCRD(obj interface{}, logger logr.Logger) { clusterName := logicalcluster.Name(crd.Annotations[apisv1alpha1.AnnotationSchemaClusterKey]) apiResourceSchema, err := c.getAPIResourceSchema(clusterName, crd.Annotations[apisv1alpha1.AnnotationSchemaNameKey]) if err != nil { - runtime.HandleError(err) + utilruntime.HandleError(err) return } // this log here is kind of redundant normally. But we are seeing missing CRD update events - // and hence stale APIBindings. So this might help to undersand what's going on. + // and hence stale APIBindings. So this might help to understand what's going on. logger.V(4).Info("queueing APIResourceSchema because of CRD", "key", kcpcache.ToClusterAwareKey(clusterName.String(), "", apiResourceSchema.Name)) c.enqueueAPIResourceSchema(apiResourceSchema, logger, " because of CRD") } // enqueueAPIResourceSchema maps an APIResourceSchema to APIExports for enqueuing. -func (c *controller) enqueueAPIResourceSchema(obj interface{}, logger logr.Logger, logSuffix string) { - key, err := kcpcache.DeletionHandlingMetaClusterNamespaceKeyFunc(obj) +func (c *controller) enqueueAPIResourceSchema(schema *apisv1alpha1.APIResourceSchema, logger logr.Logger, logSuffix string) { + apiExports, err := c.getAPIExportsBySchema(schema) if err != nil { - runtime.HandleError(err) + utilruntime.HandleError(err) return } - apiExports, err := c.apiExportsIndexer.ByIndex(indexAPIExportsByAPIResourceSchema, key) + for _, export := range apiExports { + c.enqueueAPIExport(export, logging.WithObject(logger, schema), fmt.Sprintf(" because of APIResourceSchema%s", logSuffix)) + } +} + +func (c *controller) enqueueAPIConversion(apiConversion *apisv1alpha1.APIConversion, logger logr.Logger) { + logger = logging.WithObject(logger, apiConversion) + + clusterName := logicalcluster.From(apiConversion) + apiResourceSchema, err := c.getAPIResourceSchema(clusterName, apiConversion.Name) if err != nil { - runtime.HandleError(err) + utilruntime.HandleError(err) return } - if len(apiExports) == 0 { - apiExports, err = c.globalAPIExportsIndexer.ByIndex(indexAPIExportsByAPIResourceSchema, key) - if err != nil { - runtime.HandleError(err) - return - } - } - for _, export := range apiExports { - c.enqueueAPIExport(export, logging.WithObject(logger, obj.(*apisv1alpha1.APIResourceSchema)), fmt.Sprintf(" because of APIResourceSchema%s", logSuffix)) - } + logger.V(4).Info("queueing APIResourceSchema because of APIConversion", "key", kcpcache.ToClusterAwareKey(clusterName.String(), "", apiConversion.Name)) + + c.enqueueAPIResourceSchema(apiResourceSchema, logger, "") } // Start starts the controller, which stops when ctx.Done() is closed. func (c *controller) Start(ctx context.Context, numThreads int) { - defer runtime.HandleCrash() + defer utilruntime.HandleCrash() defer c.queue.ShutDown() logger := logging.WithReconciler(klog.FromContext(ctx), ControllerName) @@ -407,7 +470,7 @@ func (c *controller) processNextWorkItem(ctx context.Context) bool { defer c.queue.Done(key) if requeue, err := c.process(ctx, key); err != nil { - runtime.HandleError(fmt.Errorf("%q controller failed to sync %q, err: %w", ControllerName, key, err)) + utilruntime.HandleError(fmt.Errorf("%q controller failed to sync %q, err: %w", ControllerName, key, err)) c.queue.AddRateLimited(key) return true } else if requeue { @@ -423,11 +486,11 @@ func (c *controller) process(ctx context.Context, key string) (bool, error) { logger := klog.FromContext(ctx) clusterName, _, name, err := kcpcache.SplitMetaClusterNamespaceKey(key) if err != nil { - runtime.HandleError(err) + utilruntime.HandleError(err) return false, nil } - obj, err := c.apiBindingsLister.Cluster(clusterName).Get(name) + binding, err := c.getAPIBinding(clusterName, name) if err != nil { if apierrors.IsNotFound(err) { logger.Error(err, "failed to get APIBinding from lister", "cluster", clusterName) @@ -436,21 +499,21 @@ func (c *controller) process(ctx context.Context, key string) (bool, error) { return false, nil // nothing we can do here } - old := obj - obj = obj.DeepCopy() + old := binding + binding = binding.DeepCopy() - logger = logging.WithObject(logger, obj) + logger = logging.WithObject(logger, binding) ctx = klog.NewContext(ctx, logger) var errs []error - requeue, err := c.reconcile(ctx, obj) + requeue, err := c.reconcile(ctx, binding) if err != nil { errs = append(errs, err) } // If the object being reconciled changed as a result, update it. oldResource := &Resource{ObjectMeta: old.ObjectMeta, Spec: &old.Spec, Status: &old.Status} - newResource := &Resource{ObjectMeta: obj.ObjectMeta, Spec: &obj.Spec, Status: &obj.Status} + newResource := &Resource{ObjectMeta: binding.ObjectMeta, Spec: &binding.Spec, Status: &binding.Status} if err := c.commit(ctx, oldResource, newResource); err != nil { errs = append(errs, err) } diff --git a/pkg/reconciler/apis/apibinding/apibinding_reconcile.go b/pkg/reconciler/apis/apibinding/apibinding_reconcile.go index 3c55905a3ce1..5ca3508ef818 100644 --- a/pkg/reconciler/apis/apibinding/apibinding_reconcile.go +++ b/pkg/reconciler/apis/apibinding/apibinding_reconcile.go @@ -265,6 +265,29 @@ func (r *bindingReconciler) reconcile(ctx context.Context, apiBinding *apisv1alp return reconcileStatusContinue, nil } + // If there are multiple versions, there must be an APIConversion + if len(schema.Spec.Versions) > 1 { + if _, err := r.getAPIConversion(logicalcluster.From(schema), schema.Name); err != nil { + // Need to wait until the APIConversion is present before we can proceed to create the bound CRD + conditions.MarkFalse( + apiBinding, + apisv1alpha1.APIExportValid, + apisv1alpha1.InternalErrorReason, + conditionsv1alpha1.ConditionSeverityError, + "Invalid APIExport. Please contact the APIExport owner to resolve", + ) + + return reconcileStatusContinue, fmt.Errorf( + "error getting APIConversion %s|%s for APIBinding %s|%s, APIExport %s|%s, APIResourceSchema %s|%s: %w", + apiExportPath, schemaName, + bindingClusterName, apiBinding.Name, + apiExportPath, apiExport.Name, + apiExportPath, schemaName, + err, + ) + } + } + // Try to get the bound CRD existingCRD, err := r.getCRD(SystemBoundCRDsClusterName, boundCRDName(schema)) if err != nil && !apierrors.IsNotFound(err) { diff --git a/pkg/reconciler/cache/replication/replication_controller.go b/pkg/reconciler/cache/replication/replication_controller.go index bce2be1958d1..a27c8b1e6f8d 100644 --- a/pkg/reconciler/cache/replication/replication_controller.go +++ b/pkg/reconciler/cache/replication/replication_controller.go @@ -79,6 +79,11 @@ func NewController( local: localKcpInformers.Apis().V1alpha1().APIResourceSchemas().Informer(), global: globalKcpInformers.Apis().V1alpha1().APIResourceSchemas().Informer(), }, + apisv1alpha1.SchemeGroupVersion.WithResource("apiconversions"): { + kind: "APIConversion", + local: localKcpInformers.Apis().V1alpha1().APIConversions().Informer(), + global: globalKcpInformers.Apis().V1alpha1().APIConversions().Informer(), + }, corev1alpha1.SchemeGroupVersion.WithResource("shards"): { kind: "Shard", local: localKcpInformers.Core().V1alpha1().Shards().Informer(), diff --git a/pkg/server/config.go b/pkg/server/config.go index 41da1f7dcc6c..28387d75aa01 100644 --- a/pkg/server/config.go +++ b/pkg/server/config.go @@ -37,7 +37,6 @@ import ( "k8s.io/apiserver/pkg/quota/v1/generic" genericapiserver "k8s.io/apiserver/pkg/server" serverstorage "k8s.io/apiserver/pkg/server/storage" - "k8s.io/apiserver/pkg/util/webhook" "k8s.io/client-go/rest" "k8s.io/client-go/tools/cache" "k8s.io/client-go/tools/clientcmd" @@ -54,6 +53,7 @@ import ( "github.com/kcp-dev/kcp/pkg/cache/client/shard" kcpclientset "github.com/kcp-dev/kcp/pkg/client/clientset/versioned/cluster" kcpinformers "github.com/kcp-dev/kcp/pkg/client/informers/externalversions" + "github.com/kcp-dev/kcp/pkg/conversion" "github.com/kcp-dev/kcp/pkg/embeddedetcd" kcpfeatures "github.com/kcp-dev/kcp/pkg/features" "github.com/kcp-dev/kcp/pkg/indexers" @@ -431,23 +431,18 @@ func NewConfig(opts *kcpserveroptions.CompletedOptions) (*Config, error) { informerfactoryhack.Wrap(c.Apis.ExtraConfig.VersionedInformers), admissionPluginInitializers, opts.GenericControlPlane, - - // Wire in a ServiceResolver that always returns an error that ResolveEndpoint is not yet - // supported. The effect is that CRD webhook conversions are not supported and will always get an - // error. - &unimplementedServiceResolver{}, - - webhook.NewDefaultAuthenticationInfoResolverWrapper( - nil, - c.Apis.GenericConfig.EgressSelector, - c.Apis.GenericConfig.LoopbackClientConfig, - c.Apis.GenericConfig.TracerProvider, - ), ) if err != nil { - return nil, fmt.Errorf("configure api extensions: %w", err) + return nil, fmt.Errorf("error configuring api extensions: %w", err) } + c.ApiExtensions.ExtraConfig.ConversionFactory = conversion.NewCRConverterFactory( + c.KcpSharedInformerFactory.Apis().V1alpha1().APIConversions(), + opts.Extra.ConversionCELTransformationTimeout, + ) + // make sure the informer gets started, otherwise conversions will not work! + _ = c.KcpSharedInformerFactory.Apis().V1alpha1().APIConversions().Informer() + c.ApiExtensionsSharedInformerFactory.Apiextensions().V1().CustomResourceDefinitions().Informer().GetIndexer().AddIndexers(cache.Indexers{byGroupResourceName: indexCRDByGroupResourceName}) //nolint:errcheck c.KcpSharedInformerFactory.Apis().V1alpha1().APIBindings().Informer().GetIndexer().AddIndexers(cache.Indexers{byIdentityGroupResource: indexAPIBindingByIdentityGroupResource}) //nolint:errcheck c.KcpSharedInformerFactory.Workload().V1alpha1().SyncTargets().Informer().GetIndexer().AddIndexers(cache.Indexers{indexers.SyncTargetsBySyncTargetKey: indexers.IndexSyncTargetsBySyncTargetKey}) //nolint:errcheck diff --git a/pkg/server/controllers.go b/pkg/server/controllers.go index 3c67f951c78e..cc0a8938ba1f 100644 --- a/pkg/server/controllers.go +++ b/pkg/server/controllers.go @@ -669,8 +669,10 @@ func (s *Server) installAPIBindingController(ctx context.Context, config *rest.C s.KcpSharedInformerFactory.Apis().V1alpha1().APIBindings(), s.KcpSharedInformerFactory.Apis().V1alpha1().APIExports(), s.KcpSharedInformerFactory.Apis().V1alpha1().APIResourceSchemas(), + s.KcpSharedInformerFactory.Apis().V1alpha1().APIConversions(), s.CacheKcpSharedInformerFactory.Apis().V1alpha1().APIExports(), s.CacheKcpSharedInformerFactory.Apis().V1alpha1().APIResourceSchemas(), + s.CacheKcpSharedInformerFactory.Apis().V1alpha1().APIConversions(), s.ApiExtensionsSharedInformerFactory.Apiextensions().V1().CustomResourceDefinitions(), ) if err != nil { diff --git a/pkg/server/handler.go b/pkg/server/handler.go index 085f7dfa284f..fd64026bf66b 100644 --- a/pkg/server/handler.go +++ b/pkg/server/handler.go @@ -18,11 +18,9 @@ package server import ( "context" - "errors" "fmt" "net/http" _ "net/http/pprof" - "net/url" "path" "sort" "strings" @@ -444,13 +442,3 @@ func (r *inMemoryResponseWriter) String() string { } return s } - -// unimplementedServiceResolver is a webhook.ServiceResolver that always returns an error, because -// we have not implemented support for this yet. As a result, CRD webhook conversions are not -// supported. -type unimplementedServiceResolver struct{} - -// ResolveEndpoint always returns an error that this is not yet supported. -func (r *unimplementedServiceResolver) ResolveEndpoint(namespace string, name string, port int32) (*url.URL, error) { - return nil, errors.New("CRD webhook conversions are not yet supported in kcp") -} diff --git a/pkg/server/options/flags.go b/pkg/server/options/flags.go index 842164429e3a..018c1372ba70 100644 --- a/pkg/server/options/flags.go +++ b/pkg/server/options/flags.go @@ -129,17 +129,18 @@ var ( "tracing-config-file", // File with apiserver tracing configuration. // KCP flags - "profiler-address", // [Address]:port to bind the profiler to - "root-directory", // Root directory. - "shard-base-url", // Base URL to this kcp shard. Defaults to external address. - "shard-external-url", // URL used by outside clients to talk to this kcp shard. Defaults to external address. - "shard-virtual-workspace-url", // An external URL address of a virtual workspace server associated with this shard. Defaults to shard's base address. - "shard-name", // A name of this kcp shard. - "shard-kubeconfig-file", // Kubeconfig holding admin(!) credentials to peer kcp shards. - "root-shard-kubeconfig-file", // Kubeconfig holding admin(!) credentials to the root kcp shard. - "experimental-bind-free-port", // Bind to a free port. --secure-bind-port must be 0. Use the admin.kubeconfig to extract the chosen port. - "batteries-included", // A list of batteries included (= default objects that might be unwanted in production, but very helpful in trying out kcp or development). - "logical-cluster-admin-kubeconfig", // Kubeconfig holding admin(!) credentials to other shards. Defaults to the loopback client. + "profiler-address", // [Address]:port to bind the profiler to + "root-directory", // Root directory. + "shard-base-url", // Base URL to this kcp shard. Defaults to external address. + "shard-external-url", // URL used by outside clients to talk to this kcp shard. Defaults to external address. + "shard-virtual-workspace-url", // An external URL address of a virtual workspace server associated with this shard. Defaults to shard's base address. + "shard-name", // A name of this kcp shard. + "shard-kubeconfig-file", // Kubeconfig holding admin(!) credentials to peer kcp shards. + "root-shard-kubeconfig-file", // Kubeconfig holding admin(!) credentials to the root kcp shard. + "experimental-bind-free-port", // Bind to a free port. --secure-bind-port must be 0. Use the admin.kubeconfig to extract the chosen port. + "conversion-cel-transformation-timeout", // Maximum amount of time that CEL transformations may take per object conversion. + "batteries-included", // A list of batteries included (= default objects that might be unwanted in production, but very helpful in trying out kcp or development). + "logical-cluster-admin-kubeconfig", // Kubeconfig holding admin(!) credentials to other shards. Defaults to the loopback client. // secure serving flags "bind-address", // The IP address on which to listen for the --secure-port port. The associated interface(s) must be reachable by the rest of the cluster, and by CLI/web clients. If blank or an unspecified address (0.0.0.0 or ::), all interfaces will be used. diff --git a/pkg/server/options/options.go b/pkg/server/options/options.go index 2579b8adc492..b2b18b880958 100644 --- a/pkg/server/options/options.go +++ b/pkg/server/options/options.go @@ -54,17 +54,18 @@ type Options struct { } type ExtraOptions struct { - RootDirectory string - ProfilerAddress string - ShardKubeconfigFile string - RootShardKubeconfigFile string - ShardBaseURL string - ShardExternalURL string - ShardName string - ShardVirtualWorkspaceURL string - DiscoveryPollInterval time.Duration - ExperimentalBindFreePort bool - LogicalClusterAdminKubeconfig string + RootDirectory string + ProfilerAddress string + ShardKubeconfigFile string + RootShardKubeconfigFile string + ShardBaseURL string + ShardExternalURL string + ShardName string + ShardVirtualWorkspaceURL string + DiscoveryPollInterval time.Duration + ExperimentalBindFreePort bool + LogicalClusterAdminKubeconfig string + ConversionCELTransformationTimeout time.Duration BatteriesIncluded []string } @@ -101,15 +102,17 @@ func NewOptions(rootDir string) *Options { Cache: *NewCache(rootDir), Extra: ExtraOptions{ - RootDirectory: rootDir, - ProfilerAddress: "", - ShardKubeconfigFile: "", - ShardBaseURL: "", - ShardExternalURL: "", - ShardName: "root", - DiscoveryPollInterval: 60 * time.Second, - ExperimentalBindFreePort: false, - BatteriesIncluded: batteries.Defaults.List(), + RootDirectory: rootDir, + ProfilerAddress: "", + ShardKubeconfigFile: "", + ShardBaseURL: "", + ShardExternalURL: "", + ShardName: "root", + DiscoveryPollInterval: 60 * time.Second, + ExperimentalBindFreePort: false, + ConversionCELTransformationTimeout: time.Second, + + BatteriesIncluded: batteries.Defaults.List(), }, } @@ -180,6 +183,8 @@ func (o *Options) rawFlags() cliflag.NamedFlagSets { fs.BoolVar(&o.Extra.ExperimentalBindFreePort, "experimental-bind-free-port", o.Extra.ExperimentalBindFreePort, "Bind to a free port. --secure-port must be 0. Use the admin.kubeconfig to extract the chosen port.") fs.MarkHidden("experimental-bind-free-port") //nolint:errcheck + fs.DurationVar(&o.Extra.ConversionCELTransformationTimeout, "conversion-cel-transformation-timeout", o.Extra.ConversionCELTransformationTimeout, "Maximum amount of time that CEL transformations may take per object conversion.") + fs.StringSliceVar(&o.Extra.BatteriesIncluded, "batteries-included", o.Extra.BatteriesIncluded, fmt.Sprintf( `A list of batteries included (= default objects that might be unwanted in production, but are very helpful in trying out kcp or for development). These are the possible values: %s. diff --git a/test/e2e/conversion/conversion_test.go b/test/e2e/conversion/conversion_test.go new file mode 100644 index 000000000000..c9bda4a87c12 --- /dev/null +++ b/test/e2e/conversion/conversion_test.go @@ -0,0 +1,175 @@ +/* +Copyright 2022 The KCP 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 conversion + +import ( + "context" + "embed" + "fmt" + "strings" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + kcpdynamic "github.com/kcp-dev/client-go/dynamic" + "github.com/stretchr/testify/require" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/discovery/cached/memory" + "k8s.io/client-go/restmapper" + + "github.com/kcp-dev/kcp/config/helpers" + apisv1alpha1 "github.com/kcp-dev/kcp/pkg/apis/apis/v1alpha1" + "github.com/kcp-dev/kcp/pkg/apis/third_party/conditions/util/conditions" + kcpclientset "github.com/kcp-dev/kcp/pkg/client/clientset/versioned/cluster" + "github.com/kcp-dev/kcp/test/e2e/framework" +) + +//go:embed *.yaml +var embeddedResources embed.FS + +func TestAPIConversion(t *testing.T) { + t.Parallel() + + server := framework.SharedKcpServer(t) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + cfg := server.BaseConfig(t) + + kcpClusterClient, err := kcpclientset.NewForConfig(cfg) + require.NoError(t, err, "error creating kube cluster client") + + orgPath, _ := framework.NewOrganizationFixture(t, server) + + cache := memory.NewMemCacheClient(kcpClusterClient.Cluster(orgPath).Discovery()) + mapper := restmapper.NewDeferredDiscoveryRESTMapper(cache) + + dynamicClusterClient, err := kcpdynamic.NewForConfig(cfg) + require.NoError(t, err, "error creating dynamic cluster client") + + t.Logf("Setting up APIResourceSchema, APIConversion, APIExport, and APIBinding for widgets") + framework.Eventually(t, func() (bool, string) { + err := helpers.CreateResourceFromFS(ctx, dynamicClusterClient.Cluster(orgPath), mapper, nil, "resources.yaml", embeddedResources) + if err != nil { + return false, err.Error() + } + return true, "" + }, wait.ForeverTestTimeout, 100.*time.Millisecond, "failed to set up test resources") + + t.Logf("Waiting for initial binding to complete") + framework.Eventually(t, func() (bool, string) { + apiBinding, err := kcpClusterClient.Cluster(orgPath).ApisV1alpha1().APIBindings().Get(ctx, "widgets.example.io", metav1.GetOptions{}) + require.NoError(t, err, "error getting APIBinding") + + if conditions.IsTrue(apiBinding, apisv1alpha1.InitialBindingCompleted) { + return true, "" + } + + return false, fmt.Sprintf("%v", apiBinding.Status) + }, wait.ForeverTestTimeout, 100*time.Millisecond, "APIBinding never completed its initial binding") + + t.Logf("Resetting the RESTMapper so it can pick up widgets") + mapper.Reset() + + t.Logf("Creating v1 widget") + err = helpers.CreateResourceFromFS(ctx, dynamicClusterClient.Cluster(orgPath), mapper, nil, "v1-widget.yaml", embeddedResources) + require.NoError(t, err, "error creating v1 widget") + + v2GVR := schema.GroupVersionResource{Group: "example.io", Version: "v2", Resource: "widgets"} + + t.Logf("Retrieving the widget as v2") + v2WidgetClient := dynamicClusterClient.Cluster(orgPath).Resource(v2GVR) + v2Widget, err := v2WidgetClient.Get(ctx, "bob", metav1.GetOptions{}) + require.NoError(t, err, "error getting v2 widget") + + t.Logf("Ensuring field conversions work") + requireUnstructuredFieldEqual(t, v2Widget, "Bob", "spec", "name", "first") + requireUnstructuredFieldEqual(t, v2Widget, "Jones", "spec", "name", "last") + requireUnstructuredFieldEqual(t, v2Widget, "JONES", "spec", "name", "lastUpper") + + t.Logf("Setting and storing a v2-only field") + err = unstructured.SetNestedField(v2Widget.Object, "someNewValue", "spec", "someNewField", "hello") + require.NoError(t, err, "error setting spec.someNewField.hello") + _, err = v2WidgetClient.Update(ctx, v2Widget, metav1.UpdateOptions{}) + require.NoError(t, err, "error updating v2 widget") + + t.Logf("Getting the v2 widget again to make sure the field was preserved") + v2Widget, err = v2WidgetClient.Get(ctx, v2Widget.GetName(), metav1.GetOptions{}) + require.NoError(t, err, "error getting v2 widget") + requireUnstructuredFieldEqual(t, v2Widget, "someNewValue", "spec", "someNewField", "hello") + + t.Logf("Make sure we can create a v2 widget") + err = helpers.CreateResourceFromFS(ctx, dynamicClusterClient.Cluster(orgPath), mapper, nil, "v2-widget.yaml", embeddedResources) + require.NoError(t, err, "error creating v2 widget") + + v1GVR := schema.GroupVersionResource{Group: "example.io", Version: "v1", Resource: "widgets"} + t.Logf("Retrieving the widget as v2") + v1WidgetClient := dynamicClusterClient.Cluster(orgPath).Resource(v1GVR) + v1Widget, err := v1WidgetClient.Get(ctx, "alice", metav1.GetOptions{}) + require.NoError(t, err, "error getting v1 widget") + + t.Logf("Ensuring field conversions work") + requireUnstructuredFieldEqual(t, v1Widget, "Alice", "spec", "firstName") + requireUnstructuredFieldEqual(t, v1Widget, "Smith", "spec", "lastName") + + t.Logf("Updating v1 names") + err = unstructured.SetNestedField(v1Widget.Object, "Robot", "spec", "firstName") + require.NoError(t, err, "error setting spec.firstName") + err = unstructured.SetNestedField(v1Widget.Object, "Dragon", "spec", "lastName") + require.NoError(t, err, "error setting spec.lastName") + _, err = v1WidgetClient.Update(ctx, v1Widget, metav1.UpdateOptions{}) + require.NoError(t, err, "error updating v1 widget") + + t.Logf("Getting the widget as v2 to make sure the names were changed and the v2-only field was preserved") + v2Widget, err = v2WidgetClient.Get(ctx, v1Widget.GetName(), metav1.GetOptions{}) + require.NoError(t, err, "error getting v2 widget") + requireUnstructuredFieldEqual(t, v2Widget, "world", "spec", "someNewField", "hello") + requireUnstructuredFieldEqual(t, v2Widget, "Robot", "spec", "name", "first") + requireUnstructuredFieldEqual(t, v2Widget, "Dragon", "spec", "name", "last") + + t.Logf("Creating v1 widget without a last name") + err = helpers.CreateResourceFromFS(ctx, dynamicClusterClient.Cluster(orgPath), mapper, nil, "v1-widget-no-last-name.yaml", embeddedResources) + require.NoError(t, err, "error creating v1 widget") + + t.Logf("Retrieving the widget as v2") + v2Widget, err = v2WidgetClient.Get(ctx, "just-bob", metav1.GetOptions{}) + require.NoError(t, err, "error getting v2 widget") + + t.Logf("Ensuring field conversions work when not all fields are present") + requireUnstructuredFieldEqual(t, v2Widget, "Bob", "spec", "name", "first") + requireUnstructuredFieldAbsent(t, v2Widget, "spec", "name", "last") +} + +func requireUnstructuredFieldEqual(t *testing.T, u *unstructured.Unstructured, expected string, fields ...string) { + t.Helper() + actual, exists, err := unstructured.NestedFieldNoCopy(u.Object, fields...) + require.NoError(t, err, "error getting %s", strings.Join(fields, ".")) + require.True(t, exists, "field %s does not exist", strings.Join(fields, ".")) + require.Empty(t, cmp.Diff(expected, actual), "unexpected value for %s", strings.Join(fields, ".")) +} + +func requireUnstructuredFieldAbsent(t *testing.T, u *unstructured.Unstructured, fields ...string) { + t.Helper() + _, exists, err := unstructured.NestedFieldNoCopy(u.Object, fields...) + require.NoError(t, err, "error getting %s", strings.Join(fields, ".")) + require.False(t, exists, "field %s should not exist", strings.Join(fields, ".")) +} diff --git a/test/e2e/conversion/resources.yaml b/test/e2e/conversion/resources.yaml new file mode 100644 index 000000000000..def0294e3778 --- /dev/null +++ b/test/e2e/conversion/resources.yaml @@ -0,0 +1,125 @@ +apiVersion: apis.kcp.io/v1alpha1 +kind: APIResourceSchema +metadata: + name: rev0002.widgets.example.io + annotations: + bootstrap.kcp.io/create-only: "" +spec: + group: example.io + names: + kind: Widget + listKind: WidgetList + plural: widgets + singular: widget + scope: Cluster + versions: + - name: v1 + schema: + description: Widgets do things + properties: + apiVersion: + type: string + kind: + type: string + metadata: + type: object + spec: + properties: + firstName: + type: string + lastName: + type: string + type: object + status: + properties: + phase: + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} + - name: v2 + schema: + description: Widgets do things + properties: + apiVersion: + type: string + kind: + type: string + metadata: + type: object + spec: + properties: + someNewField: + type: object + properties: + hello: + type: string + name: + type: object + properties: + first: + type: string + last: + type: string + lastUpper: + type: string + type: object + status: + properties: + phase: + type: string + type: object + type: object + served: true + storage: false + subresources: + status: {} +--- +apiVersion: apis.kcp.io/v1alpha1 +kind: APIConversion +metadata: + name: rev0002.widgets.example.io + annotations: + bootstrap.kcp.io/create-only: "" +spec: + conversions: + - from: v1 + to: v2 + rules: + - field: .spec.firstName + destination: .spec.name.first + - field: .spec.lastName + destination: .spec.name.last + transformation: self + - field: .spec.lastName + destination: .spec.name.lastUpper + transformation: self.upperAscii() + - from: v2 + to: v1 + rules: + - field: .spec.name.first + destination: .spec.firstName + - field: .spec.name.last + destination: .spec.lastName + preserve: + - .spec.someNewField +--- +apiVersion: apis.kcp.io/v1alpha1 +kind: APIExport +metadata: + name: widgets.example.io +spec: + latestResourceSchemas: + - rev0002.widgets.example.io +--- +apiVersion: apis.kcp.io/v1alpha1 +kind: APIBinding +metadata: + name: widgets.example.io +spec: + reference: + export: + name: widgets.example.io diff --git a/test/e2e/conversion/v1-widget-no-last-name.yaml b/test/e2e/conversion/v1-widget-no-last-name.yaml new file mode 100644 index 000000000000..3fc22af001b7 --- /dev/null +++ b/test/e2e/conversion/v1-widget-no-last-name.yaml @@ -0,0 +1,6 @@ +apiVersion: example.io/v1 +kind: Widget +metadata: + name: just-bob +spec: + firstName: Bob diff --git a/test/e2e/conversion/v1-widget.yaml b/test/e2e/conversion/v1-widget.yaml new file mode 100644 index 000000000000..1a7cd1a41949 --- /dev/null +++ b/test/e2e/conversion/v1-widget.yaml @@ -0,0 +1,7 @@ +apiVersion: example.io/v1 +kind: Widget +metadata: + name: bob +spec: + firstName: Bob + lastName: Jones diff --git a/test/e2e/conversion/v2-widget.yaml b/test/e2e/conversion/v2-widget.yaml new file mode 100644 index 000000000000..0e3796ac8d01 --- /dev/null +++ b/test/e2e/conversion/v2-widget.yaml @@ -0,0 +1,10 @@ +apiVersion: example.io/v2 +kind: Widget +metadata: + name: alice +spec: + name: + first: Alice + last: Smith + someNewField: + hello: world