From aced5c332dfcef7ccf1fbebe4413823179be0761 Mon Sep 17 00:00:00 2001 From: Ryan VanGundy <85766511+rmvangun@users.noreply.github.com> Date: Thu, 13 Nov 2025 22:47:20 -0500 Subject: [PATCH] refactor(blueprint): Remove k8s api handling from blueprint handler The blueprint handler should only involve code generation. We have moved all the direct k8s API calls to the provisioner. This PR removes the vestigial functionality from the blueprint handler Signed-off-by: Ryan VanGundy <85766511+rmvangun@users.noreply.github.com> --- cmd/down.go | 3 +- cmd/down_test.go | 48 +- pkg/composer/blueprint/blueprint_handler.go | 730 +------- .../blueprint_handler_helper_test.go | 193 -- .../blueprint_handler_private_test.go | 1593 +---------------- .../blueprint_handler_public_test.go | 1116 +----------- .../kubernetes/kubernetes_manager.go | 110 ++ .../kubernetes/kubernetes_manager_test.go | 402 +++++ .../kubernetes/mock_kubernetes_manager.go | 9 + .../mock_kubernetes_manager_test.go | 77 + pkg/provisioner/provisioner.go | 35 +- pkg/provisioner/provisioner_test.go | 189 +- 12 files changed, 818 insertions(+), 3687 deletions(-) diff --git a/cmd/down.go b/cmd/down.go index 0d1522b2f..c48ad1124 100644 --- a/cmd/down.go +++ b/cmd/down.go @@ -45,7 +45,8 @@ var downCmd = &cobra.Command{ } if !skipK8sFlag { - if err := proj.Composer.BlueprintHandler.Down(); err != nil { + blueprint := proj.Composer.BlueprintHandler.Generate() + if err := proj.Provisioner.Uninstall(blueprint); err != nil { return fmt.Errorf("error running blueprint cleanup: %w", err) } } else { diff --git a/cmd/down_test.go b/cmd/down_test.go index 42a4cff79..ca880c0da 100644 --- a/cmd/down_test.go +++ b/cmd/down_test.go @@ -9,13 +9,9 @@ import ( "github.com/spf13/cobra" "github.com/spf13/pflag" blueprintv1alpha1 "github.com/windsorcli/cli/api/v1alpha1" - "github.com/windsorcli/cli/pkg/composer" "github.com/windsorcli/cli/pkg/composer/blueprint" - "github.com/windsorcli/cli/pkg/runtime/config" - execcontext "github.com/windsorcli/cli/pkg/runtime" - "github.com/windsorcli/cli/pkg/provisioner" terraforminfra "github.com/windsorcli/cli/pkg/provisioner/terraform" - "github.com/windsorcli/cli/pkg/workstation" + "github.com/windsorcli/cli/pkg/runtime/config" "github.com/windsorcli/cli/pkg/workstation/virt" ) @@ -259,45 +255,3 @@ func TestDownCmd(t *testing.T) { } }) } - -func setupDownMocksWithProject(t *testing.T) (*Mocks, *provisioner.Provisioner, *composer.Composer, *workstation.Workstation) { - t.Helper() - - mocks := setupMocks(t) - - mockBlueprintHandler := blueprint.NewMockBlueprintHandler(mocks.Injector) - mockBlueprintHandler.GenerateFunc = func() *blueprintv1alpha1.Blueprint { - return &blueprintv1alpha1.Blueprint{} - } - mocks.Injector.Register("blueprintHandler", mockBlueprintHandler) - - mockStack := &terraforminfra.MockStack{} - mockStack.InitializeFunc = func() error { return nil } - mocks.Injector.Register("stack", mockStack) - - mockContainerRuntime := &virt.MockVirt{} - mockContainerRuntime.InitializeFunc = func() error { return nil } - mocks.Injector.Register("containerRuntime", mockContainerRuntime) - - mockExecCtx := mocks.Injector.Resolve("executionContext") - if mockExecCtx == nil { - t.Fatal("executionContext not found in injector") - } - - prov := provisioner.NewProvisioner(&provisioner.ProvisionerRuntime{ - Runtime: *mockExecCtx.(*execcontext.Runtime), - }) - - comp := composer.NewComposer(&composer.ComposerRuntime{ - Runtime: *mockExecCtx.(*execcontext.Runtime), - }) - - ws, err := workstation.NewWorkstation(&workstation.WorkstationRuntime{ - Runtime: *mockExecCtx.(*execcontext.Runtime), - }, mocks.Injector) - if err != nil { - t.Fatalf("Failed to create workstation: %v", err) - } - - return mocks, prov, comp, ws -} diff --git a/pkg/composer/blueprint/blueprint_handler.go b/pkg/composer/blueprint/blueprint_handler.go index df5f2e4e3..742b23bce 100644 --- a/pkg/composer/blueprint/blueprint_handler.go +++ b/pkg/composer/blueprint/blueprint_handler.go @@ -1,35 +1,25 @@ package blueprint import ( - "context" "fmt" "io" "maps" "os" - "os/signal" "path/filepath" "reflect" - "slices" "sort" "strings" - "syscall" - "time" _ "embed" "github.com/goccy/go-yaml" "github.com/windsorcli/cli/pkg/composer/artifact" "github.com/windsorcli/cli/pkg/constants" + "github.com/windsorcli/cli/pkg/di" "github.com/windsorcli/cli/pkg/runtime/config" "github.com/windsorcli/cli/pkg/runtime/shell" - "github.com/windsorcli/cli/pkg/di" - "github.com/windsorcli/cli/pkg/provisioner/kubernetes" - "github.com/briandowns/spinner" - kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1" - kustomize "github.com/fluxcd/pkg/apis/kustomize" - meta "github.com/fluxcd/pkg/apis/meta" - sourcev1 "github.com/fluxcd/source-controller/api/v1" + "github.com/fluxcd/pkg/apis/kustomize" blueprintv1alpha1 "github.com/windsorcli/cli/api/v1alpha1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -38,8 +28,7 @@ import ( // through a declarative, GitOps-based approach. It handles the lifecycle of infrastructure blueprints, // which are composed of Terraform components, Kubernetes Kustomizations, and associated metadata. // The handler facilitates the resolution of component sources, manages repository configurations, -// and orchestrates the deployment of infrastructure components across different environments. -// It integrates with Kubernetes for resource management and supports both local and remote +// and processes blueprint data for use by the provisioner. It supports both local and remote // infrastructure definitions, enabling consistent and reproducible infrastructure deployments. type BlueprintHandler interface { @@ -48,18 +37,15 @@ type BlueprintHandler interface { LoadConfig() error LoadData(data map[string]any, ociInfo ...*artifact.OCIArtifactInfo) error Write(overwrite ...bool) error - Install() error SetRenderedKustomizeData(data map[string]any) GetMetadata() blueprintv1alpha1.Metadata GetSources() []blueprintv1alpha1.Source GetRepository() blueprintv1alpha1.Repository GetTerraformComponents() []blueprintv1alpha1.TerraformComponent GetKustomizations() []blueprintv1alpha1.Kustomization - WaitForKustomizations(message string, names ...string) error GetDefaultTemplateData(contextName string) (map[string][]byte, error) GetLocalTemplateData() (map[string][]byte, error) Generate() *blueprintv1alpha1.Blueprint - Down() error } //go:embed templates/default.jsonnet @@ -70,7 +56,6 @@ type BaseBlueprintHandler struct { injector di.Injector configHandler config.ConfigHandler shell shell.Shell - kubernetesManager kubernetes.KubernetesManager blueprint blueprintv1alpha1.Blueprint projectRoot string templateRoot string @@ -98,7 +83,7 @@ func NewBlueprintHandler(injector di.Injector) *BaseBlueprintHandler { // ============================================================================= // Initialize resolves and assigns dependencies for BaseBlueprintHandler using the provided dependency injector. -// It sets configHandler, shell, and kubernetesManager, determines the project root directory. +// It sets configHandler and shell, determines the project root directory. // Returns an error if any dependency resolution or initialization step fails. func (b *BaseBlueprintHandler) Initialize() error { configHandler, ok := b.injector.Resolve("configHandler").(config.ConfigHandler) @@ -113,12 +98,6 @@ func (b *BaseBlueprintHandler) Initialize() error { } b.shell = shell - kubernetesManager, ok := b.injector.Resolve("kubernetesManager").(kubernetes.KubernetesManager) - if !ok { - return fmt.Errorf("error resolving kubernetesManager") - } - b.kubernetesManager = kubernetesManager - projectRoot, err := b.shell.GetProjectRoot() if err != nil { return fmt.Errorf("error getting project root: %w", err) @@ -305,135 +284,6 @@ func (b *BaseBlueprintHandler) Write(overwrite ...bool) error { return nil } -// WaitForKustomizations waits for the specified kustomizations to be ready. -// It polls the status of the kustomizations until they are all ready or a timeout occurs. -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() - defer spin.Stop() - - timeout := b.shims.TimeAfter(b.calculateMaxWaitTime()) - ticker := b.shims.NewTicker(constants.DefaultKustomizationWaitPollInterval) - defer b.shims.TickerStop(ticker) - - 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 - } - } - - // Check immediately before starting polling loop - ready, err := b.checkKustomizationStatus(kustomizationNames) - if err == nil && ready { - spin.Stop() - fmt.Fprintf(os.Stderr, "\033[32mโœ”\033[0m%s - \033[32mDone\033[0m\n", spin.Suffix) - return nil - } - - consecutiveFailures := 0 - if err != nil { - consecutiveFailures = 1 - } - - for { - select { - case <-timeout: - spin.Stop() - fmt.Fprintf(os.Stderr, "โœ—%s - \033[31mFailed\033[0m\n", spin.Suffix) - return fmt.Errorf("timeout waiting for kustomizations") - case <-ticker.C: - ready, err := b.checkKustomizationStatus(kustomizationNames) - if err != nil { - consecutiveFailures++ - if consecutiveFailures >= constants.DefaultKustomizationWaitMaxFailures { - spin.Stop() - fmt.Fprintf(os.Stderr, "โœ—%s - \033[31mFailed\033[0m\n", spin.Suffix) - return fmt.Errorf("%s after %d consecutive failures", err.Error(), consecutiveFailures) - } - continue - } - - if ready { - spin.Stop() - fmt.Fprintf(os.Stderr, "\033[32mโœ”\033[0m%s - \033[32mDone\033[0m\n", spin.Suffix) - return nil - } - - // Reset consecutive failures on successful check - consecutiveFailures = 0 - } - } -} - -// Install applies all blueprint Kubernetes resources to the cluster, including the main -// repository, additional sources, Kustomizations, and the context ConfigMap. The method -// ensures the target namespace exists, applies the main and additional source repositories, -// creates the ConfigMap, and applies all Kustomizations. Uses the environment KUBECONFIG or -// in-cluster configuration for access. Returns an error if any resource application fails. -func (b *BaseBlueprintHandler) Install() error { - spin := spinner.New(spinner.CharSets[14], 100*time.Millisecond, spinner.WithColor("green")) - spin.Suffix = " ๐Ÿ“ Installing blueprint resources" - spin.Start() - defer spin.Stop() - - if err := b.kubernetesManager.CreateNamespace(constants.DefaultFluxSystemNamespace); err != nil { - spin.Stop() - fmt.Fprintf(os.Stderr, "โœ—%s - \033[31mFailed\033[0m\n", spin.Suffix) - return fmt.Errorf("failed to create namespace: %w", err) - } - - if b.blueprint.Repository.Url != "" { - source := blueprintv1alpha1.Source{ - Name: b.blueprint.Metadata.Name, - Url: b.blueprint.Repository.Url, - Ref: b.blueprint.Repository.Ref, - SecretName: b.blueprint.Repository.SecretName, - } - if err := b.applySourceRepository(source, constants.DefaultFluxSystemNamespace); err != nil { - spin.Stop() - fmt.Fprintf(os.Stderr, "โœ—%s - \033[31mFailed\033[0m\n", spin.Suffix) - return fmt.Errorf("failed to apply blueprint repository: %w", err) - } - } - - for _, source := range b.blueprint.Sources { - if err := b.applySourceRepository(source, constants.DefaultFluxSystemNamespace); err != nil { - spin.Stop() - fmt.Fprintf(os.Stderr, "โœ—%s - \033[31mFailed\033[0m\n", spin.Suffix) - return fmt.Errorf("failed to apply source %s: %w", source.Name, err) - } - } - - if err := b.applyValuesConfigMaps(); err != nil { - spin.Stop() - fmt.Fprintf(os.Stderr, "โœ—%s - \033[31mFailed\033[0m\n", spin.Suffix) - return fmt.Errorf("failed to apply values configmaps: %w", err) - } - - kustomizations := b.GetKustomizations() - kustomizationNames := make([]string, len(kustomizations)) - for i, k := range kustomizations { - fluxKustomization := b.prepareAndConvertKustomization(k, constants.DefaultFluxSystemNamespace) - if err := b.kubernetesManager.ApplyKustomization(fluxKustomization); err != nil { - spin.Stop() - fmt.Fprintf(os.Stderr, "โœ—%s - \033[31mFailed\033[0m\n", spin.Suffix) - return fmt.Errorf("failed to apply kustomization %s: %w", k.Name, err) - } - kustomizationNames[i] = k.Name - } - - spin.Stop() - fmt.Fprintf(os.Stderr, "\033[32mโœ”\033[0m%s - \033[32mDone\033[0m\n", spin.Suffix) - - return nil -} - // GetMetadata retrieves the current blueprint's metadata. func (b *BaseBlueprintHandler) GetMetadata() blueprintv1alpha1.Metadata { resolvedBlueprint := b.blueprint @@ -653,184 +503,10 @@ func (b *BaseBlueprintHandler) GetLocalTemplateData() (map[string][]byte, error) return templateData, nil } -// Down manages the teardown of kustomizations and related resources, ignoring "not found" errors. -// It suspends kustomizations and helmreleases, applies cleanup kustomizations, waits for completion, -// deletes main kustomizations in reverse dependency order, and removes cleanup kustomizations and namespaces. -// The function filters kustomizations for destruction, sorts them by dependencies, and performs cleanup if specified. -// Dependency resolution is achieved through topological sorting for correct deletion order. -func (b *BaseBlueprintHandler) Down() error { - allKustomizations := b.GetKustomizations() - if len(allKustomizations) == 0 { - return nil - } - - var kustomizations []blueprintv1alpha1.Kustomization - for _, k := range allKustomizations { - if k.Destroy == nil || *k.Destroy { - kustomizations = append(kustomizations, k) - } - } - - if len(kustomizations) == 0 { - return nil - } - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) - go func() { - <-sigChan - fmt.Fprintf(os.Stderr, "\nReceived interrupt signal, cancelling operations...\n") - cancel() - }() - - if err := b.destroyKustomizations(ctx, kustomizations); err != nil { - if ctx.Err() == context.Canceled { - return fmt.Errorf("operation cancelled by user: %w", err) - } - return err - } - - return nil -} - // ============================================================================= // Private Methods // ============================================================================= -// destroyKustomizations removes kustomizations and performs cleanup tasks. -// It sorts kustomizations by dependencies, applies cleanup kustomizations if defined, -// ensures readiness, and deletes them, followed by the main kustomizations. -func (b *BaseBlueprintHandler) destroyKustomizations(ctx context.Context, kustomizations []blueprintv1alpha1.Kustomization) error { - deps := make(map[string][]string) - for _, k := range kustomizations { - deps[k.Name] = k.DependsOn - } - - 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) - } - 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 - } - - for _, name := range sorted { - select { - case <-ctx.Done(): - return ctx.Err() - default: - } - - k := nameToK[name] - - if len(k.Cleanup) > 0 { - status, err := b.kubernetesManager.GetKustomizationStatus([]string{k.Name}) - if err != nil { - return fmt.Errorf("failed to check if kustomization %s exists: %w", k.Name, err) - } - - if !status[k.Name] { - continue - } - - cleanupSpin := spinner.New(spinner.CharSets[14], 100*time.Millisecond, spinner.WithColor("green")) - cleanupSpin.Suffix = fmt.Sprintf(" ๐Ÿงน Applying cleanup kustomization for %s", k.Name) - cleanupSpin.Start() - - cleanupPath := strings.ReplaceAll(filepath.Join(k.Path, "cleanup"), "\\", "/") - cleanupKustomization := &blueprintv1alpha1.Kustomization{ - Name: k.Name + "-cleanup", - Path: cleanupPath, - Source: k.Source, - Components: k.Cleanup, - Timeout: &metav1.Duration{Duration: 30 * time.Minute}, - Interval: &metav1.Duration{Duration: constants.DefaultFluxKustomizationInterval}, - RetryInterval: &metav1.Duration{Duration: constants.DefaultFluxKustomizationRetryInterval}, - Wait: func() *bool { b := true; return &b }(), - Force: func() *bool { b := true; return &b }(), - } - - fluxKustomization := b.prepareAndConvertKustomization(*cleanupKustomization, constants.DefaultFluxSystemNamespace) - if err := b.kubernetesManager.ApplyKustomization(fluxKustomization); err != nil { - return fmt.Errorf("failed to apply cleanup kustomization for %s: %w", k.Name, err) - } - - timeout := b.shims.TimeAfter(constants.DefaultFluxCleanupTimeout) - ticker := b.shims.NewTicker(2 * time.Second) - defer b.shims.TickerStop(ticker) - - cleanupReady := false - - cleanupLoop: - for !cleanupReady { - select { - case <-ctx.Done(): - return ctx.Err() - case <-timeout: - break cleanupLoop - case <-ticker.C: - ready, err := b.kubernetesManager.GetKustomizationStatus([]string{cleanupKustomization.Name}) - if err != nil { - return fmt.Errorf("cleanup kustomization %s failed: %w", cleanupKustomization.Name, err) - } - if ready[cleanupKustomization.Name] { - cleanupReady = true - } - } - } - - cleanupSpin.Stop() - - if !cleanupReady { - fmt.Fprintf(os.Stderr, "Warning: Cleanup kustomization %s did not become ready within %v, proceeding anyway\n", cleanupKustomization.Name, constants.DefaultFluxCleanupTimeout) - } - fmt.Fprintf(os.Stderr, "\033[32mโœ”\033[0m ๐Ÿงน Applying cleanup kustomization for %s - \033[32mDone\033[0m\n", k.Name) - - cleanupDeleteSpin := spinner.New(spinner.CharSets[14], 100*time.Millisecond, spinner.WithColor("green")) - cleanupDeleteSpin.Suffix = fmt.Sprintf(" ๐Ÿ—‘๏ธ Deleting cleanup kustomization %s", cleanupKustomization.Name) - cleanupDeleteSpin.Start() - if err := b.kubernetesManager.DeleteKustomization(cleanupKustomization.Name, constants.DefaultFluxSystemNamespace); err != nil { - return fmt.Errorf("failed to delete cleanup kustomization %s: %w", cleanupKustomization.Name, err) - } - - cleanupDeleteSpin.Stop() - fmt.Fprintf(os.Stderr, "\033[32mโœ”\033[0m ๐Ÿ—‘๏ธ Deleting cleanup kustomization %s - \033[32mDone\033[0m\n", cleanupKustomization.Name) - } - - deleteSpin := spinner.New(spinner.CharSets[14], 100*time.Millisecond, spinner.WithColor("green")) - deleteSpin.Suffix = fmt.Sprintf(" ๐Ÿ—‘๏ธ Deleting kustomization %s", k.Name) - deleteSpin.Start() - if err := b.kubernetesManager.DeleteKustomization(k.Name, constants.DefaultFluxSystemNamespace); err != nil { - return fmt.Errorf("failed to delete kustomization %s: %w", k.Name, err) - } - - deleteSpin.Stop() - fmt.Fprintf(os.Stderr, "\033[32mโœ”\033[0m ๐Ÿ—‘๏ธ Deleting kustomization %s - \033[32mDone\033[0m\n", k.Name) - } - - return nil -} - // walkAndCollectTemplates traverses template directories to gather .jsonnet files. // It updates the provided templateData map with the relative paths and content of // the .jsonnet files found. The function handles directory recursion and file reading @@ -1205,263 +881,6 @@ func (b *BaseBlueprintHandler) isValidTerraformRemoteSource(source string) bool return false } -// applySourceRepository routes to the appropriate source handler based on URL type -func (b *BaseBlueprintHandler) applySourceRepository(source blueprintv1alpha1.Source, namespace string) error { - if strings.HasPrefix(source.Url, "oci://") { - return b.applyOCIRepository(source, namespace) - } - return b.applyGitRepository(source, namespace) -} - -// 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. -func (b *BaseBlueprintHandler) applyGitRepository(source blueprintv1alpha1.Source, namespace string) error { - sourceUrl := source.Url - if !strings.HasPrefix(sourceUrl, "http://") && !strings.HasPrefix(sourceUrl, "https://") { - sourceUrl = "https://" + sourceUrl - } - - gitRepo := &sourcev1.GitRepository{ - TypeMeta: metav1.TypeMeta{ - Kind: "GitRepository", - APIVersion: "source.toolkit.fluxcd.io/v1", - }, - ObjectMeta: metav1.ObjectMeta{ - Name: source.Name, - Namespace: namespace, - }, - Spec: sourcev1.GitRepositorySpec{ - URL: sourceUrl, - Interval: metav1.Duration{ - Duration: constants.DefaultFluxSourceInterval, - }, - Timeout: &metav1.Duration{ - Duration: constants.DefaultFluxSourceTimeout, - }, - Reference: &sourcev1.GitRepositoryRef{ - Branch: source.Ref.Branch, - Tag: source.Ref.Tag, - SemVer: source.Ref.SemVer, - Commit: source.Ref.Commit, - }, - }, - } - - if source.SecretName != "" { - gitRepo.Spec.SecretRef = &meta.LocalObjectReference{ - Name: source.SecretName, - } - } - - return b.kubernetesManager.ApplyGitRepository(gitRepo) -} - -// applyOCIRepository creates or updates an OCIRepository resource in the cluster. It handles -// OCI URL parsing, configures standard intervals and timeouts, and handles secret references -// for private registries. The OCI URL should include the tag/version (e.g., oci://registry/repo:tag). -func (b *BaseBlueprintHandler) applyOCIRepository(source blueprintv1alpha1.Source, namespace string) error { - ociURL := source.Url - var ref *sourcev1.OCIRepositoryRef - - if lastColon := strings.LastIndex(ociURL, ":"); lastColon > len("oci://") { - if tagPart := ociURL[lastColon+1:]; tagPart != "" && !strings.Contains(tagPart, "/") { - ociURL = ociURL[:lastColon] - ref = &sourcev1.OCIRepositoryRef{ - Tag: tagPart, - } - } - } - - if ref == nil && (source.Ref.Tag != "" || source.Ref.SemVer != "" || source.Ref.Commit != "") { - ref = &sourcev1.OCIRepositoryRef{ - Tag: source.Ref.Tag, - SemVer: source.Ref.SemVer, - Digest: source.Ref.Commit, - } - } - - if ref == nil { - ref = &sourcev1.OCIRepositoryRef{ - Tag: "latest", - } - } - - ociRepo := &sourcev1.OCIRepository{ - TypeMeta: metav1.TypeMeta{ - Kind: "OCIRepository", - APIVersion: "source.toolkit.fluxcd.io/v1", - }, - ObjectMeta: metav1.ObjectMeta{ - Name: source.Name, - Namespace: namespace, - }, - Spec: sourcev1.OCIRepositorySpec{ - URL: ociURL, - Interval: metav1.Duration{ - Duration: constants.DefaultFluxSourceInterval, - }, - Timeout: &metav1.Duration{ - Duration: constants.DefaultFluxSourceTimeout, - }, - Reference: ref, - }, - } - - if source.SecretName != "" { - ociRepo.Spec.SecretRef = &meta.LocalObjectReference{ - Name: source.SecretName, - } - } - - return b.kubernetesManager.ApplyOCIRepository(ociRepo) -} - -// checkKustomizationStatus verifies the readiness of specified kustomizations by first checking -// the git repository status and then polling each kustomization's status. Returns true if all -// kustomizations are ready, false otherwise, along with any errors encountered during the checks. -func (b *BaseBlueprintHandler) checkKustomizationStatus(kustomizationNames []string) (bool, error) { - if err := b.kubernetesManager.CheckGitRepositoryStatus(); err != nil { - return false, fmt.Errorf("git repository error: %w", err) - } - status, err := b.kubernetesManager.GetKustomizationStatus(kustomizationNames) - if err != nil { - return false, fmt.Errorf("kustomization error: %w", err) - } - - allReady := true - for _, ready := range status { - if !ready { - allReady = false - break - } - } - return allReady, nil -} - -// calculateMaxWaitTime calculates the maximum wait time needed based on kustomization dependencies. -// It builds a dependency graph from all kustomizations, mapping each to its dependencies and timeouts. -// Using depth-first search (DFS), it explores all possible dependency paths to find the longest one, -// accumulating timeout values along each path. The function handles circular dependencies by tracking -// visited nodes and avoiding infinite recursion while still considering their timeout contributions. -// It identifies root nodes (those with no incoming dependencies) as starting points, or if no roots -// exist due to cycles, it starts DFS from every node to ensure complete coverage. Returns the total -// time needed for the longest dependency path through the kustomization graph. -func (b *BaseBlueprintHandler) calculateMaxWaitTime() time.Duration { - kustomizations := b.GetKustomizations() - if len(kustomizations) == 0 { - return 0 - } - - deps := make(map[string][]string) - timeouts := make(map[string]time.Duration) - for _, k := range kustomizations { - deps[k.Name] = k.DependsOn - if k.Timeout != nil { - timeouts[k.Name] = k.Timeout.Duration - } else { - timeouts[k.Name] = constants.DefaultFluxKustomizationTimeout - } - } - - var maxPathTime time.Duration - visited := make(map[string]bool) - path := make([]string, 0) - - var dfs func(name string, currentTime time.Duration) - dfs = func(name string, currentTime time.Duration) { - visited[name] = true - path = append(path, name) - currentTime += timeouts[name] - - if currentTime > maxPathTime { - maxPathTime = currentTime - } - - for _, dep := range deps[name] { - if !visited[dep] { - dfs(dep, currentTime) - } else { - if currentTime+timeouts[dep] > maxPathTime { - maxPathTime = currentTime + timeouts[dep] - } - } - } - - visited[name] = false - path = path[:len(path)-1] - } - - roots := []string{} - for _, k := range kustomizations { - isRoot := true - for _, other := range kustomizations { - if slices.Contains(other.DependsOn, k.Name) { - isRoot = false - break - } - } - if isRoot { - roots = append(roots, k.Name) - } - } - if len(roots) == 0 { - for _, k := range kustomizations { - dfs(k.Name, 0) - } - } else { - for _, root := range roots { - dfs(root, 0) - } - } - - return maxPathTime -} - -// prepareAndConvertKustomization prepares a blueprint Kustomization with handler-specific data and converts it to a Flux Kustomization. -// This includes resolving patches that reference paths to actual patch content, populating substitutions from configured feature substitutions, -// and invoking the API's ToFluxKustomization with the appropriate context. The original Kustomization is not modified. -func (b *BaseBlueprintHandler) prepareAndConvertKustomization(k blueprintv1alpha1.Kustomization, namespace string) kustomizev1.Kustomization { - kCopy := k - for i := range kCopy.Patches { - if kCopy.Patches[i].Path != "" { - patchContent, target := b.resolvePatchFromPath(kCopy.Patches[i].Path, namespace) - if patchContent != "" { - kCopy.Patches[i].Patch = patchContent - } - if target != nil { - kCopy.Patches[i].Target = target - } - } - } - if substitutions, hasSubstitutions := b.featureSubstitutions[k.Name]; hasSubstitutions && len(substitutions) > 0 { - if kCopy.Substitutions == nil { - kCopy.Substitutions = make(map[string]string) - } - maps.Copy(kCopy.Substitutions, substitutions) - } - - defaultSourceName := b.blueprint.Metadata.Name - fluxKustomization := kCopy.ToFluxKustomization(namespace, defaultSourceName, b.blueprint.Sources) - - if fluxKustomization.Spec.PostBuild == nil { - fluxKustomization.Spec.PostBuild = &kustomizev1.PostBuild{ - SubstituteFrom: []kustomizev1.SubstituteReference{}, - } - } - valuesCommonRef := kustomizev1.SubstituteReference{ - Kind: "ConfigMap", - Name: "values-common", - Optional: false, - } - fluxKustomization.Spec.PostBuild.SubstituteFrom = append( - []kustomizev1.SubstituteReference{valuesCommonRef}, - fluxKustomization.Spec.PostBuild.SubstituteFrom..., - ) - - return fluxKustomization -} - // resolvePatchFromPath yields patch content as YAML string and the target selector for a given patch path. // Combines template data with user-defined files; user files take precedence. If a user file exists and cannot be merged as YAML, it overrides template data entirely. // patchPath: relative path to the patch file within the kustomize directory @@ -1585,100 +1004,6 @@ func (b *BaseBlueprintHandler) isOCISource(sourceNameOrURL string) bool { return false } -// applyValuesConfigMaps generates ConfigMaps for Flux post-build variable substitution using rendered template values and context-specific values.yaml files. -// Merges rendered template values with context values, giving precedence to context values in case of conflict. -// Produces a ConfigMap for the "common" section and for each component section, with system values merged into "common". -// The resulting ConfigMaps are referenced in PostBuild.SubstituteFrom for variable substitution. -func (b *BaseBlueprintHandler) applyValuesConfigMaps() error { - mergedCommonValues := make(map[string]any) - - domain := b.configHandler.GetString("dns.domain") - context := b.configHandler.GetContext() - lbStart := b.configHandler.GetString("network.loadbalancer_ips.start") - lbEnd := b.configHandler.GetString("network.loadbalancer_ips.end") - registryURL := b.configHandler.GetString("docker.registry_url") - localVolumePaths := b.configHandler.GetStringSlice("cluster.workers.volumes") - - loadBalancerIPRange := fmt.Sprintf("%s-%s", lbStart, lbEnd) - - var localVolumePath string - if len(localVolumePaths) > 0 { - volumeParts := strings.Split(localVolumePaths[0], ":") - if len(volumeParts) > 1 { - localVolumePath = volumeParts[1] - } else { - localVolumePath = "" - } - } else { - localVolumePath = "" - } - - mergedCommonValues["DOMAIN"] = domain - mergedCommonValues["CONTEXT"] = context - mergedCommonValues["CONTEXT_ID"] = b.configHandler.GetString("id") - mergedCommonValues["LOADBALANCER_IP_RANGE"] = loadBalancerIPRange - mergedCommonValues["LOADBALANCER_IP_START"] = lbStart - mergedCommonValues["LOADBALANCER_IP_END"] = lbEnd - mergedCommonValues["REGISTRY_URL"] = registryURL - mergedCommonValues["LOCAL_VOLUME_PATH"] = localVolumePath - - buildID := os.Getenv("BUILD_ID") - if buildID == "" && b.projectRoot != "" { - buildIDPath := filepath.Join(b.projectRoot, ".windsor", ".build-id") - if data, err := b.shims.ReadFile(buildIDPath); err == nil { - buildID = strings.TrimSpace(string(data)) - } - } - if buildID != "" { - mergedCommonValues["BUILD_ID"] = buildID - } - - allValues := make(map[string]any) - - for kustomizationName, substitutions := range b.featureSubstitutions { - if len(substitutions) > 0 { - if allValues[kustomizationName] == nil { - allValues[kustomizationName] = make(map[string]any) - } - if componentMap, ok := allValues[kustomizationName].(map[string]any); ok { - for k, v := range substitutions { - componentMap[k] = v - } - } - } - } - - contextValues, err := b.configHandler.GetContextValues() - if err != nil { - return fmt.Errorf("failed to load context values: %w", err) - } - - if contextValues != nil { - if substitutionValues, ok := contextValues["substitutions"].(map[string]any); ok { - allValues = b.deepMergeMaps(allValues, substitutionValues) - } - } - - if allValues["common"] == nil { - allValues["common"] = make(map[string]any) - } - - if commonMap, ok := allValues["common"].(map[string]any); ok { - maps.Copy(commonMap, mergedCommonValues) - } - - for componentName, componentValues := range allValues { - if componentMap, ok := componentValues.(map[string]any); ok { - configMapName := fmt.Sprintf("values-%s", componentName) - if err := b.createConfigMap(componentMap, configMapName); err != nil { - return fmt.Errorf("failed to create ConfigMap for component %s: %w", componentName, err) - } - } - } - - return nil -} - // validateValuesForSubstitution checks that all values are valid for Flux post-build variable substitution. // Permitted types are string, numeric, and boolean. Allows one level of map nesting if all nested values are scalar. // Slices and nested complex types are not allowed. Returns an error if any value is not a supported type. @@ -1734,53 +1059,6 @@ func (b *BaseBlueprintHandler) validateValuesForSubstitution(values map[string]a return validate(values, "", 0) } -// createConfigMap creates a ConfigMap named configMapName in the "flux-system" namespace for post-build variable substitution. -// Supports scalar values and one level of map nesting. The resulting ConfigMap data is a map of string keys to string values. -func (b *BaseBlueprintHandler) createConfigMap(values map[string]any, configMapName string) error { - if err := b.validateValuesForSubstitution(values); err != nil { - return fmt.Errorf("invalid values in %s: %w", configMapName, err) - } - - stringValues := make(map[string]string) - if err := b.flattenValuesToConfigMap(values, "", stringValues); err != nil { - return fmt.Errorf("failed to flatten values for %s: %w", configMapName, err) - } - - if err := b.kubernetesManager.ApplyConfigMap(configMapName, constants.DefaultFluxSystemNamespace, stringValues); err != nil { - return fmt.Errorf("failed to apply ConfigMap %s: %w", configMapName, err) - } - - return nil -} - -// flattenValuesToConfigMap recursively flattens nested values into a flat map suitable for ConfigMap data. -// Nested maps are flattened using dot notation (e.g., "ingress.host"). -func (b *BaseBlueprintHandler) flattenValuesToConfigMap(values map[string]any, prefix string, result map[string]string) error { - for key, value := range values { - currentKey := key - if prefix != "" { - currentKey = prefix + "." + key - } - - switch v := value.(type) { - case string: - result[currentKey] = v - case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64: - result[currentKey] = fmt.Sprintf("%v", v) - case bool: - result[currentKey] = fmt.Sprintf("%t", v) - case map[string]any: - err := b.flattenValuesToConfigMap(v, currentKey, result) - if err != nil { - return err - } - default: - return fmt.Errorf("unsupported value type for key %s: %T", key, v) - } - } - return nil -} - // deepMergeMaps returns a new map from a deep merge of base and overlay maps. // Overlay values take precedence; nested maps merge recursively. Non-map overlay values replace base values. func (b *BaseBlueprintHandler) deepMergeMaps(base, overlay map[string]any) map[string]any { diff --git a/pkg/composer/blueprint/blueprint_handler_helper_test.go b/pkg/composer/blueprint/blueprint_handler_helper_test.go index 5da34b2c3..f322cb7e4 100644 --- a/pkg/composer/blueprint/blueprint_handler_helper_test.go +++ b/pkg/composer/blueprint/blueprint_handler_helper_test.go @@ -4,208 +4,15 @@ import ( "os" "path/filepath" "testing" - "time" blueprintv1alpha1 "github.com/windsorcli/cli/api/v1alpha1" "github.com/windsorcli/cli/pkg/runtime/config" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) // ============================================================================= // Test Helper Functions // ============================================================================= -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, - }, - }, - }, - }, - shims: NewShims(), - configHandler: config.NewMockConfigHandler(), - projectRoot: "/tmp", - } - - // 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, - }, - }, - }, - }, - shims: NewShims(), - configHandler: config.NewMockConfigHandler(), - projectRoot: "/tmp", - } - - // 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, - }, - }, - }, - }, - shims: NewShims(), - configHandler: config.NewMockConfigHandler(), - projectRoot: "/tmp", - } - - // 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"}, - }, - }, - }, - shims: NewShims(), - configHandler: config.NewMockConfigHandler(), - projectRoot: "/tmp", - } - - // 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_GetKustomizations(t *testing.T) { t.Run("NoKustomizations", func(t *testing.T) { // Given a blueprint handler with no kustomizations diff --git a/pkg/composer/blueprint/blueprint_handler_private_test.go b/pkg/composer/blueprint/blueprint_handler_private_test.go index 880013141..51d49cd7c 100644 --- a/pkg/composer/blueprint/blueprint_handler_private_test.go +++ b/pkg/composer/blueprint/blueprint_handler_private_test.go @@ -2,1313 +2,13 @@ package blueprint import ( "fmt" - "os" - "path/filepath" "strings" "testing" - "time" - sourcev1 "github.com/fluxcd/source-controller/api/v1" - blueprintv1alpha1 "github.com/windsorcli/cli/api/v1alpha1" - "github.com/windsorcli/cli/pkg/runtime/config" - "github.com/windsorcli/cli/pkg/runtime/shell" - "github.com/windsorcli/cli/pkg/di" - "github.com/windsorcli/cli/pkg/provisioner/kubernetes" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -func TestBaseBlueprintHandler_applyValuesConfigMaps(t *testing.T) { - // Given a handler with mocks - setup := func(t *testing.T) *BaseBlueprintHandler { - t.Helper() - mocks := setupMocks(t) - handler := NewBlueprintHandler(mocks.Injector) - handler.shims = mocks.Shims - handler.configHandler = mocks.ConfigHandler - handler.kubernetesManager = mocks.KubernetesManager - handler.shell = mocks.Shell - return handler - } - - t.Run("SuccessWithGlobalValues", func(t *testing.T) { - // Given a handler - handler := setup(t) - - // And mock config root and other config methods - mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) - mockConfigHandler.GetConfigRootFunc = func() (string, error) { - return "/test/config", nil - } - mockConfigHandler.GetStringFunc = func(key string, defaultValue ...string) string { - switch key { - case "dns.domain": - return "example.com" - case "network.loadbalancer_ips.start": - return "192.168.1.100" - case "network.loadbalancer_ips.end": - return "192.168.1.200" - case "docker.registry_url": - return "registry.example.com" - case "id": - return "test-id" - default: - return "" - } - } - mockConfigHandler.GetContextFunc = func() string { - return "test-context" - } - mockConfigHandler.GetStringSliceFunc = func(key string, defaultValue ...[]string) []string { - if key == "cluster.workers.volumes" { - return []string{"/host/path:/container/path"} - } - return []string{} - } - - // And mock kustomize directory with global config.yaml - handler.shims.Stat = func(name string) (os.FileInfo, error) { - if name == filepath.Join("/test/config", "kustomize") { - return &mockFileInfo{name: "kustomize"}, nil - } - if name == filepath.Join("/test/config", "kustomize", "config.yaml") { - return &mockFileInfo{name: "config.yaml"}, nil - } - return nil, os.ErrNotExist - } - - // And mock file read for centralized values - handler.shims.ReadFile = func(name string) ([]byte, error) { - if name == filepath.Join("/test/config", "kustomize", "config.yaml") { - return []byte(`common: - domain: example.com - port: 80 - enabled: true`), nil - } - return nil, os.ErrNotExist - } - - // And mock YAML unmarshal - handler.shims.YamlUnmarshal = func(data []byte, v any) error { - values := v.(*map[string]any) - *values = map[string]any{ - "common": map[string]any{ - "domain": "example.com", - "port": 80, - "enabled": true, - }, - } - return nil - } - - // And mock Kubernetes manager - var appliedConfigMaps []string - mockKubernetesManager := handler.kubernetesManager.(*kubernetes.MockKubernetesManager) - mockKubernetesManager.ApplyConfigMapFunc = func(name, namespace string, data map[string]string) error { - appliedConfigMaps = append(appliedConfigMaps, name) - return nil - } - - // When applying values ConfigMaps - err := handler.applyValuesConfigMaps() - - // Then it should succeed - if err != nil { - t.Fatalf("expected applyValuesConfigMaps to succeed, got: %v", err) - } - - // And it should apply the common values ConfigMap - if len(appliedConfigMaps) != 1 { - t.Errorf("expected 1 ConfigMap to be applied, got %d", len(appliedConfigMaps)) - } - if appliedConfigMaps[0] != "values-common" { - t.Errorf("expected ConfigMap name to be 'values-common', got '%s'", appliedConfigMaps[0]) - } - }) - - t.Run("SuccessWithComponentValues", func(t *testing.T) { - // Given a handler - handler := setup(t) - - // And mock config root and other config methods - projectRoot := filepath.Join("test", "project") - configRoot := filepath.Join("test", "config") - mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) - mockConfigHandler.GetConfigRootFunc = func() (string, error) { - return configRoot, nil - } - mockConfigHandler.GetStringFunc = func(key string, defaultValue ...string) string { - switch key { - case "dns.domain": - return "example.com" - case "network.loadbalancer_ips.start": - return "192.168.1.100" - case "network.loadbalancer_ips.end": - return "192.168.1.200" - case "docker.registry_url": - return "registry.example.com" - case "id": - return "test-id" - default: - return "" - } - } - mockConfigHandler.GetContextFunc = func() string { - return "test-context" - } - mockConfigHandler.GetStringSliceFunc = func(key string, defaultValue ...[]string) []string { - if key == "cluster.workers.volumes" { - return []string{"/host/path:/container/path"} - } - return []string{} - } - mockConfigHandler.GetContextValuesFunc = func() (map[string]any, error) { - return map[string]any{ - "substitutions": map[string]any{ - "common": map[string]any{ - "domain": "example.com", - }, - "ingress": map[string]any{ - "host": "ingress.example.com", - "ssl": true, - }, - }, - }, nil - } - - // Mock shell for project root - mockShell := handler.shell.(*shell.MockShell) - mockShell.GetProjectRootFunc = func() (string, error) { - return projectRoot, nil - } - - // And mock context values with component values - handler.shims.Stat = func(name string) (os.FileInfo, error) { - if name == filepath.Join(projectRoot, "contexts", "_template", "values.yaml") { - return &mockFileInfo{name: "values.yaml"}, nil - } - if name == filepath.Join(configRoot, "values.yaml") { - return &mockFileInfo{name: "values.yaml"}, nil - } - return nil, os.ErrNotExist - } - - // And mock file read for context values - handler.shims.ReadFile = func(name string) ([]byte, error) { - if name == filepath.Join(projectRoot, "contexts", "_template", "values.yaml") { - return []byte(`substitutions: - common: - domain: template.com - ingress: - host: template.example.com`), nil - } - if name == filepath.Join(configRoot, "values.yaml") { - return []byte(`substitutions: - common: - domain: example.com - ingress: - host: ingress.example.com - ssl: true`), nil - } - return nil, os.ErrNotExist - } - - // And mock Kubernetes manager - var appliedConfigMaps []string - mockKubernetesManager := handler.kubernetesManager.(*kubernetes.MockKubernetesManager) - mockKubernetesManager.ApplyConfigMapFunc = func(name, namespace string, data map[string]string) error { - appliedConfigMaps = append(appliedConfigMaps, name) - return nil - } - - // When applying values ConfigMaps - err := handler.applyValuesConfigMaps() - - // Then it should succeed - if err != nil { - t.Fatalf("expected applyValuesConfigMaps to succeed, got: %v", err) - } - - // And it should apply both common and component values ConfigMaps - if len(appliedConfigMaps) != 2 { - t.Errorf("expected 2 ConfigMaps to be applied, got %d: %v", len(appliedConfigMaps), appliedConfigMaps) - } - - // Check that both ConfigMaps were applied (order may vary) - commonFound := false - ingressFound := false - for _, name := range appliedConfigMaps { - if name == "values-common" { - commonFound = true - } - if name == "values-ingress" { - ingressFound = true - } - } - if !commonFound { - t.Error("expected values-common ConfigMap to be applied") - } - if !ingressFound { - t.Error("expected values-ingress ConfigMap to be applied") - } - }) - - t.Run("SuccessWithKustomizationSubstitutions", func(t *testing.T) { - handler := setup(t) - - tmpDir := t.TempDir() - configRoot := filepath.Join(tmpDir, "config") - projectRoot := filepath.Join(tmpDir, "project") - - mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) - mockConfigHandler.GetConfigRootFunc = func() (string, error) { - return configRoot, nil - } - mockConfigHandler.GetStringFunc = func(key string, defaultValue ...string) string { - switch key { - case "dns.domain": - return "example.com" - case "network.loadbalancer_ips.start": - return "192.168.1.100" - case "network.loadbalancer_ips.end": - return "192.168.1.200" - case "docker.registry_url": - return "registry.example.com" - case "id": - return "test-id" - default: - return "" - } - } - mockConfigHandler.GetContextFunc = func() string { - return "test-context" - } - mockConfigHandler.GetStringSliceFunc = func(key string, defaultValue ...[]string) []string { - if key == "cluster.workers.volumes" { - return []string{"/host:/container"} - } - return []string{} - } - mockConfigHandler.GetContextValuesFunc = func() (map[string]any, error) { - return map[string]any{}, nil - } - - mockShell := handler.shell.(*shell.MockShell) - mockShell.GetProjectRootFunc = func() (string, error) { - return projectRoot, nil - } - - handler.shims.Stat = func(name string) (os.FileInfo, error) { - if name == filepath.Join(projectRoot, ".windsor", ".build-id") { - return nil, os.ErrNotExist - } - return nil, os.ErrNotExist - } - - handler.blueprint = blueprintv1alpha1.Blueprint{ - Kustomizations: []blueprintv1alpha1.Kustomization{ - { - Name: "ingress", - Path: "ingress", - }, - { - Name: "monitoring", - Path: "monitoring", - }, - }, - } - - handler.featureSubstitutions = map[string]map[string]string{ - "ingress": { - "host": "ingress.example.com", - "replicas": "3", - }, - "monitoring": { - "retention": "30d", - "enabled": "true", - }, - } - - var appliedConfigMaps []string - var configMapData = make(map[string]map[string]string) - mockKubernetesManager := handler.kubernetesManager.(*kubernetes.MockKubernetesManager) - mockKubernetesManager.ApplyConfigMapFunc = func(name, namespace string, data map[string]string) error { - appliedConfigMaps = append(appliedConfigMaps, name) - configMapData[name] = data - return nil - } - - err := handler.applyValuesConfigMaps() - - if err != nil { - t.Fatalf("expected applyValuesConfigMaps to succeed, got: %v", err) - } - - if len(appliedConfigMaps) != 3 { - t.Errorf("expected 3 ConfigMaps to be applied (common, ingress, monitoring), got %d: %v", len(appliedConfigMaps), appliedConfigMaps) - } - - ingressFound := false - monitoringFound := false - for _, name := range appliedConfigMaps { - if name == "values-ingress" { - ingressFound = true - } - if name == "values-monitoring" { - monitoringFound = true - } - } - - if !ingressFound { - t.Error("expected values-ingress ConfigMap to be applied") - } - if !monitoringFound { - t.Error("expected values-monitoring ConfigMap to be applied") - } - - if data, ok := configMapData["values-ingress"]; ok { - if data["host"] != "ingress.example.com" { - t.Errorf("expected ingress host to be 'ingress.example.com', got '%s'", data["host"]) - } - if data["replicas"] != "3" { - t.Errorf("expected ingress replicas to be '3', got '%s'", data["replicas"]) - } - } - - if data, ok := configMapData["values-monitoring"]; ok { - if data["retention"] != "30d" { - t.Errorf("expected monitoring retention to be '30d', got '%s'", data["retention"]) - } - if data["enabled"] != "true" { - t.Errorf("expected monitoring enabled to be 'true', got '%s'", data["enabled"]) - } - } - }) - - t.Run("EvaluatesKustomizationSubstitutionExpressions", func(t *testing.T) { - handler := setup(t) - - baseBlueprint := []byte(`kind: Blueprint -apiVersion: blueprints.windsorcli.dev/v1alpha1 -metadata: - name: base -kustomize: - - name: ingress - path: ingress -`) - - featureWithSubstitutions := []byte(`kind: Feature -apiVersion: blueprints.windsorcli.dev/v1alpha1 -metadata: - name: ingress-config -when: ingress.enabled == true -kustomize: - - name: ingress - path: ingress - substitutions: - host: "${dns.domain}" - replicas: "${cluster.workers.count}" - url: "https://${dns.domain}" - literal: "my-literal-value" -`) - - templateData := map[string][]byte{ - "blueprint": baseBlueprint, - "features/ingress-cfg.yaml": featureWithSubstitutions, - } - - config := map[string]any{ - "ingress": map[string]any{ - "enabled": true, - }, - "dns": map[string]any{ - "domain": "example.com", - }, - "cluster": map[string]any{ - "workers": map[string]any{ - "count": 3, - }, - }, - } - - err := handler.processFeatures(templateData, config) - - if err != nil { - t.Fatalf("Expected no error, got %v", err) - } - - if len(handler.featureSubstitutions) != 1 { - t.Fatalf("Expected 1 kustomization with substitutions, got %d", len(handler.featureSubstitutions)) - } - - ingressSubs, ok := handler.featureSubstitutions["ingress"] - if !ok { - t.Fatal("Expected ingress substitutions to be present") - } - - if ingressSubs["host"] != "example.com" { - t.Errorf("Expected host to be 'example.com', got '%s'", ingressSubs["host"]) - } - - if ingressSubs["replicas"] != "3" { - t.Errorf("Expected replicas to be '3', got '%s'", ingressSubs["replicas"]) - } - - if ingressSubs["url"] != "https://example.com" { - t.Errorf("Expected url to be 'https://example.com', got '%s'", ingressSubs["url"]) - } - - if ingressSubs["literal"] != "my-literal-value" { - t.Errorf("Expected literal to be 'my-literal-value', got '%s'", ingressSubs["literal"]) - } - }) - - t.Run("NoKustomizeDirectory", func(t *testing.T) { - // Given a handler - handler := setup(t) - - // And mock config root - mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) - mockConfigHandler.GetConfigRootFunc = func() (string, error) { - return "/test/config", nil - } - - // And mock that kustomize directory doesn't exist - handler.shims.Stat = func(name string) (os.FileInfo, error) { - return nil, os.ErrNotExist - } - - // When applying values ConfigMaps - err := handler.applyValuesConfigMaps() - - // Then it should succeed (no-op) - if err != nil { - t.Fatalf("expected applyValuesConfigMaps to succeed when no kustomize directory, got: %v", err) - } - }) - - t.Run("ConfigRootError", func(t *testing.T) { - // Given a handler - handler := setup(t) - - // And mock GetContextValues that fails - mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) - mockConfigHandler.GetContextValuesFunc = func() (map[string]any, error) { - return nil, fmt.Errorf("failed to load context values") - } - - // When applying values ConfigMaps - err := handler.applyValuesConfigMaps() - - // Then it should fail - if err == nil { - t.Fatal("expected applyValuesConfigMaps to fail with context values error") - } - if !strings.Contains(err.Error(), "failed to load context values") { - t.Errorf("expected error about context values, got: %v", err) - } - }) - - t.Run("ReadFileError", func(t *testing.T) { - // Given a handler - handler := setup(t) - - // And mock config root and other config methods - mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) - mockConfigHandler.GetConfigRootFunc = func() (string, error) { - return "/test/config", nil - } - mockConfigHandler.GetStringFunc = func(key string, defaultValue ...string) string { - switch key { - case "dns.domain": - return "example.com" - case "network.loadbalancer_ips.start": - return "192.168.1.100" - case "network.loadbalancer_ips.end": - return "192.168.1.200" - case "docker.registry_url": - return "registry.example.com" - case "id": - return "test-id" - default: - return "" - } - } - mockConfigHandler.GetContextFunc = func() string { - return "test-context" - } - mockConfigHandler.GetStringSliceFunc = func(key string, defaultValue ...[]string) []string { - if key == "cluster.workers.volumes" { - return []string{"/host/path:/container/path"} - } - return []string{} - } - - // And mock kustomize directory and config.yaml exists - handler.shims.Stat = func(name string) (os.FileInfo, error) { - if name == filepath.Join("/test/config", "kustomize") { - return &mockFileInfo{name: "kustomize"}, nil - } - if name == filepath.Join("/test/config", "kustomize", "config.yaml") { - return &mockFileInfo{name: "config.yaml"}, nil - } - return nil, os.ErrNotExist - } - - // And mock ReadFile that fails - handler.shims.ReadFile = func(name string) ([]byte, error) { - if name == filepath.Join("/test/config", "kustomize", "config.yaml") { - return nil, os.ErrPermission - } - return nil, os.ErrNotExist - } - - // Mock YAML marshal - handler.shims.YamlMarshal = func(v any) ([]byte, error) { - return []byte("test"), nil - } - - // When applying values ConfigMaps - err := handler.applyValuesConfigMaps() - - // Then it should still succeed since ReadFile errors are now ignored and rendered values take precedence - if err != nil { - t.Fatalf("expected applyValuesConfigMaps to succeed despite ReadFile error, got: %v", err) - } - }) - - t.Run("ComponentConfigMapError", func(t *testing.T) { - // Given a handler - handler := setup(t) - - // And mock config root and other config methods - mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) - mockConfigHandler.GetConfigRootFunc = func() (string, error) { - return "/test/config", nil - } - mockConfigHandler.GetStringFunc = func(key string, defaultValue ...string) string { - switch key { - case "dns.domain": - return "example.com" - case "network.loadbalancer_ips.start": - return "192.168.1.100" - case "network.loadbalancer_ips.end": - return "192.168.1.200" - case "docker.registry_url": - return "registry.example.com" - case "id": - return "test-id" - default: - return "" - } - } - mockConfigHandler.GetContextFunc = func() string { - return "test-context" - } - mockConfigHandler.GetStringSliceFunc = func(key string, defaultValue ...[]string) []string { - if key == "cluster.workers.volumes" { - return []string{"/host/path:/container/path"} - } - return []string{} - } - - // And mock centralized config.yaml with component values - handler.shims.Stat = func(name string) (os.FileInfo, error) { - if name == filepath.Join("/test/config", "kustomize") { - return &mockFileInfo{name: "kustomize"}, nil - } - if name == filepath.Join("/test/config", "kustomize", "config.yaml") { - return &mockFileInfo{name: "config.yaml"}, nil - } - return nil, os.ErrNotExist - } - - // And mock file read for centralized values - handler.shims.ReadFile = func(name string) ([]byte, error) { - if name == filepath.Join("/test/config", "kustomize", "config.yaml") { - return []byte(`common: - domain: example.com -ingress: - host: ingress.example.com`), nil - } - return nil, os.ErrNotExist - } - - // And mock YAML unmarshal - handler.shims.YamlUnmarshal = func(data []byte, v any) error { - values := v.(*map[string]any) - *values = map[string]any{ - "common": map[string]any{ - "domain": "example.com", - }, - "ingress": map[string]any{ - "host": "ingress.example.com", - }, - } - return nil - } - - // And mock Kubernetes manager that fails - mockKubernetesManager := handler.kubernetesManager.(*kubernetes.MockKubernetesManager) - mockKubernetesManager.ApplyConfigMapFunc = func(name, namespace string, data map[string]string) error { - return os.ErrPermission - } - - // When applying values ConfigMaps - err := handler.applyValuesConfigMaps() - - // Then it should fail - if err == nil { - t.Fatal("expected applyValuesConfigMaps to fail with ConfigMap error") - } - if !strings.Contains(err.Error(), "failed to create ConfigMap for component common") { - t.Errorf("expected error about common ConfigMap creation, got: %v", err) - } - }) -} - -func TestBaseBlueprintHandler_prepareAndConvertKustomization(t *testing.T) { - // Given a handler with mocks - setup := func(t *testing.T) *BaseBlueprintHandler { - t.Helper() - mocks := setupMocks(t) - handler := NewBlueprintHandler(mocks.Injector) - handler.shims = mocks.Shims - handler.configHandler = mocks.ConfigHandler - handler.kubernetesManager = mocks.KubernetesManager - return handler - } - - t.Run("WithGlobalValuesConfigMap", func(t *testing.T) { - // Given a handler with feature substitutions - handler := setup(t) - handler.featureSubstitutions = map[string]map[string]string{ - "test-kustomization": { - "domain": "example.com", - }, - } - - // And initialize the blueprint - handler.blueprint = blueprintv1alpha1.Blueprint{ - Metadata: blueprintv1alpha1.Metadata{ - Name: "test-blueprint", - }, - Repository: blueprintv1alpha1.Repository{ - Url: "https://github.com/test/repo.git", - }, - } - - // And a kustomization - kustomization := blueprintv1alpha1.Kustomization{ - Name: "test-kustomization", - Path: "test/path", - Source: "test-source", - Interval: &metav1.Duration{Duration: 5 * time.Minute}, - RetryInterval: &metav1.Duration{Duration: 1 * time.Minute}, - Timeout: &metav1.Duration{Duration: 10 * time.Minute}, - Force: &[]bool{false}[0], - Wait: &[]bool{false}[0], - } - - // When converting to Flux kustomization - result := handler.prepareAndConvertKustomization(kustomization, "test-namespace") - - // Then it should have PostBuild with ConfigMap references - if result.Spec.PostBuild == nil { - t.Fatal("expected PostBuild to be set") - } - - // And it should have the component ConfigMap reference - if len(result.Spec.PostBuild.SubstituteFrom) < 1 { - t.Fatal("expected at least 1 SubstituteFrom reference") - } - - componentValuesFound := false - for _, ref := range result.Spec.PostBuild.SubstituteFrom { - if ref.Kind == "ConfigMap" && ref.Name == "values-test-kustomization" { - componentValuesFound = true - if ref.Optional != false { - t.Errorf("expected values-test-kustomization ConfigMap to be Optional=false, got %v", ref.Optional) - } - } - } - - if !componentValuesFound { - t.Error("expected values-test-kustomization ConfigMap reference to be present") - } - }) - - t.Run("WithComponentValuesConfigMap", func(t *testing.T) { - // Given a handler with feature substitutions - handler := setup(t) - handler.featureSubstitutions = map[string]map[string]string{ - "ingress": { - "domain": "example.com", - }, - } - - // And initialize the blueprint - handler.blueprint = blueprintv1alpha1.Blueprint{ - Metadata: blueprintv1alpha1.Metadata{ - Name: "test-blueprint", - }, - Repository: blueprintv1alpha1.Repository{ - Url: "https://github.com/test/repo.git", - }, - } - - // And a kustomization with component name - kustomization := blueprintv1alpha1.Kustomization{ - Name: "ingress", - Path: "ingress/path", - Source: "test-source", - Interval: &metav1.Duration{Duration: 5 * time.Minute}, - RetryInterval: &metav1.Duration{Duration: 1 * time.Minute}, - Timeout: &metav1.Duration{Duration: 10 * time.Minute}, - Force: &[]bool{false}[0], - Wait: &[]bool{false}[0], - } - - // When converting to Flux kustomization - result := handler.prepareAndConvertKustomization(kustomization, "test-namespace") - - // Then it should have PostBuild with ConfigMap references - if result.Spec.PostBuild == nil { - t.Fatal("expected PostBuild to be set") - } - - // And it should have the component-specific ConfigMap reference - componentValuesFound := false - for _, ref := range result.Spec.PostBuild.SubstituteFrom { - if ref.Kind == "ConfigMap" && ref.Name == "values-ingress" { - componentValuesFound = true - if ref.Optional != false { - t.Errorf("expected values-ingress ConfigMap to be Optional=false, got %v", ref.Optional) - } - break - } - } - - if !componentValuesFound { - t.Error("expected values-ingress ConfigMap reference to be present") - } - }) - - t.Run("WithoutFeatureSubstitutions", func(t *testing.T) { - // Given a handler - handler := setup(t) - - // And initialize the blueprint - handler.blueprint = blueprintv1alpha1.Blueprint{ - Metadata: blueprintv1alpha1.Metadata{ - Name: "test-blueprint", - }, - Repository: blueprintv1alpha1.Repository{ - Url: "https://github.com/test/repo.git", - }, - } - - // And mock config root - mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) - mockConfigHandler.GetConfigRootFunc = func() (string, error) { - return "/test/config", nil - } - - // And mock that global values.yaml exists - handler.shims.Stat = func(name string) (os.FileInfo, error) { - if name == filepath.Join("/test/config", "kustomize", "values.yaml") { - return &mockFileInfo{name: "values.yaml"}, nil - } - return nil, os.ErrNotExist - } - - // And a kustomization without PostBuild - kustomization := blueprintv1alpha1.Kustomization{ - Name: "test-kustomization", - Path: "test/path", - Source: "test-source", - Interval: &metav1.Duration{Duration: 5 * time.Minute}, - RetryInterval: &metav1.Duration{Duration: 1 * time.Minute}, - Timeout: &metav1.Duration{Duration: 10 * time.Minute}, - Force: &[]bool{false}[0], - Wait: &[]bool{false}[0], - } - - // When converting to Flux kustomization - result := handler.prepareAndConvertKustomization(kustomization, "test-namespace") - - // Then it should have PostBuild with only ConfigMap references from feature substitutions - if result.Spec.PostBuild == nil { - t.Fatal("expected PostBuild to be set") - } - - // And it should not have any Substitute values since PostBuild is no longer user-facing - if len(result.Spec.PostBuild.Substitute) != 0 { - t.Errorf("expected 0 Substitute values, got %d", len(result.Spec.PostBuild.Substitute)) - } - - // And it should not add a component ConfigMap without feature substitutions - for _, ref := range result.Spec.PostBuild.SubstituteFrom { - if ref.Kind == "ConfigMap" && ref.Name == "values-test-kustomization" { - t.Error("did not expect values-test-kustomization ConfigMap reference without feature substitutions") - } - } - }) - - t.Run("WithoutValuesConfigMaps", func(t *testing.T) { - // Given a handler without feature substitutions - handler := setup(t) - - // And initialize the blueprint - handler.blueprint = blueprintv1alpha1.Blueprint{ - Metadata: blueprintv1alpha1.Metadata{ - Name: "test-blueprint", - }, - Repository: blueprintv1alpha1.Repository{ - Url: "https://github.com/test/repo.git", - }, - } - - // And a kustomization - kustomization := blueprintv1alpha1.Kustomization{ - Name: "test-kustomization", - Path: "test/path", - Source: "test-source", - Interval: &metav1.Duration{Duration: 5 * time.Minute}, - RetryInterval: &metav1.Duration{Duration: 1 * time.Minute}, - Timeout: &metav1.Duration{Duration: 10 * time.Minute}, - Force: &[]bool{false}[0], - Wait: &[]bool{false}[0], - } - - // When converting to Flux kustomization - result := handler.prepareAndConvertKustomization(kustomization, "test-namespace") - - // Then it should have PostBuild with only values-common (no component-specific ConfigMap) - if result.Spec.PostBuild == nil { - t.Fatal("expected PostBuild to be set with values-common") - } - if len(result.Spec.PostBuild.SubstituteFrom) != 1 { - t.Errorf("expected 1 SubstituteFrom reference (values-common), got %d", len(result.Spec.PostBuild.SubstituteFrom)) - } - if result.Spec.PostBuild.SubstituteFrom[0].Name != "values-common" { - t.Errorf("expected first SubstituteFrom to be values-common, got %s", result.Spec.PostBuild.SubstituteFrom[0].Name) - } - }) - - t.Run("ConfigRootError", func(t *testing.T) { - // Given a handler without feature substitutions - handler := setup(t) - - // And initialize the blueprint - handler.blueprint = blueprintv1alpha1.Blueprint{ - Metadata: blueprintv1alpha1.Metadata{ - Name: "test-blueprint", - }, - Repository: blueprintv1alpha1.Repository{ - Url: "https://github.com/test/repo.git", - }, - } - - // And a kustomization - kustomization := blueprintv1alpha1.Kustomization{ - Name: "test-kustomization", - Path: "test/path", - Source: "test-source", - Interval: &metav1.Duration{Duration: 5 * time.Minute}, - RetryInterval: &metav1.Duration{Duration: 1 * time.Minute}, - Timeout: &metav1.Duration{Duration: 10 * time.Minute}, - Force: &[]bool{false}[0], - Wait: &[]bool{false}[0], - } - - // When converting to Flux kustomization - result := handler.prepareAndConvertKustomization(kustomization, "test-namespace") - - // Then it should have PostBuild with only values-common (no component-specific ConfigMap) - if result.Spec.PostBuild == nil { - t.Fatal("expected PostBuild to be set with values-common") - } - if len(result.Spec.PostBuild.SubstituteFrom) != 1 { - t.Errorf("expected 1 SubstituteFrom reference (values-common), got %d", len(result.Spec.PostBuild.SubstituteFrom)) - } - if result.Spec.PostBuild.SubstituteFrom[0].Name != "values-common" { - t.Errorf("expected first SubstituteFrom to be values-common, got %s", result.Spec.PostBuild.SubstituteFrom[0].Name) - } - }) - - t.Run("WithPatchFromFile", func(t *testing.T) { - // Given a handler - handler := setup(t) - - // And initialize blueprint - handler.blueprint = blueprintv1alpha1.Blueprint{ - Metadata: blueprintv1alpha1.Metadata{ - Name: "test-blueprint", - }, - Repository: blueprintv1alpha1.Repository{ - Url: "https://github.com/test/repo.git", - }, - } - - // And mock config root - mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) - mockConfigHandler.GetConfigRootFunc = func() (string, error) { - return "/test/config", nil - } - - // And mock ReadFile to return patch content with namespace - handler.shims.ReadFile = func(name string) ([]byte, error) { - if strings.Contains(name, "nginx.yaml") { - return []byte(`apiVersion: v1 -kind: Service -metadata: - name: nginx-ingress-controller - namespace: ingress-nginx -spec: - type: LoadBalancer`), nil - } - return nil, fmt.Errorf("file not found") - } - - // And mock Stat to indicate file doesn't exist - handler.shims.Stat = func(name string) (os.FileInfo, error) { - return nil, os.ErrNotExist - } - - // And a kustomization with patch from file - kustomization := blueprintv1alpha1.Kustomization{ - Name: "ingress", - Path: "ingress", - Source: "test-source", - Patches: []blueprintv1alpha1.BlueprintPatch{ - { - Path: "kustomize/ingress/nginx.yaml", - }, - }, - Interval: &metav1.Duration{Duration: 5 * time.Minute}, - RetryInterval: &metav1.Duration{Duration: 1 * time.Minute}, - Timeout: &metav1.Duration{Duration: 10 * time.Minute}, - Force: &[]bool{false}[0], - Wait: &[]bool{false}[0], - Prune: &[]bool{true}[0], - Destroy: &[]bool{false}[0], - } - - // When converting to Flux kustomization - result := handler.prepareAndConvertKustomization(kustomization, "test-namespace") - - // Then patches should be populated - if len(result.Spec.Patches) != 1 { - t.Fatalf("expected 1 patch, got %d", len(result.Spec.Patches)) - } - - // And patch target should be extracted from file content - patch := result.Spec.Patches[0] - if patch.Target == nil { - t.Fatal("expected Target to be set") - } - if patch.Target.Kind != "Service" { - t.Errorf("expected Target Kind 'Service', got '%s'", patch.Target.Kind) - } - if patch.Target.Name != "nginx-ingress-controller" { - t.Errorf("expected Target Name 'nginx-ingress-controller', got '%s'", patch.Target.Name) - } - if patch.Target.Namespace != "ingress-nginx" { - t.Errorf("expected Target Namespace 'ingress-nginx', got '%s'", patch.Target.Namespace) - } - }) - - t.Run("WithInlinePatchContent", func(t *testing.T) { - // Given a handler - handler := setup(t) - - // And initialize blueprint - handler.blueprint = blueprintv1alpha1.Blueprint{ - Metadata: blueprintv1alpha1.Metadata{ - Name: "test-blueprint", - }, - Repository: blueprintv1alpha1.Repository{ - Url: "https://github.com/test/repo.git", - }, - } - - // And mock Stat to indicate file doesn't exist - handler.shims.Stat = func(name string) (os.FileInfo, error) { - return nil, os.ErrNotExist - } - - // And a kustomization with inline patch - kustomization := blueprintv1alpha1.Kustomization{ - Name: "test-kustomization", - Path: "test/path", - Source: "test-source", - Patches: []blueprintv1alpha1.BlueprintPatch{ - { - Patch: `apiVersion: v1 -kind: ConfigMap -metadata: - name: test-config - namespace: test-ns`, - }, - }, - Interval: &metav1.Duration{Duration: 5 * time.Minute}, - RetryInterval: &metav1.Duration{Duration: 1 * time.Minute}, - Timeout: &metav1.Duration{Duration: 10 * time.Minute}, - Force: &[]bool{false}[0], - Wait: &[]bool{false}[0], - Prune: &[]bool{true}[0], - Destroy: &[]bool{false}[0], - } - - // When converting to Flux kustomization - result := handler.prepareAndConvertKustomization(kustomization, "test-namespace") - - // Then patches should be populated - if len(result.Spec.Patches) != 1 { - t.Fatalf("expected 1 patch, got %d", len(result.Spec.Patches)) - } - - // And patch should have inline content (no file resolution) - patch := result.Spec.Patches[0] - if patch.Patch == "" { - t.Error("expected patch to have content") - } - - // And patch should contain the YAML content - if !strings.Contains(patch.Patch, "test-config") { - t.Error("expected patch content to contain resource name") - } - }) - - t.Run("WithMultiplePatchesFromFiles", func(t *testing.T) { - // Given a handler - handler := setup(t) - - // And initialize blueprint - handler.blueprint = blueprintv1alpha1.Blueprint{ - Metadata: blueprintv1alpha1.Metadata{ - Name: "test-blueprint", - }, - Repository: blueprintv1alpha1.Repository{ - Url: "https://github.com/test/repo.git", - }, - } - - // And mock config root - mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) - mockConfigHandler.GetConfigRootFunc = func() (string, error) { - return "/test/config", nil - } - - // And mock Stat to indicate file doesn't exist - handler.shims.Stat = func(name string) (os.FileInfo, error) { - return nil, os.ErrNotExist - } - - // And mock ReadFile to return different patch contents - handler.shims.ReadFile = func(name string) ([]byte, error) { - if strings.Contains(name, "service.yaml") { - return []byte(`apiVersion: v1 -kind: Service -metadata: - name: test-service - namespace: default`), nil - } - if strings.Contains(name, "deployment.yaml") { - return []byte(`apiVersion: apps/v1 -kind: Deployment -metadata: - name: test-deployment - namespace: default`), nil - } - return nil, fmt.Errorf("file not found") - } - - // And a kustomization with multiple patches - kustomization := blueprintv1alpha1.Kustomization{ - Name: "multi-patch", - Path: "test/path", - Source: "test-source", - Patches: []blueprintv1alpha1.BlueprintPatch{ - {Path: "kustomize/service.yaml"}, - {Path: "kustomize/deployment.yaml"}, - }, - Interval: &metav1.Duration{Duration: 5 * time.Minute}, - RetryInterval: &metav1.Duration{Duration: 1 * time.Minute}, - Timeout: &metav1.Duration{Duration: 10 * time.Minute}, - Force: &[]bool{false}[0], - Wait: &[]bool{false}[0], - Prune: &[]bool{true}[0], - Destroy: &[]bool{false}[0], - } - - // When converting to Flux kustomization - result := handler.prepareAndConvertKustomization(kustomization, "test-namespace") - - // Then multiple patches should be populated - if len(result.Spec.Patches) != 2 { - t.Fatalf("expected 2 patches, got %d", len(result.Spec.Patches)) - } - - // And first patch should be for Service - if result.Spec.Patches[0].Target == nil || result.Spec.Patches[0].Target.Kind != "Service" { - t.Error("expected first patch to target Service") - } - - // And second patch should be for Deployment - if result.Spec.Patches[1].Target == nil || result.Spec.Patches[1].Target.Kind != "Deployment" { - t.Error("expected second patch to target Deployment") - } - }) - - t.Run("WithPatchWithoutNamespace", func(t *testing.T) { - // Given a handler - handler := setup(t) - - // And initialize blueprint - handler.blueprint = blueprintv1alpha1.Blueprint{ - Metadata: blueprintv1alpha1.Metadata{ - Name: "test-blueprint", - }, - Repository: blueprintv1alpha1.Repository{ - Url: "https://github.com/test/repo.git", - }, - } - - // And mock config root - mockConfigHandler := handler.configHandler.(*config.MockConfigHandler) - mockConfigHandler.GetConfigRootFunc = func() (string, error) { - return "/test/config", nil - } - - // And mock Stat to indicate file doesn't exist - handler.shims.Stat = func(name string) (os.FileInfo, error) { - return nil, os.ErrNotExist - } - - // And mock ReadFile to return patch without namespace - handler.shims.ReadFile = func(name string) ([]byte, error) { - return []byte(`apiVersion: v1 -kind: Service -metadata: - name: test-service`), nil - } - - // And a kustomization with patch from file - kustomization := blueprintv1alpha1.Kustomization{ - Name: "test", - Path: "test/path", - Source: "test-source", - Patches: []blueprintv1alpha1.BlueprintPatch{ - {Path: "kustomize/service.yaml"}, - }, - Interval: &metav1.Duration{Duration: 5 * time.Minute}, - RetryInterval: &metav1.Duration{Duration: 1 * time.Minute}, - Timeout: &metav1.Duration{Duration: 10 * time.Minute}, - Force: &[]bool{false}[0], - Wait: &[]bool{false}[0], - Prune: &[]bool{true}[0], - Destroy: &[]bool{false}[0], - } - - // When converting to Flux kustomization - result := handler.prepareAndConvertKustomization(kustomization, "test-namespace") - - // Then patch should be populated - if len(result.Spec.Patches) != 1 { - t.Fatalf("expected 1 patch, got %d", len(result.Spec.Patches)) - } - - // And target should be set from patch content - patch := result.Spec.Patches[0] - if patch.Target == nil { - t.Fatal("expected Target to be set") - } - if patch.Target.Kind != "Service" { - t.Errorf("expected Target Kind 'Service', got '%s'", patch.Target.Kind) - } - if patch.Target.Name != "test-service" { - t.Errorf("expected Target Name 'test-service', got '%s'", patch.Target.Name) - } - }) -} - -func TestBaseBlueprintHandler_applyConfigMap(t *testing.T) { - mocks := setupMocks(t, &SetupOptions{ - ConfigStr: ` -contexts: - test: - id: "test-id" - dns: - domain: "test.com" - network: - loadbalancer_ips: - start: "10.0.0.1" - end: "10.0.0.10" - docker: - registry_url: "registry.test" - cluster: - workers: - volumes: ["/tmp:/data"] -`, - }) - - handler := NewBlueprintHandler(mocks.Injector) - if err := handler.Initialize(); err != nil { - t.Fatalf("failed to initialize handler: %v", err) - } - - // Set up build ID by ensuring project root is writable and creating the build ID file - testBuildID := "build-1234567890" - projectRoot, err := mocks.Shell.GetProjectRoot() - if err != nil { - t.Fatalf("failed to get project root: %v", err) - } - buildIDDir := filepath.Join(projectRoot, ".windsor") - buildIDPath := filepath.Join(buildIDDir, ".build-id") - - // Ensure the directory exists and create the build ID file - if err := os.MkdirAll(buildIDDir, 0755); err != nil { - t.Fatalf("failed to create build ID directory: %v", err) - } - if err := os.WriteFile(buildIDPath, []byte(testBuildID), 0644); err != nil { - t.Fatalf("failed to create build ID file: %v", err) - } - - // Mock the kubernetes manager to capture the ConfigMap data - var capturedData map[string]string - mocks.KubernetesManager.ApplyConfigMapFunc = func(name, namespace string, data map[string]string) error { - capturedData = data - return nil - } - - // Call applyValuesConfigMaps - if err := handler.applyValuesConfigMaps(); err != nil { - t.Fatalf("failed to apply ConfigMap: %v", err) - } - - // Verify BUILD_ID is included in the ConfigMap data - if capturedData == nil { - t.Fatal("ConfigMap data was not captured") - } - - buildID, exists := capturedData["BUILD_ID"] - if !exists { - t.Fatal("BUILD_ID not found in ConfigMap data") - } - - if buildID != testBuildID { - t.Errorf("expected BUILD_ID to be %s, got %s", testBuildID, buildID) - } - - // Verify other expected fields are present - expectedFields := []string{"DOMAIN", "CONTEXT", "CONTEXT_ID", "LOADBALANCER_IP_RANGE", "REGISTRY_URL"} - for _, field := range expectedFields { - if _, exists := capturedData[field]; !exists { - t.Errorf("expected field %s not found in ConfigMap data", field) - } - } -} + "github.com/windsorcli/cli/pkg/di" + "github.com/windsorcli/cli/pkg/runtime/config" + "github.com/windsorcli/cli/pkg/runtime/shell" +) func TestBaseBlueprintHandler_resolvePatchFromPath(t *testing.T) { setup := func(t *testing.T) *BaseBlueprintHandler { @@ -2283,291 +983,6 @@ func TestBaseBlueprintHandler_validateValuesForSubstitution(t *testing.T) { }) } -func TestBaseBlueprintHandler_applyOCIRepository(t *testing.T) { - setup := func(t *testing.T) (*BaseBlueprintHandler, *kubernetes.MockKubernetesManager) { - t.Helper() - mocks := setupMocks(t) - handler := NewBlueprintHandler(mocks.Injector) - handler.shims = mocks.Shims - handler.configHandler = mocks.ConfigHandler - handler.kubernetesManager = mocks.KubernetesManager - return handler, mocks.KubernetesManager - } - - t.Run("WithTagInURL", func(t *testing.T) { - // Given a handler - handler, mockKM := setup(t) - - // And a mock that captures the applied repository - var appliedRepo *sourcev1.OCIRepository - mockKM.ApplyOCIRepositoryFunc = func(repo *sourcev1.OCIRepository) error { - appliedRepo = repo - return nil - } - - // And a source with tag in URL - source := blueprintv1alpha1.Source{ - Name: "test-oci-source", - Url: "oci://ghcr.io/test/repo:v1.0.0", - } - - // When applying OCI repository - err := handler.applyOCIRepository(source, "test-namespace") - - // Then no error should occur - if err != nil { - t.Fatalf("expected no error, got: %v", err) - } - - // And repository should be created with correct fields - if appliedRepo == nil { - t.Fatal("expected repository to be applied") - } - if appliedRepo.Name != "test-oci-source" { - t.Errorf("expected Name 'test-oci-source', got '%s'", appliedRepo.Name) - } - if appliedRepo.Namespace != "test-namespace" { - t.Errorf("expected Namespace 'test-namespace', got '%s'", appliedRepo.Namespace) - } - if appliedRepo.Spec.URL != "oci://ghcr.io/test/repo" { - t.Errorf("expected URL without tag, got '%s'", appliedRepo.Spec.URL) - } - if appliedRepo.Spec.Reference == nil || appliedRepo.Spec.Reference.Tag != "v1.0.0" { - t.Errorf("expected tag 'v1.0.0', got %v", appliedRepo.Spec.Reference) - } - }) - - t.Run("WithTagInRefField", func(t *testing.T) { - // Given a handler - handler, mockKM := setup(t) - - // And a mock that captures the applied repository - var appliedRepo *sourcev1.OCIRepository - mockKM.ApplyOCIRepositoryFunc = func(repo *sourcev1.OCIRepository) error { - appliedRepo = repo - return nil - } - - // And a source with tag in Ref field - source := blueprintv1alpha1.Source{ - Name: "test-oci-ref", - Url: "oci://ghcr.io/test/repo", - Ref: blueprintv1alpha1.Reference{ - Tag: "v2.0.0", - }, - } - - // When applying OCI repository - err := handler.applyOCIRepository(source, "test-namespace") - - // Then no error should occur - if err != nil { - t.Fatalf("expected no error, got: %v", err) - } - - // And tag should be from Ref field - if appliedRepo.Spec.Reference == nil || appliedRepo.Spec.Reference.Tag != "v2.0.0" { - t.Errorf("expected tag 'v2.0.0', got %v", appliedRepo.Spec.Reference) - } - }) - - t.Run("WithSemVerInRefField", func(t *testing.T) { - // Given a handler - handler, mockKM := setup(t) - - // And a mock that captures the applied repository - var appliedRepo *sourcev1.OCIRepository - mockKM.ApplyOCIRepositoryFunc = func(repo *sourcev1.OCIRepository) error { - appliedRepo = repo - return nil - } - - // And a source with SemVer in Ref field - source := blueprintv1alpha1.Source{ - Name: "test-oci-semver", - Url: "oci://ghcr.io/test/repo", - Ref: blueprintv1alpha1.Reference{ - SemVer: ">=1.0.0 <2.0.0", - }, - } - - // When applying OCI repository - err := handler.applyOCIRepository(source, "test-namespace") - - // Then no error should occur - if err != nil { - t.Fatalf("expected no error, got: %v", err) - } - - // And SemVer should be set - if appliedRepo.Spec.Reference == nil || appliedRepo.Spec.Reference.SemVer != ">=1.0.0 <2.0.0" { - t.Errorf("expected SemVer '>=1.0.0 <2.0.0', got %v", appliedRepo.Spec.Reference) - } - }) - - t.Run("WithCommitDigest", func(t *testing.T) { - // Given a handler - handler, mockKM := setup(t) - - // And a mock that captures the applied repository - var appliedRepo *sourcev1.OCIRepository - mockKM.ApplyOCIRepositoryFunc = func(repo *sourcev1.OCIRepository) error { - appliedRepo = repo - return nil - } - - // And a source with commit digest - source := blueprintv1alpha1.Source{ - Name: "test-oci-digest", - Url: "oci://ghcr.io/test/repo", - Ref: blueprintv1alpha1.Reference{ - Commit: "sha256:abc123", - }, - } - - // When applying OCI repository - err := handler.applyOCIRepository(source, "test-namespace") - - // Then no error should occur - if err != nil { - t.Fatalf("expected no error, got: %v", err) - } - - // And Digest should be set - if appliedRepo.Spec.Reference == nil || appliedRepo.Spec.Reference.Digest != "sha256:abc123" { - t.Errorf("expected Digest 'sha256:abc123', got %v", appliedRepo.Spec.Reference) - } - }) - - t.Run("WithSecretReference", func(t *testing.T) { - // Given a handler - handler, mockKM := setup(t) - - // And a mock that captures the applied repository - var appliedRepo *sourcev1.OCIRepository - mockKM.ApplyOCIRepositoryFunc = func(repo *sourcev1.OCIRepository) error { - appliedRepo = repo - return nil - } - - // And a source with secret reference - source := blueprintv1alpha1.Source{ - Name: "test-oci-secret", - Url: "oci://ghcr.io/test/private-repo", - SecretName: "registry-credentials", - } - - // When applying OCI repository - err := handler.applyOCIRepository(source, "test-namespace") - - // Then no error should occur - if err != nil { - t.Fatalf("expected no error, got: %v", err) - } - - // And secret reference should be set - if appliedRepo.Spec.SecretRef == nil || appliedRepo.Spec.SecretRef.Name != "registry-credentials" { - t.Errorf("expected SecretRef 'registry-credentials', got %v", appliedRepo.Spec.SecretRef) - } - }) - - t.Run("DefaultLatestTag", func(t *testing.T) { - // Given a handler - handler, mockKM := setup(t) - - // And a mock that captures the applied repository - var appliedRepo *sourcev1.OCIRepository - mockKM.ApplyOCIRepositoryFunc = func(repo *sourcev1.OCIRepository) error { - appliedRepo = repo - return nil - } - - // And a source without any tag specified - source := blueprintv1alpha1.Source{ - Name: "test-oci-default", - Url: "oci://ghcr.io/test/repo", - } - - // When applying OCI repository - err := handler.applyOCIRepository(source, "test-namespace") - - // Then no error should occur - if err != nil { - t.Fatalf("expected no error, got: %v", err) - } - - // And default 'latest' tag should be used - if appliedRepo.Spec.Reference == nil || appliedRepo.Spec.Reference.Tag != "latest" { - t.Errorf("expected default tag 'latest', got %v", appliedRepo.Spec.Reference) - } - }) - - t.Run("ApplyRepositoryError", func(t *testing.T) { - // Given a handler - handler, mockKM := setup(t) - - // And a mock that returns an error - mockKM.ApplyOCIRepositoryFunc = func(repo *sourcev1.OCIRepository) error { - return fmt.Errorf("apply failed: network error") - } - - // And a source - source := blueprintv1alpha1.Source{ - Name: "test-oci-error", - Url: "oci://ghcr.io/test/repo", - } - - // When applying OCI repository - err := handler.applyOCIRepository(source, "test-namespace") - - // Then an error should occur - if err == nil { - t.Fatal("expected error, got nil") - } - - // And error message should contain the failure - if !strings.Contains(err.Error(), "network error") { - t.Errorf("expected error to contain 'network error', got: %v", err) - } - }) - - t.Run("URLWithPortShouldNotExtractTag", func(t *testing.T) { - // Given a handler - handler, mockKM := setup(t) - - // And a mock that captures the applied repository - var appliedRepo *sourcev1.OCIRepository - mockKM.ApplyOCIRepositoryFunc = func(repo *sourcev1.OCIRepository) error { - appliedRepo = repo - return nil - } - - // And a source with port in URL (should not be treated as tag) - source := blueprintv1alpha1.Source{ - Name: "test-oci-port", - Url: "oci://registry.local:5000/test/repo", - } - - // When applying OCI repository - err := handler.applyOCIRepository(source, "test-namespace") - - // Then no error should occur - if err != nil { - t.Fatalf("expected no error, got: %v", err) - } - - // And URL should remain unchanged (port not extracted as tag) - if appliedRepo.Spec.URL != "oci://registry.local:5000/test/repo" { - t.Errorf("expected URL to keep port, got '%s'", appliedRepo.Spec.URL) - } - - // And default 'latest' tag should be used - if appliedRepo.Spec.Reference == nil || appliedRepo.Spec.Reference.Tag != "latest" { - t.Errorf("expected default tag 'latest', got %v", appliedRepo.Spec.Reference) - } - }) -} - func TestBaseBlueprintHandler_parseFeature(t *testing.T) { setup := func(t *testing.T) *BaseBlueprintHandler { t.Helper() diff --git a/pkg/composer/blueprint/blueprint_handler_public_test.go b/pkg/composer/blueprint/blueprint_handler_public_test.go index 519419558..05e042e2e 100644 --- a/pkg/composer/blueprint/blueprint_handler_public_test.go +++ b/pkg/composer/blueprint/blueprint_handler_public_test.go @@ -12,16 +12,14 @@ import ( helmv2 "github.com/fluxcd/helm-controller/api/v2" kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1" - sourcev1 "github.com/fluxcd/source-controller/api/v1" "github.com/goccy/go-yaml" blueprintv1alpha1 "github.com/windsorcli/cli/api/v1alpha1" "github.com/windsorcli/cli/pkg/composer/artifact" "github.com/windsorcli/cli/pkg/constants" - "github.com/windsorcli/cli/pkg/runtime/config" - "github.com/windsorcli/cli/pkg/runtime/shell" "github.com/windsorcli/cli/pkg/di" "github.com/windsorcli/cli/pkg/provisioner/kubernetes" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "github.com/windsorcli/cli/pkg/runtime/config" + "github.com/windsorcli/cli/pkg/runtime/shell" ) // ============================================================================= @@ -492,9 +490,7 @@ func TestBlueprintHandler_NewBlueprintHandler(t *testing.T) { if handler.shell != nil { t.Error("Expected shell to be nil before Initialize()") } - if handler.kubernetesManager != nil { - t.Error("Expected kubernetesManager to be nil before Initialize()") - } + // kubernetesManager removed - no longer part of blueprint handler // When Initialize is called err := handler.Initialize() @@ -509,9 +505,7 @@ func TestBlueprintHandler_NewBlueprintHandler(t *testing.T) { if handler.shell == nil { t.Error("Expected shell to be set after Initialize()") } - if handler.kubernetesManager == nil { - t.Error("Expected kubernetesManager to be set after Initialize()") - } + // kubernetesManager removed - no longer part of blueprint handler }) } @@ -587,27 +581,6 @@ func TestBlueprintHandler_Initialize(t *testing.T) { } }) - t.Run("ErrorResolvingKubernetesManager", func(t *testing.T) { - // Given a handler with missing kubernetesManager - handler, mocks := setup(t) - - // And an injector that registers nil for kubernetesManager - mocks.Injector.Register("configHandler", mocks.ConfigHandler) - mocks.Injector.Register("shell", mocks.Shell) - mocks.Injector.Register("kubernetesManager", nil) - - // When calling Initialize - err := handler.Initialize() - - // Then an error should be returned - if err == nil { - t.Error("Expected error, got nil") - } - if !strings.Contains(err.Error(), "error resolving kubernetesManager") { - t.Errorf("Expected kubernetesManager resolution error, got: %v", err) - } - }) - } func TestBlueprintHandler_LoadConfig(t *testing.T) { @@ -991,1087 +964,6 @@ kustomize: []` }) } -func TestBlueprintHandler_Install(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 BlueprintHandler: %v", err) - } - return handler, mocks - } - - t.Run("Success", func(t *testing.T) { - // Given a blueprint handler with repository, sources, and kustomizations - handler, _ := setup(t) - - handler.blueprint.Repository = blueprintv1alpha1.Repository{ - Url: "git::https://example.com/repo.git", - Ref: blueprintv1alpha1.Reference{Branch: "main"}, - } - - expectedSources := []blueprintv1alpha1.Source{ - { - Name: "source1", - Url: "https://example.com/source1.git", - Ref: blueprintv1alpha1.Reference{Branch: "main"}, - }, - } - handler.blueprint.Sources = expectedSources - - expectedKustomizations := []blueprintv1alpha1.Kustomization{ - { - Name: "kustomization1", - }, - } - handler.blueprint.Kustomizations = expectedKustomizations - - // 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) - } - }) - - t.Run("KustomizationDefaults", func(t *testing.T) { - // Given a blueprint handler with repository and kustomizations - handler, mocks := setup(t) - - handler.blueprint.Repository = blueprintv1alpha1.Repository{ - Url: "git::https://example.com/repo.git", - Ref: blueprintv1alpha1.Reference{Branch: "main"}, - } - - // And a blueprint with metadata name - handler.blueprint.Metadata.Name = "test-blueprint" - - // And kustomizations with various configurations - kustomizations := []blueprintv1alpha1.Kustomization{ - { - Name: "k1", // No source, should use blueprint name - }, - { - Name: "k2", - Source: "custom-source", // Explicit source - }, - { - Name: "k3", // No path, should default to "kustomize" - }, - { - Name: "k4", - Path: "custom/path", // Custom path, should be prefixed with "kustomize/" - }, - { - Name: "k5", // No intervals/timeouts, should use defaults - }, - { - Name: "k6", - Interval: &metav1.Duration{Duration: 2 * time.Minute}, - RetryInterval: &metav1.Duration{Duration: 30 * time.Second}, - Timeout: &metav1.Duration{Duration: 5 * time.Minute}, - }, - } - handler.blueprint.Kustomizations = kustomizations - - // And a mock that captures the applied kustomizations - var appliedKustomizations []kustomizev1.Kustomization - mocks.KubernetesManager.ApplyKustomizationFunc = func(k kustomizev1.Kustomization) error { - appliedKustomizations = append(appliedKustomizations, k) - return nil - } - - // 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 kustomizations should have the correct defaults - if len(appliedKustomizations) != 6 { - t.Fatalf("Expected 6 kustomizations to be applied, got %d", len(appliedKustomizations)) - } - - // Verify k1 (no source) - if appliedKustomizations[0].Spec.SourceRef.Name != "test-blueprint" { - t.Errorf("Expected k1 source to be 'test-blueprint', got '%s'", appliedKustomizations[0].Spec.SourceRef.Name) - } - - // Verify k2 (explicit source) - if appliedKustomizations[1].Spec.SourceRef.Name != "custom-source" { - t.Errorf("Expected k2 source to be 'custom-source', got '%s'", appliedKustomizations[1].Spec.SourceRef.Name) - } - - // Verify k3 (no path) - if appliedKustomizations[2].Spec.Path != "kustomize" { - t.Errorf("Expected k3 path to be 'kustomize', got '%s'", appliedKustomizations[2].Spec.Path) - } - - // Verify k4 (custom path) - if appliedKustomizations[3].Spec.Path != "kustomize/custom/path" { - t.Errorf("Expected k4 path to be 'kustomize/custom/path', got '%s'", appliedKustomizations[3].Spec.Path) - } - - // Verify k5 (default intervals/timeouts) - if appliedKustomizations[4].Spec.Interval.Duration != constants.DefaultFluxKustomizationInterval { - t.Errorf("Expected k5 interval to be %v, got %v", constants.DefaultFluxKustomizationInterval, appliedKustomizations[4].Spec.Interval.Duration) - } - if appliedKustomizations[4].Spec.RetryInterval.Duration != constants.DefaultFluxKustomizationRetryInterval { - t.Errorf("Expected k5 retry interval to be %v, got %v", constants.DefaultFluxKustomizationRetryInterval, appliedKustomizations[4].Spec.RetryInterval.Duration) - } - if appliedKustomizations[4].Spec.Timeout.Duration != constants.DefaultFluxKustomizationTimeout { - t.Errorf("Expected k5 timeout to be %v, got %v", constants.DefaultFluxKustomizationTimeout, appliedKustomizations[4].Spec.Timeout.Duration) - } - - // Verify k6 (custom intervals/timeouts) - if appliedKustomizations[5].Spec.Interval.Duration != 2*time.Minute { - t.Errorf("Expected k6 interval to be 2m, got %v", appliedKustomizations[5].Spec.Interval.Duration) - } - if appliedKustomizations[5].Spec.RetryInterval.Duration != 30*time.Second { - t.Errorf("Expected k6 retry interval to be 30s, got %v", appliedKustomizations[5].Spec.RetryInterval.Duration) - } - if appliedKustomizations[5].Spec.Timeout.Duration != 5*time.Minute { - t.Errorf("Expected k6 timeout to be 5m, got %v", appliedKustomizations[5].Spec.Timeout.Duration) - } - }) - - t.Run("ApplyKustomizationError", func(t *testing.T) { - // Given a blueprint handler with repository, sources, and kustomizations - handler, mocks := setup(t) - - handler.blueprint.Repository = blueprintv1alpha1.Repository{ - Url: "git::https://example.com/repo.git", - Ref: blueprintv1alpha1.Reference{Branch: "main"}, - } - - sources := []blueprintv1alpha1.Source{ - { - Name: "source1", - Url: "git::https://example.com/source1.git", - Ref: blueprintv1alpha1.Reference{Branch: "main"}, - PathPrefix: "terraform", - }, - } - handler.blueprint.Sources = sources - - kustomizations := []blueprintv1alpha1.Kustomization{ - { - Name: "kustomization1", - }, - } - handler.blueprint.Kustomizations = kustomizations - - // Set up mock to return error for ApplyKustomization - mocks.KubernetesManager.ApplyKustomizationFunc = func(kustomization kustomizev1.Kustomization) error { - return fmt.Errorf("apply error") - } - - // When installing the blueprint - err := handler.Install() - - // Then an error should be returned - if err == nil { - t.Error("Expected error, got nil") - } - if !strings.Contains(err.Error(), "failed to apply kustomization kustomization1") { - t.Errorf("Expected error about failed kustomization apply, got: %v", err) - } - }) - - t.Run("Error_CreateManagedNamespace", func(t *testing.T) { - // Given a blueprint handler with namespace creation error - handler, mocks := setup(t) - - // Override: CreateNamespace returns error - mocks.KubernetesManager.CreateNamespaceFunc = func(name string) error { - return fmt.Errorf("namespace creation error") - } - - // When installing the blueprint - err := handler.Install() - - // Then an error should be returned - if err == nil { - t.Error("Expected error, got nil") - } - if !strings.Contains(err.Error(), "failed to create namespace") { - t.Errorf("Expected namespace creation error, got: %v", err) - } - }) - - t.Run("Error_ApplyMainRepository", func(t *testing.T) { - // Given a blueprint handler with main repository apply error - handler, mocks := setup(t) - - handler.blueprint.Repository = blueprintv1alpha1.Repository{ - Url: "git::https://example.com/repo.git", - Ref: blueprintv1alpha1.Reference{Branch: "main"}, - } - - // Override: ApplyGitRepository returns error - mocks.KubernetesManager.ApplyGitRepositoryFunc = func(repo *sourcev1.GitRepository) error { - return fmt.Errorf("git repository apply error") - } - - // When installing the blueprint - err := handler.Install() - - // Then an error should be returned - if err == nil { - t.Error("Expected error, got nil") - } - if !strings.Contains(err.Error(), "failed to apply blueprint repository") { - t.Errorf("Expected main repository error, got: %v", err) - } - }) - - t.Run("Error_ApplySourceRepository", func(t *testing.T) { - // Given a blueprint handler with source repository apply error - handler, mocks := setup(t) - - sources := []blueprintv1alpha1.Source{ - { - Name: "source1", - Url: "https://example.com/source1.git", - Ref: blueprintv1alpha1.Reference{Branch: "main"}, - }, - } - handler.blueprint.Sources = sources - - // Override: ApplyGitRepository returns error for sources - mocks.KubernetesManager.ApplyGitRepositoryFunc = func(repo *sourcev1.GitRepository) error { - return fmt.Errorf("source repository apply error") - } - - // When installing the blueprint - err := handler.Install() - - // Then an error should be returned - if err == nil { - t.Error("Expected error, got nil") - } - if !strings.Contains(err.Error(), "failed to apply source source1") { - t.Errorf("Expected source repository error, got: %v", err) - } - }) - - t.Run("Error_ApplyConfigMap", func(t *testing.T) { - // Given a blueprint handler with configmap apply error - handler, mocks := setup(t) - - // Override: ApplyConfigMap returns error - mocks.KubernetesManager.ApplyConfigMapFunc = func(name, namespace string, data map[string]string) error { - return fmt.Errorf("configmap apply error") - } - - // When installing the blueprint - err := handler.Install() - - // Then an error should be returned - if err == nil { - t.Error("Expected error, got nil") - } - if !strings.Contains(err.Error(), "failed to apply values configmaps") { - t.Errorf("Expected values configmaps error, got: %v", err) - } - }) - - t.Run("Success_EmptyRepositoryUrl", func(t *testing.T) { - // Given a blueprint handler with empty repository URL - handler, _ := setup(t) - - // Repository with empty URL should be skipped - handler.blueprint.Repository = blueprintv1alpha1.Repository{ - Url: "", - } - - sources := []blueprintv1alpha1.Source{ - { - Name: "source1", - Url: "https://example.com/source1.git", - Ref: blueprintv1alpha1.Reference{Branch: "main"}, - }, - } - handler.blueprint.Sources = sources - - // When installing the blueprint - err := handler.Install() - - // Then no error should be returned - if err != nil { - t.Errorf("Expected no error, got: %v", err) - } - }) - - t.Run("Success_NoSources", func(t *testing.T) { - // Given a blueprint handler with no sources - handler, _ := setup(t) - - handler.blueprint.Repository = blueprintv1alpha1.Repository{ - Url: "git::https://example.com/repo.git", - Ref: blueprintv1alpha1.Reference{Branch: "main"}, - } - - // No sources defined - handler.blueprint.Sources = []blueprintv1alpha1.Source{} - - // When installing the blueprint - err := handler.Install() - - // Then no error should be returned - if err != nil { - t.Errorf("Expected no error, got: %v", err) - } - }) - - t.Run("Success_NoKustomizations", func(t *testing.T) { - // Given a blueprint handler with no kustomizations - handler, _ := setup(t) - - handler.blueprint.Repository = blueprintv1alpha1.Repository{ - Url: "git::https://example.com/repo.git", - Ref: blueprintv1alpha1.Reference{Branch: "main"}, - } - - // No kustomizations defined - handler.blueprint.Kustomizations = []blueprintv1alpha1.Kustomization{} - - // When installing the blueprint - err := handler.Install() - - // Then no error should be returned - if err != nil { - t.Errorf("Expected no error, got: %v", err) - } - }) - - t.Run("Success_WithSecretName", func(t *testing.T) { - // Given a blueprint handler with repository that has secret name - handler, _ := setup(t) - - handler.blueprint.Repository = blueprintv1alpha1.Repository{ - Url: "git::https://example.com/private-repo.git", - Ref: blueprintv1alpha1.Reference{Branch: "main"}, - SecretName: "git-credentials", - } - - sources := []blueprintv1alpha1.Source{ - { - Name: "source1", - Url: "https://example.com/private-source.git", - Ref: blueprintv1alpha1.Reference{Branch: "main"}, - SecretName: "source-credentials", - }, - } - handler.blueprint.Sources = sources - - // When installing the blueprint - err := handler.Install() - - // Then no error should be returned - if err != nil { - t.Errorf("Expected no error, got: %v", err) - } - }) -} - -func TestBlueprintHandler_WaitForKustomizations(t *testing.T) { - setup := func(t *testing.T) (*BaseBlueprintHandler, *Mocks) { - t.Helper() - mocks := setupMocks(t) - handler := NewBlueprintHandler(mocks.Injector) - handler.shims = mocks.Shims - err := handler.Initialize() - if err != nil { - t.Fatalf("Failed to initialize handler: %v", err) - } - return handler, mocks - } - - // setupFastTiming sets up fast timing mocks for testing - setupFastTiming := func(handler *BaseBlueprintHandler) { - // Mock TimeAfter to return a channel that never fires (for non-timeout tests) - // or fires immediately (for timeout tests) - handler.shims.TimeAfter = func(d time.Duration) <-chan time.Time { - if d <= 1*time.Millisecond { - // For very short durations (timeout tests), fire immediately - ch := make(chan time.Time, 1) - ch <- time.Now() - return ch - } - // For normal durations, return a channel that never fires - return make(chan time.Time) - } - - // Mock NewTicker to return a ticker that fires every 1ms - handler.shims.NewTicker = func(d time.Duration) *time.Ticker { - return time.NewTicker(1 * time.Millisecond) - } - - // Keep the original TickerStop - handler.shims.TickerStop = func(t *time.Ticker) { t.Stop() } - } - - t.Run("Success_ImmediateReady", func(t *testing.T) { - // Given a blueprint handler with kustomizations that are immediately ready - handler, mocks := setup(t) - setupFastTiming(handler) - - // Set up blueprint with kustomizations - handler.blueprint.Kustomizations = []blueprintv1alpha1.Kustomization{ - {Name: "test-kustomization-1"}, - {Name: "test-kustomization-2"}, - } - - // Track method calls - checkGitRepoStatusCalled := false - getKustomizationStatusCalled := false - - // Override: return ready status immediately - mocks.KubernetesManager.CheckGitRepositoryStatusFunc = func() error { - checkGitRepoStatusCalled = true - return nil - } - mocks.KubernetesManager.GetKustomizationStatusFunc = func(names []string) (map[string]bool, error) { - getKustomizationStatusCalled = true - status := make(map[string]bool) - for _, name := range names { - status[name] = true // All ready - } - return status, nil - } - - // When waiting for kustomizations - err := handler.WaitForKustomizations("Testing kustomizations") - - // Then no error should be returned - if err != nil { - t.Errorf("Expected no error, got: %v", err) - } - - // And CheckGitRepositoryStatus should be called - if !checkGitRepoStatusCalled { - t.Error("Expected CheckGitRepositoryStatus to be called") - } - - // And GetKustomizationStatus should be called - if !getKustomizationStatusCalled { - t.Error("Expected GetKustomizationStatus to be called") - } - }) - - t.Run("Success_SpecificNames", func(t *testing.T) { - // Given a blueprint handler - handler, mocks := setup(t) - setupFastTiming(handler) - - // Override: return ready status and verify specific names - mocks.KubernetesManager.CheckGitRepositoryStatusFunc = func() error { - return nil - } - mocks.KubernetesManager.GetKustomizationStatusFunc = func(names []string) (map[string]bool, error) { - // Verify specific names are passed - expectedNames := []string{"custom-kustomization-1", "custom-kustomization-2"} - if len(names) != len(expectedNames) { - t.Errorf("Expected %d names, got %d", len(expectedNames), len(names)) - } - for i, name := range names { - if name != expectedNames[i] { - t.Errorf("Expected name %s, got %s", expectedNames[i], name) - } - } - - status := make(map[string]bool) - for _, name := range names { - status[name] = true - } - return status, nil - } - - // When waiting for specific kustomizations - err := handler.WaitForKustomizations("Testing specific kustomizations", "custom-kustomization-1", "custom-kustomization-2") - - // Then no error should be returned - if err != nil { - t.Errorf("Expected no error, got: %v", err) - } - }) - - t.Run("Success_AfterPolling", func(t *testing.T) { - // Given a blueprint handler with kustomizations - handler, mocks := setup(t) - setupFastTiming(handler) - - handler.blueprint.Kustomizations = []blueprintv1alpha1.Kustomization{ - {Name: "test-kustomization"}, - } - - // Override: return not ready initially, then ready - callCount := 0 - mocks.KubernetesManager.CheckGitRepositoryStatusFunc = func() error { - return nil - } - mocks.KubernetesManager.GetKustomizationStatusFunc = func(names []string) (map[string]bool, error) { - callCount++ - status := make(map[string]bool) - for _, name := range names { - // Ready on second call - status[name] = callCount >= 2 - } - return status, nil - } - - // When waiting for kustomizations - err := handler.WaitForKustomizations("Testing polling") - - // Then no error should be returned - if err != nil { - t.Errorf("Expected no error, got: %v", err) - } - - // And GetKustomizationStatus should be called multiple times - if callCount < 2 { - t.Errorf("Expected at least 2 calls to GetKustomizationStatus, got %d", callCount) - } - }) - - t.Run("Error_GitRepositoryStatus", func(t *testing.T) { - // Given a blueprint handler - handler, mocks := setup(t) - setupFastTiming(handler) - - handler.blueprint.Kustomizations = []blueprintv1alpha1.Kustomization{ - {Name: "test-kustomization"}, - } - - // Override: return git repository error - mocks.KubernetesManager.CheckGitRepositoryStatusFunc = func() error { - return fmt.Errorf("git repository not ready") - } - - // When waiting for kustomizations - err := handler.WaitForKustomizations("Testing git repo error") - - // Then an error should be returned - if err == nil { - t.Error("Expected error, got nil") - } - if !strings.Contains(err.Error(), "git repository error") { - t.Errorf("Expected git repository error, got: %v", err) - } - }) - - t.Run("Error_KustomizationStatus", func(t *testing.T) { - // Given a blueprint handler - handler, mocks := setup(t) - setupFastTiming(handler) - - handler.blueprint.Kustomizations = []blueprintv1alpha1.Kustomization{ - {Name: "test-kustomization"}, - } - - // Override: return kustomization error - mocks.KubernetesManager.CheckGitRepositoryStatusFunc = func() error { - return nil - } - mocks.KubernetesManager.GetKustomizationStatusFunc = func(names []string) (map[string]bool, error) { - return nil, fmt.Errorf("kustomization status error") - } - - // When waiting for kustomizations - err := handler.WaitForKustomizations("Testing kustomization error") - - // Then an error should be returned - if err == nil { - t.Error("Expected error, got nil") - } - if !strings.Contains(err.Error(), "kustomization error") { - t.Errorf("Expected kustomization error, got: %v", err) - } - }) - - t.Run("Error_ConsecutiveFailures", func(t *testing.T) { - // Given a blueprint handler - handler, mocks := setup(t) - setupFastTiming(handler) - - handler.blueprint.Kustomizations = []blueprintv1alpha1.Kustomization{ - {Name: "test-kustomization"}, - } - - // Override: return errors consistently - callCount := 0 - mocks.KubernetesManager.CheckGitRepositoryStatusFunc = func() error { - return nil - } - mocks.KubernetesManager.GetKustomizationStatusFunc = func(names []string) (map[string]bool, error) { - callCount++ - return nil, fmt.Errorf("persistent error %d", callCount) - } - - // When waiting for kustomizations - err := handler.WaitForKustomizations("Testing consecutive failures") - - // Then an error should be returned mentioning consecutive failures - if err == nil { - t.Error("Expected error, got nil") - } - if !strings.Contains(err.Error(), "consecutive failures") { - t.Errorf("Expected consecutive failures error, got: %v", err) - } - - // And GetKustomizationStatus should be called multiple times (initial + 4 more failures = 5 total) - expectedCalls := 5 // 1 initial + 4 more failures to reach max of 5 consecutive failures - if callCount != expectedCalls { - t.Errorf("Expected %d calls to GetKustomizationStatus, got %d", expectedCalls, callCount) - } - }) - - t.Run("Error_RecoveryFromFailures", func(t *testing.T) { - // Given a blueprint handler - handler, mocks := setup(t) - setupFastTiming(handler) - - handler.blueprint.Kustomizations = []blueprintv1alpha1.Kustomization{ - {Name: "test-kustomization"}, - } - - // Override: fail a few times then succeed - callCount := 0 - mocks.KubernetesManager.CheckGitRepositoryStatusFunc = func() error { - return nil - } - mocks.KubernetesManager.GetKustomizationStatusFunc = func(names []string) (map[string]bool, error) { - callCount++ - if callCount <= 3 { - return nil, fmt.Errorf("temporary error %d", callCount) - } - // Success after 3 failures - status := make(map[string]bool) - for _, name := range names { - status[name] = true - } - return status, nil - } - - // When waiting for kustomizations - err := handler.WaitForKustomizations("Testing recovery") - - // Then no error should be returned (it should recover) - if err != nil { - t.Errorf("Expected no error after recovery, got: %v", err) - } - - // And GetKustomizationStatus should be called 4 times (1 initial + 3 failures + 1 success) - expectedCalls := 4 - if callCount != expectedCalls { - t.Errorf("Expected %d calls to GetKustomizationStatus, got %d", expectedCalls, callCount) - } - }) - - t.Run("Timeout_ExceedsMaxWaitTime", func(t *testing.T) { - // Given a blueprint handler with very short timeout - handler, mocks := setup(t) - - // Set up kustomizations with very short timeout - shortTimeout := &metav1.Duration{Duration: 1 * time.Millisecond} - handler.blueprint.Kustomizations = []blueprintv1alpha1.Kustomization{ - {Name: "test-kustomization", Timeout: shortTimeout}, - } - - // Setup fast timing that will timeout immediately for short durations - setupFastTiming(handler) - - // Override: never be ready - mocks.KubernetesManager.CheckGitRepositoryStatusFunc = func() error { - return nil - } - mocks.KubernetesManager.GetKustomizationStatusFunc = func(names []string) (map[string]bool, error) { - status := make(map[string]bool) - for _, name := range names { - status[name] = false // Never ready - } - return status, nil - } - - // When waiting for kustomizations - err := handler.WaitForKustomizations("Testing timeout") - - // Then a timeout error should be returned - if err == nil { - t.Error("Expected timeout error, got nil") - } - if !strings.Contains(err.Error(), "timeout waiting for kustomizations") { - t.Errorf("Expected timeout error, got: %v", err) - } - }) - - t.Run("EmptyKustomizationNames", func(t *testing.T) { - // Given a blueprint handler with no kustomizations - handler, mocks := setup(t) - setupFastTiming(handler) - - // No kustomizations in blueprint - handler.blueprint.Kustomizations = []blueprintv1alpha1.Kustomization{} - - // Override: verify empty names list - mocks.KubernetesManager.CheckGitRepositoryStatusFunc = func() error { - return nil - } - mocks.KubernetesManager.GetKustomizationStatusFunc = func(names []string) (map[string]bool, error) { - if len(names) != 0 { - t.Errorf("Expected empty names list, got %v", names) - } - return make(map[string]bool), nil - } - - // When waiting for kustomizations - err := handler.WaitForKustomizations("Testing empty") - - // Then no error should be returned - if err != nil { - t.Errorf("Expected no error for empty kustomizations, got: %v", err) - } - }) - - t.Run("EmptySpecificNames", func(t *testing.T) { - // Given a blueprint handler - handler, mocks := setup(t) - setupFastTiming(handler) - - // Set up blueprint with kustomizations - handler.blueprint.Kustomizations = []blueprintv1alpha1.Kustomization{ - {Name: "blueprint-kustomization"}, - } - - // Override: verify blueprint kustomizations are used when empty names provided - mocks.KubernetesManager.CheckGitRepositoryStatusFunc = func() error { - return nil - } - mocks.KubernetesManager.GetKustomizationStatusFunc = func(names []string) (map[string]bool, error) { - expectedNames := []string{"blueprint-kustomization"} - if len(names) != len(expectedNames) { - t.Errorf("Expected %d names, got %d", len(expectedNames), len(names)) - } - if names[0] != expectedNames[0] { - t.Errorf("Expected name %s, got %s", expectedNames[0], names[0]) - } - - status := make(map[string]bool) - for _, name := range names { - status[name] = true - } - return status, nil - } - - // When waiting with empty string as name - err := handler.WaitForKustomizations("Testing empty names", "") - - // Then no error should be returned and blueprint kustomizations should be used - if err != nil { - t.Errorf("Expected no error, got: %v", err) - } - }) - - t.Run("PartialReadiness", func(t *testing.T) { - // Given a blueprint handler with multiple kustomizations - handler, mocks := setup(t) - setupFastTiming(handler) - - handler.blueprint.Kustomizations = []blueprintv1alpha1.Kustomization{ - {Name: "kustomization-1"}, - {Name: "kustomization-2"}, - {Name: "kustomization-3"}, - } - - // Override: simulate gradual readiness - callCount := 0 - mocks.KubernetesManager.CheckGitRepositoryStatusFunc = func() error { - return nil - } - mocks.KubernetesManager.GetKustomizationStatusFunc = func(names []string) (map[string]bool, error) { - callCount++ - status := make(map[string]bool) - - // Simulate gradual readiness - switch callCount { - case 1: - // First call: only kustomization-1 ready - status["kustomization-1"] = true - status["kustomization-2"] = false - status["kustomization-3"] = false - case 2: - // Second call: kustomization-1 and kustomization-2 ready - status["kustomization-1"] = true - status["kustomization-2"] = true - status["kustomization-3"] = false - default: - // Third call and beyond: all ready - status["kustomization-1"] = true - status["kustomization-2"] = true - status["kustomization-3"] = true - } - - return status, nil - } - - // When waiting for kustomizations - err := handler.WaitForKustomizations("Testing partial readiness") - - // Then no error should be returned - if err != nil { - t.Errorf("Expected no error, got: %v", err) - } - - // And GetKustomizationStatus should be called at least 3 times - if callCount < 3 { - t.Errorf("Expected at least 3 calls to GetKustomizationStatus, got %d", callCount) - } - }) - - t.Run("ImmediateReadyWithInitialError", func(t *testing.T) { - // Given a blueprint handler - handler, mocks := setup(t) - setupFastTiming(handler) - - handler.blueprint.Kustomizations = []blueprintv1alpha1.Kustomization{ - {Name: "test-kustomization"}, - } - - // Override: fail on initial check but succeed immediately in polling - initialCall := true - mocks.KubernetesManager.CheckGitRepositoryStatusFunc = func() error { - return nil - } - mocks.KubernetesManager.GetKustomizationStatusFunc = func(names []string) (map[string]bool, error) { - if initialCall { - initialCall = false - return nil, fmt.Errorf("initial error") - } - - // Ready on subsequent calls - status := make(map[string]bool) - for _, name := range names { - status[name] = true - } - return status, nil - } - - // When waiting for kustomizations - err := handler.WaitForKustomizations("Testing initial error recovery") - - // Then no error should be returned (should recover quickly) - if err != nil { - t.Errorf("Expected no error after recovery, got: %v", err) - } - }) -} - -func TestBlueprintHandler_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) - } - 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{}}, - } - - // 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", Path: "", Cleanup: []string{"cleanup/path"}}, - } - - // When calling Down - err := baseHandler.Down() - - // Then no error should be returned - if err != nil { - t.Fatalf("expected nil error, got %v", err) - } - }) - - t.Run("MultipleKustomizationsWithCleanup", func(t *testing.T) { - // Given a handler with multiple kustomizations, some with cleanup paths - handler, _ := setup(t) - baseHandler := handler - baseHandler.blueprint.Kustomizations = []blueprintv1alpha1.Kustomization{ - {Name: "k1", Path: "", Cleanup: []string{"cleanup"}}, - {Name: "k2", Path: "", Cleanup: []string{"cleanup"}}, - {Name: "k3", Path: "", Cleanup: []string{"cleanup"}}, - {Name: "k4", Path: "", Cleanup: []string{}}, - } - - // When calling Down - err := baseHandler.Down() - - // Then no error should be returned - if err != nil { - t.Fatalf("expected nil error, got %v", err) - } - }) - - t.Run("ErrorCases", func(t *testing.T) { - testCases := []struct { - name string - kustomizations []blueprintv1alpha1.Kustomization - setupMock func(*kubernetes.MockKubernetesManager) - expectedError string - }{ - { - name: "ApplyKustomizationError", - kustomizations: []blueprintv1alpha1.Kustomization{ - {Name: "k1", Cleanup: []string{"cleanup/path1"}}, - }, - setupMock: func(m *kubernetes.MockKubernetesManager) { - m.ApplyKustomizationFunc = func(kustomization kustomizev1.Kustomization) error { - return fmt.Errorf("apply error") - } - }, - expectedError: "apply error", - }, - { - name: "ErrorDeletingKustomization", - kustomizations: []blueprintv1alpha1.Kustomization{ - {Name: "k1"}, - }, - setupMock: func(m *kubernetes.MockKubernetesManager) { - m.DeleteKustomizationFunc = func(name, namespace string) error { - return fmt.Errorf("delete error") - } - }, - expectedError: "delete error", - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - // Given a handler with the test case kustomizations - handler, mocks := setup(t) - baseHandler := handler - baseHandler.blueprint.Kustomizations = tc.kustomizations - - // And the mock setup - tc.setupMock(mocks.KubernetesManager) - - // 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(), tc.expectedError) { - t.Errorf("Expected error containing %q, got: %v", tc.expectedError, err) - } - }) - } - }) - - t.Run("EmptyKustomizations", func(t *testing.T) { - // Given a handler with no kustomizations - handler, _ := setup(t) - baseHandler := handler - baseHandler.blueprint.Kustomizations = []blueprintv1alpha1.Kustomization{} - - // When calling Down - err := baseHandler.Down() - - // Then no error should be returned - if err != nil { - t.Errorf("Expected no error, got: %v", err) - } - }) - - t.Run("ErrorDeletingCleanupKustomizations", func(t *testing.T) { - // Given a handler with kustomizations that have cleanup paths - handler, mocks := setup(t) - baseHandler := handler - baseHandler.blueprint.Kustomizations = []blueprintv1alpha1.Kustomization{ - {Name: "k1", Cleanup: []string{"cleanup/path"}}, - } - - // And a mock that fails to delete cleanup kustomizations - mocks.KubernetesManager.DeleteKustomizationFunc = func(name, namespace string) error { - if strings.Contains(name, "cleanup") { - return fmt.Errorf("delete cleanup kustomization error") - } - return nil - } - - // 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 delete cleanup kustomization") { - t.Errorf("Expected cleanup kustomization deletion error, got: %v", err) - } - }) - - t.Run("CleanupPathNormalization", func(t *testing.T) { - // Given a handler with kustomizations that have cleanup paths with backslashes - handler, mocks := setup(t) - baseHandler := handler - baseHandler.blueprint.Kustomizations = []blueprintv1alpha1.Kustomization{ - {Name: "k1", Path: "ingress\\base", Cleanup: []string{"cleanup"}}, - } - - // Track the applied kustomization to verify path normalization - var appliedKustomization kustomizev1.Kustomization - mocks.KubernetesManager.ApplyKustomizationFunc = func(k kustomizev1.Kustomization) error { - appliedKustomization = k - return nil - } - - // When calling Down - err := baseHandler.Down() - - // Then no error should be returned - if err != nil { - t.Fatalf("expected nil error, got %v", err) - } - - // And the cleanup path should use forward slashes - expectedPath := "kustomize/ingress/base/cleanup" - if appliedKustomization.Spec.Path != expectedPath { - t.Errorf("Expected cleanup path to be normalized to %s, got %s", expectedPath, appliedKustomization.Spec.Path) - } - }) -} - func TestBlueprintHandler_GetRepository(t *testing.T) { setup := func(t *testing.T) (*BaseBlueprintHandler, *Mocks) { t.Helper() diff --git a/pkg/provisioner/kubernetes/kubernetes_manager.go b/pkg/provisioner/kubernetes/kubernetes_manager.go index 17e8ea155..d57badc6f 100644 --- a/pkg/provisioner/kubernetes/kubernetes_manager.go +++ b/pkg/provisioner/kubernetes/kubernetes_manager.go @@ -51,6 +51,7 @@ type KubernetesManager interface { WaitForKubernetesHealthy(ctx context.Context, endpoint string, outputFunc func(string), nodeNames ...string) error GetNodeReadyStatus(ctx context.Context, nodeNames []string) (map[string]bool, error) ApplyBlueprint(blueprint *blueprintv1alpha1.Blueprint, namespace string) error + DeleteBlueprint(blueprint *blueprintv1alpha1.Blueprint, namespace string) error } // ============================================================================= @@ -631,6 +632,115 @@ func (k *BaseKubernetesManager) ApplyBlueprint(blueprint *blueprintv1alpha1.Blue return nil } +// DeleteBlueprint deletes all kustomizations from the blueprint and handles cleanup kustomizations. +// It first deletes all main kustomizations, then for each kustomization with cleanup paths, it applies +// cleanup kustomizations and then deletes them. This method orchestrates the complete blueprint +// teardown process in the correct order. +func (k *BaseKubernetesManager) DeleteBlueprint(blueprint *blueprintv1alpha1.Blueprint, namespace string) error { + defaultSourceName := blueprint.Metadata.Name + + for _, kustomization := range blueprint.Kustomizations { + if kustomization.Destroy != nil && !*kustomization.Destroy { + continue + } + + if err := k.DeleteKustomization(kustomization.Name, namespace); err != nil { + return fmt.Errorf("failed to delete kustomization %s: %w", kustomization.Name, err) + } + + if len(kustomization.Cleanup) > 0 { + sourceName := kustomization.Source + if sourceName == "" { + sourceName = defaultSourceName + } + + sourceKind := "GitRepository" + for _, source := range blueprint.Sources { + if source.Name == sourceName && strings.HasPrefix(source.Url, "oci://") { + sourceKind = "OCIRepository" + break + } + } + + basePath := kustomization.Path + if basePath == "" { + basePath = "kustomize" + } else { + basePath = strings.ReplaceAll(basePath, "\\", "/") + if basePath != "kustomize" && !strings.HasPrefix(basePath, "kustomize/") { + basePath = "kustomize/" + basePath + } + } + + for i, cleanupPath := range kustomization.Cleanup { + cleanupPathNormalized := strings.ReplaceAll(cleanupPath, "\\", "/") + fullCleanupPath := basePath + "/" + cleanupPathNormalized + + cleanupKustomizationName := fmt.Sprintf("%s-cleanup-%d", kustomization.Name, i) + + interval := metav1.Duration{Duration: constants.DefaultFluxKustomizationInterval} + if kustomization.Interval != nil && kustomization.Interval.Duration != 0 { + interval = *kustomization.Interval + } + + retryInterval := metav1.Duration{Duration: constants.DefaultFluxKustomizationRetryInterval} + if kustomization.RetryInterval != nil && kustomization.RetryInterval.Duration != 0 { + retryInterval = *kustomization.RetryInterval + } + + timeout := metav1.Duration{Duration: constants.DefaultFluxKustomizationTimeout} + if kustomization.Timeout != nil && kustomization.Timeout.Duration != 0 { + timeout = *kustomization.Timeout + } + + wait := constants.DefaultFluxKustomizationWait + if kustomization.Wait != nil { + wait = *kustomization.Wait + } + + force := constants.DefaultFluxKustomizationForce + if kustomization.Force != nil { + force = *kustomization.Force + } + + cleanupKustomization := kustomizev1.Kustomization{ + TypeMeta: metav1.TypeMeta{ + Kind: "Kustomization", + APIVersion: "kustomize.toolkit.fluxcd.io/v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: cleanupKustomizationName, + Namespace: namespace, + }, + Spec: kustomizev1.KustomizationSpec{ + SourceRef: kustomizev1.CrossNamespaceSourceReference{ + Kind: sourceKind, + Name: sourceName, + }, + Path: fullCleanupPath, + Interval: interval, + RetryInterval: &retryInterval, + Timeout: &timeout, + Wait: wait, + Force: force, + Prune: true, + }, + } + + if err := k.ApplyKustomization(cleanupKustomization); err != nil { + return fmt.Errorf("failed to apply cleanup kustomization %s: %w", cleanupKustomizationName, err) + } + + if err := k.DeleteKustomization(cleanupKustomizationName, namespace); err != nil { + return fmt.Errorf("failed to delete cleanup kustomization %s: %w", cleanupKustomizationName, err) + } + } + } + } + + return nil +} + // ============================================================================= // Private Methods // ============================================================================= diff --git a/pkg/provisioner/kubernetes/kubernetes_manager_test.go b/pkg/provisioner/kubernetes/kubernetes_manager_test.go index ba2012b43..a0fbca938 100644 --- a/pkg/provisioner/kubernetes/kubernetes_manager_test.go +++ b/pkg/provisioner/kubernetes/kubernetes_manager_test.go @@ -12,6 +12,7 @@ import ( kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1" sourcev1 "github.com/fluxcd/source-controller/api/v1" + blueprintv1alpha1 "github.com/windsorcli/cli/api/v1alpha1" "github.com/windsorcli/cli/pkg/di" "github.com/windsorcli/cli/pkg/provisioner/kubernetes/client" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -2457,3 +2458,404 @@ func TestBaseKubernetesManager_getHelmRelease(t *testing.T) { } }) } + +func TestBaseKubernetesManager_DeleteBlueprint(t *testing.T) { + setup := func(t *testing.T) *BaseKubernetesManager { + t.Helper() + mocks := setupMocks(t) + manager := NewKubernetesManager(mocks.Injector) + if err := manager.Initialize(); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + manager.kustomizationWaitPollInterval = 50 * time.Millisecond + manager.kustomizationReconcileTimeout = 100 * time.Millisecond + manager.kustomizationReconcileSleep = 50 * time.Millisecond + return manager + } + + t.Run("Success", func(t *testing.T) { + manager := setup(t) + kubernetesClient := client.NewMockKubernetesClient() + + var deleteCalls []string + kubernetesClient.DeleteResourceFunc = func(gvr schema.GroupVersionResource, namespace, name string, opts metav1.DeleteOptions) error { + deleteCalls = append(deleteCalls, name) + return nil + } + kubernetesClient.GetResourceFunc = func(gvr schema.GroupVersionResource, namespace, name string) (*unstructured.Unstructured, error) { + return nil, fmt.Errorf("the server could not find the requested resource") + } + kubernetesClient.ApplyResourceFunc = func(gvr schema.GroupVersionResource, obj *unstructured.Unstructured, opts metav1.ApplyOptions) (*unstructured.Unstructured, error) { + return obj, nil + } + manager.client = kubernetesClient + + blueprint := &blueprintv1alpha1.Blueprint{ + Metadata: blueprintv1alpha1.Metadata{ + Name: "test-blueprint", + }, + Kustomizations: []blueprintv1alpha1.Kustomization{ + { + Name: "test-kustomization-1", + }, + { + Name: "test-kustomization-2", + }, + }, + } + + err := manager.DeleteBlueprint(blueprint, "test-namespace") + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + if len(deleteCalls) != 2 { + t.Errorf("Expected 2 delete calls, got %d", len(deleteCalls)) + } + }) + + t.Run("SuccessSkipsDestroyFalse", func(t *testing.T) { + manager := setup(t) + kubernetesClient := client.NewMockKubernetesClient() + + var deleteCalls []string + kubernetesClient.DeleteResourceFunc = func(gvr schema.GroupVersionResource, namespace, name string, opts metav1.DeleteOptions) error { + deleteCalls = append(deleteCalls, name) + return nil + } + kubernetesClient.GetResourceFunc = func(gvr schema.GroupVersionResource, namespace, name string) (*unstructured.Unstructured, error) { + return nil, fmt.Errorf("the server could not find the requested resource") + } + manager.client = kubernetesClient + + destroyFalse := false + blueprint := &blueprintv1alpha1.Blueprint{ + Metadata: blueprintv1alpha1.Metadata{ + Name: "test-blueprint", + }, + Kustomizations: []blueprintv1alpha1.Kustomization{ + { + Name: "test-kustomization-1", + Destroy: &destroyFalse, + }, + { + Name: "test-kustomization-2", + Destroy: nil, + }, + }, + } + + err := manager.DeleteBlueprint(blueprint, "test-namespace") + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + if len(deleteCalls) != 1 { + t.Errorf("Expected 1 delete call, got %d", len(deleteCalls)) + } + if deleteCalls[0] != "test-kustomization-2" { + t.Errorf("Expected delete call for 'test-kustomization-2', got %s", deleteCalls[0]) + } + }) + + t.Run("SuccessWithCleanupKustomizations", func(t *testing.T) { + manager := setup(t) + kubernetesClient := client.NewMockKubernetesClient() + + var deleteCalls []string + var applyCalls []string + kubernetesClient.DeleteResourceFunc = func(gvr schema.GroupVersionResource, namespace, name string, opts metav1.DeleteOptions) error { + deleteCalls = append(deleteCalls, name) + return nil + } + kubernetesClient.GetResourceFunc = func(gvr schema.GroupVersionResource, namespace, name string) (*unstructured.Unstructured, error) { + return nil, fmt.Errorf("the server could not find the requested resource") + } + kubernetesClient.ApplyResourceFunc = func(gvr schema.GroupVersionResource, obj *unstructured.Unstructured, opts metav1.ApplyOptions) (*unstructured.Unstructured, error) { + if name, ok := obj.Object["metadata"].(map[string]any)["name"].(string); ok { + applyCalls = append(applyCalls, name) + } + return obj, nil + } + manager.client = kubernetesClient + + blueprint := &blueprintv1alpha1.Blueprint{ + Metadata: blueprintv1alpha1.Metadata{ + Name: "test-blueprint", + }, + Kustomizations: []blueprintv1alpha1.Kustomization{ + { + Name: "test-kustomization", + Path: "base", + Cleanup: []string{"cleanup/path"}, + }, + }, + } + + err := manager.DeleteBlueprint(blueprint, "test-namespace") + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + if len(deleteCalls) < 2 { + t.Errorf("Expected at least 2 delete calls (main + cleanup), got %d", len(deleteCalls)) + } + if len(applyCalls) != 1 { + t.Errorf("Expected 1 apply call for cleanup kustomization, got %d", len(applyCalls)) + } + if applyCalls[0] != "test-kustomization-cleanup-0" { + t.Errorf("Expected apply call for 'test-kustomization-cleanup-0', got %s", applyCalls[0]) + } + }) + + t.Run("SuccessWithMultipleCleanupPaths", func(t *testing.T) { + manager := setup(t) + kubernetesClient := client.NewMockKubernetesClient() + + var deleteCalls []string + var applyCalls []string + kubernetesClient.DeleteResourceFunc = func(gvr schema.GroupVersionResource, namespace, name string, opts metav1.DeleteOptions) error { + deleteCalls = append(deleteCalls, name) + return nil + } + kubernetesClient.GetResourceFunc = func(gvr schema.GroupVersionResource, namespace, name string) (*unstructured.Unstructured, error) { + return nil, fmt.Errorf("the server could not find the requested resource") + } + kubernetesClient.ApplyResourceFunc = func(gvr schema.GroupVersionResource, obj *unstructured.Unstructured, opts metav1.ApplyOptions) (*unstructured.Unstructured, error) { + if name, ok := obj.Object["metadata"].(map[string]any)["name"].(string); ok { + applyCalls = append(applyCalls, name) + } + return obj, nil + } + manager.client = kubernetesClient + + blueprint := &blueprintv1alpha1.Blueprint{ + Metadata: blueprintv1alpha1.Metadata{ + Name: "test-blueprint", + }, + Kustomizations: []blueprintv1alpha1.Kustomization{ + { + Name: "test-kustomization", + Cleanup: []string{"cleanup1", "cleanup2"}, + }, + }, + } + + err := manager.DeleteBlueprint(blueprint, "test-namespace") + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + if len(applyCalls) != 2 { + t.Errorf("Expected 2 apply calls for cleanup kustomizations, got %d", len(applyCalls)) + } + if len(deleteCalls) < 3 { + t.Errorf("Expected at least 3 delete calls (main + 2 cleanup), got %d", len(deleteCalls)) + } + }) + + t.Run("SuccessWithOCISource", func(t *testing.T) { + manager := setup(t) + kubernetesClient := client.NewMockKubernetesClient() + + var applyCalls []map[string]any + kubernetesClient.DeleteResourceFunc = func(gvr schema.GroupVersionResource, namespace, name string, opts metav1.DeleteOptions) error { + return nil + } + kubernetesClient.GetResourceFunc = func(gvr schema.GroupVersionResource, namespace, name string) (*unstructured.Unstructured, error) { + return nil, fmt.Errorf("the server could not find the requested resource") + } + kubernetesClient.ApplyResourceFunc = func(gvr schema.GroupVersionResource, obj *unstructured.Unstructured, opts metav1.ApplyOptions) (*unstructured.Unstructured, error) { + spec := obj.Object["spec"].(map[string]any) + sourceRef := spec["sourceRef"].(map[string]any) + applyCalls = append(applyCalls, sourceRef) + return obj, nil + } + manager.client = kubernetesClient + + blueprint := &blueprintv1alpha1.Blueprint{ + Metadata: blueprintv1alpha1.Metadata{ + Name: "test-blueprint", + }, + Sources: []blueprintv1alpha1.Source{ + { + Name: "oci-source", + Url: "oci://example.com/repo", + }, + }, + Kustomizations: []blueprintv1alpha1.Kustomization{ + { + Name: "test-kustomization", + Source: "oci-source", + Cleanup: []string{"cleanup"}, + }, + }, + } + + err := manager.DeleteBlueprint(blueprint, "test-namespace") + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + if len(applyCalls) != 1 { + t.Errorf("Expected 1 apply call, got %d", len(applyCalls)) + } + if applyCalls[0]["kind"] != "OCIRepository" { + t.Errorf("Expected source kind 'OCIRepository', got %v", applyCalls[0]["kind"]) + } + }) + + t.Run("SuccessWithPathNormalization", func(t *testing.T) { + manager := setup(t) + kubernetesClient := client.NewMockKubernetesClient() + + var applyCalls []map[string]any + kubernetesClient.DeleteResourceFunc = func(gvr schema.GroupVersionResource, namespace, name string, opts metav1.DeleteOptions) error { + return nil + } + kubernetesClient.GetResourceFunc = func(gvr schema.GroupVersionResource, namespace, name string) (*unstructured.Unstructured, error) { + return nil, fmt.Errorf("the server could not find the requested resource") + } + kubernetesClient.ApplyResourceFunc = func(gvr schema.GroupVersionResource, obj *unstructured.Unstructured, opts metav1.ApplyOptions) (*unstructured.Unstructured, error) { + spec := obj.Object["spec"].(map[string]any) + applyCalls = append(applyCalls, spec) + return obj, nil + } + manager.client = kubernetesClient + + blueprint := &blueprintv1alpha1.Blueprint{ + Metadata: blueprintv1alpha1.Metadata{ + Name: "test-blueprint", + }, + Kustomizations: []blueprintv1alpha1.Kustomization{ + { + Name: "test-kustomization", + Path: "base\\path", + Cleanup: []string{"cleanup\\path"}, + }, + }, + } + + err := manager.DeleteBlueprint(blueprint, "test-namespace") + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + if len(applyCalls) != 1 { + t.Errorf("Expected 1 apply call, got %d", len(applyCalls)) + } + expectedPath := "kustomize/base/path/cleanup/path" + if applyCalls[0]["path"] != expectedPath { + t.Errorf("Expected path '%s', got %v", expectedPath, applyCalls[0]["path"]) + } + }) + + t.Run("ErrorDeleteKustomization", func(t *testing.T) { + manager := setup(t) + kubernetesClient := client.NewMockKubernetesClient() + + kubernetesClient.DeleteResourceFunc = func(gvr schema.GroupVersionResource, namespace, name string, opts metav1.DeleteOptions) error { + return fmt.Errorf("delete error") + } + kubernetesClient.GetResourceFunc = func(gvr schema.GroupVersionResource, namespace, name string) (*unstructured.Unstructured, error) { + return nil, fmt.Errorf("the server could not find the requested resource") + } + manager.client = kubernetesClient + + blueprint := &blueprintv1alpha1.Blueprint{ + Metadata: blueprintv1alpha1.Metadata{ + Name: "test-blueprint", + }, + Kustomizations: []blueprintv1alpha1.Kustomization{ + { + Name: "test-kustomization", + }, + }, + } + + err := manager.DeleteBlueprint(blueprint, "test-namespace") + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "failed to delete kustomization") { + t.Errorf("Expected error containing 'failed to delete kustomization', got %v", err) + } + }) + + t.Run("ErrorApplyCleanupKustomization", func(t *testing.T) { + manager := setup(t) + kubernetesClient := client.NewMockKubernetesClient() + + kubernetesClient.DeleteResourceFunc = func(gvr schema.GroupVersionResource, namespace, name string, opts metav1.DeleteOptions) error { + return nil + } + kubernetesClient.GetResourceFunc = func(gvr schema.GroupVersionResource, namespace, name string) (*unstructured.Unstructured, error) { + return nil, fmt.Errorf("the server could not find the requested resource") + } + kubernetesClient.ApplyResourceFunc = func(gvr schema.GroupVersionResource, obj *unstructured.Unstructured, opts metav1.ApplyOptions) (*unstructured.Unstructured, error) { + return nil, fmt.Errorf("apply error") + } + manager.client = kubernetesClient + + blueprint := &blueprintv1alpha1.Blueprint{ + Metadata: blueprintv1alpha1.Metadata{ + Name: "test-blueprint", + }, + Kustomizations: []blueprintv1alpha1.Kustomization{ + { + Name: "test-kustomization", + Cleanup: []string{"cleanup"}, + }, + }, + } + + err := manager.DeleteBlueprint(blueprint, "test-namespace") + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "failed to apply cleanup kustomization") { + t.Errorf("Expected error containing 'failed to apply cleanup kustomization', got %v", err) + } + }) + + t.Run("ErrorDeleteCleanupKustomization", func(t *testing.T) { + manager := setup(t) + kubernetesClient := client.NewMockKubernetesClient() + + deleteCallCount := 0 + kubernetesClient.DeleteResourceFunc = func(gvr schema.GroupVersionResource, namespace, name string, opts metav1.DeleteOptions) error { + deleteCallCount++ + if strings.Contains(name, "cleanup") { + return fmt.Errorf("delete cleanup error") + } + return nil + } + kubernetesClient.GetResourceFunc = func(gvr schema.GroupVersionResource, namespace, name string) (*unstructured.Unstructured, error) { + return nil, fmt.Errorf("the server could not find the requested resource") + } + kubernetesClient.ApplyResourceFunc = func(gvr schema.GroupVersionResource, obj *unstructured.Unstructured, opts metav1.ApplyOptions) (*unstructured.Unstructured, error) { + return obj, nil + } + manager.client = kubernetesClient + + blueprint := &blueprintv1alpha1.Blueprint{ + Metadata: blueprintv1alpha1.Metadata{ + Name: "test-blueprint", + }, + Kustomizations: []blueprintv1alpha1.Kustomization{ + { + Name: "test-kustomization", + Cleanup: []string{"cleanup"}, + }, + }, + } + + err := manager.DeleteBlueprint(blueprint, "test-namespace") + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "failed to delete cleanup kustomization") { + t.Errorf("Expected error containing 'failed to delete cleanup kustomization', got %v", err) + } + }) +} diff --git a/pkg/provisioner/kubernetes/mock_kubernetes_manager.go b/pkg/provisioner/kubernetes/mock_kubernetes_manager.go index f410265bc..2c14298f7 100644 --- a/pkg/provisioner/kubernetes/mock_kubernetes_manager.go +++ b/pkg/provisioner/kubernetes/mock_kubernetes_manager.go @@ -37,6 +37,7 @@ type MockKubernetesManager struct { WaitForKubernetesHealthyFunc func(ctx context.Context, endpoint string, outputFunc func(string), nodeNames ...string) error GetNodeReadyStatusFunc func(ctx context.Context, nodeNames []string) (map[string]bool, error) ApplyBlueprintFunc func(blueprint *blueprintv1alpha1.Blueprint, namespace string) error + DeleteBlueprintFunc func(blueprint *blueprintv1alpha1.Blueprint, namespace string) error } // ============================================================================= @@ -192,3 +193,11 @@ func (m *MockKubernetesManager) ApplyBlueprint(blueprint *blueprintv1alpha1.Blue } return nil } + +// DeleteBlueprint implements KubernetesManager interface +func (m *MockKubernetesManager) DeleteBlueprint(blueprint *blueprintv1alpha1.Blueprint, namespace string) error { + if m.DeleteBlueprintFunc != nil { + return m.DeleteBlueprintFunc(blueprint, namespace) + } + return nil +} diff --git a/pkg/provisioner/kubernetes/mock_kubernetes_manager_test.go b/pkg/provisioner/kubernetes/mock_kubernetes_manager_test.go index 577ff8a88..482e7940b 100644 --- a/pkg/provisioner/kubernetes/mock_kubernetes_manager_test.go +++ b/pkg/provisioner/kubernetes/mock_kubernetes_manager_test.go @@ -13,6 +13,7 @@ import ( helmv2 "github.com/fluxcd/helm-controller/api/v2" kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1" sourcev1 "github.com/fluxcd/source-controller/api/v1" + blueprintv1alpha1 "github.com/windsorcli/cli/api/v1alpha1" ) // ============================================================================= @@ -464,3 +465,79 @@ func TestMockKubernetesManager_GetNodeReadyStatus(t *testing.T) { } }) } + +func TestMockKubernetesManager_ApplyBlueprint(t *testing.T) { + setup := func(t *testing.T) *MockKubernetesManager { + t.Helper() + return NewMockKubernetesManager(nil) + } + blueprint := &blueprintv1alpha1.Blueprint{ + Metadata: blueprintv1alpha1.Metadata{ + Name: "test-blueprint", + }, + } + namespace := "test-namespace" + + t.Run("FuncSet", func(t *testing.T) { + manager := setup(t) + manager.ApplyBlueprintFunc = func(b *blueprintv1alpha1.Blueprint, ns string) error { + if b != blueprint { + t.Errorf("Expected blueprint %v, got %v", blueprint, b) + } + if ns != namespace { + t.Errorf("Expected namespace %s, got %s", namespace, ns) + } + return fmt.Errorf("err") + } + err := manager.ApplyBlueprint(blueprint, namespace) + if err == nil || err.Error() != "err" { + t.Errorf("Expected error 'err', got %v", err) + } + }) + + t.Run("FuncNotSet", func(t *testing.T) { + manager := setup(t) + err := manager.ApplyBlueprint(blueprint, namespace) + if err != nil { + t.Errorf("Expected nil, got %v", err) + } + }) +} + +func TestMockKubernetesManager_DeleteBlueprint(t *testing.T) { + setup := func(t *testing.T) *MockKubernetesManager { + t.Helper() + return NewMockKubernetesManager(nil) + } + blueprint := &blueprintv1alpha1.Blueprint{ + Metadata: blueprintv1alpha1.Metadata{ + Name: "test-blueprint", + }, + } + namespace := "test-namespace" + + t.Run("FuncSet", func(t *testing.T) { + manager := setup(t) + manager.DeleteBlueprintFunc = func(b *blueprintv1alpha1.Blueprint, ns string) error { + if b != blueprint { + t.Errorf("Expected blueprint %v, got %v", blueprint, b) + } + if ns != namespace { + t.Errorf("Expected namespace %s, got %s", namespace, ns) + } + return fmt.Errorf("err") + } + err := manager.DeleteBlueprint(blueprint, namespace) + if err == nil || err.Error() != "err" { + t.Errorf("Expected error 'err', got %v", err) + } + }) + + t.Run("FuncNotSet", func(t *testing.T) { + manager := setup(t) + err := manager.DeleteBlueprint(blueprint, namespace) + if err != nil { + t.Errorf("Expected nil, got %v", err) + } + }) +} diff --git a/pkg/provisioner/provisioner.go b/pkg/provisioner/provisioner.go index 6db8701ea..43e6b512e 100644 --- a/pkg/provisioner/provisioner.go +++ b/pkg/provisioner/provisioner.go @@ -9,11 +9,11 @@ import ( "github.com/briandowns/spinner" blueprintv1alpha1 "github.com/windsorcli/cli/api/v1alpha1" "github.com/windsorcli/cli/pkg/constants" - execcontext "github.com/windsorcli/cli/pkg/runtime" "github.com/windsorcli/cli/pkg/provisioner/cluster" "github.com/windsorcli/cli/pkg/provisioner/kubernetes" k8sclient "github.com/windsorcli/cli/pkg/provisioner/kubernetes/client" terraforminfra "github.com/windsorcli/cli/pkg/provisioner/terraform" + execcontext "github.com/windsorcli/cli/pkg/runtime" ) // The Provisioner package provides high-level infrastructure provisioning functionality @@ -205,6 +205,39 @@ func (i *Provisioner) Wait(blueprint *blueprintv1alpha1.Blueprint) error { return nil } +// Uninstall orchestrates the high-level kustomization teardown process from the blueprint. +// It initializes the kubernetes manager and deletes all blueprint kustomizations, including +// handling cleanup kustomizations. The blueprint must be provided as a parameter. +// Returns an error if any step fails. +func (i *Provisioner) Uninstall(blueprint *blueprintv1alpha1.Blueprint) error { + if blueprint == nil { + return fmt.Errorf("blueprint not provided") + } + + if i.KubernetesManager == nil { + return fmt.Errorf("kubernetes manager not configured") + } + if err := i.KubernetesManager.Initialize(); err != nil { + return fmt.Errorf("failed to initialize kubernetes manager: %w", err) + } + + message := "๐Ÿ—‘๏ธ Uninstalling blueprint resources" + spin := spinner.New(spinner.CharSets[14], 100*time.Millisecond, spinner.WithColor("green")) + spin.Suffix = " " + message + spin.Start() + + if err := i.KubernetesManager.DeleteBlueprint(blueprint, constants.DefaultFluxSystemNamespace); err != nil { + spin.Stop() + fmt.Fprintf(os.Stderr, "\033[31mโœ— %s - Failed\033[0m\n", message) + return fmt.Errorf("failed to delete blueprint: %w", err) + } + + spin.Stop() + fmt.Fprintf(os.Stderr, "\033[32mโœ”\033[0m %s - \033[32mDone\033[0m\n", message) + + return nil +} + // CheckNodeHealth performs health checks for cluster nodes and Kubernetes endpoints. // It supports checking node health via cluster client (for Talos/Omni clusters) and/or // Kubernetes API health checks. The method handles timeout configuration, version checking, diff --git a/pkg/provisioner/provisioner_test.go b/pkg/provisioner/provisioner_test.go index 32163377b..76a324c82 100644 --- a/pkg/provisioner/provisioner_test.go +++ b/pkg/provisioner/provisioner_test.go @@ -8,14 +8,14 @@ import ( "time" blueprintv1alpha1 "github.com/windsorcli/cli/api/v1alpha1" - "github.com/windsorcli/cli/pkg/runtime" - "github.com/windsorcli/cli/pkg/runtime/config" - "github.com/windsorcli/cli/pkg/runtime/shell" "github.com/windsorcli/cli/pkg/di" "github.com/windsorcli/cli/pkg/provisioner/cluster" "github.com/windsorcli/cli/pkg/provisioner/kubernetes" k8sclient "github.com/windsorcli/cli/pkg/provisioner/kubernetes/client" terraforminfra "github.com/windsorcli/cli/pkg/provisioner/terraform" + "github.com/windsorcli/cli/pkg/runtime" + "github.com/windsorcli/cli/pkg/runtime/config" + "github.com/windsorcli/cli/pkg/runtime/shell" ) // ============================================================================= @@ -53,13 +53,13 @@ func createTestBlueprint() *blueprintv1alpha1.Blueprint { } type Mocks struct { - Injector di.Injector - ConfigHandler config.ConfigHandler - Shell *shell.MockShell - TerraformStack *terraforminfra.MockStack - KubernetesManager *kubernetes.MockKubernetesManager - KubernetesClient k8sclient.KubernetesClient - ClusterClient *cluster.MockClusterClient + Injector di.Injector + ConfigHandler config.ConfigHandler + Shell *shell.MockShell + TerraformStack *terraforminfra.MockStack + KubernetesManager *kubernetes.MockKubernetesManager + KubernetesClient k8sclient.KubernetesClient + ClusterClient *cluster.MockClusterClient ProvisionerRuntime *ProvisionerRuntime } @@ -103,7 +103,7 @@ func setupProvisionerMocks(t *testing.T) *Mocks { } provisionerCtx := &ProvisionerRuntime{ - Runtime: *execCtx, + Runtime: *execCtx, TerraformStack: terraformStack, KubernetesManager: kubernetesManager, KubernetesClient: kubernetesClient, @@ -118,13 +118,13 @@ func setupProvisionerMocks(t *testing.T) *Mocks { injector.Register("clusterClient", clusterClient) return &Mocks{ - Injector: injector, - ConfigHandler: configHandler, - Shell: mockShell, - TerraformStack: terraformStack, - KubernetesManager: kubernetesManager, - KubernetesClient: kubernetesClient, - ClusterClient: clusterClient, + Injector: injector, + ConfigHandler: configHandler, + Shell: mockShell, + TerraformStack: terraformStack, + KubernetesManager: kubernetesManager, + KubernetesClient: kubernetesClient, + ClusterClient: clusterClient, ProvisionerRuntime: provisionerCtx, } } @@ -636,6 +636,159 @@ func TestProvisioner_Wait(t *testing.T) { }) } +func TestProvisioner_Uninstall(t *testing.T) { + t.Run("Success", func(t *testing.T) { + mocks := setupProvisionerMocks(t) + provisioner := NewProvisioner(mocks.ProvisionerRuntime) + + mocks.KubernetesManager.InitializeFunc = func() error { + return nil + } + mocks.KubernetesManager.DeleteBlueprintFunc = func(blueprint *blueprintv1alpha1.Blueprint, namespace string) error { + return nil + } + + blueprint := createTestBlueprint() + err := provisioner.Uninstall(blueprint) + + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + }) + + t.Run("SuccessWithCleanupKustomizations", func(t *testing.T) { + mocks := setupProvisionerMocks(t) + provisioner := NewProvisioner(mocks.ProvisionerRuntime) + + mocks.KubernetesManager.InitializeFunc = func() error { + return nil + } + mocks.KubernetesManager.DeleteBlueprintFunc = func(blueprint *blueprintv1alpha1.Blueprint, namespace string) error { + return nil + } + + blueprint := createTestBlueprint() + blueprint.Kustomizations = []blueprintv1alpha1.Kustomization{ + { + Name: "test-kustomization", + Cleanup: []string{"cleanup/path"}, + }, + } + + err := provisioner.Uninstall(blueprint) + + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + }) + + t.Run("SuccessSkipsDestroyFalse", func(t *testing.T) { + mocks := setupProvisionerMocks(t) + provisioner := NewProvisioner(mocks.ProvisionerRuntime) + + mocks.KubernetesManager.InitializeFunc = func() error { + return nil + } + mocks.KubernetesManager.DeleteBlueprintFunc = func(blueprint *blueprintv1alpha1.Blueprint, namespace string) error { + return nil + } + + blueprint := createTestBlueprint() + destroyFalse := false + blueprint.Kustomizations = []blueprintv1alpha1.Kustomization{ + { + Name: "test-kustomization-1", + Destroy: &destroyFalse, + }, + { + Name: "test-kustomization-2", + Destroy: nil, + }, + } + + err := provisioner.Uninstall(blueprint) + + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + }) + + t.Run("ErrorNilBlueprint", func(t *testing.T) { + mocks := setupProvisionerMocks(t) + provisioner := NewProvisioner(mocks.ProvisionerRuntime) + + err := provisioner.Uninstall(nil) + + if err == nil { + t.Error("Expected error for nil blueprint") + } + + if !strings.Contains(err.Error(), "blueprint not provided") { + t.Errorf("Expected specific error message, got: %v", err) + } + }) + + t.Run("ErrorNilKubernetesManager", func(t *testing.T) { + mocks := setupProvisionerMocks(t) + provisioner := NewProvisioner(mocks.ProvisionerRuntime) + provisioner.KubernetesManager = nil + + blueprint := createTestBlueprint() + err := provisioner.Uninstall(blueprint) + + if err == nil { + t.Error("Expected error for nil kubernetes manager") + } + + if !strings.Contains(err.Error(), "kubernetes manager not configured") { + t.Errorf("Expected specific error message, got: %v", err) + } + }) + + t.Run("ErrorKubernetesManagerInitialize", func(t *testing.T) { + mocks := setupProvisionerMocks(t) + provisioner := NewProvisioner(mocks.ProvisionerRuntime) + + mocks.KubernetesManager.InitializeFunc = func() error { + return fmt.Errorf("initialize failed") + } + + blueprint := createTestBlueprint() + err := provisioner.Uninstall(blueprint) + + if err == nil { + t.Error("Expected error for kubernetes manager initialize failure") + } + + if !strings.Contains(err.Error(), "failed to initialize kubernetes manager") { + t.Errorf("Expected specific error message, got: %v", err) + } + }) + + t.Run("ErrorDeleteBlueprint", func(t *testing.T) { + mocks := setupProvisionerMocks(t) + provisioner := NewProvisioner(mocks.ProvisionerRuntime) + + mocks.KubernetesManager.InitializeFunc = func() error { + return nil + } + mocks.KubernetesManager.DeleteBlueprintFunc = func(blueprint *blueprintv1alpha1.Blueprint, namespace string) error { + return fmt.Errorf("delete blueprint failed") + } + + blueprint := createTestBlueprint() + err := provisioner.Uninstall(blueprint) + + if err == nil { + t.Error("Expected error for delete blueprint failure") + } + + if !strings.Contains(err.Error(), "failed to delete blueprint") { + t.Errorf("Expected specific error message, got: %v", err) + } + }) +} + func TestProvisioner_Close(t *testing.T) { t.Run("Success", func(t *testing.T) { mocks := setupProvisionerMocks(t)