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") - } }) }