diff --git a/projects/kubernetes-sigs/cluster-api/CHECKSUMS b/projects/kubernetes-sigs/cluster-api/CHECKSUMS index 2e9c04c470..1470a35962 100644 --- a/projects/kubernetes-sigs/cluster-api/CHECKSUMS +++ b/projects/kubernetes-sigs/cluster-api/CHECKSUMS @@ -1,10 +1,10 @@ -d4d51b89823630469937a7ff03e8f00828c31263460bc24bcd4f7009594b887a _output/bin/cluster-api/linux-amd64/cluster-api-provider-docker-manager -6b867211d9327153c806895861ccbb705659f7598b900d0ae4525fbe8e33ab51 _output/bin/cluster-api/linux-amd64/clusterctl -ec289d09b6148de261f5a932822c93273ba93543773a839bbe16e474eeba1c8c _output/bin/cluster-api/linux-amd64/kubeadm-bootstrap-manager -1aad13d8a323d5817556f7435fc808efa16a7a9144bb3664c209ea2a4d66e49f _output/bin/cluster-api/linux-amd64/kubeadm-control-plane-manager -6092538a81ea84d55a09f484e9ad9567c5b423d4e9969622eff7d75e10f59b68 _output/bin/cluster-api/linux-amd64/manager -6305ebc6d13fd7c14f60d3cc408a646014316d121cd7d33bde88a6f538d2b2d3 _output/bin/cluster-api/linux-arm64/cluster-api-provider-docker-manager -a1cea748c77bbed83d164365571e0ca2bc537f05361a8922e000cc4962d1f874 _output/bin/cluster-api/linux-arm64/clusterctl -ebb33b73af8e8b84e1dd473828a4cdf09153f1170f8c7a1122b9dce8f1a1221c _output/bin/cluster-api/linux-arm64/kubeadm-bootstrap-manager -a3a0e60b051a9a6f7662d68b9314cff2751bfd7e4fa2a14f6878df555e79a164 _output/bin/cluster-api/linux-arm64/kubeadm-control-plane-manager -27402c508a4a5abc9661c00f6cae99007110ff7d4b0bf506b32add586d8204fe _output/bin/cluster-api/linux-arm64/manager +d962cc2e9e4b4edbc37e2399ce5b2b022a024120ebeec40240495a5563babba1 _output/bin/cluster-api/linux-amd64/cluster-api-provider-docker-manager +7c6a3a8b4f5fb01a9b944f827ffa07d40aaadffa149ccaf0f78868678ebad24f _output/bin/cluster-api/linux-amd64/clusterctl +281768813da35fa35f6338d6ce19b4519d1ab935eb8ed10840e54c846005a1ff _output/bin/cluster-api/linux-amd64/kubeadm-bootstrap-manager +4606415a9cf12e49bf2e75cc6e41811b2542436e3776fe4aa5196850ce133609 _output/bin/cluster-api/linux-amd64/kubeadm-control-plane-manager +cc514fa00653d030e8a8161d9e53702e2094d99fdc4dfec616d36f3eade43d2b _output/bin/cluster-api/linux-amd64/manager +049741740f107bfebcd1e74358d812171a28b6e3cbf9b7ce3ad3e433fc91f814 _output/bin/cluster-api/linux-arm64/cluster-api-provider-docker-manager +393053f49a4d8a7d56d80664c2aa792c1d67deb6299806d0cc2e3db8f20561e3 _output/bin/cluster-api/linux-arm64/clusterctl +e765b96abcb6a585a48c1349810d6d423825e9f758e91c0cac72a1e6d8fd52f5 _output/bin/cluster-api/linux-arm64/kubeadm-bootstrap-manager +7de59b1bbf6307c601427487a689005886229eac78a510b0cb309a579341c1af _output/bin/cluster-api/linux-arm64/kubeadm-control-plane-manager +32cc838b3d97e0f956dd702dfc56014ffdcf0381b5db4e2120669f16df0d3676 _output/bin/cluster-api/linux-arm64/manager diff --git a/projects/kubernetes-sigs/cluster-api/patches/0021-Implement-Reconcile-mode-for-ClusterResourceSet.patch b/projects/kubernetes-sigs/cluster-api/patches/0021-Implement-Reconcile-mode-for-ClusterResourceSet.patch new file mode 100644 index 0000000000..a51d549150 --- /dev/null +++ b/projects/kubernetes-sigs/cluster-api/patches/0021-Implement-Reconcile-mode-for-ClusterResourceSet.patch @@ -0,0 +1,1334 @@ +From 6264da1f0439c301e85d146052468381f0628313 Mon Sep 17 00:00:00 2001 +From: Guillermo Gaston +Date: Fri, 4 Nov 2022 13:55:39 +0000 +Subject: [PATCH] Implement Reconcile mode for ClusterResourceSet + +--- + ....cluster.x-k8s.io_clusterresourcesets.yaml | 1 + + .../api/v1beta1/clusterresourceset_types.go | 10 +- + .../v1beta1/clusterresourceset_types_test.go | 65 ++++ + .../clusterresourcesetbinding_types.go | 16 +- + .../clusterresourcesetbinding_types_test.go | 60 ++++ + .../clusterresourceset_controller.go | 96 +++--- + .../clusterresourceset_controller_test.go | 291 +++++++++++++++--- + .../controllers/clusterresourceset_helpers.go | 131 +++++--- + .../controllers/clusterresourceset_scope.go | 173 +++++++++++ + .../clusterresourceset_scope_test.go | 187 +++++++++++ + 10 files changed, 878 insertions(+), 152 deletions(-) + create mode 100644 exp/addons/api/v1beta1/clusterresourceset_types_test.go + create mode 100644 exp/addons/internal/controllers/clusterresourceset_scope.go + create mode 100644 exp/addons/internal/controllers/clusterresourceset_scope_test.go + +diff --git a/config/crd/bases/addons.cluster.x-k8s.io_clusterresourcesets.yaml b/config/crd/bases/addons.cluster.x-k8s.io_clusterresourcesets.yaml +index f4a82cd2b..38b45ed0d 100644 +--- a/config/crd/bases/addons.cluster.x-k8s.io_clusterresourcesets.yaml ++++ b/config/crd/bases/addons.cluster.x-k8s.io_clusterresourcesets.yaml +@@ -438,6 +438,7 @@ spec: + Defaults to ApplyOnce. This field is immutable. + enum: + - ApplyOnce ++ - Reconcile + type: string + required: + - clusterSelector +diff --git a/exp/addons/api/v1beta1/clusterresourceset_types.go b/exp/addons/api/v1beta1/clusterresourceset_types.go +index 77fd7dcc5..4b2c774ea 100644 +--- a/exp/addons/api/v1beta1/clusterresourceset_types.go ++++ b/exp/addons/api/v1beta1/clusterresourceset_types.go +@@ -46,7 +46,7 @@ type ClusterResourceSetSpec struct { + Resources []ResourceRef `json:"resources,omitempty"` + + // Strategy is the strategy to be used during applying resources. Defaults to ApplyOnce. This field is immutable. +- // +kubebuilder:validation:Enum=ApplyOnce ++ // +kubebuilder:validation:Enum=ApplyOnce;Reconcile + // +optional + Strategy string `json:"strategy,omitempty"` + } +@@ -80,6 +80,9 @@ const ( + // ClusterResourceSetStrategyApplyOnce is the default strategy a ClusterResourceSet strategy is assigned by + // ClusterResourceSet controller after being created if not specified by user. + ClusterResourceSetStrategyApplyOnce ClusterResourceSetStrategy = "ApplyOnce" ++ // ClusterResourceSetStrategyReconcile reapplies the resources managed by a ClusterResourceSet ++ // if their normalize hash changes. ++ ClusterResourceSetStrategyReconcile ClusterResourceSetStrategy = "Reconcile" + ) + + // SetTypedStrategy sets the Strategy field to the string representation of ClusterResourceSetStrategy. +@@ -112,6 +115,11 @@ func (m *ClusterResourceSet) SetConditions(conditions clusterv1.Conditions) { + m.Status.Conditions = conditions + } + ++// IsStrategy compares the ClusterResourceSet strategy to the given ClusterResourceSetStrategy. ++func (m *ClusterResourceSet) IsStrategy(s ClusterResourceSetStrategy) bool { ++ return m.Spec.Strategy == string(s) ++} ++ + // +kubebuilder:object:root=true + // +kubebuilder:resource:path=clusterresourcesets,scope=Namespaced,categories=cluster-api + // +kubebuilder:subresource:status +diff --git a/exp/addons/api/v1beta1/clusterresourceset_types_test.go b/exp/addons/api/v1beta1/clusterresourceset_types_test.go +new file mode 100644 +index 000000000..c46ce58bb +--- /dev/null ++++ b/exp/addons/api/v1beta1/clusterresourceset_types_test.go +@@ -0,0 +1,65 @@ ++/* ++Copyright 2022 The Kubernetes Authors. ++ ++Licensed under the Apache License, Version 2.0 (the "License"); ++you may not use this file except in compliance with the License. ++You may obtain a copy of the License at ++ ++ http://www.apache.org/licenses/LICENSE-2.0 ++ ++Unless required by applicable law or agreed to in writing, software ++distributed under the License is distributed on an "AS IS" BASIS, ++WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. ++See the License for the specific language governing permissions and ++limitations under the License. ++*/ ++ ++package v1beta1 ++ ++import ( ++ "testing" ++ ++ . "github.com/onsi/gomega" ++) ++ ++func TestClusterResourceSetIsStrategy(t *testing.T) { ++ tests := []struct { ++ name string ++ crs *ClusterResourceSet ++ strategy ClusterResourceSetStrategy ++ want bool ++ }{ ++ { ++ name: "no strategy set", ++ crs: &ClusterResourceSet{}, ++ strategy: ClusterResourceSetStrategyApplyOnce, ++ want: false, ++ }, ++ { ++ name: "diff strategy", ++ crs: &ClusterResourceSet{ ++ Spec: ClusterResourceSetSpec{ ++ Strategy: string(ClusterResourceSetStrategyReconcile), ++ }, ++ }, ++ strategy: ClusterResourceSetStrategyApplyOnce, ++ want: false, ++ }, ++ { ++ name: "same strategy", ++ crs: &ClusterResourceSet{ ++ Spec: ClusterResourceSetSpec{ ++ Strategy: string(ClusterResourceSetStrategyReconcile), ++ }, ++ }, ++ strategy: ClusterResourceSetStrategyReconcile, ++ want: true, ++ }, ++ } ++ for _, tt := range tests { ++ t.Run(tt.name, func(t *testing.T) { ++ gs := NewWithT(t) ++ gs.Expect(tt.crs.IsStrategy(tt.strategy)).To(Equal(tt.want)) ++ }) ++ } ++} +diff --git a/exp/addons/api/v1beta1/clusterresourcesetbinding_types.go b/exp/addons/api/v1beta1/clusterresourcesetbinding_types.go +index 14f15991b..3b7eb0005 100644 +--- a/exp/addons/api/v1beta1/clusterresourcesetbinding_types.go ++++ b/exp/addons/api/v1beta1/clusterresourcesetbinding_types.go +@@ -56,14 +56,22 @@ type ResourceSetBinding struct { + + // IsApplied returns true if the resource is applied to the cluster by checking the cluster's binding. + func (r *ResourceSetBinding) IsApplied(resourceRef ResourceRef) bool { ++ resourceBinding := r.GetResourceBinding(resourceRef) ++ if resourceBinding == nil { ++ return false ++ } ++ ++ return resourceBinding.Applied ++} ++ ++// GetResourceBinding returns a ResourceBinding for a resource ref if present. ++func (r *ResourceSetBinding) GetResourceBinding(resourceRef ResourceRef) *ResourceBinding { + for _, resource := range r.Resources { + if reflect.DeepEqual(resource.ResourceRef, resourceRef) { +- if resource.Applied { +- return true +- } ++ return &resource + } + } +- return false ++ return nil + } + + // SetBinding sets resourceBinding for a resource in resourceSetbinding either by updating the existing one or +diff --git a/exp/addons/api/v1beta1/clusterresourcesetbinding_types_test.go b/exp/addons/api/v1beta1/clusterresourcesetbinding_types_test.go +index 5c59e40f5..2e71c3b7b 100644 +--- a/exp/addons/api/v1beta1/clusterresourcesetbinding_types_test.go ++++ b/exp/addons/api/v1beta1/clusterresourcesetbinding_types_test.go +@@ -90,6 +90,66 @@ func TestIsResourceApplied(t *testing.T) { + } + } + ++func TestResourceSetBindingGetResourceBinding(t *testing.T) { ++ resourceRefApplyFailed := ResourceRef{ ++ Name: "applyFailed", ++ Kind: "Secret", ++ } ++ resourceRefApplySucceeded := ResourceRef{ ++ Name: "ApplySucceeded", ++ Kind: "Secret", ++ } ++ resourceRefNotExist := ResourceRef{ ++ Name: "notExist", ++ Kind: "Secret", ++ } ++ ++ resourceRefApplyFailedBinding := ResourceBinding{ ++ ResourceRef: resourceRefApplyFailed, ++ Applied: false, ++ Hash: "", ++ LastAppliedTime: &metav1.Time{Time: time.Now().UTC()}, ++ } ++ crsBinding := &ResourceSetBinding{ ++ ClusterResourceSetName: "test-clusterResourceSet", ++ Resources: []ResourceBinding{ ++ { ++ ResourceRef: resourceRefApplySucceeded, ++ Applied: true, ++ Hash: "xyz", ++ LastAppliedTime: &metav1.Time{Time: time.Now().UTC()}, ++ }, ++ resourceRefApplyFailedBinding, ++ }, ++ } ++ ++ tests := []struct { ++ name string ++ resourceSetBinding *ResourceSetBinding ++ resourceRef ResourceRef ++ want *ResourceBinding ++ }{ ++ { ++ name: "does't exist", ++ resourceSetBinding: crsBinding, ++ resourceRef: resourceRefNotExist, ++ want: nil, ++ }, ++ { ++ name: "does't exist", ++ resourceSetBinding: crsBinding, ++ resourceRef: resourceRefApplyFailed, ++ want: &resourceRefApplyFailedBinding, ++ }, ++ } ++ for _, tt := range tests { ++ t.Run(tt.name, func(t *testing.T) { ++ gs := NewWithT(t) ++ gs.Expect(tt.resourceSetBinding.GetResourceBinding(tt.resourceRef)).To(Equal(tt.want)) ++ }) ++ } ++} ++ + func TestSetResourceBinding(t *testing.T) { + resourceRefApplyFailed := ResourceRef{ + Name: "applyFailed", +diff --git a/exp/addons/internal/controllers/clusterresourceset_controller.go b/exp/addons/internal/controllers/clusterresourceset_controller.go +index 0e1f542cf..00ae815c3 100644 +--- a/exp/addons/internal/controllers/clusterresourceset_controller.go ++++ b/exp/addons/internal/controllers/clusterresourceset_controller.go +@@ -18,9 +18,7 @@ package controllers + + import ( + "context" +- "encoding/base64" + "fmt" +- "sort" + "time" + + "github.com/pkg/errors" +@@ -37,23 +35,22 @@ import ( + "sigs.k8s.io/controller-runtime/pkg/client/apiutil" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" ++ "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" ++ "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/source" + + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" + "sigs.k8s.io/cluster-api/controllers/remote" + addonsv1 "sigs.k8s.io/cluster-api/exp/addons/api/v1beta1" +- resourcepredicates "sigs.k8s.io/cluster-api/exp/addons/internal/controllers/predicates" + "sigs.k8s.io/cluster-api/util" + "sigs.k8s.io/cluster-api/util/conditions" + "sigs.k8s.io/cluster-api/util/patch" + "sigs.k8s.io/cluster-api/util/predicates" + ) + +-var ( +- // ErrSecretTypeNotSupported signals that a Secret is not supported. +- ErrSecretTypeNotSupported = errors.New("unsupported secret type") +-) ++// ErrSecretTypeNotSupported signals that a Secret is not supported. ++var ErrSecretTypeNotSupported = errors.New("unsupported secret type") + + // +kubebuilder:rbac:groups=core,resources=secrets,verbs=get;list;watch;patch + // +kubebuilder:rbac:groups=core,resources=configmaps,verbs=get;list;watch;patch +@@ -81,7 +78,12 @@ func (r *ClusterResourceSetReconciler) SetupWithManager(ctx context.Context, mgr + handler.EnqueueRequestsFromMapFunc(r.resourceToClusterResourceSet), + builder.OnlyMetadata, + builder.WithPredicates( +- resourcepredicates.ResourceCreate(ctrl.LoggerFrom(ctx)), ++ predicate.Funcs{ ++ CreateFunc: func(e event.CreateEvent) bool { return true }, ++ UpdateFunc: func(e event.UpdateEvent) bool { return true }, ++ DeleteFunc: func(e event.DeleteEvent) bool { return false }, ++ GenericFunc: func(e event.GenericEvent) bool { return false }, ++ }, + ), + ). + Watches( +@@ -89,13 +91,17 @@ func (r *ClusterResourceSetReconciler) SetupWithManager(ctx context.Context, mgr + handler.EnqueueRequestsFromMapFunc(r.resourceToClusterResourceSet), + builder.OnlyMetadata, + builder.WithPredicates( +- resourcepredicates.ResourceCreate(ctrl.LoggerFrom(ctx)), ++ predicate.Funcs{ ++ CreateFunc: func(e event.CreateEvent) bool { return true }, ++ UpdateFunc: func(e event.UpdateEvent) bool { return true }, ++ DeleteFunc: func(e event.DeleteEvent) bool { return false }, ++ GenericFunc: func(e event.GenericEvent) bool { return false }, ++ }, + ), + ). + WithOptions(options). + WithEventFilter(predicates.ResourceNotPausedAndHasFilterLabel(ctrl.LoggerFrom(ctx), r.WatchFilterValue)). + Complete(r) +- + if err != nil { + return errors.Wrap(err, "failed setting up with a controller manager") + } +@@ -234,6 +240,8 @@ func (r *ClusterResourceSetReconciler) getClustersByClusterResourceSetSelector(c + // cluster's ClusterResourceSetBinding. + // In ApplyOnce strategy, resources are applied only once to a particular cluster. ClusterResourceSetBinding is used to check if a resource is applied before. + // It applies resources best effort and continue on scenarios like: unsupported resource types, failure during creation, missing resources. ++// In Reconcile strategy, resources are re-applied to a particular cluster when their definition changes. The hash in ClusterResourceSetBinding is used to check ++// if a resource has changed or not. + // TODO: If a resource already exists in the cluster but not applied by ClusterResourceSet, the resource will be updated ? + func (r *ClusterResourceSetReconciler) ApplyClusterResourceSet(ctx context.Context, cluster *clusterv1.Cluster, clusterResourceSet *addonsv1.ClusterResourceSet) error { + log := ctrl.LoggerFrom(ctx, "cluster", cluster.Name) +@@ -268,11 +276,6 @@ func (r *ClusterResourceSetReconciler) ApplyClusterResourceSet(ctx context.Conte + + // Iterate all resources and apply them to the cluster and update the resource status in the ClusterResourceSetBinding object. + for _, resource := range clusterResourceSet.Spec.Resources { +- // If resource is already applied successfully and clusterResourceSet mode is "ApplyOnce", continue. (No need to check hash changes here) +- if resourceSetBinding.IsApplied(resource) { +- continue +- } +- + unstructuredObj, err := r.getResource(ctx, resource, cluster.GetNamespace()) + if err != nil { + if err == ErrSecretTypeNotSupported { +@@ -289,69 +292,44 @@ func (r *ClusterResourceSetReconciler) ApplyClusterResourceSet(ctx context.Conte + continue + } + +- // Set status in ClusterResourceSetBinding in case of early continue due to a failure. +- // Set only when resource is retrieved successfully. +- resourceSetBinding.SetBinding(addonsv1.ResourceBinding{ +- ResourceRef: resource, +- Hash: "", +- Applied: false, +- LastAppliedTime: &metav1.Time{Time: time.Now().UTC()}, +- }) +- + if err := r.patchOwnerRefToResource(ctx, clusterResourceSet, unstructuredObj); err != nil { + log.Error(err, "Failed to patch ClusterResourceSet as resource owner reference", + "Resource type", unstructuredObj.GetKind(), "Resource name", unstructuredObj.GetName()) + errList = append(errList, err) + } + +- // Since maps are not ordered, we need to order them to get the same hash at each reconcile. +- keys := make([]string, 0) +- data, ok := unstructuredObj.UnstructuredContent()["data"] +- if !ok { +- errList = append(errList, errors.New("failed to get data field from the resource")) ++ resourceScope, scopeError := reconcileScopeForResource(clusterResourceSet, resource, resourceSetBinding, unstructuredObj) ++ if scopeError == nil && !resourceScope.needsApply() { + continue + } + +- unstructuredData := data.(map[string]interface{}) +- for key := range unstructuredData { +- keys = append(keys, key) +- } +- sort.Strings(keys) +- +- dataList := make([][]byte, 0) +- for _, key := range keys { +- val, ok, err := unstructured.NestedString(unstructuredData, key) +- if !ok || err != nil { +- errList = append(errList, errors.New("failed to get value field from the resource")) +- continue +- } +- +- byteArr := []byte(val) +- // If the resource is a Secret, data needs to be decoded. +- if unstructuredObj.GetKind() == string(addonsv1.SecretClusterResourceSetResourceKind) { +- byteArr, _ = base64.StdEncoding.DecodeString(val) +- } ++ // Set status in ClusterResourceSetBinding in case of early continue due to a failure. ++ // Set only when resource is retrieved successfully. ++ resourceSetBinding.SetBinding(addonsv1.ResourceBinding{ ++ ResourceRef: resource, ++ Hash: "", ++ Applied: false, ++ LastAppliedTime: &metav1.Time{Time: time.Now().UTC()}, ++ }) + +- dataList = append(dataList, byteArr) ++ if scopeError != nil { ++ errList = append(errList, scopeError) ++ continue + } + + // Apply all values in the key-value pair of the resource to the cluster. + // As there can be multiple key-value pairs in a resource, each value may have multiple objects in it. + isSuccessful := true +- for i := range dataList { +- data := dataList[i] +- +- if err := apply(ctx, remoteClient, data); err != nil { +- isSuccessful = false +- log.Error(err, "failed to apply ClusterResourceSet resource", "Resource kind", resource.Kind, "Resource name", resource.Name) +- conditions.MarkFalse(clusterResourceSet, addonsv1.ResourcesAppliedCondition, addonsv1.ApplyFailedReason, clusterv1.ConditionSeverityWarning, err.Error()) +- errList = append(errList, err) +- } ++ if err = apply(ctx, remoteClient, resourceScope); err != nil { ++ isSuccessful = false ++ log.Error(err, "failed to apply ClusterResourceSet resource", "Resource kind", resource.Kind, "Resource name", resource.Name) ++ conditions.MarkFalse(clusterResourceSet, addonsv1.ResourcesAppliedCondition, addonsv1.ApplyFailedReason, clusterv1.ConditionSeverityWarning, err.Error()) ++ errList = append(errList, err) + } + + resourceSetBinding.SetBinding(addonsv1.ResourceBinding{ + ResourceRef: resource, +- Hash: computeHash(dataList), ++ Hash: resourceScope.hash(), + Applied: isSuccessful, + LastAppliedTime: &metav1.Time{Time: time.Now().UTC()}, + }) +diff --git a/exp/addons/internal/controllers/clusterresourceset_controller_test.go b/exp/addons/internal/controllers/clusterresourceset_controller_test.go +index 33bc5919c..1c24b8c69 100644 +--- a/exp/addons/internal/controllers/clusterresourceset_controller_test.go ++++ b/exp/addons/internal/controllers/clusterresourceset_controller_test.go +@@ -18,6 +18,7 @@ package controllers + + import ( + "fmt" ++ "reflect" + "testing" + "time" + +@@ -27,9 +28,11 @@ import ( + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" ++ "sigs.k8s.io/yaml" + + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" + addonsv1 "sigs.k8s.io/cluster-api/exp/addons/api/v1beta1" ++ "sigs.k8s.io/cluster-api/internal/test/envtest" + "sigs.k8s.io/cluster-api/util" + ) + +@@ -39,13 +42,16 @@ const ( + + func TestClusterResourceSetReconciler(t *testing.T) { + var ( +- clusterResourceSetName string +- testCluster *clusterv1.Cluster +- clusterName string +- labels map[string]string +- configmapName = "test-configmap" +- secretName = "test-secret" +- namespacePrefix = "test-cluster-resource-set" ++ clusterResourceSetName string ++ testCluster *clusterv1.Cluster ++ clusterName string ++ labels map[string]string ++ configmapName = "test-configmap" ++ secretName = "test-secret" ++ namespacePrefix = "test-cluster-resource-set" ++ resourceConfigMap1Name = "resource-configmap" ++ resourceConfigMap2Name = "resource-configmap-2" ++ resourceConfigMapsNamespace = "default" + ) + + setup := func(t *testing.T, g *WithT) *corev1.Namespace { +@@ -64,19 +70,29 @@ func TestClusterResourceSetReconciler(t *testing.T) { + g.Expect(env.Create(ctx, testCluster)).To(Succeed()) + t.Log("Creating the remote Cluster kubeconfig") + g.Expect(env.CreateKubeconfigSecret(ctx, testCluster)).To(Succeed()) ++ ++ resourceConfigMap1Content := fmt.Sprintf(`metadata: ++ name: %s ++ namespace: %s ++kind: ConfigMap ++apiVersion: v1`, resourceConfigMap1Name, resourceConfigMapsNamespace) ++ + testConfigmap := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: configmapName, + Namespace: ns.Name, + }, + Data: map[string]string{ +- "cm": `metadata: +- name: resource-configmap +- namespace: default +-kind: ConfigMap +-apiVersion: v1`, ++ "cm": resourceConfigMap1Content, + }, + } ++ ++ resourceConfigMap2Content := fmt.Sprintf(`metadata: ++kind: ConfigMap ++apiVersion: v1 ++metadata: ++ name: %s ++ namespace: %s`, resourceConfigMap2Name, resourceConfigMapsNamespace) + testSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, +@@ -84,12 +100,7 @@ apiVersion: v1`, + }, + Type: "addons.cluster.x-k8s.io/resource-set", + StringData: map[string]string{ +- "cm": `metadata: +-kind: ConfigMap +-apiVersion: v1 +-metadata: +- name: resource-configmap +- namespace: default`, ++ "cm": resourceConfigMap2Content, + }, + } + t.Log("Creating a Secret and a ConfigMap with ConfigMap in their data field") +@@ -141,7 +152,32 @@ metadata: + Name: secretName, + Namespace: ns.Name, + }})).To(Succeed()) ++ ++ cm1 := &corev1.ConfigMap{ ++ ObjectMeta: metav1.ObjectMeta{ ++ Name: resourceConfigMap1Name, ++ Namespace: resourceConfigMapsNamespace, ++ }, ++ } ++ if err = env.Get(ctx, client.ObjectKeyFromObject(cm1), cm1); err == nil { ++ g.Expect(env.Delete(ctx, cm1)).To(Succeed()) ++ } ++ cm2 := &corev1.ConfigMap{ ++ ObjectMeta: metav1.ObjectMeta{ ++ Name: resourceConfigMap2Name, ++ Namespace: resourceConfigMapsNamespace, ++ }, ++ } ++ if err = env.Get(ctx, client.ObjectKeyFromObject(cm2), cm2); err == nil { ++ g.Expect(env.Delete(ctx, cm2)).To(Succeed()) ++ } ++ + g.Expect(env.Delete(ctx, ns)).To(Succeed()) ++ ++ clusterKey := client.ObjectKey{Namespace: testCluster.Namespace, Name: testCluster.Name} ++ if err = env.Get(ctx, clusterKey, testCluster); err == nil { ++ g.Expect(env.Delete(ctx, testCluster)).To(Succeed()) ++ } + } + + t.Run("Should reconcile a ClusterResourceSet with multiple resources when a cluster with matching label exists", func(t *testing.T) { +@@ -170,35 +206,7 @@ metadata: + g.Expect(env.Create(ctx, clusterResourceSetInstance)).To(Succeed()) + + t.Log("Verifying ClusterResourceSetBinding is created with cluster owner reference") +- g.Eventually(func() bool { +- binding := &addonsv1.ClusterResourceSetBinding{} +- clusterResourceSetBindingKey := client.ObjectKey{ +- Namespace: testCluster.Namespace, +- Name: testCluster.Name, +- } +- err := env.Get(ctx, clusterResourceSetBindingKey, binding) +- if err != nil { +- return false +- } +- +- if len(binding.Spec.Bindings) != 1 { +- return false +- } +- if len(binding.Spec.Bindings[0].Resources) != 2 { +- return false +- } +- +- if binding.Spec.Bindings[0].Resources[0].Applied != true || binding.Spec.Bindings[0].Resources[1].Applied != true { +- return false +- } +- +- return util.HasOwnerRef(binding.GetOwnerReferences(), metav1.OwnerReference{ +- APIVersion: clusterv1.GroupVersion.String(), +- Kind: "Cluster", +- Name: testCluster.Name, +- UID: testCluster.UID, +- }) +- }, timeout).Should(BeTrue()) ++ g.Eventually(clusterResourceSetBindingReady(env, testCluster), timeout).Should(BeTrue()) + t.Log("Deleting the Cluster") + g.Expect(env.Delete(ctx, testCluster)).To(Succeed()) + }) +@@ -588,4 +596,193 @@ metadata: + return false + }, timeout).Should(BeTrue()) + }) ++ ++ t.Run("Should reconcile a ClusterResourceSet with Reconcile strategy after the resources have already been created", func(t *testing.T) { ++ g := NewWithT(t) ++ ns := setup(t, g) ++ defer teardown(t, g, ns) ++ ++ t.Log("Updating the cluster with labels") ++ testCluster.SetLabels(labels) ++ g.Expect(env.Update(ctx, testCluster)).To(Succeed()) ++ ++ t.Log("Creating a ClusterResourceSet instance that has same labels as selector") ++ clusterResourceSet := &addonsv1.ClusterResourceSet{ ++ ObjectMeta: metav1.ObjectMeta{ ++ Name: clusterResourceSetName, ++ Namespace: ns.Name, ++ }, ++ Spec: addonsv1.ClusterResourceSetSpec{ ++ Strategy: string(addonsv1.ClusterResourceSetStrategyReconcile), ++ ClusterSelector: metav1.LabelSelector{ ++ MatchLabels: labels, ++ }, ++ Resources: []addonsv1.ResourceRef{{Name: configmapName, Kind: "ConfigMap"}, {Name: secretName, Kind: "Secret"}}, ++ }, ++ } ++ ++ g.Expect(env.Create(ctx, clusterResourceSet)).To(Succeed()) ++ ++ t.Log("Verifying ClusterResourceSetBinding is created with cluster owner reference") ++ clusterResourceSetBindingKey := client.ObjectKey{ ++ Namespace: testCluster.Namespace, ++ Name: testCluster.Name, ++ } ++ g.Eventually(clusterResourceSetBindingReady(env, testCluster), timeout).Should(BeTrue()) ++ ++ binding := &addonsv1.ClusterResourceSetBinding{} ++ err := env.Get(ctx, clusterResourceSetBindingKey, binding) ++ g.Expect(err).NotTo(HaveOccurred()) ++ resourceHashes := map[string]string{} ++ for _, r := range binding.Spec.Bindings[0].Resources { ++ resourceHashes[r.Name] = r.Hash ++ } ++ ++ t.Log("Verifying resource ConfigMap 1 has been created") ++ resourceConfigMap1Key := client.ObjectKey{ ++ Namespace: resourceConfigMapsNamespace, ++ Name: resourceConfigMap1Name, ++ } ++ g.Eventually(func() error { ++ cm := &corev1.ConfigMap{} ++ return env.Get(ctx, resourceConfigMap1Key, cm) ++ }, timeout).Should(Succeed()) ++ ++ t.Log("Verifying resource ConfigMap 2 has been created") ++ resourceConfigMap2Key := client.ObjectKey{ ++ Namespace: resourceConfigMapsNamespace, ++ Name: resourceConfigMap2Name, ++ } ++ g.Eventually(func() error { ++ cm := &corev1.ConfigMap{} ++ return env.Get(ctx, resourceConfigMap2Key, cm) ++ }, timeout).Should(Succeed()) ++ ++ resourceConfigMap1 := configMap( ++ resourceConfigMap1Name, ++ resourceConfigMapsNamespace, ++ map[string]string{ ++ "my_new_config": "some_value", ++ }, ++ ) ++ ++ resourceConfigMap1Content, err := yaml.Marshal(resourceConfigMap1) ++ g.Expect(err).NotTo(HaveOccurred()) ++ ++ testConfigmap := configMap( ++ configmapName, ++ ns.Name, ++ map[string]string{ ++ "cm": string(resourceConfigMap1Content), ++ }, ++ ) ++ ++ resourceConfigMap2 := configMap( ++ resourceConfigMap2Name, ++ resourceConfigMapsNamespace, ++ map[string]string{ ++ "my_new_secret_config": "some_secret_value", ++ }, ++ ) ++ ++ resourceConfigMap2Content, err := yaml.Marshal(resourceConfigMap2) ++ g.Expect(err).NotTo(HaveOccurred()) ++ ++ testSecret := &corev1.Secret{ ++ ObjectMeta: metav1.ObjectMeta{ ++ Name: secretName, ++ Namespace: ns.Name, ++ }, ++ Type: "addons.cluster.x-k8s.io/resource-set", ++ StringData: map[string]string{ ++ "cm": string(resourceConfigMap2Content), ++ }, ++ } ++ t.Log("Updating the Secret and a ConfigMap with updated ConfigMaps in their data field") ++ g.Expect(env.Update(ctx, testConfigmap)).To(Succeed()) ++ g.Expect(env.Update(ctx, testSecret)).To(Succeed()) ++ ++ t.Log("Verifying ClusterResourceSetBinding has been updated with new hashes") ++ g.Eventually(func() error { ++ binding := &addonsv1.ClusterResourceSetBinding{} ++ if err := env.Get(ctx, clusterResourceSetBindingKey, binding); err != nil { ++ return err ++ } ++ ++ for _, r := range binding.Spec.Bindings[0].Resources { ++ if resourceHashes[r.Name] == r.Hash { ++ return errors.Errorf("resource binding for %s hasn't been updated with new hash", r.Name) ++ } ++ } ++ ++ return nil ++ }, timeout).Should(Succeed()) ++ ++ t.Log("Checking resource ConfigMap 1 has been updated") ++ g.Eventually(configMapHasBeenUpdated(env, resourceConfigMap1Key, resourceConfigMap1), timeout).Should(Succeed()) ++ ++ t.Log("Checking resource ConfigMap 2 has been updated") ++ g.Eventually(configMapHasBeenUpdated(env, resourceConfigMap2Key, resourceConfigMap2), timeout).Should(Succeed()) ++ }) ++} ++ ++func clusterResourceSetBindingReady(env *envtest.Environment, cluster *clusterv1.Cluster) func() bool { ++ return func() bool { ++ clusterResourceSetBindingKey := client.ObjectKey{ ++ Namespace: cluster.Namespace, ++ Name: cluster.Name, ++ } ++ binding := &addonsv1.ClusterResourceSetBinding{} ++ err := env.Get(ctx, clusterResourceSetBindingKey, binding) ++ if err != nil { ++ return false ++ } ++ ++ if len(binding.Spec.Bindings) != 1 { ++ return false ++ } ++ if len(binding.Spec.Bindings[0].Resources) != 2 { ++ return false ++ } ++ ++ if !binding.Spec.Bindings[0].Resources[0].Applied || !binding.Spec.Bindings[0].Resources[1].Applied { ++ return false ++ } ++ ++ return util.HasOwnerRef(binding.GetOwnerReferences(), metav1.OwnerReference{ ++ APIVersion: clusterv1.GroupVersion.String(), ++ Kind: "Cluster", ++ Name: cluster.Name, ++ UID: cluster.UID, ++ }) ++ } ++} ++ ++func configMapHasBeenUpdated(env *envtest.Environment, key client.ObjectKey, newState *corev1.ConfigMap) func() error { ++ return func() error { ++ cm := &corev1.ConfigMap{} ++ if err := env.Get(ctx, key, cm); err != nil { ++ return err ++ } ++ ++ if !reflect.DeepEqual(cm.Data, newState.Data) { ++ return errors.Errorf("configMap %s hasn't been updated yet", key.Name) ++ } ++ ++ return nil ++ } ++} ++ ++func configMap(name, namespace string, data map[string]string) *corev1.ConfigMap { ++ return &corev1.ConfigMap{ ++ TypeMeta: metav1.TypeMeta{ ++ APIVersion: "v1", ++ Kind: "ConfigMap", ++ }, ++ ObjectMeta: metav1.ObjectMeta{ ++ Name: name, ++ Namespace: namespace, ++ }, ++ Data: data, ++ } + } +diff --git a/exp/addons/internal/controllers/clusterresourceset_helpers.go b/exp/addons/internal/controllers/clusterresourceset_helpers.go +index a45c8fbdd..26488b9b3 100644 +--- a/exp/addons/internal/controllers/clusterresourceset_helpers.go ++++ b/exp/addons/internal/controllers/clusterresourceset_helpers.go +@@ -21,8 +21,10 @@ import ( + "bytes" + "context" + "crypto/sha256" ++ "encoding/base64" + "encoding/json" + "fmt" ++ "sort" + "unicode" + + "github.com/pkg/errors" +@@ -43,6 +45,42 @@ import ( + + var jsonListPrefix = []byte("[") + ++// objsFromYamlData parses a collection of yaml documents into Unstructured objects. ++// The returned objects are sorted for creation priority within the objects defined ++// in the same document. The flattening of the documents preserves the original order. ++func objsFromYamlData(yamlDocs [][]byte) ([]unstructured.Unstructured, error) { ++ allObjs := []unstructured.Unstructured{} ++ for _, data := range yamlDocs { ++ isJSONList, err := isJSONList(data) ++ if err != nil { ++ return nil, err ++ } ++ objs := []unstructured.Unstructured{} ++ // If it is a json list, convert each list element to an unstructured object. ++ if isJSONList { ++ var results []map[string]interface{} ++ // Unmarshal the JSON to the interface. ++ if err = json.Unmarshal(data, &results); err == nil { ++ for i := range results { ++ var u unstructured.Unstructured ++ u.SetUnstructuredContent(results[i]) ++ objs = append(objs, u) ++ } ++ } ++ } else { ++ // If it is not a json list, data is either json or yaml format. ++ objs, err = utilyaml.ToUnstructured(data) ++ if err != nil { ++ return nil, errors.Wrapf(err, "failed converting data to unstructured objects") ++ } ++ } ++ ++ allObjs = append(allObjs, utilresource.SortForCreate(objs)...) ++ } ++ ++ return allObjs, nil ++} ++ + // isJSONList returns whether the data is in JSON list format. + func isJSONList(data []byte) (bool, error) { + const peekSize = 32 +@@ -55,56 +93,30 @@ func isJSONList(data []byte) (bool, error) { + return bytes.HasPrefix(trim, jsonListPrefix), nil + } + +-func apply(ctx context.Context, c client.Client, data []byte) error { +- isJSONList, err := isJSONList(data) +- if err != nil { +- return err +- } +- objs := []unstructured.Unstructured{} +- // If it is a json list, convert each list element to an unstructured object. +- if isJSONList { +- var results []map[string]interface{} +- // Unmarshal the JSON to the interface. +- if err = json.Unmarshal(data, &results); err == nil { +- for i := range results { +- var u unstructured.Unstructured +- u.SetUnstructuredContent(results[i]) +- objs = append(objs, u) +- } +- } +- } else { +- // If it is not a json list, data is either json or yaml format. +- objs, err = utilyaml.ToUnstructured(data) +- if err != nil { +- return errors.Wrapf(err, "failed converting data to unstructured objects") +- } +- } +- ++// apply reconciles all objects defined by a resource from a ClusterResourceSet ++// delegating on the reconcileScope for the applying strategy. ++func apply(ctx context.Context, c client.Client, scope resourceReconcileScope) error { + errList := []error{} +- sortedObjs := utilresource.SortForCreate(objs) +- for i := range sortedObjs { +- if err := applyUnstructured(ctx, c, &sortedObjs[i]); err != nil { ++ objs := scope.objs() ++ for i := range objs { ++ if err := scope.apply(ctx, c, &objs[i]); err != nil { + errList = append(errList, err) + } + } + return kerrors.NewAggregate(errList) + } + +-func applyUnstructured(ctx context.Context, c client.Client, obj *unstructured.Unstructured) error { +- // Create the object on the API server. +- // TODO: Errors are only logged. If needed, exponential backoff or requeuing could be used here for remedying connection glitches etc. ++func createUnstructured(ctx context.Context, c client.Client, obj *unstructured.Unstructured) error { + if err := c.Create(ctx, obj); err != nil { +- // The create call is idempotent, so if the object already exists +- // then do not consider it to be an error. +- if !apierrors.IsAlreadyExists(err) { +- return errors.Wrapf( +- err, +- "failed to create object %s %s/%s", +- obj.GroupVersionKind(), +- obj.GetNamespace(), +- obj.GetName()) +- } ++ return errors.Wrapf( ++ err, ++ "creating object %s %s/%s", ++ obj.GroupVersionKind(), ++ obj.GetNamespace(), ++ obj.GetName(), ++ ) + } ++ + return nil + } + +@@ -184,3 +196,40 @@ func computeHash(dataArr [][]byte) string { + } + return fmt.Sprintf("sha256:%x", hash.Sum(nil)) + } ++ ++// normalizeData reads content of the resource (configmap or secret) and returns ++// them serialized with constant order. Secret's data is base64 decoded. ++// This is useful to achieve consistent data between runs, since the content ++// of the data field is a mapp and its order is non deterministic. ++func normalizeData(resource *unstructured.Unstructured) ([][]byte, error) { ++ // Since maps are not ordered, we need to order them to get the same hash at each reconcile. ++ keys := make([]string, 0) ++ data, ok := resource.UnstructuredContent()["data"] ++ if !ok { ++ return nil, errors.New("failed to get data field from the resource") ++ } ++ ++ unstructuredData := data.(map[string]interface{}) ++ for key := range unstructuredData { ++ keys = append(keys, key) ++ } ++ sort.Strings(keys) ++ ++ dataList := make([][]byte, 0) ++ for _, key := range keys { ++ val, ok, err := unstructured.NestedString(unstructuredData, key) ++ if !ok || err != nil { ++ return nil, errors.New("failed to get value field from the resource") ++ } ++ ++ byteArr := []byte(val) ++ // If the resource is a Secret, data needs to be decoded. ++ if resource.GetKind() == string(addonsv1.SecretClusterResourceSetResourceKind) { ++ byteArr, _ = base64.StdEncoding.DecodeString(val) ++ } ++ ++ dataList = append(dataList, byteArr) ++ } ++ ++ return dataList, nil ++} +diff --git a/exp/addons/internal/controllers/clusterresourceset_scope.go b/exp/addons/internal/controllers/clusterresourceset_scope.go +new file mode 100644 +index 000000000..3cf15d151 +--- /dev/null ++++ b/exp/addons/internal/controllers/clusterresourceset_scope.go +@@ -0,0 +1,173 @@ ++/* ++Copyright 2022 The Kubernetes Authors. ++ ++Licensed under the Apache License, Version 2.0 (the "License"); ++you may not use this file except in compliance with the License. ++You may obtain a copy of the License at ++ ++ http://www.apache.org/licenses/LICENSE-2.0 ++ ++Unless required by applicable law or agreed to in writing, software ++distributed under the License is distributed on an "AS IS" BASIS, ++WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. ++See the License for the specific language governing permissions and ++limitations under the License. ++*/ ++ ++package controllers ++ ++import ( ++ "context" ++ ++ "github.com/pkg/errors" ++ apierrors "k8s.io/apimachinery/pkg/api/errors" ++ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" ++ "sigs.k8s.io/controller-runtime/pkg/client" ++ ++ addonsv1 "sigs.k8s.io/cluster-api/exp/addons/api/v1beta1" ++) ++ ++// resourceReconcileScope contains the scope for a CRS's resource ++// reconciliation request. ++type resourceReconcileScope interface { ++ // needsApply determines if a resource needs to be applied to the target cluster ++ // based on the strategy. ++ needsApply() bool ++ // apply reconciles the resource to the target cluster following a different process ++ // depending on the strategy. ++ apply(ctx context.Context, c client.Client, obj *unstructured.Unstructured) error ++ // objs returns all the Kubernetes objects defined in the resource. ++ objs() []unstructured.Unstructured ++ // hash returns a computed hash of the defined objects in the resource. It is consistent ++ // between runs. ++ hash() string ++} ++ ++func reconcileScopeForResource( ++ crs *addonsv1.ClusterResourceSet, ++ resourceRef addonsv1.ResourceRef, ++ resourceSetBinding *addonsv1.ResourceSetBinding, ++ resource *unstructured.Unstructured, ++) (resourceReconcileScope, error) { ++ normalizedData, err := normalizeData(resource) ++ if err != nil { ++ return nil, err ++ } ++ ++ objs, err := objsFromYamlData(normalizedData) ++ if err != nil { ++ return nil, err ++ } ++ ++ return newResourceReconcileScope(crs, resourceRef, resourceSetBinding, normalizedData, objs), nil ++} ++ ++func newResourceReconcileScope( ++ clusterResourceSet *addonsv1.ClusterResourceSet, ++ resourceRef addonsv1.ResourceRef, ++ resourceSetBinding *addonsv1.ResourceSetBinding, ++ normalizedData [][]byte, ++ objs []unstructured.Unstructured, ++) resourceReconcileScope { ++ if clusterResourceSet.IsStrategy(addonsv1.ClusterResourceSetStrategyApplyOnce) { ++ return &reconcileApplyOnceScope{ ++ baseResourceReconcileScope{ ++ clusterResourceSet: clusterResourceSet, ++ resourceRef: resourceRef, ++ resourceSetBinding: resourceSetBinding, ++ data: normalizedData, ++ normalizedObjs: objs, ++ computedHash: computeHash(normalizedData), ++ }, ++ } ++ } else if clusterResourceSet.IsStrategy(addonsv1.ClusterResourceSetStrategyReconcile) { ++ return &reconcileStrategyScope{ ++ baseResourceReconcileScope{ ++ clusterResourceSet: clusterResourceSet, ++ resourceRef: resourceRef, ++ resourceSetBinding: resourceSetBinding, ++ data: normalizedData, ++ normalizedObjs: objs, ++ computedHash: computeHash(normalizedData), ++ }, ++ } ++ } ++ ++ return nil ++} ++ ++type baseResourceReconcileScope struct { ++ clusterResourceSet *addonsv1.ClusterResourceSet ++ resourceRef addonsv1.ResourceRef ++ resourceSetBinding *addonsv1.ResourceSetBinding ++ normalizedObjs []unstructured.Unstructured ++ data [][]byte ++ computedHash string ++} ++ ++func (b baseResourceReconcileScope) objs() []unstructured.Unstructured { ++ return b.normalizedObjs ++} ++ ++func (b baseResourceReconcileScope) hash() string { ++ return b.computedHash ++} ++ ++type reconcileStrategyScope struct { ++ baseResourceReconcileScope ++} ++ ++func (r *reconcileStrategyScope) needsApply() bool { ++ resourceBinding := r.resourceSetBinding.GetResourceBinding(r.resourceRef) ++ ++ return resourceBinding == nil || !resourceBinding.Applied || resourceBinding.Hash != r.computedHash ++} ++ ++func (r *reconcileStrategyScope) apply(ctx context.Context, c client.Client, obj *unstructured.Unstructured) error { ++ currentObj := &unstructured.Unstructured{} ++ currentObj.SetAPIVersion(obj.GetAPIVersion()) ++ currentObj.SetKind(obj.GetKind()) ++ err := c.Get(ctx, client.ObjectKeyFromObject(obj), currentObj) ++ if apierrors.IsNotFound(err) { ++ return createUnstructured(ctx, c, obj) ++ } ++ if err != nil { ++ return errors.Wrapf( ++ err, ++ "reading object %s %s/%s", ++ obj.GroupVersionKind(), ++ obj.GetNamespace(), ++ obj.GetName(), ++ ) ++ } ++ ++ patch := client.MergeFrom(currentObj.DeepCopy()) ++ if err = c.Patch(ctx, obj, patch); err != nil { ++ return errors.Wrapf( ++ err, ++ "patching object %s %s/%s", ++ obj.GroupVersionKind(), ++ obj.GetNamespace(), ++ obj.GetName(), ++ ) ++ } ++ ++ return nil ++} ++ ++type reconcileApplyOnceScope struct { ++ baseResourceReconcileScope ++} ++ ++func (r *reconcileApplyOnceScope) needsApply() bool { ++ return !r.resourceSetBinding.IsApplied(r.resourceRef) ++} ++ ++func (r *reconcileApplyOnceScope) apply(ctx context.Context, c client.Client, obj *unstructured.Unstructured) error { ++ // The create call is idempotent, so if the object already exists ++ // then do not consider it to be an error. ++ if err := createUnstructured(ctx, c, obj); !apierrors.IsAlreadyExists(err) { ++ return err ++ } ++ return nil ++} +diff --git a/exp/addons/internal/controllers/clusterresourceset_scope_test.go b/exp/addons/internal/controllers/clusterresourceset_scope_test.go +new file mode 100644 +index 000000000..5f495cf6d +--- /dev/null ++++ b/exp/addons/internal/controllers/clusterresourceset_scope_test.go +@@ -0,0 +1,187 @@ ++/* ++Copyright 2022 The Kubernetes Authors. ++ ++Licensed under the Apache License, Version 2.0 (the "License"); ++you may not use this file except in compliance with the License. ++You may obtain a copy of the License at ++ ++ http://www.apache.org/licenses/LICENSE-2.0 ++ ++Unless required by applicable law or agreed to in writing, software ++distributed under the License is distributed on an "AS IS" BASIS, ++WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. ++See the License for the specific language governing permissions and ++limitations under the License. ++*/ ++ ++package controllers ++ ++import ( ++ "testing" ++ ++ . "github.com/onsi/gomega" ++ ++ addonsv1 "sigs.k8s.io/cluster-api/exp/addons/api/v1beta1" ++) ++ ++func TestReconcileStrategyScopeNeedsApply(t *testing.T) { ++ tests := []struct { ++ name string ++ scope *reconcileStrategyScope ++ want bool ++ }{ ++ { ++ name: "not binding", ++ scope: &reconcileStrategyScope{ ++ baseResourceReconcileScope: baseResourceReconcileScope{ ++ resourceSetBinding: &addonsv1.ResourceSetBinding{}, ++ resourceRef: addonsv1.ResourceRef{ ++ Name: "cp", ++ Kind: "ConfigMap", ++ }, ++ }, ++ }, ++ want: true, ++ }, ++ { ++ name: "not applied binding", ++ scope: &reconcileStrategyScope{ ++ baseResourceReconcileScope: baseResourceReconcileScope{ ++ resourceSetBinding: &addonsv1.ResourceSetBinding{ ++ Resources: []addonsv1.ResourceBinding{ ++ { ++ ResourceRef: addonsv1.ResourceRef{ ++ Name: "cp", ++ Kind: "ConfigMap", ++ }, ++ Applied: false, ++ }, ++ }, ++ }, ++ resourceRef: addonsv1.ResourceRef{ ++ Name: "cp", ++ Kind: "ConfigMap", ++ }, ++ }, ++ }, ++ want: true, ++ }, ++ { ++ name: "applied binding and different hash", ++ scope: &reconcileStrategyScope{ ++ baseResourceReconcileScope: baseResourceReconcileScope{ ++ resourceSetBinding: &addonsv1.ResourceSetBinding{ ++ Resources: []addonsv1.ResourceBinding{ ++ { ++ ResourceRef: addonsv1.ResourceRef{ ++ Name: "cp", ++ Kind: "ConfigMap", ++ }, ++ Applied: true, ++ Hash: "111", ++ }, ++ }, ++ }, ++ resourceRef: addonsv1.ResourceRef{ ++ Name: "cp", ++ Kind: "ConfigMap", ++ }, ++ computedHash: "222", ++ }, ++ }, ++ want: true, ++ }, ++ { ++ name: "applied binding and same hash", ++ scope: &reconcileStrategyScope{ ++ baseResourceReconcileScope: baseResourceReconcileScope{ ++ resourceSetBinding: &addonsv1.ResourceSetBinding{ ++ Resources: []addonsv1.ResourceBinding{ ++ { ++ ResourceRef: addonsv1.ResourceRef{ ++ Name: "cp", ++ Kind: "ConfigMap", ++ }, ++ Applied: true, ++ Hash: "111", ++ }, ++ }, ++ }, ++ resourceRef: addonsv1.ResourceRef{ ++ Name: "cp", ++ Kind: "ConfigMap", ++ }, ++ computedHash: "111", ++ }, ++ }, ++ want: false, ++ }, ++ } ++ for _, tt := range tests { ++ t.Run(tt.name, func(t *testing.T) { ++ gs := NewWithT(t) ++ gs.Expect(tt.scope.needsApply()).To(Equal(tt.want)) ++ }) ++ } ++} ++ ++func TestReconcileApplyOnceScopeNeedsApply(t *testing.T) { ++ tests := []struct { ++ name string ++ scope *reconcileApplyOnceScope ++ want bool ++ }{ ++ { ++ name: "not applied binding", ++ scope: &reconcileApplyOnceScope{ ++ baseResourceReconcileScope: baseResourceReconcileScope{ ++ resourceSetBinding: &addonsv1.ResourceSetBinding{ ++ Resources: []addonsv1.ResourceBinding{ ++ { ++ ResourceRef: addonsv1.ResourceRef{ ++ Name: "cp", ++ Kind: "ConfigMap", ++ }, ++ Applied: false, ++ }, ++ }, ++ }, ++ resourceRef: addonsv1.ResourceRef{ ++ Name: "cp", ++ Kind: "ConfigMap", ++ }, ++ }, ++ }, ++ want: true, ++ }, ++ { ++ name: "applied binding", ++ scope: &reconcileApplyOnceScope{ ++ baseResourceReconcileScope: baseResourceReconcileScope{ ++ resourceSetBinding: &addonsv1.ResourceSetBinding{ ++ Resources: []addonsv1.ResourceBinding{ ++ { ++ ResourceRef: addonsv1.ResourceRef{ ++ Name: "cp", ++ Kind: "ConfigMap", ++ }, ++ Applied: true, ++ }, ++ }, ++ }, ++ resourceRef: addonsv1.ResourceRef{ ++ Name: "cp", ++ Kind: "ConfigMap", ++ }, ++ }, ++ }, ++ want: false, ++ }, ++ } ++ for _, tt := range tests { ++ t.Run(tt.name, func(t *testing.T) { ++ gs := NewWithT(t) ++ gs.Expect(tt.scope.needsApply()).To(Equal(tt.want)) ++ }) ++ } ++} +-- +2.25.1 +