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 }