sigs.k8s.io/cluster-api-provider-azure@v1.17.0/controllers/resource_reconciler_test.go (about)

     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 controllers
    18  
    19  import (
    20  	"context"
    21  	"testing"
    22  
    23  	asoresourcesv1 "github.com/Azure/azure-service-operator/v2/api/resources/v1api20200601"
    24  	"github.com/Azure/azure-service-operator/v2/pkg/common/annotations"
    25  	"github.com/Azure/azure-service-operator/v2/pkg/genruntime/conditions"
    26  	"github.com/go-logr/logr"
    27  	. "github.com/onsi/gomega"
    28  	apierrors "k8s.io/apimachinery/pkg/api/errors"
    29  	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    30  	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
    31  	"k8s.io/apimachinery/pkg/runtime"
    32  	infrav1alpha "sigs.k8s.io/cluster-api-provider-azure/api/v1alpha1"
    33  	"sigs.k8s.io/controller-runtime/pkg/client"
    34  	fakeclient "sigs.k8s.io/controller-runtime/pkg/client/fake"
    35  	"sigs.k8s.io/controller-runtime/pkg/handler"
    36  	"sigs.k8s.io/controller-runtime/pkg/predicate"
    37  )
    38  
    39  type FakeClient struct {
    40  	client.Client
    41  	// Override the Patch method because controller-runtime's doesn't really support
    42  	// server-side apply, so we make our own dollar store version:
    43  	// https://github.com/kubernetes-sigs/controller-runtime/issues/2341
    44  	patchFunc func(context.Context, client.Object, client.Patch, ...client.PatchOption) error
    45  }
    46  
    47  func (c *FakeClient) Patch(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.PatchOption) error {
    48  	if c.patchFunc == nil {
    49  		return c.Client.Patch(ctx, obj, patch, opts...)
    50  	}
    51  	return c.patchFunc(ctx, obj, patch, opts...)
    52  }
    53  
    54  type FakeWatcher struct {
    55  	watching map[string]struct{}
    56  }
    57  
    58  func (w *FakeWatcher) Watch(_ logr.Logger, obj client.Object, _ handler.EventHandler, _ ...predicate.Predicate) error {
    59  	if w.watching == nil {
    60  		w.watching = make(map[string]struct{})
    61  	}
    62  	w.watching[obj.GetObjectKind().GroupVersionKind().GroupKind().String()] = struct{}{}
    63  	return nil
    64  }
    65  
    66  func TestResourceReconcilerReconcile(t *testing.T) {
    67  	ctx := context.Background()
    68  
    69  	s := runtime.NewScheme()
    70  	sb := runtime.NewSchemeBuilder(
    71  		infrav1alpha.AddToScheme,
    72  		asoresourcesv1.AddToScheme,
    73  	)
    74  	NewGomegaWithT(t).Expect(sb.AddToScheme(s)).To(Succeed())
    75  
    76  	fakeClientBuilder := func() *fakeclient.ClientBuilder {
    77  		return fakeclient.NewClientBuilder().
    78  			WithScheme(s).
    79  			WithStatusSubresource(&infrav1alpha.AzureASOManagedCluster{})
    80  	}
    81  
    82  	t.Run("empty resources", func(t *testing.T) {
    83  		g := NewGomegaWithT(t)
    84  
    85  		r := &ResourceReconciler{
    86  			resources: []*unstructured.Unstructured{},
    87  			owner:     &infrav1alpha.AzureASOManagedCluster{},
    88  		}
    89  
    90  		g.Expect(r.Reconcile(ctx)).To(Succeed())
    91  	})
    92  
    93  	t.Run("reconcile several resources", func(t *testing.T) {
    94  		g := NewGomegaWithT(t)
    95  
    96  		w := &FakeWatcher{}
    97  		c := fakeClientBuilder().
    98  			Build()
    99  
   100  		asoManagedCluster := &infrav1alpha.AzureASOManagedCluster{}
   101  
   102  		unpatchedRGs := map[string]struct{}{
   103  			"rg1": {},
   104  			"rg2": {},
   105  		}
   106  		r := &ResourceReconciler{
   107  			Client: &FakeClient{
   108  				Client: c,
   109  				patchFunc: func(ctx context.Context, o client.Object, p client.Patch, po ...client.PatchOption) error {
   110  					g.Expect(unpatchedRGs).To(HaveKey(o.GetName()))
   111  					delete(unpatchedRGs, o.GetName())
   112  					return nil
   113  				},
   114  			},
   115  			resources: []*unstructured.Unstructured{
   116  				rgJSON(g, s, &asoresourcesv1.ResourceGroup{
   117  					ObjectMeta: metav1.ObjectMeta{
   118  						Name: "rg1",
   119  					},
   120  					// Status normally wouldn't be defined here. This simulates the server response after a PATCH.
   121  					Status: asoresourcesv1.ResourceGroup_STATUS{
   122  						Conditions: []conditions.Condition{
   123  							{
   124  								Type:   conditions.ConditionTypeReady,
   125  								Status: metav1.ConditionTrue,
   126  							},
   127  						},
   128  					},
   129  				}),
   130  				rgJSON(g, s, &asoresourcesv1.ResourceGroup{
   131  					ObjectMeta: metav1.ObjectMeta{
   132  						Name: "rg2",
   133  					},
   134  				}),
   135  			},
   136  			owner:   asoManagedCluster,
   137  			watcher: w,
   138  		}
   139  
   140  		g.Expect(r.Reconcile(ctx)).To(Succeed())
   141  		g.Expect(w.watching).To(HaveKey("ResourceGroup.resources.azure.com"))
   142  		g.Expect(unpatchedRGs).To(BeEmpty()) // all expected resources were patched
   143  
   144  		resourcesStatuses := asoManagedCluster.Status.Resources
   145  		g.Expect(resourcesStatuses).To(HaveLen(2))
   146  		g.Expect(resourcesStatuses[0].Resource.Name).To(Equal("rg1"))
   147  		g.Expect(resourcesStatuses[0].Ready).To(BeTrue())
   148  		g.Expect(resourcesStatuses[1].Resource.Name).To(Equal("rg2"))
   149  		g.Expect(resourcesStatuses[1].Ready).To(BeFalse())
   150  	})
   151  
   152  	t.Run("delete stale resources", func(t *testing.T) {
   153  		g := NewGomegaWithT(t)
   154  
   155  		owner := &infrav1alpha.AzureASOManagedCluster{
   156  			Status: infrav1alpha.AzureASOManagedClusterStatus{
   157  				Resources: []infrav1alpha.ResourceStatus{
   158  					rgStatus("rg0"),
   159  					rgStatus("rg1"),
   160  					rgStatus("rg2"),
   161  					rgStatus("rg3"),
   162  				},
   163  			},
   164  		}
   165  
   166  		objs := []client.Object{
   167  			&asoresourcesv1.ResourceGroup{
   168  				ObjectMeta: metav1.ObjectMeta{
   169  					Name:      "rg0",
   170  					Namespace: owner.Namespace,
   171  				},
   172  			},
   173  			&asoresourcesv1.ResourceGroup{
   174  				ObjectMeta: metav1.ObjectMeta{
   175  					Name:      "rg1",
   176  					Namespace: owner.Namespace,
   177  				},
   178  			},
   179  			&asoresourcesv1.ResourceGroup{
   180  				ObjectMeta: metav1.ObjectMeta{
   181  					Name:      "rg2",
   182  					Namespace: owner.Namespace,
   183  				},
   184  			},
   185  			&asoresourcesv1.ResourceGroup{
   186  				ObjectMeta: metav1.ObjectMeta{
   187  					Name:       "rg3",
   188  					Namespace:  owner.Namespace,
   189  					Finalizers: []string{"still deleting"},
   190  				},
   191  			},
   192  		}
   193  
   194  		c := fakeClientBuilder().
   195  			WithObjects(objs...).
   196  			Build()
   197  
   198  		r := &ResourceReconciler{
   199  			Client: &FakeClient{
   200  				Client: c,
   201  				patchFunc: func(ctx context.Context, o client.Object, p client.Patch, po ...client.PatchOption) error {
   202  					return nil
   203  				},
   204  			},
   205  			resources: []*unstructured.Unstructured{
   206  				rgJSON(g, s, &asoresourcesv1.ResourceGroup{
   207  					ObjectMeta: metav1.ObjectMeta{
   208  						Name: "rg1",
   209  					},
   210  				}),
   211  				rgJSON(g, s, &asoresourcesv1.ResourceGroup{
   212  					ObjectMeta: metav1.ObjectMeta{
   213  						Name: "rg2",
   214  					},
   215  				}),
   216  			},
   217  			owner:   owner,
   218  			watcher: &FakeWatcher{},
   219  		}
   220  
   221  		g.Expect(r.Reconcile(ctx)).To(Succeed())
   222  
   223  		resourcesStatuses := owner.Status.Resources
   224  		g.Expect(resourcesStatuses).To(HaveLen(3))
   225  		// rg0 should be deleted and gone
   226  		g.Expect(resourcesStatuses[0].Resource.Name).To(Equal("rg1"))
   227  		g.Expect(resourcesStatuses[1].Resource.Name).To(Equal("rg2"))
   228  		g.Expect(resourcesStatuses[2].Resource.Name).To(Equal("rg3"))
   229  	})
   230  }
   231  
   232  func TestResourceReconcilerPause(t *testing.T) {
   233  	ctx := context.Background()
   234  
   235  	s := runtime.NewScheme()
   236  	sb := runtime.NewSchemeBuilder(
   237  		infrav1alpha.AddToScheme,
   238  		asoresourcesv1.AddToScheme,
   239  	)
   240  	NewGomegaWithT(t).Expect(sb.AddToScheme(s)).To(Succeed())
   241  
   242  	fakeClientBuilder := func() *fakeclient.ClientBuilder {
   243  		return fakeclient.NewClientBuilder().
   244  			WithScheme(s).
   245  			WithStatusSubresource(&infrav1alpha.AzureASOManagedCluster{})
   246  	}
   247  
   248  	t.Run("empty resources", func(t *testing.T) {
   249  		g := NewGomegaWithT(t)
   250  
   251  		r := &ResourceReconciler{
   252  			resources: []*unstructured.Unstructured{},
   253  			owner:     &infrav1alpha.AzureASOManagedCluster{},
   254  		}
   255  
   256  		g.Expect(r.Pause(ctx)).To(Succeed())
   257  	})
   258  
   259  	t.Run("pause several resources", func(t *testing.T) {
   260  		g := NewGomegaWithT(t)
   261  
   262  		c := fakeClientBuilder().
   263  			Build()
   264  
   265  		asoManagedCluster := &infrav1alpha.AzureASOManagedCluster{}
   266  
   267  		var patchedRGs []string
   268  		r := &ResourceReconciler{
   269  			Client: &FakeClient{
   270  				Client: c,
   271  				patchFunc: func(ctx context.Context, o client.Object, p client.Patch, po ...client.PatchOption) error {
   272  					g.Expect(o.GetAnnotations()).To(HaveKeyWithValue(annotations.ReconcilePolicy, string(annotations.ReconcilePolicySkip)))
   273  					patchedRGs = append(patchedRGs, o.GetName())
   274  					return nil
   275  				},
   276  			},
   277  			resources: []*unstructured.Unstructured{
   278  				rgJSON(g, s, &asoresourcesv1.ResourceGroup{
   279  					ObjectMeta: metav1.ObjectMeta{
   280  						Name: "rg1",
   281  					},
   282  				}),
   283  				rgJSON(g, s, &asoresourcesv1.ResourceGroup{
   284  					ObjectMeta: metav1.ObjectMeta{
   285  						Name: "rg2",
   286  					},
   287  				}),
   288  			},
   289  			owner: asoManagedCluster,
   290  		}
   291  
   292  		g.Expect(r.Pause(ctx)).To(Succeed())
   293  		g.Expect(patchedRGs).To(ConsistOf("rg1", "rg2"))
   294  	})
   295  }
   296  
   297  func TestResourceReconcilerDelete(t *testing.T) {
   298  	ctx := context.Background()
   299  
   300  	s := runtime.NewScheme()
   301  	sb := runtime.NewSchemeBuilder(
   302  		infrav1alpha.AddToScheme,
   303  		asoresourcesv1.AddToScheme,
   304  	)
   305  	NewGomegaWithT(t).Expect(sb.AddToScheme(s)).To(Succeed())
   306  
   307  	fakeClientBuilder := func() *fakeclient.ClientBuilder {
   308  		return fakeclient.NewClientBuilder().
   309  			WithScheme(s).
   310  			WithStatusSubresource(&infrav1alpha.AzureASOManagedCluster{})
   311  	}
   312  
   313  	t.Run("empty resources", func(t *testing.T) {
   314  		g := NewGomegaWithT(t)
   315  
   316  		r := &ResourceReconciler{
   317  			resources: []*unstructured.Unstructured{},
   318  			owner:     &infrav1alpha.AzureASOManagedCluster{},
   319  		}
   320  
   321  		g.Expect(r.Delete(ctx)).To(Succeed())
   322  	})
   323  
   324  	t.Run("delete several resources", func(t *testing.T) {
   325  		g := NewGomegaWithT(t)
   326  
   327  		owner := &infrav1alpha.AzureASOManagedCluster{
   328  			ObjectMeta: metav1.ObjectMeta{
   329  				Namespace: "ns",
   330  			},
   331  			Status: infrav1alpha.AzureASOManagedClusterStatus{
   332  				Resources: []infrav1alpha.ResourceStatus{
   333  					rgStatus("still-deleting"),
   334  					rgStatus("already-gone"),
   335  				},
   336  			},
   337  		}
   338  
   339  		objs := []client.Object{
   340  			&asoresourcesv1.ResourceGroup{
   341  				ObjectMeta: metav1.ObjectMeta{
   342  					Name:      "still-deleting",
   343  					Namespace: owner.Namespace,
   344  					Finalizers: []string{
   345  						"ASO finalizer",
   346  					},
   347  				},
   348  			},
   349  		}
   350  
   351  		c := fakeClientBuilder().
   352  			WithObjects(objs...).
   353  			Build()
   354  
   355  		r := &ResourceReconciler{
   356  			Client: &FakeClient{
   357  				Client: c,
   358  			},
   359  			owner: owner,
   360  		}
   361  
   362  		g.Expect(r.Delete(ctx)).To(Succeed())
   363  		g.Expect(apierrors.IsNotFound(r.Client.Get(ctx, client.ObjectKey{Namespace: owner.Namespace, Name: "already-gone"}, &asoresourcesv1.ResourceGroup{}))).To(BeTrue())
   364  		stillDeleting := &asoresourcesv1.ResourceGroup{}
   365  		g.Expect(r.Client.Get(ctx, client.ObjectKey{Namespace: owner.Namespace, Name: "still-deleting"}, stillDeleting)).To(Succeed())
   366  		g.Expect(stillDeleting.GetDeletionTimestamp().IsZero()).To(BeFalse())
   367  
   368  		g.Expect(owner.Status.Resources).To(HaveLen(1))
   369  		g.Expect(owner.Status.Resources[0].Resource.Name).To(Equal("still-deleting"))
   370  		g.Expect(owner.Status.Resources[0].Ready).To(BeFalse())
   371  	})
   372  }
   373  
   374  func TestReadyStatus(t *testing.T) {
   375  	ctx := context.Background()
   376  
   377  	t.Run("unstructured", func(t *testing.T) {
   378  		tests := []struct {
   379  			name          string
   380  			object        *unstructured.Unstructured
   381  			expectedReady bool
   382  		}{
   383  			{
   384  				name:          "empty object",
   385  				object:        &unstructured.Unstructured{Object: make(map[string]interface{})},
   386  				expectedReady: false,
   387  			},
   388  			{
   389  				name: "empty status.conditions",
   390  				object: &unstructured.Unstructured{Object: map[string]interface{}{
   391  					"status": map[string]interface{}{
   392  						"conditions": []interface{}{},
   393  					},
   394  				}},
   395  				expectedReady: false,
   396  			},
   397  			{
   398  				name: "status.conditions wrong type",
   399  				object: &unstructured.Unstructured{Object: map[string]interface{}{
   400  					"status": map[string]interface{}{
   401  						"conditions": []interface{}{
   402  							int64(0),
   403  						},
   404  					},
   405  				}},
   406  				expectedReady: false,
   407  			},
   408  			{
   409  				name: "non-Ready type status.conditions",
   410  				object: &unstructured.Unstructured{Object: map[string]interface{}{
   411  					"status": map[string]interface{}{
   412  						"conditions": []interface{}{
   413  							map[string]interface{}{
   414  								"type": "not" + conditions.ConditionTypeReady,
   415  							},
   416  						},
   417  					},
   418  				}},
   419  				expectedReady: false,
   420  			},
   421  			{
   422  				name: "observedGeneration not up to date",
   423  				object: &unstructured.Unstructured{Object: map[string]interface{}{
   424  					"metadata": map[string]interface{}{
   425  						"generation": int64(1),
   426  					},
   427  					"status": map[string]interface{}{
   428  						"conditions": []interface{}{
   429  							map[string]interface{}{
   430  								"type":               conditions.ConditionTypeReady,
   431  								"observedGeneration": int64(0),
   432  							},
   433  						},
   434  					},
   435  				}},
   436  				expectedReady: false,
   437  			},
   438  			{
   439  				name: "status is not defined",
   440  				object: &unstructured.Unstructured{Object: map[string]interface{}{
   441  					"status": map[string]interface{}{
   442  						"conditions": []interface{}{
   443  							map[string]interface{}{
   444  								"type":    conditions.ConditionTypeReady,
   445  								"message": "a message",
   446  							},
   447  						},
   448  					},
   449  				}},
   450  				expectedReady: false,
   451  			},
   452  			{
   453  				name: "status is not True",
   454  				object: &unstructured.Unstructured{Object: map[string]interface{}{
   455  					"status": map[string]interface{}{
   456  						"conditions": []interface{}{
   457  							map[string]interface{}{
   458  								"type":    conditions.ConditionTypeReady,
   459  								"status":  "not-" + string(metav1.ConditionTrue),
   460  								"message": "a message",
   461  							},
   462  						},
   463  					},
   464  				}},
   465  				expectedReady: false,
   466  			},
   467  			{
   468  				name: "status is True",
   469  				object: &unstructured.Unstructured{Object: map[string]interface{}{
   470  					"status": map[string]interface{}{
   471  						"conditions": []interface{}{
   472  							map[string]interface{}{
   473  								"type":   "not-" + conditions.ConditionTypeReady,
   474  								"status": "not-" + string(metav1.ConditionTrue),
   475  							},
   476  							map[string]interface{}{
   477  								"type":   conditions.ConditionTypeReady,
   478  								"status": string(metav1.ConditionTrue),
   479  							},
   480  							map[string]interface{}{
   481  								"type":   "not-" + conditions.ConditionTypeReady,
   482  								"status": "not-" + string(metav1.ConditionTrue),
   483  							},
   484  						},
   485  					},
   486  				}},
   487  				expectedReady: true,
   488  			},
   489  		}
   490  
   491  		for _, test := range tests {
   492  			t.Run(test.name, func(t *testing.T) {
   493  				g := NewGomegaWithT(t)
   494  
   495  				ready, err := readyStatus(ctx, test.object)
   496  				g.Expect(err).NotTo(HaveOccurred())
   497  				g.Expect(ready).To(Equal(test.expectedReady))
   498  			})
   499  		}
   500  	})
   501  
   502  	// These tests verify readyStatus() on an actual ASO typed object to ensure the unstructured assertions
   503  	// work on the actual structure of ASO objects.
   504  	t.Run("ResourceGroup", func(t *testing.T) {
   505  		tests := []struct {
   506  			name          string
   507  			conditions    conditions.Conditions
   508  			expectedReady bool
   509  		}{
   510  			{
   511  				name:          "empty conditions",
   512  				conditions:    nil,
   513  				expectedReady: false,
   514  			},
   515  			{
   516  				name: "not ready conditions",
   517  				conditions: conditions.Conditions{
   518  					{
   519  						Type:    conditions.ConditionTypeReady,
   520  						Status:  metav1.ConditionFalse,
   521  						Message: "a message",
   522  					},
   523  					{
   524  						Type:    "not-" + conditions.ConditionTypeReady,
   525  						Status:  metav1.ConditionTrue,
   526  						Message: "another message",
   527  					},
   528  				},
   529  				expectedReady: false,
   530  			},
   531  			{
   532  				name: "ready conditions",
   533  				conditions: conditions.Conditions{
   534  					{
   535  						Type:    "not-" + conditions.ConditionTypeReady,
   536  						Status:  metav1.ConditionTrue,
   537  						Message: "another message",
   538  					},
   539  					{
   540  						Type:    conditions.ConditionTypeReady,
   541  						Status:  metav1.ConditionTrue,
   542  						Message: "a message",
   543  					},
   544  					{
   545  						Type:    "not-" + conditions.ConditionTypeReady,
   546  						Status:  metav1.ConditionTrue,
   547  						Message: "another message",
   548  					},
   549  				},
   550  				expectedReady: true,
   551  			},
   552  		}
   553  
   554  		s := runtime.NewScheme()
   555  		NewGomegaWithT(t).Expect(asoresourcesv1.AddToScheme(s)).To(Succeed())
   556  
   557  		for _, test := range tests {
   558  			t.Run(test.name, func(t *testing.T) {
   559  				g := NewGomegaWithT(t)
   560  
   561  				rg := &asoresourcesv1.ResourceGroup{
   562  					Status: asoresourcesv1.ResourceGroup_STATUS{
   563  						Conditions: test.conditions,
   564  					},
   565  				}
   566  				u := &unstructured.Unstructured{}
   567  				g.Expect(s.Convert(rg, u, nil)).To(Succeed())
   568  
   569  				ready, err := readyStatus(ctx, u)
   570  				g.Expect(err).NotTo(HaveOccurred())
   571  				g.Expect(ready).To(Equal(test.expectedReady))
   572  			})
   573  		}
   574  	})
   575  }
   576  
   577  func rgJSON(g Gomega, scheme *runtime.Scheme, rg *asoresourcesv1.ResourceGroup) *unstructured.Unstructured {
   578  	rg.SetGroupVersionKind(asoresourcesv1.GroupVersion.WithKind("ResourceGroup"))
   579  	u := &unstructured.Unstructured{}
   580  	g.Expect(scheme.Convert(rg, u, nil)).To(Succeed())
   581  	return u
   582  }
   583  
   584  func rgStatus(name string) infrav1alpha.ResourceStatus {
   585  	return infrav1alpha.ResourceStatus{
   586  		Resource: infrav1alpha.StatusResource{
   587  			Group:   asoresourcesv1.GroupVersion.Group,
   588  			Version: asoresourcesv1.GroupVersion.Version,
   589  			Kind:    "ResourceGroup",
   590  			Name:    name,
   591  		},
   592  	}
   593  }