diff --git a/exp/controllers/azureasomanagedcluster_controller.go b/exp/controllers/azureasomanagedcluster_controller.go index 9edc400475f..21a1d56bd35 100644 --- a/exp/controllers/azureasomanagedcluster_controller.go +++ b/exp/controllers/azureasomanagedcluster_controller.go @@ -23,9 +23,9 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime" infracontroller "sigs.k8s.io/cluster-api-provider-azure/controllers" infrav1exp "sigs.k8s.io/cluster-api-provider-azure/exp/api/v1alpha1" + "sigs.k8s.io/cluster-api-provider-azure/exp/mutators" "sigs.k8s.io/cluster-api-provider-azure/util/tele" clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" "sigs.k8s.io/cluster-api/controllers/external" @@ -227,11 +227,11 @@ func (r *AzureASOManagedClusterReconciler) reconcileNormal(ctx context.Context, return ctrl.Result{Requeue: true}, nil } - us, err := resourcesToUnstructured(asoManagedCluster.Spec.Resources) + resources, err := mutators.ToUnstructured(ctx, asoManagedCluster.Spec.Resources) if err != nil { return ctrl.Result{}, err } - resourceReconciler := r.newResourceReconciler(asoManagedCluster, us) + resourceReconciler := r.newResourceReconciler(asoManagedCluster, resources) err = resourceReconciler.Reconcile(ctx) if err != nil { return ctrl.Result{}, fmt.Errorf("failed to reconcile resources: %w", err) @@ -279,11 +279,11 @@ func (r *AzureASOManagedClusterReconciler) reconcileDelete(ctx context.Context, defer done() log.V(4).Info("reconciling delete") - us, err := resourcesToUnstructured(asoManagedCluster.Spec.Resources) + resources, err := mutators.ToUnstructured(ctx, asoManagedCluster.Spec.Resources) if err != nil { return ctrl.Result{}, err } - resourceReconciler := r.newResourceReconciler(asoManagedCluster, us) + resourceReconciler := r.newResourceReconciler(asoManagedCluster, resources) err = resourceReconciler.Delete(ctx) if err != nil { return ctrl.Result{}, fmt.Errorf("failed to reconcile resources: %w", err) @@ -295,15 +295,3 @@ func (r *AzureASOManagedClusterReconciler) reconcileDelete(ctx context.Context, controllerutil.RemoveFinalizer(asoManagedCluster, clusterv1.ClusterFinalizer) return ctrl.Result{}, nil } - -func resourcesToUnstructured(resources []runtime.RawExtension) ([]*unstructured.Unstructured, error) { - var us []*unstructured.Unstructured - for _, resource := range resources { - u := &unstructured.Unstructured{} - if err := u.UnmarshalJSON(resource.Raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal resource JSON: %w", err) - } - us = append(us, u) - } - return us, nil -} diff --git a/exp/controllers/azureasomanagedcontrolplane_controller.go b/exp/controllers/azureasomanagedcontrolplane_controller.go index 7b2f4471cd7..923fe2a2945 100644 --- a/exp/controllers/azureasomanagedcontrolplane_controller.go +++ b/exp/controllers/azureasomanagedcontrolplane_controller.go @@ -28,6 +28,7 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" infracontroller "sigs.k8s.io/cluster-api-provider-azure/controllers" infrav1exp "sigs.k8s.io/cluster-api-provider-azure/exp/api/v1alpha1" + "sigs.k8s.io/cluster-api-provider-azure/exp/mutators" "sigs.k8s.io/cluster-api-provider-azure/util/tele" clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" "sigs.k8s.io/cluster-api/controllers/external" @@ -179,13 +180,13 @@ func (r *AzureASOManagedControlPlaneReconciler) reconcileNormal(ctx context.Cont return ctrl.Result{Requeue: true}, nil } - us, err := resourcesToUnstructured(asoManagedControlPlane.Spec.Resources) + resources, err := mutators.ApplyMutators(ctx, asoManagedControlPlane.Spec.Resources, mutators.SetManagedClusterDefaults(asoManagedControlPlane, cluster)) if err != nil { return ctrl.Result{}, err } var managedClusterName string - for _, resource := range us { + for _, resource := range resources { if resource.GroupVersionKind().Group == asocontainerservicev1.GroupVersion.Group && resource.GroupVersionKind().Kind == "ManagedCluster" { managedClusterName = resource.GetName() @@ -193,10 +194,10 @@ func (r *AzureASOManagedControlPlaneReconciler) reconcileNormal(ctx context.Cont } } if managedClusterName == "" { - return ctrl.Result{}, reconcile.TerminalError(fmt.Errorf("no %s ManagedCluster defined in AzureASOManagedControlPlane spec.resources", asocontainerservicev1.GroupVersion.Group)) + return ctrl.Result{}, reconcile.TerminalError(mutators.ErrNoManagedClusterDefined) } - resourceReconciler := r.newResourceReconciler(asoManagedControlPlane, us) + resourceReconciler := r.newResourceReconciler(asoManagedControlPlane, resources) err = resourceReconciler.Reconcile(ctx) if err != nil { return ctrl.Result{}, fmt.Errorf("failed to reconcile resources: %w", err) @@ -295,11 +296,11 @@ func (r *AzureASOManagedControlPlaneReconciler) reconcileDelete(ctx context.Cont defer done() log.V(4).Info("reconciling delete") - us, err := resourcesToUnstructured(asoManagedControlPlane.Spec.Resources) + resources, err := mutators.ToUnstructured(ctx, asoManagedControlPlane.Spec.Resources) if err != nil { return ctrl.Result{}, err } - resourceReconciler := r.newResourceReconciler(asoManagedControlPlane, us) + resourceReconciler := r.newResourceReconciler(asoManagedControlPlane, resources) err = resourceReconciler.Delete(ctx) if err != nil { return ctrl.Result{}, fmt.Errorf("failed to reconcile resources: %w", err) diff --git a/exp/controllers/azureasomanagedmachinepool_controller.go b/exp/controllers/azureasomanagedmachinepool_controller.go index f0a7bd79ee5..f3ee9bb7257 100644 --- a/exp/controllers/azureasomanagedmachinepool_controller.go +++ b/exp/controllers/azureasomanagedmachinepool_controller.go @@ -28,6 +28,7 @@ import ( "k8s.io/utils/ptr" infracontroller "sigs.k8s.io/cluster-api-provider-azure/controllers" infrav1exp "sigs.k8s.io/cluster-api-provider-azure/exp/api/v1alpha1" + "sigs.k8s.io/cluster-api-provider-azure/exp/mutators" "sigs.k8s.io/cluster-api-provider-azure/util/tele" clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" "sigs.k8s.io/cluster-api/controllers/external" @@ -208,13 +209,13 @@ func (r *AzureASOManagedMachinePoolReconciler) reconcileNormal(ctx context.Conte return ctrl.Result{Requeue: true}, nil } - us, err := resourcesToUnstructured(asoManagedMachinePool.Spec.Resources) + resources, err := mutators.ApplyMutators(ctx, asoManagedMachinePool.Spec.Resources) if err != nil { return ctrl.Result{}, err } var agentPoolName string - for _, resource := range us { + for _, resource := range resources { if resource.GroupVersionKind().Group == asocontainerservicev1.GroupVersion.Group && resource.GroupVersionKind().Kind == "ManagedClustersAgentPool" { agentPoolName = resource.GetName() @@ -225,7 +226,7 @@ func (r *AzureASOManagedMachinePoolReconciler) reconcileNormal(ctx context.Conte return ctrl.Result{}, reconcile.TerminalError(fmt.Errorf("no %s ManagedClustersAgentPools defined in AzureASOManagedMachinePool spec.resources", asocontainerservicev1.GroupVersion.Group)) } - resourceReconciler := r.newResourceReconciler(asoManagedMachinePool, us) + resourceReconciler := r.newResourceReconciler(asoManagedMachinePool, resources) err = resourceReconciler.Reconcile(ctx) if err != nil { return ctrl.Result{}, fmt.Errorf("failed to reconcile resources: %w", err) @@ -311,11 +312,11 @@ func (r *AzureASOManagedMachinePoolReconciler) reconcileDelete(ctx context.Conte // If the entire cluster is being deleted, this ASO ManagedClustersAgentPool will be deleted with the rest // of the ManagedCluster. if cluster.DeletionTimestamp.IsZero() { - us, err := resourcesToUnstructured(asoManagedMachinePool.Spec.Resources) + resources, err := mutators.ToUnstructured(ctx, asoManagedMachinePool.Spec.Resources) if err != nil { return ctrl.Result{}, err } - resourceReconciler := r.newResourceReconciler(asoManagedMachinePool, us) + resourceReconciler := r.newResourceReconciler(asoManagedMachinePool, resources) err = resourceReconciler.Delete(ctx) if err != nil { return ctrl.Result{}, fmt.Errorf("failed to reconcile resources: %w", err) diff --git a/exp/mutators/azureasomanagedcontrolplane.go b/exp/mutators/azureasomanagedcontrolplane.go new file mode 100644 index 00000000000..8fad06554c4 --- /dev/null +++ b/exp/mutators/azureasomanagedcontrolplane.go @@ -0,0 +1,167 @@ +/* +Copyright 2024 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 mutators + +import ( + "context" + "fmt" + "strings" + + asocontainerservicev1 "github.com/Azure/azure-service-operator/v2/api/containerservice/v1api20231001" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + infrav1exp "sigs.k8s.io/cluster-api-provider-azure/exp/api/v1alpha1" + "sigs.k8s.io/cluster-api-provider-azure/util/tele" + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +var ( + // ErrNoManagedClusterDefined describes an AzureASOManagedControlPlane without a ManagedCluster. + ErrNoManagedClusterDefined = fmt.Errorf("no %s ManagedCluster defined in AzureASOManagedControlPlane spec.resources", asocontainerservicev1.GroupVersion.Group) +) + +// SetManagedClusterDefaults propagates values defined by Cluster API to an ASO ManagedCluster. +func SetManagedClusterDefaults(asoManagedControlPlane *infrav1exp.AzureASOManagedControlPlane, cluster *clusterv1.Cluster) ResourcesMutator { + return func(ctx context.Context, us []*unstructured.Unstructured) error { + ctx, _, done := tele.StartSpanWithLogger(ctx, "mutators.SetManagedClusterDefaults") + defer done() + + var managedCluster *unstructured.Unstructured + var managedClusterPath string + for i, u := range us { + if u.GroupVersionKind().Group == asocontainerservicev1.GroupVersion.Group && + u.GroupVersionKind().Kind == "ManagedCluster" { + managedCluster = u + managedClusterPath = fmt.Sprintf("spec.resources[%d]", i) + break + } + } + if managedCluster == nil { + return reconcile.TerminalError(ErrNoManagedClusterDefined) + } + + if err := setManagedClusterKubernetesVersion(ctx, asoManagedControlPlane, managedClusterPath, managedCluster); err != nil { + return err + } + + if err := setManagedClusterServiceCIDR(ctx, cluster, managedClusterPath, managedCluster); err != nil { + return err + } + + if err := setManagedClusterPodCIDR(ctx, cluster, managedClusterPath, managedCluster); err != nil { + return err + } + + return nil + } +} + +func setManagedClusterKubernetesVersion(ctx context.Context, asoManagedControlPlane *infrav1exp.AzureASOManagedControlPlane, managedClusterPath string, managedCluster *unstructured.Unstructured) error { + _, log, done := tele.StartSpanWithLogger(ctx, "mutators.setManagedClusterKubernetesVersion") + defer done() + + capzK8sVersion := strings.TrimPrefix(asoManagedControlPlane.Spec.Version, "v") + if capzK8sVersion == "" { + // When the CAPI contract field isn't set, any value for version in the embedded ASO resource may be specified. + return nil + } + + k8sVersionPath := []string{"spec", "kubernetesVersion"} + userK8sVersion, k8sVersionFound, err := unstructured.NestedString(managedCluster.UnstructuredContent(), k8sVersionPath...) + if err != nil { + return err + } + setK8sVersion := mutation{ + location: managedClusterPath + "." + strings.Join(k8sVersionPath, "."), + val: capzK8sVersion, + reason: "because spec.version is set to " + asoManagedControlPlane.Spec.Version, + } + if k8sVersionFound && userK8sVersion != capzK8sVersion { + return Incompatible{ + mutation: setK8sVersion, + userVal: userK8sVersion, + } + } + logMutation(log, setK8sVersion) + return unstructured.SetNestedField(managedCluster.UnstructuredContent(), capzK8sVersion, k8sVersionPath...) +} + +func setManagedClusterServiceCIDR(ctx context.Context, cluster *clusterv1.Cluster, managedClusterPath string, managedCluster *unstructured.Unstructured) error { + _, log, done := tele.StartSpanWithLogger(ctx, "mutators.setManagedClusterServiceCIDR") + defer done() + + if cluster.Spec.ClusterNetwork == nil || + cluster.Spec.ClusterNetwork.Services == nil || + len(cluster.Spec.ClusterNetwork.Services.CIDRBlocks) == 0 { + return nil + } + + capiCIDR := cluster.Spec.ClusterNetwork.Services.CIDRBlocks[0] + + // ManagedCluster.v1api20210501.containerservice.azure.com does not contain the plural serviceCidrs field. + svcCIDRPath := []string{"spec", "networkProfile", "serviceCidr"} + userSvcCIDR, found, err := unstructured.NestedString(managedCluster.UnstructuredContent(), svcCIDRPath...) + if err != nil { + return err + } + setSvcCIDR := mutation{ + location: managedClusterPath + "." + strings.Join(svcCIDRPath, "."), + val: capiCIDR, + reason: fmt.Sprintf("because spec.clusterNetwork.services.cidrBlocks[0] in Cluster %s/%s is set to %s", cluster.Namespace, cluster.Name, capiCIDR), + } + if found && userSvcCIDR != capiCIDR { + return Incompatible{ + mutation: setSvcCIDR, + userVal: userSvcCIDR, + } + } + logMutation(log, setSvcCIDR) + return unstructured.SetNestedField(managedCluster.UnstructuredContent(), capiCIDR, svcCIDRPath...) +} + +func setManagedClusterPodCIDR(ctx context.Context, cluster *clusterv1.Cluster, managedClusterPath string, managedCluster *unstructured.Unstructured) error { + _, log, done := tele.StartSpanWithLogger(ctx, "mutators.setManagedClusterPodCIDR") + defer done() + + if cluster.Spec.ClusterNetwork == nil || + cluster.Spec.ClusterNetwork.Pods == nil || + len(cluster.Spec.ClusterNetwork.Pods.CIDRBlocks) == 0 { + return nil + } + + capiCIDR := cluster.Spec.ClusterNetwork.Pods.CIDRBlocks[0] + + // ManagedCluster.v1api20210501.containerservice.azure.com does not contain the plural podCidrs field. + podCIDRPath := []string{"spec", "networkProfile", "podCidr"} + userPodCIDR, found, err := unstructured.NestedString(managedCluster.UnstructuredContent(), podCIDRPath...) + if err != nil { + return err + } + setPodCIDR := mutation{ + location: managedClusterPath + "." + strings.Join(podCIDRPath, "."), + val: capiCIDR, + reason: fmt.Sprintf("because spec.clusterNetwork.pods.cidrBlocks[0] in Cluster %s/%s is set to %s", cluster.Namespace, cluster.Name, capiCIDR), + } + if found && userPodCIDR != capiCIDR { + return Incompatible{ + mutation: setPodCIDR, + userVal: userPodCIDR, + } + } + logMutation(log, setPodCIDR) + return unstructured.SetNestedField(managedCluster.UnstructuredContent(), capiCIDR, podCIDRPath...) +} diff --git a/exp/mutators/azureasomanagedcontrolplane_test.go b/exp/mutators/azureasomanagedcontrolplane_test.go new file mode 100644 index 00000000000..58c35d54f6a --- /dev/null +++ b/exp/mutators/azureasomanagedcontrolplane_test.go @@ -0,0 +1,494 @@ +/* +Copyright 2024 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 mutators + +import ( + "context" + "encoding/json" + "testing" + + asocontainerservicev1 "github.com/Azure/azure-service-operator/v2/api/containerservice/v1api20231001" + "github.com/google/go-cmp/cmp" + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/utils/ptr" + infrav1exp "sigs.k8s.io/cluster-api-provider-azure/exp/api/v1alpha1" + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" +) + +func TestSetManagedClusterDefaults(t *testing.T) { + ctx := context.Background() + g := NewGomegaWithT(t) + + tests := []struct { + name string + asoManagedControlPlane *infrav1exp.AzureASOManagedControlPlane + cluster *clusterv1.Cluster + expected []*unstructured.Unstructured + expectedErr error + }{ + { + name: "no ManagedCluster", + asoManagedControlPlane: &infrav1exp.AzureASOManagedControlPlane{ + Spec: infrav1exp.AzureASOManagedControlPlaneSpec{ + AzureASOManagedControlPlaneTemplateResourceSpec: infrav1exp.AzureASOManagedControlPlaneTemplateResourceSpec{ + Resources: []runtime.RawExtension{}, + }, + }, + }, + expectedErr: ErrNoManagedClusterDefined, + }, + { + name: "success", + asoManagedControlPlane: &infrav1exp.AzureASOManagedControlPlane{ + Spec: infrav1exp.AzureASOManagedControlPlaneSpec{ + AzureASOManagedControlPlaneTemplateResourceSpec: infrav1exp.AzureASOManagedControlPlaneTemplateResourceSpec{ + Version: "vCAPI k8s version", + Resources: []runtime.RawExtension{ + { + Raw: mcJSON(g, &asocontainerservicev1.ManagedCluster{}), + }, + }, + }, + }, + }, + cluster: &clusterv1.Cluster{ + Spec: clusterv1.ClusterSpec{ + ClusterNetwork: &clusterv1.ClusterNetwork{ + Pods: &clusterv1.NetworkRanges{ + CIDRBlocks: []string{"pod-0", "pod-1"}, + }, + Services: &clusterv1.NetworkRanges{ + CIDRBlocks: []string{"svc-0", "svc-1"}, + }, + }, + }, + }, + expected: []*unstructured.Unstructured{ + mcUnstructured(g, &asocontainerservicev1.ManagedCluster{ + Spec: asocontainerservicev1.ManagedCluster_Spec{ + KubernetesVersion: ptr.To("CAPI k8s version"), + NetworkProfile: &asocontainerservicev1.ContainerServiceNetworkProfile{ + ServiceCidr: ptr.To("svc-0"), + PodCidr: ptr.To("pod-0"), + }, + }, + }), + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + g := NewGomegaWithT(t) + + mutator := SetManagedClusterDefaults(test.asoManagedControlPlane, test.cluster) + actual, err := ApplyMutators(ctx, test.asoManagedControlPlane.Spec.Resources, mutator) + if test.expectedErr != nil { + g.Expect(err).To(MatchError(test.expectedErr)) + } else { + g.Expect(err).NotTo(HaveOccurred()) + } + g.Expect(cmp.Diff(test.expected, actual)).To(BeEmpty()) + }) + } +} + +func TestSetManagedClusterKubernetesVersion(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + asoManagedControlPlane *infrav1exp.AzureASOManagedControlPlane + managedCluster *asocontainerservicev1.ManagedCluster + expected *asocontainerservicev1.ManagedCluster + expectedErr error + }{ + { + name: "no CAPI opinion", + asoManagedControlPlane: &infrav1exp.AzureASOManagedControlPlane{}, + managedCluster: &asocontainerservicev1.ManagedCluster{ + Spec: asocontainerservicev1.ManagedCluster_Spec{ + KubernetesVersion: ptr.To("user k8s version"), + }, + }, + expected: &asocontainerservicev1.ManagedCluster{ + Spec: asocontainerservicev1.ManagedCluster_Spec{ + KubernetesVersion: ptr.To("user k8s version"), + }, + }, + }, + { + name: "set from CAPI opinion", + asoManagedControlPlane: &infrav1exp.AzureASOManagedControlPlane{ + Spec: infrav1exp.AzureASOManagedControlPlaneSpec{ + AzureASOManagedControlPlaneTemplateResourceSpec: infrav1exp.AzureASOManagedControlPlaneTemplateResourceSpec{ + Version: "vCAPI k8s version", + }, + }, + }, + managedCluster: &asocontainerservicev1.ManagedCluster{}, + expected: &asocontainerservicev1.ManagedCluster{ + Spec: asocontainerservicev1.ManagedCluster_Spec{ + KubernetesVersion: ptr.To("CAPI k8s version"), + }, + }, + }, + { + name: "user value matching CAPI ok", + asoManagedControlPlane: &infrav1exp.AzureASOManagedControlPlane{ + Spec: infrav1exp.AzureASOManagedControlPlaneSpec{ + AzureASOManagedControlPlaneTemplateResourceSpec: infrav1exp.AzureASOManagedControlPlaneTemplateResourceSpec{ + Version: "vCAPI k8s version", + }, + }, + }, + managedCluster: &asocontainerservicev1.ManagedCluster{ + Spec: asocontainerservicev1.ManagedCluster_Spec{ + KubernetesVersion: ptr.To("CAPI k8s version"), + }, + }, + expected: &asocontainerservicev1.ManagedCluster{ + Spec: asocontainerservicev1.ManagedCluster_Spec{ + KubernetesVersion: ptr.To("CAPI k8s version"), + }, + }, + }, + { + name: "incompatible", + asoManagedControlPlane: &infrav1exp.AzureASOManagedControlPlane{ + Spec: infrav1exp.AzureASOManagedControlPlaneSpec{ + AzureASOManagedControlPlaneTemplateResourceSpec: infrav1exp.AzureASOManagedControlPlaneTemplateResourceSpec{ + Version: "vCAPI k8s version", + }, + }, + }, + managedCluster: &asocontainerservicev1.ManagedCluster{ + Spec: asocontainerservicev1.ManagedCluster_Spec{ + KubernetesVersion: ptr.To("user k8s version"), + }, + }, + expectedErr: Incompatible{ + mutation: mutation{ + location: ".spec.kubernetesVersion", + val: "CAPI k8s version", + reason: "because spec.version is set to vCAPI k8s version", + }, + userVal: "user k8s version", + }, + }, + } + + s := runtime.NewScheme() + NewGomegaWithT(t).Expect(asocontainerservicev1.AddToScheme(s)).To(Succeed()) + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + g := NewGomegaWithT(t) + + before := test.managedCluster.DeepCopy() + umc := mcUnstructured(g, test.managedCluster) + + err := setManagedClusterKubernetesVersion(ctx, test.asoManagedControlPlane, "", umc) + g.Expect(s.Convert(umc, test.managedCluster, nil)).To(Succeed()) + if test.expectedErr != nil { + g.Expect(err).To(MatchError(test.expectedErr)) + g.Expect(cmp.Diff(before, test.managedCluster)).To(BeEmpty()) // errors should never modify the resource. + } else { + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(cmp.Diff(test.expected, test.managedCluster)).To(BeEmpty()) + } + }) + } +} + +func TestSetManagedClusterServiceCIDR(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + cluster *clusterv1.Cluster + managedCluster *asocontainerservicev1.ManagedCluster + expected *asocontainerservicev1.ManagedCluster + expectedErr error + }{ + { + name: "no CAPI opinion", + cluster: &clusterv1.Cluster{}, + managedCluster: &asocontainerservicev1.ManagedCluster{ + Spec: asocontainerservicev1.ManagedCluster_Spec{ + NetworkProfile: &asocontainerservicev1.ContainerServiceNetworkProfile{ + ServiceCidr: ptr.To("user cidr"), + }, + }, + }, + expected: &asocontainerservicev1.ManagedCluster{ + Spec: asocontainerservicev1.ManagedCluster_Spec{ + NetworkProfile: &asocontainerservicev1.ContainerServiceNetworkProfile{ + ServiceCidr: ptr.To("user cidr"), + }, + }, + }, + }, + { + name: "set from CAPI opinion", + cluster: &clusterv1.Cluster{ + Spec: clusterv1.ClusterSpec{ + ClusterNetwork: &clusterv1.ClusterNetwork{ + Services: &clusterv1.NetworkRanges{ + CIDRBlocks: []string{"capi cidr"}, + }, + }, + }, + }, + managedCluster: &asocontainerservicev1.ManagedCluster{}, + expected: &asocontainerservicev1.ManagedCluster{ + Spec: asocontainerservicev1.ManagedCluster_Spec{ + NetworkProfile: &asocontainerservicev1.ContainerServiceNetworkProfile{ + ServiceCidr: ptr.To("capi cidr"), + }, + }, + }, + }, + { + name: "user value matching CAPI ok", + cluster: &clusterv1.Cluster{ + Spec: clusterv1.ClusterSpec{ + ClusterNetwork: &clusterv1.ClusterNetwork{ + Services: &clusterv1.NetworkRanges{ + CIDRBlocks: []string{"capi cidr"}, + }, + }, + }, + }, + managedCluster: &asocontainerservicev1.ManagedCluster{ + Spec: asocontainerservicev1.ManagedCluster_Spec{ + NetworkProfile: &asocontainerservicev1.ContainerServiceNetworkProfile{ + ServiceCidr: ptr.To("capi cidr"), + }, + }, + }, + expected: &asocontainerservicev1.ManagedCluster{ + Spec: asocontainerservicev1.ManagedCluster_Spec{ + NetworkProfile: &asocontainerservicev1.ContainerServiceNetworkProfile{ + ServiceCidr: ptr.To("capi cidr"), + }, + }, + }, + }, + { + name: "incompatible", + cluster: &clusterv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "name", + Namespace: "ns", + }, + Spec: clusterv1.ClusterSpec{ + ClusterNetwork: &clusterv1.ClusterNetwork{ + Services: &clusterv1.NetworkRanges{ + CIDRBlocks: []string{"capi cidr"}, + }, + }, + }, + }, + managedCluster: &asocontainerservicev1.ManagedCluster{ + Spec: asocontainerservicev1.ManagedCluster_Spec{ + NetworkProfile: &asocontainerservicev1.ContainerServiceNetworkProfile{ + ServiceCidr: ptr.To("user cidr"), + }, + }, + }, + expectedErr: Incompatible{ + mutation: mutation{ + location: ".spec.networkProfile.serviceCidr", + val: "capi cidr", + reason: "because spec.clusterNetwork.services.cidrBlocks[0] in Cluster ns/name is set to capi cidr", + }, + userVal: "user cidr", + }, + }, + } + + s := runtime.NewScheme() + NewGomegaWithT(t).Expect(asocontainerservicev1.AddToScheme(s)).To(Succeed()) + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + g := NewGomegaWithT(t) + + before := test.managedCluster.DeepCopy() + umc := mcUnstructured(g, test.managedCluster) + + err := setManagedClusterServiceCIDR(ctx, test.cluster, "", umc) + g.Expect(s.Convert(umc, test.managedCluster, nil)).To(Succeed()) + if test.expectedErr != nil { + g.Expect(err).To(MatchError(test.expectedErr)) + g.Expect(cmp.Diff(before, test.managedCluster)).To(BeEmpty()) // errors should never modify the resource. + } else { + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(cmp.Diff(test.expected, test.managedCluster)).To(BeEmpty()) + } + }) + } +} + +func TestSetManagedClusterPodCIDR(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + cluster *clusterv1.Cluster + managedCluster *asocontainerservicev1.ManagedCluster + expected *asocontainerservicev1.ManagedCluster + expectedErr error + }{ + { + name: "no CAPI opinion", + cluster: &clusterv1.Cluster{}, + managedCluster: &asocontainerservicev1.ManagedCluster{ + Spec: asocontainerservicev1.ManagedCluster_Spec{ + NetworkProfile: &asocontainerservicev1.ContainerServiceNetworkProfile{ + PodCidr: ptr.To("user cidr"), + }, + }, + }, + expected: &asocontainerservicev1.ManagedCluster{ + Spec: asocontainerservicev1.ManagedCluster_Spec{ + NetworkProfile: &asocontainerservicev1.ContainerServiceNetworkProfile{ + PodCidr: ptr.To("user cidr"), + }, + }, + }, + }, + { + name: "set from CAPI opinion", + cluster: &clusterv1.Cluster{ + Spec: clusterv1.ClusterSpec{ + ClusterNetwork: &clusterv1.ClusterNetwork{ + Pods: &clusterv1.NetworkRanges{ + CIDRBlocks: []string{"capi cidr"}, + }, + }, + }, + }, + managedCluster: &asocontainerservicev1.ManagedCluster{}, + expected: &asocontainerservicev1.ManagedCluster{ + Spec: asocontainerservicev1.ManagedCluster_Spec{ + NetworkProfile: &asocontainerservicev1.ContainerServiceNetworkProfile{ + PodCidr: ptr.To("capi cidr"), + }, + }, + }, + }, + { + name: "user value matching CAPI ok", + cluster: &clusterv1.Cluster{ + Spec: clusterv1.ClusterSpec{ + ClusterNetwork: &clusterv1.ClusterNetwork{ + Pods: &clusterv1.NetworkRanges{ + CIDRBlocks: []string{"capi cidr"}, + }, + }, + }, + }, + managedCluster: &asocontainerservicev1.ManagedCluster{ + Spec: asocontainerservicev1.ManagedCluster_Spec{ + NetworkProfile: &asocontainerservicev1.ContainerServiceNetworkProfile{ + PodCidr: ptr.To("capi cidr"), + }, + }, + }, + expected: &asocontainerservicev1.ManagedCluster{ + Spec: asocontainerservicev1.ManagedCluster_Spec{ + NetworkProfile: &asocontainerservicev1.ContainerServiceNetworkProfile{ + PodCidr: ptr.To("capi cidr"), + }, + }, + }, + }, + { + name: "incompatible", + cluster: &clusterv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "name", + Namespace: "ns", + }, + Spec: clusterv1.ClusterSpec{ + ClusterNetwork: &clusterv1.ClusterNetwork{ + Pods: &clusterv1.NetworkRanges{ + CIDRBlocks: []string{"capi cidr"}, + }, + }, + }, + }, + managedCluster: &asocontainerservicev1.ManagedCluster{ + Spec: asocontainerservicev1.ManagedCluster_Spec{ + NetworkProfile: &asocontainerservicev1.ContainerServiceNetworkProfile{ + PodCidr: ptr.To("user cidr"), + }, + }, + }, + expectedErr: Incompatible{ + mutation: mutation{ + location: ".spec.networkProfile.podCidr", + val: "capi cidr", + reason: "because spec.clusterNetwork.pods.cidrBlocks[0] in Cluster ns/name is set to capi cidr", + }, + userVal: "user cidr", + }, + }, + } + + s := runtime.NewScheme() + NewGomegaWithT(t).Expect(asocontainerservicev1.AddToScheme(s)).To(Succeed()) + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + g := NewGomegaWithT(t) + + before := test.managedCluster.DeepCopy() + umc := mcUnstructured(g, test.managedCluster) + + err := setManagedClusterPodCIDR(ctx, test.cluster, "", umc) + g.Expect(s.Convert(umc, test.managedCluster, nil)).To(Succeed()) + if test.expectedErr != nil { + g.Expect(err).To(MatchError(test.expectedErr)) + g.Expect(cmp.Diff(before, test.managedCluster)).To(BeEmpty()) // errors should never modify the resource. + } else { + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(cmp.Diff(test.expected, test.managedCluster)).To(BeEmpty()) + } + }) + } +} + +func mcJSON(g Gomega, mc *asocontainerservicev1.ManagedCluster) []byte { + mc.SetGroupVersionKind(asocontainerservicev1.GroupVersion.WithKind("ManagedCluster")) + j, err := json.Marshal(mc) + g.Expect(err).NotTo(HaveOccurred()) + return j +} + +func mcUnstructured(g Gomega, mc *asocontainerservicev1.ManagedCluster) *unstructured.Unstructured { + s := runtime.NewScheme() + g.Expect(asocontainerservicev1.AddToScheme(s)).To(Succeed()) + u := &unstructured.Unstructured{} + g.Expect(s.Convert(mc, u, nil)).To(Succeed()) + return u +} diff --git a/exp/mutators/mutator.go b/exp/mutators/mutator.go new file mode 100644 index 00000000000..35369f28b27 --- /dev/null +++ b/exp/mutators/mutator.go @@ -0,0 +1,81 @@ +/* +Copyright 2024 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 mutators + +import ( + "context" + "errors" + "fmt" + + "github.com/go-logr/logr" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +// ResourcesMutator mutates in-place a slice of ASO resources to be reconciled. These mutations make only the +// changes strictly necessary for CAPZ resources to play nice with Cluster API. Any mutations should be logged +// and mutations that conflict with user-defined values should be rejected by returning Incompatible. +type ResourcesMutator func(context.Context, []*unstructured.Unstructured) error + +type mutation struct { + location string + val any + reason string +} + +func logMutation(log logr.Logger, mutation mutation) { + log.V(4).Info(fmt.Sprintf("setting %s to %v %s", mutation.location, mutation.val, mutation.reason)) +} + +// Incompatible describes an error where a piece of user-defined configuration does not match what CAPZ +// requires. +type Incompatible struct { + mutation + userVal any +} + +func (e Incompatible) Error() string { + return fmt.Sprintf("incompatible value: value at %s set by user to %v but CAPZ must set it to %v %s. The user-defined value must not be defined, or must match CAPZ's desired value.", e.location, e.userVal, e.val, e.reason) +} + +// ApplyMutators applies the given mutators to the given resources. +func ApplyMutators(ctx context.Context, resources []runtime.RawExtension, mutators ...ResourcesMutator) ([]*unstructured.Unstructured, error) { + us := []*unstructured.Unstructured{} + for _, resource := range resources { + u := &unstructured.Unstructured{} + if err := u.UnmarshalJSON(resource.Raw); err != nil { + return nil, fmt.Errorf("failed to unmarshal resource JSON: %w", err) + } + us = append(us, u) + } + for _, mutator := range mutators { + if err := mutator(ctx, us); err != nil { + err = fmt.Errorf("failed to run mutator: %w", err) + if errors.As(err, &Incompatible{}) { + err = reconcile.TerminalError(err) + } + return nil, err + } + } + return us, nil +} + +// ToUnstructured converts the given resources to Unstructured. +func ToUnstructured(ctx context.Context, resources []runtime.RawExtension) ([]*unstructured.Unstructured, error) { + return ApplyMutators(ctx, resources) +} diff --git a/exp/mutators/mutator_test.go b/exp/mutators/mutator_test.go new file mode 100644 index 00000000000..db942a8c8e1 --- /dev/null +++ b/exp/mutators/mutator_test.go @@ -0,0 +1,118 @@ +/* +Copyright 2024 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 mutators + +import ( + "context" + "errors" + "testing" + + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +func TestApplyMutators(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + resources []runtime.RawExtension + mutators []ResourcesMutator + expected []*unstructured.Unstructured + expectedErr error + }{ + { + name: "no mutators", + resources: []runtime.RawExtension{ + {Raw: []byte(`{"apiVersion": "v1", "kind": "SomeObject"}`)}, + }, + expected: []*unstructured.Unstructured{ + {Object: map[string]interface{}{"apiVersion": "v1", "kind": "SomeObject"}}, + }, + }, + { + name: "mutators apply in order", + resources: []runtime.RawExtension{ + {Raw: []byte(`{"apiVersion": "v1", "kind": "SomeObject"}`)}, + }, + mutators: []ResourcesMutator{ + func(_ context.Context, us []*unstructured.Unstructured) error { + us[0].Object["f1"] = "3" + us[0].Object["f2"] = "3" + us[0].Object["f3"] = "3" + return nil + }, + func(_ context.Context, us []*unstructured.Unstructured) error { + us[0].Object["f1"] = "2" + us[0].Object["f2"] = "2" + return nil + }, + func(_ context.Context, us []*unstructured.Unstructured) error { + us[0].Object["f1"] = "1" + return nil + }, + }, + expected: []*unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "SomeObject", + "f1": "1", + "f2": "2", + "f3": "3", + }, + }, + }, + }, + { + name: "error", + resources: []runtime.RawExtension{}, + mutators: []ResourcesMutator{ + func(_ context.Context, us []*unstructured.Unstructured) error { + return errors.New("mutator err") + }, + }, + expectedErr: errors.New("mutator err"), + }, + { + name: "incompatible is terminal", + resources: []runtime.RawExtension{}, + mutators: []ResourcesMutator{ + func(_ context.Context, us []*unstructured.Unstructured) error { + return Incompatible{} + }, + }, + expectedErr: reconcile.TerminalError(Incompatible{}), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + g := NewGomegaWithT(t) + + actual, err := ApplyMutators(ctx, test.resources, test.mutators...) + if test.expectedErr != nil { + g.Expect(err).To(MatchError(test.expectedErr)) + } else { + g.Expect(err).NotTo(HaveOccurred()) + } + g.Expect(actual).To(Equal(test.expected)) + }) + } +}