Skip to content

Commit e5e73a2

Browse files
committed
ASOAPI: add resource mutator framework
1 parent d817ab3 commit e5e73a2

File tree

5 files changed

+211
-27
lines changed

5 files changed

+211
-27
lines changed

exp/controllers/azureasomanagedcluster_controller.go

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@ import (
2323

2424
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2525
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
26-
"k8s.io/apimachinery/pkg/runtime"
2726
infracontroller "sigs.k8s.io/cluster-api-provider-azure/controllers"
2827
infrav1exp "sigs.k8s.io/cluster-api-provider-azure/exp/api/v1alpha1"
28+
"sigs.k8s.io/cluster-api-provider-azure/exp/mutators"
2929
"sigs.k8s.io/cluster-api-provider-azure/util/tele"
3030
clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1"
3131
"sigs.k8s.io/cluster-api/controllers/external"
@@ -227,11 +227,11 @@ func (r *AzureASOManagedClusterReconciler) reconcileNormal(ctx context.Context,
227227
return ctrl.Result{Requeue: true}, nil
228228
}
229229

230-
us, err := resourcesToUnstructured(asoManagedCluster.Spec.Resources)
230+
resources, err := mutators.ToUnstructured(ctx, asoManagedCluster.Spec.Resources)
231231
if err != nil {
232232
return ctrl.Result{}, err
233233
}
234-
resourceReconciler := r.newResourceReconciler(asoManagedCluster, us)
234+
resourceReconciler := r.newResourceReconciler(asoManagedCluster, resources)
235235
err = resourceReconciler.Reconcile(ctx)
236236
if err != nil {
237237
return ctrl.Result{}, fmt.Errorf("failed to reconcile resources: %w", err)
@@ -279,11 +279,11 @@ func (r *AzureASOManagedClusterReconciler) reconcileDelete(ctx context.Context,
279279
defer done()
280280
log.V(4).Info("reconciling delete")
281281

282-
us, err := resourcesToUnstructured(asoManagedCluster.Spec.Resources)
282+
resources, err := mutators.ToUnstructured(ctx, asoManagedCluster.Spec.Resources)
283283
if err != nil {
284284
return ctrl.Result{}, err
285285
}
286-
resourceReconciler := r.newResourceReconciler(asoManagedCluster, us)
286+
resourceReconciler := r.newResourceReconciler(asoManagedCluster, resources)
287287
err = resourceReconciler.Delete(ctx)
288288
if err != nil {
289289
return ctrl.Result{}, fmt.Errorf("failed to reconcile resources: %w", err)
@@ -295,15 +295,3 @@ func (r *AzureASOManagedClusterReconciler) reconcileDelete(ctx context.Context,
295295
controllerutil.RemoveFinalizer(asoManagedCluster, clusterv1.ClusterFinalizer)
296296
return ctrl.Result{}, nil
297297
}
298-
299-
func resourcesToUnstructured(resources []runtime.RawExtension) ([]*unstructured.Unstructured, error) {
300-
var us []*unstructured.Unstructured
301-
for _, resource := range resources {
302-
u := &unstructured.Unstructured{}
303-
if err := u.UnmarshalJSON(resource.Raw); err != nil {
304-
return nil, fmt.Errorf("failed to unmarshal resource JSON: %w", err)
305-
}
306-
us = append(us, u)
307-
}
308-
return us, nil
309-
}

exp/controllers/azureasomanagedcontrolplane_controller.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
2929
infracontroller "sigs.k8s.io/cluster-api-provider-azure/controllers"
3030
infrav1exp "sigs.k8s.io/cluster-api-provider-azure/exp/api/v1alpha1"
31+
"sigs.k8s.io/cluster-api-provider-azure/exp/mutators"
3132
"sigs.k8s.io/cluster-api-provider-azure/util/tele"
3233
clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1"
3334
"sigs.k8s.io/cluster-api/controllers/external"
@@ -179,13 +180,13 @@ func (r *AzureASOManagedControlPlaneReconciler) reconcileNormal(ctx context.Cont
179180
return ctrl.Result{Requeue: true}, nil
180181
}
181182

182-
us, err := resourcesToUnstructured(asoManagedControlPlane.Spec.Resources)
183+
resources, err := mutators.ApplyMutators(ctx, asoManagedControlPlane.Spec.Resources)
183184
if err != nil {
184185
return ctrl.Result{}, err
185186
}
186187

187188
var managedClusterName string
188-
for _, resource := range us {
189+
for _, resource := range resources {
189190
if resource.GroupVersionKind().Group == asocontainerservicev1.GroupVersion.Group &&
190191
resource.GroupVersionKind().Kind == "ManagedCluster" {
191192
managedClusterName = resource.GetName()
@@ -196,7 +197,7 @@ func (r *AzureASOManagedControlPlaneReconciler) reconcileNormal(ctx context.Cont
196197
return ctrl.Result{}, reconcile.TerminalError(fmt.Errorf("no %s ManagedCluster defined in AzureASOManagedControlPlane spec.resources", asocontainerservicev1.GroupVersion.Group))
197198
}
198199

199-
resourceReconciler := r.newResourceReconciler(asoManagedControlPlane, us)
200+
resourceReconciler := r.newResourceReconciler(asoManagedControlPlane, resources)
200201
err = resourceReconciler.Reconcile(ctx)
201202
if err != nil {
202203
return ctrl.Result{}, fmt.Errorf("failed to reconcile resources: %w", err)
@@ -295,11 +296,11 @@ func (r *AzureASOManagedControlPlaneReconciler) reconcileDelete(ctx context.Cont
295296
defer done()
296297
log.V(4).Info("reconciling delete")
297298

298-
us, err := resourcesToUnstructured(asoManagedControlPlane.Spec.Resources)
299+
resources, err := mutators.ToUnstructured(ctx, asoManagedControlPlane.Spec.Resources)
299300
if err != nil {
300301
return ctrl.Result{}, err
301302
}
302-
resourceReconciler := r.newResourceReconciler(asoManagedControlPlane, us)
303+
resourceReconciler := r.newResourceReconciler(asoManagedControlPlane, resources)
303304
err = resourceReconciler.Delete(ctx)
304305
if err != nil {
305306
return ctrl.Result{}, fmt.Errorf("failed to reconcile resources: %w", err)

exp/controllers/azureasomanagedmachinepool_controller.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828
"k8s.io/utils/ptr"
2929
infracontroller "sigs.k8s.io/cluster-api-provider-azure/controllers"
3030
infrav1exp "sigs.k8s.io/cluster-api-provider-azure/exp/api/v1alpha1"
31+
"sigs.k8s.io/cluster-api-provider-azure/exp/mutators"
3132
"sigs.k8s.io/cluster-api-provider-azure/util/tele"
3233
clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1"
3334
"sigs.k8s.io/cluster-api/controllers/external"
@@ -208,13 +209,13 @@ func (r *AzureASOManagedMachinePoolReconciler) reconcileNormal(ctx context.Conte
208209
return ctrl.Result{Requeue: true}, nil
209210
}
210211

211-
us, err := resourcesToUnstructured(asoManagedMachinePool.Spec.Resources)
212+
resources, err := mutators.ApplyMutators(ctx, asoManagedMachinePool.Spec.Resources)
212213
if err != nil {
213214
return ctrl.Result{}, err
214215
}
215216

216217
var agentPoolName string
217-
for _, resource := range us {
218+
for _, resource := range resources {
218219
if resource.GroupVersionKind().Group == asocontainerservicev1.GroupVersion.Group &&
219220
resource.GroupVersionKind().Kind == "ManagedClustersAgentPool" {
220221
agentPoolName = resource.GetName()
@@ -225,7 +226,7 @@ func (r *AzureASOManagedMachinePoolReconciler) reconcileNormal(ctx context.Conte
225226
return ctrl.Result{}, reconcile.TerminalError(fmt.Errorf("no %s ManagedClustersAgentPools defined in AzureASOManagedMachinePool spec.resources", asocontainerservicev1.GroupVersion.Group))
226227
}
227228

228-
resourceReconciler := r.newResourceReconciler(asoManagedMachinePool, us)
229+
resourceReconciler := r.newResourceReconciler(asoManagedMachinePool, resources)
229230
err = resourceReconciler.Reconcile(ctx)
230231
if err != nil {
231232
return ctrl.Result{}, fmt.Errorf("failed to reconcile resources: %w", err)
@@ -311,11 +312,11 @@ func (r *AzureASOManagedMachinePoolReconciler) reconcileDelete(ctx context.Conte
311312
// If the entire cluster is being deleted, this ASO ManagedClustersAgentPool will be deleted with the rest
312313
// of the ManagedCluster.
313314
if cluster.DeletionTimestamp.IsZero() {
314-
us, err := resourcesToUnstructured(asoManagedMachinePool.Spec.Resources)
315+
resources, err := mutators.ToUnstructured(ctx, asoManagedMachinePool.Spec.Resources)
315316
if err != nil {
316317
return ctrl.Result{}, err
317318
}
318-
resourceReconciler := r.newResourceReconciler(asoManagedMachinePool, us)
319+
resourceReconciler := r.newResourceReconciler(asoManagedMachinePool, resources)
319320
err = resourceReconciler.Delete(ctx)
320321
if err != nil {
321322
return ctrl.Result{}, fmt.Errorf("failed to reconcile resources: %w", err)

exp/mutators/mutator.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/*
2+
Copyright 2024 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package mutators
18+
19+
import (
20+
"context"
21+
"errors"
22+
"fmt"
23+
24+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
25+
"k8s.io/apimachinery/pkg/runtime"
26+
"sigs.k8s.io/controller-runtime/pkg/reconcile"
27+
)
28+
29+
// ResourcesMutator mutates in-place a slice of ASO resources to be reconciled. These mutations make only the
30+
// changes strictly necessary for CAPZ resources to play nice with Cluster API. Any mutations should be logged
31+
// and mutations that conflict with user-defined values should be rejected by returning Incompatible.
32+
type ResourcesMutator func(context.Context, []*unstructured.Unstructured) error
33+
34+
type mutation struct {
35+
location string
36+
val any
37+
reason string
38+
}
39+
40+
// Incompatible describes an error where a piece of user-defined configuration does not match what CAPZ
41+
// requires.
42+
type Incompatible struct {
43+
mutation
44+
userVal any
45+
}
46+
47+
func (e Incompatible) Error() string {
48+
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)
49+
}
50+
51+
// ApplyMutators applies the given mutators to the given resources.
52+
func ApplyMutators(ctx context.Context, resources []runtime.RawExtension, mutators ...ResourcesMutator) ([]*unstructured.Unstructured, error) {
53+
us := []*unstructured.Unstructured{}
54+
for _, resource := range resources {
55+
u := &unstructured.Unstructured{}
56+
if err := u.UnmarshalJSON(resource.Raw); err != nil {
57+
return nil, fmt.Errorf("failed to unmarshal resource JSON: %w", err)
58+
}
59+
us = append(us, u)
60+
}
61+
for _, mutator := range mutators {
62+
if err := mutator(ctx, us); err != nil {
63+
err = fmt.Errorf("failed to run mutator: %w", err)
64+
if errors.As(err, &Incompatible{}) {
65+
err = reconcile.TerminalError(err)
66+
}
67+
return nil, err
68+
}
69+
}
70+
return us, nil
71+
}
72+
73+
// ToUnstructured converts the given resources to Unstructured.
74+
func ToUnstructured(ctx context.Context, resources []runtime.RawExtension) ([]*unstructured.Unstructured, error) {
75+
return ApplyMutators(ctx, resources)
76+
}

exp/mutators/mutator_test.go

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/*
2+
Copyright 2024 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package mutators
18+
19+
import (
20+
"context"
21+
"errors"
22+
"testing"
23+
24+
. "github.com/onsi/gomega"
25+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
26+
"k8s.io/apimachinery/pkg/runtime"
27+
"sigs.k8s.io/controller-runtime/pkg/reconcile"
28+
)
29+
30+
func TestApplyMutators(t *testing.T) {
31+
ctx := context.Background()
32+
33+
tests := []struct {
34+
name string
35+
resources []runtime.RawExtension
36+
mutators []ResourcesMutator
37+
expected []*unstructured.Unstructured
38+
expectedErr error
39+
}{
40+
{
41+
name: "no mutators",
42+
resources: []runtime.RawExtension{
43+
{Raw: []byte(`{"apiVersion": "v1", "kind": "SomeObject"}`)},
44+
},
45+
expected: []*unstructured.Unstructured{
46+
{Object: map[string]interface{}{"apiVersion": "v1", "kind": "SomeObject"}},
47+
},
48+
},
49+
{
50+
name: "mutators apply in order",
51+
resources: []runtime.RawExtension{
52+
{Raw: []byte(`{"apiVersion": "v1", "kind": "SomeObject"}`)},
53+
},
54+
mutators: []ResourcesMutator{
55+
func(_ context.Context, us []*unstructured.Unstructured) error {
56+
us[0].Object["f1"] = "3"
57+
us[0].Object["f2"] = "3"
58+
us[0].Object["f3"] = "3"
59+
return nil
60+
},
61+
func(_ context.Context, us []*unstructured.Unstructured) error {
62+
us[0].Object["f1"] = "2"
63+
us[0].Object["f2"] = "2"
64+
return nil
65+
},
66+
func(_ context.Context, us []*unstructured.Unstructured) error {
67+
us[0].Object["f1"] = "1"
68+
return nil
69+
},
70+
},
71+
expected: []*unstructured.Unstructured{
72+
{
73+
Object: map[string]interface{}{
74+
"apiVersion": "v1",
75+
"kind": "SomeObject",
76+
"f1": "1",
77+
"f2": "2",
78+
"f3": "3",
79+
},
80+
},
81+
},
82+
},
83+
{
84+
name: "error",
85+
resources: []runtime.RawExtension{},
86+
mutators: []ResourcesMutator{
87+
func(_ context.Context, us []*unstructured.Unstructured) error {
88+
return errors.New("mutator err")
89+
},
90+
},
91+
expectedErr: errors.New("mutator err"),
92+
},
93+
{
94+
name: "incompatible is terminal",
95+
resources: []runtime.RawExtension{},
96+
mutators: []ResourcesMutator{
97+
func(_ context.Context, us []*unstructured.Unstructured) error {
98+
return Incompatible{}
99+
},
100+
},
101+
expectedErr: reconcile.TerminalError(Incompatible{}),
102+
},
103+
}
104+
105+
for _, test := range tests {
106+
t.Run(test.name, func(t *testing.T) {
107+
g := NewGomegaWithT(t)
108+
109+
actual, err := ApplyMutators(ctx, test.resources, test.mutators...)
110+
if test.expectedErr != nil {
111+
g.Expect(err).To(MatchError(test.expectedErr))
112+
} else {
113+
g.Expect(err).NotTo(HaveOccurred())
114+
}
115+
g.Expect(actual).To(Equal(test.expected))
116+
})
117+
}
118+
}

0 commit comments

Comments
 (0)