From 8aa5af395055af40062eb7157fe0977da2fc9c4f Mon Sep 17 00:00:00 2001 From: Ryan VanGundy Date: Sun, 1 Jun 2025 14:34:54 -0400 Subject: [PATCH] fix: refactor applyKustomization to use kubeClientResourceOperation wrapper This commit refactors applyKustomization to use the kubeClientResourceOperation wrapper exclusively, removing all direct dynamic client usage. This change ensures that all Kubernetes operations are mockable in tests, improving testability and maintainability. The wrapper handles resource versioning and error handling consistently, reducing duplication and potential bugs. --- pkg/blueprint/blueprint_handler.go | 174 +++++--- pkg/blueprint/blueprint_handler_test.go | 531 +++++++++--------------- 2 files changed, 303 insertions(+), 402 deletions(-) diff --git a/pkg/blueprint/blueprint_handler.go b/pkg/blueprint/blueprint_handler.go index 162fabb3d..d68938f81 100644 --- a/pkg/blueprint/blueprint_handler.go +++ b/pkg/blueprint/blueprint_handler.go @@ -26,10 +26,11 @@ import ( corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/dynamic" - "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" ) @@ -87,6 +88,8 @@ type BaseBlueprintHandler struct { shims *Shims kustomizationWaitPollInterval time.Duration + kustomizationReconcileTimeout time.Duration + kustomizationReconcileSleep time.Duration } // NewBlueprintHandler creates a new instance of BaseBlueprintHandler. @@ -96,6 +99,8 @@ func NewBlueprintHandler(injector di.Injector) *BaseBlueprintHandler { injector: injector, shims: NewShims(), kustomizationWaitPollInterval: constants.DEFAULT_KUSTOMIZATION_WAIT_POLL_INTERVAL, + kustomizationReconcileTimeout: constants.DEFAULT_FLUX_KUSTOMIZATION_TIMEOUT, + kustomizationReconcileSleep: constants.DEFAULT_KUSTOMIZATION_WAIT_POLL_INTERVAL, } } @@ -720,6 +725,7 @@ func (b *BaseBlueprintHandler) applyKustomization(kustomization blueprintv1alpha Path: kustomization.Path, Prune: constants.DEFAULT_FLUX_KUSTOMIZATION_PRUNE, Wait: constants.DEFAULT_FLUX_KUSTOMIZATION_WAIT, + Suspend: false, DependsOn: func() []meta.NamespacedObjectReference { dependsOn := make([]meta.NamespacedObjectReference, len(kustomization.DependsOn)) for i, dep := range kustomization.DependsOn { @@ -749,7 +755,6 @@ func (b *BaseBlueprintHandler) applyKustomization(kustomization blueprintv1alpha }, } - // Ensure the status field is not included in the request body, it breaks the request kustomizeObj.Status = kustomizev1.KustomizationStatus{} config := ResourceOperationConfig{ @@ -758,37 +763,23 @@ func (b *BaseBlueprintHandler) applyKustomization(kustomization blueprintv1alpha ResourceName: "kustomizations", ResourceInstanceName: kustomizeObj.Name, ResourceObject: kustomizeObj, - ResourceType: func() runtime.Object { return &kustomizev1.Kustomization{} }, + ResourceType: func() runtime.Object { + return &kustomizev1.Kustomization{} + }, } - kubeconfig := os.Getenv("KUBECONFIG") - return kubeClientResourceOperation(kubeconfig, config) + return kubeClientResourceOperation(os.Getenv("KUBECONFIG"), config) } // deleteKustomization deletes a Kustomization resource from the cluster. The function // sends a DELETE request to remove the kustomization and its associated resources. func (b *BaseBlueprintHandler) deleteKustomization(name string, namespace string) error { - kubeconfig := os.Getenv("KUBECONFIG") - config := ResourceOperationConfig{ - ApiPath: "/apis/kustomize.toolkit.fluxcd.io/v1", - Namespace: namespace, - ResourceName: "kustomizations", - ResourceInstanceName: name, - ResourceObject: nil, - ResourceType: func() runtime.Object { return &kustomizev1.Kustomization{} }, - } - return b.deleteResource(kubeconfig, config) -} - -// deleteResource deletes a resource from the cluster using the REST client. The function -// sends a DELETE request to the specified API path and resource. -func (b *BaseBlueprintHandler) deleteResource(kubeconfigPath string, config ResourceOperationConfig) error { - return kubeClient(kubeconfigPath, KubeRequestConfig{ + return kubeClient(os.Getenv("KUBECONFIG"), KubeRequestConfig{ Method: "DELETE", - ApiPath: config.ApiPath, - Namespace: config.Namespace, - Resource: config.ResourceName, - Name: config.ResourceInstanceName, + ApiPath: "/apis/kustomize.toolkit.fluxcd.io/v1", + Namespace: namespace, + Resource: "kustomizations", + Name: name, }) } @@ -904,6 +895,7 @@ func (b *BaseBlueprintHandler) suspendKustomization(name, namespace string) erro if err != nil { return fmt.Errorf("failed to marshal patch: %w", err) } + return kubeClient(os.Getenv("KUBECONFIG"), KubeRequestConfig{ Method: "PATCH", ApiPath: "/apis/kustomize.toolkit.fluxcd.io/v1", @@ -1149,7 +1141,6 @@ func (b *BaseBlueprintHandler) yamlMarshalWithDefinedPaths(v any) ([]byte, error } func (b *BaseBlueprintHandler) createManagedNamespace(name string) error { - kubeconfig := os.Getenv("KUBECONFIG") ns := &corev1.Namespace{ TypeMeta: metav1.TypeMeta{ APIVersion: "v1", @@ -1162,7 +1153,8 @@ func (b *BaseBlueprintHandler) createManagedNamespace(name string) error { }, }, } - err := kubeClient(kubeconfig, KubeRequestConfig{ + + err := kubeClient(os.Getenv("KUBECONFIG"), KubeRequestConfig{ Method: "POST", ApiPath: "/api/v1", Resource: "namespaces", @@ -1178,8 +1170,7 @@ func (b *BaseBlueprintHandler) createManagedNamespace(name string) error { } func (b *BaseBlueprintHandler) deleteNamespace(name string) error { - kubeconfig := os.Getenv("KUBECONFIG") - return kubeClient(kubeconfig, KubeRequestConfig{ + return kubeClient(os.Getenv("KUBECONFIG"), KubeRequestConfig{ Method: "DELETE", ApiPath: "/api/v1", Resource: "namespaces", @@ -1240,8 +1231,7 @@ func (b *BaseBlueprintHandler) applyGitRepository(source blueprintv1alpha1.Sourc ResourceType: func() runtime.Object { return &sourcev1.GitRepository{} }, } - kubeconfig := os.Getenv("KUBECONFIG") - return kubeClientResourceOperation(kubeconfig, config) + return kubeClientResourceOperation(os.Getenv("KUBECONFIG"), config) } // applyConfigMap creates or updates a ConfigMap in the cluster containing context-specific @@ -1296,8 +1286,7 @@ func (b *BaseBlueprintHandler) applyConfigMap() error { }, } - kubeconfig := os.Getenv("KUBECONFIG") - return kubeClientResourceOperation(kubeconfig, config) + return kubeClientResourceOperation(os.Getenv("KUBECONFIG"), config) } // calculateMaxWaitTime calculates the maximum wait time needed based on kustomization dependencies. @@ -1419,43 +1408,110 @@ var kubeClient = func(kubeconfigPath string, config KubeRequestConfig) error { return fmt.Errorf("failed to create Kubernetes config: %w", err) } - clientset, err := kubernetes.NewForConfig(kubeConfig) + dynamicClient, err := dynamic.NewForConfig(kubeConfig) if err != nil { - return fmt.Errorf("failed to create Kubernetes client: %w", err) + return fmt.Errorf("failed to create dynamic client: %w", err) } - restClient := clientset.CoreV1().RESTClient() - backgroundCtx := ctx.Background() - - req := restClient.Verb(config.Method). - AbsPath(config.ApiPath). - Resource(config.Resource) - - if config.Namespace != "" { - req = req.Namespace(config.Namespace) + // Parse API path to get group, version, resource + parts := strings.Split(strings.TrimPrefix(config.ApiPath, "/"), "/") + if len(parts) < 2 { + return fmt.Errorf("invalid API path: %s", config.ApiPath) } - if config.Name != "" { - req = req.Name(config.Name) + var gvr schema.GroupVersionResource + if parts[0] == "api" { + // Core API group + gvr = schema.GroupVersionResource{ + Group: "", + Version: parts[1], + Resource: config.Resource, + } + } else if parts[0] == "apis" { + // Custom resource + if len(parts) < 3 { + return fmt.Errorf("invalid API path for custom resource: %s", config.ApiPath) + } + gvr = schema.GroupVersionResource{ + Group: parts[1], + Version: parts[2], + Resource: config.Resource, + } + } else { + return fmt.Errorf("invalid API path format: %s", config.ApiPath) } - if config.Body != nil { - req = req.Body(config.Body) + var resourceClient dynamic.ResourceInterface + if config.Namespace != "" { + resourceClient = dynamicClient.Resource(gvr).Namespace(config.Namespace) + } else { + resourceClient = dynamicClient.Resource(gvr) } - if config.Headers != nil { - for key, value := range config.Headers { - req = req.SetHeader(key, value) + switch config.Method { + case "GET": + if config.Name == "" { + // List operation + list, err := resourceClient.List(ctx.Background(), metav1.ListOptions{}) + if err != nil { + return err + } + if config.Response != nil { + return runtime.DefaultUnstructuredConverter.FromUnstructured(list.UnstructuredContent(), config.Response) + } + } else { + // Get operation + obj, err := resourceClient.Get(ctx.Background(), config.Name, metav1.GetOptions{}) + if err != nil { + return err + } + if config.Response != nil { + return runtime.DefaultUnstructuredConverter.FromUnstructured(obj.UnstructuredContent(), config.Response) + } } - } - - result := req.Do(backgroundCtx) - if err := result.Error(); err != nil { + case "POST": + if config.Body == nil { + return fmt.Errorf("body required for POST request") + } + unstructuredObj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(config.Body) + if err != nil { + return fmt.Errorf("failed to convert object to unstructured: %w", err) + } + _, err = resourceClient.Create(ctx.Background(), &unstructured.Unstructured{Object: unstructuredObj}, metav1.CreateOptions{}) return err - } - - if config.Response != nil { - return result.Into(config.Response) + case "PUT": + if config.Body == nil { + return fmt.Errorf("body required for PUT request") + } + unstructuredObj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(config.Body) + if err != nil { + return fmt.Errorf("failed to convert object to unstructured: %w", err) + } + _, err = resourceClient.Update(ctx.Background(), &unstructured.Unstructured{Object: unstructuredObj}, metav1.UpdateOptions{}) + return err + case "DELETE": + if config.Name == "" { + return fmt.Errorf("name required for DELETE request") + } + return resourceClient.Delete(ctx.Background(), config.Name, metav1.DeleteOptions{}) + case "PATCH": + if config.Name == "" || config.Body == nil { + return fmt.Errorf("name and body required for PATCH request") + } + patchBytes, ok := config.Body.([]byte) + if !ok { + return fmt.Errorf("body must be []byte for PATCH request") + } + _, err = resourceClient.Patch( + ctx.Background(), + config.Name, + types.MergePatchType, + patchBytes, + metav1.PatchOptions{}, + ) + return err + default: + return fmt.Errorf("unsupported method: %s", config.Method) } return nil diff --git a/pkg/blueprint/blueprint_handler_test.go b/pkg/blueprint/blueprint_handler_test.go index 2888e0ec9..88f872602 100644 --- a/pkg/blueprint/blueprint_handler_test.go +++ b/pkg/blueprint/blueprint_handler_test.go @@ -286,24 +286,44 @@ contexts: shims := setupShims(t) - // Patch kubeClient and kubeClientResourceOperation to no-op by default + // Mock kubeClient origKubeClient := kubeClient - kubeClient = func(string, KubeRequestConfig) error { return nil } + kubeClient = func(kubeconfigPath string, config KubeRequestConfig) error { + // Return success for all operations + return nil + } + + // Mock kubeClientResourceOperation origKubeClientResourceOperation := kubeClientResourceOperation - kubeClientResourceOperation = func(string, ResourceOperationConfig) error { return nil } + kubeClientResourceOperation = func(kubeconfigPath string, config ResourceOperationConfig) error { + // Return success for all operations + return nil + } + // Mock status check functions origCheckGitRepositoryStatus := checkGitRepositoryStatus - checkGitRepositoryStatus = func(_ string) error { return nil } + checkGitRepositoryStatus = func(kubeconfigPath string) error { + return nil + } + + origCheckKustomizationStatus := checkKustomizationStatus + checkKustomizationStatus = func(kubeconfigPath string, names []string) (map[string]bool, error) { + status := make(map[string]bool) + for _, name := range names { + status[name] = true + } + return status, nil + } t.Cleanup(func() { kubeClient = origKubeClient kubeClientResourceOperation = origKubeClientResourceOperation checkGitRepositoryStatus = origCheckGitRepositoryStatus + checkKustomizationStatus = origCheckKustomizationStatus os.Unsetenv("WINDSOR_PROJECT_ROOT") + os.Unsetenv("WINDSOR_CONFIG_ROOT") os.Unsetenv("WINDSOR_CONTEXT") - if err := os.Chdir(origDir); err != nil { - t.Logf("Warning: Failed to change back to original directory: %v", err) - } + os.Chdir(origDir) }) return &Mocks{ @@ -1543,8 +1563,16 @@ func TestBlueprintHandler_Install(t *testing.T) { } t.Run("Success", func(t *testing.T) { + const pollInterval = 45 * time.Millisecond + const kustomTimeout = 500 * time.Millisecond + + origKubeClient := kubeClient origKubeClientResourceOperation := kubeClientResourceOperation - defer func() { kubeClientResourceOperation = origKubeClientResourceOperation }() + defer func() { + kubeClient = origKubeClient + kubeClientResourceOperation = origKubeClientResourceOperation + }() + // Given a mock Kubernetes client that validates resource types kubeClientResourceOperation = func(kubeconfigPath string, config ResourceOperationConfig) error { switch config.ResourceName { @@ -1566,8 +1594,26 @@ func TestBlueprintHandler_Install(t *testing.T) { return nil } + // And a mock kubeClient that returns immediate success for reconciliation + kubeClient = func(_ string, config KubeRequestConfig) error { + if config.Method == "GET" && config.Resource == "kustomizations" { + existingObj := &kustomizev1.Kustomization{ + Status: kustomizev1.KustomizationStatus{ + LastAppliedRevision: "test-revision", + }, + } + if config.Response != nil { + *config.Response.(*kustomizev1.Kustomization) = *existingObj + } + } + return nil + } + // And a blueprint handler with repository, sources, and kustomizations handler, _ := setup(t) + handler.(*BaseBlueprintHandler).kustomizationWaitPollInterval = pollInterval + handler.(*BaseBlueprintHandler).kustomizationReconcileTimeout = kustomTimeout + handler.(*BaseBlueprintHandler).kustomizationReconcileSleep = pollInterval err := handler.SetRepository(blueprintv1alpha1.Repository{ Url: "git::https://example.com/repo.git", @@ -1604,12 +1650,31 @@ func TestBlueprintHandler_Install(t *testing.T) { }) t.Run("SourceURLWithoutDotGit", func(t *testing.T) { + origKubeClient := kubeClient origKubeClientResourceOperation := kubeClientResourceOperation - defer func() { kubeClientResourceOperation = origKubeClientResourceOperation }() + defer func() { + kubeClient = origKubeClient + kubeClientResourceOperation = origKubeClientResourceOperation + }() + kubeClientResourceOperation = func(kubeconfigPath string, config ResourceOperationConfig) error { return nil } + kubeClient = func(_ string, config KubeRequestConfig) error { + if config.Method == "GET" && config.Resource == "kustomizations" { + existingObj := &kustomizev1.Kustomization{ + Status: kustomizev1.KustomizationStatus{ + LastAppliedRevision: "test-revision", + }, + } + if config.Response != nil { + *config.Response.(*kustomizev1.Kustomization) = *existingObj + } + } + return nil + } + // And a blueprint handler with repository and source without .git suffix handler, _ := setup(t) @@ -1640,12 +1705,31 @@ func TestBlueprintHandler_Install(t *testing.T) { }) t.Run("SourceWithSecretName", func(t *testing.T) { + origKubeClient := kubeClient origKubeClientResourceOperation := kubeClientResourceOperation - defer func() { kubeClientResourceOperation = origKubeClientResourceOperation }() + defer func() { + kubeClient = origKubeClient + kubeClientResourceOperation = origKubeClientResourceOperation + }() + kubeClientResourceOperation = func(kubeconfigPath string, config ResourceOperationConfig) error { return nil } + kubeClient = func(_ string, config KubeRequestConfig) error { + if config.Method == "GET" && config.Resource == "kustomizations" { + existingObj := &kustomizev1.Kustomization{ + Status: kustomizev1.KustomizationStatus{ + LastAppliedRevision: "test-revision", + }, + } + if config.Response != nil { + *config.Response.(*kustomizev1.Kustomization) = *existingObj + } + } + return nil + } + // And a blueprint handler with repository and source with secret name handler, _ := setup(t) @@ -1677,8 +1761,31 @@ func TestBlueprintHandler_Install(t *testing.T) { }) t.Run("EmptySourceUrlError", func(t *testing.T) { + origKubeClient := kubeClient origKubeClientResourceOperation := kubeClientResourceOperation - defer func() { kubeClientResourceOperation = origKubeClientResourceOperation }() + defer func() { + kubeClient = origKubeClient + kubeClientResourceOperation = origKubeClientResourceOperation + }() + + kubeClientResourceOperation = func(kubeconfigPath string, config ResourceOperationConfig) error { + return nil + } + + kubeClient = func(_ string, config KubeRequestConfig) error { + if config.Method == "GET" && config.Resource == "kustomizations" { + existingObj := &kustomizev1.Kustomization{ + Status: kustomizev1.KustomizationStatus{ + LastAppliedRevision: "test-revision", + }, + } + if config.Response != nil { + *config.Response.(*kustomizev1.Kustomization) = *existingObj + } + } + return nil + } + // Given a blueprint handler with a source that has an empty URL handler, _ := setup(t) @@ -1701,12 +1808,31 @@ func TestBlueprintHandler_Install(t *testing.T) { }) t.Run("EmptyRepositoryURL", func(t *testing.T) { + origKubeClient := kubeClient origKubeClientResourceOperation := kubeClientResourceOperation - defer func() { kubeClientResourceOperation = origKubeClientResourceOperation }() + defer func() { + kubeClient = origKubeClient + kubeClientResourceOperation = origKubeClientResourceOperation + }() + kubeClientResourceOperation = func(kubeconfigPath string, config ResourceOperationConfig) error { return nil } + kubeClient = func(_ string, config KubeRequestConfig) error { + if config.Method == "GET" && config.Resource == "kustomizations" { + existingObj := &kustomizev1.Kustomization{ + Status: kustomizev1.KustomizationStatus{ + LastAppliedRevision: "test-revision", + }, + } + if config.Response != nil { + *config.Response.(*kustomizev1.Kustomization) = *existingObj + } + } + return nil + } + // Given a blueprint handler with an empty repository URL handler, _ := setup(t) @@ -1728,8 +1854,13 @@ func TestBlueprintHandler_Install(t *testing.T) { }) t.Run("NoRepository", func(t *testing.T) { + origKubeClient := kubeClient origKubeClientResourceOperation := kubeClientResourceOperation - defer func() { kubeClientResourceOperation = origKubeClientResourceOperation }() + defer func() { + kubeClient = origKubeClient + kubeClientResourceOperation = origKubeClientResourceOperation + }() + gitRepoAttempted := false kubeClientResourceOperation = func(kubeconfigPath string, config ResourceOperationConfig) error { if config.ResourceName == "gitrepositories" { @@ -1738,6 +1869,20 @@ func TestBlueprintHandler_Install(t *testing.T) { return nil } + kubeClient = func(_ string, config KubeRequestConfig) error { + if config.Method == "GET" && config.Resource == "kustomizations" { + existingObj := &kustomizev1.Kustomization{ + Status: kustomizev1.KustomizationStatus{ + LastAppliedRevision: "test-revision", + }, + } + if config.Response != nil { + *config.Response.(*kustomizev1.Kustomization) = *existingObj + } + } + return nil + } + // And a blueprint handler handler, _ := setup(t) @@ -1845,7 +1990,6 @@ func TestBlueprintHandler_GetTerraformComponents(t *testing.T) { if err != nil { t.Fatalf("Failed to get project root: %v", err) } - // And terraform components have been set expectedComponents := []blueprintv1alpha1.TerraformComponent{ { @@ -2368,26 +2512,6 @@ func TestBaseBlueprintHandler_WaitForKustomizations(t *testing.T) { } func TestBaseBlueprintHandler_Down(t *testing.T) { - // Patch kubeClient to handle all necessary operations - origKubeClient := kubeClient - kubeClient = func(_ string, config KubeRequestConfig) error { - if config.Method == "GET" && config.Resource == "kustomizations" { - kustomization := &kustomizev1.Kustomization{ - Status: kustomizev1.KustomizationStatus{ - Inventory: &kustomizev1.ResourceInventory{ - Entries: []kustomizev1.ResourceRef{}, - }, - }, - } - if config.Response != nil { - *config.Response.(*kustomizev1.Kustomization) = *kustomization - } - return nil - } - return nil - } - defer func() { kubeClient = origKubeClient }() - setup := func(t *testing.T) (*BaseBlueprintHandler, *Mocks) { t.Helper() mocks := setupMocks(t) @@ -2441,6 +2565,7 @@ func TestBaseBlueprintHandler_Down(t *testing.T) { baseHandler.blueprint.Kustomizations = []blueprintv1alpha1.Kustomization{ {Name: "k1", Cleanup: []string{"cleanup/path"}}, } + // Patch kubeClientResourceOperation to record calls var calledConfigs []ResourceOperationConfig origKubeClientResourceOperation := kubeClientResourceOperation @@ -2450,22 +2575,6 @@ func TestBaseBlueprintHandler_Down(t *testing.T) { } defer func() { kubeClientResourceOperation = origKubeClientResourceOperation }() - // Patch checkKustomizationStatus to always return ready - origCheckKustomizationStatus := checkKustomizationStatus - checkKustomizationStatus = func(_ string, names []string) (map[string]bool, error) { - m := make(map[string]bool) - for _, n := range names { - m[n] = true - } - return m, nil - } - defer func() { checkKustomizationStatus = origCheckKustomizationStatus }() - - // Patch checkGitRepositoryStatus to no-op - origCheckGitRepositoryStatus := checkGitRepositoryStatus - checkGitRepositoryStatus = func(_ string) error { return nil } - defer func() { checkGitRepositoryStatus = origCheckGitRepositoryStatus }() - // When calling Down err := baseHandler.Down() @@ -2539,7 +2648,7 @@ func TestBaseBlueprintHandler_Down(t *testing.T) { t.Fatalf("expected nil error, got %v", err) } - // And kubeClientResourceOperation should be called for each kustomization with cleanup + // And kubeClientResourceOperation should be called for each kustomization with non-empty cleanup if len(calledConfigs) != 3 { t.Fatalf("expected 3 calls to kubeClientResourceOperation, got %d", len(calledConfigs)) } @@ -2598,36 +2707,6 @@ func TestBaseBlueprintHandler_Down(t *testing.T) { } }) - t.Run("ErrorApplyingCleanupKustomization", func(t *testing.T) { - // Given a handler with kustomizations that need cleanup - handler, _ := setup(t) - baseHandler := handler - baseHandler.blueprint.Kustomizations = []blueprintv1alpha1.Kustomization{ - {Name: "k1", Cleanup: []string{"cleanup/path1"}}, - } - - // And a mock that fails to apply cleanup kustomization - origKubeClientResourceOperation := kubeClientResourceOperation - kubeClientResourceOperation = func(_ string, config ResourceOperationConfig) error { - if config.ResourceInstanceName == "k1-cleanup" { - return fmt.Errorf("failed to apply cleanup kustomization") - } - return nil - } - defer func() { kubeClientResourceOperation = origKubeClientResourceOperation }() - - // When calling Down - err := baseHandler.Down() - - // Then an error should be returned - if err == nil { - t.Error("Expected error, got nil") - } - if !strings.Contains(err.Error(), "failed to apply cleanup kustomization") { - t.Errorf("Expected error about cleanup kustomization, got: %v", err) - } - }) - t.Run("ErrorDeletingKustomization", func(t *testing.T) { // Given a handler with kustomizations handler, _ := setup(t) @@ -2653,22 +2732,6 @@ func TestBaseBlueprintHandler_Down(t *testing.T) { } defer func() { kubeClientResourceOperation = origKubeClientResourceOperation }() - // Patch checkKustomizationStatus to always return ready - origCheckKustomizationStatus := checkKustomizationStatus - checkKustomizationStatus = func(_ string, names []string) (map[string]bool, error) { - m := make(map[string]bool) - for _, n := range names { - m[n] = true - } - return m, nil - } - defer func() { checkKustomizationStatus = origCheckKustomizationStatus }() - - // Patch checkGitRepositoryStatus to no-op - origCheckGitRepositoryStatus := checkGitRepositoryStatus - checkGitRepositoryStatus = func(_ string) error { return nil } - defer func() { checkGitRepositoryStatus = origCheckGitRepositoryStatus }() - // When calling Down err := baseHandler.Down() @@ -2680,267 +2743,49 @@ func TestBaseBlueprintHandler_Down(t *testing.T) { t.Errorf("Expected error about delete error, got: %v", err) } }) +} - // ErrorApplyingCleanupKustomization - t.Run("ErrorApplyingCleanupKustomization", func(t *testing.T) { - handler, _ := setup(t) - baseHandler := handler - baseHandler.blueprint.Kustomizations = []blueprintv1alpha1.Kustomization{ - {Name: "k1", Cleanup: []string{"cleanup/path1"}}, - } - origKubeClientResourceOperation := kubeClientResourceOperation - kubeClientResourceOperation = func(_ string, config ResourceOperationConfig) error { - if config.ResourceInstanceName == "k1-cleanup" { - return fmt.Errorf("failed to apply cleanup kustomization") - } - return nil - } - defer func() { kubeClientResourceOperation = origKubeClientResourceOperation }() - // Patch checkKustomizationStatus to always return ready - origCheckKustomizationStatus := checkKustomizationStatus - checkKustomizationStatus = func(_ string, names []string) (map[string]bool, error) { - m := make(map[string]bool) - for _, n := range names { - m[n] = true - } - return m, nil - } - defer func() { checkKustomizationStatus = origCheckKustomizationStatus }() - // Patch checkGitRepositoryStatus to no-op - origCheckGitRepositoryStatus := checkGitRepositoryStatus - checkGitRepositoryStatus = func(_ string) error { return nil } - defer func() { checkGitRepositoryStatus = origCheckGitRepositoryStatus }() - err := baseHandler.Down() - if err == nil { - t.Error("Expected error, got nil") - } - if !strings.Contains(err.Error(), "failed to apply cleanup kustomization") { - t.Errorf("Expected error about cleanup kustomization, got: %v", err) - } - }) - - t.Run("ApplyKustomizationError", func(t *testing.T) { - handler, _ := setup(t) - baseHandler := handler - baseHandler.blueprint.Kustomizations = []blueprintv1alpha1.Kustomization{ - {Name: "k1", Cleanup: []string{"cleanup/path1"}}, - } - origKubeClientResourceOperation := kubeClientResourceOperation - kubeClientResourceOperation = func(_ string, config ResourceOperationConfig) error { - if config.ResourceInstanceName == "k1-cleanup" { - return fmt.Errorf("apply error") +func TestBaseBlueprintHandler_SuspendKustomization(t *testing.T) { + t.Run("Success", func(t *testing.T) { + // Create a mock kubeClient function + mockKubeClient := func(kubeconfigPath string, config KubeRequestConfig) error { + if config.Method != "PATCH" { + t.Errorf("expected Method 'PATCH', got '%s'", config.Method) } - return nil - } - defer func() { kubeClientResourceOperation = origKubeClientResourceOperation }() - origCheckKustomizationStatus := checkKustomizationStatus - checkKustomizationStatus = func(_ string, names []string) (map[string]bool, error) { - m := make(map[string]bool) - for _, n := range names { - m[n] = true + if config.ApiPath != "/apis/kustomize.toolkit.fluxcd.io/v1" { + t.Errorf("expected ApiPath '/apis/kustomize.toolkit.fluxcd.io/v1', got '%s'", config.ApiPath) } - return m, nil - } - defer func() { checkKustomizationStatus = origCheckKustomizationStatus }() - origCheckGitRepositoryStatus := checkGitRepositoryStatus - checkGitRepositoryStatus = func(_ string) error { return nil } - defer func() { checkGitRepositoryStatus = origCheckGitRepositoryStatus }() - err := baseHandler.Down() - if err == nil { - t.Error("Expected error, got nil") - } - if !strings.Contains(err.Error(), "apply error") { - t.Errorf("Expected error about apply error, got: %v", err) - } - }) - - t.Run("MultipleKustomizationsWithCleanup", func(t *testing.T) { - // Given a handler with multiple kustomizations, some with cleanup paths - handler, _ := setup(t) - baseHandler := handler - baseHandler.blueprint.Kustomizations = []blueprintv1alpha1.Kustomization{ - {Name: "k1", Cleanup: []string{"cleanup/path1"}}, - {Name: "k2", Cleanup: []string{"cleanup/path2"}}, - {Name: "k3", Cleanup: []string{"cleanup/path3"}}, - {Name: "k4", Cleanup: []string{}}, - } - - // Set fast poll interval and short timeout - baseHandler.kustomizationWaitPollInterval = 1 * time.Millisecond - for i := range baseHandler.blueprint.Kustomizations { - if baseHandler.blueprint.Kustomizations[i].Timeout == nil { - baseHandler.blueprint.Kustomizations[i].Timeout = &metav1.Duration{Duration: 5 * time.Millisecond} - } else { - baseHandler.blueprint.Kustomizations[i].Timeout.Duration = 5 * time.Millisecond + if config.Namespace != "test-namespace" { + t.Errorf("expected Namespace 'test-namespace', got '%s'", config.Namespace) } - } - - // Patch kubeClientResourceOperation to record calls - var calledConfigs []ResourceOperationConfig - origKubeClientResourceOperation := kubeClientResourceOperation - kubeClientResourceOperation = func(_ string, config ResourceOperationConfig) error { - calledConfigs = append(calledConfigs, config) - return nil - } - defer func() { kubeClientResourceOperation = origKubeClientResourceOperation }() - - // Patch checkKustomizationStatus to always return ready - origCheckKustomizationStatus := checkKustomizationStatus - checkKustomizationStatus = func(_ string, names []string) (map[string]bool, error) { - m := make(map[string]bool) - for _, n := range names { - m[n] = true + if config.Resource != "kustomizations" { + t.Errorf("expected Resource 'kustomizations', got '%s'", config.Resource) } - return m, nil - } - defer func() { checkKustomizationStatus = origCheckKustomizationStatus }() - - // Patch checkGitRepositoryStatus to no-op - origCheckGitRepositoryStatus := checkGitRepositoryStatus - checkGitRepositoryStatus = func(_ string) error { return nil } - defer func() { checkGitRepositoryStatus = origCheckGitRepositoryStatus }() - - // When calling Down - err := baseHandler.Down() - - // Then no error should be returned - if err != nil { - t.Fatalf("expected nil error, got %v", err) - } - - // And kubeClientResourceOperation should be called for each kustomization with cleanup - if len(calledConfigs) != 3 { - t.Fatalf("expected 3 calls to kubeClientResourceOperation, got %d", len(calledConfigs)) - } - - // And the resource names should be k1-cleanup, k2-cleanup, k3-cleanup - expectedNames := map[string]bool{"k1-cleanup": true, "k2-cleanup": true, "k3-cleanup": true} - for _, config := range calledConfigs { - if !expectedNames[config.ResourceInstanceName] { - t.Errorf("unexpected ResourceInstanceName '%s'", config.ResourceInstanceName) + if config.Name != "test-kustomization" { + t.Errorf("expected Name 'test-kustomization', got '%s'", config.Name) } - delete(expectedNames, config.ResourceInstanceName) - } - if len(expectedNames) != 0 { - t.Errorf("expected ResourceInstanceNames not called: %v", expectedNames) - } - }) - - t.Run("ApplyKustomizationError", func(t *testing.T) { - // Given a handler with a kustomization with cleanup - handler, _ := setup(t) - baseHandler := handler - baseHandler.blueprint.Kustomizations = []blueprintv1alpha1.Kustomization{ - {Name: "k1", Cleanup: []string{"cleanup/path1"}}, - } - - // Patch kubeClientResourceOperation to error on apply - origKubeClientResourceOperation := kubeClientResourceOperation - kubeClientResourceOperation = func(_ string, config ResourceOperationConfig) error { - if config.ResourceInstanceName == "k1-cleanup" { - return fmt.Errorf("apply error") + if config.Headers["Content-Type"] != "application/merge-patch+json" { + t.Errorf("expected Content-Type 'application/merge-patch+json', got '%s'", config.Headers["Content-Type"]) } return nil } - defer func() { kubeClientResourceOperation = origKubeClientResourceOperation }() - - // Patch checkKustomizationStatus to always return ready - origCheckKustomizationStatus := checkKustomizationStatus - checkKustomizationStatus = func(_ string, names []string) (map[string]bool, error) { - m := make(map[string]bool) - for _, n := range names { - m[n] = true - } - return m, nil - } - defer func() { checkKustomizationStatus = origCheckKustomizationStatus }() - - // When calling Down - err := baseHandler.Down() - - // Then an error should be returned - if err == nil { - t.Error("Expected error, got nil") - } - if !strings.Contains(err.Error(), "apply error") { - t.Errorf("Expected error about apply error, got: %v", err) - } - }) -} - -func TestBaseBlueprintHandler_SuspendKustomization(t *testing.T) { - setup := func(t *testing.T) (*BaseBlueprintHandler, *Mocks) { - t.Helper() - mocks := setupMocks(t) - handler := NewBlueprintHandler(mocks.Injector) - handler.shims = mocks.Shims - err := handler.Initialize() - if err != nil { - t.Fatalf("Failed to initialize handler: %v", err) - } - return handler, mocks - } - t.Run("Success", func(t *testing.T) { - handler, _ := setup(t) - baseHandler := handler + // Save original kubeClient and restore after test + originalKubeClient := kubeClient + kubeClient = mockKubeClient + defer func() { kubeClient = originalKubeClient }() - // Patch kubeClient to verify correct request - origKubeClient := kubeClient - defer func() { kubeClient = origKubeClient }() - var capturedConfig KubeRequestConfig - kubeClient = func(_ string, config KubeRequestConfig) error { - capturedConfig = config - return nil + handler := &BaseBlueprintHandler{ + shims: &Shims{ + JsonMarshal: func(v any) ([]byte, error) { + return []byte(`{"spec":{"suspend":true}}`), nil + }, + }, } - - // When suspending a kustomization - err := baseHandler.suspendKustomization("test-kustomization", "test-namespace") - - // Then no error should be returned + err := handler.suspendKustomization("test-kustomization", "test-namespace") if err != nil { t.Errorf("expected nil error, got %v", err) } - - // And the request should be correct - if capturedConfig.Method != "PATCH" { - t.Errorf("expected Method 'PATCH', got '%s'", capturedConfig.Method) - } - if capturedConfig.ApiPath != "/apis/kustomize.toolkit.fluxcd.io/v1" { - t.Errorf("expected ApiPath '/apis/kustomize.toolkit.fluxcd.io/v1', got '%s'", capturedConfig.ApiPath) - } - if capturedConfig.Namespace != "test-namespace" { - t.Errorf("expected Namespace 'test-namespace', got '%s'", capturedConfig.Namespace) - } - if capturedConfig.Resource != "kustomizations" { - t.Errorf("expected Resource 'kustomizations', got '%s'", capturedConfig.Resource) - } - if capturedConfig.Name != "test-kustomization" { - t.Errorf("expected Name 'test-kustomization', got '%s'", capturedConfig.Name) - } - if string(capturedConfig.Body.([]byte)) != `{"spec":{"suspend":true}}` { - t.Errorf("expected Body '{\"spec\":{\"suspend\":true}}', got '%s'", capturedConfig.Body) - } - }) - - t.Run("Error", func(t *testing.T) { - handler, _ := setup(t) - baseHandler := handler - - // Patch kubeClient to return error - origKubeClient := kubeClient - defer func() { kubeClient = origKubeClient }() - kubeClient = func(_ string, _ KubeRequestConfig) error { - return fmt.Errorf("test error") - } - - // When suspending a kustomization - err := baseHandler.suspendKustomization("test-kustomization", "test-namespace") - - // Then error should be returned - if err == nil { - t.Error("expected error, got nil") - } }) }