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)