diff --git a/charts/zora/README.md b/charts/zora/README.md index 4d680836..3be22da0 100644 --- a/charts/zora/README.md +++ b/charts/zora/README.md @@ -137,6 +137,7 @@ The following table lists the configurable parameters of the Zora chart and thei | customChecksConfigMap | string | `"zora-custom-checks"` | Custom checks ConfigMap name | | httpsProxy | string | `""` | HTTPS proxy URL | | noProxy | string | `"kubernetes.default.svc.*,127.0.0.1,localhost"` | Comma-separated list of URL patterns to be excluded from going through the proxy | +| updateCRDs | bool | `true` for upgrades | Specifies whether CRDs should be updated by operator at startup | Specify each parameter using the `--set key=value[,key=value]` argument to `helm install`. For example, diff --git a/charts/zora/templates/operator/deployment.yaml b/charts/zora/templates/operator/deployment.yaml index d6725157..d81d4a38 100644 --- a/charts/zora/templates/operator/deployment.yaml +++ b/charts/zora/templates/operator/deployment.yaml @@ -91,6 +91,7 @@ spec: - --checks-configmap-namespace={{ .Release.Namespace }} - --checks-configmap-name={{ .Values.customChecksConfigMap }} - --kubexns-image={{ printf "%s:%s" .Values.kubexnsImage.repository .Values.kubexnsImage.tag }} + - --update-crds={{ .Values.updateCRDs | default .Release.IsUpgrade }} image: "{{ .Values.operator.image.repository }}:{{ .Values.operator.image.tag | default .Chart.AppVersion }}" imagePullPolicy: {{ .Values.operator.image.pullPolicy }} ports: diff --git a/charts/zora/templates/operator/rbac.yaml b/charts/zora/templates/operator/rbac.yaml index 44526b5f..e29b68e9 100644 --- a/charts/zora/templates/operator/rbac.yaml +++ b/charts/zora/templates/operator/rbac.yaml @@ -114,6 +114,16 @@ rules: - serviceaccounts/status verbs: - get +- apiGroups: + - apiextensions.k8s.io + resources: + - customresourcedefinitions + verbs: + - get + - list + - patch + - update + - watch - apiGroups: - batch resources: diff --git a/charts/zora/values.yaml b/charts/zora/values.yaml index 7a865460..ac8b539d 100644 --- a/charts/zora/values.yaml +++ b/charts/zora/values.yaml @@ -285,3 +285,7 @@ customChecksConfigMap: zora-custom-checks httpsProxy: "" # -- Comma-separated list of URL patterns to be excluded from going through the proxy noProxy: kubernetes.default.svc.*,127.0.0.1,localhost + +# -- (bool) Specifies whether CRDs should be updated by operator at startup +# @default -- `true` for upgrades +updateCRDs: \ No newline at end of file diff --git a/cmd/Dockerfile b/cmd/Dockerfile index 489c5d41..5bb771b2 100644 --- a/cmd/Dockerfile +++ b/cmd/Dockerfile @@ -25,6 +25,7 @@ COPY cmd/main.go cmd/main.go COPY api/ api/ COPY internal/ internal/ COPY pkg/ pkg/ +COPY config/crd/bases/ config/crd/bases/ RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o manager cmd/main.go diff --git a/cmd/main.go b/cmd/main.go index 72f66d4b..7adc4ca8 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -23,15 +23,17 @@ import ( "strings" "time" - // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) - // to ensure that exec-entrypoint and run can make use of them. - _ "k8s.io/client-go/plugin/pkg/client/auth" - "go.uber.org/zap/zapcore" + apiextensionsv1client "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset/typed/apiextensions/v1" "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" "k8s.io/client-go/kubernetes" clientgoscheme "k8s.io/client-go/kubernetes/scheme" + ctrllog "sigs.k8s.io/controller-runtime/pkg/log" + + // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) + // to ensure that exec-entrypoint and run can make use of them. + _ "k8s.io/client-go/plugin/pkg/client/auth" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/healthz" "sigs.k8s.io/controller-runtime/pkg/log/zap" @@ -41,6 +43,7 @@ import ( zorav1alpha2 "github.com/undistro/zora/api/zora/v1alpha2" zoracontroller "github.com/undistro/zora/internal/controller/zora" "github.com/undistro/zora/internal/saas" + "github.com/undistro/zora/pkg/crds" //+kubebuilder:scaffold:imports ) @@ -63,6 +66,7 @@ func main() { var probeAddr string var secureMetrics bool var enableHTTP2 bool + var updateCRDs bool var defaultPluginsNamespace string var defaultPluginsNames string var workerImage string @@ -86,6 +90,8 @@ func main() { "If set the metrics endpoint is served securely") flag.BoolVar(&enableHTTP2, "enable-http2", false, "If set, HTTP/2 will be enabled for the metrics and webhook servers") + flag.BoolVar(&updateCRDs, "update-crds", false, + "If set, operator will update Zora CRDs if needed") flag.StringVar(&defaultPluginsNamespace, "default-plugins-namespace", "zora-system", "The namespace of default plugins") flag.StringVar(&defaultPluginsNames, "default-plugins-names", "marvin,popeye", "Comma separated list of default plugins") flag.StringVar(&workerImage, "worker-image", "ghcr.io/undistro/zora/worker:latest", "Docker image name of Worker container") @@ -124,8 +130,8 @@ func main() { if !enableHTTP2 { tlsOpts = append(tlsOpts, disableHTTP2) } - - mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ + restConfig := ctrl.GetConfigOrDie() + mgr, err := ctrl.NewManager(restConfig, ctrl.Options{ Scheme: scheme, Metrics: metricsserver.Options{ BindAddress: metricsAddr, @@ -161,7 +167,7 @@ func main() { Client: mgr.GetClient(), Scheme: mgr.GetScheme(), Recorder: mgr.GetEventRecorderFor("cluster-controller"), - Config: mgr.GetConfig(), + Config: restConfig, OnUpdate: onClusterUpdate, OnDelete: onClusterDelete, }).SetupWithManager(mgr); err != nil { @@ -169,12 +175,6 @@ func main() { os.Exit(1) } - kcli, err := kubernetes.NewForConfig(mgr.GetConfig()) - if err != nil { - setupLog.Error(err, "unable to create Kubernetes clientset", "controller", "Cluster") - os.Exit(1) - } - annotations, err := annotations(cronJobAnnotations) if err != nil { setupLog.Error(err, "unable to parse annotations") @@ -182,7 +182,7 @@ func main() { } if err = (&zoracontroller.ClusterScanReconciler{ Client: mgr.GetClient(), - K8sClient: kcli, + K8sClient: kubernetes.NewForConfigOrDie(restConfig), Scheme: mgr.GetScheme(), Recorder: mgr.GetEventRecorderFor("clusterscan-controller"), DefaultPluginsNamespace: defaultPluginsNamespace, @@ -219,9 +219,17 @@ func main() { setupLog.Error(err, "unable to set up ready check") os.Exit(1) } + ctx := ctrl.SetupSignalHandler() + + if updateCRDs { + if err := crds.Update(ctrllog.IntoContext(ctx, setupLog), apiextensionsv1client.NewForConfigOrDie(restConfig)); err != nil { + setupLog.Error(err, "unable to update CRDs") + os.Exit(1) + } + } setupLog.Info("starting manager") - if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { + if err := mgr.Start(ctx); err != nil { setupLog.Error(err, "problem running manager") os.Exit(1) } diff --git a/config/crd/bases/embed.go b/config/crd/bases/embed.go new file mode 100644 index 00000000..d70eead8 --- /dev/null +++ b/config/crd/bases/embed.go @@ -0,0 +1,6 @@ +package bases + +import "embed" + +//go:embed zora.undistro.io*.yaml +var CRDsFS embed.FS diff --git a/config/crd/bases/embed_test.go b/config/crd/bases/embed_test.go new file mode 100644 index 00000000..03be19c7 --- /dev/null +++ b/config/crd/bases/embed_test.go @@ -0,0 +1,13 @@ +package bases + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestEmbedCRDs(t *testing.T) { + entries, err := CRDsFS.ReadDir(".") + assert.NoError(t, err) + assert.Equal(t, 6, len(entries)) +} diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 9cd04334..b5204c87 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -50,6 +50,16 @@ rules: - serviceaccounts/status verbs: - get +- apiGroups: + - apiextensions.k8s.io + resources: + - customresourcedefinitions + verbs: + - get + - list + - patch + - update + - watch - apiGroups: - batch resources: diff --git a/internal/controller/zora/customcheck_controller.go b/internal/controller/zora/customcheck_controller.go index 9f02fbd8..edcc71fc 100644 --- a/internal/controller/zora/customcheck_controller.go +++ b/internal/controller/zora/customcheck_controller.go @@ -52,6 +52,7 @@ type CustomCheckReconciler struct { //+kubebuilder:rbac:groups=zora.undistro.io,resources=customchecks/status,verbs=get;update;patch //+kubebuilder:rbac:groups=zora.undistro.io,resources=customchecks/finalizers,verbs=update //+kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=apiextensions.k8s.io,resources=customresourcedefinitions,verbs=get;list;watch;update;patch // Reconcile is part of the main kubernetes reconciliation loop which aims to // move the current state of the cluster closer to the desired state. diff --git a/pkg/crds/crds.go b/pkg/crds/crds.go new file mode 100644 index 00000000..b1798ae7 --- /dev/null +++ b/pkg/crds/crds.go @@ -0,0 +1,152 @@ +// Copyright 2024 Undistro 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 crds + +import ( + "context" + "fmt" + "path/filepath" + "sort" + + "github.com/undistro/zora/config/crd/bases" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + apiextensionsv1client "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset/typed/apiextensions/v1" + "k8s.io/apimachinery/pkg/api/equality" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrllog "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/yaml" +) + +var CRDs []apiextensionsv1.CustomResourceDefinition + +// Update updates Zora CRDs if needed +func Update(ctx context.Context, client *apiextensionsv1client.ApiextensionsV1Client) error { + log := ctrllog.FromContext(ctx) + for _, crd := range CRDs { + existing, err := client.CustomResourceDefinitions().Get(ctx, crd.Name, metav1.GetOptions{}) + if err != nil { + if apierrors.IsNotFound(err) { + log.Info("CRD not found", "name", crd.Name) + continue + } + return err + } + obj, updatedFields := merge(*existing, crd) + if len(updatedFields) == 0 { + log.Info("Unchanged CRD", "name", crd.Name) + continue + } + if _, err := client.CustomResourceDefinitions().Update(ctx, obj, metav1.UpdateOptions{}); err != nil { + return err + } + log.Info("CRD updated", "name", crd.Name, "changes", updatedFields) + } + return nil +} + +func merge(existing, desired apiextensionsv1.CustomResourceDefinition) (*apiextensionsv1.CustomResourceDefinition, []string) { + existingVersions := make(map[string]apiextensionsv1.CustomResourceDefinitionVersion, len(existing.Spec.Versions)) + for _, v := range existing.Spec.Versions { + existingVersions[v.Name] = v + } + result := existing.DeepCopy() + var updatedFields []string + + if result.Spec.PreserveUnknownFields != desired.Spec.PreserveUnknownFields { + result.Spec.PreserveUnknownFields = desired.Spec.PreserveUnknownFields + updatedFields = append(updatedFields, "spec.preserveUnknownFields") + } + + if !equality.Semantic.DeepEqual(conversionOrNone(result.Spec.Conversion), conversionOrNone(desired.Spec.Conversion)) { + result.Spec.Conversion = desired.Spec.Conversion + updatedFields = append(updatedFields, "spec.conversion") + } + + sort.Strings(result.Spec.Names.ShortNames) + sort.Strings(desired.Spec.Names.ShortNames) + if !equality.Semantic.DeepEqual(result.Spec.Names.ShortNames, desired.Spec.Names.ShortNames) { + result.Spec.Names.ShortNames = desired.Spec.Names.ShortNames + updatedFields = append(updatedFields, "spec.names.shortNames") + } + + for i, desiredVersion := range desired.Spec.Versions { + existingVersion, exists := existingVersions[desiredVersion.Name] + if !exists { + // desired version doesn't exist in the existing CRD + result.Spec.Versions = append(result.Spec.Versions, desiredVersion) + updatedFields = append(updatedFields, fmt.Sprintf(`spec.versions[?(@.name==%q)]`, desiredVersion.Name)) + continue + } + + if !equality.Semantic.DeepEqual(existingVersion.AdditionalPrinterColumns, desiredVersion.AdditionalPrinterColumns) { + result.Spec.Versions[i].AdditionalPrinterColumns = desiredVersion.AdditionalPrinterColumns + updatedFields = append(updatedFields, fmt.Sprintf(`spec.versions[?(@.name==%q)].additionalPrinterColumns`, desiredVersion.Name)) + } + desiredSchemaStatus := desiredVersion.Schema.OpenAPIV3Schema.Properties["status"] + if !equality.Semantic.DeepEqual(existingVersion.Schema.OpenAPIV3Schema.Properties["status"], desiredSchemaStatus) { + result.Spec.Versions[i].Schema.OpenAPIV3Schema.Properties["status"] = desiredSchemaStatus + updatedFields = append(updatedFields, fmt.Sprintf(`spec.versions[?(@.name==%q)].schema.openAPIV3Schema.properties.status`, desiredVersion.Name)) + } + if existingVersion.Served != desiredVersion.Served { + result.Spec.Versions[i].Served = desiredVersion.Served + updatedFields = append(updatedFields, fmt.Sprintf(`spec.versions[?(@.name==%q)].served`, desiredVersion.Name)) + } + if existingVersion.Storage != desiredVersion.Storage { + result.Spec.Versions[i].Storage = desiredVersion.Storage + updatedFields = append(updatedFields, fmt.Sprintf(`spec.versions[?(@.name==%q)].storage`, desiredVersion.Name)) + } + if existingVersion.Deprecated != desiredVersion.Deprecated { + result.Spec.Versions[i].Deprecated = desiredVersion.Deprecated + updatedFields = append(updatedFields, fmt.Sprintf(`spec.versions[?(@.name==%q)].deprecated`, desiredVersion.Name)) + } + if existingVersion.DeprecationWarning != desiredVersion.DeprecationWarning { + result.Spec.Versions[i].DeprecationWarning = desiredVersion.DeprecationWarning + updatedFields = append(updatedFields, fmt.Sprintf(`spec.versions[?(@.name==%q)].deprecationWarning`, desiredVersion.Name)) + } + } + return result, updatedFields +} + +func conversionOrNone(c *apiextensionsv1.CustomResourceConversion) *apiextensionsv1.CustomResourceConversion { + if c != nil { + return c + } + return &apiextensionsv1.CustomResourceConversion{Strategy: apiextensionsv1.NoneConverter} +} + +func init() { + entries, err := bases.CRDsFS.ReadDir(".") + if err != nil { + panic(err) + } + crds := make([]apiextensionsv1.CustomResourceDefinition, 0, len(entries)) + for _, entry := range entries { + name := entry.Name() + if filepath.Ext(name) != ".yaml" { + continue + } + bs, err := bases.CRDsFS.ReadFile(name) + if err != nil { + panic(err) + } + crd := &apiextensionsv1.CustomResourceDefinition{} + if err := yaml.Unmarshal(bs, crd); err != nil { + panic(err) + } + crds = append(crds, *crd) + } + CRDs = crds +} diff --git a/pkg/crds/crds_test.go b/pkg/crds/crds_test.go new file mode 100644 index 00000000..4cbcf9f1 --- /dev/null +++ b/pkg/crds/crds_test.go @@ -0,0 +1,217 @@ +// Copyright 2024 Undistro 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 crds + +import ( + "reflect" + "sort" + "testing" + + "github.com/google/go-cmp/cmp" + v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/pointer" +) + +var ( + exampleCRD = v1.CustomResourceDefinition{ + ObjectMeta: metav1.ObjectMeta{Name: "examples.zora.undistro.io"}, + Spec: v1.CustomResourceDefinitionSpec{ + Group: "zora.undistro.io", + Names: v1.CustomResourceDefinitionNames{ + Plural: "examples", + Singular: "example", + ShortNames: []string{"ex", "exs"}, + Kind: "Example", + ListKind: "ExampleList", + }, + Scope: "Namespaced", + Versions: []v1.CustomResourceDefinitionVersion{ + { + Name: "v1alpha1", + Served: true, + Storage: false, + Subresources: &v1.CustomResourceSubresources{Status: &v1.CustomResourceSubresourceStatus{}}, + AdditionalPrinterColumns: []v1.CustomResourceColumnDefinition{ + { + JSONPath: `.status.conditions[?(@.type=="Ready")].status`, + Name: "Ready", + Type: "string", + }, + }, + Schema: &v1.CustomResourceValidation{OpenAPIV3Schema: &v1.JSONSchemaProps{ + Properties: map[string]v1.JSONSchemaProps{ + "foo": {Type: "string"}, + "status": {Type: "object", Properties: map[string]v1.JSONSchemaProps{"bar": {Type: "string"}}}, + }, + }}, + }, + }, + Conversion: &v1.CustomResourceConversion{Strategy: v1.NoneConverter}, + PreserveUnknownFields: false, + }, + } + v1alpha2Version = v1.CustomResourceDefinitionVersion{ + Name: "v1alpha2", + Served: true, + Storage: true, + Subresources: &v1.CustomResourceSubresources{Status: &v1.CustomResourceSubresourceStatus{}}, + AdditionalPrinterColumns: []v1.CustomResourceColumnDefinition{ + { + JSONPath: `.status.conditions[?(@.type=="Ready")].status`, + Name: "Ready", + Type: "string", + }, + }, + Schema: &v1.CustomResourceValidation{OpenAPIV3Schema: &v1.JSONSchemaProps{ + Properties: map[string]v1.JSONSchemaProps{ + "foo": {Type: "string"}, + "bar": {Type: "string"}, + "status": {Type: "object", Properties: map[string]v1.JSONSchemaProps{"bar": {Type: "string"}}}, + }, + }}, + } +) + +func TestMergeCRDs(t *testing.T) { + type args struct { + existing v1.CustomResourceDefinition + updateFunc func(*v1.CustomResourceDefinition) + } + tests := []struct { + name string + args args + want *v1.CustomResourceDefinition + fields []string + }{ + { + name: "equal", + args: args{ + existing: exampleCRD, + updateFunc: func(crd *v1.CustomResourceDefinition) { + // just sorting update + crd.Spec.Names.ShortNames = []string{"exs", "ex"} + // the same value + crd.Spec.Versions[0].Schema.OpenAPIV3Schema.Properties["status"] = v1.JSONSchemaProps{Type: "object", Properties: map[string]v1.JSONSchemaProps{"bar": {Type: "string"}}} + // the default value should be with None strategy + crd.Spec.Conversion = nil + }, + }, + want: &exampleCRD, + fields: nil, + }, + { + name: "ignored fields", + args: args{ + existing: exampleCRD, + updateFunc: func(crd *v1.CustomResourceDefinition) { + crd.Spec.Versions[0].Schema.OpenAPIV3Schema.Properties["new"] = v1.JSONSchemaProps{Type: "string"} + crd.Spec.Scope = "Cluster" + crd.Spec.Group = "foo.bar" + crd.Spec.Names.Kind = "Foo" + crd.Spec.Names.ListKind = "FooList" + crd.Spec.Names.Plural = "foos" + crd.Spec.Names.Singular = "foo" + }, + }, + want: &exampleCRD, + fields: nil, + }, + { + name: "allowed updates", + args: args{ + existing: exampleCRD, + updateFunc: func(crd *v1.CustomResourceDefinition) { + crd.Spec.PreserveUnknownFields = true + crd.Spec.Conversion = &v1.CustomResourceConversion{Strategy: v1.WebhookConverter} + crd.Spec.Names.ShortNames = append(crd.Spec.Names.ShortNames, "new") + crd.Spec.Versions[0].AdditionalPrinterColumns[0].Name = "Readyz" + crd.Spec.Versions[0].Served = false + crd.Spec.Versions[0].Storage = true + crd.Spec.Versions[0].Deprecated = true + crd.Spec.Versions[0].DeprecationWarning = pointer.String("deprecated version") + crd.Spec.Versions[0].Schema.OpenAPIV3Schema.Properties["status"] = v1.JSONSchemaProps{Type: "string"} + crd.Spec.Versions = append(crd.Spec.Versions, v1alpha2Version) + }, + }, + fields: []string{ + "spec.preserveUnknownFields", + "spec.conversion", + "spec.names.shortNames", + `spec.versions[?(@.name=="v1alpha1")].additionalPrinterColumns`, + `spec.versions[?(@.name=="v1alpha1")].served`, + `spec.versions[?(@.name=="v1alpha1")].storage`, + `spec.versions[?(@.name=="v1alpha1")].deprecated`, + `spec.versions[?(@.name=="v1alpha1")].deprecationWarning`, + `spec.versions[?(@.name=="v1alpha1")].schema.openAPIV3Schema.properties.status`, + `spec.versions[?(@.name=="v1alpha2")]`, + }, + want: &v1.CustomResourceDefinition{ + ObjectMeta: metav1.ObjectMeta{Name: "examples.zora.undistro.io"}, + Spec: v1.CustomResourceDefinitionSpec{ + Group: "zora.undistro.io", + Names: v1.CustomResourceDefinitionNames{ + Plural: "examples", + Singular: "example", + ShortNames: []string{"ex", "exs", "new"}, + Kind: "Example", + ListKind: "ExampleList", + }, + Scope: "Namespaced", + Versions: []v1.CustomResourceDefinitionVersion{ + { + Name: "v1alpha1", + Served: false, + Storage: true, + Deprecated: true, + DeprecationWarning: pointer.String("deprecated version"), + Subresources: &v1.CustomResourceSubresources{Status: &v1.CustomResourceSubresourceStatus{}}, + AdditionalPrinterColumns: []v1.CustomResourceColumnDefinition{ + { + JSONPath: `.status.conditions[?(@.type=="Ready")].status`, + Name: "Readyz", + Type: "string", + }, + }, + Schema: &v1.CustomResourceValidation{OpenAPIV3Schema: &v1.JSONSchemaProps{ + Properties: map[string]v1.JSONSchemaProps{ + "foo": {Type: "string"}, + "status": {Type: "string"}}, + }}, + }, + v1alpha2Version, + }, + Conversion: &v1.CustomResourceConversion{Strategy: "Webhook"}, + PreserveUnknownFields: true, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + desired := tt.args.existing.DeepCopy() + tt.args.updateFunc(desired) + got, fields := merge(tt.args.existing, *desired) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("merge() mismatch (-want +got):\n%s", cmp.Diff(tt.want, got)) + } + sort.Strings(fields) + sort.Strings(tt.fields) + if !reflect.DeepEqual(fields, tt.fields) { + t.Errorf("merge() updated fields mismatch (-want +got):\n%s", cmp.Diff(tt.fields, fields)) + } + }) + } +}