From d43f71e3e7737490c8a2bf6e6401aff0b4349214 Mon Sep 17 00:00:00 2001 From: Ryan VanGundy Date: Thu, 22 May 2025 18:18:39 -0400 Subject: [PATCH 1/8] feat(install): add cleanup support for kustomizations - Add cleanup support for kustomizations in Down method - Update Down method to handle kustomization cleanup - Add kustomization cleanup to cleanup process - Update test coverage for kustomization cleanup --- api/v1alpha1/blueprint_types.go | 38 + cmd/down.go | 20 +- cmd/down_test.go | 52 + cmd/install.go | 2 +- cmd/install_test.go | 2 +- cmd/up.go | 2 +- cmd/up_test.go | 2 +- pkg/blueprint/blueprint_handler.go | 533 ++-- .../blueprint_handler_helper_test.go | 931 ++++++ .../blueprint_handler_private_test.go | 684 ++++ pkg/blueprint/blueprint_handler_test.go | 2747 +++++------------ pkg/blueprint/mock_blueprint_handler.go | 17 +- pkg/blueprint/mock_blueprint_handler_test.go | 27 + pkg/constants/constants.go | 1 + pkg/stack/windsor_stack.go | 6 +- 15 files changed, 2908 insertions(+), 2156 deletions(-) create mode 100644 pkg/blueprint/blueprint_handler_helper_test.go create mode 100644 pkg/blueprint/blueprint_handler_private_test.go diff --git a/api/v1alpha1/blueprint_types.go b/api/v1alpha1/blueprint_types.go index 3d96962b2..38bafe458 100644 --- a/api/v1alpha1/blueprint_types.go +++ b/api/v1alpha1/blueprint_types.go @@ -158,6 +158,9 @@ type Kustomization struct { // Components to include in the kustomization. Components []string `yaml:"components,omitempty"` + // Cleanup lists resources to clean up after the kustomization is applied. + Cleanup []string `yaml:"cleanup,omitempty"` + // PostBuild is a post-build step to run after the kustomization is applied. PostBuild *PostBuild `yaml:"postBuild,omitempty"` } @@ -268,6 +271,7 @@ func (b *Blueprint) DeepCopy() *Blueprint { Wait: kustomization.Wait, Force: kustomization.Force, Components: slices.Clone(kustomization.Components), + Cleanup: slices.Clone(kustomization.Cleanup), PostBuild: postBuildCopy, } } @@ -385,3 +389,37 @@ func (b *Blueprint) Merge(overlay *Blueprint) { b.Kustomizations = overlay.Kustomizations } } + +// DeepCopy creates a deep copy of the Kustomization object. +func (k *Kustomization) DeepCopy() *Kustomization { + if k == nil { + return nil + } + + var postBuildCopy *PostBuild + if k.PostBuild != nil { + postBuildCopy = &PostBuild{ + Substitute: make(map[string]string), + SubstituteFrom: slices.Clone(k.PostBuild.SubstituteFrom), + } + for key, value := range k.PostBuild.Substitute { + postBuildCopy.Substitute[key] = value + } + } + + return &Kustomization{ + Name: k.Name, + Path: k.Path, + Source: k.Source, + DependsOn: slices.Clone(k.DependsOn), + Interval: k.Interval, + RetryInterval: k.RetryInterval, + Timeout: k.Timeout, + Patches: slices.Clone(k.Patches), + Wait: k.Wait, + Force: k.Force, + Components: slices.Clone(k.Components), + Cleanup: slices.Clone(k.Cleanup), + PostBuild: postBuildCopy, + } +} diff --git a/cmd/down.go b/cmd/down.go index a01eda6c4..776e90f1f 100644 --- a/cmd/down.go +++ b/cmd/down.go @@ -10,7 +10,8 @@ import ( ) var ( - cleanFlag bool + cleanFlag bool + skipK8sFlag bool ) var downCmd = &cobra.Command{ @@ -48,6 +49,22 @@ var downCmd = &cobra.Command{ shell := controller.ResolveShell() configHandler := controller.ResolveConfigHandler() + // Run blueprint cleanup before stack down + blueprintHandler := controller.ResolveBlueprintHandler() + if blueprintHandler == nil { + return fmt.Errorf("No blueprint handler found") + } + if err := blueprintHandler.LoadConfig(); err != nil { + return fmt.Errorf("Error loading blueprint config: %w", err) + } + if !skipK8sFlag { + if err := blueprintHandler.Down(); err != nil { + return fmt.Errorf("Error running blueprint down: %w", err) + } + } else { + fmt.Fprintln(os.Stderr, "Skipping Kubernetes cleanup (--skip-k8s set)") + } + // Tear down the stack components stack := controller.ResolveStack() if stack == nil { @@ -100,5 +117,6 @@ var downCmd = &cobra.Command{ func init() { downCmd.Flags().BoolVar(&cleanFlag, "clean", false, "Clean up context specific artifacts") + downCmd.Flags().BoolVar(&skipK8sFlag, "skip-k8s", false, "Skip Kubernetes cleanup (blueprint cleanup)") rootCmd.AddCommand(downCmd) } diff --git a/cmd/down_test.go b/cmd/down_test.go index 6d0a36613..fb0660a22 100644 --- a/cmd/down_test.go +++ b/cmd/down_test.go @@ -5,6 +5,7 @@ import ( "strings" "testing" + "github.com/windsorcli/cli/pkg/blueprint" "github.com/windsorcli/cli/pkg/config" "github.com/windsorcli/cli/pkg/controller" "github.com/windsorcli/cli/pkg/stack" @@ -297,4 +298,55 @@ contexts: t.Errorf("Expected error to contain 'Error deleting .volumes folder', got: %v", err) } }) + + t.Run("CleanupCalledBeforeStackDown", func(t *testing.T) { + mocks := setupDownMocks(t) + callOrder := []string{} + mocks.BlueprintHandler.DownFunc = func() error { + callOrder = append(callOrder, "cleanup") + return nil + } + mocks.Stack.DownFunc = func() error { + callOrder = append(callOrder, "stackdown") + return nil + } + rootCmd.SetArgs([]string{"down"}) + err := Execute(mocks.Controller) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if len(callOrder) != 2 || callOrder[0] != "cleanup" || callOrder[1] != "stackdown" { + t.Errorf("Expected Cleanup before stack.Down, got call order: %v", callOrder) + } + }) + + t.Run("ErrorNilBlueprintHandler", func(t *testing.T) { + mocks := setupDownMocks(t) + mocks.Controller.ResolveBlueprintHandlerFunc = func() blueprint.BlueprintHandler { + return nil + } + rootCmd.SetArgs([]string{"down"}) + err := Execute(mocks.Controller) + if err == nil { + t.Error("Expected error, got nil") + } + if err == nil || err.Error() != "No blueprint handler found" { + t.Errorf("Expected 'No blueprint handler found', got %v", err) + } + }) + + t.Run("ErrorCleanup", func(t *testing.T) { + mocks := setupDownMocks(t) + mocks.BlueprintHandler.DownFunc = func() error { + return fmt.Errorf("cleanup failed") + } + rootCmd.SetArgs([]string{"down"}) + err := Execute(mocks.Controller) + if err == nil { + t.Error("Expected error, got nil") + } + if err == nil || !strings.Contains(err.Error(), "Error running blueprint down: cleanup failed") { + t.Errorf("Expected error containing 'Error running blueprint down: cleanup failed', got %v", err) + } + }) } diff --git a/cmd/install.go b/cmd/install.go index b7fd9b070..2d951e7c3 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -62,7 +62,7 @@ var installCmd = &cobra.Command{ // If wait flag is set, wait for kustomizations to be ready if waitFlag { - if err := blueprintHandler.WaitForKustomizations(); err != nil { + if err := blueprintHandler.WaitForKustomizations("⏳ Waiting for kustomizations to be ready"); err != nil { return fmt.Errorf("failed waiting for kustomizations: %w", err) } } diff --git a/cmd/install_test.go b/cmd/install_test.go index 7611b71e5..2ccfa15bf 100644 --- a/cmd/install_test.go +++ b/cmd/install_test.go @@ -201,7 +201,7 @@ func TestInstallCmd(t *testing.T) { // Set up a flag to check if WaitForKustomizations is called called := false - mocks.BlueprintHandler.WaitForKustomizationsFunc = func() error { + mocks.BlueprintHandler.WaitForKustomizationsFunc = func(message string, names ...string) error { called = true return nil } diff --git a/cmd/up.go b/cmd/up.go index 2231af594..5610962fb 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -146,7 +146,7 @@ var upCmd = &cobra.Command{ } // If wait flag is set, poll for kustomization readiness if waitFlag { - if err := blueprintHandler.WaitForKustomizations(); err != nil { + if err := blueprintHandler.WaitForKustomizations("⏳ Waiting for kustomizations to be ready"); err != nil { return fmt.Errorf("Error waiting for kustomizations: %w", err) } } diff --git a/cmd/up_test.go b/cmd/up_test.go index dce0eb3fa..2e95651fe 100644 --- a/cmd/up_test.go +++ b/cmd/up_test.go @@ -405,7 +405,7 @@ func TestUpCmd(t *testing.T) { defer func() { installFlag = false; waitFlag = false }() called := false - mocks.BlueprintHandler.WaitForKustomizationsFunc = func() error { + mocks.BlueprintHandler.WaitForKustomizationsFunc = func(message string, names ...string) error { called = true return nil } diff --git a/pkg/blueprint/blueprint_handler.go b/pkg/blueprint/blueprint_handler.go index 10df50cb7..3c026a258 100644 --- a/pkg/blueprint/blueprint_handler.go +++ b/pkg/blueprint/blueprint_handler.go @@ -57,7 +57,8 @@ type BlueprintHandler interface { SetRepository(repository blueprintv1alpha1.Repository) error SetTerraformComponents(terraformComponents []blueprintv1alpha1.TerraformComponent) error SetKustomizations(kustomizations []blueprintv1alpha1.Kustomization) error - WaitForKustomizations() error + WaitForKustomizations(message string, names ...string) error + Down() error } //go:embed templates/default.jsonnet @@ -146,61 +147,84 @@ func (b *BaseBlueprintHandler) LoadConfig(path ...string) error { basePath = path[0] } - // Get platform from context - platform := "" - if b.configHandler.GetConfig().Cluster != nil && b.configHandler.GetConfig().Cluster.Platform != nil { - platform = *b.configHandler.GetConfig().Cluster.Platform - } + yamlPath := basePath + ".yaml" + jsonnetPath := basePath + ".jsonnet" - // Try to load platform-specific template first - platformData, err := b.loadPlatformTemplate(platform) - if err != nil { - return fmt.Errorf("error loading platform template: %w", err) + // 1. blueprint.yaml + if _, err := b.shims.Stat(yamlPath); err == nil { + yamlData, err := b.shims.ReadFile(yamlPath) + if err != nil { + return err + } + if err := b.processBlueprintData(yamlData, &b.blueprint); err != nil { + return err + } + return nil } - var yamlData []byte - // If no platform template, fall back to default - if len(platformData) == 0 { - jsonnetData, jsonnetErr := b.loadFileData(basePath + ".jsonnet") - var yamlErr error - yamlData, yamlErr = b.loadFileData(basePath + ".yaml") - if jsonnetErr != nil { - return jsonnetErr + // 2. blueprint.jsonnet + if _, err := b.shims.Stat(jsonnetPath); err == nil { + jsonnetData, err := b.shims.ReadFile(jsonnetPath) + if err != nil { + return err } - if yamlErr != nil && !os.IsNotExist(yamlErr) { - return yamlErr + config := b.configHandler.GetConfig() + contextYAML, err := b.yamlMarshalWithDefinedPaths(config) + if err != nil { + return fmt.Errorf("error marshalling context to YAML: %w", err) } - - if len(jsonnetData) > 0 { - platformData = jsonnetData + var contextMap map[string]any = make(map[string]any) + if err := b.shims.YamlUnmarshal(contextYAML, &contextMap); err != nil { + return fmt.Errorf("error unmarshalling context YAML: %w", err) + } + context := b.configHandler.GetContext() + contextMap["name"] = context + contextJSON, err := b.shims.JsonMarshal(contextMap) + if err != nil { + return fmt.Errorf("error marshalling context map to JSON: %w", err) } + vm := b.shims.NewJsonnetVM() + vm.ExtCode("context", string(contextJSON)) + evaluatedJsonnet, err := vm.EvaluateAnonymousSnippet("blueprint.jsonnet", string(jsonnetData)) + if err != nil { + return fmt.Errorf("error generating blueprint from jsonnet: %w", err) + } + if err := b.processBlueprintData([]byte(evaluatedJsonnet), &b.blueprint); err != nil { + return err + } + return nil } + // 3. internal default (platform-specific if available, else global default) + platform := "" + if b.configHandler.GetConfig().Cluster != nil && b.configHandler.GetConfig().Cluster.Platform != nil { + platform = *b.configHandler.GetConfig().Cluster.Platform + } + var platformData []byte + if platform != "" { + platformData, err = b.loadPlatformTemplate(platform) + if err != nil { + return fmt.Errorf("error loading platform template: %w", err) + } + } + var evaluatedJsonnet string config := b.configHandler.GetConfig() contextYAML, err := b.yamlMarshalWithDefinedPaths(config) if err != nil { return fmt.Errorf("error marshalling context to YAML: %w", err) } - var contextMap map[string]any = make(map[string]any) if err := b.shims.YamlUnmarshal(contextYAML, &contextMap); err != nil { return fmt.Errorf("error unmarshalling context YAML: %w", err) } - - // Add "name" to the context map context := b.configHandler.GetContext() contextMap["name"] = context - contextJSON, err := b.shims.JsonMarshal(contextMap) if err != nil { return fmt.Errorf("error marshalling context map to JSON: %w", err) } - - var evaluatedJsonnet string - vm := b.shims.NewJsonnetVM() vm.ExtCode("context", string(contextJSON)) - if len(platformData) > 0 { evaluatedJsonnet, err = vm.EvaluateAnonymousSnippet("blueprint.jsonnet", string(platformData)) if err != nil { @@ -212,7 +236,6 @@ func (b *BaseBlueprintHandler) LoadConfig(path ...string) error { return fmt.Errorf("error generating blueprint from default jsonnet: %w", err) } } - if evaluatedJsonnet == "" { b.blueprint = *DefaultBlueprint.DeepCopy() b.blueprint.Metadata.Name = context @@ -222,15 +245,6 @@ func (b *BaseBlueprintHandler) LoadConfig(path ...string) error { return err } } - - if len(yamlData) > 0 { - if err := b.processBlueprintData(yamlData, &b.localBlueprint); err != nil { - return err - } - } - - b.blueprint.Merge(&b.localBlueprint) - return nil } @@ -287,8 +301,7 @@ func (b *BaseBlueprintHandler) WriteConfig(path ...string) error { // WaitForKustomizations polls for readiness of all kustomizations with a maximum timeout. // It uses a spinner to show progress and checks both GitRepository and Kustomization status. // The timeout is calculated based on the longest dependency path through the kustomizations. -func (b *BaseBlueprintHandler) WaitForKustomizations() error { - message := "⏳ Waiting for kustomizations to be ready" +func (b *BaseBlueprintHandler) WaitForKustomizations(message string, names ...string) error { spin := spinner.New(spinner.CharSets[14], 100*time.Millisecond, spinner.WithColor("green")) spin.Suffix = " " + message spin.Start() @@ -298,10 +311,15 @@ func (b *BaseBlueprintHandler) WaitForKustomizations() error { ticker := time.NewTicker(b.kustomizationWaitPollInterval) defer ticker.Stop() - kustomizations := b.GetKustomizations() - names := make([]string, len(kustomizations)) - for i, k := range kustomizations { - names[i] = k.Name + var kustomizationNames []string + if len(names) > 0 && len(names[0]) > 0 { + kustomizationNames = names + } else { + kustomizations := b.GetKustomizations() + kustomizationNames = make([]string, len(kustomizations)) + for i, k := range kustomizations { + kustomizationNames[i] = k.Name + } } consecutiveFailures := 0 @@ -309,7 +327,7 @@ func (b *BaseBlueprintHandler) WaitForKustomizations() error { select { case <-timeout: spin.Stop() - fmt.Fprintf(os.Stderr, "\033[31m✗\033[0m %s - \033[31mTimeout\033[0m\n", message) + fmt.Fprintf(os.Stderr, "\033[31m✗ %s - \033[31mTimeout\033[0m\n", message) return fmt.Errorf("timeout waiting for kustomizations to be ready") case <-ticker.C: kubeconfig := os.Getenv("KUBECONFIG") @@ -317,17 +335,17 @@ func (b *BaseBlueprintHandler) WaitForKustomizations() error { consecutiveFailures++ if consecutiveFailures >= constants.DEFAULT_KUSTOMIZATION_WAIT_MAX_FAILURES { spin.Stop() - fmt.Fprintf(os.Stderr, "\033[31m✗\033[0m %s - \033[31mFailed\033[0m\n", message) + fmt.Fprintf(os.Stderr, "\033[31m✗ %s - \033[31mFailed\033[0m\n", message) return fmt.Errorf("git repository error after %d consecutive failures: %w", consecutiveFailures, err) } continue } - status, err := checkKustomizationStatus(kubeconfig, names) + status, err := checkKustomizationStatus(kubeconfig, kustomizationNames) if err != nil { consecutiveFailures++ if consecutiveFailures >= constants.DEFAULT_KUSTOMIZATION_WAIT_MAX_FAILURES { spin.Stop() - fmt.Fprintf(os.Stderr, "\033[31m✗\033[0m %s - \033[31mFailed\033[0m\n", message) + fmt.Fprintf(os.Stderr, "\033[31m✗ %s - \033[31mFailed\033[0m\n", message) return fmt.Errorf("kustomization error after %d consecutive failures: %w", consecutiveFailures, err) } continue @@ -347,7 +365,6 @@ func (b *BaseBlueprintHandler) WaitForKustomizations() error { return nil } - // Reset failure counter on successful check consecutiveFailures = 0 } } @@ -394,7 +411,7 @@ func (b *BaseBlueprintHandler) Install() error { } for _, kustomization := range b.GetKustomizations() { - if err := b.applyKustomization(kustomization); err != nil { + if err := b.applyKustomization(kustomization, constants.DEFAULT_FLUX_SYSTEM_NAMESPACE); err != nil { spin.Stop() fmt.Fprintf(os.Stderr, "\033[31m✗ %s - Failed\033[0m\n", message) return fmt.Errorf("failed to apply Kustomization: %w", err) @@ -538,10 +555,208 @@ func (b *BaseBlueprintHandler) SetKustomizations(kustomizations []blueprintv1alp return nil } +// Down tears down all kustomizations in the correct order, running cleanup kustomizations if defined. +func (b *BaseBlueprintHandler) Down() error { + kustomizations := b.GetKustomizations() + if len(kustomizations) == 0 { + return nil + } + + // Build dependency graph + deps := make(map[string][]string) + for _, k := range kustomizations { + deps[k.Name] = k.DependsOn + } + + // Topological sort (reverse order) + var sorted []string + visited := make(map[string]bool) + var visit func(string) + visit = func(n string) { + if visited[n] { + return + } + visited[n] = true + for _, dep := range deps[n] { + visit(dep) + } + sorted = append(sorted, n) + } + for _, k := range kustomizations { + visit(k.Name) + } + // Reverse for teardown order + for i, j := 0, len(sorted)-1; i < j; i, j = i+1, j-1 { + sorted[i], sorted[j] = sorted[j], sorted[i] + } + + nameToK := make(map[string]blueprintv1alpha1.Kustomization) + for _, k := range kustomizations { + nameToK[k.Name] = k + } + + // Check if we need cleanup namespace + needsCleanupNamespace := false + for _, k := range kustomizations { + if len(k.Cleanup) > 0 { + needsCleanupNamespace = true + break + } + } + + // Create cleanup namespace if needed + if needsCleanupNamespace { + if err := b.createManagedNamespace("system-cleanup"); err != nil { + return fmt.Errorf("failed to create system-cleanup namespace: %w", err) + } + } + + var cleanupNames []string + for _, name := range sorted { + k := nameToK[name] + if len(k.Cleanup) > 0 { + cleanupKustomization := &blueprintv1alpha1.Kustomization{ + Name: k.Name + "-cleanup", + Path: filepath.Join(k.Path, "cleanup"), + Source: k.Source, + Components: k.Cleanup, + Timeout: &metav1.Duration{Duration: 30 * time.Minute}, + Interval: &metav1.Duration{Duration: constants.DEFAULT_FLUX_KUSTOMIZATION_INTERVAL}, + RetryInterval: &metav1.Duration{Duration: constants.DEFAULT_FLUX_KUSTOMIZATION_RETRY_INTERVAL}, + Wait: func() *bool { b := true; return &b }(), + PostBuild: &blueprintv1alpha1.PostBuild{ + SubstituteFrom: []blueprintv1alpha1.SubstituteReference{}, + }, + } + if err := b.applyKustomization(*cleanupKustomization, constants.DEFAULT_FLUX_SYSTEM_NAMESPACE); err != nil { + return fmt.Errorf("failed to apply cleanup kustomization for %s: %w", k.Name, err) + } + cleanupNames = append(cleanupNames, cleanupKustomization.Name) + } + // Delete the main kustomization + if err := b.deleteKustomization(k.Name, constants.DEFAULT_FLUX_SYSTEM_NAMESPACE); err != nil { + return fmt.Errorf("failed to delete kustomization %s: %w", k.Name, err) + } + } + + if len(cleanupNames) > 0 { + if err := b.WaitForKustomizations("📐 Deploying cleanup kustomizations", cleanupNames...); err != nil { + return fmt.Errorf("failed waiting for cleanup kustomizations: %w", err) + } + + // Delete cleanup kustomizations + for _, cname := range cleanupNames { + if err := b.deleteKustomization(cname, constants.DEFAULT_FLUX_SYSTEM_NAMESPACE); err != nil { + return fmt.Errorf("failed to delete cleanup kustomization %s: %w", cname, err) + } + } + + // Delete the cleanup namespace + if err := b.deleteNamespace("system-cleanup"); err != nil { + return fmt.Errorf("failed to delete system-cleanup namespace: %w", err) + } + } + + return nil +} + // ============================================================================= // Private Methods // ============================================================================= +// applyKustomization creates or updates a Kustomization resource in the cluster. +func (b *BaseBlueprintHandler) applyKustomization(kustomization blueprintv1alpha1.Kustomization, namespace string) error { + if kustomization.Source == "" { + context := b.configHandler.GetContext() + kustomization.Source = context + } + + kustomizeObj := &kustomizev1.Kustomization{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "kustomize.toolkit.fluxcd.io/v1", + Kind: "Kustomization", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: kustomization.Name, + Namespace: namespace, + }, + Spec: kustomizev1.KustomizationSpec{ + Interval: *kustomization.Interval, + Timeout: kustomization.Timeout, + RetryInterval: kustomization.RetryInterval, + Path: kustomization.Path, + Prune: constants.DEFAULT_FLUX_KUSTOMIZATION_PRUNE, + Wait: constants.DEFAULT_FLUX_KUSTOMIZATION_WAIT, + DependsOn: func() []meta.NamespacedObjectReference { + dependsOn := make([]meta.NamespacedObjectReference, len(kustomization.DependsOn)) + for i, dep := range kustomization.DependsOn { + dependsOn[i] = meta.NamespacedObjectReference{Name: dep} + } + return dependsOn + }(), + SourceRef: kustomizev1.CrossNamespaceSourceReference{ + Kind: "GitRepository", + Name: kustomization.Source, + Namespace: constants.DEFAULT_FLUX_SYSTEM_NAMESPACE, + }, + Patches: kustomization.Patches, + Components: kustomization.Components, + PostBuild: &kustomizev1.PostBuild{ + SubstituteFrom: func() []kustomizev1.SubstituteReference { + substituteFrom := make([]kustomizev1.SubstituteReference, len(kustomization.PostBuild.SubstituteFrom)) + for i, sub := range kustomization.PostBuild.SubstituteFrom { + substituteFrom[i] = kustomizev1.SubstituteReference{ + Kind: sub.Kind, + Name: sub.Name, + } + } + return substituteFrom + }(), + }, + }, + } + + // Ensure the status field is not included in the request body, it breaks the request + kustomizeObj.Status = kustomizev1.KustomizationStatus{} + + config := ResourceOperationConfig{ + ApiPath: "/apis/kustomize.toolkit.fluxcd.io/v1", + Namespace: namespace, + ResourceName: "kustomizations", + ResourceInstanceName: kustomizeObj.Name, + ResourceObject: kustomizeObj, + ResourceType: func() runtime.Object { return &kustomizev1.Kustomization{} }, + } + + kubeconfig := os.Getenv("KUBECONFIG") + return kubeClientResourceOperation(kubeconfig, config) +} + +// deleteKustomization deletes a Kustomization resource from the cluster. +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. +func (b *BaseBlueprintHandler) deleteResource(kubeconfigPath string, config ResourceOperationConfig) error { + return kubeClient(kubeconfigPath, KubeRequestConfig{ + Method: "DELETE", + ApiPath: config.ApiPath, + Namespace: config.Namespace, + Resource: config.ResourceName, + Name: config.ResourceInstanceName, + }) +} + // resolveComponentSources processes each Terraform component's source field, expanding it into a full // URL with path prefix and reference information based on the associated source configuration. func (b *BaseBlueprintHandler) resolveComponentSources(blueprint *blueprintv1alpha1.Blueprint) { @@ -671,15 +886,6 @@ func (b *BaseBlueprintHandler) isValidTerraformRemoteSource(source string) bool return false } -// loadFileData loads the file data from the specified path. -// It checks if the file exists and reads its content, returning the data as a byte slice. -func (b *BaseBlueprintHandler) loadFileData(path string) ([]byte, error) { - if _, err := b.shims.Stat(path); err == nil { - return b.shims.ReadFile(path) - } - return nil, nil -} - // loadPlatformTemplate loads a platform-specific template if one exists func (b *BaseBlueprintHandler) loadPlatformTemplate(platform string) ([]byte, error) { if platform == "" { @@ -818,6 +1024,38 @@ func (b *BaseBlueprintHandler) yamlMarshalWithDefinedPaths(v any) ([]byte, error return yamlData, nil } +func (b *BaseBlueprintHandler) createManagedNamespace(name string) error { + kubeconfig := os.Getenv("KUBECONFIG") + ns := &corev1.Namespace{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "v1", + Kind: "Namespace", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: map[string]string{ + "app.kubernetes.io/managed-by": "windsor-cli", + }, + }, + } + return kubeClient(kubeconfig, KubeRequestConfig{ + Method: "POST", + ApiPath: "/api/v1", + Resource: "namespaces", + Body: ns, + }) +} + +func (b *BaseBlueprintHandler) deleteNamespace(name string) error { + kubeconfig := os.Getenv("KUBECONFIG") + return kubeClient(kubeconfig, KubeRequestConfig{ + Method: "DELETE", + ApiPath: "/api/v1", + Resource: "namespaces", + Name: name, + }) +} + // applyGitRepository creates or updates a GitRepository resource in the cluster. It normalizes // the repository URL format, configures standard intervals and timeouts, and handles secret // references for private repositories. @@ -875,76 +1113,6 @@ func (b *BaseBlueprintHandler) applyGitRepository(source blueprintv1alpha1.Sourc return kubeClientResourceOperation(kubeconfig, config) } -// applyKustomization creates or updates a Kustomization resource in the cluster. It configures -// dependencies, source references, and PostBuild substitutions while applying standard defaults -// for intervals and operational parameters. -func (b *BaseBlueprintHandler) applyKustomization(kustomization blueprintv1alpha1.Kustomization) error { - if kustomization.Source == "" { - context := b.configHandler.GetContext() - kustomization.Source = context - } - - kustomizeObj := &kustomizev1.Kustomization{ - TypeMeta: metav1.TypeMeta{ - APIVersion: "kustomize.toolkit.fluxcd.io/v1", - Kind: "Kustomization", - }, - ObjectMeta: metav1.ObjectMeta{ - Name: kustomization.Name, - Namespace: constants.DEFAULT_FLUX_SYSTEM_NAMESPACE, - }, - Spec: kustomizev1.KustomizationSpec{ - Interval: *kustomization.Interval, - Timeout: kustomization.Timeout, - RetryInterval: kustomization.RetryInterval, - Path: kustomization.Path, - Prune: constants.DEFAULT_FLUX_KUSTOMIZATION_PRUNE, - Wait: constants.DEFAULT_FLUX_KUSTOMIZATION_WAIT, - DependsOn: func() []meta.NamespacedObjectReference { - dependsOn := make([]meta.NamespacedObjectReference, len(kustomization.DependsOn)) - for i, dep := range kustomization.DependsOn { - dependsOn[i] = meta.NamespacedObjectReference{Name: dep} - } - return dependsOn - }(), - SourceRef: kustomizev1.CrossNamespaceSourceReference{ - Kind: "GitRepository", - Name: kustomization.Source, - Namespace: constants.DEFAULT_FLUX_SYSTEM_NAMESPACE, - }, - Patches: kustomization.Patches, - Components: kustomization.Components, - PostBuild: &kustomizev1.PostBuild{ - SubstituteFrom: func() []kustomizev1.SubstituteReference { - substituteFrom := make([]kustomizev1.SubstituteReference, len(kustomization.PostBuild.SubstituteFrom)) - for i, sub := range kustomization.PostBuild.SubstituteFrom { - substituteFrom[i] = kustomizev1.SubstituteReference{ - Kind: sub.Kind, - Name: sub.Name, - } - } - return substituteFrom - }(), - }, - }, - } - - // Ensure the status field is not included in the request body, it breaks the request - kustomizeObj.Status = kustomizev1.KustomizationStatus{} - - config := ResourceOperationConfig{ - ApiPath: "/apis/kustomize.toolkit.fluxcd.io/v1", - Namespace: kustomizeObj.Namespace, - ResourceName: "kustomizations", - ResourceInstanceName: kustomizeObj.Name, - ResourceObject: kustomizeObj, - ResourceType: func() runtime.Object { return &kustomizev1.Kustomization{} }, - } - - kubeconfig := os.Getenv("KUBECONFIG") - return kubeClientResourceOperation(kubeconfig, config) -} - // applyConfigMap creates or updates a ConfigMap in the cluster containing context-specific // configuration values used by the blueprint's resources, such as domain names, IP ranges, // and volume paths. @@ -1091,13 +1259,16 @@ type ResourceOperationConfig struct { ResourceType func() runtime.Object } -// NOTE: This is a temporary solution until we've integrated the kube client into our DI system. -// As such, this function is not internally covered by our tests. -// -// kubeClientResourceOperation is a comprehensive function that handles the entire lifecycle of creating a Kubernetes client -// and performing a sequence of operations (Get, Post, Put) on Kubernetes resources. It takes a kubeconfig path and a -// configuration object that specifies the parameters for the operations. -var kubeClientResourceOperation = func(kubeconfigPath string, config ResourceOperationConfig) error { +type KubeRequestConfig struct { + Method string + ApiPath string + Namespace string + Resource string + Name string + Body interface{} +} + +var kubeClient = func(kubeconfigPath string, config KubeRequestConfig) error { var kubeConfig *rest.Config var err error @@ -1119,46 +1290,57 @@ var kubeClientResourceOperation = func(kubeconfigPath string, config ResourceOpe restClient := clientset.CoreV1().RESTClient() backgroundCtx := ctx.Background() - existingResource := config.ResourceType().(runtime.Object) - err = restClient.Get(). + req := restClient.Verb(config.Method). AbsPath(config.ApiPath). - Namespace(config.Namespace). - Resource(config.ResourceName). - Name(config.ResourceInstanceName). - Do(backgroundCtx). - Into(existingResource) + Resource(config.Resource) + + if config.Namespace != "" { + req = req.Namespace(config.Namespace) + } + + if config.Name != "" { + req = req.Name(config.Name) + } + + if config.Body != nil { + req = req.Body(config.Body) + } + + return req.Do(backgroundCtx).Error() +} +var kubeClientResourceOperation = func(kubeconfigPath string, config ResourceOperationConfig) error { + // First try to get the resource + err := kubeClient(kubeconfigPath, KubeRequestConfig{ + Method: "GET", + ApiPath: config.ApiPath, + Namespace: config.Namespace, + Resource: config.ResourceName, + Name: config.ResourceInstanceName, + }) if err != nil { if apierrors.IsNotFound(err) { - if err := restClient.Post(). - AbsPath(config.ApiPath). - Namespace(config.Namespace). - Resource(config.ResourceName). - Body(config.ResourceObject). - Do(backgroundCtx). - Error(); err != nil { - return fmt.Errorf("failed to create resource: %w", err) - } - } else { - return fmt.Errorf("failed to get resource: %w", err) - } - } else { - // Ensure the resourceVersion is set for the update - config.ResourceObject.(metav1.Object).SetResourceVersion(existingResource.(metav1.Object).GetResourceVersion()) - - if err := restClient.Put(). - AbsPath(config.ApiPath). - Namespace(config.Namespace). - Resource(config.ResourceName). - Name(config.ResourceInstanceName). - Body(config.ResourceObject). - Do(backgroundCtx). - Error(); err != nil { - return fmt.Errorf("failed to update resource: %w", err) + // Create if not found + return kubeClient(kubeconfigPath, KubeRequestConfig{ + Method: "POST", + ApiPath: config.ApiPath, + Namespace: config.Namespace, + Resource: config.ResourceName, + Body: config.ResourceObject, + }) } - } - - return nil + return fmt.Errorf("failed to get resource: %w", err) + } + + // Update if found + return kubeClient(kubeconfigPath, KubeRequestConfig{ + Method: "PUT", + ApiPath: config.ApiPath, + Namespace: config.Namespace, + Resource: config.ResourceName, + Name: config.ResourceInstanceName, + Body: config.ResourceObject, + }) } // NOTE: This is a temporary solution until we've integrated the kube client into our DI system. @@ -1226,7 +1408,8 @@ var checkKustomizationStatus = func(kubeconfigPath string, names []string) (map[ for _, name := range names { if !found[name] { - return nil, fmt.Errorf("kustomization %s not found", name) + status[name] = false + continue } } diff --git a/pkg/blueprint/blueprint_handler_helper_test.go b/pkg/blueprint/blueprint_handler_helper_test.go new file mode 100644 index 000000000..b72fdea90 --- /dev/null +++ b/pkg/blueprint/blueprint_handler_helper_test.go @@ -0,0 +1,931 @@ +package blueprint + +import ( + "fmt" + "strings" + "testing" + "time" + + blueprintv1alpha1 "github.com/windsorcli/cli/api/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// ============================================================================= +// Test Helper Functions +// ============================================================================= + +func TestYamlMarshalWithDefinedPaths(t *testing.T) { + setup := func(t *testing.T) (BlueprintHandler, *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 BlueprintHandler: %v", err) + } + return handler, mocks + } + + t.Run("IgnoreYamlMinusTag", func(t *testing.T) { + // Given a struct with a YAML minus tag + type testStruct struct { + Public string `yaml:"public"` + private string `yaml:"-"` + } + input := testStruct{Public: "value", private: "ignored"} + + // When marshalling the struct + handler, _ := setup(t) + baseHandler := handler.(*BaseBlueprintHandler) + result, err := baseHandler.yamlMarshalWithDefinedPaths(input) + + // Then no error should be returned + if err != nil { + t.Errorf("yamlMarshalWithDefinedPaths failed: %v", err) + } + + // And the public field should be included + if !strings.Contains(string(result), "public: value") { + t.Errorf("Expected 'public: value' in result, got: %s", string(result)) + } + + // And the ignored field should be excluded + if strings.Contains(string(result), "ignored") { + t.Errorf("Expected 'ignored' not to be in result, got: %s", string(result)) + } + }) + + t.Run("NilInput", func(t *testing.T) { + // When marshalling nil input + handler, _ := setup(t) + baseHandler := handler.(*BaseBlueprintHandler) + _, err := baseHandler.yamlMarshalWithDefinedPaths(nil) + + // Then an error should be returned + if err == nil { + t.Error("Expected error for nil input, got nil") + } + + // And the error message should be appropriate + if !strings.Contains(err.Error(), "invalid input: nil value") { + t.Errorf("Expected error about nil input, got: %v", err) + } + }) + + t.Run("EmptySlice", func(t *testing.T) { + // Given an empty slice + input := []string{} + + // When marshalling the slice + handler, _ := setup(t) + baseHandler := handler.(*BaseBlueprintHandler) + result, err := baseHandler.yamlMarshalWithDefinedPaths(input) + + // Then no error should be returned + if err != nil { + t.Errorf("yamlMarshalWithDefinedPaths failed: %v", err) + } + + // And the result should be an empty array + if string(result) != "[]\n" { + t.Errorf("Expected '[]\n', got: %s", string(result)) + } + }) + + t.Run("NoYamlTag", func(t *testing.T) { + // Given a struct with no YAML tags + type testStruct struct { + Field string + } + input := testStruct{Field: "value"} + + // When marshalling the struct + handler, _ := setup(t) + baseHandler := handler.(*BaseBlueprintHandler) + result, err := baseHandler.yamlMarshalWithDefinedPaths(input) + + // Then no error should be returned + if err != nil { + t.Errorf("yamlMarshalWithDefinedPaths failed: %v", err) + } + + // And the field name should be used as is + if !strings.Contains(string(result), "Field: value") { + t.Errorf("Expected 'Field: value' in result, got: %s", string(result)) + } + }) + + t.Run("CustomYamlTag", func(t *testing.T) { + // Given a struct with a custom YAML tag + type testStruct struct { + Field string `yaml:"custom_field"` + } + input := testStruct{Field: "value"} + + // When marshalling the struct + handler, _ := setup(t) + baseHandler := handler.(*BaseBlueprintHandler) + result, err := baseHandler.yamlMarshalWithDefinedPaths(input) + + // Then no error should be returned + if err != nil { + t.Errorf("yamlMarshalWithDefinedPaths failed: %v", err) + } + + // And the custom field name should be used + if !strings.Contains(string(result), "custom_field: value") { + t.Errorf("Expected 'custom_field: value' in result, got: %s", string(result)) + } + }) + + t.Run("MapWithCustomTags", func(t *testing.T) { + // Given a map with nested structs using custom YAML tags + type nestedStruct struct { + Value string `yaml:"custom_value"` + } + input := map[string]nestedStruct{ + "key": {Value: "test"}, + } + + // When marshalling the map + handler, _ := setup(t) + baseHandler := handler.(*BaseBlueprintHandler) + result, err := baseHandler.yamlMarshalWithDefinedPaths(input) + + // Then no error should be returned + if err != nil { + t.Errorf("yamlMarshalWithDefinedPaths failed: %v", err) + } + + // And the map key should be preserved + if !strings.Contains(string(result), "key:") { + t.Errorf("Expected 'key:' in result, got: %s", string(result)) + } + + // And the nested custom field name should be used + if !strings.Contains(string(result), " custom_value: test") { + t.Errorf("Expected ' custom_value: test' in result, got: %s", string(result)) + } + }) + + t.Run("DefaultFieldName", func(t *testing.T) { + // Given a struct with default field names + data := struct { + Field string + }{ + Field: "value", + } + + // When marshalling the struct + handler, _ := setup(t) + baseHandler := handler.(*BaseBlueprintHandler) + result, err := baseHandler.yamlMarshalWithDefinedPaths(data) + + // Then no error should be returned + if err != nil { + t.Errorf("yamlMarshalWithDefinedPaths failed: %v", err) + } + + // And the default field name should be used + if !strings.Contains(string(result), "Field: value") { + t.Errorf("Expected 'Field: value' in result, got: %s", string(result)) + } + }) + + t.Run("NilInput", func(t *testing.T) { + // When marshalling nil input + handler, _ := setup(t) + baseHandler := handler.(*BaseBlueprintHandler) + _, err := baseHandler.yamlMarshalWithDefinedPaths(nil) + + // Then an error should be returned + if err == nil { + t.Error("Expected error for nil input, got nil") + } + + // And the error message should be appropriate + if !strings.Contains(err.Error(), "invalid input: nil value") { + t.Errorf("Expected error about nil input, got: %v", err) + } + }) + + t.Run("FuncType", func(t *testing.T) { + // When marshalling a function type + handler, _ := setup(t) + baseHandler := handler.(*BaseBlueprintHandler) + _, err := baseHandler.yamlMarshalWithDefinedPaths(func() {}) + + // Then an error should be returned + if err == nil { + t.Error("Expected error for func type, got nil") + } + + // And the error message should be appropriate + if !strings.Contains(err.Error(), "unsupported value type func") { + t.Errorf("Expected error about unsupported value type, got: %v", err) + } + }) + + t.Run("UnsupportedType", func(t *testing.T) { + // When marshalling an unsupported type + handler, _ := setup(t) + baseHandler := handler.(*BaseBlueprintHandler) + _, err := baseHandler.yamlMarshalWithDefinedPaths(make(chan int)) + + // Then an error should be returned + if err == nil { + t.Error("Expected error for unsupported type, got nil") + } + + // And the error message should be appropriate + if !strings.Contains(err.Error(), "unsupported value type") { + t.Errorf("Expected error about unsupported value type, got: %v", err) + } + }) + + t.Run("MapWithNilValues", func(t *testing.T) { + // Given a map with nil values + input := map[string]any{ + "key1": nil, + "key2": "value2", + } + + // When marshalling the map + handler, _ := setup(t) + baseHandler := handler.(*BaseBlueprintHandler) + result, err := baseHandler.yamlMarshalWithDefinedPaths(input) + + // Then no error should be returned + if err != nil { + t.Errorf("yamlMarshalWithDefinedPaths failed: %v", err) + } + + // And nil values should be represented as null + if !strings.Contains(string(result), "key1: null") { + t.Errorf("Expected 'key1: null' in result, got: %s", string(result)) + } + + // And non-nil values should be preserved + if !strings.Contains(string(result), "key2: value2") { + t.Errorf("Expected 'key2: value2' in result, got: %s", string(result)) + } + }) + + t.Run("SliceWithNilValues", func(t *testing.T) { + // Given a slice with nil values + input := []any{nil, "value", nil} + + // When marshalling the slice + handler, _ := setup(t) + baseHandler := handler.(*BaseBlueprintHandler) + result, err := baseHandler.yamlMarshalWithDefinedPaths(input) + + // Then no error should be returned + if err != nil { + t.Errorf("yamlMarshalWithDefinedPaths failed: %v", err) + } + + // And nil values should be represented as null + if !strings.Contains(string(result), "- null") { + t.Errorf("Expected '- null' in result, got: %s", string(result)) + } + + // And non-nil values should be preserved + if !strings.Contains(string(result), "- value") { + t.Errorf("Expected '- value' in result, got: %s", string(result)) + } + }) + + t.Run("StructWithPrivateFields", func(t *testing.T) { + // Given a struct with both public and private fields + type testStruct struct { + Public string + private string + } + input := testStruct{Public: "value", private: "ignored"} + + // When marshalling the struct + handler, _ := setup(t) + baseHandler := handler.(*BaseBlueprintHandler) + result, err := baseHandler.yamlMarshalWithDefinedPaths(input) + + // Then no error should be returned + if err != nil { + t.Errorf("yamlMarshalWithDefinedPaths failed: %v", err) + } + + // And public fields should be included + if !strings.Contains(string(result), "Public: value") { + t.Errorf("Expected 'Public: value' in result, got: %s", string(result)) + } + + // And private fields should be excluded + if strings.Contains(string(result), "private") { + t.Errorf("Expected 'private' not to be in result, got: %s", string(result)) + } + }) + + t.Run("StructWithYamlTag", func(t *testing.T) { + // Given a struct with a YAML tag + type testStruct struct { + Field string `yaml:"custom_name"` + } + input := testStruct{Field: "value"} + + // When marshalling the struct + handler, _ := setup(t) + baseHandler := handler.(*BaseBlueprintHandler) + result, err := baseHandler.yamlMarshalWithDefinedPaths(input) + + // Then no error should be returned + if err != nil { + t.Errorf("yamlMarshalWithDefinedPaths failed: %v", err) + } + + // And the custom field name should be used + if !strings.Contains(string(result), "custom_name: value") { + t.Errorf("Expected 'custom_name: value' in result, got: %s", string(result)) + } + }) + + t.Run("NestedStructs", func(t *testing.T) { + // Given nested structs + type nested struct { + Value string + } + type parent struct { + Nested nested + } + input := parent{Nested: nested{Value: "test"}} + + // When marshalling the nested structs + handler, _ := setup(t) + baseHandler := handler.(*BaseBlueprintHandler) + result, err := baseHandler.yamlMarshalWithDefinedPaths(input) + + // Then no error should be returned + if err != nil { + t.Errorf("yamlMarshalWithDefinedPaths failed: %v", err) + } + + // And the parent field should be included + if !strings.Contains(string(result), "Nested:") { + t.Errorf("Expected 'Nested:' in result, got: %s", string(result)) + } + + // And the nested field should be properly indented + if !strings.Contains(string(result), " Value: test") { + t.Errorf("Expected ' Value: test' in result, got: %s", string(result)) + } + }) + + t.Run("NumericTypes", func(t *testing.T) { + // Given a struct with various numeric types + type numbers struct { + Int int `yaml:"int"` + Int8 int8 `yaml:"int8"` + Int16 int16 `yaml:"int16"` + Int32 int32 `yaml:"int32"` + Int64 int64 `yaml:"int64"` + Uint uint `yaml:"uint"` + Uint8 uint8 `yaml:"uint8"` + Uint16 uint16 `yaml:"uint16"` + Uint32 uint32 `yaml:"uint32"` + Uint64 uint64 `yaml:"uint64"` + Float32 float32 `yaml:"float32"` + Float64 float64 `yaml:"float64"` + } + input := numbers{ + Int: 1, Int8: 2, Int16: 3, Int32: 4, Int64: 5, + Uint: 6, Uint8: 7, Uint16: 8, Uint32: 9, Uint64: 10, + Float32: 11.1, Float64: 12.2, + } + + // When marshalling the struct + handler, _ := setup(t) + baseHandler := handler.(*BaseBlueprintHandler) + result, err := baseHandler.yamlMarshalWithDefinedPaths(input) + + // Then no error should be returned + if err != nil { + t.Errorf("yamlMarshalWithDefinedPaths failed: %v", err) + } + + // And all numeric values should be correctly represented + for _, expected := range []string{ + "int: 1", "int8: 2", "int16: 3", "int32: 4", "int64: 5", + "uint: 6", "uint8: 7", "uint16: 8", "uint32: 9", "uint64: 10", + "float32: 11.1", "float64: 12.2", + } { + if !strings.Contains(string(result), expected) { + t.Errorf("Expected '%s' in result, got: %s", expected, string(result)) + } + } + }) + + t.Run("BooleanType", func(t *testing.T) { + // Given a struct with boolean fields + type boolStruct struct { + True bool `yaml:"true"` + False bool `yaml:"false"` + } + input := boolStruct{True: true, False: false} + + // When marshalling the struct + handler, _ := setup(t) + baseHandler := handler.(*BaseBlueprintHandler) + result, err := baseHandler.yamlMarshalWithDefinedPaths(input) + + // Then no error should be returned + if err != nil { + t.Errorf("yamlMarshalWithDefinedPaths failed: %v", err) + } + + // And the boolean values should be correctly represented + if !strings.Contains(string(result), `"true": true`) { + t.Errorf("Expected '\"true\": true' in result, got: %s", string(result)) + } + if !strings.Contains(string(result), `"false": false`) { + t.Errorf("Expected '\"false\": false' in result, got: %s", string(result)) + } + }) + + t.Run("NilPointerAndInterface", func(t *testing.T) { + // Given a struct with nil pointers and interfaces + type testStruct struct { + NilPtr *string `yaml:"nil_ptr"` + NilInterface any `yaml:"nil_interface"` + NilMap map[string]string `yaml:"nil_map"` + NilSlice []string `yaml:"nil_slice"` + NilStruct *struct{ Field int } `yaml:"nil_struct"` + } + input := testStruct{} + + // When marshalling the struct + handler, _ := setup(t) + baseHandler := handler.(*BaseBlueprintHandler) + result, err := baseHandler.yamlMarshalWithDefinedPaths(input) + + // Then no error should be returned + if err != nil { + t.Errorf("yamlMarshalWithDefinedPaths failed: %v", err) + } + + // And nil interfaces should be represented as empty objects + if !strings.Contains(string(result), "nil_interface: {}") { + t.Errorf("Expected 'nil_interface: {}' in result, got: %s", string(result)) + } + + // And nil slices should be represented as empty arrays + if !strings.Contains(string(result), "nil_slice: []") { + t.Errorf("Expected 'nil_slice: []' in result, got: %s", string(result)) + } + + // And nil maps should be represented as empty objects + if !strings.Contains(string(result), "nil_map: {}") { + t.Errorf("Expected 'nil_map: {}' in result, got: %s", string(result)) + } + + // And nil structs should be represented as empty objects + if !strings.Contains(string(result), "nil_struct: {}") { + t.Errorf("Expected 'nil_struct: {}' in result, got: %s", string(result)) + } + }) + + t.Run("SliceWithNilElements", func(t *testing.T) { + // Given a slice with nil elements + type elem struct { + Field string + } + input := []*elem{nil, {Field: "value"}, nil} + + // When marshalling the slice + handler, _ := setup(t) + baseHandler := handler.(*BaseBlueprintHandler) + result, err := baseHandler.yamlMarshalWithDefinedPaths(input) + + // Then no error should be returned + if err != nil { + t.Errorf("yamlMarshalWithDefinedPaths failed: %v", err) + } + + // And non-nil elements should be correctly represented + if !strings.Contains(string(result), "Field: value") { + t.Errorf("Expected 'Field: value' in result, got: %s", string(result)) + } + }) + + t.Run("MapWithNilValues", func(t *testing.T) { + // Given a map with nil and non-nil values + input := map[string]any{ + "nil": nil, + "nonnil": "value", + "nilptr": (*string)(nil), + } + + // When marshalling the map to YAML + handler, _ := setup(t) + baseHandler := handler.(*BaseBlueprintHandler) + result, err := baseHandler.yamlMarshalWithDefinedPaths(input) + + // Then no error should be returned + if err != nil { + t.Errorf("yamlMarshalWithDefinedPaths failed: %v", err) + } + + // And nil values should be represented as null + if !strings.Contains(string(result), "nil: null") { + t.Errorf("Expected 'nil: null' in result, got: %s", string(result)) + } + + // And non-nil values should be preserved + if !strings.Contains(string(result), "nonnil: value") { + t.Errorf("Expected 'nonnil: value' in result, got: %s", string(result)) + } + }) + + t.Run("UnsupportedType", func(t *testing.T) { + // Given an unsupported channel type + input := make(chan int) + + // When attempting to marshal the channel + handler, _ := setup(t) + baseHandler := handler.(*BaseBlueprintHandler) + _, err := baseHandler.yamlMarshalWithDefinedPaths(input) + + // Then an error should be returned + if err == nil { + t.Error("Expected error for unsupported type, got nil") + } + + // And the error should indicate the unsupported type + if !strings.Contains(err.Error(), "unsupported value type chan") { + t.Errorf("Expected error about unsupported type, got: %v", err) + } + }) + + t.Run("FunctionType", func(t *testing.T) { + // Given a function type + input := func() {} + + // When attempting to marshal the function + handler, _ := setup(t) + baseHandler := handler.(*BaseBlueprintHandler) + _, err := baseHandler.yamlMarshalWithDefinedPaths(input) + + // Then an error should be returned + if err == nil { + t.Error("Expected error for function type, got nil") + } + + // And the error should indicate the unsupported type + if !strings.Contains(err.Error(), "unsupported value type func") { + t.Errorf("Expected error about unsupported type, got: %v", err) + } + }) + + t.Run("ErrorInSliceConversion", func(t *testing.T) { + // Given a slice containing an unsupported type + input := []any{make(chan int)} + + // When attempting to marshal the slice + handler, _ := setup(t) + baseHandler := handler.(*BaseBlueprintHandler) + _, err := baseHandler.yamlMarshalWithDefinedPaths(input) + + // Then an error should be returned + if err == nil { + t.Error("Expected error for slice with unsupported type, got nil") + } + + // And the error should indicate the slice conversion issue + if !strings.Contains(err.Error(), "error converting slice element") { + t.Errorf("Expected error about slice conversion, got: %v", err) + } + }) + + t.Run("ErrorInMapConversion", func(t *testing.T) { + // Given a map containing an unsupported type + input := map[string]any{ + "channel": make(chan int), + } + + // When attempting to marshal the map + handler, _ := setup(t) + baseHandler := handler.(*BaseBlueprintHandler) + _, err := baseHandler.yamlMarshalWithDefinedPaths(input) + + // Then an error should be returned + if err == nil { + t.Error("Expected error for map with unsupported type, got nil") + } + + // And the error should indicate the map conversion issue + if !strings.Contains(err.Error(), "error converting map value") { + t.Errorf("Expected error about map conversion, got: %v", err) + } + }) + + t.Run("ErrorInStructFieldConversion", func(t *testing.T) { + // Given a struct containing an unsupported field type + type testStruct struct { + Channel chan int + } + input := testStruct{Channel: make(chan int)} + + // When attempting to marshal the struct + handler, _ := setup(t) + baseHandler := handler.(*BaseBlueprintHandler) + _, err := baseHandler.yamlMarshalWithDefinedPaths(input) + + // Then an error should be returned + if err == nil { + t.Error("Expected error for struct with unsupported field type, got nil") + } + + // And the error should indicate the field conversion issue + if !strings.Contains(err.Error(), "error converting field") { + t.Errorf("Expected error about field conversion, got: %v", err) + } + }) + + t.Run("YamlMarshalError", func(t *testing.T) { + // Given a blueprint handler + handler, _ := setup(t) + baseHandler := handler.(*BaseBlueprintHandler) + + // And a mock YAML marshaller that returns an error + baseHandler.shims.YamlMarshal = func(v any) ([]byte, error) { + return nil, fmt.Errorf("mock yaml marshal error") + } + + // And a simple struct to marshal + input := struct{ Field string }{"value"} + + // When marshalling the struct + _, err := baseHandler.yamlMarshalWithDefinedPaths(input) + + // Then an error should be returned + if err == nil { + t.Error("Expected error from yaml marshal, got nil") + } + + // And the error should indicate the YAML marshalling issue + if !strings.Contains(err.Error(), "error marshalling yaml") { + t.Errorf("Expected error about yaml marshalling, got: %v", err) + } + }) +} + +func TestTLACode(t *testing.T) { + // Given a mock Jsonnet VM that returns an error about missing authors + vm := NewMockJsonnetVM(func(filename, snippet string) (string, error) { + return "", fmt.Errorf("blueprint has no authors") + }) + + // When evaluating an empty snippet + _, err := vm.EvaluateAnonymousSnippet("test.jsonnet", "") + + // Then an error about missing authors should be returned + if err == nil || !strings.Contains(err.Error(), "blueprint has no authors") { + t.Errorf("expected error containing 'blueprint has no authors', got %v", err) + } +} + +func TestBaseBlueprintHandler_calculateMaxWaitTime(t *testing.T) { + t.Run("EmptyKustomizations", func(t *testing.T) { + // Given a blueprint handler with no kustomizations + handler := &BaseBlueprintHandler{ + blueprint: blueprintv1alpha1.Blueprint{ + Kustomizations: []blueprintv1alpha1.Kustomization{}, + }, + } + + // When calculating max wait time + waitTime := handler.calculateMaxWaitTime() + + // Then it should return 0 since there are no kustomizations + if waitTime != 0 { + t.Errorf("expected 0 duration, got %v", waitTime) + } + }) + + t.Run("SingleKustomization", func(t *testing.T) { + // Given a blueprint handler with a single kustomization + customTimeout := 2 * time.Minute + handler := &BaseBlueprintHandler{ + blueprint: blueprintv1alpha1.Blueprint{ + Kustomizations: []blueprintv1alpha1.Kustomization{ + { + Name: "test-kustomization", + Timeout: &metav1.Duration{ + Duration: customTimeout, + }, + }, + }, + }, + } + + // When calculating max wait time + waitTime := handler.calculateMaxWaitTime() + + // Then it should return the kustomization's timeout + if waitTime != customTimeout { + t.Errorf("expected timeout %v, got %v", customTimeout, waitTime) + } + }) + + t.Run("LinearDependencies", func(t *testing.T) { + // Given a blueprint handler with linear dependencies + timeout1 := 1 * time.Minute + timeout2 := 2 * time.Minute + timeout3 := 3 * time.Minute + handler := &BaseBlueprintHandler{ + blueprint: blueprintv1alpha1.Blueprint{ + Kustomizations: []blueprintv1alpha1.Kustomization{ + { + Name: "kustomization-1", + Timeout: &metav1.Duration{ + Duration: timeout1, + }, + DependsOn: []string{"kustomization-2"}, + }, + { + Name: "kustomization-2", + Timeout: &metav1.Duration{ + Duration: timeout2, + }, + DependsOn: []string{"kustomization-3"}, + }, + { + Name: "kustomization-3", + Timeout: &metav1.Duration{ + Duration: timeout3, + }, + }, + }, + }, + } + + // When calculating max wait time + waitTime := handler.calculateMaxWaitTime() + + // Then it should return the sum of all timeouts + expectedTime := timeout1 + timeout2 + timeout3 + if waitTime != expectedTime { + t.Errorf("expected timeout %v, got %v", expectedTime, waitTime) + } + }) + + t.Run("BranchingDependencies", func(t *testing.T) { + // Given a blueprint handler with branching dependencies + timeout1 := 1 * time.Minute + timeout2 := 2 * time.Minute + timeout3 := 3 * time.Minute + timeout4 := 4 * time.Minute + handler := &BaseBlueprintHandler{ + blueprint: blueprintv1alpha1.Blueprint{ + Kustomizations: []blueprintv1alpha1.Kustomization{ + { + Name: "kustomization-1", + Timeout: &metav1.Duration{ + Duration: timeout1, + }, + DependsOn: []string{"kustomization-2", "kustomization-3"}, + }, + { + Name: "kustomization-2", + Timeout: &metav1.Duration{ + Duration: timeout2, + }, + DependsOn: []string{"kustomization-4"}, + }, + { + Name: "kustomization-3", + Timeout: &metav1.Duration{ + Duration: timeout3, + }, + DependsOn: []string{"kustomization-4"}, + }, + { + Name: "kustomization-4", + Timeout: &metav1.Duration{ + Duration: timeout4, + }, + }, + }, + }, + } + + // When calculating max wait time + waitTime := handler.calculateMaxWaitTime() + + // Then it should return the longest path (1 -> 3 -> 4) + expectedTime := timeout1 + timeout3 + timeout4 + if waitTime != expectedTime { + t.Errorf("expected timeout %v, got %v", expectedTime, waitTime) + } + }) + + t.Run("CircularDependencies", func(t *testing.T) { + // Given a blueprint handler with circular dependencies + timeout1 := 1 * time.Minute + timeout2 := 2 * time.Minute + timeout3 := 3 * time.Minute + handler := &BaseBlueprintHandler{ + blueprint: blueprintv1alpha1.Blueprint{ + Kustomizations: []blueprintv1alpha1.Kustomization{ + { + Name: "kustomization-1", + Timeout: &metav1.Duration{ + Duration: timeout1, + }, + DependsOn: []string{"kustomization-2"}, + }, + { + Name: "kustomization-2", + Timeout: &metav1.Duration{ + Duration: timeout2, + }, + DependsOn: []string{"kustomization-3"}, + }, + { + Name: "kustomization-3", + Timeout: &metav1.Duration{ + Duration: timeout3, + }, + DependsOn: []string{"kustomization-1"}, + }, + }, + }, + } + + // When calculating max wait time + waitTime := handler.calculateMaxWaitTime() + + // Then it should return the sum of all timeouts in the cycle (1+2+3+3) + expectedTime := timeout1 + timeout2 + timeout3 + timeout3 + if waitTime != expectedTime { + t.Errorf("expected timeout %v, got %v", expectedTime, waitTime) + } + }) +} + +func TestBaseBlueprintHandler_loadPlatformTemplate(t *testing.T) { + t.Run("ValidPlatforms", func(t *testing.T) { + // Given a BaseBlueprintHandler + handler := &BaseBlueprintHandler{} + + // When loading templates for valid platforms + platforms := []string{"local", "metal", "aws", "azure"} + for _, platform := range platforms { + // Then the template should be loaded successfully + template, err := handler.loadPlatformTemplate(platform) + if err != nil { + t.Errorf("Expected no error for platform %s, got: %v", platform, err) + } + if len(template) == 0 { + t.Errorf("Expected non-empty template for platform %s", platform) + } + } + }) + + t.Run("InvalidPlatform", func(t *testing.T) { + // Given a BaseBlueprintHandler + handler := &BaseBlueprintHandler{} + + // When loading template for invalid platform + template, err := handler.loadPlatformTemplate("invalid-platform") + + // Then no error should occur but template should be empty + if err != nil { + t.Errorf("Expected no error for invalid platform, got: %v", err) + } + if len(template) != 0 { + t.Errorf("Expected empty template for invalid platform, got length: %d", len(template)) + } + }) + + t.Run("EmptyPlatform", func(t *testing.T) { + // Given a BaseBlueprintHandler + handler := &BaseBlueprintHandler{} + + // When loading template with empty platform + template, err := handler.loadPlatformTemplate("") + + // Then no error should occur and template should be empty + if err != nil { + t.Errorf("Expected no error for empty platform, got: %v", err) + } + if len(template) != 0 { + t.Errorf("Expected empty template for empty platform, got length: %d", len(template)) + } + }) +} + +func TestBaseBlueprintHandler_loadFileData(t *testing.T) { + t.Run("func", func(t *testing.T) { + // Test cases will go here + }) +} diff --git a/pkg/blueprint/blueprint_handler_private_test.go b/pkg/blueprint/blueprint_handler_private_test.go new file mode 100644 index 000000000..951b4a640 --- /dev/null +++ b/pkg/blueprint/blueprint_handler_private_test.go @@ -0,0 +1,684 @@ +package blueprint + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + sourcev1 "github.com/fluxcd/source-controller/api/v1" + blueprintv1alpha1 "github.com/windsorcli/cli/api/v1alpha1" + "github.com/windsorcli/cli/pkg/config" +) + +// ============================================================================= +// Test Private Methods +// ============================================================================= + +func TestBlueprintHandler_resolveComponentSources(t *testing.T) { + setup := func(t *testing.T) (BlueprintHandler, *Mocks) { + mocks := setupMocks(t) + handler := NewBlueprintHandler(mocks.Injector) + handler.shims = mocks.Shims + err := handler.Initialize() + if err != nil { + t.Fatalf("Failed to initialize BlueprintHandler: %v", err) + } + return handler, mocks + } + + t.Run("Success", func(t *testing.T) { + // Given a blueprint handler + handler, _ := setup(t) + + // And a mock resource operation that tracks applied sources + var appliedSources []string + kubeClientResourceOperation = func(_ string, config ResourceOperationConfig) error { + if repo, ok := config.ResourceObject.(*sourcev1.GitRepository); ok { + appliedSources = append(appliedSources, repo.Spec.URL) + } + return nil + } + + // And sources have been set + sources := []blueprintv1alpha1.Source{ + { + Name: "source1", + Url: "git::https://example.com/source1.git", + PathPrefix: "terraform", + Ref: blueprintv1alpha1.Reference{Branch: "main"}, + }, + } + handler.SetSources(sources) + + // And terraform components have been set + components := []blueprintv1alpha1.TerraformComponent{ + { + Source: "source1", + Path: "path/to/code", + }, + } + handler.SetTerraformComponents(components) + + // When installing the components + err := handler.Install() + + // Then no error should be returned + if err != nil { + t.Fatalf("Expected successful installation, but got error: %v", err) + } + + // And the source URL should be applied + expectedURL := "git::https://example.com/source1.git" + found := false + for _, url := range appliedSources { + if strings.TrimPrefix(url, "https://") == expectedURL { + found = true + break + } + } + if !found { + t.Errorf("Expected source URL %s to be applied, but it wasn't. Applied sources: %v", expectedURL, appliedSources) + } + }) + + t.Run("DefaultPathPrefix", func(t *testing.T) { + // Given a blueprint handler + handler, _ := setup(t) + baseHandler := handler.(*BaseBlueprintHandler) + + // And sources have been set without a path prefix + handler.SetSources([]blueprintv1alpha1.Source{{ + Name: "test-source", + Url: "https://github.com/user/repo.git", + Ref: blueprintv1alpha1.Reference{Branch: "main"}, + }}) + + // And terraform components have been set + handler.SetTerraformComponents([]blueprintv1alpha1.TerraformComponent{{ + Source: "test-source", + Path: "module/path", + }}) + + // When resolving component sources + blueprint := baseHandler.blueprint.DeepCopy() + baseHandler.resolveComponentSources(blueprint) + + // Then the default path prefix should be used + expectedSource := "https://github.com/user/repo.git//terraform/module/path?ref=main" + if blueprint.TerraformComponents[0].Source != expectedSource { + t.Errorf("Expected source URL %s, got %s", expectedSource, blueprint.TerraformComponents[0].Source) + } + }) +} + +func TestBlueprintHandler_resolveComponentPaths(t *testing.T) { + setup := func(t *testing.T) (BlueprintHandler, *Mocks) { + mocks := setupMocks(t) + handler := NewBlueprintHandler(mocks.Injector) + handler.shims = mocks.Shims + err := handler.Initialize() + if err != nil { + t.Fatalf("Failed to initialize BlueprintHandler: %v", err) + } + return handler, mocks + } + + t.Run("Success", func(t *testing.T) { + // Given a blueprint handler + handler, _ := setup(t) + baseHandler := handler.(*BaseBlueprintHandler) + + // And terraform components have been set + expectedComponents := []blueprintv1alpha1.TerraformComponent{ + { + Source: "source1", + Path: "path/to/code", + }, + } + handler.SetTerraformComponents(expectedComponents) + + // When resolving component paths + blueprint := baseHandler.blueprint.DeepCopy() + baseHandler.resolveComponentPaths(blueprint) + + // Then each component should have the correct full path + for _, component := range blueprint.TerraformComponents { + expectedPath := filepath.Join(baseHandler.projectRoot, "terraform", component.Path) + if component.FullPath != expectedPath { + t.Errorf("Expected component path to be %v, but got %v", expectedPath, component.FullPath) + } + } + }) + + t.Run("isValidTerraformRemoteSource", func(t *testing.T) { + handler, _ := setup(t) + + // Given a set of test cases for terraform source validation + tests := []struct { + name string + source string + want bool + }{ + {"ValidLocalPath", "/absolute/path/to/module", false}, + {"ValidRelativePath", "./relative/path/to/module", false}, + {"InvalidLocalPath", "/invalid/path/to/module", false}, + {"ValidGitURL", "git::https://github.com/user/repo.git", true}, + {"ValidSSHGitURL", "git@github.com:user/repo.git", true}, + {"ValidHTTPURL", "https://github.com/user/repo.git", true}, + {"ValidHTTPZipURL", "https://example.com/archive.zip", true}, + {"InvalidHTTPURL", "https://example.com/not-a-zip", false}, + {"ValidTerraformRegistry", "registry.terraform.io/hashicorp/consul/aws", true}, + {"ValidGitHubReference", "github.com/hashicorp/terraform-aws-consul", true}, + {"InvalidSource", "invalid-source", false}, + {"VersionFileGitAtURL", "git@github.com:user/version.git", true}, + {"VersionFileGitAtURLWithPath", "git@github.com:user/version.git@v1.0.0", true}, + {"ValidGitLabURL", "git::https://gitlab.com/user/repo.git", true}, + {"ValidSSHGitLabURL", "git@gitlab.com:user/repo.git", true}, + {"ErrorCausingPattern", "[invalid-regex", false}, + } + + // When validating each source + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Then the validation result should match the expected outcome + if got := handler.(*BaseBlueprintHandler).isValidTerraformRemoteSource(tt.source); got != tt.want { + t.Errorf("isValidTerraformRemoteSource(%s) = %v, want %v", tt.source, got, tt.want) + } + }) + } + }) + + t.Run("ValidRemoteSourceWithFullPath", func(t *testing.T) { + // Given a blueprint handler + handler, _ := setup(t) + baseHandler := handler.(*BaseBlueprintHandler) + + // And a source with URL and path prefix + handler.SetSources([]blueprintv1alpha1.Source{{ + Name: "test-source", + Url: "https://github.com/user/repo.git", + PathPrefix: "terraform", + Ref: blueprintv1alpha1.Reference{Branch: "main"}, + }}) + + // And a terraform component referencing that source + handler.SetTerraformComponents([]blueprintv1alpha1.TerraformComponent{{ + Source: "test-source", + Path: "module/path", + }}) + + // When resolving component sources and paths + blueprint := baseHandler.blueprint.DeepCopy() + baseHandler.resolveComponentSources(blueprint) + baseHandler.resolveComponentPaths(blueprint) + + // Then the source should be properly resolved + if blueprint.TerraformComponents[0].Source != "https://github.com/user/repo.git//terraform/module/path?ref=main" { + t.Errorf("Unexpected resolved source: %v", blueprint.TerraformComponents[0].Source) + } + + // And the full path should be correctly constructed + expectedPath := filepath.Join(baseHandler.projectRoot, ".windsor", ".tf_modules", "module/path") + if blueprint.TerraformComponents[0].FullPath != expectedPath { + t.Errorf("Unexpected full path: %v", blueprint.TerraformComponents[0].FullPath) + } + }) + + t.Run("RegexpMatchStringError", func(t *testing.T) { + // Given a blueprint handler + handler, _ := setup(t) + baseHandler := handler.(*BaseBlueprintHandler) + + // And a mock regexp matcher that returns an error + originalRegexpMatchString := baseHandler.shims.RegexpMatchString + defer func() { baseHandler.shims.RegexpMatchString = originalRegexpMatchString }() + baseHandler.shims.RegexpMatchString = func(pattern, s string) (bool, error) { + return false, fmt.Errorf("mocked error in regexpMatchString") + } + + // When validating an invalid regex pattern + if got := baseHandler.isValidTerraformRemoteSource("[invalid-regex"); got != false { + t.Errorf("isValidTerraformRemoteSource([invalid-regex) = %v, want %v", got, false) + } + }) +} + +func TestBlueprintHandler_processBlueprintData(t *testing.T) { + setup := func(t *testing.T) (BlueprintHandler, *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("ValidBlueprintData", func(t *testing.T) { + // Given a blueprint handler and an empty blueprint + handler, _ := setup(t) + blueprint := &blueprintv1alpha1.Blueprint{ + Sources: []blueprintv1alpha1.Source{}, + TerraformComponents: []blueprintv1alpha1.TerraformComponent{}, + Kustomizations: []blueprintv1alpha1.Kustomization{}, + } + + // And valid blueprint data + data := []byte(` +kind: Blueprint +apiVersion: v1alpha1 +metadata: + name: test-blueprint + description: A test blueprint + authors: + - John Doe +sources: + - name: test-source + url: git::https://example.com/test-repo.git +terraform: + - source: test-source + path: path/to/code +kustomize: + - name: test-kustomization + path: ./kustomize +repository: + url: git::https://example.com/test-repo.git + ref: + branch: main +`) + + // When processing the blueprint data + baseHandler := handler.(*BaseBlueprintHandler) + err := baseHandler.processBlueprintData(data, blueprint) + + // Then no error should be returned + if err != nil { + t.Errorf("processBlueprintData failed: %v", err) + } + + // And the metadata should be correctly set + if blueprint.Metadata.Name != "test-blueprint" { + t.Errorf("Expected name 'test-blueprint', got %s", blueprint.Metadata.Name) + } + if blueprint.Metadata.Description != "A test blueprint" { + t.Errorf("Expected description 'A test blueprint', got %s", blueprint.Metadata.Description) + } + if len(blueprint.Metadata.Authors) != 1 || blueprint.Metadata.Authors[0] != "John Doe" { + t.Errorf("Expected authors ['John Doe'], got %v", blueprint.Metadata.Authors) + } + + // And the sources should be correctly set + if len(blueprint.Sources) != 1 || blueprint.Sources[0].Name != "test-source" { + t.Errorf("Expected one source named 'test-source', got %v", blueprint.Sources) + } + + // And the terraform components should be correctly set + if len(blueprint.TerraformComponents) != 1 || blueprint.TerraformComponents[0].Source != "test-source" { + t.Errorf("Expected one component with source 'test-source', got %v", blueprint.TerraformComponents) + } + + // And the kustomizations should be correctly set + if len(blueprint.Kustomizations) != 1 || blueprint.Kustomizations[0].Name != "test-kustomization" { + t.Errorf("Expected one kustomization named 'test-kustomization', got %v", blueprint.Kustomizations) + } + + // And the repository should be correctly set + if blueprint.Repository.Url != "git::https://example.com/test-repo.git" { + t.Errorf("Expected repository URL 'git::https://example.com/test-repo.git', got %s", blueprint.Repository.Url) + } + if blueprint.Repository.Ref.Branch != "main" { + t.Errorf("Expected repository branch 'main', got %s", blueprint.Repository.Ref.Branch) + } + }) + + t.Run("MissingRequiredFields", func(t *testing.T) { + // Given a blueprint handler and an empty blueprint + handler, _ := setup(t) + blueprint := &blueprintv1alpha1.Blueprint{} + + // And blueprint data with missing required fields + data := []byte(` +kind: Blueprint +apiVersion: v1alpha1 +metadata: + name: "" + description: "" +`) + + // When processing the blueprint data + baseHandler := handler.(*BaseBlueprintHandler) + err := baseHandler.processBlueprintData(data, blueprint) + + // Then no error should be returned since validation is removed + if err != nil { + t.Errorf("Expected no error for missing required fields, got: %v", err) + } + }) + + t.Run("InvalidYAML", func(t *testing.T) { + // Given a blueprint handler and an empty blueprint + handler, _ := setup(t) + blueprint := &blueprintv1alpha1.Blueprint{} + + // And invalid YAML data + data := []byte(`invalid yaml content`) + + // When processing the blueprint data + baseHandler := handler.(*BaseBlueprintHandler) + err := baseHandler.processBlueprintData(data, blueprint) + + // Then an error should be returned + if err == nil { + t.Error("Expected error for invalid YAML, got nil") + } + if !strings.Contains(err.Error(), "error unmarshalling blueprint data") { + t.Errorf("Expected error about unmarshalling, got: %v", err) + } + }) + + t.Run("InvalidKustomization", func(t *testing.T) { + // Given a blueprint handler and an empty blueprint + handler, _ := setup(t) + blueprint := &blueprintv1alpha1.Blueprint{} + + // And blueprint data with an invalid kustomization interval + data := []byte(` +kind: Blueprint +apiVersion: v1alpha1 +metadata: + name: test-blueprint + description: A test blueprint + authors: + - John Doe +kustomize: + - name: test-kustomization + interval: invalid-interval + path: ./kustomize +`) + + // When processing the blueprint data + baseHandler := handler.(*BaseBlueprintHandler) + err := baseHandler.processBlueprintData(data, blueprint) + + // Then an error should be returned + if err == nil { + t.Fatal("Expected error for invalid kustomization, got nil") + } + if !strings.Contains(err.Error(), "error unmarshalling kustomization YAML") { + t.Errorf("Expected error about unmarshalling kustomization YAML, got: %v", err) + } + }) + + t.Run("ErrorMarshallingKustomizationMap", func(t *testing.T) { + // Given a blueprint handler and an empty blueprint + handler, _ := setup(t) + baseHandler := handler.(*BaseBlueprintHandler) + blueprint := &blueprintv1alpha1.Blueprint{} + + // And a mock YAML marshaller that returns an error + baseHandler.shims.YamlMarshalNonNull = func(v any) ([]byte, error) { + if _, ok := v.(map[string]any); ok { + return nil, fmt.Errorf("mock kustomization map marshal error") + } + return []byte{}, nil + } + + // And valid blueprint data + data := []byte(` +kind: Blueprint +apiVersion: v1alpha1 +metadata: + name: test-blueprint + description: Test description + authors: + - Test Author +kustomize: + - name: test-kustomization + path: ./test +`) + + // When processing the blueprint data + err := baseHandler.processBlueprintData(data, blueprint) + + // Then an error should be returned + if err == nil { + t.Error("Expected error for kustomization map marshalling, got nil") + } + if !strings.Contains(err.Error(), "error marshalling kustomization map") { + t.Errorf("Expected error about marshalling kustomization map, got: %v", err) + } + }) + + t.Run("InvalidKustomizationIntervalZero", func(t *testing.T) { + // Given a blueprint handler and an empty blueprint + handler, _ := setup(t) + baseHandler := handler.(*BaseBlueprintHandler) + blueprint := &blueprintv1alpha1.Blueprint{} + + // And blueprint data with a zero kustomization interval + data := []byte(` +kind: Blueprint +apiVersion: v1alpha1 +metadata: + name: test-blueprint + description: Test description + authors: + - Test Author +kustomize: + - apiVersion: kustomize.toolkit.fluxcd.io/v1 + kind: Kustomization + metadata: + name: test-kustomization + spec: + interval: 0s + path: ./test +`) + + // When processing the blueprint data + err := baseHandler.processBlueprintData(data, blueprint) + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error for kustomization with zero interval, got: %v", err) + } + }) + + t.Run("InvalidKustomizationIntervalValue", func(t *testing.T) { + // Given a blueprint handler and an empty blueprint + handler, _ := setup(t) + baseHandler := handler.(*BaseBlueprintHandler) + blueprint := &blueprintv1alpha1.Blueprint{} + + // And blueprint data with an invalid kustomization interval + data := []byte(` +kind: Blueprint +apiVersion: v1alpha1 +metadata: + name: test-blueprint + description: Test description + authors: + - Test Author +kustomize: + - apiVersion: kustomize.toolkit.fluxcd.io/v1 + kind: Kustomization + metadata: + name: test-kustomization + spec: + interval: "invalid" + path: ./test +`) + // When processing the blueprint data + err := baseHandler.processBlueprintData(data, blueprint) + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error for invalid kustomization interval value, got: %v", err) + } + }) + + t.Run("MissingDescription", func(t *testing.T) { + // Given a blueprint handler and data with missing description + handler, _ := setup(t) + blueprint := &blueprintv1alpha1.Blueprint{} + + data := []byte(` +kind: Blueprint +apiVersion: v1alpha1 +metadata: + name: test-blueprint + authors: + - John Doe +`) + + // When processing the blueprint data + baseHandler := handler.(*BaseBlueprintHandler) + err := baseHandler.processBlueprintData(data, blueprint) + + // Then no error should be returned since validation is removed + if err != nil { + t.Errorf("Expected no error for missing description, got: %v", err) + } + }) + + t.Run("MissingAuthors", func(t *testing.T) { + // Given a blueprint handler and data with empty authors list + handler, _ := setup(t) + blueprint := &blueprintv1alpha1.Blueprint{} + + data := []byte(` +kind: Blueprint +apiVersion: v1alpha1 +metadata: + name: test-blueprint + description: A test blueprint + authors: [] +`) + + // When processing the blueprint data + baseHandler := handler.(*BaseBlueprintHandler) + err := baseHandler.processBlueprintData(data, blueprint) + + // Then no error should be returned since validation is removed + if err != nil { + t.Errorf("Expected no error for empty authors list, got: %v", err) + } + }) +} + +func TestBaseBlueprintHandler_deleteKustomization(t *testing.T) { + t.Run("Success", func(t *testing.T) { + baseHandler := &BaseBlueprintHandler{} + os.Setenv("KUBECONFIG", "test-kubeconfig") + name := "test-kustomization" + namespace := "test-namespace" + + var called bool + var gotKubeconfig string + var gotReq KubeRequestConfig + origKubeClient := kubeClient + kubeClient = func(kubeconfig string, req KubeRequestConfig) error { + called = true + gotKubeconfig = kubeconfig + gotReq = req + return nil + } + defer func() { kubeClient = origKubeClient }() + + err := baseHandler.deleteKustomization(name, namespace) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if !called { + t.Fatal("expected kubeClient to be called") + } + if gotKubeconfig != "test-kubeconfig" { + t.Errorf("expected kubeconfig 'test-kubeconfig', got '%s'", gotKubeconfig) + } + if gotReq.Namespace != namespace { + t.Errorf("expected namespace '%s', got '%s'", namespace, gotReq.Namespace) + } + if gotReq.Name != name { + t.Errorf("expected resource name '%s', got '%s'", name, gotReq.Name) + } + if gotReq.Resource != "kustomizations" { + t.Errorf("expected resource type 'kustomizations', got '%s'", gotReq.Resource) + } + if gotReq.Method != "DELETE" { + t.Errorf("expected method 'DELETE', got '%s'", gotReq.Method) + } + }) + + t.Run("DeleteError", func(t *testing.T) { + baseHandler := &BaseBlueprintHandler{} + os.Setenv("KUBECONFIG", "test-kubeconfig") + name := "test-kustomization" + namespace := "test-namespace" + + expectedErr := fmt.Errorf("delete error") + origKubeClient := kubeClient + kubeClient = func(kubeconfig string, req KubeRequestConfig) error { + return expectedErr + } + defer func() { kubeClient = origKubeClient }() + + err := baseHandler.deleteKustomization(name, namespace) + if err == nil { + t.Fatal("expected error, got nil") + } + if err != expectedErr { + t.Errorf("expected error %v, got %v", expectedErr, err) + } + }) +} + +func TestBaseBlueprintHandler_applyConfigMap(t *testing.T) { + t.Run("Error", func(t *testing.T) { + baseHandler := &BaseBlueprintHandler{} + os.Setenv("KUBECONFIG", "test-kubeconfig") + + mockConfigHandler := &config.MockConfigHandler{ + GetStringFunc: func(key string, _ ...string) string { return "foo" }, + GetStringSliceFunc: func(key string, _ ...[]string) []string { return []string{"/tmp:/var/local"} }, + GetContextFunc: func() string { return "mock-context" }, + } + baseHandler.configHandler = mockConfigHandler + + errExpected := fmt.Errorf("apply configmap error") + origKubeClientResourceOperation := kubeClientResourceOperation + kubeClientResourceOperation = func(_ string, _ ResourceOperationConfig) error { + return errExpected + } + defer func() { kubeClientResourceOperation = origKubeClientResourceOperation }() + + err := baseHandler.applyConfigMap() + if err == nil { + t.Fatal("expected error, got nil") + } + if err != errExpected { + t.Errorf("expected error %v, got %v", errExpected, err) + } + }) +} + +// mockConfigHandler implements config.ConfigHandler for testing +// Only the methods used in applyConfigMap are implemented + +type mockConfigHandler struct { + getString func(string) string + getStringSlice func(string) []string + getContext func() string +} + +func (m *mockConfigHandler) GetString(key string) string { + return m.getString(key) +} +func (m *mockConfigHandler) GetStringSlice(key string) []string { + return m.getStringSlice(key) +} +func (m *mockConfigHandler) GetContext() string { + return m.getContext() +} diff --git a/pkg/blueprint/blueprint_handler_test.go b/pkg/blueprint/blueprint_handler_test.go index d2d1977c5..41c13da93 100644 --- a/pkg/blueprint/blueprint_handler_test.go +++ b/pkg/blueprint/blueprint_handler_test.go @@ -286,10 +286,16 @@ contexts: shims := setupShims(t) + // Patch kubeClient and kubeClientResourceOperation to no-op by default + origKubeClient := kubeClient + kubeClient = func(string, KubeRequestConfig) error { return nil } + origKubeClientResourceOperation := kubeClientResourceOperation + kubeClientResourceOperation = func(string, ResourceOperationConfig) error { return nil } t.Cleanup(func() { + kubeClient = origKubeClient + kubeClientResourceOperation = origKubeClientResourceOperation os.Unsetenv("WINDSOR_PROJECT_ROOT") os.Unsetenv("WINDSOR_CONTEXT") - if err := os.Chdir(origDir); err != nil { t.Logf("Warning: Failed to change back to original directory: %v", err) } @@ -353,7 +359,6 @@ func TestBlueprintHandler_NewBlueprintHandler(t *testing.T) { func TestBlueprintHandler_Initialize(t *testing.T) { setup := func(t *testing.T) (*BaseBlueprintHandler, *Mocks) { - t.Helper() t.Helper() mocks := setupMocks(t) handler := NewBlueprintHandler(mocks.Injector) @@ -506,7 +511,6 @@ func TestBlueprintHandler_Initialize(t *testing.T) { func TestBlueprintHandler_LoadConfig(t *testing.T) { setup := func(t *testing.T) (*BaseBlueprintHandler, *Mocks) { - t.Helper() t.Helper() mocks := setupMocks(t) handler := NewBlueprintHandler(mocks.Injector) @@ -543,11 +547,20 @@ func TestBlueprintHandler_LoadConfig(t *testing.T) { // And a mock file system that tracks checked paths var checkedPaths []string + handler.shims.Stat = func(name string) (os.FileInfo, error) { + if strings.HasSuffix(name, ".jsonnet") || strings.HasSuffix(name, ".yaml") { + return nil, nil + } + return nil, os.ErrNotExist + } handler.shims.ReadFile = func(name string) ([]byte, error) { checkedPaths = append(checkedPaths, name) if strings.HasSuffix(name, ".jsonnet") { return []byte(safeBlueprintJsonnet), nil } + if strings.HasSuffix(name, ".yaml") { + return []byte(safeBlueprintYAML), nil + } return nil, os.ErrNotExist } @@ -560,9 +573,8 @@ func TestBlueprintHandler_LoadConfig(t *testing.T) { t.Errorf("Expected no error, got %v", err) } - // And both jsonnet and yaml paths should be checked + // And only yaml path should be checked since it exists expectedPaths := []string{ - customPath + ".jsonnet", customPath + ".yaml", } for _, expected := range expectedPaths { @@ -672,11 +684,17 @@ func TestBlueprintHandler_LoadConfig(t *testing.T) { handler, _ := setup(t) // And a mock file system that returns an error for jsonnet files + handler.shims.Stat = func(name string) (os.FileInfo, error) { + if strings.HasSuffix(name, ".jsonnet") { + return nil, nil + } + return nil, os.ErrNotExist + } handler.shims.ReadFile = func(name string) ([]byte, error) { if strings.HasSuffix(name, ".jsonnet") { return nil, fmt.Errorf("error reading jsonnet file") } - return nil, nil + return nil, os.ErrNotExist } // When loading the config @@ -693,11 +711,17 @@ func TestBlueprintHandler_LoadConfig(t *testing.T) { handler, _ := setup(t) // And a mock file system that returns an error for yaml files + handler.shims.Stat = func(name string) (os.FileInfo, error) { + if strings.HasSuffix(name, ".yaml") { + return nil, nil + } + return nil, os.ErrNotExist + } handler.shims.ReadFile = func(name string) ([]byte, error) { if strings.HasSuffix(name, ".yaml") { return nil, fmt.Errorf("error reading yaml file") } - return nil, nil + return nil, os.ErrNotExist } // When loading the config @@ -748,6 +772,18 @@ func TestBlueprintHandler_LoadConfig(t *testing.T) { mocks.ConfigHandler.SetContext("local") // And a mock json marshaller that returns an error + handler.shims.Stat = func(name string) (os.FileInfo, error) { + if strings.HasSuffix(name, ".jsonnet") { + return nil, nil + } + return nil, os.ErrNotExist + } + handler.shims.ReadFile = func(name string) ([]byte, error) { + if strings.HasSuffix(name, ".jsonnet") { + return []byte(safeBlueprintJsonnet), nil + } + return nil, os.ErrNotExist + } handler.shims.JsonMarshal = func(v any) ([]byte, error) { return nil, fmt.Errorf("simulated marshalling error") } @@ -767,6 +803,18 @@ func TestBlueprintHandler_LoadConfig(t *testing.T) { mocks.ConfigHandler.SetContext("local") // And a mock jsonnet VM that returns an error + handler.shims.Stat = func(name string) (os.FileInfo, error) { + if strings.HasSuffix(name, ".jsonnet") { + return nil, nil + } + return nil, os.ErrNotExist + } + handler.shims.ReadFile = func(name string) ([]byte, error) { + if strings.HasSuffix(name, ".jsonnet") { + return []byte(safeBlueprintJsonnet), nil + } + return nil, os.ErrNotExist + } handler.shims.NewJsonnetVM = func() JsonnetVM { return NewMockJsonnetVM(func(filename, snippet string) (string, error) { return "", fmt.Errorf("simulated jsonnet evaluation error") @@ -788,6 +836,18 @@ func TestBlueprintHandler_LoadConfig(t *testing.T) { mocks.ConfigHandler.SetContext("local") // And a mock yaml marshaller that returns an error + handler.shims.Stat = func(name string) (os.FileInfo, error) { + if strings.HasSuffix(name, ".jsonnet") { + return nil, nil + } + return nil, os.ErrNotExist + } + handler.shims.ReadFile = func(name string) ([]byte, error) { + if strings.HasSuffix(name, ".jsonnet") { + return []byte(safeBlueprintJsonnet), nil + } + return nil, os.ErrNotExist + } handler.shims.YamlMarshal = func(v any) ([]byte, error) { return nil, fmt.Errorf("simulated yaml marshalling error") } @@ -807,6 +867,18 @@ func TestBlueprintHandler_LoadConfig(t *testing.T) { mocks.ConfigHandler.SetContext("local") // And a mock json marshaller that returns an error + handler.shims.Stat = func(name string) (os.FileInfo, error) { + if strings.HasSuffix(name, ".jsonnet") { + return nil, nil + } + return nil, os.ErrNotExist + } + handler.shims.ReadFile = func(name string) ([]byte, error) { + if strings.HasSuffix(name, ".jsonnet") { + return []byte(safeBlueprintJsonnet), nil + } + return nil, os.ErrNotExist + } handler.shims.JsonMarshal = func(v any) ([]byte, error) { return nil, fmt.Errorf("simulated json marshalling error") } @@ -826,11 +898,17 @@ func TestBlueprintHandler_LoadConfig(t *testing.T) { mocks.ConfigHandler.SetContext("local") // And a mock file system that returns an error for jsonnet files + handler.shims.Stat = func(name string) (os.FileInfo, error) { + if strings.HasSuffix(name, ".jsonnet") { + return nil, nil + } + return nil, os.ErrNotExist + } handler.shims.ReadFile = func(name string) ([]byte, error) { if strings.HasSuffix(name, ".jsonnet") { return nil, fmt.Errorf("error reading jsonnet file") } - return nil, fmt.Errorf("file not found") + return nil, os.ErrNotExist } // When loading the config @@ -1450,6 +1528,7 @@ func TestBlueprintHandler_WriteConfig(t *testing.T) { func TestBlueprintHandler_Install(t *testing.T) { setup := func(t *testing.T) (BlueprintHandler, *Mocks) { + t.Helper() mocks := setupMocks(t) handler := NewBlueprintHandler(mocks.Injector) err := handler.Initialize() @@ -1460,6 +1539,8 @@ func TestBlueprintHandler_Install(t *testing.T) { } t.Run("Success", func(t *testing.T) { + origKubeClientResourceOperation := kubeClientResourceOperation + defer func() { kubeClientResourceOperation = origKubeClientResourceOperation }() // Given a mock Kubernetes client that validates resource types kubeClientResourceOperation = func(kubeconfigPath string, config ResourceOperationConfig) error { switch config.ResourceName { @@ -1519,7 +1600,8 @@ func TestBlueprintHandler_Install(t *testing.T) { }) t.Run("SourceURLWithoutDotGit", func(t *testing.T) { - // Given a mock Kubernetes client that accepts any resource + origKubeClientResourceOperation := kubeClientResourceOperation + defer func() { kubeClientResourceOperation = origKubeClientResourceOperation }() kubeClientResourceOperation = func(kubeconfigPath string, config ResourceOperationConfig) error { return nil } @@ -1554,7 +1636,8 @@ func TestBlueprintHandler_Install(t *testing.T) { }) t.Run("SourceWithSecretName", func(t *testing.T) { - // Given a mock Kubernetes client that accepts any resource + origKubeClientResourceOperation := kubeClientResourceOperation + defer func() { kubeClientResourceOperation = origKubeClientResourceOperation }() kubeClientResourceOperation = func(kubeconfigPath string, config ResourceOperationConfig) error { return nil } @@ -1589,115 +1672,9 @@ func TestBlueprintHandler_Install(t *testing.T) { } }) - t.Run("EmptyLocalVolumePaths", func(t *testing.T) { - // Given a mock Kubernetes client that validates ConfigMap data - configMapApplied := false - kubeClientResourceOperation = func(kubeconfigPath string, config ResourceOperationConfig) error { - if config.ResourceName == "configmaps" { - configMapApplied = true - - configMap, ok := config.ResourceObject.(*corev1.ConfigMap) - if !ok { - return fmt.Errorf("unexpected resource object type") - } - - if configMap.Data["LOCAL_VOLUME_PATH"] != "" { - return fmt.Errorf("expected empty LOCAL_VOLUME_PATH value, but got: %s", configMap.Data["LOCAL_VOLUME_PATH"]) - } - } - return nil - } - - // And a mock config handler that returns empty volume paths - mockConfigHandler := config.NewMockConfigHandler() - opts := &SetupOptions{ - ConfigHandler: mockConfigHandler, - } - mocks := setupMocks(t, opts) - - mockConfigHandler.GetStringSliceFunc = func(key string, defaultValue ...[]string) []string { - if key == "cluster.workers.volumes" { - return []string{} - } - return []string{"default value"} - } - - // And a blueprint handler with sources - handler := NewBlueprintHandler(mocks.Injector) - handler.shims = mocks.Shims - err := handler.Initialize() - if err != nil { - t.Fatalf("Failed to initialize BlueprintHandler: %v", err) - } - - expectedSources := []blueprintv1alpha1.Source{ - { - Name: "source1", - Url: "git::https://example.com/source1.git", - Ref: blueprintv1alpha1.Reference{Branch: "main"}, - }, - } - handler.SetSources(expectedSources) - - // When installing the blueprint - err = handler.Install() - - // Then no error should be returned - if err != nil { - t.Fatalf("Expected successful installation, but got error: %v", err) - } - - // And the ConfigMap should be applied - if !configMapApplied { - t.Fatalf("Expected ConfigMap to be applied, but it was not") - } - }) - - t.Run("ApplyGitRepoError", func(t *testing.T) { - // Given a mock Kubernetes client that returns an error for a specific GitRepository - kubeClientResourceOperation = func(kubeconfigPath string, config ResourceOperationConfig) error { - if config.ResourceName == "gitrepositories" { - gitRepo, ok := config.ResourceObject.(*sourcev1.GitRepository) - if !ok { - return fmt.Errorf("unexpected resource object type") - } - if gitRepo.Name == "primary-repo" { - return fmt.Errorf("mock error applying primary GitRepository") - } - } - return nil - } - - // And a blueprint handler with sources - mocks := setupMocks(t) - handler := NewBlueprintHandler(mocks.Injector) - handler.shims = mocks.Shims - if err := handler.Initialize(); err != nil { - t.Fatalf("Failed to initialize handler: %v", err) - } - - expectedRepository := blueprintv1alpha1.Repository{ - Url: "git::https://example.com/primary-repo.git", - Ref: blueprintv1alpha1.Reference{Branch: "main"}, - } - err := handler.SetSources([]blueprintv1alpha1.Source{ - { - Name: "primary-repo", - Url: expectedRepository.Url, - Ref: expectedRepository.Ref, - }, - }) - if err != nil { - t.Fatalf("Failed to set sources: %v", err) - } - - err = handler.Install() - if err == nil || !strings.Contains(err.Error(), "mock error applying primary GitRepository") { - t.Fatalf("Expected error when applying primary GitRepository, but got: %v", err) - } - }) - t.Run("EmptySourceUrlError", func(t *testing.T) { + origKubeClientResourceOperation := kubeClientResourceOperation + defer func() { kubeClientResourceOperation = origKubeClientResourceOperation }() // Given a blueprint handler with a source that has an empty URL handler, _ := setup(t) @@ -1720,52 +1697,17 @@ func TestBlueprintHandler_Install(t *testing.T) { }) t.Run("EmptyRepositoryURL", func(t *testing.T) { - // Given a blueprint handler with an empty repository URL - handler, _ := setup(t) - - err := handler.SetRepository(blueprintv1alpha1.Repository{ - Url: "", - Ref: blueprintv1alpha1.Reference{Branch: "main"}, - }) - if err != nil { - t.Fatalf("Failed to set repository: %v", err) - } - + origKubeClientResourceOperation := kubeClientResourceOperation + defer func() { kubeClientResourceOperation = origKubeClientResourceOperation }() kubeClientResourceOperation = func(kubeconfigPath string, config ResourceOperationConfig) error { return nil } - // When installing the blueprint - err = handler.Install() - - // Then no error should be returned - if err != nil { - t.Errorf("Expected no error for empty repository URL, got: %v", err) - } - }) - - t.Run("ValidRepository", func(t *testing.T) { - // Given a blueprint handler + // Given a blueprint handler with an empty repository URL handler, _ := setup(t) - // And a mock Kubernetes client that tracks GitRepository creation - gitRepoApplied := false - kubeClientResourceOperation = func(kubeconfigPath string, config ResourceOperationConfig) error { - if config.ResourceName == "gitrepositories" { - gitRepo, ok := config.ResourceObject.(*sourcev1.GitRepository) - if !ok { - return fmt.Errorf("unexpected resource type") - } - if gitRepo.Name == "mock-context" { - gitRepoApplied = true - } - } - return nil - } - - // And a valid repository configuration err := handler.SetRepository(blueprintv1alpha1.Repository{ - Url: "https://example.com/repo.git", + Url: "", Ref: blueprintv1alpha1.Reference{Branch: "main"}, }) if err != nil { @@ -1777,20 +1719,13 @@ func TestBlueprintHandler_Install(t *testing.T) { // Then no error should be returned if err != nil { - t.Errorf("Expected no error, got: %v", err) - } - - // And the GitRepository should be created - if !gitRepoApplied { - t.Error("Expected GitRepository to be applied, but it wasn't") + t.Errorf("Expected no error for empty repository URL, got: %v", err) } }) t.Run("NoRepository", func(t *testing.T) { - // Given a blueprint handler without a repository - handler, _ := setup(t) - - // And a mock Kubernetes client that tracks GitRepository creation attempts + origKubeClientResourceOperation := kubeClientResourceOperation + defer func() { kubeClientResourceOperation = origKubeClientResourceOperation }() gitRepoAttempted := false kubeClientResourceOperation = func(kubeconfigPath string, config ResourceOperationConfig) error { if config.ResourceName == "gitrepositories" { @@ -1799,342 +1734,72 @@ func TestBlueprintHandler_Install(t *testing.T) { return nil } - // When installing the blueprint - err := handler.Install() + // And a blueprint handler + handler, _ := setup(t) - // Then no error should be returned + err := handler.Install() if err != nil { t.Errorf("Expected no error when no repository is defined, got: %v", err) } - - // And no GitRepository should be created if gitRepoAttempted { t.Error("Expected no GitRepository to be applied when no repository is defined") } }) +} - t.Run("ErrorApplyingGitRepository", func(t *testing.T) { - // Given a mock Kubernetes client that fails to apply GitRepository resources - kubeClientResourceOperation = func(kubeconfigPath string, config ResourceOperationConfig) error { - if config.ResourceName == "gitrepositories" { - return fmt.Errorf("mock error applying GitRepository") - } - return nil +func TestBlueprintHandler_GetMetadata(t *testing.T) { + setup := func(t *testing.T) (BlueprintHandler, *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 BlueprintHandler: %v", err) } + return handler, mocks + } - // And a blueprint handler with a repository + t.Run("Success", func(t *testing.T) { + // Given a blueprint handler handler, _ := setup(t) - err := handler.SetRepository(blueprintv1alpha1.Repository{ - Url: "git::https://example.com/repo.git", - Ref: blueprintv1alpha1.Reference{Branch: "main"}, - }) + // And metadata has been set + expectedMetadata := blueprintv1alpha1.Metadata{ + Name: "test-blueprint", + Description: "A test blueprint", + Authors: []string{"John Doe"}, + } + handler.SetMetadata(expectedMetadata) + + // When getting the metadata + actualMetadata := handler.GetMetadata() + + // Then it should match the expected metadata + if !reflect.DeepEqual(actualMetadata, expectedMetadata) { + t.Errorf("Expected metadata to be %v, but got %v", expectedMetadata, actualMetadata) + } + }) +} + +func TestBlueprintHandler_GetSources(t *testing.T) { + setup := func(t *testing.T) (BlueprintHandler, *Mocks) { + t.Helper() + mocks := setupMocks(t) + handler := NewBlueprintHandler(mocks.Injector) + handler.shims = mocks.Shims + err := handler.Initialize() if err != nil { - t.Fatalf("Failed to set repository: %v", err) + t.Fatalf("Failed to initialize BlueprintHandler: %v", err) } + return handler, mocks + } - // And sources and kustomizations are configured - expectedSources := []blueprintv1alpha1.Source{ - { - Name: "source1", - Url: "git::https://example.com/source1.git", - Ref: blueprintv1alpha1.Reference{Branch: "main"}, - }, - } - handler.SetSources(expectedSources) - - expectedKustomizations := []blueprintv1alpha1.Kustomization{ - { - Name: "kustomization1", - DependsOn: []string{"dependency1", "dependency2"}, - }, - } - handler.SetKustomizations(expectedKustomizations) - - // When installing the blueprint - err = handler.Install() - - // Then an error about applying GitRepository should be returned - if err == nil || !strings.Contains(err.Error(), "mock error applying GitRepository") { - t.Fatalf("Expected error when applying GitRepository, but got: %v", err) - } - }) - - t.Run("ErrorApplyingKustomization", func(t *testing.T) { - // Given a mock Kubernetes client that fails to apply Kustomization resources - kubeClientResourceOperation = func(kubeconfigPath string, config ResourceOperationConfig) error { - if config.ResourceName == "kustomizations" { - return fmt.Errorf("mock error applying Kustomization") - } - return nil - } - - // And a blueprint handler with repository, sources, and kustomizations - handler, _ := setup(t) - - err := handler.SetRepository(blueprintv1alpha1.Repository{ - Url: "git::https://example.com/repo.git", - Ref: blueprintv1alpha1.Reference{Branch: "main"}, - }) - if err != nil { - t.Fatalf("Failed to set repository: %v", err) - } - - expectedSources := []blueprintv1alpha1.Source{ - { - Name: "source1", - Url: "git::https://example.com/source1.git", - Ref: blueprintv1alpha1.Reference{Branch: "main"}, - }, - } - handler.SetSources(expectedSources) - - expectedKustomizations := []blueprintv1alpha1.Kustomization{ - { - Name: "kustomization1", - DependsOn: []string{"dependency1", "dependency2"}, - }, - } - handler.SetKustomizations(expectedKustomizations) - - // When installing the blueprint - err = handler.Install() - - // Then an error about applying Kustomization should be returned - if err == nil || !strings.Contains(err.Error(), "mock error applying Kustomization") { - t.Fatalf("Expected error when applying Kustomization, but got: %v", err) - } - }) - - t.Run("ErrorApplyingConfigMap", func(t *testing.T) { - // Given a mock Kubernetes client that fails to apply ConfigMap resources - kubeClientResourceOperation = func(kubeconfigPath string, config ResourceOperationConfig) error { - if config.ResourceName == "configmaps" { - return fmt.Errorf("mock error applying ConfigMap") - } - return nil - } - - // And a blueprint handler with repository and sources - handler, _ := setup(t) - - err := handler.SetRepository(blueprintv1alpha1.Repository{ - Url: "git::https://example.com/repo.git", - Ref: blueprintv1alpha1.Reference{Branch: "main"}, - }) - if err != nil { - t.Fatalf("Failed to set repository: %v", err) - } - - expectedSources := []blueprintv1alpha1.Source{ - { - Name: "source1", - Url: "git::https://example.com/source1.git", - Ref: blueprintv1alpha1.Reference{Branch: "main"}, - }, - } - handler.SetSources(expectedSources) - - // When installing the blueprint - err = handler.Install() - - // Then an error about applying ConfigMap should be returned - if err == nil || !strings.Contains(err.Error(), "mock error applying ConfigMap") { - t.Fatalf("Expected error when applying ConfigMap, but got: %v", err) - } - }) - - t.Run("SuccessApplyingConfigMap", func(t *testing.T) { - // Given a mock Kubernetes client that validates ConfigMap data - configMapApplied := false - kubeClientResourceOperation = func(kubeconfigPath string, config ResourceOperationConfig) error { - if config.ResourceName == "configmaps" { - configMapApplied = true - - configMap, ok := config.ResourceObject.(*corev1.ConfigMap) - if !ok { - return fmt.Errorf("unexpected resource object type") - } - if configMap.Data["DOMAIN"] != "mock.domain.com" { - return fmt.Errorf("unexpected DOMAIN value: got %s, want %s", configMap.Data["DOMAIN"], "mock.domain.com") - } - if configMap.Data["CONTEXT"] != "mock-context" { - return fmt.Errorf("unexpected CONTEXT value: got %s, want %s", configMap.Data["CONTEXT"], "mock-context") - } - if configMap.Data["LOADBALANCER_IP_RANGE"] != "192.168.1.1-192.168.1.100" { - return fmt.Errorf("unexpected LOADBALANCER_IP_RANGE value: got %s, want %s", configMap.Data["LOADBALANCER_IP_RANGE"], "192.168.1.1-192.168.1.100") - } - if configMap.Data["REGISTRY_URL"] != "mock.registry.com" { - return fmt.Errorf("unexpected REGISTRY_URL value: got %s, want %s", configMap.Data["REGISTRY_URL"], "mock.registry.com") - } - if configMap.Data["LOCAL_VOLUME_PATH"] != "/var/local" { - return fmt.Errorf("unexpected LOCAL_VOLUME_PATH value: got %s, want %s", configMap.Data["LOCAL_VOLUME_PATH"], "/var/local") - } - } - return nil - } - - // And a blueprint handler with repository and sources - handler, _ := setup(t) - - err := handler.SetRepository(blueprintv1alpha1.Repository{ - Url: "git::https://example.com/repo.git", - Ref: blueprintv1alpha1.Reference{Branch: "main"}, - }) - if err != nil { - t.Fatalf("Failed to set repository: %v", err) - } - - expectedSources := []blueprintv1alpha1.Source{ - { - Name: "source1", - Url: "git::https://example.com/source1.git", - Ref: blueprintv1alpha1.Reference{Branch: "main"}, - }, - } - handler.SetSources(expectedSources) - - // When installing the blueprint - err = handler.Install() - - // Then no error should be returned - if err != nil { - t.Fatalf("Expected successful installation, but got error: %v", err) - } - - // And the ConfigMap should be applied with correct values - if !configMapApplied { - t.Fatalf("Expected ConfigMap to be applied, but it was not") - } - }) - - t.Run("EmptyLocalVolumePaths", func(t *testing.T) { - // Given a mock Kubernetes client that validates ConfigMap data - configMapApplied := false - kubeClientResourceOperation = func(kubeconfigPath string, config ResourceOperationConfig) error { - if config.ResourceName == "configmaps" { - configMapApplied = true - configMap, ok := config.ResourceObject.(*corev1.ConfigMap) - if !ok { - return fmt.Errorf("unexpected resource object type") - } - if configMap.Data["LOCAL_VOLUME_PATH"] != "" { - return fmt.Errorf("expected empty LOCAL_VOLUME_PATH value, but got: %s", configMap.Data["LOCAL_VOLUME_PATH"]) - } - } - return nil - } - - // And a blueprint handler with empty volume paths - handler, mocks := setup(t) - mocks.ConfigHandler.LoadConfigString(` -contexts: - mock-context: - cluster: - workers: - volumes: [] -`) - - // When installing the blueprint - err := handler.Install() - - // Then no error should be returned - if err != nil { - t.Fatalf("Expected successful installation, but got error: %v", err) - } - - // And the ConfigMap should be applied with empty LOCAL_VOLUME_PATH - if !configMapApplied { - t.Fatalf("Expected ConfigMap to be applied, but it was not") - } - }) - - t.Run("EmptySourceUrlError", func(t *testing.T) { - // Given a blueprint handler with repository - handler, _ := setup(t) - - err := handler.SetRepository(blueprintv1alpha1.Repository{ - Url: "git::https://example.com/repo.git", - Ref: blueprintv1alpha1.Reference{Branch: "main"}, - }) - if err != nil { - t.Fatalf("Failed to set repository: %v", err) - } - - // And a source with empty URL - expectedSources := []blueprintv1alpha1.Source{ - { - Name: "source1", - Url: "", - Ref: blueprintv1alpha1.Reference{Branch: "main"}, - }, - } - handler.SetSources(expectedSources) - - // When installing the blueprint - err = handler.Install() - - // Then an error about empty source URL should be returned - if err == nil || !strings.Contains(err.Error(), "source URL cannot be empty") { - t.Fatalf("Expected error for empty source URL, but got: %v", err) - } - }) -} - -func TestBlueprintHandler_GetMetadata(t *testing.T) { - setup := func(t *testing.T) (BlueprintHandler, *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 BlueprintHandler: %v", err) - } - return handler, mocks - } - - t.Run("Success", func(t *testing.T) { - // Given a blueprint handler - handler, _ := setup(t) - - // And metadata has been set - expectedMetadata := blueprintv1alpha1.Metadata{ - Name: "test-blueprint", - Description: "A test blueprint", - Authors: []string{"John Doe"}, - } - handler.SetMetadata(expectedMetadata) - - // When getting the metadata - actualMetadata := handler.GetMetadata() - - // Then it should match the expected metadata - if !reflect.DeepEqual(actualMetadata, expectedMetadata) { - t.Errorf("Expected metadata to be %v, but got %v", expectedMetadata, actualMetadata) - } - }) -} - -func TestBlueprintHandler_GetSources(t *testing.T) { - setup := func(t *testing.T) (BlueprintHandler, *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 BlueprintHandler: %v", err) - } - return handler, mocks - } - - t.Run("Success", func(t *testing.T) { - // Given a blueprint handler - handler, _ := setup(t) - - // And sources have been set + t.Run("Success", func(t *testing.T) { + // Given a blueprint handler + handler, _ := setup(t) + + // And sources have been set expectedSources := []blueprintv1alpha1.Source{ { Name: "source1", @@ -2156,6 +1821,7 @@ func TestBlueprintHandler_GetSources(t *testing.T) { func TestBlueprintHandler_GetTerraformComponents(t *testing.T) { setup := func(t *testing.T) (BlueprintHandler, *Mocks) { + t.Helper() mocks := setupMocks(t) handler := NewBlueprintHandler(mocks.Injector) handler.shims = mocks.Shims @@ -2389,1452 +2055,33 @@ func TestBlueprintHandler_SetRepository(t *testing.T) { return handler, mocks } - t.Run("Success", func(t *testing.T) { - // Given a blueprint handler - handler, _ := setup(t) - - // And a test repository configuration - testRepo := blueprintv1alpha1.Repository{ - Url: "git::https://example.com/test-repo.git", - Ref: blueprintv1alpha1.Reference{ - Branch: "feature/test", - }, - } - - // When setting the repository - err := handler.SetRepository(testRepo) - - // Then no error should be returned - if err != nil { - t.Errorf("SetRepository failed: %v", err) - } - - // And the repository should match the test configuration - repo := handler.GetRepository() - if repo.Url != testRepo.Url { - t.Errorf("Expected URL %s, got %s", testRepo.Url, repo.Url) - } - if repo.Ref.Branch != testRepo.Ref.Branch { - t.Errorf("Expected branch %s, got %s", testRepo.Ref.Branch, repo.Ref.Branch) - } - }) -} - -// ============================================================================= -// Test Private Methods -// ============================================================================= - -func TestBlueprintHandler_resolveComponentSources(t *testing.T) { - setup := func(t *testing.T) (BlueprintHandler, *Mocks) { - mocks := setupMocks(t) - handler := NewBlueprintHandler(mocks.Injector) - handler.shims = mocks.Shims - err := handler.Initialize() - if err != nil { - t.Fatalf("Failed to initialize BlueprintHandler: %v", err) - } - return handler, mocks - } - - t.Run("Success", func(t *testing.T) { - // Given a blueprint handler - handler, _ := setup(t) - - // And a mock resource operation that tracks applied sources - var appliedSources []string - kubeClientResourceOperation = func(_ string, config ResourceOperationConfig) error { - if repo, ok := config.ResourceObject.(*sourcev1.GitRepository); ok { - appliedSources = append(appliedSources, repo.Spec.URL) - } - return nil - } - - // And sources have been set - sources := []blueprintv1alpha1.Source{ - { - Name: "source1", - Url: "git::https://example.com/source1.git", - PathPrefix: "terraform", - Ref: blueprintv1alpha1.Reference{Branch: "main"}, - }, - } - handler.SetSources(sources) - - // And terraform components have been set - components := []blueprintv1alpha1.TerraformComponent{ - { - Source: "source1", - Path: "path/to/code", - }, - } - handler.SetTerraformComponents(components) - - // When installing the components - err := handler.Install() - - // Then no error should be returned - if err != nil { - t.Fatalf("Expected successful installation, but got error: %v", err) - } - - // And the source URL should be applied - expectedURL := "git::https://example.com/source1.git" - found := false - for _, url := range appliedSources { - if strings.TrimPrefix(url, "https://") == expectedURL { - found = true - break - } - } - if !found { - t.Errorf("Expected source URL %s to be applied, but it wasn't. Applied sources: %v", expectedURL, appliedSources) - } - }) - - t.Run("DefaultPathPrefix", func(t *testing.T) { - // Given a blueprint handler - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - - // And sources have been set without a path prefix - handler.SetSources([]blueprintv1alpha1.Source{{ - Name: "test-source", - Url: "https://github.com/user/repo.git", - Ref: blueprintv1alpha1.Reference{Branch: "main"}, - }}) - - // And terraform components have been set - handler.SetTerraformComponents([]blueprintv1alpha1.TerraformComponent{{ - Source: "test-source", - Path: "module/path", - }}) - - // When resolving component sources - blueprint := baseHandler.blueprint.DeepCopy() - baseHandler.resolveComponentSources(blueprint) - - // Then the default path prefix should be used - expectedSource := "https://github.com/user/repo.git//terraform/module/path?ref=main" - if blueprint.TerraformComponents[0].Source != expectedSource { - t.Errorf("Expected source URL %s, got %s", expectedSource, blueprint.TerraformComponents[0].Source) - } - }) -} - -func TestBlueprintHandler_resolveComponentPaths(t *testing.T) { - setup := func(t *testing.T) (BlueprintHandler, *Mocks) { - mocks := setupMocks(t) - handler := NewBlueprintHandler(mocks.Injector) - handler.shims = mocks.Shims - err := handler.Initialize() - if err != nil { - t.Fatalf("Failed to initialize BlueprintHandler: %v", err) - } - return handler, mocks - } - - t.Run("Success", func(t *testing.T) { - // Given a blueprint handler - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - - // And terraform components have been set - expectedComponents := []blueprintv1alpha1.TerraformComponent{ - { - Source: "source1", - Path: "path/to/code", - }, - } - handler.SetTerraformComponents(expectedComponents) - - // When resolving component paths - blueprint := baseHandler.blueprint.DeepCopy() - baseHandler.resolveComponentPaths(blueprint) - - // Then each component should have the correct full path - for _, component := range blueprint.TerraformComponents { - expectedPath := filepath.Join(baseHandler.projectRoot, "terraform", component.Path) - if component.FullPath != expectedPath { - t.Errorf("Expected component path to be %v, but got %v", expectedPath, component.FullPath) - } - } - }) - - t.Run("isValidTerraformRemoteSource", func(t *testing.T) { - handler, _ := setup(t) - - // Given a set of test cases for terraform source validation - tests := []struct { - name string - source string - want bool - }{ - {"ValidLocalPath", "/absolute/path/to/module", false}, - {"ValidRelativePath", "./relative/path/to/module", false}, - {"InvalidLocalPath", "/invalid/path/to/module", false}, - {"ValidGitURL", "git::https://github.com/user/repo.git", true}, - {"ValidSSHGitURL", "git@github.com:user/repo.git", true}, - {"ValidHTTPURL", "https://github.com/user/repo.git", true}, - {"ValidHTTPZipURL", "https://example.com/archive.zip", true}, - {"InvalidHTTPURL", "https://example.com/not-a-zip", false}, - {"ValidTerraformRegistry", "registry.terraform.io/hashicorp/consul/aws", true}, - {"ValidGitHubReference", "github.com/hashicorp/terraform-aws-consul", true}, - {"InvalidSource", "invalid-source", false}, - {"VersionFileGitAtURL", "git@github.com:user/version.git", true}, - {"VersionFileGitAtURLWithPath", "git@github.com:user/version.git@v1.0.0", true}, - {"ValidGitLabURL", "git::https://gitlab.com/user/repo.git", true}, - {"ValidSSHGitLabURL", "git@gitlab.com:user/repo.git", true}, - {"ErrorCausingPattern", "[invalid-regex", false}, - } - - // When validating each source - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Then the validation result should match the expected outcome - if got := handler.(*BaseBlueprintHandler).isValidTerraformRemoteSource(tt.source); got != tt.want { - t.Errorf("isValidTerraformRemoteSource(%s) = %v, want %v", tt.source, got, tt.want) - } - }) - } - }) - - t.Run("ValidRemoteSourceWithFullPath", func(t *testing.T) { - // Given a blueprint handler - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - - // And a source with URL and path prefix - handler.SetSources([]blueprintv1alpha1.Source{{ - Name: "test-source", - Url: "https://github.com/user/repo.git", - PathPrefix: "terraform", - Ref: blueprintv1alpha1.Reference{Branch: "main"}, - }}) - - // And a terraform component referencing that source - handler.SetTerraformComponents([]blueprintv1alpha1.TerraformComponent{{ - Source: "test-source", - Path: "module/path", - }}) - - // When resolving component sources and paths - blueprint := baseHandler.blueprint.DeepCopy() - baseHandler.resolveComponentSources(blueprint) - baseHandler.resolveComponentPaths(blueprint) - - // Then the source should be properly resolved - if blueprint.TerraformComponents[0].Source != "https://github.com/user/repo.git//terraform/module/path?ref=main" { - t.Errorf("Unexpected resolved source: %v", blueprint.TerraformComponents[0].Source) - } - - // And the full path should be correctly constructed - expectedPath := filepath.Join(baseHandler.projectRoot, ".windsor", ".tf_modules", "module/path") - if blueprint.TerraformComponents[0].FullPath != expectedPath { - t.Errorf("Unexpected full path: %v", blueprint.TerraformComponents[0].FullPath) - } - }) - - t.Run("RegexpMatchStringError", func(t *testing.T) { - // Given a blueprint handler - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - - // And a mock regexp matcher that returns an error - originalRegexpMatchString := baseHandler.shims.RegexpMatchString - defer func() { baseHandler.shims.RegexpMatchString = originalRegexpMatchString }() - baseHandler.shims.RegexpMatchString = func(pattern, s string) (bool, error) { - return false, fmt.Errorf("mocked error in regexpMatchString") - } - - // When validating an invalid regex pattern - if got := baseHandler.isValidTerraformRemoteSource("[invalid-regex"); got != false { - t.Errorf("isValidTerraformRemoteSource([invalid-regex) = %v, want %v", got, false) - } - }) -} - -func TestBlueprintHandler_processBlueprintData(t *testing.T) { - setup := func(t *testing.T) (BlueprintHandler, *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("ValidBlueprintData", func(t *testing.T) { - // Given a blueprint handler and an empty blueprint - handler, _ := setup(t) - blueprint := &blueprintv1alpha1.Blueprint{ - Sources: []blueprintv1alpha1.Source{}, - TerraformComponents: []blueprintv1alpha1.TerraformComponent{}, - Kustomizations: []blueprintv1alpha1.Kustomization{}, - } - - // And valid blueprint data - data := []byte(` -kind: Blueprint -apiVersion: v1alpha1 -metadata: - name: test-blueprint - description: A test blueprint - authors: - - John Doe -sources: - - name: test-source - url: git::https://example.com/test-repo.git -terraform: - - source: test-source - path: path/to/code -kustomize: - - name: test-kustomization - path: ./kustomize -repository: - url: git::https://example.com/test-repo.git - ref: - branch: main -`) - - // When processing the blueprint data - baseHandler := handler.(*BaseBlueprintHandler) - err := baseHandler.processBlueprintData(data, blueprint) - - // Then no error should be returned - if err != nil { - t.Errorf("processBlueprintData failed: %v", err) - } - - // And the metadata should be correctly set - if blueprint.Metadata.Name != "test-blueprint" { - t.Errorf("Expected name 'test-blueprint', got %s", blueprint.Metadata.Name) - } - if blueprint.Metadata.Description != "A test blueprint" { - t.Errorf("Expected description 'A test blueprint', got %s", blueprint.Metadata.Description) - } - if len(blueprint.Metadata.Authors) != 1 || blueprint.Metadata.Authors[0] != "John Doe" { - t.Errorf("Expected authors ['John Doe'], got %v", blueprint.Metadata.Authors) - } - - // And the sources should be correctly set - if len(blueprint.Sources) != 1 || blueprint.Sources[0].Name != "test-source" { - t.Errorf("Expected one source named 'test-source', got %v", blueprint.Sources) - } - - // And the terraform components should be correctly set - if len(blueprint.TerraformComponents) != 1 || blueprint.TerraformComponents[0].Source != "test-source" { - t.Errorf("Expected one component with source 'test-source', got %v", blueprint.TerraformComponents) - } - - // And the kustomizations should be correctly set - if len(blueprint.Kustomizations) != 1 || blueprint.Kustomizations[0].Name != "test-kustomization" { - t.Errorf("Expected one kustomization named 'test-kustomization', got %v", blueprint.Kustomizations) - } - - // And the repository should be correctly set - if blueprint.Repository.Url != "git::https://example.com/test-repo.git" { - t.Errorf("Expected repository URL 'git::https://example.com/test-repo.git', got %s", blueprint.Repository.Url) - } - if blueprint.Repository.Ref.Branch != "main" { - t.Errorf("Expected repository branch 'main', got %s", blueprint.Repository.Ref.Branch) - } - }) - - t.Run("MissingRequiredFields", func(t *testing.T) { - // Given a blueprint handler and an empty blueprint - handler, _ := setup(t) - blueprint := &blueprintv1alpha1.Blueprint{} - - // And blueprint data with missing required fields - data := []byte(` -kind: Blueprint -apiVersion: v1alpha1 -metadata: - name: "" - description: "" -`) - - // When processing the blueprint data - baseHandler := handler.(*BaseBlueprintHandler) - err := baseHandler.processBlueprintData(data, blueprint) - - // Then no error should be returned since validation is removed - if err != nil { - t.Errorf("Expected no error for missing required fields, got: %v", err) - } - }) - - t.Run("InvalidYAML", func(t *testing.T) { - // Given a blueprint handler and an empty blueprint - handler, _ := setup(t) - blueprint := &blueprintv1alpha1.Blueprint{} - - // And invalid YAML data - data := []byte(`invalid yaml content`) - - // When processing the blueprint data - baseHandler := handler.(*BaseBlueprintHandler) - err := baseHandler.processBlueprintData(data, blueprint) - - // Then an error should be returned - if err == nil { - t.Error("Expected error for invalid YAML, got nil") - } - if !strings.Contains(err.Error(), "error unmarshalling blueprint data") { - t.Errorf("Expected error about unmarshalling, got: %v", err) - } - }) - - t.Run("InvalidKustomization", func(t *testing.T) { - // Given a blueprint handler and an empty blueprint - handler, _ := setup(t) - blueprint := &blueprintv1alpha1.Blueprint{} - - // And blueprint data with an invalid kustomization interval - data := []byte(` -kind: Blueprint -apiVersion: v1alpha1 -metadata: - name: test-blueprint - description: A test blueprint - authors: - - John Doe -kustomize: - - name: test-kustomization - interval: invalid-interval - path: ./kustomize -`) - - // When processing the blueprint data - baseHandler := handler.(*BaseBlueprintHandler) - err := baseHandler.processBlueprintData(data, blueprint) - - // Then an error should be returned - if err == nil { - t.Fatal("Expected error for invalid kustomization, got nil") - } - if !strings.Contains(err.Error(), "error unmarshalling kustomization YAML") { - t.Errorf("Expected error about unmarshalling kustomization YAML, got: %v", err) - } - }) - - t.Run("ErrorMarshallingKustomizationMap", func(t *testing.T) { - // Given a blueprint handler and an empty blueprint - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - blueprint := &blueprintv1alpha1.Blueprint{} - - // And a mock YAML marshaller that returns an error - baseHandler.shims.YamlMarshalNonNull = func(v any) ([]byte, error) { - if _, ok := v.(map[string]any); ok { - return nil, fmt.Errorf("mock kustomization map marshal error") - } - return []byte{}, nil - } - - // And valid blueprint data - data := []byte(` -kind: Blueprint -apiVersion: v1alpha1 -metadata: - name: test-blueprint - description: Test description - authors: - - Test Author -kustomize: - - name: test-kustomization - path: ./test -`) - - // When processing the blueprint data - err := baseHandler.processBlueprintData(data, blueprint) - - // Then an error should be returned - if err == nil { - t.Error("Expected error for kustomization map marshalling, got nil") - } - if !strings.Contains(err.Error(), "error marshalling kustomization map") { - t.Errorf("Expected error about marshalling kustomization map, got: %v", err) - } - }) - - t.Run("InvalidKustomizationIntervalZero", func(t *testing.T) { - // Given a blueprint handler and an empty blueprint - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - blueprint := &blueprintv1alpha1.Blueprint{} - - // And blueprint data with a zero kustomization interval - data := []byte(` -kind: Blueprint -apiVersion: v1alpha1 -metadata: - name: test-blueprint - description: Test description - authors: - - Test Author -kustomize: - - apiVersion: kustomize.toolkit.fluxcd.io/v1 - kind: Kustomization - metadata: - name: test-kustomization - spec: - interval: 0s - path: ./test -`) - - // When processing the blueprint data - err := baseHandler.processBlueprintData(data, blueprint) - - // Then no error should be returned - if err != nil { - t.Errorf("Expected no error for kustomization with zero interval, got: %v", err) - } - }) - - t.Run("InvalidKustomizationIntervalValue", func(t *testing.T) { - // Given a blueprint handler and an empty blueprint - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - blueprint := &blueprintv1alpha1.Blueprint{} - - // And blueprint data with an invalid kustomization interval - data := []byte(` -kind: Blueprint -apiVersion: v1alpha1 -metadata: - name: test-blueprint - description: Test description - authors: - - Test Author -kustomize: - - apiVersion: kustomize.toolkit.fluxcd.io/v1 - kind: Kustomization - metadata: - name: test-kustomization - spec: - interval: "invalid" - path: ./test -`) - // When processing the blueprint data - err := baseHandler.processBlueprintData(data, blueprint) - - // Then no error should be returned - if err != nil { - t.Errorf("Expected no error for invalid kustomization interval value, got: %v", err) - } - }) - - t.Run("MissingDescription", func(t *testing.T) { - // Given a blueprint handler and data with missing description - handler, _ := setup(t) - blueprint := &blueprintv1alpha1.Blueprint{} - - data := []byte(` -kind: Blueprint -apiVersion: v1alpha1 -metadata: - name: test-blueprint - authors: - - John Doe -`) - - // When processing the blueprint data - baseHandler := handler.(*BaseBlueprintHandler) - err := baseHandler.processBlueprintData(data, blueprint) - - // Then no error should be returned since validation is removed - if err != nil { - t.Errorf("Expected no error for missing description, got: %v", err) - } - }) - - t.Run("MissingAuthors", func(t *testing.T) { - // Given a blueprint handler and data with empty authors list - handler, _ := setup(t) - blueprint := &blueprintv1alpha1.Blueprint{} - - data := []byte(` -kind: Blueprint -apiVersion: v1alpha1 -metadata: - name: test-blueprint - description: A test blueprint - authors: [] -`) - - // When processing the blueprint data - baseHandler := handler.(*BaseBlueprintHandler) - err := baseHandler.processBlueprintData(data, blueprint) - - // Then no error should be returned since validation is removed - if err != nil { - t.Errorf("Expected no error for empty authors list, got: %v", err) - } - }) -} - -// ============================================================================= -// Test Helper Functions -// ============================================================================= - -func TestYamlMarshalWithDefinedPaths(t *testing.T) { - setup := func(t *testing.T) (BlueprintHandler, *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 BlueprintHandler: %v", err) - } - return handler, mocks - } - - t.Run("IgnoreYamlMinusTag", func(t *testing.T) { - // Given a struct with a YAML minus tag - type testStruct struct { - Public string `yaml:"public"` - private string `yaml:"-"` - } - input := testStruct{Public: "value", private: "ignored"} - - // When marshalling the struct - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - result, err := baseHandler.yamlMarshalWithDefinedPaths(input) - - // Then no error should be returned - if err != nil { - t.Errorf("yamlMarshalWithDefinedPaths failed: %v", err) - } - - // And the public field should be included - if !strings.Contains(string(result), "public: value") { - t.Errorf("Expected 'public: value' in result, got: %s", string(result)) - } - - // And the ignored field should be excluded - if strings.Contains(string(result), "ignored") { - t.Errorf("Expected 'ignored' not to be in result, got: %s", string(result)) - } - }) - - t.Run("NilInput", func(t *testing.T) { - // When marshalling nil input - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - _, err := baseHandler.yamlMarshalWithDefinedPaths(nil) - - // Then an error should be returned - if err == nil { - t.Error("Expected error for nil input, got nil") - } - - // And the error message should be appropriate - if !strings.Contains(err.Error(), "invalid input: nil value") { - t.Errorf("Expected error about nil input, got: %v", err) - } - }) - - t.Run("EmptySlice", func(t *testing.T) { - // Given an empty slice - input := []string{} - - // When marshalling the slice - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - result, err := baseHandler.yamlMarshalWithDefinedPaths(input) - - // Then no error should be returned - if err != nil { - t.Errorf("yamlMarshalWithDefinedPaths failed: %v", err) - } - - // And the result should be an empty array - if string(result) != "[]\n" { - t.Errorf("Expected '[]\n', got: %s", string(result)) - } - }) - - t.Run("NoYamlTag", func(t *testing.T) { - // Given a struct with no YAML tags - type testStruct struct { - Field string - } - input := testStruct{Field: "value"} - - // When marshalling the struct - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - result, err := baseHandler.yamlMarshalWithDefinedPaths(input) - - // Then no error should be returned - if err != nil { - t.Errorf("yamlMarshalWithDefinedPaths failed: %v", err) - } - - // And the field name should be used as is - if !strings.Contains(string(result), "Field: value") { - t.Errorf("Expected 'Field: value' in result, got: %s", string(result)) - } - }) - - t.Run("CustomYamlTag", func(t *testing.T) { - // Given a struct with a custom YAML tag - type testStruct struct { - Field string `yaml:"custom_field"` - } - input := testStruct{Field: "value"} - - // When marshalling the struct - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - result, err := baseHandler.yamlMarshalWithDefinedPaths(input) - - // Then no error should be returned - if err != nil { - t.Errorf("yamlMarshalWithDefinedPaths failed: %v", err) - } - - // And the custom field name should be used - if !strings.Contains(string(result), "custom_field: value") { - t.Errorf("Expected 'custom_field: value' in result, got: %s", string(result)) - } - }) - - t.Run("MapWithCustomTags", func(t *testing.T) { - // Given a map with nested structs using custom YAML tags - type nestedStruct struct { - Value string `yaml:"custom_value"` - } - input := map[string]nestedStruct{ - "key": {Value: "test"}, - } - - // When marshalling the map - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - result, err := baseHandler.yamlMarshalWithDefinedPaths(input) - - // Then no error should be returned - if err != nil { - t.Errorf("yamlMarshalWithDefinedPaths failed: %v", err) - } - - // And the map key should be preserved - if !strings.Contains(string(result), "key:") { - t.Errorf("Expected 'key:' in result, got: %s", string(result)) - } - - // And the nested custom field name should be used - if !strings.Contains(string(result), " custom_value: test") { - t.Errorf("Expected ' custom_value: test' in result, got: %s", string(result)) - } - }) - - t.Run("DefaultFieldName", func(t *testing.T) { - // Given a struct with default field names - data := struct { - Field string - }{ - Field: "value", - } - - // When marshalling the struct - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - result, err := baseHandler.yamlMarshalWithDefinedPaths(data) - - // Then no error should be returned - if err != nil { - t.Errorf("yamlMarshalWithDefinedPaths failed: %v", err) - } - - // And the default field name should be used - if !strings.Contains(string(result), "Field: value") { - t.Errorf("Expected 'Field: value' in result, got: %s", string(result)) - } - }) - - t.Run("NilInput", func(t *testing.T) { - // When marshalling nil input - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - _, err := baseHandler.yamlMarshalWithDefinedPaths(nil) - - // Then an error should be returned - if err == nil { - t.Error("Expected error for nil input, got nil") - } - - // And the error message should be appropriate - if !strings.Contains(err.Error(), "invalid input: nil value") { - t.Errorf("Expected error about nil input, got: %v", err) - } - }) - - t.Run("FuncType", func(t *testing.T) { - // When marshalling a function type - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - _, err := baseHandler.yamlMarshalWithDefinedPaths(func() {}) - - // Then an error should be returned - if err == nil { - t.Error("Expected error for func type, got nil") - } - - // And the error message should be appropriate - if !strings.Contains(err.Error(), "unsupported value type func") { - t.Errorf("Expected error about unsupported value type, got: %v", err) - } - }) - - t.Run("UnsupportedType", func(t *testing.T) { - // When marshalling an unsupported type - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - _, err := baseHandler.yamlMarshalWithDefinedPaths(make(chan int)) - - // Then an error should be returned - if err == nil { - t.Error("Expected error for unsupported type, got nil") - } - - // And the error message should be appropriate - if !strings.Contains(err.Error(), "unsupported value type") { - t.Errorf("Expected error about unsupported value type, got: %v", err) - } - }) - - t.Run("MapWithNilValues", func(t *testing.T) { - // Given a map with nil values - input := map[string]any{ - "key1": nil, - "key2": "value2", - } - - // When marshalling the map - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - result, err := baseHandler.yamlMarshalWithDefinedPaths(input) - - // Then no error should be returned - if err != nil { - t.Errorf("yamlMarshalWithDefinedPaths failed: %v", err) - } - - // And nil values should be represented as null - if !strings.Contains(string(result), "key1: null") { - t.Errorf("Expected 'key1: null' in result, got: %s", string(result)) - } - - // And non-nil values should be preserved - if !strings.Contains(string(result), "key2: value2") { - t.Errorf("Expected 'key2: value2' in result, got: %s", string(result)) - } - }) - - t.Run("SliceWithNilValues", func(t *testing.T) { - // Given a slice with nil values - input := []any{nil, "value", nil} - - // When marshalling the slice - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - result, err := baseHandler.yamlMarshalWithDefinedPaths(input) - - // Then no error should be returned - if err != nil { - t.Errorf("yamlMarshalWithDefinedPaths failed: %v", err) - } - - // And nil values should be represented as null - if !strings.Contains(string(result), "- null") { - t.Errorf("Expected '- null' in result, got: %s", string(result)) - } - - // And non-nil values should be preserved - if !strings.Contains(string(result), "- value") { - t.Errorf("Expected '- value' in result, got: %s", string(result)) - } - }) - - t.Run("StructWithPrivateFields", func(t *testing.T) { - // Given a struct with both public and private fields - type testStruct struct { - Public string - private string - } - input := testStruct{Public: "value", private: "ignored"} - - // When marshalling the struct - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - result, err := baseHandler.yamlMarshalWithDefinedPaths(input) - - // Then no error should be returned - if err != nil { - t.Errorf("yamlMarshalWithDefinedPaths failed: %v", err) - } - - // And public fields should be included - if !strings.Contains(string(result), "Public: value") { - t.Errorf("Expected 'Public: value' in result, got: %s", string(result)) - } - - // And private fields should be excluded - if strings.Contains(string(result), "private") { - t.Errorf("Expected 'private' not to be in result, got: %s", string(result)) - } - }) - - t.Run("StructWithYamlTag", func(t *testing.T) { - // Given a struct with a YAML tag - type testStruct struct { - Field string `yaml:"custom_name"` - } - input := testStruct{Field: "value"} - - // When marshalling the struct - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - result, err := baseHandler.yamlMarshalWithDefinedPaths(input) - - // Then no error should be returned - if err != nil { - t.Errorf("yamlMarshalWithDefinedPaths failed: %v", err) - } - - // And the custom field name should be used - if !strings.Contains(string(result), "custom_name: value") { - t.Errorf("Expected 'custom_name: value' in result, got: %s", string(result)) - } - }) - - t.Run("NestedStructs", func(t *testing.T) { - // Given nested structs - type nested struct { - Value string - } - type parent struct { - Nested nested - } - input := parent{Nested: nested{Value: "test"}} - - // When marshalling the nested structs - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - result, err := baseHandler.yamlMarshalWithDefinedPaths(input) - - // Then no error should be returned - if err != nil { - t.Errorf("yamlMarshalWithDefinedPaths failed: %v", err) - } - - // And the parent field should be included - if !strings.Contains(string(result), "Nested:") { - t.Errorf("Expected 'Nested:' in result, got: %s", string(result)) - } - - // And the nested field should be properly indented - if !strings.Contains(string(result), " Value: test") { - t.Errorf("Expected ' Value: test' in result, got: %s", string(result)) - } - }) - - t.Run("NumericTypes", func(t *testing.T) { - // Given a struct with various numeric types - type numbers struct { - Int int `yaml:"int"` - Int8 int8 `yaml:"int8"` - Int16 int16 `yaml:"int16"` - Int32 int32 `yaml:"int32"` - Int64 int64 `yaml:"int64"` - Uint uint `yaml:"uint"` - Uint8 uint8 `yaml:"uint8"` - Uint16 uint16 `yaml:"uint16"` - Uint32 uint32 `yaml:"uint32"` - Uint64 uint64 `yaml:"uint64"` - Float32 float32 `yaml:"float32"` - Float64 float64 `yaml:"float64"` - } - input := numbers{ - Int: 1, Int8: 2, Int16: 3, Int32: 4, Int64: 5, - Uint: 6, Uint8: 7, Uint16: 8, Uint32: 9, Uint64: 10, - Float32: 11.1, Float64: 12.2, - } - - // When marshalling the struct - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - result, err := baseHandler.yamlMarshalWithDefinedPaths(input) - - // Then no error should be returned - if err != nil { - t.Errorf("yamlMarshalWithDefinedPaths failed: %v", err) - } - - // And all numeric values should be correctly represented - for _, expected := range []string{ - "int: 1", "int8: 2", "int16: 3", "int32: 4", "int64: 5", - "uint: 6", "uint8: 7", "uint16: 8", "uint32: 9", "uint64: 10", - "float32: 11.1", "float64: 12.2", - } { - if !strings.Contains(string(result), expected) { - t.Errorf("Expected '%s' in result, got: %s", expected, string(result)) - } - } - }) - - t.Run("BooleanType", func(t *testing.T) { - // Given a struct with boolean fields - type boolStruct struct { - True bool `yaml:"true"` - False bool `yaml:"false"` - } - input := boolStruct{True: true, False: false} - - // When marshalling the struct - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - result, err := baseHandler.yamlMarshalWithDefinedPaths(input) - - // Then no error should be returned - if err != nil { - t.Errorf("yamlMarshalWithDefinedPaths failed: %v", err) - } - - // And the boolean values should be correctly represented - if !strings.Contains(string(result), `"true": true`) { - t.Errorf("Expected '\"true\": true' in result, got: %s", string(result)) - } - if !strings.Contains(string(result), `"false": false`) { - t.Errorf("Expected '\"false\": false' in result, got: %s", string(result)) - } - }) - - t.Run("NilPointerAndInterface", func(t *testing.T) { - // Given a struct with nil pointers and interfaces - type testStruct struct { - NilPtr *string `yaml:"nil_ptr"` - NilInterface any `yaml:"nil_interface"` - NilMap map[string]string `yaml:"nil_map"` - NilSlice []string `yaml:"nil_slice"` - NilStruct *struct{ Field int } `yaml:"nil_struct"` - } - input := testStruct{} - - // When marshalling the struct - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - result, err := baseHandler.yamlMarshalWithDefinedPaths(input) - - // Then no error should be returned - if err != nil { - t.Errorf("yamlMarshalWithDefinedPaths failed: %v", err) - } - - // And nil interfaces should be represented as empty objects - if !strings.Contains(string(result), "nil_interface: {}") { - t.Errorf("Expected 'nil_interface: {}' in result, got: %s", string(result)) - } - - // And nil slices should be represented as empty arrays - if !strings.Contains(string(result), "nil_slice: []") { - t.Errorf("Expected 'nil_slice: []' in result, got: %s", string(result)) - } - - // And nil maps should be represented as empty objects - if !strings.Contains(string(result), "nil_map: {}") { - t.Errorf("Expected 'nil_map: {}' in result, got: %s", string(result)) - } - - // And nil structs should be represented as empty objects - if !strings.Contains(string(result), "nil_struct: {}") { - t.Errorf("Expected 'nil_struct: {}' in result, got: %s", string(result)) - } - }) - - t.Run("SliceWithNilElements", func(t *testing.T) { - // Given a slice with nil elements - type elem struct { - Field string - } - input := []*elem{nil, {Field: "value"}, nil} - - // When marshalling the slice - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - result, err := baseHandler.yamlMarshalWithDefinedPaths(input) - - // Then no error should be returned - if err != nil { - t.Errorf("yamlMarshalWithDefinedPaths failed: %v", err) - } - - // And non-nil elements should be correctly represented - if !strings.Contains(string(result), "Field: value") { - t.Errorf("Expected 'Field: value' in result, got: %s", string(result)) - } - }) - - t.Run("MapWithNilValues", func(t *testing.T) { - // Given a map with nil and non-nil values - input := map[string]any{ - "nil": nil, - "nonnil": "value", - "nilptr": (*string)(nil), - } - - // When marshalling the map to YAML - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - result, err := baseHandler.yamlMarshalWithDefinedPaths(input) - - // Then no error should be returned - if err != nil { - t.Errorf("yamlMarshalWithDefinedPaths failed: %v", err) - } - - // And nil values should be represented as null - if !strings.Contains(string(result), "nil: null") { - t.Errorf("Expected 'nil: null' in result, got: %s", string(result)) - } - - // And non-nil values should be preserved - if !strings.Contains(string(result), "nonnil: value") { - t.Errorf("Expected 'nonnil: value' in result, got: %s", string(result)) - } - }) - - t.Run("UnsupportedType", func(t *testing.T) { - // Given an unsupported channel type - input := make(chan int) - - // When attempting to marshal the channel - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - _, err := baseHandler.yamlMarshalWithDefinedPaths(input) - - // Then an error should be returned - if err == nil { - t.Error("Expected error for unsupported type, got nil") - } - - // And the error should indicate the unsupported type - if !strings.Contains(err.Error(), "unsupported value type chan") { - t.Errorf("Expected error about unsupported type, got: %v", err) - } - }) - - t.Run("FunctionType", func(t *testing.T) { - // Given a function type - input := func() {} - - // When attempting to marshal the function - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - _, err := baseHandler.yamlMarshalWithDefinedPaths(input) - - // Then an error should be returned - if err == nil { - t.Error("Expected error for function type, got nil") - } - - // And the error should indicate the unsupported type - if !strings.Contains(err.Error(), "unsupported value type func") { - t.Errorf("Expected error about unsupported type, got: %v", err) - } - }) - - t.Run("ErrorInSliceConversion", func(t *testing.T) { - // Given a slice containing an unsupported type - input := []any{make(chan int)} - - // When attempting to marshal the slice - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - _, err := baseHandler.yamlMarshalWithDefinedPaths(input) - - // Then an error should be returned - if err == nil { - t.Error("Expected error for slice with unsupported type, got nil") - } - - // And the error should indicate the slice conversion issue - if !strings.Contains(err.Error(), "error converting slice element") { - t.Errorf("Expected error about slice conversion, got: %v", err) - } - }) - - t.Run("ErrorInMapConversion", func(t *testing.T) { - // Given a map containing an unsupported type - input := map[string]any{ - "channel": make(chan int), - } - - // When attempting to marshal the map - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - _, err := baseHandler.yamlMarshalWithDefinedPaths(input) - - // Then an error should be returned - if err == nil { - t.Error("Expected error for map with unsupported type, got nil") - } - - // And the error should indicate the map conversion issue - if !strings.Contains(err.Error(), "error converting map value") { - t.Errorf("Expected error about map conversion, got: %v", err) - } - }) - - t.Run("ErrorInStructFieldConversion", func(t *testing.T) { - // Given a struct containing an unsupported field type - type testStruct struct { - Channel chan int - } - input := testStruct{Channel: make(chan int)} - - // When attempting to marshal the struct - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - _, err := baseHandler.yamlMarshalWithDefinedPaths(input) - - // Then an error should be returned - if err == nil { - t.Error("Expected error for struct with unsupported field type, got nil") - } - - // And the error should indicate the field conversion issue - if !strings.Contains(err.Error(), "error converting field") { - t.Errorf("Expected error about field conversion, got: %v", err) - } - }) - - t.Run("YamlMarshalError", func(t *testing.T) { - // Given a blueprint handler - handler, _ := setup(t) - baseHandler := handler.(*BaseBlueprintHandler) - - // And a mock YAML marshaller that returns an error - baseHandler.shims.YamlMarshal = func(v any) ([]byte, error) { - return nil, fmt.Errorf("mock yaml marshal error") - } - - // And a simple struct to marshal - input := struct{ Field string }{"value"} - - // When marshalling the struct - _, err := baseHandler.yamlMarshalWithDefinedPaths(input) - - // Then an error should be returned - if err == nil { - t.Error("Expected error from yaml marshal, got nil") - } - - // And the error should indicate the YAML marshalling issue - if !strings.Contains(err.Error(), "error marshalling yaml") { - t.Errorf("Expected error about yaml marshalling, got: %v", err) - } - }) -} - -func TestTLACode(t *testing.T) { - // Given a mock Jsonnet VM that returns an error about missing authors - vm := NewMockJsonnetVM(func(filename, snippet string) (string, error) { - return "", fmt.Errorf("blueprint has no authors") - }) - - // When evaluating an empty snippet - _, err := vm.EvaluateAnonymousSnippet("test.jsonnet", "") - - // Then an error about missing authors should be returned - if err == nil || !strings.Contains(err.Error(), "blueprint has no authors") { - t.Errorf("expected error containing 'blueprint has no authors', got %v", err) - } -} - -func TestBaseBlueprintHandler_calculateMaxWaitTime(t *testing.T) { - t.Run("EmptyKustomizations", func(t *testing.T) { - // Given a blueprint handler with no kustomizations - handler := &BaseBlueprintHandler{ - blueprint: blueprintv1alpha1.Blueprint{ - Kustomizations: []blueprintv1alpha1.Kustomization{}, - }, - } - - // When calculating max wait time - waitTime := handler.calculateMaxWaitTime() - - // Then it should return 0 since there are no kustomizations - if waitTime != 0 { - t.Errorf("expected 0 duration, got %v", waitTime) - } - }) - - t.Run("SingleKustomization", func(t *testing.T) { - // Given a blueprint handler with a single kustomization - customTimeout := 2 * time.Minute - handler := &BaseBlueprintHandler{ - blueprint: blueprintv1alpha1.Blueprint{ - Kustomizations: []blueprintv1alpha1.Kustomization{ - { - Name: "test-kustomization", - Timeout: &metav1.Duration{ - Duration: customTimeout, - }, - }, - }, - }, - } - - // When calculating max wait time - waitTime := handler.calculateMaxWaitTime() - - // Then it should return the kustomization's timeout - if waitTime != customTimeout { - t.Errorf("expected timeout %v, got %v", customTimeout, waitTime) - } - }) - - t.Run("LinearDependencies", func(t *testing.T) { - // Given a blueprint handler with linear dependencies - timeout1 := 1 * time.Minute - timeout2 := 2 * time.Minute - timeout3 := 3 * time.Minute - handler := &BaseBlueprintHandler{ - blueprint: blueprintv1alpha1.Blueprint{ - Kustomizations: []blueprintv1alpha1.Kustomization{ - { - Name: "kustomization-1", - Timeout: &metav1.Duration{ - Duration: timeout1, - }, - DependsOn: []string{"kustomization-2"}, - }, - { - Name: "kustomization-2", - Timeout: &metav1.Duration{ - Duration: timeout2, - }, - DependsOn: []string{"kustomization-3"}, - }, - { - Name: "kustomization-3", - Timeout: &metav1.Duration{ - Duration: timeout3, - }, - }, - }, - }, - } - - // When calculating max wait time - waitTime := handler.calculateMaxWaitTime() - - // Then it should return the sum of all timeouts - expectedTime := timeout1 + timeout2 + timeout3 - if waitTime != expectedTime { - t.Errorf("expected timeout %v, got %v", expectedTime, waitTime) - } - }) - - t.Run("BranchingDependencies", func(t *testing.T) { - // Given a blueprint handler with branching dependencies - timeout1 := 1 * time.Minute - timeout2 := 2 * time.Minute - timeout3 := 3 * time.Minute - timeout4 := 4 * time.Minute - handler := &BaseBlueprintHandler{ - blueprint: blueprintv1alpha1.Blueprint{ - Kustomizations: []blueprintv1alpha1.Kustomization{ - { - Name: "kustomization-1", - Timeout: &metav1.Duration{ - Duration: timeout1, - }, - DependsOn: []string{"kustomization-2", "kustomization-3"}, - }, - { - Name: "kustomization-2", - Timeout: &metav1.Duration{ - Duration: timeout2, - }, - DependsOn: []string{"kustomization-4"}, - }, - { - Name: "kustomization-3", - Timeout: &metav1.Duration{ - Duration: timeout3, - }, - DependsOn: []string{"kustomization-4"}, - }, - { - Name: "kustomization-4", - Timeout: &metav1.Duration{ - Duration: timeout4, - }, - }, - }, - }, - } - - // When calculating max wait time - waitTime := handler.calculateMaxWaitTime() - - // Then it should return the longest path (1 -> 3 -> 4) - expectedTime := timeout1 + timeout3 + timeout4 - if waitTime != expectedTime { - t.Errorf("expected timeout %v, got %v", expectedTime, waitTime) - } - }) - - t.Run("CircularDependencies", func(t *testing.T) { - // Given a blueprint handler with circular dependencies - timeout1 := 1 * time.Minute - timeout2 := 2 * time.Minute - timeout3 := 3 * time.Minute - handler := &BaseBlueprintHandler{ - blueprint: blueprintv1alpha1.Blueprint{ - Kustomizations: []blueprintv1alpha1.Kustomization{ - { - Name: "kustomization-1", - Timeout: &metav1.Duration{ - Duration: timeout1, - }, - DependsOn: []string{"kustomization-2"}, - }, - { - Name: "kustomization-2", - Timeout: &metav1.Duration{ - Duration: timeout2, - }, - DependsOn: []string{"kustomization-3"}, - }, - { - Name: "kustomization-3", - Timeout: &metav1.Duration{ - Duration: timeout3, - }, - DependsOn: []string{"kustomization-1"}, - }, - }, + t.Run("Success", func(t *testing.T) { + // Given a blueprint handler + handler, _ := setup(t) + + // And a test repository configuration + testRepo := blueprintv1alpha1.Repository{ + Url: "git::https://example.com/test-repo.git", + Ref: blueprintv1alpha1.Reference{ + Branch: "feature/test", }, } - // When calculating max wait time - waitTime := handler.calculateMaxWaitTime() + // When setting the repository + err := handler.SetRepository(testRepo) + + // Then no error should be returned + if err != nil { + t.Errorf("SetRepository failed: %v", err) + } - // Then it should return the sum of all timeouts in the cycle (1+2+3+3) - expectedTime := timeout1 + timeout2 + timeout3 + timeout3 - if waitTime != expectedTime { - t.Errorf("expected timeout %v, got %v", expectedTime, waitTime) + // And the repository should match the test configuration + repo := handler.GetRepository() + if repo.Url != testRepo.Url { + t.Errorf("Expected URL %s, got %s", testRepo.Url, repo.Url) + } + if repo.Ref.Branch != testRepo.Ref.Branch { + t.Errorf("Expected branch %s, got %s", testRepo.Ref.Branch, repo.Ref.Branch) } }) } @@ -3865,7 +2112,7 @@ func TestBaseBlueprintHandler_WaitForKustomizations(t *testing.T) { } // When waiting for kustomizations to be ready - err := handler.WaitForKustomizations() + err := handler.WaitForKustomizations("") // Then no error should be returned if err != nil { @@ -3898,7 +2145,7 @@ func TestBaseBlueprintHandler_WaitForKustomizations(t *testing.T) { } // When waiting for kustomizations to be ready - err := handler.WaitForKustomizations() + err := handler.WaitForKustomizations("") // Then a timeout error should be returned if err == nil || !strings.Contains(err.Error(), "timeout waiting for kustomizations") { @@ -3930,7 +2177,7 @@ func TestBaseBlueprintHandler_WaitForKustomizations(t *testing.T) { } // When waiting for kustomizations to be ready - err := handler.WaitForKustomizations() + err := handler.WaitForKustomizations("") // Then a Git repository error should be returned if err == nil || !strings.Contains(err.Error(), "git repository error") { @@ -3962,7 +2209,7 @@ func TestBaseBlueprintHandler_WaitForKustomizations(t *testing.T) { } // When waiting for kustomizations to be ready - err := handler.WaitForKustomizations() + err := handler.WaitForKustomizations("") // Then a kustomization error should be returned if err == nil || !strings.Contains(err.Error(), "kustomization error") { @@ -4001,7 +2248,7 @@ func TestBaseBlueprintHandler_WaitForKustomizations(t *testing.T) { } // When waiting for kustomizations to be ready - err := handler.WaitForKustomizations() + err := handler.WaitForKustomizations("") // Then no error should be returned if err != nil { @@ -4038,7 +2285,7 @@ func TestBaseBlueprintHandler_WaitForKustomizations(t *testing.T) { } // When waiting for kustomizations to be ready - err := handler.WaitForKustomizations() + err := handler.WaitForKustomizations("") // Then no error should be returned if err != nil { @@ -4070,7 +2317,7 @@ func TestBaseBlueprintHandler_WaitForKustomizations(t *testing.T) { } // When waiting for kustomizations to be ready - err := handler.WaitForKustomizations() + err := handler.WaitForKustomizations("") // Then a Git repository error should be returned with failure count expectedMsg := fmt.Sprintf("after %d consecutive failures", constants.DEFAULT_KUSTOMIZATION_WAIT_MAX_FAILURES) @@ -4103,7 +2350,7 @@ func TestBaseBlueprintHandler_WaitForKustomizations(t *testing.T) { } // When waiting for kustomizations to be ready - err := handler.WaitForKustomizations() + err := handler.WaitForKustomizations("") // Then a kustomization error should be returned with failure count expectedMsg := fmt.Sprintf("after %d consecutive failures", constants.DEFAULT_KUSTOMIZATION_WAIT_MAX_FAILURES) @@ -4113,54 +2360,616 @@ func TestBaseBlueprintHandler_WaitForKustomizations(t *testing.T) { }) } -func TestBaseBlueprintHandler_loadPlatformTemplate(t *testing.T) { - t.Run("ValidPlatforms", func(t *testing.T) { - // Given a BaseBlueprintHandler - handler := &BaseBlueprintHandler{} - - // When loading templates for valid platforms - platforms := []string{"local", "metal", "aws", "azure"} - for _, platform := range platforms { - // Then the template should be loaded successfully - template, err := handler.loadPlatformTemplate(platform) - if err != nil { - t.Errorf("Expected no error for platform %s, got: %v", platform, err) +func TestBaseBlueprintHandler_Down(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) + } + // Set fast poll interval and short timeout for all kustomizations + handler.kustomizationWaitPollInterval = 1 * time.Millisecond + for i := range handler.blueprint.Kustomizations { + if handler.blueprint.Kustomizations[i].Timeout == nil { + handler.blueprint.Kustomizations[i].Timeout = &metav1.Duration{Duration: 5 * time.Millisecond} + } else { + handler.blueprint.Kustomizations[i].Timeout.Duration = 5 * time.Millisecond } - if len(template) == 0 { - t.Errorf("Expected non-empty template for platform %s", platform) + } + return handler, mocks + } + + t.Run("NoKustomizationsWithCleanup", func(t *testing.T) { + // Given a handler with kustomizations that have no cleanup paths + handler, _ := setup(t) + baseHandler := handler + baseHandler.blueprint.Kustomizations = []blueprintv1alpha1.Kustomization{ + {Name: "k1", Cleanup: nil}, + {Name: "k2", Cleanup: []string{}}, + } + + // Patch kubeClientResourceOperation to panic if called (simulate applyKustomization) + origKubeClientResourceOperation := kubeClientResourceOperation + defer func() { kubeClientResourceOperation = origKubeClientResourceOperation }() + kubeClientResourceOperation = func(string, ResourceOperationConfig) error { + panic("kubeClientResourceOperation should not be called") + } + + // When calling Down + err := baseHandler.Down() + + // Then no error should be returned + if err != nil { + t.Errorf("expected nil error, got %v", err) + } + }) + + t.Run("SingleKustomizationWithCleanup", func(t *testing.T) { + // Given a handler with a single kustomization with a cleanup path + handler, _ := setup(t) + baseHandler := handler + baseHandler.blueprint.Kustomizations = []blueprintv1alpha1.Kustomization{ + {Name: "k1", Cleanup: []string{"cleanup/path"}}, + } + // 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 } + 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 once + if len(calledConfigs) != 1 { + t.Fatalf("expected 1 call to kubeClientResourceOperation, got %d", len(calledConfigs)) + } + + // And the resource name should be k1-cleanup + if calledConfigs[0].ResourceInstanceName != "k1-cleanup" { + t.Errorf("expected ResourceInstanceName 'k1-cleanup', got '%s'", calledConfigs[0].ResourceInstanceName) } }) - t.Run("InvalidPlatform", func(t *testing.T) { - // Given a BaseBlueprintHandler - handler := &BaseBlueprintHandler{} + 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 + } + } + + // 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 + } + 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 loading template for invalid platform - template, err := handler.loadPlatformTemplate("invalid-platform") + // When calling Down + err := baseHandler.Down() - // Then no error should occur but template should be empty + // Then no error should be returned if err != nil { - t.Errorf("Expected no error for invalid platform, got: %v", err) + 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) + } + 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") + } + 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) + } + }) + + t.Run("WaitForKustomizationsError", 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"}}, + } + + // 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 + } + } + + // Patch kubeClientResourceOperation to succeed + origKubeClientResourceOperation := kubeClientResourceOperation + kubeClientResourceOperation = func(_ string, config ResourceOperationConfig) error { + return nil + } + defer func() { kubeClientResourceOperation = origKubeClientResourceOperation }() + + // Patch checkKustomizationStatus to error + origCheckKustomizationStatus := checkKustomizationStatus + checkKustomizationStatus = func(_ string, names []string) (map[string]bool, error) { + return nil, fmt.Errorf("wait error") + } + 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(), "timeout waiting for kustomizations") { + t.Errorf("Expected timeout error, got: %v", err) + } + }) + + 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) + baseHandler := handler + baseHandler.blueprint.Kustomizations = []blueprintv1alpha1.Kustomization{ + {Name: "k1"}, + } + + // Patch kubeClient to return error on DELETE + origKubeClient := kubeClient + kubeClient = func(kubeconfig string, req KubeRequestConfig) error { + if req.Method == "DELETE" { + return fmt.Errorf("delete error") + } + return nil + } + defer func() { kubeClient = origKubeClient }() + + // Patch kubeClientResourceOperation to no-op + origKubeClientResourceOperation := kubeClientResourceOperation + kubeClientResourceOperation = func(_ string, config ResourceOperationConfig) error { + 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 }() + + // 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(), "delete error") { + 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) + } + }) + + // Error paths for WaitForKustomizationsError and ApplyKustomizationError + 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") + } + 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 + } + 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("WaitForKustomizationsError", 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"}}, + } + + // 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 + } + } + + // Patch kubeClientResourceOperation to succeed + origKubeClientResourceOperation := kubeClientResourceOperation + kubeClientResourceOperation = func(_ string, config ResourceOperationConfig) error { + return nil + } + defer func() { kubeClientResourceOperation = origKubeClientResourceOperation }() + + // Patch checkKustomizationStatus to error + origCheckKustomizationStatus := checkKustomizationStatus + checkKustomizationStatus = func(_ string, names []string) (map[string]bool, error) { + return nil, fmt.Errorf("wait error") + } + 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 len(template) != 0 { - t.Errorf("Expected empty template for invalid platform, got length: %d", len(template)) + if !strings.Contains(err.Error(), "timeout waiting for kustomizations") { + t.Errorf("Expected timeout error, got: %v", err) } }) - t.Run("EmptyPlatform", func(t *testing.T) { - // Given a BaseBlueprintHandler - handler := &BaseBlueprintHandler{} + 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 + } + } + + // 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 + } + 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 loading template with empty platform - template, err := handler.loadPlatformTemplate("") + // When calling Down + err := baseHandler.Down() - // Then no error should occur and template should be empty + // Then no error should be returned if err != nil { - t.Errorf("Expected no error for empty platform, got: %v", err) + 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) + } + 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") + } + 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) + } + }) + + t.Run("WaitForKustomizationsError", 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"}}, + } + + // 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 + } + } + + // Patch kubeClientResourceOperation to succeed + origKubeClientResourceOperation := kubeClientResourceOperation + kubeClientResourceOperation = func(_ string, config ResourceOperationConfig) error { + return nil + } + defer func() { kubeClientResourceOperation = origKubeClientResourceOperation }() + + // Patch checkKustomizationStatus to error + origCheckKustomizationStatus := checkKustomizationStatus + checkKustomizationStatus = func(_ string, names []string) (map[string]bool, error) { + return nil, fmt.Errorf("wait error") + } + 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 len(template) != 0 { - t.Errorf("Expected empty template for empty platform, got length: %d", len(template)) + if !strings.Contains(err.Error(), "timeout waiting for kustomizations") { + t.Errorf("Expected timeout error, got: %v", err) } }) } diff --git a/pkg/blueprint/mock_blueprint_handler.go b/pkg/blueprint/mock_blueprint_handler.go index 7f960ab60..4b978a98e 100644 --- a/pkg/blueprint/mock_blueprint_handler.go +++ b/pkg/blueprint/mock_blueprint_handler.go @@ -21,7 +21,8 @@ type MockBlueprintHandler struct { InstallFunc func() error GetRepositoryFunc func() blueprintv1alpha1.Repository SetRepositoryFunc func(repository blueprintv1alpha1.Repository) error - WaitForKustomizationsFunc func() error + WaitForKustomizationsFunc func(message string, names ...string) error + DownFunc func() error } // ============================================================================= @@ -153,10 +154,18 @@ func (m *MockBlueprintHandler) SetRepository(repository blueprintv1alpha1.Reposi return nil } -// WaitForKustomizations calls the mock WaitForKustomizationsFunc if set, otherwise returns nil -func (m *MockBlueprintHandler) WaitForKustomizations() error { +// WaitForKustomizations mocks the WaitForKustomizations method. +func (m *MockBlueprintHandler) WaitForKustomizations(message string, names ...string) error { if m.WaitForKustomizationsFunc != nil { - return m.WaitForKustomizationsFunc() + return m.WaitForKustomizationsFunc(message, names...) + } + return nil +} + +// Down mocks the Down method. +func (m *MockBlueprintHandler) Down() error { + if m.DownFunc != nil { + return m.DownFunc() } return nil } diff --git a/pkg/blueprint/mock_blueprint_handler_test.go b/pkg/blueprint/mock_blueprint_handler_test.go index 825a360e2..6f4602216 100644 --- a/pkg/blueprint/mock_blueprint_handler_test.go +++ b/pkg/blueprint/mock_blueprint_handler_test.go @@ -522,3 +522,30 @@ func TestMockBlueprintHandler_SetRepository(t *testing.T) { } }) } + +func TestMockBlueprintHandler_WaitForKustomizations(t *testing.T) { + t.Run("DefaultReturnsNil", func(t *testing.T) { + mock := &MockBlueprintHandler{} + err := mock.WaitForKustomizations("⏳ Waiting for kustomizations to be ready", "a", "b") + if err != nil { + t.Errorf("expected nil, got %v", err) + } + }) + + t.Run("CustomFuncIsCalled", func(t *testing.T) { + called := false + mock := &MockBlueprintHandler{ + WaitForKustomizationsFunc: func(message string, names ...string) error { + called = true + return nil + }, + } + err := mock.WaitForKustomizations("⏳ Waiting for kustomizations to be ready", "x", "y") + if !called { + t.Error("expected custom func to be called") + } + if err != nil { + t.Errorf("expected error nil, got %v", err) + } + }) +} diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index c54d896d5..1b4a6d66e 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -36,6 +36,7 @@ const ( DEFAULT_FLUX_KUSTOMIZATION_TIMEOUT = 5 * time.Minute DEFAULT_FLUX_SOURCE_INTERVAL = 1 * time.Minute DEFAULT_FLUX_SOURCE_TIMEOUT = 2 * time.Minute + DEFAULT_FLUX_CLEANUP_TIMEOUT = 30 * time.Minute // Used for aggregate CLI wait (not per-resource) DEFAULT_KUSTOMIZATION_WAIT_TOTAL_TIMEOUT = 10 * time.Minute diff --git a/pkg/stack/windsor_stack.go b/pkg/stack/windsor_stack.go index 755dc8004..1acf55f86 100644 --- a/pkg/stack/windsor_stack.go +++ b/pkg/stack/windsor_stack.go @@ -154,17 +154,17 @@ func (s *WindsorStack) Down() error { } } - _, err = s.shell.ExecProgress(fmt.Sprintf("🗑️ Initializing Terraform in %s", component.Path), "terraform", "init", "-migrate-state", "-upgrade", "-force-copy") + _, err = s.shell.ExecProgress(fmt.Sprintf("🗑️ Initializing Terraform in %s", component.Path), "terraform", "init", "-migrate-state", "-upgrade", "-force-copy") if err != nil { return fmt.Errorf("error initializing Terraform in %s: %w", component.FullPath, err) } - _, err = s.shell.ExecProgress(fmt.Sprintf("🗑️ Planning Terraform destruction in %s", component.Path), "terraform", "plan", "-destroy") + _, err = s.shell.ExecProgress(fmt.Sprintf("🗑️ Planning Terraform destruction in %s", component.Path), "terraform", "plan", "-destroy") if err != nil { return fmt.Errorf("error planning Terraform destruction in %s: %w", component.FullPath, err) } - _, err = s.shell.ExecProgress(fmt.Sprintf("🗑️ Destroying Terraform resources in %s", component.Path), "terraform", "destroy", "-auto-approve") + _, err = s.shell.ExecProgress(fmt.Sprintf("🗑️ Destroying Terraform resources in %s", component.Path), "terraform", "destroy", "-auto-approve") if err != nil { return fmt.Errorf("error destroying Terraform resources in %s: %w", component.FullPath, err) } From 47ad0edb470ebb43e0ea5cf3498d22df0e35af55 Mon Sep 17 00:00:00 2001 From: Ryan VanGundy Date: Thu, 22 May 2025 18:44:46 -0400 Subject: [PATCH 2/8] fix(blueprint): fix consecutive failures tracking in WaitForKustomizations - Reset consecutive failures counter only on successful check - Fix issue where timeout error was returned instead of failure count error - Ensure proper error handling for kustomization status checks --- pkg/blueprint/blueprint_handler.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/pkg/blueprint/blueprint_handler.go b/pkg/blueprint/blueprint_handler.go index 3c026a258..d12548cea 100644 --- a/pkg/blueprint/blueprint_handler.go +++ b/pkg/blueprint/blueprint_handler.go @@ -325,10 +325,6 @@ func (b *BaseBlueprintHandler) WaitForKustomizations(message string, names ...st consecutiveFailures := 0 for { select { - case <-timeout: - spin.Stop() - fmt.Fprintf(os.Stderr, "\033[31m✗ %s - \033[31mTimeout\033[0m\n", message) - return fmt.Errorf("timeout waiting for kustomizations to be ready") case <-ticker.C: kubeconfig := os.Getenv("KUBECONFIG") if err := checkGitRepositoryStatus(kubeconfig); err != nil { @@ -365,7 +361,12 @@ func (b *BaseBlueprintHandler) WaitForKustomizations(message string, names ...st return nil } + // Reset consecutive failures on successful check consecutiveFailures = 0 + case <-timeout: + spin.Stop() + fmt.Fprintf(os.Stderr, "\033[31m✗ %s - \033[31mTimeout\033[0m\n", message) + return fmt.Errorf("timeout waiting for kustomizations to be ready") } } } From 045f4dd729459a614d3423da64bf9a14fb59d2fc Mon Sep 17 00:00:00 2001 From: Ryan VanGundy Date: Thu, 22 May 2025 18:50:30 -0400 Subject: [PATCH 3/8] fix(blueprint): fix consecutive failures tracking in WaitForKustomizations --- pkg/blueprint/blueprint_handler.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/blueprint/blueprint_handler.go b/pkg/blueprint/blueprint_handler.go index d12548cea..7441e36aa 100644 --- a/pkg/blueprint/blueprint_handler.go +++ b/pkg/blueprint/blueprint_handler.go @@ -325,6 +325,10 @@ func (b *BaseBlueprintHandler) WaitForKustomizations(message string, names ...st consecutiveFailures := 0 for { select { + case <-timeout: + spin.Stop() + fmt.Fprintf(os.Stderr, "\033[31m✗ %s - \033[31mTimeout\033[0m\n", message) + return fmt.Errorf("timeout waiting for kustomizations to be ready") case <-ticker.C: kubeconfig := os.Getenv("KUBECONFIG") if err := checkGitRepositoryStatus(kubeconfig); err != nil { @@ -363,10 +367,6 @@ func (b *BaseBlueprintHandler) WaitForKustomizations(message string, names ...st // Reset consecutive failures on successful check consecutiveFailures = 0 - case <-timeout: - spin.Stop() - fmt.Fprintf(os.Stderr, "\033[31m✗ %s - \033[31mTimeout\033[0m\n", message) - return fmt.Errorf("timeout waiting for kustomizations to be ready") } } } From 10876dfc24b795fd43cf9d824e337030cdbc6d42 Mon Sep 17 00:00:00 2001 From: Ryan VanGundy Date: Thu, 22 May 2025 18:52:28 -0400 Subject: [PATCH 4/8] Try different timeout --- pkg/blueprint/blueprint_handler.go | 8 ++++---- pkg/blueprint/blueprint_handler_test.go | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pkg/blueprint/blueprint_handler.go b/pkg/blueprint/blueprint_handler.go index 7441e36aa..d12548cea 100644 --- a/pkg/blueprint/blueprint_handler.go +++ b/pkg/blueprint/blueprint_handler.go @@ -325,10 +325,6 @@ func (b *BaseBlueprintHandler) WaitForKustomizations(message string, names ...st consecutiveFailures := 0 for { select { - case <-timeout: - spin.Stop() - fmt.Fprintf(os.Stderr, "\033[31m✗ %s - \033[31mTimeout\033[0m\n", message) - return fmt.Errorf("timeout waiting for kustomizations to be ready") case <-ticker.C: kubeconfig := os.Getenv("KUBECONFIG") if err := checkGitRepositoryStatus(kubeconfig); err != nil { @@ -367,6 +363,10 @@ func (b *BaseBlueprintHandler) WaitForKustomizations(message string, names ...st // Reset consecutive failures on successful check consecutiveFailures = 0 + case <-timeout: + spin.Stop() + fmt.Fprintf(os.Stderr, "\033[31m✗ %s - \033[31mTimeout\033[0m\n", message) + return fmt.Errorf("timeout waiting for kustomizations to be ready") } } } diff --git a/pkg/blueprint/blueprint_handler_test.go b/pkg/blueprint/blueprint_handler_test.go index 41c13da93..1f69a5b70 100644 --- a/pkg/blueprint/blueprint_handler_test.go +++ b/pkg/blueprint/blueprint_handler_test.go @@ -2331,7 +2331,7 @@ func TestBaseBlueprintHandler_WaitForKustomizations(t *testing.T) { handler := &BaseBlueprintHandler{ blueprint: blueprintv1alpha1.Blueprint{ Kustomizations: []blueprintv1alpha1.Kustomization{ - {Name: "k1", Timeout: &metav1.Duration{Duration: 100 * time.Millisecond}}, + {Name: "k1", Timeout: &metav1.Duration{Duration: 1 * time.Second}}, }, }, } From 02ddb85d7d5e2b9b33d3a3d424ff8aef24877550 Mon Sep 17 00:00:00 2001 From: Ryan VanGundy Date: Thu, 22 May 2025 19:07:16 -0400 Subject: [PATCH 5/8] use no-op for checkGitRepositoryStatus --- pkg/blueprint/blueprint_handler_test.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pkg/blueprint/blueprint_handler_test.go b/pkg/blueprint/blueprint_handler_test.go index 1f69a5b70..d903bd1d4 100644 --- a/pkg/blueprint/blueprint_handler_test.go +++ b/pkg/blueprint/blueprint_handler_test.go @@ -291,9 +291,14 @@ contexts: kubeClient = func(string, KubeRequestConfig) error { return nil } origKubeClientResourceOperation := kubeClientResourceOperation kubeClientResourceOperation = func(string, ResourceOperationConfig) error { return nil } + + origCheckGitRepositoryStatus := checkGitRepositoryStatus + checkGitRepositoryStatus = func(_ string) error { return nil } + t.Cleanup(func() { kubeClient = origKubeClient kubeClientResourceOperation = origKubeClientResourceOperation + checkGitRepositoryStatus = origCheckGitRepositoryStatus os.Unsetenv("WINDSOR_PROJECT_ROOT") os.Unsetenv("WINDSOR_CONTEXT") if err := os.Chdir(origDir); err != nil { From 885a559432b1c7963de3a2fda1f561403f29a81e Mon Sep 17 00:00:00 2001 From: Ryan VanGundy Date: Thu, 22 May 2025 20:06:24 -0400 Subject: [PATCH 6/8] Improve test timeouts for Windows --- pkg/blueprint/blueprint_handler_test.go | 97 +------------------------ 1 file changed, 4 insertions(+), 93 deletions(-) diff --git a/pkg/blueprint/blueprint_handler_test.go b/pkg/blueprint/blueprint_handler_test.go index d903bd1d4..0a498f6aa 100644 --- a/pkg/blueprint/blueprint_handler_test.go +++ b/pkg/blueprint/blueprint_handler_test.go @@ -2576,50 +2576,6 @@ func TestBaseBlueprintHandler_Down(t *testing.T) { } }) - t.Run("WaitForKustomizationsError", 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"}}, - } - - // 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 - } - } - - // Patch kubeClientResourceOperation to succeed - origKubeClientResourceOperation := kubeClientResourceOperation - kubeClientResourceOperation = func(_ string, config ResourceOperationConfig) error { - return nil - } - defer func() { kubeClientResourceOperation = origKubeClientResourceOperation }() - - // Patch checkKustomizationStatus to error - origCheckKustomizationStatus := checkKustomizationStatus - checkKustomizationStatus = func(_ string, names []string) (map[string]bool, error) { - return nil, fmt.Errorf("wait error") - } - 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(), "timeout waiting for kustomizations") { - t.Errorf("Expected timeout error, got: %v", err) - } - }) - t.Run("ErrorApplyingCleanupKustomization", func(t *testing.T) { // Given a handler with kustomizations that need cleanup handler, _ := setup(t) @@ -2741,7 +2697,6 @@ func TestBaseBlueprintHandler_Down(t *testing.T) { } }) - // Error paths for WaitForKustomizationsError and ApplyKustomizationError t.Run("ApplyKustomizationError", func(t *testing.T) { handler, _ := setup(t) baseHandler := handler @@ -2785,13 +2740,13 @@ func TestBaseBlueprintHandler_Down(t *testing.T) { {Name: "k1", Cleanup: []string{"cleanup/path1"}}, } - // Set fast poll interval and short timeout - baseHandler.kustomizationWaitPollInterval = 1 * time.Millisecond + // Set reasonable poll interval and timeout + baseHandler.kustomizationWaitPollInterval = 10 * 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} + baseHandler.blueprint.Kustomizations[i].Timeout = &metav1.Duration{Duration: 50 * time.Millisecond} } else { - baseHandler.blueprint.Kustomizations[i].Timeout.Duration = 5 * time.Millisecond + baseHandler.blueprint.Kustomizations[i].Timeout.Duration = 50 * time.Millisecond } } @@ -2933,48 +2888,4 @@ func TestBaseBlueprintHandler_Down(t *testing.T) { t.Errorf("Expected error about apply error, got: %v", err) } }) - - t.Run("WaitForKustomizationsError", 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"}}, - } - - // 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 - } - } - - // Patch kubeClientResourceOperation to succeed - origKubeClientResourceOperation := kubeClientResourceOperation - kubeClientResourceOperation = func(_ string, config ResourceOperationConfig) error { - return nil - } - defer func() { kubeClientResourceOperation = origKubeClientResourceOperation }() - - // Patch checkKustomizationStatus to error - origCheckKustomizationStatus := checkKustomizationStatus - checkKustomizationStatus = func(_ string, names []string) (map[string]bool, error) { - return nil, fmt.Errorf("wait error") - } - 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(), "timeout waiting for kustomizations") { - t.Errorf("Expected timeout error, got: %v", err) - } - }) } From 9ae45236eea4e04e44c04bd08187adb4cbb4ed6a Mon Sep 17 00:00:00 2001 From: Ryan VanGundy Date: Fri, 23 May 2025 09:14:19 -0400 Subject: [PATCH 7/8] Remove flakey test --- pkg/blueprint/blueprint_handler_test.go | 44 ------------------------- 1 file changed, 44 deletions(-) diff --git a/pkg/blueprint/blueprint_handler_test.go b/pkg/blueprint/blueprint_handler_test.go index 0a498f6aa..07f28da10 100644 --- a/pkg/blueprint/blueprint_handler_test.go +++ b/pkg/blueprint/blueprint_handler_test.go @@ -2732,50 +2732,6 @@ func TestBaseBlueprintHandler_Down(t *testing.T) { } }) - t.Run("WaitForKustomizationsError", 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"}}, - } - - // Set reasonable poll interval and timeout - baseHandler.kustomizationWaitPollInterval = 10 * time.Millisecond - for i := range baseHandler.blueprint.Kustomizations { - if baseHandler.blueprint.Kustomizations[i].Timeout == nil { - baseHandler.blueprint.Kustomizations[i].Timeout = &metav1.Duration{Duration: 50 * time.Millisecond} - } else { - baseHandler.blueprint.Kustomizations[i].Timeout.Duration = 50 * time.Millisecond - } - } - - // Patch kubeClientResourceOperation to succeed - origKubeClientResourceOperation := kubeClientResourceOperation - kubeClientResourceOperation = func(_ string, config ResourceOperationConfig) error { - return nil - } - defer func() { kubeClientResourceOperation = origKubeClientResourceOperation }() - - // Patch checkKustomizationStatus to error - origCheckKustomizationStatus := checkKustomizationStatus - checkKustomizationStatus = func(_ string, names []string) (map[string]bool, error) { - return nil, fmt.Errorf("wait error") - } - 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(), "timeout waiting for kustomizations") { - t.Errorf("Expected timeout error, got: %v", err) - } - }) - t.Run("MultipleKustomizationsWithCleanup", func(t *testing.T) { // Given a handler with multiple kustomizations, some with cleanup paths handler, _ := setup(t) From 0c4b391082a75eba132e898c63cef250c5a06b58 Mon Sep 17 00:00:00 2001 From: Ryan VanGundy Date: Fri, 23 May 2025 09:34:45 -0400 Subject: [PATCH 8/8] Increase timeout / poll interval --- pkg/blueprint/blueprint_handler.go | 8 ++--- pkg/blueprint/blueprint_handler_test.go | 39 +++++++++++++------------ 2 files changed, 25 insertions(+), 22 deletions(-) diff --git a/pkg/blueprint/blueprint_handler.go b/pkg/blueprint/blueprint_handler.go index d12548cea..a1e84d9b4 100644 --- a/pkg/blueprint/blueprint_handler.go +++ b/pkg/blueprint/blueprint_handler.go @@ -325,6 +325,10 @@ func (b *BaseBlueprintHandler) WaitForKustomizations(message string, names ...st consecutiveFailures := 0 for { select { + case <-timeout: + spin.Stop() + fmt.Fprintf(os.Stderr, "\033[31m✗ %s - \033[31mFailed\033[0m\n", message) + return fmt.Errorf("timeout waiting for kustomizations") case <-ticker.C: kubeconfig := os.Getenv("KUBECONFIG") if err := checkGitRepositoryStatus(kubeconfig); err != nil { @@ -363,10 +367,6 @@ func (b *BaseBlueprintHandler) WaitForKustomizations(message string, names ...st // Reset consecutive failures on successful check consecutiveFailures = 0 - case <-timeout: - spin.Stop() - fmt.Fprintf(os.Stderr, "\033[31m✗ %s - \033[31mTimeout\033[0m\n", message) - return fmt.Errorf("timeout waiting for kustomizations to be ready") } } } diff --git a/pkg/blueprint/blueprint_handler_test.go b/pkg/blueprint/blueprint_handler_test.go index 07f28da10..dec99bfa2 100644 --- a/pkg/blueprint/blueprint_handler_test.go +++ b/pkg/blueprint/blueprint_handler_test.go @@ -2092,17 +2092,20 @@ func TestBlueprintHandler_SetRepository(t *testing.T) { } func TestBaseBlueprintHandler_WaitForKustomizations(t *testing.T) { + const pollInterval = 50 * time.Millisecond + const kustomTimeout = 300 * time.Millisecond + t.Run("AllKustomizationsReady", func(t *testing.T) { // Given a blueprint handler with multiple kustomizations that are all ready handler := &BaseBlueprintHandler{ blueprint: blueprintv1alpha1.Blueprint{ Kustomizations: []blueprintv1alpha1.Kustomization{ - {Name: "k1", Timeout: &metav1.Duration{Duration: 100 * time.Millisecond}}, - {Name: "k2", Timeout: &metav1.Duration{Duration: 100 * time.Millisecond}}, + {Name: "k1", Timeout: &metav1.Duration{Duration: kustomTimeout}}, + {Name: "k2", Timeout: &metav1.Duration{Duration: kustomTimeout}}, }, }, } - handler.kustomizationWaitPollInterval = 10 * time.Millisecond + handler.kustomizationWaitPollInterval = pollInterval // And Git repository and kustomization status checks that return success origCheckGit := checkGitRepositoryStatus @@ -2130,12 +2133,12 @@ func TestBaseBlueprintHandler_WaitForKustomizations(t *testing.T) { handler := &BaseBlueprintHandler{ blueprint: blueprintv1alpha1.Blueprint{ Kustomizations: []blueprintv1alpha1.Kustomization{ - {Name: "k1", Timeout: &metav1.Duration{Duration: 200 * time.Millisecond}}, - {Name: "k2", Timeout: &metav1.Duration{Duration: 200 * time.Millisecond}}, + {Name: "k1", Timeout: &metav1.Duration{Duration: kustomTimeout}}, + {Name: "k2", Timeout: &metav1.Duration{Duration: kustomTimeout}}, }, }, } - handler.kustomizationWaitPollInterval = 10 * time.Millisecond + handler.kustomizationWaitPollInterval = pollInterval // And status checks that always return not ready origCheckGit := checkGitRepositoryStatus @@ -2163,11 +2166,11 @@ func TestBaseBlueprintHandler_WaitForKustomizations(t *testing.T) { handler := &BaseBlueprintHandler{ blueprint: blueprintv1alpha1.Blueprint{ Kustomizations: []blueprintv1alpha1.Kustomization{ - {Name: "k1", Timeout: &metav1.Duration{Duration: 100 * time.Millisecond}}, + {Name: "k1", Timeout: &metav1.Duration{Duration: kustomTimeout}}, }, }, } - handler.kustomizationWaitPollInterval = 10 * time.Millisecond + handler.kustomizationWaitPollInterval = pollInterval // And a Git repository status check that returns an error origCheckGit := checkGitRepositoryStatus @@ -2195,11 +2198,11 @@ func TestBaseBlueprintHandler_WaitForKustomizations(t *testing.T) { handler := &BaseBlueprintHandler{ blueprint: blueprintv1alpha1.Blueprint{ Kustomizations: []blueprintv1alpha1.Kustomization{ - {Name: "k1", Timeout: &metav1.Duration{Duration: 100 * time.Millisecond}}, + {Name: "k1", Timeout: &metav1.Duration{Duration: kustomTimeout}}, }, }, } - handler.kustomizationWaitPollInterval = 10 * time.Millisecond + handler.kustomizationWaitPollInterval = pollInterval // And a kustomization status check that returns an error origCheckGit := checkGitRepositoryStatus @@ -2227,11 +2230,11 @@ func TestBaseBlueprintHandler_WaitForKustomizations(t *testing.T) { handler := &BaseBlueprintHandler{ blueprint: blueprintv1alpha1.Blueprint{ Kustomizations: []blueprintv1alpha1.Kustomization{ - {Name: "k1", Timeout: &metav1.Duration{Duration: 100 * time.Millisecond}}, + {Name: "k1", Timeout: &metav1.Duration{Duration: kustomTimeout}}, }, }, } - handler.kustomizationWaitPollInterval = 10 * time.Millisecond + handler.kustomizationWaitPollInterval = pollInterval // And a Git repository status check that fails twice then succeeds failCount := 0 @@ -2266,11 +2269,11 @@ func TestBaseBlueprintHandler_WaitForKustomizations(t *testing.T) { handler := &BaseBlueprintHandler{ blueprint: blueprintv1alpha1.Blueprint{ Kustomizations: []blueprintv1alpha1.Kustomization{ - {Name: "k1", Timeout: &metav1.Duration{Duration: 100 * time.Millisecond}}, + {Name: "k1", Timeout: &metav1.Duration{Duration: kustomTimeout}}, }, }, } - handler.kustomizationWaitPollInterval = 10 * time.Millisecond + handler.kustomizationWaitPollInterval = pollInterval // And a kustomization status check that fails twice then succeeds failCount := 0 @@ -2303,11 +2306,11 @@ func TestBaseBlueprintHandler_WaitForKustomizations(t *testing.T) { handler := &BaseBlueprintHandler{ blueprint: blueprintv1alpha1.Blueprint{ Kustomizations: []blueprintv1alpha1.Kustomization{ - {Name: "k1", Timeout: &metav1.Duration{Duration: 100 * time.Millisecond}}, + {Name: "k1", Timeout: &metav1.Duration{Duration: kustomTimeout}}, }, }, } - handler.kustomizationWaitPollInterval = 10 * time.Millisecond + handler.kustomizationWaitPollInterval = pollInterval // And a Git repository status check that always fails origCheckGit := checkGitRepositoryStatus @@ -2336,11 +2339,11 @@ func TestBaseBlueprintHandler_WaitForKustomizations(t *testing.T) { handler := &BaseBlueprintHandler{ blueprint: blueprintv1alpha1.Blueprint{ Kustomizations: []blueprintv1alpha1.Kustomization{ - {Name: "k1", Timeout: &metav1.Duration{Duration: 1 * time.Second}}, + {Name: "k1", Timeout: &metav1.Duration{Duration: kustomTimeout}}, }, }, } - handler.kustomizationWaitPollInterval = 10 * time.Millisecond + handler.kustomizationWaitPollInterval = pollInterval // And a kustomization status check that always fails origCheckGit := checkGitRepositoryStatus