From 0d0d14322e6686f89a55ce81454727d666ea6867 Mon Sep 17 00:00:00 2001 From: Ryan VanGundy <85766511+rmvangun@users.noreply.github.com> Date: Thu, 30 Oct 2025 17:13:11 -0400 Subject: [PATCH 1/2] refactor(infrastructure): Move child objects in to infrastructure folder Signed-off-by: Ryan VanGundy <85766511+rmvangun@users.noreply.github.com> --- cmd/bundle_test.go | 2 +- cmd/push_test.go | 2 +- cmd/root_test.go | 2 +- pkg/{ => infrastructure}/cluster/cluster_client.go | 0 .../cluster/cluster_client_test.go | 0 .../cluster/mock_cluster_client.go | 0 .../cluster/mock_cluster_client_test.go | 0 pkg/{ => infrastructure}/cluster/shims.go | 0 .../cluster/talos_cluster_client.go | 0 .../cluster/talos_cluster_client_test.go | 0 .../kubernetes/kubernetes_client.go | 0 .../kubernetes/kubernetes_manager.go | 0 .../kubernetes/kubernetes_manager_test.go | 0 .../kubernetes/mock_kubernetes_client.go | 0 .../kubernetes/mock_kubernetes_client_test.go | 0 .../kubernetes/mock_kubernetes_manager.go | 0 .../kubernetes/mock_kubernetes_manager_test.go | 0 pkg/{ => infrastructure}/kubernetes/shims.go | 0 .../terraform}/mock_stack.go | 2 +- .../terraform}/mock_stack_test.go | 2 +- pkg/{stack => infrastructure/terraform}/shims.go | 2 +- pkg/{stack => infrastructure/terraform}/stack.go | 2 +- .../terraform}/stack_test.go | 2 +- .../terraform}/windsor_stack.go | 2 +- .../terraform}/windsor_stack_test.go | 2 +- pkg/pipelines/check.go | 4 ++-- pkg/pipelines/check_test.go | 4 ++-- pkg/pipelines/down.go | 6 +++--- pkg/pipelines/down_test.go | 8 ++++---- pkg/pipelines/init.go | 4 ++-- pkg/pipelines/init_test.go | 8 ++++---- pkg/pipelines/pipeline.go | 12 ++++++------ pkg/pipelines/pipeline_test.go | 8 ++++---- pkg/pipelines/up.go | 4 ++-- pkg/pipelines/up_test.go | 6 +++--- pkg/resources/blueprint/blueprint_handler.go | 2 +- .../blueprint/blueprint_handler_private_test.go | 2 +- .../blueprint/blueprint_handler_public_test.go | 2 +- pkg/runtime/runtime.go | 4 ++-- pkg/runtime/runtime_loaders.go | 4 ++-- pkg/runtime/runtime_loaders_test.go | 4 ++-- 41 files changed, 51 insertions(+), 51 deletions(-) rename pkg/{ => infrastructure}/cluster/cluster_client.go (100%) rename pkg/{ => infrastructure}/cluster/cluster_client_test.go (100%) rename pkg/{ => infrastructure}/cluster/mock_cluster_client.go (100%) rename pkg/{ => infrastructure}/cluster/mock_cluster_client_test.go (100%) rename pkg/{ => infrastructure}/cluster/shims.go (100%) rename pkg/{ => infrastructure}/cluster/talos_cluster_client.go (100%) rename pkg/{ => infrastructure}/cluster/talos_cluster_client_test.go (100%) rename pkg/{ => infrastructure}/kubernetes/kubernetes_client.go (100%) rename pkg/{ => infrastructure}/kubernetes/kubernetes_manager.go (100%) rename pkg/{ => infrastructure}/kubernetes/kubernetes_manager_test.go (100%) rename pkg/{ => infrastructure}/kubernetes/mock_kubernetes_client.go (100%) rename pkg/{ => infrastructure}/kubernetes/mock_kubernetes_client_test.go (100%) rename pkg/{ => infrastructure}/kubernetes/mock_kubernetes_manager.go (100%) rename pkg/{ => infrastructure}/kubernetes/mock_kubernetes_manager_test.go (100%) rename pkg/{ => infrastructure}/kubernetes/shims.go (100%) rename pkg/{stack => infrastructure/terraform}/mock_stack.go (98%) rename pkg/{stack => infrastructure/terraform}/mock_stack_test.go (99%) rename pkg/{stack => infrastructure/terraform}/shims.go (98%) rename pkg/{stack => infrastructure/terraform}/stack.go (99%) rename pkg/{stack => infrastructure/terraform}/stack_test.go (99%) rename pkg/{stack => infrastructure/terraform}/windsor_stack.go (99%) rename pkg/{stack => infrastructure/terraform}/windsor_stack_test.go (99%) diff --git a/cmd/bundle_test.go b/cmd/bundle_test.go index 3f6feecfe..6626986bf 100644 --- a/cmd/bundle_test.go +++ b/cmd/bundle_test.go @@ -11,7 +11,7 @@ import ( "github.com/spf13/cobra" "github.com/windsorcli/cli/pkg/config" "github.com/windsorcli/cli/pkg/di" - "github.com/windsorcli/cli/pkg/kubernetes" + "github.com/windsorcli/cli/pkg/infrastructure/kubernetes" "github.com/windsorcli/cli/pkg/resources/artifact" "github.com/windsorcli/cli/pkg/resources/blueprint" "github.com/windsorcli/cli/pkg/shell" diff --git a/cmd/push_test.go b/cmd/push_test.go index 44ec51c69..68a531bb4 100644 --- a/cmd/push_test.go +++ b/cmd/push_test.go @@ -11,7 +11,7 @@ import ( "github.com/spf13/cobra" "github.com/windsorcli/cli/pkg/config" "github.com/windsorcli/cli/pkg/di" - "github.com/windsorcli/cli/pkg/kubernetes" + "github.com/windsorcli/cli/pkg/infrastructure/kubernetes" "github.com/windsorcli/cli/pkg/resources/artifact" "github.com/windsorcli/cli/pkg/resources/blueprint" "github.com/windsorcli/cli/pkg/shell" diff --git a/cmd/root_test.go b/cmd/root_test.go index a0497507e..e0c093c04 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -16,7 +16,7 @@ import ( "github.com/windsorcli/cli/pkg/config" "github.com/windsorcli/cli/pkg/di" "github.com/windsorcli/cli/pkg/environment/envvars" - "github.com/windsorcli/cli/pkg/kubernetes" + "github.com/windsorcli/cli/pkg/infrastructure/kubernetes" blueprintpkg "github.com/windsorcli/cli/pkg/resources/blueprint" "github.com/windsorcli/cli/pkg/secrets" "github.com/windsorcli/cli/pkg/shell" diff --git a/pkg/cluster/cluster_client.go b/pkg/infrastructure/cluster/cluster_client.go similarity index 100% rename from pkg/cluster/cluster_client.go rename to pkg/infrastructure/cluster/cluster_client.go diff --git a/pkg/cluster/cluster_client_test.go b/pkg/infrastructure/cluster/cluster_client_test.go similarity index 100% rename from pkg/cluster/cluster_client_test.go rename to pkg/infrastructure/cluster/cluster_client_test.go diff --git a/pkg/cluster/mock_cluster_client.go b/pkg/infrastructure/cluster/mock_cluster_client.go similarity index 100% rename from pkg/cluster/mock_cluster_client.go rename to pkg/infrastructure/cluster/mock_cluster_client.go diff --git a/pkg/cluster/mock_cluster_client_test.go b/pkg/infrastructure/cluster/mock_cluster_client_test.go similarity index 100% rename from pkg/cluster/mock_cluster_client_test.go rename to pkg/infrastructure/cluster/mock_cluster_client_test.go diff --git a/pkg/cluster/shims.go b/pkg/infrastructure/cluster/shims.go similarity index 100% rename from pkg/cluster/shims.go rename to pkg/infrastructure/cluster/shims.go diff --git a/pkg/cluster/talos_cluster_client.go b/pkg/infrastructure/cluster/talos_cluster_client.go similarity index 100% rename from pkg/cluster/talos_cluster_client.go rename to pkg/infrastructure/cluster/talos_cluster_client.go diff --git a/pkg/cluster/talos_cluster_client_test.go b/pkg/infrastructure/cluster/talos_cluster_client_test.go similarity index 100% rename from pkg/cluster/talos_cluster_client_test.go rename to pkg/infrastructure/cluster/talos_cluster_client_test.go diff --git a/pkg/kubernetes/kubernetes_client.go b/pkg/infrastructure/kubernetes/kubernetes_client.go similarity index 100% rename from pkg/kubernetes/kubernetes_client.go rename to pkg/infrastructure/kubernetes/kubernetes_client.go diff --git a/pkg/kubernetes/kubernetes_manager.go b/pkg/infrastructure/kubernetes/kubernetes_manager.go similarity index 100% rename from pkg/kubernetes/kubernetes_manager.go rename to pkg/infrastructure/kubernetes/kubernetes_manager.go diff --git a/pkg/kubernetes/kubernetes_manager_test.go b/pkg/infrastructure/kubernetes/kubernetes_manager_test.go similarity index 100% rename from pkg/kubernetes/kubernetes_manager_test.go rename to pkg/infrastructure/kubernetes/kubernetes_manager_test.go diff --git a/pkg/kubernetes/mock_kubernetes_client.go b/pkg/infrastructure/kubernetes/mock_kubernetes_client.go similarity index 100% rename from pkg/kubernetes/mock_kubernetes_client.go rename to pkg/infrastructure/kubernetes/mock_kubernetes_client.go diff --git a/pkg/kubernetes/mock_kubernetes_client_test.go b/pkg/infrastructure/kubernetes/mock_kubernetes_client_test.go similarity index 100% rename from pkg/kubernetes/mock_kubernetes_client_test.go rename to pkg/infrastructure/kubernetes/mock_kubernetes_client_test.go diff --git a/pkg/kubernetes/mock_kubernetes_manager.go b/pkg/infrastructure/kubernetes/mock_kubernetes_manager.go similarity index 100% rename from pkg/kubernetes/mock_kubernetes_manager.go rename to pkg/infrastructure/kubernetes/mock_kubernetes_manager.go diff --git a/pkg/kubernetes/mock_kubernetes_manager_test.go b/pkg/infrastructure/kubernetes/mock_kubernetes_manager_test.go similarity index 100% rename from pkg/kubernetes/mock_kubernetes_manager_test.go rename to pkg/infrastructure/kubernetes/mock_kubernetes_manager_test.go diff --git a/pkg/kubernetes/shims.go b/pkg/infrastructure/kubernetes/shims.go similarity index 100% rename from pkg/kubernetes/shims.go rename to pkg/infrastructure/kubernetes/shims.go diff --git a/pkg/stack/mock_stack.go b/pkg/infrastructure/terraform/mock_stack.go similarity index 98% rename from pkg/stack/mock_stack.go rename to pkg/infrastructure/terraform/mock_stack.go index 8e725c2b5..8d83589d8 100644 --- a/pkg/stack/mock_stack.go +++ b/pkg/infrastructure/terraform/mock_stack.go @@ -1,4 +1,4 @@ -package stack +package terraform import "github.com/windsorcli/cli/pkg/di" diff --git a/pkg/stack/mock_stack_test.go b/pkg/infrastructure/terraform/mock_stack_test.go similarity index 99% rename from pkg/stack/mock_stack_test.go rename to pkg/infrastructure/terraform/mock_stack_test.go index 9de778c56..626d52117 100644 --- a/pkg/stack/mock_stack_test.go +++ b/pkg/infrastructure/terraform/mock_stack_test.go @@ -1,4 +1,4 @@ -package stack +package terraform // The MockStackTest provides test coverage for the MockStack implementation. // It provides validation of the mock's function field behaviors, diff --git a/pkg/stack/shims.go b/pkg/infrastructure/terraform/shims.go similarity index 98% rename from pkg/stack/shims.go rename to pkg/infrastructure/terraform/shims.go index 9ddb58ca4..d0b29694d 100644 --- a/pkg/stack/shims.go +++ b/pkg/infrastructure/terraform/shims.go @@ -3,7 +3,7 @@ // It serves as a testing aid by allowing system calls to be intercepted // It enables dependency injection and test isolation for system-level operations -package stack +package terraform import ( "os" diff --git a/pkg/stack/stack.go b/pkg/infrastructure/terraform/stack.go similarity index 99% rename from pkg/stack/stack.go rename to pkg/infrastructure/terraform/stack.go index 0aa91b2f1..6f97f4613 100644 --- a/pkg/stack/stack.go +++ b/pkg/infrastructure/terraform/stack.go @@ -1,4 +1,4 @@ -package stack +package terraform // The Stack is a core component that manages infrastructure component stacks. // It provides a unified interface for initializing and managing infrastructure stacks, diff --git a/pkg/stack/stack_test.go b/pkg/infrastructure/terraform/stack_test.go similarity index 99% rename from pkg/stack/stack_test.go rename to pkg/infrastructure/terraform/stack_test.go index 5d41760bb..01311fb6f 100644 --- a/pkg/stack/stack_test.go +++ b/pkg/infrastructure/terraform/stack_test.go @@ -1,4 +1,4 @@ -package stack +package terraform // The StackTest provides comprehensive test coverage for the Stack interface implementation. // It provides validation of stack initialization, component management, and infrastructure operations, diff --git a/pkg/stack/windsor_stack.go b/pkg/infrastructure/terraform/windsor_stack.go similarity index 99% rename from pkg/stack/windsor_stack.go rename to pkg/infrastructure/terraform/windsor_stack.go index 2b2c7c295..5fcea8672 100644 --- a/pkg/stack/windsor_stack.go +++ b/pkg/infrastructure/terraform/windsor_stack.go @@ -1,4 +1,4 @@ -package stack +package terraform // The WindsorStack is a specialized implementation of the Stack interface for Terraform-based infrastructure. // It provides a concrete implementation for managing Terraform components through the Windsor CLI, diff --git a/pkg/stack/windsor_stack_test.go b/pkg/infrastructure/terraform/windsor_stack_test.go similarity index 99% rename from pkg/stack/windsor_stack_test.go rename to pkg/infrastructure/terraform/windsor_stack_test.go index 56ac2fc74..c43158387 100644 --- a/pkg/stack/windsor_stack_test.go +++ b/pkg/infrastructure/terraform/windsor_stack_test.go @@ -1,4 +1,4 @@ -package stack +package terraform // The WindsorStackTest provides comprehensive test coverage for the WindsorStack implementation. // It provides validation of stack initialization, component management, and infrastructure operations, diff --git a/pkg/pipelines/check.go b/pkg/pipelines/check.go index 650cd3c0e..a28da3fb2 100644 --- a/pkg/pipelines/check.go +++ b/pkg/pipelines/check.go @@ -5,10 +5,10 @@ import ( "fmt" "time" - "github.com/windsorcli/cli/pkg/cluster" "github.com/windsorcli/cli/pkg/di" "github.com/windsorcli/cli/pkg/environment/tools" - "github.com/windsorcli/cli/pkg/kubernetes" + "github.com/windsorcli/cli/pkg/infrastructure/cluster" + "github.com/windsorcli/cli/pkg/infrastructure/kubernetes" ) // The CheckPipeline is a specialized component that manages tool version checking and node health checking functionality. diff --git a/pkg/pipelines/check_test.go b/pkg/pipelines/check_test.go index ab202eef4..c01cf0e86 100644 --- a/pkg/pipelines/check_test.go +++ b/pkg/pipelines/check_test.go @@ -8,11 +8,11 @@ import ( "testing" "time" - "github.com/windsorcli/cli/pkg/cluster" "github.com/windsorcli/cli/pkg/config" "github.com/windsorcli/cli/pkg/di" "github.com/windsorcli/cli/pkg/environment/tools" - "github.com/windsorcli/cli/pkg/kubernetes" + "github.com/windsorcli/cli/pkg/infrastructure/cluster" + "github.com/windsorcli/cli/pkg/infrastructure/kubernetes" "github.com/windsorcli/cli/pkg/shell" ) diff --git a/pkg/pipelines/down.go b/pkg/pipelines/down.go index 99cd709cd..b967beb87 100644 --- a/pkg/pipelines/down.go +++ b/pkg/pipelines/down.go @@ -8,10 +8,10 @@ import ( "github.com/windsorcli/cli/pkg/di" "github.com/windsorcli/cli/pkg/environment/envvars" - "github.com/windsorcli/cli/pkg/kubernetes" + "github.com/windsorcli/cli/pkg/infrastructure/kubernetes" + terraforminfra "github.com/windsorcli/cli/pkg/infrastructure/terraform" "github.com/windsorcli/cli/pkg/resources/blueprint" "github.com/windsorcli/cli/pkg/shell" - "github.com/windsorcli/cli/pkg/stack" "github.com/windsorcli/cli/pkg/workstation/network" "github.com/windsorcli/cli/pkg/workstation/virt" ) @@ -31,7 +31,7 @@ type DownPipeline struct { virtualMachine virt.VirtualMachine containerRuntime virt.ContainerRuntime networkManager network.NetworkManager - stack stack.Stack + stack terraforminfra.Stack blueprintHandler blueprint.BlueprintHandler kubernetesClient kubernetes.KubernetesClient kubernetesManager kubernetes.KubernetesManager diff --git a/pkg/pipelines/down_test.go b/pkg/pipelines/down_test.go index c86c4558e..d95fb238c 100644 --- a/pkg/pipelines/down_test.go +++ b/pkg/pipelines/down_test.go @@ -9,10 +9,10 @@ import ( "github.com/windsorcli/cli/pkg/config" "github.com/windsorcli/cli/pkg/environment/envvars" - "github.com/windsorcli/cli/pkg/kubernetes" + "github.com/windsorcli/cli/pkg/infrastructure/kubernetes" + terraforminfra "github.com/windsorcli/cli/pkg/infrastructure/terraform" "github.com/windsorcli/cli/pkg/resources/blueprint" "github.com/windsorcli/cli/pkg/shell" - "github.com/windsorcli/cli/pkg/stack" "github.com/windsorcli/cli/pkg/workstation/network" "github.com/windsorcli/cli/pkg/workstation/virt" ) @@ -26,7 +26,7 @@ type DownMocks struct { VirtualMachine *virt.MockVirt ContainerRuntime *virt.MockVirt NetworkManager *network.MockNetworkManager - Stack *stack.MockStack + Stack *terraforminfra.MockStack BlueprintHandler *blueprint.MockBlueprintHandler } @@ -87,7 +87,7 @@ contexts: baseMocks.Injector.Register("networkManager", mockNetworkManager) // Setup stack mock - mockStack := stack.NewMockStack(baseMocks.Injector) + mockStack := terraforminfra.NewMockStack(baseMocks.Injector) mockStack.InitializeFunc = func() error { return nil } mockStack.DownFunc = func() error { return nil } baseMocks.Injector.Register("stack", mockStack) diff --git a/pkg/pipelines/init.go b/pkg/pipelines/init.go index d05bceafa..54526f47e 100644 --- a/pkg/pipelines/init.go +++ b/pkg/pipelines/init.go @@ -14,11 +14,11 @@ import ( "github.com/windsorcli/cli/pkg/environment/envvars" "github.com/windsorcli/cli/pkg/environment/tools" "github.com/windsorcli/cli/pkg/generators" + terraforminfra "github.com/windsorcli/cli/pkg/infrastructure/terraform" "github.com/windsorcli/cli/pkg/resources/artifact" "github.com/windsorcli/cli/pkg/resources/blueprint" "github.com/windsorcli/cli/pkg/resources/terraform" "github.com/windsorcli/cli/pkg/shell" - "github.com/windsorcli/cli/pkg/stack" "github.com/windsorcli/cli/pkg/workstation/network" "github.com/windsorcli/cli/pkg/workstation/services" "github.com/windsorcli/cli/pkg/workstation/virt" @@ -39,7 +39,7 @@ type InitPipeline struct { BasePipeline blueprintHandler blueprint.BlueprintHandler toolsManager tools.ToolsManager - stack stack.Stack + stack terraforminfra.Stack generators []generators.Generator artifactBuilder artifact.Artifact services []services.Service diff --git a/pkg/pipelines/init_test.go b/pkg/pipelines/init_test.go index ab088313a..7158ee913 100644 --- a/pkg/pipelines/init_test.go +++ b/pkg/pipelines/init_test.go @@ -12,11 +12,11 @@ import ( "github.com/windsorcli/cli/pkg/config" "github.com/windsorcli/cli/pkg/di" "github.com/windsorcli/cli/pkg/environment/tools" - "github.com/windsorcli/cli/pkg/kubernetes" + "github.com/windsorcli/cli/pkg/infrastructure/kubernetes" + terraforminfra "github.com/windsorcli/cli/pkg/infrastructure/terraform" "github.com/windsorcli/cli/pkg/resources/artifact" "github.com/windsorcli/cli/pkg/resources/blueprint" "github.com/windsorcli/cli/pkg/shell" - "github.com/windsorcli/cli/pkg/stack" "github.com/windsorcli/cli/pkg/workstation/virt" ) @@ -41,7 +41,7 @@ type InitMocks struct { BlueprintHandler *blueprint.MockBlueprintHandler KubernetesManager *kubernetes.MockKubernetesManager ToolsManager *tools.MockToolsManager - Stack *stack.MockStack + Stack *terraforminfra.MockStack VirtualMachine *virt.MockVirt ContainerRuntime *virt.MockVirt ArtifactBuilder *artifact.MockArtifact @@ -103,7 +103,7 @@ contexts: baseMocks.Injector.Register("toolsManager", mockToolsManager) // Setup stack mock - mockStack := stack.NewMockStack(baseMocks.Injector) + mockStack := terraforminfra.NewMockStack(baseMocks.Injector) mockStack.InitializeFunc = func() error { return nil } baseMocks.Injector.Register("stack", mockStack) diff --git a/pkg/pipelines/pipeline.go b/pkg/pipelines/pipeline.go index 21f2a601a..9fa5819ea 100644 --- a/pkg/pipelines/pipeline.go +++ b/pkg/pipelines/pipeline.go @@ -7,21 +7,21 @@ import ( "path/filepath" secretsConfigType "github.com/windsorcli/cli/api/v1alpha1/secrets" - "github.com/windsorcli/cli/pkg/cluster" "github.com/windsorcli/cli/pkg/config" "github.com/windsorcli/cli/pkg/constants" "github.com/windsorcli/cli/pkg/di" envpkg "github.com/windsorcli/cli/pkg/environment/envvars" "github.com/windsorcli/cli/pkg/environment/tools" "github.com/windsorcli/cli/pkg/generators" - "github.com/windsorcli/cli/pkg/kubernetes" + "github.com/windsorcli/cli/pkg/infrastructure/cluster" + "github.com/windsorcli/cli/pkg/infrastructure/kubernetes" + terraforminfra "github.com/windsorcli/cli/pkg/infrastructure/terraform" "github.com/windsorcli/cli/pkg/resources/artifact" "github.com/windsorcli/cli/pkg/resources/blueprint" "github.com/windsorcli/cli/pkg/resources/terraform" "github.com/windsorcli/cli/pkg/secrets" "github.com/windsorcli/cli/pkg/shell" "github.com/windsorcli/cli/pkg/shell/ssh" - "github.com/windsorcli/cli/pkg/stack" "github.com/windsorcli/cli/pkg/workstation/network" "github.com/windsorcli/cli/pkg/workstation/services" "github.com/windsorcli/cli/pkg/workstation/virt" @@ -260,14 +260,14 @@ func (p *BasePipeline) withBlueprintHandler() blueprint.BlueprintHandler { } // withStack resolves or creates stack from DI container -func (p *BasePipeline) withStack() stack.Stack { +func (p *BasePipeline) withStack() terraforminfra.Stack { if existing := p.injector.Resolve("stack"); existing != nil { - if stack, ok := existing.(stack.Stack); ok { + if stack, ok := existing.(terraforminfra.Stack); ok { return stack } } - stack := stack.NewWindsorStack(p.injector) + stack := terraforminfra.NewWindsorStack(p.injector) p.injector.Register("stack", stack) return stack } diff --git a/pkg/pipelines/pipeline_test.go b/pkg/pipelines/pipeline_test.go index dddc32583..5a59dabb1 100644 --- a/pkg/pipelines/pipeline_test.go +++ b/pkg/pipelines/pipeline_test.go @@ -12,16 +12,16 @@ import ( "github.com/windsorcli/cli/api/v1alpha1" "github.com/windsorcli/cli/api/v1alpha1/docker" secretsConfigType "github.com/windsorcli/cli/api/v1alpha1/secrets" - "github.com/windsorcli/cli/pkg/cluster" "github.com/windsorcli/cli/pkg/config" "github.com/windsorcli/cli/pkg/di" "github.com/windsorcli/cli/pkg/environment/envvars" "github.com/windsorcli/cli/pkg/environment/tools" - "github.com/windsorcli/cli/pkg/kubernetes" + "github.com/windsorcli/cli/pkg/infrastructure/cluster" + "github.com/windsorcli/cli/pkg/infrastructure/kubernetes" + terraforminfra "github.com/windsorcli/cli/pkg/infrastructure/terraform" "github.com/windsorcli/cli/pkg/resources/artifact" "github.com/windsorcli/cli/pkg/resources/blueprint" "github.com/windsorcli/cli/pkg/shell" - "github.com/windsorcli/cli/pkg/stack" "github.com/windsorcli/cli/pkg/workstation/virt" ) @@ -1657,7 +1657,7 @@ func TestBasePipeline_withStack(t *testing.T) { t.Run("ReusesExistingStackWhenRegistered", func(t *testing.T) { // Given a pipeline with existing stack pipeline, mocks := setup(t) - existingStack := stack.NewWindsorStack(mocks.Injector) + existingStack := terraforminfra.NewWindsorStack(mocks.Injector) pipeline.injector.Register("stack", existingStack) // When getting stack diff --git a/pkg/pipelines/up.go b/pkg/pipelines/up.go index 8149ec986..db5b5c7a9 100644 --- a/pkg/pipelines/up.go +++ b/pkg/pipelines/up.go @@ -8,8 +8,8 @@ import ( "github.com/windsorcli/cli/pkg/di" "github.com/windsorcli/cli/pkg/environment/envvars" "github.com/windsorcli/cli/pkg/environment/tools" + terraforminfra "github.com/windsorcli/cli/pkg/infrastructure/terraform" "github.com/windsorcli/cli/pkg/shell" - "github.com/windsorcli/cli/pkg/stack" "github.com/windsorcli/cli/pkg/workstation/network" "github.com/windsorcli/cli/pkg/workstation/virt" ) @@ -31,7 +31,7 @@ type UpPipeline struct { virtualMachine virt.VirtualMachine containerRuntime virt.ContainerRuntime networkManager network.NetworkManager - stack stack.Stack + stack terraforminfra.Stack envPrinters []envvars.EnvPrinter } diff --git a/pkg/pipelines/up_test.go b/pkg/pipelines/up_test.go index 16d3452ab..6a76bba28 100644 --- a/pkg/pipelines/up_test.go +++ b/pkg/pipelines/up_test.go @@ -8,8 +8,8 @@ import ( "github.com/windsorcli/cli/pkg/config" "github.com/windsorcli/cli/pkg/environment/envvars" "github.com/windsorcli/cli/pkg/environment/tools" + terraforminfra "github.com/windsorcli/cli/pkg/infrastructure/terraform" "github.com/windsorcli/cli/pkg/shell" - "github.com/windsorcli/cli/pkg/stack" "github.com/windsorcli/cli/pkg/workstation/network" "github.com/windsorcli/cli/pkg/workstation/virt" ) @@ -24,7 +24,7 @@ type UpMocks struct { VirtualMachine *virt.MockVirt ContainerRuntime *virt.MockVirt NetworkManager *network.MockNetworkManager - Stack *stack.MockStack + Stack *terraforminfra.MockStack } func setupUpMocks(t *testing.T, opts ...*SetupOptions) *UpMocks { @@ -93,7 +93,7 @@ contexts: baseMocks.Injector.Register("networkManager", mockNetworkManager) // Setup stack mock - mockStack := stack.NewMockStack(baseMocks.Injector) + mockStack := terraforminfra.NewMockStack(baseMocks.Injector) mockStack.InitializeFunc = func() error { return nil } mockStack.UpFunc = func() error { return nil } baseMocks.Injector.Register("stack", mockStack) diff --git a/pkg/resources/blueprint/blueprint_handler.go b/pkg/resources/blueprint/blueprint_handler.go index 6c18e4272..632d1ad63 100644 --- a/pkg/resources/blueprint/blueprint_handler.go +++ b/pkg/resources/blueprint/blueprint_handler.go @@ -21,7 +21,7 @@ import ( "github.com/windsorcli/cli/pkg/config" "github.com/windsorcli/cli/pkg/constants" "github.com/windsorcli/cli/pkg/di" - "github.com/windsorcli/cli/pkg/kubernetes" + "github.com/windsorcli/cli/pkg/infrastructure/kubernetes" "github.com/windsorcli/cli/pkg/resources/artifact" "github.com/windsorcli/cli/pkg/shell" diff --git a/pkg/resources/blueprint/blueprint_handler_private_test.go b/pkg/resources/blueprint/blueprint_handler_private_test.go index 656bbfa97..6309cb84f 100644 --- a/pkg/resources/blueprint/blueprint_handler_private_test.go +++ b/pkg/resources/blueprint/blueprint_handler_private_test.go @@ -12,7 +12,7 @@ import ( blueprintv1alpha1 "github.com/windsorcli/cli/api/v1alpha1" "github.com/windsorcli/cli/pkg/config" "github.com/windsorcli/cli/pkg/di" - "github.com/windsorcli/cli/pkg/kubernetes" + "github.com/windsorcli/cli/pkg/infrastructure/kubernetes" "github.com/windsorcli/cli/pkg/shell" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) diff --git a/pkg/resources/blueprint/blueprint_handler_public_test.go b/pkg/resources/blueprint/blueprint_handler_public_test.go index e65d845f6..c498e0077 100644 --- a/pkg/resources/blueprint/blueprint_handler_public_test.go +++ b/pkg/resources/blueprint/blueprint_handler_public_test.go @@ -18,7 +18,7 @@ import ( "github.com/windsorcli/cli/pkg/config" "github.com/windsorcli/cli/pkg/constants" "github.com/windsorcli/cli/pkg/di" - "github.com/windsorcli/cli/pkg/kubernetes" + "github.com/windsorcli/cli/pkg/infrastructure/kubernetes" "github.com/windsorcli/cli/pkg/resources/artifact" "github.com/windsorcli/cli/pkg/shell" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" diff --git a/pkg/runtime/runtime.go b/pkg/runtime/runtime.go index df9ed9bea..781266be9 100644 --- a/pkg/runtime/runtime.go +++ b/pkg/runtime/runtime.go @@ -5,13 +5,13 @@ import ( "maps" "os" - "github.com/windsorcli/cli/pkg/cluster" "github.com/windsorcli/cli/pkg/config" "github.com/windsorcli/cli/pkg/di" "github.com/windsorcli/cli/pkg/environment/envvars" "github.com/windsorcli/cli/pkg/environment/tools" "github.com/windsorcli/cli/pkg/generators" - "github.com/windsorcli/cli/pkg/kubernetes" + "github.com/windsorcli/cli/pkg/infrastructure/cluster" + "github.com/windsorcli/cli/pkg/infrastructure/kubernetes" "github.com/windsorcli/cli/pkg/resources/artifact" "github.com/windsorcli/cli/pkg/resources/blueprint" "github.com/windsorcli/cli/pkg/resources/terraform" diff --git a/pkg/runtime/runtime_loaders.go b/pkg/runtime/runtime_loaders.go index 245a833c8..d8f84a11d 100644 --- a/pkg/runtime/runtime_loaders.go +++ b/pkg/runtime/runtime_loaders.go @@ -7,10 +7,10 @@ import ( "path/filepath" secretsConfigType "github.com/windsorcli/cli/api/v1alpha1/secrets" - "github.com/windsorcli/cli/pkg/cluster" "github.com/windsorcli/cli/pkg/config" "github.com/windsorcli/cli/pkg/environment/envvars" - "github.com/windsorcli/cli/pkg/kubernetes" + "github.com/windsorcli/cli/pkg/infrastructure/cluster" + "github.com/windsorcli/cli/pkg/infrastructure/kubernetes" "github.com/windsorcli/cli/pkg/resources/blueprint" "github.com/windsorcli/cli/pkg/secrets" "github.com/windsorcli/cli/pkg/shell" diff --git a/pkg/runtime/runtime_loaders_test.go b/pkg/runtime/runtime_loaders_test.go index c2a0fdfd2..d89f30aa9 100644 --- a/pkg/runtime/runtime_loaders_test.go +++ b/pkg/runtime/runtime_loaders_test.go @@ -7,10 +7,10 @@ import ( "testing" secretsConfigType "github.com/windsorcli/cli/api/v1alpha1/secrets" - "github.com/windsorcli/cli/pkg/cluster" "github.com/windsorcli/cli/pkg/config" "github.com/windsorcli/cli/pkg/di" - "github.com/windsorcli/cli/pkg/kubernetes" + "github.com/windsorcli/cli/pkg/infrastructure/cluster" + "github.com/windsorcli/cli/pkg/infrastructure/kubernetes" "github.com/windsorcli/cli/pkg/shell" ) From f6fd14f4c192233cd6aca29bc5dceae8e882e976 Mon Sep 17 00:00:00 2001 From: Ryan VanGundy <85766511+rmvangun@users.noreply.github.com> Date: Fri, 31 Oct 2025 16:18:53 -0400 Subject: [PATCH 2/2] refactor(infrastructure): Add infrastructure manager The infrastructure manager implements an interface to the kubernetes client APIs and Terraform stack resources. Signed-off-by: Ryan VanGundy <85766511+rmvangun@users.noreply.github.com> --- api/v1alpha1/blueprint_types.go | 141 ++++ api/v1alpha1/blueprint_types_test.go | 388 ++++++++++ pkg/infrastructure/infrastructure.go | 186 +++++ pkg/infrastructure/infrastructure_test.go | 699 ++++++++++++++++++ .../kubernetes/kubernetes_manager.go | 243 ++++-- .../kubernetes/mock_kubernetes_manager.go | 10 + pkg/infrastructure/terraform/mock_stack.go | 17 +- .../terraform/mock_stack_test.go | 18 +- pkg/infrastructure/terraform/stack.go | 350 ++++++++- pkg/infrastructure/terraform/stack_test.go | 514 +++++++++++-- pkg/infrastructure/terraform/windsor_stack.go | 228 ------ .../terraform/windsor_stack_test.go | 470 ------------ pkg/pipelines/down.go | 13 +- pkg/pipelines/down_test.go | 13 +- pkg/pipelines/up.go | 16 +- pkg/pipelines/up_test.go | 5 +- pkg/resources/resources.go | 2 - pkg/types/context.go | 4 - 18 files changed, 2486 insertions(+), 831 deletions(-) create mode 100644 pkg/infrastructure/infrastructure.go create mode 100644 pkg/infrastructure/infrastructure_test.go delete mode 100644 pkg/infrastructure/terraform/windsor_stack.go delete mode 100644 pkg/infrastructure/terraform/windsor_stack_test.go diff --git a/api/v1alpha1/blueprint_types.go b/api/v1alpha1/blueprint_types.go index 463741d99..a0e277aac 100644 --- a/api/v1alpha1/blueprint_types.go +++ b/api/v1alpha1/blueprint_types.go @@ -8,7 +8,9 @@ import ( "slices" "strings" + kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1" "github.com/fluxcd/pkg/apis/kustomize" + "github.com/windsorcli/cli/pkg/constants" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -592,6 +594,145 @@ func (k *Kustomization) DeepCopy() *Kustomization { } } +// ToFluxKustomization converts a blueprint Kustomization to a Flux Kustomization. +// It takes the namespace for the kustomization, the default source name to use if no source is specified, +// and the list of sources to determine the source kind (GitRepository or OCIRepository). +func (k *Kustomization) ToFluxKustomization(namespace string, defaultSourceName string, sources []Source) kustomizev1.Kustomization { + dependsOn := make([]kustomizev1.DependencyReference, len(k.DependsOn)) + for idx, dep := range k.DependsOn { + dependsOn[idx] = kustomizev1.DependencyReference{ + Name: dep, + Namespace: namespace, + } + } + + sourceName := k.Source + if sourceName == "" { + sourceName = defaultSourceName + } + + sourceKind := "GitRepository" + for _, source := range sources { + if source.Name == sourceName && strings.HasPrefix(source.Url, "oci://") { + sourceKind = "OCIRepository" + break + } + } + + path := k.Path + if path == "" { + path = "kustomize" + } else { + path = "kustomize/" + strings.ReplaceAll(path, "\\", "/") + } + + interval := metav1.Duration{Duration: constants.DEFAULT_FLUX_KUSTOMIZATION_INTERVAL} + if k.Interval != nil && k.Interval.Duration != 0 { + interval = *k.Interval + } + + retryInterval := metav1.Duration{Duration: constants.DEFAULT_FLUX_KUSTOMIZATION_RETRY_INTERVAL} + if k.RetryInterval != nil && k.RetryInterval.Duration != 0 { + retryInterval = *k.RetryInterval + } + + timeout := metav1.Duration{Duration: constants.DEFAULT_FLUX_KUSTOMIZATION_TIMEOUT} + if k.Timeout != nil && k.Timeout.Duration != 0 { + timeout = *k.Timeout + } + + wait := constants.DEFAULT_FLUX_KUSTOMIZATION_WAIT + if k.Wait != nil { + wait = *k.Wait + } + + force := constants.DEFAULT_FLUX_KUSTOMIZATION_FORCE + if k.Force != nil { + force = *k.Force + } + + prune := true + if k.Prune != nil { + prune = *k.Prune + } + + deletionPolicy := "MirrorPrune" + if k.Destroy == nil || *k.Destroy { + deletionPolicy = "WaitForTermination" + } + + patches := make([]kustomize.Patch, 0, len(k.Patches)) + for _, p := range k.Patches { + if p.Patch != "" { + var target *kustomize.Selector + if p.Target != nil { + target = &kustomize.Selector{ + Kind: p.Target.Kind, + Name: p.Target.Name, + Namespace: p.Target.Namespace, + } + } + patches = append(patches, kustomize.Patch{ + Patch: p.Patch, + Target: target, + }) + } + } + + var postBuild *kustomizev1.PostBuild + substituteFrom := make([]kustomizev1.SubstituteReference, 0) + + substituteFrom = append(substituteFrom, kustomizev1.SubstituteReference{ + Kind: "ConfigMap", + Name: "values-common", + Optional: false, + }) + + if len(k.Substitutions) > 0 { + configMapName := fmt.Sprintf("values-%s", k.Name) + substituteFrom = append(substituteFrom, kustomizev1.SubstituteReference{ + Kind: "ConfigMap", + Name: configMapName, + Optional: false, + }) + } + + if len(substituteFrom) > 0 { + postBuild = &kustomizev1.PostBuild{ + SubstituteFrom: substituteFrom, + } + } + + return kustomizev1.Kustomization{ + TypeMeta: metav1.TypeMeta{ + Kind: "Kustomization", + APIVersion: "kustomize.toolkit.fluxcd.io/v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: k.Name, + Namespace: namespace, + }, + Spec: kustomizev1.KustomizationSpec{ + SourceRef: kustomizev1.CrossNamespaceSourceReference{ + Kind: sourceKind, + Name: sourceName, + }, + Path: path, + DependsOn: dependsOn, + Interval: interval, + RetryInterval: &retryInterval, + Timeout: &timeout, + Wait: wait, + Force: force, + Prune: prune, + DeletionPolicy: deletionPolicy, + Patches: patches, + Components: k.Components, + PostBuild: postBuild, + }, + } +} + // sortTerraform reorders the Blueprint's TerraformComponents so that dependencies precede dependents. // It applies a topological sort to ensure dependency order. Components without dependencies come first. // Returns an error if a dependency cycle is detected. diff --git a/api/v1alpha1/blueprint_types_test.go b/api/v1alpha1/blueprint_types_test.go index 5f12b7ea5..967eda643 100644 --- a/api/v1alpha1/blueprint_types_test.go +++ b/api/v1alpha1/blueprint_types_test.go @@ -3,6 +3,11 @@ package v1alpha1 import ( "strings" "testing" + "time" + + "github.com/fluxcd/pkg/apis/kustomize" + "github.com/windsorcli/cli/pkg/constants" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) func intPtr(i int) *int { @@ -795,3 +800,386 @@ func contains(slice []string, value string) bool { } return false } + +func TestKustomization_ToFluxKustomization(t *testing.T) { + t.Run("BasicConversionWithDefaults", func(t *testing.T) { + kustomization := &Kustomization{ + Name: "test-kustomization", + Path: "test/path", + } + + result := kustomization.ToFluxKustomization("test-namespace", "default-source", []Source{}) + + if result.Name != "test-kustomization" { + t.Errorf("Expected name 'test-kustomization', got '%s'", result.Name) + } + if result.Namespace != "test-namespace" { + t.Errorf("Expected namespace 'test-namespace', got '%s'", result.Namespace) + } + if result.Spec.SourceRef.Name != "default-source" { + t.Errorf("Expected source name 'default-source', got '%s'", result.Spec.SourceRef.Name) + } + if result.Spec.SourceRef.Kind != "GitRepository" { + t.Errorf("Expected source kind 'GitRepository', got '%s'", result.Spec.SourceRef.Kind) + } + if result.Spec.Path != "kustomize/test/path" { + t.Errorf("Expected path 'kustomize/test/path', got '%s'", result.Spec.Path) + } + if result.Spec.Interval.Duration != constants.DEFAULT_FLUX_KUSTOMIZATION_INTERVAL { + t.Errorf("Expected default interval, got %v", result.Spec.Interval.Duration) + } + if result.Spec.PostBuild == nil { + t.Fatal("Expected PostBuild to be set") + } + if len(result.Spec.PostBuild.SubstituteFrom) != 1 { + t.Fatalf("Expected 1 SubstituteFrom reference, got %d", len(result.Spec.PostBuild.SubstituteFrom)) + } + if result.Spec.PostBuild.SubstituteFrom[0].Name != "values-common" { + t.Errorf("Expected values-common ConfigMap reference, got '%s'", result.Spec.PostBuild.SubstituteFrom[0].Name) + } + }) + + t.Run("WithAllFieldsSet", func(t *testing.T) { + interval := metav1.Duration{Duration: 5 * time.Minute} + retryInterval := metav1.Duration{Duration: 2 * time.Minute} + timeout := metav1.Duration{Duration: 10 * time.Minute} + wait := true + force := false + prune := true + destroy := false + + kustomization := &Kustomization{ + Name: "test-kustomization", + Path: "test/path", + Source: "custom-source", + DependsOn: []string{"dep1", "dep2"}, + Interval: &interval, + RetryInterval: &retryInterval, + Timeout: &timeout, + Wait: &wait, + Force: &force, + Prune: &prune, + Destroy: &destroy, + Components: []string{"comp1", "comp2"}, + Patches: []BlueprintPatch{ + { + Patch: "apiVersion: v1\nkind: Service\nmetadata:\n name: test", + Target: &kustomize.Selector{ + Kind: "Service", + Name: "test", + Namespace: "test-ns", + }, + }, + }, + } + + result := kustomization.ToFluxKustomization("test-namespace", "default-source", []Source{}) + + if result.Spec.SourceRef.Name != "custom-source" { + t.Errorf("Expected source name 'custom-source', got '%s'", result.Spec.SourceRef.Name) + } + if len(result.Spec.DependsOn) != 2 { + t.Errorf("Expected 2 dependencies, got %d", len(result.Spec.DependsOn)) + } + if result.Spec.DependsOn[0].Name != "dep1" || result.Spec.DependsOn[0].Namespace != "test-namespace" { + t.Errorf("Expected dependency dep1 in test-namespace, got %v", result.Spec.DependsOn[0]) + } + if result.Spec.Interval.Duration != 5*time.Minute { + t.Errorf("Expected interval 5m, got %v", result.Spec.Interval.Duration) + } + if result.Spec.RetryInterval.Duration != 2*time.Minute { + t.Errorf("Expected retry interval 2m, got %v", result.Spec.RetryInterval.Duration) + } + if result.Spec.Timeout.Duration != 10*time.Minute { + t.Errorf("Expected timeout 10m, got %v", result.Spec.Timeout.Duration) + } + if result.Spec.Wait != wait { + t.Errorf("Expected wait %v, got %v", wait, result.Spec.Wait) + } + if result.Spec.Force != force { + t.Errorf("Expected force %v, got %v", force, result.Spec.Force) + } + if result.Spec.Prune != prune { + t.Errorf("Expected prune %v, got %v", prune, result.Spec.Prune) + } + if result.Spec.DeletionPolicy != "MirrorPrune" { + t.Errorf("Expected deletion policy 'MirrorPrune', got '%s'", result.Spec.DeletionPolicy) + } + if len(result.Spec.Components) != 2 { + t.Errorf("Expected 2 components, got %d", len(result.Spec.Components)) + } + if len(result.Spec.Patches) != 1 { + t.Errorf("Expected 1 patch, got %d", len(result.Spec.Patches)) + } + if result.Spec.Patches[0].Target.Kind != "Service" { + t.Errorf("Expected patch target kind 'Service', got '%s'", result.Spec.Patches[0].Target.Kind) + } + }) + + t.Run("WithSubstitutions", func(t *testing.T) { + kustomization := &Kustomization{ + Name: "test-kustomization", + Path: "test/path", + Substitutions: map[string]string{ + "domain": "example.com", + }, + } + + result := kustomization.ToFluxKustomization("test-namespace", "default-source", []Source{}) + + if result.Spec.PostBuild == nil { + t.Fatal("Expected PostBuild to be set") + } + if len(result.Spec.PostBuild.SubstituteFrom) != 2 { + t.Fatalf("Expected 2 SubstituteFrom references, got %d", len(result.Spec.PostBuild.SubstituteFrom)) + } + + foundValuesCommon := false + foundValuesKustomization := false + for _, ref := range result.Spec.PostBuild.SubstituteFrom { + if ref.Name == "values-common" { + foundValuesCommon = true + } + if ref.Name == "values-test-kustomization" { + foundValuesKustomization = true + } + } + + if !foundValuesCommon { + t.Error("Expected values-common ConfigMap reference") + } + if !foundValuesKustomization { + t.Error("Expected values-test-kustomization ConfigMap reference") + } + }) + + t.Run("WithoutSubstitutions", func(t *testing.T) { + kustomization := &Kustomization{ + Name: "test-kustomization", + Path: "test/path", + } + + result := kustomization.ToFluxKustomization("test-namespace", "default-source", []Source{}) + + if result.Spec.PostBuild == nil { + t.Fatal("Expected PostBuild to be set") + } + if len(result.Spec.PostBuild.SubstituteFrom) != 1 { + t.Fatalf("Expected 1 SubstituteFrom reference, got %d", len(result.Spec.PostBuild.SubstituteFrom)) + } + if result.Spec.PostBuild.SubstituteFrom[0].Name != "values-common" { + t.Errorf("Expected values-common ConfigMap reference, got '%s'", result.Spec.PostBuild.SubstituteFrom[0].Name) + } + }) + + t.Run("WithOCISource", func(t *testing.T) { + kustomization := &Kustomization{ + Name: "test-kustomization", + Path: "test/path", + Source: "oci-source", + } + + sources := []Source{ + { + Name: "oci-source", + Url: "oci://example.com/repo", + }, + } + + result := kustomization.ToFluxKustomization("test-namespace", "default-source", sources) + + if result.Spec.SourceRef.Kind != "OCIRepository" { + t.Errorf("Expected source kind 'OCIRepository', got '%s'", result.Spec.SourceRef.Kind) + } + if result.Spec.SourceRef.Name != "oci-source" { + t.Errorf("Expected source name 'oci-source', got '%s'", result.Spec.SourceRef.Name) + } + }) + + t.Run("WithGitSource", func(t *testing.T) { + kustomization := &Kustomization{ + Name: "test-kustomization", + Path: "test/path", + Source: "git-source", + } + + sources := []Source{ + { + Name: "git-source", + Url: "https://example.com/repo.git", + }, + } + + result := kustomization.ToFluxKustomization("test-namespace", "default-source", sources) + + if result.Spec.SourceRef.Kind != "GitRepository" { + t.Errorf("Expected source kind 'GitRepository', got '%s'", result.Spec.SourceRef.Kind) + } + if result.Spec.SourceRef.Name != "git-source" { + t.Errorf("Expected source name 'git-source', got '%s'", result.Spec.SourceRef.Name) + } + }) + + t.Run("WithEmptyPath", func(t *testing.T) { + kustomization := &Kustomization{ + Name: "test-kustomization", + Path: "", + } + + result := kustomization.ToFluxKustomization("test-namespace", "default-source", []Source{}) + + if result.Spec.Path != "kustomize" { + t.Errorf("Expected path 'kustomize', got '%s'", result.Spec.Path) + } + }) + + t.Run("WithPathBackslashes", func(t *testing.T) { + kustomization := &Kustomization{ + Name: "test-kustomization", + Path: "test\\path\\with\\backslashes", + } + + result := kustomization.ToFluxKustomization("test-namespace", "default-source", []Source{}) + + if result.Spec.Path != "kustomize/test/path/with/backslashes" { + t.Errorf("Expected path with forward slashes, got '%s'", result.Spec.Path) + } + }) + + t.Run("WithDestroyTrue", func(t *testing.T) { + destroy := true + kustomization := &Kustomization{ + Name: "test-kustomization", + Path: "test/path", + Destroy: &destroy, + } + + result := kustomization.ToFluxKustomization("test-namespace", "default-source", []Source{}) + + if result.Spec.DeletionPolicy != "WaitForTermination" { + t.Errorf("Expected deletion policy 'WaitForTermination', got '%s'", result.Spec.DeletionPolicy) + } + }) + + t.Run("WithDestroyFalse", func(t *testing.T) { + destroy := false + kustomization := &Kustomization{ + Name: "test-kustomization", + Path: "test/path", + Destroy: &destroy, + } + + result := kustomization.ToFluxKustomization("test-namespace", "default-source", []Source{}) + + if result.Spec.DeletionPolicy != "MirrorPrune" { + t.Errorf("Expected deletion policy 'MirrorPrune', got '%s'", result.Spec.DeletionPolicy) + } + }) + + t.Run("WithDestroyNil", func(t *testing.T) { + kustomization := &Kustomization{ + Name: "test-kustomization", + Path: "test/path", + Destroy: nil, + } + + result := kustomization.ToFluxKustomization("test-namespace", "default-source", []Source{}) + + if result.Spec.DeletionPolicy != "WaitForTermination" { + t.Errorf("Expected deletion policy 'WaitForTermination' when Destroy is nil, got '%s'", result.Spec.DeletionPolicy) + } + }) + + t.Run("WithEmptySourceUsesDefault", func(t *testing.T) { + kustomization := &Kustomization{ + Name: "test-kustomization", + Path: "test/path", + Source: "", + } + + result := kustomization.ToFluxKustomization("test-namespace", "default-source", []Source{}) + + if result.Spec.SourceRef.Name != "default-source" { + t.Errorf("Expected source name 'default-source', got '%s'", result.Spec.SourceRef.Name) + } + }) + + t.Run("WithZeroIntervalUsesDefault", func(t *testing.T) { + zeroInterval := metav1.Duration{Duration: 0} + kustomization := &Kustomization{ + Name: "test-kustomization", + Path: "test/path", + Interval: &zeroInterval, + } + + result := kustomization.ToFluxKustomization("test-namespace", "default-source", []Source{}) + + if result.Spec.Interval.Duration != constants.DEFAULT_FLUX_KUSTOMIZATION_INTERVAL { + t.Errorf("Expected default interval, got %v", result.Spec.Interval.Duration) + } + }) + + t.Run("WithPatchesWithoutTarget", func(t *testing.T) { + kustomization := &Kustomization{ + Name: "test-kustomization", + Path: "test/path", + Patches: []BlueprintPatch{ + { + Patch: "apiVersion: v1\nkind: Service\nmetadata:\n name: test", + }, + }, + } + + result := kustomization.ToFluxKustomization("test-namespace", "default-source", []Source{}) + + if len(result.Spec.Patches) != 1 { + t.Fatalf("Expected 1 patch, got %d", len(result.Spec.Patches)) + } + if result.Spec.Patches[0].Target != nil { + t.Error("Expected patch target to be nil") + } + if result.Spec.Patches[0].Patch != "apiVersion: v1\nkind: Service\nmetadata:\n name: test" { + t.Errorf("Expected patch content, got '%s'", result.Spec.Patches[0].Patch) + } + }) + + t.Run("WithEmptyPatchIgnored", func(t *testing.T) { + kustomization := &Kustomization{ + Name: "test-kustomization", + Path: "test/path", + Patches: []BlueprintPatch{ + { + Patch: "", + }, + }, + } + + result := kustomization.ToFluxKustomization("test-namespace", "default-source", []Source{}) + + if len(result.Spec.Patches) != 0 { + t.Errorf("Expected 0 patches (empty patch ignored), got %d", len(result.Spec.Patches)) + } + }) + + t.Run("TypeMetaAndObjectMeta", func(t *testing.T) { + kustomization := &Kustomization{ + Name: "test-kustomization", + Path: "test/path", + } + + result := kustomization.ToFluxKustomization("test-namespace", "default-source", []Source{}) + + if result.Kind != "Kustomization" { + t.Errorf("Expected Kind 'Kustomization', got '%s'", result.Kind) + } + if result.APIVersion != "kustomize.toolkit.fluxcd.io/v1" { + t.Errorf("Expected APIVersion 'kustomize.toolkit.fluxcd.io/v1', got '%s'", result.APIVersion) + } + if result.Name != "test-kustomization" { + t.Errorf("Expected Name 'test-kustomization', got '%s'", result.Name) + } + if result.Namespace != "test-namespace" { + t.Errorf("Expected Namespace 'test-namespace', got '%s'", result.Namespace) + } + }) +} diff --git a/pkg/infrastructure/infrastructure.go b/pkg/infrastructure/infrastructure.go new file mode 100644 index 000000000..ad1f01d58 --- /dev/null +++ b/pkg/infrastructure/infrastructure.go @@ -0,0 +1,186 @@ +package infrastructure + +import ( + "fmt" + + blueprintv1alpha1 "github.com/windsorcli/cli/api/v1alpha1" + "github.com/windsorcli/cli/pkg/constants" + "github.com/windsorcli/cli/pkg/infrastructure/cluster" + "github.com/windsorcli/cli/pkg/infrastructure/kubernetes" + terraforminfra "github.com/windsorcli/cli/pkg/infrastructure/terraform" + "github.com/windsorcli/cli/pkg/types" +) + +// The Infrastructure package provides high-level infrastructure management functionality +// for terraform operations, kubernetes cluster interactions, and cluster API operations. +// It consolidates the creation and management of terraform stacks, kubernetes managers, +// and cluster clients, providing a unified interface for infrastructure lifecycle operations +// across the Windsor CLI. + +// ============================================================================= +// Types +// ============================================================================= + +// InfrastructureExecutionContext holds the execution context for infrastructure operations. +// It embeds the base ExecutionContext and includes all infrastructure-specific dependencies. +type InfrastructureExecutionContext struct { + types.ExecutionContext + + TerraformStack terraforminfra.Stack + KubernetesManager kubernetes.KubernetesManager + KubernetesClient kubernetes.KubernetesClient + ClusterClient cluster.ClusterClient +} + +// Infrastructure manages the lifecycle of all infrastructure components (terraform, kubernetes, clusters). +// It provides a unified interface for creating, initializing, and managing these infrastructure components +// with proper dependency injection and error handling. +type Infrastructure struct { + *InfrastructureExecutionContext +} + +// ============================================================================= +// Constructor +// ============================================================================= + +// NewInfrastructure creates a new Infrastructure instance with the provided execution context. +// It sets up all required infrastructure handlersโ€”terraform stack, kubernetes manager, kubernetes client, +// and cluster clientโ€”and registers each handler with the dependency injector for use throughout the +// infrastructure lifecycle. The cluster client is created based on the cluster driver configuration (talos/omni). +// Components are initialized lazily when needed by the Up() and Down() methods. +// Returns a pointer to the Infrastructure struct. +func NewInfrastructure(ctx *InfrastructureExecutionContext) *Infrastructure { + infra := &Infrastructure{ + InfrastructureExecutionContext: ctx, + } + + if infra.TerraformStack == nil { + infra.TerraformStack = terraforminfra.NewWindsorStack(infra.Injector) + infra.Injector.Register("terraformStack", infra.TerraformStack) + } + + if infra.KubernetesClient == nil { + infra.KubernetesClient = kubernetes.NewDynamicKubernetesClient() + infra.Injector.Register("kubernetesClient", infra.KubernetesClient) + } + + if infra.KubernetesManager == nil { + infra.KubernetesManager = kubernetes.NewKubernetesManager(infra.Injector) + infra.Injector.Register("kubernetesManager", infra.KubernetesManager) + } + + if infra.ClusterClient == nil { + clusterDriver := infra.ConfigHandler.GetString("cluster.driver", "") + if clusterDriver == "talos" || clusterDriver == "omni" { + infra.ClusterClient = cluster.NewTalosClusterClient(infra.Injector) + infra.Injector.Register("clusterClient", infra.ClusterClient) + } + } + + return infra +} + +// ============================================================================= +// Public Methods +// ============================================================================= + +// Up orchestrates the high-level infrastructure deployment process. It executes terraform apply operations +// for all components in the stack. This method coordinates terraform, kubernetes, and cluster operations +// to bring up the complete infrastructure. Initializes components as needed. The blueprint parameter is required. +// Returns an error if any step fails. +func (i *Infrastructure) Up(blueprint *blueprintv1alpha1.Blueprint) error { + if blueprint == nil { + return fmt.Errorf("blueprint not provided") + } + + if i.TerraformStack == nil { + return fmt.Errorf("terraform stack not configured") + } + if err := i.TerraformStack.Initialize(); err != nil { + return fmt.Errorf("failed to initialize terraform stack: %w", err) + } + if err := i.TerraformStack.Up(blueprint); err != nil { + return fmt.Errorf("failed to run terraform up: %w", err) + } + return nil +} + +// Down orchestrates the high-level infrastructure teardown process. It executes terraform destroy operations +// for all components in the stack in reverse dependency order. Components with Destroy set to false are skipped. +// This method coordinates terraform, kubernetes, and cluster operations to tear down the infrastructure. +// Initializes components as needed. The blueprint parameter is required. Returns an error if any destroy operation fails. +func (i *Infrastructure) Down(blueprint *blueprintv1alpha1.Blueprint) error { + if blueprint == nil { + return fmt.Errorf("blueprint not provided") + } + + if i.TerraformStack == nil { + return fmt.Errorf("terraform stack not configured") + } + if err := i.TerraformStack.Initialize(); err != nil { + return fmt.Errorf("failed to initialize terraform stack: %w", err) + } + if err := i.TerraformStack.Down(blueprint); err != nil { + return fmt.Errorf("failed to run terraform down: %w", err) + } + return nil +} + +// Install orchestrates the high-level kustomization installation process from the blueprint. +// It initializes the kubernetes manager and applies all blueprint resources in order: creates namespace, +// applies source repositories, and applies all kustomizations. The blueprint must be provided as a parameter. +// Returns an error if any step fails. +func (i *Infrastructure) Install(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) + } + + if err := i.KubernetesManager.ApplyBlueprint(blueprint, constants.DEFAULT_FLUX_SYSTEM_NAMESPACE); err != nil { + return fmt.Errorf("failed to apply blueprint: %w", err) + } + + return nil +} + +// Wait waits for kustomizations from the blueprint to be ready. It initializes the kubernetes manager +// if needed and polls the status of all kustomizations until they are ready or a timeout occurs. +// Returns an error if the kubernetes manager is not configured, initialization fails, or waiting times out. +func (i *Infrastructure) Wait(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) + } + + kustomizationNames := make([]string, len(blueprint.Kustomizations)) + for i, k := range blueprint.Kustomizations { + kustomizationNames[i] = k.Name + } + + if err := i.KubernetesManager.WaitForKustomizations("โณ Waiting for kustomizations to be ready", kustomizationNames...); err != nil { + return fmt.Errorf("failed waiting for kustomizations: %w", err) + } + + return nil +} + +// Close releases resources held by infrastructure components. +// It closes cluster client connections if present. This method should be called when the +// infrastructure instance is no longer needed to clean up resources. +func (i *Infrastructure) Close() { + if i.ClusterClient != nil { + i.ClusterClient.Close() + } +} diff --git a/pkg/infrastructure/infrastructure_test.go b/pkg/infrastructure/infrastructure_test.go new file mode 100644 index 000000000..f45dc257c --- /dev/null +++ b/pkg/infrastructure/infrastructure_test.go @@ -0,0 +1,699 @@ +package infrastructure + +import ( + "fmt" + "strings" + "testing" + + blueprintv1alpha1 "github.com/windsorcli/cli/api/v1alpha1" + "github.com/windsorcli/cli/pkg/config" + "github.com/windsorcli/cli/pkg/di" + "github.com/windsorcli/cli/pkg/infrastructure/cluster" + "github.com/windsorcli/cli/pkg/infrastructure/kubernetes" + terraforminfra "github.com/windsorcli/cli/pkg/infrastructure/terraform" + "github.com/windsorcli/cli/pkg/shell" + "github.com/windsorcli/cli/pkg/types" +) + +// ============================================================================= +// Test Setup +// ============================================================================= + +// createTestBlueprint creates a test blueprint with terraform components and kustomizations +func createTestBlueprint() *blueprintv1alpha1.Blueprint { + return &blueprintv1alpha1.Blueprint{ + Metadata: blueprintv1alpha1.Metadata{ + Name: "test-blueprint", + }, + Sources: []blueprintv1alpha1.Source{ + { + Name: "source1", + Url: "https://github.com/example/example.git", + Ref: blueprintv1alpha1.Reference{Branch: "main"}, + }, + }, + TerraformComponents: []blueprintv1alpha1.TerraformComponent{ + { + Source: "source1", + Path: "remote/path", + Inputs: map[string]any{ + "remote_variable1": "default_value", + }, + }, + }, + Kustomizations: []blueprintv1alpha1.Kustomization{ + { + Name: "test-kustomization", + }, + }, + } +} + +type Mocks struct { + Injector di.Injector + ConfigHandler config.ConfigHandler + Shell *shell.MockShell + TerraformStack *terraforminfra.MockStack + KubernetesManager *kubernetes.MockKubernetesManager + KubernetesClient kubernetes.KubernetesClient + ClusterClient *cluster.MockClusterClient + InfrastructureExecutionContext *InfrastructureExecutionContext +} + +// setupInfrastructureMocks creates mock components for testing the Infrastructure +func setupInfrastructureMocks(t *testing.T) *Mocks { + t.Helper() + + injector := di.NewInjector() + configHandler := config.NewMockConfigHandler() + mockShell := shell.NewMockShell() + + configHandler.GetStringFunc = func(key string, defaultValue ...string) string { + switch key { + case "cluster.driver": + return "talos" + default: + if len(defaultValue) > 0 { + return defaultValue[0] + } + return "" + } + } + + mockShell.GetProjectRootFunc = func() (string, error) { + return "/test/project", nil + } + + terraformStack := terraforminfra.NewMockStack(injector) + kubernetesManager := kubernetes.NewMockKubernetesManager(injector) + kubernetesClient := kubernetes.NewMockKubernetesClient() + clusterClient := cluster.NewMockClusterClient() + + execCtx := &types.ExecutionContext{ + ContextName: "test-context", + ProjectRoot: "/test/project", + ConfigRoot: "/test/project/contexts/test-context", + TemplateRoot: "/test/project/contexts/_template", + Injector: injector, + ConfigHandler: configHandler, + Shell: mockShell, + } + + infraCtx := &InfrastructureExecutionContext{ + ExecutionContext: *execCtx, + TerraformStack: terraformStack, + KubernetesManager: kubernetesManager, + KubernetesClient: kubernetesClient, + ClusterClient: clusterClient, + } + + injector.Register("shell", mockShell) + injector.Register("configHandler", configHandler) + injector.Register("terraformStack", terraformStack) + injector.Register("kubernetesManager", kubernetesManager) + injector.Register("kubernetesClient", kubernetesClient) + injector.Register("clusterClient", clusterClient) + + return &Mocks{ + Injector: injector, + ConfigHandler: configHandler, + Shell: mockShell, + TerraformStack: terraformStack, + KubernetesManager: kubernetesManager, + KubernetesClient: kubernetesClient, + ClusterClient: clusterClient, + InfrastructureExecutionContext: infraCtx, + } +} + +// ============================================================================= +// Test Constructor +// ============================================================================= + +func TestNewInfrastructure(t *testing.T) { + t.Run("CreatesInfrastructureWithDependencies", func(t *testing.T) { + mocks := setupInfrastructureMocks(t) + + infra := NewInfrastructure(mocks.InfrastructureExecutionContext) + + if infra == nil { + t.Fatal("Expected Infrastructure to be created") + } + + if infra.Injector != mocks.Injector { + t.Error("Expected injector to be set") + } + + if infra.Shell != mocks.Shell { + t.Error("Expected shell to be set") + } + + if infra.ConfigHandler != mocks.ConfigHandler { + t.Error("Expected config handler to be set") + } + + if infra.TerraformStack == nil { + t.Error("Expected terraform stack to be initialized") + } + + if infra.KubernetesManager == nil { + t.Error("Expected kubernetes manager to be initialized") + } + + if infra.KubernetesClient == nil { + t.Error("Expected kubernetes client to be initialized") + } + }) + + t.Run("CreatesClusterClientForTalos", func(t *testing.T) { + mocks := setupInfrastructureMocks(t) + mocks.InfrastructureExecutionContext.ClusterClient = nil + + mockConfigHandler := mocks.ConfigHandler.(*config.MockConfigHandler) + mockConfigHandler.GetStringFunc = func(key string, defaultValue ...string) string { + if key == "cluster.driver" { + return "talos" + } + return "" + } + + infra := NewInfrastructure(mocks.InfrastructureExecutionContext) + + if infra.ClusterClient == nil { + t.Error("Expected cluster client to be created for talos driver") + } + }) + + t.Run("CreatesClusterClientForOmni", func(t *testing.T) { + mocks := setupInfrastructureMocks(t) + mocks.InfrastructureExecutionContext.ClusterClient = nil + + mockConfigHandler := mocks.ConfigHandler.(*config.MockConfigHandler) + mockConfigHandler.GetStringFunc = func(key string, defaultValue ...string) string { + if key == "cluster.driver" { + return "omni" + } + return "" + } + + infra := NewInfrastructure(mocks.InfrastructureExecutionContext) + + if infra.ClusterClient == nil { + t.Error("Expected cluster client to be created for omni driver") + } + }) + + t.Run("SkipsClusterClientForOtherDrivers", func(t *testing.T) { + mocks := setupInfrastructureMocks(t) + mocks.InfrastructureExecutionContext.ClusterClient = nil + + mockConfigHandler := mocks.ConfigHandler.(*config.MockConfigHandler) + mockConfigHandler.GetStringFunc = func(key string, defaultValue ...string) string { + if key == "cluster.driver" { + return "k3s" + } + return "" + } + + infra := NewInfrastructure(mocks.InfrastructureExecutionContext) + + if infra.ClusterClient != nil { + t.Error("Expected cluster client to be nil for non-talos/omni driver") + } + }) + + t.Run("UsesExistingDependencies", func(t *testing.T) { + mocks := setupInfrastructureMocks(t) + + infra := NewInfrastructure(mocks.InfrastructureExecutionContext) + + if infra.TerraformStack != mocks.TerraformStack { + t.Error("Expected existing terraform stack to be used") + } + + if infra.KubernetesManager != mocks.KubernetesManager { + t.Error("Expected existing kubernetes manager to be used") + } + + if infra.KubernetesClient != mocks.KubernetesClient { + t.Error("Expected existing kubernetes client to be used") + } + + if infra.ClusterClient != mocks.ClusterClient { + t.Error("Expected existing cluster client to be used") + } + }) +} + +// ============================================================================= +// Test Public Methods +// ============================================================================= + +func TestInfrastructure_Up(t *testing.T) { + t.Run("Success", func(t *testing.T) { + mocks := setupInfrastructureMocks(t) + infra := NewInfrastructure(mocks.InfrastructureExecutionContext) + + mocks.TerraformStack.InitializeFunc = func() error { + return nil + } + mocks.TerraformStack.UpFunc = func(blueprint *blueprintv1alpha1.Blueprint) error { + return nil + } + + blueprint := createTestBlueprint() + err := infra.Up(blueprint) + + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + }) + + t.Run("ErrorNilBlueprint", func(t *testing.T) { + mocks := setupInfrastructureMocks(t) + infra := NewInfrastructure(mocks.InfrastructureExecutionContext) + + err := infra.Up(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("ErrorNilTerraformStack", func(t *testing.T) { + mocks := setupInfrastructureMocks(t) + infra := NewInfrastructure(mocks.InfrastructureExecutionContext) + infra.TerraformStack = nil + + blueprint := createTestBlueprint() + err := infra.Up(blueprint) + + if err == nil { + t.Error("Expected error for nil terraform stack") + } + + if !strings.Contains(err.Error(), "terraform stack not configured") { + t.Errorf("Expected specific error message, got: %v", err) + } + }) + + t.Run("ErrorTerraformStackInitialize", func(t *testing.T) { + mocks := setupInfrastructureMocks(t) + infra := NewInfrastructure(mocks.InfrastructureExecutionContext) + + mocks.TerraformStack.InitializeFunc = func() error { + return fmt.Errorf("initialize failed") + } + + blueprint := createTestBlueprint() + err := infra.Up(blueprint) + + if err == nil { + t.Error("Expected error for terraform stack initialize failure") + } + + if !strings.Contains(err.Error(), "failed to initialize terraform stack") { + t.Errorf("Expected specific error message, got: %v", err) + } + }) + + t.Run("ErrorTerraformStackUp", func(t *testing.T) { + mocks := setupInfrastructureMocks(t) + infra := NewInfrastructure(mocks.InfrastructureExecutionContext) + + mocks.TerraformStack.InitializeFunc = func() error { + return nil + } + mocks.TerraformStack.UpFunc = func(blueprint *blueprintv1alpha1.Blueprint) error { + return fmt.Errorf("up failed") + } + + blueprint := createTestBlueprint() + err := infra.Up(blueprint) + + if err == nil { + t.Error("Expected error for terraform stack up failure") + } + + if !strings.Contains(err.Error(), "failed to run terraform up") { + t.Errorf("Expected specific error message, got: %v", err) + } + }) +} + +func TestInfrastructure_Down(t *testing.T) { + t.Run("Success", func(t *testing.T) { + mocks := setupInfrastructureMocks(t) + infra := NewInfrastructure(mocks.InfrastructureExecutionContext) + + mocks.TerraformStack.InitializeFunc = func() error { + return nil + } + mocks.TerraformStack.DownFunc = func(blueprint *blueprintv1alpha1.Blueprint) error { + return nil + } + + blueprint := createTestBlueprint() + err := infra.Down(blueprint) + + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + }) + + t.Run("ErrorNilBlueprint", func(t *testing.T) { + mocks := setupInfrastructureMocks(t) + infra := NewInfrastructure(mocks.InfrastructureExecutionContext) + + err := infra.Down(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("ErrorNilTerraformStack", func(t *testing.T) { + mocks := setupInfrastructureMocks(t) + infra := NewInfrastructure(mocks.InfrastructureExecutionContext) + infra.TerraformStack = nil + + blueprint := createTestBlueprint() + err := infra.Down(blueprint) + + if err == nil { + t.Error("Expected error for nil terraform stack") + } + + if !strings.Contains(err.Error(), "terraform stack not configured") { + t.Errorf("Expected specific error message, got: %v", err) + } + }) + + t.Run("ErrorTerraformStackInitialize", func(t *testing.T) { + mocks := setupInfrastructureMocks(t) + infra := NewInfrastructure(mocks.InfrastructureExecutionContext) + + mocks.TerraformStack.InitializeFunc = func() error { + return fmt.Errorf("initialize failed") + } + + blueprint := createTestBlueprint() + err := infra.Down(blueprint) + + if err == nil { + t.Error("Expected error for terraform stack initialize failure") + } + + if !strings.Contains(err.Error(), "failed to initialize terraform stack") { + t.Errorf("Expected specific error message, got: %v", err) + } + }) + + t.Run("ErrorTerraformStackDown", func(t *testing.T) { + mocks := setupInfrastructureMocks(t) + infra := NewInfrastructure(mocks.InfrastructureExecutionContext) + + mocks.TerraformStack.InitializeFunc = func() error { + return nil + } + mocks.TerraformStack.DownFunc = func(blueprint *blueprintv1alpha1.Blueprint) error { + return fmt.Errorf("down failed") + } + + blueprint := createTestBlueprint() + err := infra.Down(blueprint) + + if err == nil { + t.Error("Expected error for terraform stack down failure") + } + + if !strings.Contains(err.Error(), "failed to run terraform down") { + t.Errorf("Expected specific error message, got: %v", err) + } + }) +} + +func TestInfrastructure_Install(t *testing.T) { + t.Run("Success", func(t *testing.T) { + mocks := setupInfrastructureMocks(t) + infra := NewInfrastructure(mocks.InfrastructureExecutionContext) + + mocks.KubernetesManager.InitializeFunc = func() error { + return nil + } + mocks.KubernetesManager.ApplyBlueprintFunc = func(blueprint *blueprintv1alpha1.Blueprint, namespace string) error { + return nil + } + + blueprint := createTestBlueprint() + err := infra.Install(blueprint) + + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + }) + + t.Run("ErrorNilBlueprint", func(t *testing.T) { + mocks := setupInfrastructureMocks(t) + infra := NewInfrastructure(mocks.InfrastructureExecutionContext) + + err := infra.Install(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 := setupInfrastructureMocks(t) + infra := NewInfrastructure(mocks.InfrastructureExecutionContext) + infra.KubernetesManager = nil + + blueprint := createTestBlueprint() + err := infra.Install(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 := setupInfrastructureMocks(t) + infra := NewInfrastructure(mocks.InfrastructureExecutionContext) + + mocks.KubernetesManager.InitializeFunc = func() error { + return fmt.Errorf("initialize failed") + } + + blueprint := createTestBlueprint() + err := infra.Install(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("ErrorApplyBlueprint", func(t *testing.T) { + mocks := setupInfrastructureMocks(t) + infra := NewInfrastructure(mocks.InfrastructureExecutionContext) + + mocks.KubernetesManager.InitializeFunc = func() error { + return nil + } + mocks.KubernetesManager.ApplyBlueprintFunc = func(blueprint *blueprintv1alpha1.Blueprint, namespace string) error { + return fmt.Errorf("apply blueprint failed") + } + + blueprint := createTestBlueprint() + err := infra.Install(blueprint) + + if err == nil { + t.Error("Expected error for apply blueprint failure") + } + + if !strings.Contains(err.Error(), "failed to apply blueprint") { + t.Errorf("Expected specific error message, got: %v", err) + } + }) +} + +func TestInfrastructure_Wait(t *testing.T) { + t.Run("Success", func(t *testing.T) { + mocks := setupInfrastructureMocks(t) + infra := NewInfrastructure(mocks.InfrastructureExecutionContext) + + mocks.KubernetesManager.InitializeFunc = func() error { + return nil + } + mocks.KubernetesManager.WaitForKustomizationsFunc = func(message string, names ...string) error { + return nil + } + + blueprint := createTestBlueprint() + err := infra.Wait(blueprint) + + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + }) + + t.Run("ErrorNilBlueprint", func(t *testing.T) { + mocks := setupInfrastructureMocks(t) + infra := NewInfrastructure(mocks.InfrastructureExecutionContext) + + err := infra.Wait(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 := setupInfrastructureMocks(t) + infra := NewInfrastructure(mocks.InfrastructureExecutionContext) + infra.KubernetesManager = nil + + blueprint := createTestBlueprint() + err := infra.Wait(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 := setupInfrastructureMocks(t) + infra := NewInfrastructure(mocks.InfrastructureExecutionContext) + + mocks.KubernetesManager.InitializeFunc = func() error { + return fmt.Errorf("initialize failed") + } + + blueprint := createTestBlueprint() + err := infra.Wait(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("ErrorWaitForKustomizations", func(t *testing.T) { + mocks := setupInfrastructureMocks(t) + infra := NewInfrastructure(mocks.InfrastructureExecutionContext) + + mocks.KubernetesManager.InitializeFunc = func() error { + return nil + } + mocks.KubernetesManager.WaitForKustomizationsFunc = func(message string, names ...string) error { + return fmt.Errorf("wait failed") + } + + blueprint := createTestBlueprint() + err := infra.Wait(blueprint) + + if err == nil { + t.Error("Expected error for wait for kustomizations failure") + } + + if !strings.Contains(err.Error(), "failed waiting for kustomizations") { + t.Errorf("Expected specific error message, got: %v", err) + } + }) +} + +func TestInfrastructure_Close(t *testing.T) { + t.Run("Success", func(t *testing.T) { + mocks := setupInfrastructureMocks(t) + infra := NewInfrastructure(mocks.InfrastructureExecutionContext) + + closeCalled := false + mocks.ClusterClient.CloseFunc = func() { + closeCalled = true + } + + infra.Close() + + if !closeCalled { + t.Error("Expected ClusterClient.Close to be called") + } + }) + + t.Run("HandlesNilClusterClient", func(t *testing.T) { + mocks := setupInfrastructureMocks(t) + infra := NewInfrastructure(mocks.InfrastructureExecutionContext) + infra.ClusterClient = nil + + infra.Close() + + if infra.ClusterClient != nil { + t.Error("Expected nil cluster client to remain nil after Close") + } + }) +} + +// ============================================================================= +// Test InfrastructureExecutionContext +// ============================================================================= + +func TestInfrastructureExecutionContext(t *testing.T) { + t.Run("CreatesInfrastructureExecutionContext", func(t *testing.T) { + execCtx := &types.ExecutionContext{ + ContextName: "test-context", + ProjectRoot: "/test/project", + ConfigRoot: "/test/project/contexts/test-context", + TemplateRoot: "/test/project/contexts/_template", + } + + infraCtx := &InfrastructureExecutionContext{ + ExecutionContext: *execCtx, + } + + if infraCtx.ContextName != "test-context" { + t.Errorf("Expected context name 'test-context', got: %s", infraCtx.ContextName) + } + + if infraCtx.ProjectRoot != "/test/project" { + t.Errorf("Expected project root '/test/project', got: %s", infraCtx.ProjectRoot) + } + + if infraCtx.ConfigRoot != "/test/project/contexts/test-context" { + t.Errorf("Expected config root '/test/project/contexts/test-context', got: %s", infraCtx.ConfigRoot) + } + + if infraCtx.TemplateRoot != "/test/project/contexts/_template" { + t.Errorf("Expected template root '/test/project/contexts/_template', got: %s", infraCtx.TemplateRoot) + } + }) +} diff --git a/pkg/infrastructure/kubernetes/kubernetes_manager.go b/pkg/infrastructure/kubernetes/kubernetes_manager.go index ba92d7de7..5d523dba9 100644 --- a/pkg/infrastructure/kubernetes/kubernetes_manager.go +++ b/pkg/infrastructure/kubernetes/kubernetes_manager.go @@ -15,7 +15,9 @@ import ( "github.com/briandowns/spinner" helmv2 "github.com/fluxcd/helm-controller/api/v2" kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1" + meta "github.com/fluxcd/pkg/apis/meta" sourcev1 "github.com/fluxcd/source-controller/api/v1" + blueprintv1alpha1 "github.com/windsorcli/cli/api/v1alpha1" "github.com/windsorcli/cli/pkg/constants" "github.com/windsorcli/cli/pkg/di" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -47,6 +49,7 @@ type KubernetesManager interface { GetKustomizationStatus(names []string) (map[string]bool, error) 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 } // ============================================================================= @@ -575,6 +578,120 @@ func (k *BaseKubernetesManager) WaitForKubernetesHealthy(ctx context.Context, en return fmt.Errorf("timeout waiting for Kubernetes API to be healthy") } +// GetNodeReadyStatus returns a map of node names to their Ready condition status. +// Returns a map of node names to Ready status (true if Ready, false if NotReady), or an error if listing fails. +func (k *BaseKubernetesManager) GetNodeReadyStatus(ctx context.Context, nodeNames []string) (map[string]bool, error) { + if k.client == nil { + return nil, fmt.Errorf("kubernetes client not initialized") + } + return k.client.GetNodeReadyStatus(ctx, nodeNames) +} + +// ApplyBlueprint applies an entire blueprint to the cluster. It creates the namespace, applies all source +// repositories (Git and OCI), and applies all kustomizations. This method orchestrates the complete +// blueprint installation process in the correct order. +func (k *BaseKubernetesManager) ApplyBlueprint(blueprint *blueprintv1alpha1.Blueprint, namespace string) error { + if err := k.CreateNamespace(namespace); err != nil { + return fmt.Errorf("failed to create namespace: %w", err) + } + + if blueprint.Repository.Url != "" { + source := blueprintv1alpha1.Source{ + Name: blueprint.Metadata.Name, + Url: blueprint.Repository.Url, + Ref: blueprint.Repository.Ref, + SecretName: blueprint.Repository.SecretName, + } + if err := k.applyBlueprintSource(source, namespace); err != nil { + return fmt.Errorf("failed to apply blueprint repository: %w", err) + } + } + + for _, source := range blueprint.Sources { + if err := k.applyBlueprintSource(source, namespace); err != nil { + return fmt.Errorf("failed to apply source %s: %w", source.Name, err) + } + } + + defaultSourceName := blueprint.Metadata.Name + for _, kustomization := range blueprint.Kustomizations { + if len(kustomization.Substitutions) > 0 { + configMapName := fmt.Sprintf("values-%s", kustomization.Name) + if err := k.ApplyConfigMap(configMapName, namespace, kustomization.Substitutions); err != nil { + return fmt.Errorf("failed to create ConfigMap for kustomization %s: %w", kustomization.Name, err) + } + } + fluxKustomization := kustomization.ToFluxKustomization(namespace, defaultSourceName, blueprint.Sources) + if err := k.ApplyKustomization(fluxKustomization); err != nil { + return fmt.Errorf("failed to apply kustomization %s: %w", kustomization.Name, err) + } + } + + return nil +} + +// ============================================================================= +// Private Methods +// ============================================================================= + +// applyWithRetry applies a resource using SSA with minimal logic +func (k *BaseKubernetesManager) applyWithRetry(gvr schema.GroupVersionResource, obj *unstructured.Unstructured, opts metav1.ApplyOptions) error { + existing, err := k.client.GetResource(gvr, obj.GetNamespace(), obj.GetName()) + if err == nil { + applyConfig, err := k.shims.ToUnstructured(existing) + if err != nil { + return fmt.Errorf("failed to convert existing object to unstructured: %w", err) + } + + maps.Copy(applyConfig, obj.Object) + + mergedObj := &unstructured.Unstructured{Object: applyConfig} + mergedObj.SetResourceVersion(existing.GetResourceVersion()) + + opts.Force = true + + _, err = k.client.ApplyResource(gvr, mergedObj, opts) + return err + } + + _, err = k.client.ApplyResource(gvr, obj, opts) + return err +} + +// getHelmRelease gets a HelmRelease by name and namespace +func (k *BaseKubernetesManager) getHelmRelease(name, namespace string) (*helmv2.HelmRelease, error) { + gvr := schema.GroupVersionResource{ + Group: "helm.toolkit.fluxcd.io", + Version: "v2", + Resource: "helmreleases", + } + + obj, err := k.client.GetResource(gvr, namespace, name) + if err != nil { + return nil, fmt.Errorf("failed to get helm release: %w", err) + } + + var helmRelease helmv2.HelmRelease + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.UnstructuredContent(), &helmRelease); err != nil { + return nil, fmt.Errorf("failed to convert helm release: %w", err) + } + + return &helmRelease, nil +} + +// applyBlueprintSource applies a blueprint Source as a GitRepository or OCIRepository resource. +// It routes to the appropriate repository type based on the source URL and applies it to the cluster. +func (k *BaseKubernetesManager) applyBlueprintSource(source blueprintv1alpha1.Source, namespace string) error { + if strings.HasPrefix(source.Url, "oci://") { + return k.applyBlueprintOCIRepository(source, namespace) + } + return k.applyBlueprintGitRepository(source, namespace) +} + +// ============================================================================= +// Private Methods +// ============================================================================= + // waitForNodesReady blocks until all specified nodes exist and are in Ready state or the context deadline is reached. // It periodically queries node status, invokes outputFunc on status changes, and returns an error if any nodes are missing or not Ready within the deadline. // If the context is cancelled, returns an error immediately. @@ -600,15 +717,12 @@ func (k *BaseKubernetesManager) waitForNodesReady(ctx context.Context, nodeNames var missingNodes []string var notReadyNodes []string - var readyNodes []string for _, nodeName := range nodeNames { if ready, exists := readyStatus[nodeName]; !exists { missingNodes = append(missingNodes, nodeName) } else if !ready { notReadyNodes = append(notReadyNodes, nodeName) - } else { - readyNodes = append(readyNodes, nodeName) } } @@ -638,7 +752,6 @@ func (k *BaseKubernetesManager) waitForNodesReady(ctx context.Context, nodeNames } } - // Final check to get the current status for error reporting readyStatus, err := k.client.GetNodeReadyStatus(ctx, nodeNames) if err != nil { return fmt.Errorf("timeout waiting for nodes to be ready: failed to get final status: %w", err) @@ -666,58 +779,104 @@ func (k *BaseKubernetesManager) waitForNodesReady(ctx context.Context, nodeNames return fmt.Errorf("timeout waiting for nodes to be ready") } -// GetNodeReadyStatus returns a map of node names to their Ready condition status. -// Returns a map of node names to Ready status (true if Ready, false if NotReady), or an error if listing fails. -func (k *BaseKubernetesManager) GetNodeReadyStatus(ctx context.Context, nodeNames []string) (map[string]bool, error) { - if k.client == nil { - return nil, fmt.Errorf("kubernetes client not initialized") +// applyBlueprintGitRepository converts and applies a blueprint Source as a GitRepository. +func (k *BaseKubernetesManager) applyBlueprintGitRepository(source blueprintv1alpha1.Source, namespace string) error { + sourceUrl := source.Url + if !strings.HasPrefix(sourceUrl, "http://") && !strings.HasPrefix(sourceUrl, "https://") { + sourceUrl = "https://" + sourceUrl } - return k.client.GetNodeReadyStatus(ctx, nodeNames) -} -// applyWithRetry applies a resource using SSA with minimal logic -func (k *BaseKubernetesManager) applyWithRetry(gvr schema.GroupVersionResource, obj *unstructured.Unstructured, opts metav1.ApplyOptions) error { - existing, err := k.client.GetResource(gvr, obj.GetNamespace(), obj.GetName()) - if err == nil { - applyConfig, err := k.shims.ToUnstructured(existing) - if err != nil { - return fmt.Errorf("failed to convert existing object to unstructured: %w", err) - } + 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.DEFAULT_FLUX_SOURCE_INTERVAL, + }, + Timeout: &metav1.Duration{ + Duration: constants.DEFAULT_FLUX_SOURCE_TIMEOUT, + }, + Reference: &sourcev1.GitRepositoryRef{ + Branch: source.Ref.Branch, + Tag: source.Ref.Tag, + SemVer: source.Ref.SemVer, + Commit: source.Ref.Commit, + }, + }, + } - maps.Copy(applyConfig, obj.Object) + if source.SecretName != "" { + gitRepo.Spec.SecretRef = &meta.LocalObjectReference{ + Name: source.SecretName, + } + } - mergedObj := &unstructured.Unstructured{Object: applyConfig} - mergedObj.SetResourceVersion(existing.GetResourceVersion()) + return k.ApplyGitRepository(gitRepo) +} - opts.Force = true +// applyBlueprintOCIRepository converts and applies a blueprint Source as an OCIRepository. +func (k *BaseKubernetesManager) applyBlueprintOCIRepository(source blueprintv1alpha1.Source, namespace string) error { + ociURL := source.Url + var ref *sourcev1.OCIRepositoryRef - _, err = k.client.ApplyResource(gvr, mergedObj, opts) - return err + 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, + } + } } - _, err = k.client.ApplyResource(gvr, obj, opts) - return err -} + 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, + } + } -// getHelmRelease gets a HelmRelease by name and namespace -func (k *BaseKubernetesManager) getHelmRelease(name, namespace string) (*helmv2.HelmRelease, error) { - gvr := schema.GroupVersionResource{ - Group: "helm.toolkit.fluxcd.io", - Version: "v2", - Resource: "helmreleases", + if ref == nil { + ref = &sourcev1.OCIRepositoryRef{ + Tag: "latest", + } } - obj, err := k.client.GetResource(gvr, namespace, name) - if err != nil { - return nil, fmt.Errorf("failed to get helm release: %w", err) + 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.DEFAULT_FLUX_SOURCE_INTERVAL, + }, + Timeout: &metav1.Duration{ + Duration: constants.DEFAULT_FLUX_SOURCE_TIMEOUT, + }, + Reference: ref, + }, } - var helmRelease helmv2.HelmRelease - if err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.UnstructuredContent(), &helmRelease); err != nil { - return nil, fmt.Errorf("failed to convert helm release: %w", err) + if source.SecretName != "" { + ociRepo.Spec.SecretRef = &meta.LocalObjectReference{ + Name: source.SecretName, + } } - return &helmRelease, nil + return k.ApplyOCIRepository(ociRepo) } // ============================================================================= @@ -782,14 +941,12 @@ func isImmutableConfigMap(obj *unstructured.Unstructured) bool { } // isNotFoundError checks if an error is a Kubernetes resource not found error -// This is used during cleanup to ignore errors when resources don't exist func isNotFoundError(err error) bool { if err == nil { return false } errMsg := strings.ToLower(err.Error()) - // Check for resource not found errors, but not namespace not found errors return (strings.Contains(errMsg, "resource not found") || strings.Contains(errMsg, "could not find the requested resource") || strings.Contains(errMsg, "the server could not find the requested resource") || diff --git a/pkg/infrastructure/kubernetes/mock_kubernetes_manager.go b/pkg/infrastructure/kubernetes/mock_kubernetes_manager.go index 45ce2b494..f410265bc 100644 --- a/pkg/infrastructure/kubernetes/mock_kubernetes_manager.go +++ b/pkg/infrastructure/kubernetes/mock_kubernetes_manager.go @@ -10,6 +10,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" "github.com/windsorcli/cli/pkg/di" ) @@ -35,6 +36,7 @@ type MockKubernetesManager struct { CheckGitRepositoryStatusFunc func() error 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 } // ============================================================================= @@ -182,3 +184,11 @@ func (m *MockKubernetesManager) GetNodeReadyStatus(ctx context.Context, nodeName } return make(map[string]bool), nil } + +// ApplyBlueprint implements KubernetesManager interface +func (m *MockKubernetesManager) ApplyBlueprint(blueprint *blueprintv1alpha1.Blueprint, namespace string) error { + if m.ApplyBlueprintFunc != nil { + return m.ApplyBlueprintFunc(blueprint, namespace) + } + return nil +} diff --git a/pkg/infrastructure/terraform/mock_stack.go b/pkg/infrastructure/terraform/mock_stack.go index 8d83589d8..4270845eb 100644 --- a/pkg/infrastructure/terraform/mock_stack.go +++ b/pkg/infrastructure/terraform/mock_stack.go @@ -1,6 +1,9 @@ package terraform -import "github.com/windsorcli/cli/pkg/di" +import ( + blueprintv1alpha1 "github.com/windsorcli/cli/api/v1alpha1" + "github.com/windsorcli/cli/pkg/di" +) // The MockStack is a test implementation of the Stack interface. // It provides function fields that can be set to customize behavior in tests, @@ -14,8 +17,8 @@ import "github.com/windsorcli/cli/pkg/di" // MockStack is a mock implementation of the Stack interface for testing. type MockStack struct { InitializeFunc func() error - UpFunc func() error - DownFunc func() error + UpFunc func(blueprint *blueprintv1alpha1.Blueprint) error + DownFunc func(blueprint *blueprintv1alpha1.Blueprint) error } // ============================================================================= @@ -40,17 +43,17 @@ func (m *MockStack) Initialize() error { } // Up is a mock implementation of the Up method. -func (m *MockStack) Up() error { +func (m *MockStack) Up(blueprint *blueprintv1alpha1.Blueprint) error { if m.UpFunc != nil { - return m.UpFunc() + return m.UpFunc(blueprint) } return nil } // Down is a mock implementation of the Down method. -func (m *MockStack) Down() error { +func (m *MockStack) Down(blueprint *blueprintv1alpha1.Blueprint) error { if m.DownFunc != nil { - return m.DownFunc() + return m.DownFunc(blueprint) } return nil } diff --git a/pkg/infrastructure/terraform/mock_stack_test.go b/pkg/infrastructure/terraform/mock_stack_test.go index 626d52117..8e84de28e 100644 --- a/pkg/infrastructure/terraform/mock_stack_test.go +++ b/pkg/infrastructure/terraform/mock_stack_test.go @@ -8,6 +8,8 @@ package terraform import ( "fmt" "testing" + + blueprintv1alpha1 "github.com/windsorcli/cli/api/v1alpha1" ) // ============================================================================= @@ -51,12 +53,13 @@ func TestMockStack_Up(t *testing.T) { t.Run("WithFuncSet", func(t *testing.T) { // Given a new MockStack with a custom UpFunc that returns an error mock := NewMockStack(nil) - mock.UpFunc = func() error { + mock.UpFunc = func(blueprint *blueprintv1alpha1.Blueprint) error { return mockUpErr } // When Up is called - err := mock.Up() + blueprint := &blueprintv1alpha1.Blueprint{} + err := mock.Up(blueprint) // Then the custom error should be returned if err != mockUpErr { @@ -69,7 +72,8 @@ func TestMockStack_Up(t *testing.T) { mock := NewMockStack(nil) // When Up is called - err := mock.Up() + blueprint := &blueprintv1alpha1.Blueprint{} + err := mock.Up(blueprint) // Then no error should be returned if err != nil { @@ -84,12 +88,13 @@ func TestMockStack_Down(t *testing.T) { t.Run("WithFuncSet", func(t *testing.T) { // Given a new MockStack with a custom DownFunc that returns an error mock := NewMockStack(nil) - mock.DownFunc = func() error { + mock.DownFunc = func(blueprint *blueprintv1alpha1.Blueprint) error { return mockDownErr } // When Down is called - err := mock.Down() + blueprint := &blueprintv1alpha1.Blueprint{} + err := mock.Down(blueprint) // Then the custom error should be returned if err != mockDownErr { @@ -102,7 +107,8 @@ func TestMockStack_Down(t *testing.T) { mock := NewMockStack(nil) // When Down is called - err := mock.Down() + blueprint := &blueprintv1alpha1.Blueprint{} + err := mock.Down(blueprint) // Then no error should be returned if err != nil { diff --git a/pkg/infrastructure/terraform/stack.go b/pkg/infrastructure/terraform/stack.go index 6f97f4613..eaf23555f 100644 --- a/pkg/infrastructure/terraform/stack.go +++ b/pkg/infrastructure/terraform/stack.go @@ -1,15 +1,23 @@ package terraform -// The Stack is a core component that manages infrastructure component stacks. +// The Stack package provides infrastructure component stack management functionality. // It provides a unified interface for initializing and managing infrastructure stacks, // with support for dependency injection and component lifecycle management. // The Stack acts as the primary orchestrator for infrastructure operations, -// coordinating shell operations and blueprint handling. +// coordinating shell operations and blueprint handling. The WindsorStack is a specialized +// implementation for Terraform-based infrastructure that handles directory management, +// terraform environment configuration, and Terraform operations. import ( "fmt" + "os" + "path/filepath" + "regexp" + "strings" + blueprintv1alpha1 "github.com/windsorcli/cli/api/v1alpha1" "github.com/windsorcli/cli/pkg/di" + "github.com/windsorcli/cli/pkg/environment/envvars" "github.com/windsorcli/cli/pkg/resources/blueprint" "github.com/windsorcli/cli/pkg/shell" ) @@ -21,8 +29,8 @@ import ( // Stack is an interface that represents a stack of components. type Stack interface { Initialize() error - Up() error - Down() error + Up(blueprint *blueprintv1alpha1.Blueprint) error + Down(blueprint *blueprintv1alpha1.Blueprint) error } // ============================================================================= @@ -37,8 +45,14 @@ type BaseStack struct { shims *Shims } +// WindsorStack is a struct that implements the Stack interface. +type WindsorStack struct { + BaseStack + terraformEnv *envvars.TerraformEnvPrinter +} + // ============================================================================= -// Constructor +// Constructors // ============================================================================= // NewBaseStack creates a new base stack of components. @@ -49,20 +63,28 @@ func NewBaseStack(injector di.Injector) *BaseStack { } } +// NewWindsorStack creates a new WindsorStack. +func NewWindsorStack(injector di.Injector) *WindsorStack { + return &WindsorStack{ + BaseStack: BaseStack{ + injector: injector, + shims: NewShims(), + }, + } +} + // ============================================================================= // Public Methods // ============================================================================= // Initialize initializes the stack of components. func (s *BaseStack) Initialize() error { - // Resolve the shell shell, ok := s.injector.Resolve("shell").(shell.Shell) if !ok { return fmt.Errorf("error resolving shell") } s.shell = shell - // Resolve the blueprint handler blueprintHandler, ok := s.injector.Resolve("blueprintHandler").(blueprint.BlueprintHandler) if !ok { return fmt.Errorf("error resolving blueprintHandler") @@ -73,14 +95,324 @@ func (s *BaseStack) Initialize() error { } // Up creates a new stack of components. -func (s *BaseStack) Up() error { +func (s *BaseStack) Up(blueprint *blueprintv1alpha1.Blueprint) error { return nil } // Down destroys a stack of components. -func (s *BaseStack) Down() error { +func (s *BaseStack) Down(blueprint *blueprintv1alpha1.Blueprint) error { return nil } +// Initialize initializes the WindsorStack by calling the base Initialize and resolving terraform environment. +func (s *WindsorStack) Initialize() error { + if err := s.BaseStack.Initialize(); err != nil { + return err + } + + terraformEnvInterface := s.injector.Resolve("terraformEnv") + if terraformEnvInterface == nil { + return fmt.Errorf("terraformEnv not found in dependency injector") + } + + terraformEnv, ok := terraformEnvInterface.(*envvars.TerraformEnvPrinter) + if !ok { + return fmt.Errorf("error resolving terraformEnv") + } + s.terraformEnv = terraformEnv + + return nil +} + +// Up creates a new stack of components by initializing and applying Terraform configurations. +// It processes components in order, generating terraform arguments, running Terraform init, +// plan, and apply operations, and cleaning up backend override files. +// The method ensures proper directory management and terraform argument setup for each component. +// The blueprint parameter is required to resolve terraform components. +func (s *WindsorStack) Up(blueprint *blueprintv1alpha1.Blueprint) error { + if blueprint == nil { + return fmt.Errorf("blueprint not provided") + } + + currentDir, err := s.shims.Getwd() + if err != nil { + return fmt.Errorf("error getting current directory: %v", err) + } + + defer func() { + _ = s.shims.Chdir(currentDir) + }() + + projectRoot, err := s.shell.GetProjectRoot() + if err != nil { + return fmt.Errorf("error getting project root: %w", err) + } + components := s.resolveTerraformComponents(blueprint, projectRoot) + + for _, component := range components { + if _, err := s.shims.Stat(component.FullPath); os.IsNotExist(err) { + return fmt.Errorf("directory %s does not exist", component.FullPath) + } + + terraformArgs, err := s.terraformEnv.GenerateTerraformArgs(component.Path, component.FullPath) + if err != nil { + return fmt.Errorf("error generating terraform args for %s: %w", component.Path, err) + } + + tfCliArgsVars := []string{"TF_CLI_ARGS_init", "TF_CLI_ARGS_plan", "TF_CLI_ARGS_apply", "TF_CLI_ARGS_destroy", "TF_CLI_ARGS_import"} + for _, envVar := range tfCliArgsVars { + if err := s.shims.Unsetenv(envVar); err != nil { + return fmt.Errorf("error unsetting %s: %w", envVar, err) + } + } + + for key, value := range terraformArgs.TerraformVars { + if key == "TF_DATA_DIR" || strings.HasPrefix(key, "TF_VAR_") { + if err := s.shims.Setenv(key, value); err != nil { + return fmt.Errorf("error setting %s: %w", key, err) + } + } + } + + if err := s.terraformEnv.PostEnvHook(component.FullPath); err != nil { + return fmt.Errorf("error creating backend override file for %s: %w", component.Path, err) + } + + initArgs := []string{fmt.Sprintf("-chdir=%s", terraformArgs.ModulePath), "init"} + initArgs = append(initArgs, terraformArgs.InitArgs...) + _, err = s.shell.ExecProgress(fmt.Sprintf("๐ŸŒŽ Initializing Terraform in %s", component.Path), "terraform", initArgs...) + if err != nil { + return fmt.Errorf("error running terraform init for %s: %w", component.Path, err) + } + + refreshArgs := []string{fmt.Sprintf("-chdir=%s", terraformArgs.ModulePath), "refresh"} + refreshArgs = append(refreshArgs, terraformArgs.RefreshArgs...) + _, _ = s.shell.ExecProgress(fmt.Sprintf("๐Ÿ”„ Refreshing Terraform state in %s", component.Path), "terraform", refreshArgs...) + + planArgs := []string{fmt.Sprintf("-chdir=%s", terraformArgs.ModulePath), "plan"} + planArgs = append(planArgs, terraformArgs.PlanArgs...) + _, err = s.shell.ExecProgress(fmt.Sprintf("๐ŸŒŽ Planning Terraform changes in %s", component.Path), "terraform", planArgs...) + if err != nil { + return fmt.Errorf("error running terraform plan for %s: %w", component.Path, err) + } + + applyArgs := []string{fmt.Sprintf("-chdir=%s", terraformArgs.ModulePath), "apply"} + applyArgs = append(applyArgs, terraformArgs.ApplyArgs...) + _, err = s.shell.ExecProgress(fmt.Sprintf("๐ŸŒŽ Applying Terraform changes in %s", component.Path), "terraform", applyArgs...) + if err != nil { + return fmt.Errorf("error running terraform apply for %s: %w", component.Path, err) + } + + backendOverridePath := filepath.Join(component.FullPath, "backend_override.tf") + if _, err := s.shims.Stat(backendOverridePath); err == nil { + if err := s.shims.Remove(backendOverridePath); err != nil { + return fmt.Errorf("error removing backend override file for %s: %w", component.Path, err) + } + } + } + + return nil +} + +// Down destroys all Terraform components in the stack by executing Terraform destroy operations in reverse dependency order. +// For each component, Down generates Terraform arguments, sets required environment variables, unsets conflicting TF_CLI_ARGS_* variables, +// creates backend override files, runs Terraform refresh, plan (with destroy flag), and destroy commands, and removes backend override files. +// Components with Destroy set to false are skipped. Directory state is restored after execution. Errors are returned on any operation failure. +// The blueprint parameter is required to resolve terraform components. +func (s *WindsorStack) Down(blueprint *blueprintv1alpha1.Blueprint) error { + if blueprint == nil { + return fmt.Errorf("blueprint not provided") + } + + currentDir, err := s.shims.Getwd() + if err != nil { + return fmt.Errorf("error getting current directory: %v", err) + } + + defer func() { + _ = s.shims.Chdir(currentDir) + }() + + projectRoot, err := s.shell.GetProjectRoot() + if err != nil { + return fmt.Errorf("error getting project root: %w", err) + } + components := s.resolveTerraformComponents(blueprint, projectRoot) + + for i := len(components) - 1; i >= 0; i-- { + component := components[i] + + if component.Destroy != nil && !*component.Destroy { + continue + } + + if _, err := s.shims.Stat(component.FullPath); os.IsNotExist(err) { + continue + } + + terraformArgs, err := s.terraformEnv.GenerateTerraformArgs(component.Path, component.FullPath) + if err != nil { + return fmt.Errorf("error generating terraform args for %s: %w", component.Path, err) + } + + tfCliArgsVars := []string{"TF_CLI_ARGS_init", "TF_CLI_ARGS_plan", "TF_CLI_ARGS_apply", "TF_CLI_ARGS_destroy", "TF_CLI_ARGS_import"} + for _, envVar := range tfCliArgsVars { + if err := s.shims.Unsetenv(envVar); err != nil { + return fmt.Errorf("error unsetting %s: %w", envVar, err) + } + } + + for key, value := range terraformArgs.TerraformVars { + if key == "TF_DATA_DIR" || strings.HasPrefix(key, "TF_VAR_") { + if err := s.shims.Setenv(key, value); err != nil { + return fmt.Errorf("error setting %s: %w", key, err) + } + } + } + + if err := s.terraformEnv.PostEnvHook(component.FullPath); err != nil { + return fmt.Errorf("error creating backend override file for %s: %w", component.Path, err) + } + + refreshArgs := []string{fmt.Sprintf("-chdir=%s", terraformArgs.ModulePath), "refresh"} + refreshArgs = append(refreshArgs, terraformArgs.RefreshArgs...) + _, _ = s.shell.ExecProgress(fmt.Sprintf("๐Ÿ”„ Refreshing Terraform state in %s", component.Path), "terraform", refreshArgs...) + + planArgs := []string{fmt.Sprintf("-chdir=%s", terraformArgs.ModulePath), "plan"} + planArgs = append(planArgs, terraformArgs.PlanDestroyArgs...) + if _, err := s.shell.ExecProgress(fmt.Sprintf("๐Ÿ—‘๏ธ Planning terraform destroy for %s", component.Path), "terraform", planArgs...); err != nil { + return fmt.Errorf("error running terraform plan destroy for %s: %w", component.Path, err) + } + + destroyArgs := []string{fmt.Sprintf("-chdir=%s", terraformArgs.ModulePath), "destroy"} + destroyArgs = append(destroyArgs, terraformArgs.DestroyArgs...) + if _, err := s.shell.ExecProgress(fmt.Sprintf("๐Ÿ—‘๏ธ Destroying terraform for %s", component.Path), "terraform", destroyArgs...); err != nil { + return fmt.Errorf("error running terraform destroy for %s: %w", component.Path, err) + } + + if err := s.shims.Remove(filepath.Join(component.FullPath, "backend_override.tf")); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("error removing backend_override.tf from %s: %w", component.Path, err) + } + } + + return nil +} + +// ============================================================================= +// Private Methods +// ============================================================================= + +// resolveTerraformComponents resolves terraform components from the blueprint by resolving sources and paths. +func (s *WindsorStack) resolveTerraformComponents(blueprint *blueprintv1alpha1.Blueprint, projectRoot string) []blueprintv1alpha1.TerraformComponent { + blueprintCopy := *blueprint + s.resolveComponentSources(&blueprintCopy) + s.resolveComponentPaths(&blueprintCopy, projectRoot) + return blueprintCopy.TerraformComponents +} + +// resolveComponentSources resolves component source names to full URLs using blueprint sources. +func (s *WindsorStack) resolveComponentSources(blueprint *blueprintv1alpha1.Blueprint) { + resolvedComponents := make([]blueprintv1alpha1.TerraformComponent, len(blueprint.TerraformComponents)) + copy(resolvedComponents, blueprint.TerraformComponents) + + for i, component := range resolvedComponents { + for _, source := range blueprint.Sources { + if component.Source == source.Name { + pathPrefix := source.PathPrefix + if pathPrefix == "" { + pathPrefix = "terraform" + } + + ref := source.Ref.Commit + if ref == "" { + ref = source.Ref.SemVer + } + if ref == "" { + ref = source.Ref.Tag + } + if ref == "" { + ref = source.Ref.Branch + } + + if strings.HasPrefix(source.Url, "oci://") { + baseURL := source.Url + if ref != "" && !strings.Contains(baseURL, ":") { + baseURL = baseURL + ":" + ref + } + resolvedComponents[i].Source = baseURL + "//" + pathPrefix + "/" + component.Path + } else { + resolvedComponents[i].Source = source.Url + "//" + pathPrefix + "/" + component.Path + "?ref=" + ref + } + break + } + } + } + + blueprint.TerraformComponents = resolvedComponents +} + +// resolveComponentPaths determines the full filesystem path for each Terraform component. +func (s *WindsorStack) resolveComponentPaths(blueprint *blueprintv1alpha1.Blueprint, projectRoot string) { + resolvedComponents := make([]blueprintv1alpha1.TerraformComponent, len(blueprint.TerraformComponents)) + copy(resolvedComponents, blueprint.TerraformComponents) + + for i, component := range resolvedComponents { + componentCopy := component + + if s.isValidTerraformRemoteSource(componentCopy.Source) || s.isOCISource(componentCopy.Source, blueprint) { + componentCopy.FullPath = filepath.Join(projectRoot, ".windsor", ".tf_modules", componentCopy.Path) + } else { + componentCopy.FullPath = filepath.Join(projectRoot, "terraform", componentCopy.Path) + } + + componentCopy.FullPath = filepath.FromSlash(componentCopy.FullPath) + + resolvedComponents[i] = componentCopy + } + + blueprint.TerraformComponents = resolvedComponents +} + +// isValidTerraformRemoteSource checks if the source is a valid Terraform module reference. +func (s *WindsorStack) isValidTerraformRemoteSource(source string) bool { + patterns := []string{ + `^git::https://[^/]+/.*\.git(?:@.*)?$`, + `^git@[^:]+:.*\.git(?:@.*)?$`, + `^https?://[^/]+/.*\.git(?:@.*)?$`, + `^https?://[^/]+/.*\.zip(?:@.*)?$`, + `^https?://[^/]+/.*//.*(?:@.*)?$`, + `^registry\.terraform\.io/.*`, + `^[^/]+\.com/.*`, + } + + for _, pattern := range patterns { + matched, err := regexp.MatchString(pattern, source) + if err != nil { + return false + } + if matched { + return true + } + } + + return false +} + +// isOCISource returns true if the provided source is an OCI repository reference. +func (s *WindsorStack) isOCISource(sourceNameOrURL string, blueprint *blueprintv1alpha1.Blueprint) bool { + if strings.HasPrefix(sourceNameOrURL, "oci://") { + return true + } + if sourceNameOrURL == blueprint.Metadata.Name && strings.HasPrefix(blueprint.Repository.Url, "oci://") { + return true + } + for _, source := range blueprint.Sources { + if source.Name == sourceNameOrURL && strings.HasPrefix(source.Url, "oci://") { + return true + } + } + return false +} + // Ensure BaseStack implements Stack var _ Stack = (*BaseStack)(nil) diff --git a/pkg/infrastructure/terraform/stack_test.go b/pkg/infrastructure/terraform/stack_test.go index 01311fb6f..acd8b9a05 100644 --- a/pkg/infrastructure/terraform/stack_test.go +++ b/pkg/infrastructure/terraform/stack_test.go @@ -6,6 +6,7 @@ package terraform // verifying error handling, mock interactions, and infrastructure state management. import ( + "fmt" "os" "path/filepath" "strings" @@ -14,6 +15,7 @@ import ( blueprintv1alpha1 "github.com/windsorcli/cli/api/v1alpha1" "github.com/windsorcli/cli/pkg/config" "github.com/windsorcli/cli/pkg/di" + "github.com/windsorcli/cli/pkg/environment/envvars" "github.com/windsorcli/cli/pkg/resources/blueprint" "github.com/windsorcli/cli/pkg/shell" ) @@ -22,6 +24,38 @@ import ( // Test Setup // ============================================================================= +// createTestBlueprint creates a test blueprint with terraform components +func createTestBlueprint() *blueprintv1alpha1.Blueprint { + return &blueprintv1alpha1.Blueprint{ + Metadata: blueprintv1alpha1.Metadata{ + Name: "test-blueprint", + }, + Sources: []blueprintv1alpha1.Source{ + { + Name: "source1", + Url: "https://github.com/example/example.git", + Ref: blueprintv1alpha1.Reference{Branch: "main"}, + }, + }, + TerraformComponents: []blueprintv1alpha1.TerraformComponent{ + { + Source: "source1", + Path: "remote/path", + Inputs: map[string]any{ + "remote_variable1": "default_value", + }, + }, + { + Source: "", + Path: "local/path", + Inputs: map[string]any{ + "local_variable1": "default_value", + }, + }, + }, + } +} + type Mocks struct { Injector di.Injector ConfigHandler config.ConfigHandler @@ -40,7 +74,6 @@ type SetupOptions struct { func setupMocks(t *testing.T, opts ...*SetupOptions) *Mocks { t.Helper() - // Store original directory and create temp dir origDir, err := os.Getwd() if err != nil { t.Fatalf("Failed to get working directory: %v", err) @@ -51,16 +84,13 @@ func setupMocks(t *testing.T, opts ...*SetupOptions) *Mocks { t.Fatalf("Failed to change to temp directory: %v", err) } - // Set project root environment variable os.Setenv("WINDSOR_PROJECT_ROOT", tmpDir) - // Process options with defaults options := &SetupOptions{} if len(opts) > 0 && opts[0] != nil { options = opts[0] } - // Create injector var injector di.Injector if options.Injector == nil { injector = di.NewMockInjector() @@ -68,10 +98,8 @@ func setupMocks(t *testing.T, opts ...*SetupOptions) *Mocks { injector = options.Injector } - // Create mock shell mockShell := shell.NewMockShell() - // Create mock blueprint handler mockBlueprint := blueprint.NewMockBlueprintHandler(injector) mockBlueprint.GetTerraformComponentsFunc = func() []blueprintv1alpha1.TerraformComponent { return []blueprintv1alpha1.TerraformComponent{ @@ -94,11 +122,9 @@ func setupMocks(t *testing.T, opts ...*SetupOptions) *Mocks { } } - // Register dependencies injector.Register("shell", mockShell) injector.Register("blueprintHandler", mockBlueprint) - // Create config handler var configHandler config.ConfigHandler if options.ConfigHandler == nil { configHandler = config.NewConfigHandler(injector) @@ -106,7 +132,6 @@ func setupMocks(t *testing.T, opts ...*SetupOptions) *Mocks { configHandler = options.ConfigHandler } - // Initialize config handler if err := configHandler.Initialize(); err != nil { t.Fatalf("Failed to initialize config handler: %v", err) } @@ -114,7 +139,6 @@ func setupMocks(t *testing.T, opts ...*SetupOptions) *Mocks { t.Fatalf("Failed to set context: %v", err) } - // Load default config string defaultConfigStr := ` contexts: mock-context: @@ -130,10 +154,8 @@ contexts: } } - // Register config handler injector.Register("configHandler", configHandler) - // Mock system calls shims := &Shims{} shims.Stat = func(path string) (os.FileInfo, error) { @@ -155,7 +177,6 @@ contexts: return nil } - // Register cleanup to restore original state t.Cleanup(func() { os.Unsetenv("WINDSOR_PROJECT_ROOT") if err := os.Chdir(origDir); err != nil { @@ -172,6 +193,38 @@ contexts: } } +// setupWindsorStackMocks creates mock components for testing the WindsorStack +func setupWindsorStackMocks(t *testing.T, opts ...*SetupOptions) *Mocks { + t.Helper() + mocks := setupMocks(t, opts...) + + projectRoot := os.Getenv("WINDSOR_PROJECT_ROOT") + tfModulesDir := filepath.Join(projectRoot, ".windsor", ".tf_modules", "remote", "path") + if err := os.MkdirAll(tfModulesDir, 0755); err != nil { + t.Fatalf("Failed to create tf modules directory: %v", err) + } + + localDir := filepath.Join(projectRoot, "terraform", "local", "path") + if err := os.MkdirAll(localDir, 0755); err != nil { + t.Fatalf("Failed to create local directory: %v", err) + } + + terraformEnv := envvars.NewTerraformEnvPrinter(mocks.Injector) + if err := terraformEnv.Initialize(); err != nil { + t.Fatalf("Failed to initialize terraform env printer: %v", err) + } + mocks.Injector.Register("terraformEnv", terraformEnv) + + mocks.Shims.Stat = func(path string) (os.FileInfo, error) { + if path == tfModulesDir || path == localDir { + return os.Stat(path) + } + return nil, nil + } + + return mocks +} + // ============================================================================= // Test Public Methods // ============================================================================= @@ -188,7 +241,6 @@ func TestStack_NewStack(t *testing.T) { t.Run("Success", func(t *testing.T) { stack, _ := setup(t) - // Then the stack should be non-nil if stack == nil { t.Errorf("Expected stack to be non-nil") } @@ -207,25 +259,19 @@ func TestStack_Initialize(t *testing.T) { t.Run("Success", func(t *testing.T) { stack, _ := setup(t) - // When a new BaseStack is initialized if err := stack.Initialize(); err != nil { - // Then no error should occur t.Errorf("Expected Initialize to return nil, got %v", err) } }) t.Run("ErrorResolvingShell", func(t *testing.T) { - // Given safe mock components mocks := setupMocks(t) - // And the shell is unregistered to simulate an error mocks.Injector.Register("shell", nil) - // When a new BaseStack is initialized stack := NewBaseStack(mocks.Injector) err := stack.Initialize() - // Then an error should occur if err == nil { t.Errorf("Expected Initialize to return an error") } else { @@ -237,16 +283,12 @@ func TestStack_Initialize(t *testing.T) { }) t.Run("ErrorResolvingBlueprintHandler", func(t *testing.T) { - // Given safe mock components mocks := setupMocks(t) - // And the blueprintHandler is unregistered to simulate an error mocks.Injector.Register("blueprintHandler", nil) - // When a new BaseStack is initialized stack := NewBaseStack(mocks.Injector) - // Then an error should occur if err := stack.Initialize(); err == nil { t.Errorf("Expected Initialize to return an error") } @@ -263,39 +305,32 @@ func TestStack_Up(t *testing.T) { } t.Run("Success", func(t *testing.T) { - // Given safe mock components stack, _ := setup(t) - // When a new BaseStack is created and initialized if err := stack.Initialize(); err != nil { t.Fatalf("Expected no error during initialization, got %v", err) } - // And when Up is called - if err := stack.Up(); err != nil { - // Then no error should occur + blueprint := createTestBlueprint() + if err := stack.Up(blueprint); err != nil { t.Errorf("Expected Up to return nil, got %v", err) } }) t.Run("UninitializedStack", func(t *testing.T) { - // Given a new BaseStack without initialization stack, _ := setup(t) - // When Up is called without initializing - if err := stack.Up(); err != nil { - // Then no error should occur since base implementation is empty + blueprint := createTestBlueprint() + if err := stack.Up(blueprint); err != nil { t.Errorf("Expected Up to return nil even without initialization, got %v", err) } }) t.Run("NilInjector", func(t *testing.T) { - // Given a BaseStack with nil injector stack := NewBaseStack(nil) - // When Up is called - if err := stack.Up(); err != nil { - // Then no error should occur since base implementation is empty + blueprint := createTestBlueprint() + if err := stack.Up(blueprint); err != nil { t.Errorf("Expected Up to return nil even with nil injector, got %v", err) } }) @@ -311,39 +346,32 @@ func TestStack_Down(t *testing.T) { } t.Run("Success", func(t *testing.T) { - // Given safe mock components stack, _ := setup(t) - // When a new BaseStack is created and initialized if err := stack.Initialize(); err != nil { t.Fatalf("Expected no error during initialization, got %v", err) } - // And when Down is called - if err := stack.Down(); err != nil { - // Then no error should occur + blueprint := createTestBlueprint() + if err := stack.Down(blueprint); err != nil { t.Errorf("Expected Down to return nil, got %v", err) } }) t.Run("UninitializedStack", func(t *testing.T) { - // Given a new BaseStack without initialization stack, _ := setup(t) - // When Down is called without initializing - if err := stack.Down(); err != nil { - // Then no error should occur since base implementation is empty + blueprint := createTestBlueprint() + if err := stack.Down(blueprint); err != nil { t.Errorf("Expected Down to return nil even without initialization, got %v", err) } }) t.Run("NilInjector", func(t *testing.T) { - // Given a BaseStack with nil injector stack := NewBaseStack(nil) - // When Down is called - if err := stack.Down(); err != nil { - // Then no error should occur since base implementation is empty + blueprint := createTestBlueprint() + if err := stack.Down(blueprint); err != nil { t.Errorf("Expected Down to return nil even with nil injector, got %v", err) } }) @@ -351,9 +379,391 @@ func TestStack_Down(t *testing.T) { func TestStack_Interface(t *testing.T) { t.Run("BaseStackImplementsStack", func(t *testing.T) { - // Given a type assertion for Stack interface var _ Stack = (*BaseStack)(nil) + }) +} + +// ============================================================================= +// Test Public Methods +// ============================================================================= + +func TestWindsorStack_NewWindsorStack(t *testing.T) { + setup := func(t *testing.T) (*WindsorStack, *Mocks) { + t.Helper() + mocks := setupWindsorStackMocks(t) + stack := NewWindsorStack(mocks.Injector) + return stack, mocks + } + + t.Run("Success", func(t *testing.T) { + stack, _ := setup(t) + + if stack == nil { + t.Errorf("Expected stack to be non-nil") + } + }) +} + +func TestWindsorStack_Initialize(t *testing.T) { + setup := func(t *testing.T) (*WindsorStack, *Mocks) { + t.Helper() + mocks := setupWindsorStackMocks(t) + stack := NewWindsorStack(mocks.Injector) + return stack, mocks + } + + t.Run("Success", func(t *testing.T) { + stack, _ := setup(t) + + if err := stack.Initialize(); err != nil { + t.Errorf("Expected Initialize to return nil, got %v", err) + } + + if stack.terraformEnv == nil { + t.Errorf("Expected terraformEnv to be resolved") + } + }) + + t.Run("ErrorTerraformEnvNotFound", func(t *testing.T) { + stack, mocks := setup(t) + + mocks.Injector.Register("terraformEnv", nil) + + err := stack.Initialize() + + if err == nil { + t.Errorf("Expected Initialize to return an error") + } else { + expectedError := "terraformEnv not found in dependency injector" + if !strings.Contains(err.Error(), expectedError) { + t.Errorf("Expected error to contain %q, got %q", expectedError, err.Error()) + } + } + }) + + t.Run("ErrorResolvingTerraformEnv", func(t *testing.T) { + stack, mocks := setup(t) + + mocks.Injector.Register("terraformEnv", "not-a-terraform-env") + + err := stack.Initialize() + + if err == nil { + t.Errorf("Expected Initialize to return an error") + } else { + expectedError := "error resolving terraformEnv" + if !strings.Contains(err.Error(), expectedError) { + t.Errorf("Expected error to contain %q, got %q", expectedError, err.Error()) + } + } + }) + + t.Run("ErrorResolvingShell", func(t *testing.T) { + stack, mocks := setup(t) + + mocks.Injector.Register("shell", nil) + + err := stack.Initialize() + + if err == nil { + t.Errorf("Expected Initialize to return an error") + } else { + expectedError := "error resolving shell" + if !strings.Contains(err.Error(), expectedError) { + t.Errorf("Expected error to contain %q, got %q", expectedError, err.Error()) + } + } + }) + + t.Run("ErrorResolvingBlueprintHandler", func(t *testing.T) { + stack, mocks := setup(t) + + mocks.Injector.Register("blueprintHandler", nil) + + if err := stack.Initialize(); err == nil { + t.Errorf("Expected Initialize to return an error") + } + }) +} + +func TestWindsorStack_Up(t *testing.T) { + setup := func(t *testing.T) (*WindsorStack, *Mocks) { + t.Helper() + mocks := setupWindsorStackMocks(t) + stack := NewWindsorStack(mocks.Injector) + stack.shims = mocks.Shims + if err := stack.Initialize(); err != nil { + t.Fatalf("Expected no error during initialization, got %v", err) + } + return stack, mocks + } + + t.Run("Success", func(t *testing.T) { + stack, _ := setup(t) + blueprint := createTestBlueprint() + + if err := stack.Up(blueprint); err != nil { + t.Errorf("Expected Up to return nil, got %v", err) + } + }) + + t.Run("ErrorGettingCurrentDirectory", func(t *testing.T) { + stack, mocks := setup(t) + mocks.Shims.Getwd = func() (string, error) { + return "", fmt.Errorf("mock error getting current directory") + } + + blueprint := createTestBlueprint() + err := stack.Up(blueprint) + expectedError := "error getting current directory" + if !strings.Contains(err.Error(), expectedError) { + t.Fatalf("Expected error to contain %q, got %q", expectedError, err.Error()) + } + }) + + t.Run("ErrorCheckingDirectoryExists", func(t *testing.T) { + stack, mocks := setup(t) + mocks.Shims.Stat = func(path string) (os.FileInfo, error) { + return nil, os.ErrNotExist + } - // Then the code should compile, indicating BaseStack implements Stack + blueprint := createTestBlueprint() + err := stack.Up(blueprint) + if err == nil { + t.Fatalf("Expected an error, but got nil") + } + + expectedError := "directory" + if !strings.Contains(err.Error(), expectedError) { + t.Fatalf("Expected error to contain %q, got %q", expectedError, err.Error()) + } + }) + + t.Run("ErrorGeneratingTerraformArgs", func(t *testing.T) { + stack, mocks := setup(t) + mocks.ConfigHandler.Set("terraform.backend.type", "unsupported") + + blueprint := createTestBlueprint() + err := stack.Up(blueprint) + expectedError := "error generating terraform args" + if !strings.Contains(err.Error(), expectedError) { + t.Fatalf("Expected error to contain %q, got %q", expectedError, err.Error()) + } + }) + + t.Run("ErrorRunningTerraformInit", func(t *testing.T) { + stack, mocks := setup(t) + mocks.Shell.ExecProgressFunc = func(message string, command string, args ...string) (string, error) { + if command == "terraform" && len(args) > 0 && strings.HasPrefix(args[0], "-chdir=") && len(args) > 1 && args[1] == "init" { + return "", fmt.Errorf("mock error running terraform init") + } + return "", nil + } + + blueprint := createTestBlueprint() + err := stack.Up(blueprint) + expectedError := "error running terraform init for" + if !strings.Contains(err.Error(), expectedError) { + t.Fatalf("Expected error to contain %q, got %q", expectedError, err.Error()) + } + }) + + t.Run("ErrorRunningTerraformPlan", func(t *testing.T) { + stack, mocks := setup(t) + mocks.Shell.ExecProgressFunc = func(message string, command string, args ...string) (string, error) { + if command == "terraform" && len(args) > 0 && strings.HasPrefix(args[0], "-chdir=") && len(args) > 1 && args[1] == "plan" { + return "", fmt.Errorf("mock error running terraform plan") + } + return "", nil + } + + blueprint := createTestBlueprint() + err := stack.Up(blueprint) + expectedError := "error running terraform plan for" + if !strings.Contains(err.Error(), expectedError) { + t.Fatalf("Expected error to contain %q, got %q", expectedError, err.Error()) + } + }) + + t.Run("ErrorRunningTerraformApply", func(t *testing.T) { + stack, mocks := setup(t) + mocks.Shell.ExecProgressFunc = func(message string, command string, args ...string) (string, error) { + if command == "terraform" && len(args) > 0 && strings.HasPrefix(args[0], "-chdir=") && len(args) > 1 && args[1] == "apply" { + return "", fmt.Errorf("mock error running terraform apply") + } + return "", nil + } + + blueprint := createTestBlueprint() + err := stack.Up(blueprint) + expectedError := "error running terraform apply for" + if !strings.Contains(err.Error(), expectedError) { + t.Fatalf("Expected error to contain %q, got %q", expectedError, err.Error()) + } + }) +} + +func TestWindsorStack_Down(t *testing.T) { + setup := func(t *testing.T) (*WindsorStack, *Mocks) { + t.Helper() + mocks := setupWindsorStackMocks(t) + stack := NewWindsorStack(mocks.Injector) + stack.shims = mocks.Shims + if err := stack.Initialize(); err != nil { + t.Fatalf("Expected no error during initialization, got %v", err) + } + + mocks.Blueprint.GetTerraformComponentsFunc = func() []blueprintv1alpha1.TerraformComponent { + return []blueprintv1alpha1.TerraformComponent{ + { + Source: "source1", + Path: "module/path1", + FullPath: filepath.Join(os.Getenv("WINDSOR_PROJECT_ROOT"), ".windsor", ".tf_modules", "remote", "path"), + }, + } + } + + return stack, mocks + } + + t.Run("Success", func(t *testing.T) { + stack, _ := setup(t) + blueprint := createTestBlueprint() + + if err := stack.Down(blueprint); err != nil { + t.Errorf("Expected Down to return nil, got %v", err) + } + }) + + t.Run("ErrorGettingCurrentDirectory", func(t *testing.T) { + stack, mocks := setup(t) + mocks.Shims.Getwd = func() (string, error) { + return "", fmt.Errorf("mock error getting current directory") + } + + blueprint := createTestBlueprint() + err := stack.Down(blueprint) + expectedError := "error getting current directory" + if !strings.Contains(err.Error(), expectedError) { + t.Fatalf("Expected error to contain %q, got %q", expectedError, err.Error()) + } + }) + + t.Run("ErrorCheckingDirectoryExists", func(t *testing.T) { + stack, mocks := setup(t) + mocks.Shims.Stat = func(path string) (os.FileInfo, error) { + return nil, os.ErrNotExist + } + + blueprint := createTestBlueprint() + err := stack.Down(blueprint) + if err != nil { + t.Fatalf("Expected no error when directory doesn't exist, got %v", err) + } + }) + + t.Run("ErrorGeneratingTerraformArgs", func(t *testing.T) { + stack, mocks := setup(t) + mocks.ConfigHandler.Set("terraform.backend.type", "unsupported") + + blueprint := createTestBlueprint() + err := stack.Down(blueprint) + expectedError := "error generating terraform args" + if !strings.Contains(err.Error(), expectedError) { + t.Fatalf("Expected error to contain %q, got %q", expectedError, err.Error()) + } + }) + + t.Run("ErrorRunningTerraformPlan", func(t *testing.T) { + stack, mocks := setup(t) + mocks.Shell.ExecProgressFunc = func(message string, command string, args ...string) (string, error) { + if command == "terraform" && len(args) > 0 && strings.HasPrefix(args[0], "-chdir=") && len(args) > 1 && args[1] == "plan" { + return "", fmt.Errorf("mock error running terraform plan") + } + return "", nil + } + + blueprint := createTestBlueprint() + err := stack.Down(blueprint) + expectedError := "error running terraform plan destroy for" + if !strings.Contains(err.Error(), expectedError) { + t.Fatalf("Expected error to contain %q, got %q", expectedError, err.Error()) + } + }) + + t.Run("SkipComponentsWithDestroyFalse", func(t *testing.T) { + stack, mocks := setup(t) + + projectRoot := os.Getenv("WINDSOR_PROJECT_ROOT") + destroyFalse := false + blueprint := createTestBlueprint() + blueprint.TerraformComponents = []blueprintv1alpha1.TerraformComponent{ + { + Source: "source1", + Path: "module/path1", + FullPath: filepath.Join(projectRoot, ".windsor", ".tf_modules", "remote", "path1"), + Destroy: &destroyFalse, + }, + { + Source: "source2", + Path: "module/path2", + FullPath: filepath.Join(projectRoot, ".windsor", ".tf_modules", "remote", "path2"), + }, + } + + if err := os.MkdirAll(blueprint.TerraformComponents[0].FullPath, 0755); err != nil { + t.Fatalf("Failed to create directory: %v", err) + } + if err := os.MkdirAll(blueprint.TerraformComponents[1].FullPath, 0755); err != nil { + t.Fatalf("Failed to create directory: %v", err) + } + + var terraformCommands []string + mocks.Shell.ExecProgressFunc = func(message string, command string, args ...string) (string, error) { + if command == "terraform" && len(args) > 1 { + terraformCommands = append(terraformCommands, fmt.Sprintf("%s %s", args[0], args[1])) + } + return "", nil + } + + if err := stack.Down(blueprint); err != nil { + t.Errorf("Expected Down to return nil, got %v", err) + } + + foundPath1Commands := false + foundPath2Commands := false + + for _, cmd := range terraformCommands { + if strings.Contains(cmd, "path1") { + foundPath1Commands = true + } + if strings.Contains(cmd, "path2") { + foundPath2Commands = true + } + } + + if foundPath1Commands { + t.Errorf("Expected no terraform commands for path1 (destroy: false), but found commands") + } + if !foundPath2Commands { + t.Errorf("Expected terraform commands for path2 (destroy: true), but found none") + } + }) + + t.Run("ErrorRunningTerraformDestroy", func(t *testing.T) { + stack, mocks := setup(t) + mocks.Shell.ExecProgressFunc = func(message string, command string, args ...string) (string, error) { + if command == "terraform" && len(args) > 0 && strings.HasPrefix(args[0], "-chdir=") && len(args) > 1 && args[1] == "destroy" { + return "", fmt.Errorf("mock error running terraform destroy") + } + return "", nil + } + + blueprint := createTestBlueprint() + err := stack.Down(blueprint) + expectedError := "error running terraform destroy for" + if !strings.Contains(err.Error(), expectedError) { + t.Fatalf("Expected error to contain %q, got %q", expectedError, err.Error()) + } }) } diff --git a/pkg/infrastructure/terraform/windsor_stack.go b/pkg/infrastructure/terraform/windsor_stack.go deleted file mode 100644 index 5fcea8672..000000000 --- a/pkg/infrastructure/terraform/windsor_stack.go +++ /dev/null @@ -1,228 +0,0 @@ -package terraform - -// The WindsorStack is a specialized implementation of the Stack interface for Terraform-based infrastructure. -// It provides a concrete implementation for managing Terraform components through the Windsor CLI, -// handling directory management, terraform environment configuration, and Terraform operations. -// The WindsorStack orchestrates Terraform initialization, planning, and application, -// while managing terraform arguments and backend configurations. - -import ( - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/windsorcli/cli/pkg/di" - "github.com/windsorcli/cli/pkg/environment/envvars" -) - -// ============================================================================= -// Types -// ============================================================================= - -// WindsorStack is a struct that implements the Stack interface. -type WindsorStack struct { - BaseStack - terraformEnv *envvars.TerraformEnvPrinter -} - -// ============================================================================= -// Constructor -// ============================================================================= - -// NewWindsorStack creates a new WindsorStack. -func NewWindsorStack(injector di.Injector) *WindsorStack { - return &WindsorStack{ - BaseStack: BaseStack{ - injector: injector, - shims: NewShims(), - }, - } -} - -// ============================================================================= -// Public Methods -// ============================================================================= - -// Initialize initializes the WindsorStack by calling the base Initialize and resolving terraform environment. -func (s *WindsorStack) Initialize() error { - // Call the base Initialize method - if err := s.BaseStack.Initialize(); err != nil { - return err - } - - // Resolve the terraform environment printer - required for WindsorStack - terraformEnvInterface := s.injector.Resolve("terraformEnv") - if terraformEnvInterface == nil { - return fmt.Errorf("terraformEnv not found in dependency injector") - } - - terraformEnv, ok := terraformEnvInterface.(*envvars.TerraformEnvPrinter) - if !ok { - return fmt.Errorf("error resolving terraformEnv") - } - s.terraformEnv = terraformEnv - - return nil -} - -// Up creates a new stack of components by initializing and applying Terraform configurations. -// It processes components in order, generating terraform arguments, running Terraform init, -// plan, and apply operations, and cleaning up backend override files. -// The method ensures proper directory management and terraform argument setup for each component. -func (s *WindsorStack) Up() error { - currentDir, err := s.shims.Getwd() - if err != nil { - return fmt.Errorf("error getting current directory: %v", err) - } - - defer func() { - _ = s.shims.Chdir(currentDir) - }() - - components := s.blueprintHandler.GetTerraformComponents() - - for _, component := range components { - if _, err := s.shims.Stat(component.FullPath); os.IsNotExist(err) { - return fmt.Errorf("directory %s does not exist", component.FullPath) - } - - terraformArgs, err := s.terraformEnv.GenerateTerraformArgs(component.Path, component.FullPath) - if err != nil { - return fmt.Errorf("error generating terraform args for %s: %w", component.Path, err) - } - - // Set terraform environment variables (TF_VAR_* and TF_DATA_DIR) - // First, unset any existing TF_CLI_ARGS_* environment variables to avoid conflicts - tfCliArgsVars := []string{"TF_CLI_ARGS_init", "TF_CLI_ARGS_plan", "TF_CLI_ARGS_apply", "TF_CLI_ARGS_destroy", "TF_CLI_ARGS_import"} - for _, envVar := range tfCliArgsVars { - if err := s.shims.Unsetenv(envVar); err != nil { - return fmt.Errorf("error unsetting %s: %w", envVar, err) - } - } - - for key, value := range terraformArgs.TerraformVars { - if key == "TF_DATA_DIR" || strings.HasPrefix(key, "TF_VAR_") { - if err := s.shims.Setenv(key, value); err != nil { - return fmt.Errorf("error setting %s: %w", key, err) - } - } - } - - // Create backend_override.tf file in the component directory - if err := s.terraformEnv.PostEnvHook(component.FullPath); err != nil { - return fmt.Errorf("error creating backend override file for %s: %w", component.Path, err) - } - - initArgs := []string{fmt.Sprintf("-chdir=%s", terraformArgs.ModulePath), "init"} - initArgs = append(initArgs, terraformArgs.InitArgs...) - _, err = s.shell.ExecProgress(fmt.Sprintf("๐ŸŒŽ Initializing Terraform in %s", component.Path), "terraform", initArgs...) - if err != nil { - return fmt.Errorf("error running terraform init for %s: %w", component.Path, err) - } - - // Run terraform refresh to sync state with actual infrastructure - // This is tolerant of failures for non-existent state - refreshArgs := []string{fmt.Sprintf("-chdir=%s", terraformArgs.ModulePath), "refresh"} - refreshArgs = append(refreshArgs, terraformArgs.RefreshArgs...) - _, _ = s.shell.ExecProgress(fmt.Sprintf("๐Ÿ”„ Refreshing Terraform state in %s", component.Path), "terraform", refreshArgs...) - - planArgs := []string{fmt.Sprintf("-chdir=%s", terraformArgs.ModulePath), "plan"} - planArgs = append(planArgs, terraformArgs.PlanArgs...) - _, err = s.shell.ExecProgress(fmt.Sprintf("๐ŸŒŽ Planning Terraform changes in %s", component.Path), "terraform", planArgs...) - if err != nil { - return fmt.Errorf("error running terraform plan for %s: %w", component.Path, err) - } - - applyArgs := []string{fmt.Sprintf("-chdir=%s", terraformArgs.ModulePath), "apply"} - applyArgs = append(applyArgs, terraformArgs.ApplyArgs...) - _, err = s.shell.ExecProgress(fmt.Sprintf("๐ŸŒŽ Applying Terraform changes in %s", component.Path), "terraform", applyArgs...) - if err != nil { - return fmt.Errorf("error running terraform apply for %s: %w", component.Path, err) - } - - backendOverridePath := filepath.Join(component.FullPath, "backend_override.tf") - if _, err := s.shims.Stat(backendOverridePath); err == nil { - if err := s.shims.Remove(backendOverridePath); err != nil { - return fmt.Errorf("error removing backend override file for %s: %w", component.Path, err) - } - } - } - - return nil -} - -// Down destroys all Terraform components in the stack by executing Terraform destroy operations in reverse dependency order. -// For each component, Down generates Terraform arguments, sets required environment variables, unsets conflicting TF_CLI_ARGS_* variables, -// creates backend override files, runs Terraform refresh, plan (with destroy flag), and destroy commands, and removes backend override files. -// Components with Destroy set to false are skipped. Directory state is restored after execution. Errors are returned on any operation failure. -func (s *WindsorStack) Down() error { - currentDir, err := s.shims.Getwd() - if err != nil { - return fmt.Errorf("error getting current directory: %v", err) - } - - defer func() { - _ = s.shims.Chdir(currentDir) - }() - - components := s.blueprintHandler.GetTerraformComponents() - - for i := len(components) - 1; i >= 0; i-- { - component := components[i] - - if component.Destroy != nil && !*component.Destroy { - continue - } - - if _, err := s.shims.Stat(component.FullPath); os.IsNotExist(err) { - continue - } - - terraformArgs, err := s.terraformEnv.GenerateTerraformArgs(component.Path, component.FullPath) - if err != nil { - return fmt.Errorf("error generating terraform args for %s: %w", component.Path, err) - } - - tfCliArgsVars := []string{"TF_CLI_ARGS_init", "TF_CLI_ARGS_plan", "TF_CLI_ARGS_apply", "TF_CLI_ARGS_destroy", "TF_CLI_ARGS_import"} - for _, envVar := range tfCliArgsVars { - if err := s.shims.Unsetenv(envVar); err != nil { - return fmt.Errorf("error unsetting %s: %w", envVar, err) - } - } - - for key, value := range terraformArgs.TerraformVars { - if key == "TF_DATA_DIR" || strings.HasPrefix(key, "TF_VAR_") { - if err := s.shims.Setenv(key, value); err != nil { - return fmt.Errorf("error setting %s: %w", key, err) - } - } - } - - if err := s.terraformEnv.PostEnvHook(component.FullPath); err != nil { - return fmt.Errorf("error creating backend override file for %s: %w", component.Path, err) - } - - refreshArgs := []string{fmt.Sprintf("-chdir=%s", terraformArgs.ModulePath), "refresh"} - refreshArgs = append(refreshArgs, terraformArgs.RefreshArgs...) - _, _ = s.shell.ExecProgress(fmt.Sprintf("๐Ÿ”„ Refreshing Terraform state in %s", component.Path), "terraform", refreshArgs...) - - planArgs := []string{fmt.Sprintf("-chdir=%s", terraformArgs.ModulePath), "plan"} - planArgs = append(planArgs, terraformArgs.PlanDestroyArgs...) - if _, err := s.shell.ExecProgress(fmt.Sprintf("๐Ÿ—‘๏ธ Planning terraform destroy for %s", component.Path), "terraform", planArgs...); err != nil { - return fmt.Errorf("error running terraform plan destroy for %s: %w", component.Path, err) - } - - destroyArgs := []string{fmt.Sprintf("-chdir=%s", terraformArgs.ModulePath), "destroy"} - destroyArgs = append(destroyArgs, terraformArgs.DestroyArgs...) - if _, err := s.shell.ExecProgress(fmt.Sprintf("๐Ÿ—‘๏ธ Destroying terraform for %s", component.Path), "terraform", destroyArgs...); err != nil { - return fmt.Errorf("error running terraform destroy for %s: %w", component.Path, err) - } - - if err := s.shims.Remove(filepath.Join(component.FullPath, "backend_override.tf")); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("error removing backend_override.tf from %s: %w", component.Path, err) - } - } - - return nil -} diff --git a/pkg/infrastructure/terraform/windsor_stack_test.go b/pkg/infrastructure/terraform/windsor_stack_test.go deleted file mode 100644 index c43158387..000000000 --- a/pkg/infrastructure/terraform/windsor_stack_test.go +++ /dev/null @@ -1,470 +0,0 @@ -package terraform - -// The WindsorStackTest provides comprehensive test coverage for the WindsorStack implementation. -// It provides validation of stack initialization, component management, and infrastructure operations, -// The WindsorStackTest ensures proper dependency injection and component lifecycle management, -// verifying error handling, mock interactions, and infrastructure state management. - -import ( - "fmt" - "os" - "path/filepath" - "strings" - "testing" - - blueprintv1alpha1 "github.com/windsorcli/cli/api/v1alpha1" - "github.com/windsorcli/cli/pkg/environment/envvars" -) - -// ============================================================================= -// Test Setup -// ============================================================================= - -// setupWindsorStackMocks creates mock components for testing the WindsorStack -func setupWindsorStackMocks(t *testing.T, opts ...*SetupOptions) *Mocks { - t.Helper() - mocks := setupMocks(t, opts...) - - // Create necessary directories for tests - projectRoot := os.Getenv("WINDSOR_PROJECT_ROOT") - tfModulesDir := filepath.Join(projectRoot, ".windsor", ".tf_modules", "remote", "path") - if err := os.MkdirAll(tfModulesDir, 0755); err != nil { - t.Fatalf("Failed to create tf modules directory: %v", err) - } - - localDir := filepath.Join(projectRoot, "terraform", "local", "path") - if err := os.MkdirAll(localDir, 0755); err != nil { - t.Fatalf("Failed to create local directory: %v", err) - } - - // Register and initialize terraform env printer by default - terraformEnv := envvars.NewTerraformEnvPrinter(mocks.Injector) - if err := terraformEnv.Initialize(); err != nil { - t.Fatalf("Failed to initialize terraform env printer: %v", err) - } - mocks.Injector.Register("terraformEnv", terraformEnv) - - // Update shims to handle Windsor-specific paths - mocks.Shims.Stat = func(path string) (os.FileInfo, error) { - // Return success for both directories - if path == tfModulesDir || path == localDir { - return os.Stat(path) - } - return nil, nil - } - - return mocks -} - -// ============================================================================= -// Test Public Methods -// ============================================================================= - -func TestWindsorStack_NewWindsorStack(t *testing.T) { - setup := func(t *testing.T) (*WindsorStack, *Mocks) { - t.Helper() - mocks := setupWindsorStackMocks(t) - stack := NewWindsorStack(mocks.Injector) - return stack, mocks - } - - t.Run("Success", func(t *testing.T) { - stack, _ := setup(t) - - // Then the stack should be non-nil - if stack == nil { - t.Errorf("Expected stack to be non-nil") - } - }) -} - -func TestWindsorStack_Initialize(t *testing.T) { - setup := func(t *testing.T) (*WindsorStack, *Mocks) { - t.Helper() - mocks := setupWindsorStackMocks(t) - stack := NewWindsorStack(mocks.Injector) - return stack, mocks - } - - t.Run("Success", func(t *testing.T) { - stack, _ := setup(t) - - // When a new WindsorStack is initialized - if err := stack.Initialize(); err != nil { - // Then no error should occur - t.Errorf("Expected Initialize to return nil, got %v", err) - } - - // And the terraform env should be resolved - if stack.terraformEnv == nil { - t.Errorf("Expected terraformEnv to be resolved") - } - }) - - t.Run("ErrorTerraformEnvNotFound", func(t *testing.T) { - stack, mocks := setup(t) - - // And the terraformEnv is unregistered - mocks.Injector.Register("terraformEnv", nil) - - // When a new WindsorStack is initialized - err := stack.Initialize() - - // Then an error should occur - if err == nil { - t.Errorf("Expected Initialize to return an error") - } else { - expectedError := "terraformEnv not found in dependency injector" - if !strings.Contains(err.Error(), expectedError) { - t.Errorf("Expected error to contain %q, got %q", expectedError, err.Error()) - } - } - }) - - t.Run("ErrorResolvingTerraformEnv", func(t *testing.T) { - stack, mocks := setup(t) - - // And a non-terraform env printer is registered with terraformEnv key - mocks.Injector.Register("terraformEnv", "not-a-terraform-env") - - // When a new WindsorStack is initialized - err := stack.Initialize() - - // Then an error should occur - if err == nil { - t.Errorf("Expected Initialize to return an error") - } else { - expectedError := "error resolving terraformEnv" - if !strings.Contains(err.Error(), expectedError) { - t.Errorf("Expected error to contain %q, got %q", expectedError, err.Error()) - } - } - }) - - t.Run("ErrorResolvingShell", func(t *testing.T) { - stack, mocks := setup(t) - - // And the shell is unregistered to simulate an error - mocks.Injector.Register("shell", nil) - - // When a new WindsorStack is initialized - err := stack.Initialize() - - // Then an error should occur - if err == nil { - t.Errorf("Expected Initialize to return an error") - } else { - expectedError := "error resolving shell" - if !strings.Contains(err.Error(), expectedError) { - t.Errorf("Expected error to contain %q, got %q", expectedError, err.Error()) - } - } - }) - - t.Run("ErrorResolvingBlueprintHandler", func(t *testing.T) { - stack, mocks := setup(t) - - // And the blueprintHandler is unregistered to simulate an error - mocks.Injector.Register("blueprintHandler", nil) - - // Then an error should occur - if err := stack.Initialize(); err == nil { - t.Errorf("Expected Initialize to return an error") - } - }) -} - -func TestWindsorStack_Up(t *testing.T) { - setup := func(t *testing.T) (*WindsorStack, *Mocks) { - t.Helper() - mocks := setupWindsorStackMocks(t) - stack := NewWindsorStack(mocks.Injector) - stack.shims = mocks.Shims - if err := stack.Initialize(); err != nil { - t.Fatalf("Expected no error during initialization, got %v", err) - } - return stack, mocks - } - - t.Run("Success", func(t *testing.T) { - stack, _ := setup(t) - - // And when the stack is brought up - if err := stack.Up(); err != nil { - // Then no error should occur - t.Errorf("Expected Up to return nil, got %v", err) - } - }) - - t.Run("ErrorGettingCurrentDirectory", func(t *testing.T) { - stack, mocks := setup(t) - mocks.Shims.Getwd = func() (string, error) { - return "", fmt.Errorf("mock error getting current directory") - } - - // And when Up is called - err := stack.Up() - // Then the expected error is contained in err - expectedError := "error getting current directory" - if !strings.Contains(err.Error(), expectedError) { - t.Fatalf("Expected error to contain %q, got %q", expectedError, err.Error()) - } - }) - - t.Run("ErrorCheckingDirectoryExists", func(t *testing.T) { - stack, mocks := setup(t) - mocks.Shims.Stat = func(path string) (os.FileInfo, error) { - return nil, os.ErrNotExist - } - - // And when Up is called - err := stack.Up() - if err == nil { - t.Fatalf("Expected an error, but got nil") - } - - // Then the expected error is contained in err - expectedError := "directory" - if !strings.Contains(err.Error(), expectedError) { - t.Fatalf("Expected error to contain %q, got %q", expectedError, err.Error()) - } - }) - - t.Run("ErrorGeneratingTerraformArgs", func(t *testing.T) { - stack, mocks := setup(t) - mocks.ConfigHandler.Set("terraform.backend.type", "unsupported") - - // And when Up is called - err := stack.Up() - // Then the expected error is contained in err - expectedError := "error generating terraform args" - if !strings.Contains(err.Error(), expectedError) { - t.Fatalf("Expected error to contain %q, got %q", expectedError, err.Error()) - } - }) - - t.Run("ErrorRunningTerraformInit", func(t *testing.T) { - stack, mocks := setup(t) - mocks.Shell.ExecProgressFunc = func(message string, command string, args ...string) (string, error) { - if command == "terraform" && len(args) > 0 && strings.HasPrefix(args[0], "-chdir=") && len(args) > 1 && args[1] == "init" { - return "", fmt.Errorf("mock error running terraform init") - } - return "", nil - } - - // And when Up is called - err := stack.Up() - // Then the expected error is contained in err - expectedError := "error running terraform init for" - if !strings.Contains(err.Error(), expectedError) { - t.Fatalf("Expected error to contain %q, got %q", expectedError, err.Error()) - } - }) - - t.Run("ErrorRunningTerraformPlan", func(t *testing.T) { - stack, mocks := setup(t) - mocks.Shell.ExecProgressFunc = func(message string, command string, args ...string) (string, error) { - if command == "terraform" && len(args) > 0 && strings.HasPrefix(args[0], "-chdir=") && len(args) > 1 && args[1] == "plan" { - return "", fmt.Errorf("mock error running terraform plan") - } - return "", nil - } - - // And when Up is called - err := stack.Up() - // Then the expected error is contained in err - expectedError := "error running terraform plan for" - if !strings.Contains(err.Error(), expectedError) { - t.Fatalf("Expected error to contain %q, got %q", expectedError, err.Error()) - } - }) - - t.Run("ErrorRunningTerraformApply", func(t *testing.T) { - stack, mocks := setup(t) - mocks.Shell.ExecProgressFunc = func(message string, command string, args ...string) (string, error) { - if command == "terraform" && len(args) > 0 && strings.HasPrefix(args[0], "-chdir=") && len(args) > 1 && args[1] == "apply" { - return "", fmt.Errorf("mock error running terraform apply") - } - return "", nil - } - - // And when Up is called - err := stack.Up() - // Then the expected error is contained in err - expectedError := "error running terraform apply for" - if !strings.Contains(err.Error(), expectedError) { - t.Fatalf("Expected error to contain %q, got %q", expectedError, err.Error()) - } - }) -} - -func TestWindsorStack_Down(t *testing.T) { - setup := func(t *testing.T) (*WindsorStack, *Mocks) { - t.Helper() - mocks := setupWindsorStackMocks(t) - stack := NewWindsorStack(mocks.Injector) - stack.shims = mocks.Shims - if err := stack.Initialize(); err != nil { - t.Fatalf("Expected no error during initialization, got %v", err) - } - - // Set up default components for the blueprint handler - mocks.Blueprint.GetTerraformComponentsFunc = func() []blueprintv1alpha1.TerraformComponent { - return []blueprintv1alpha1.TerraformComponent{ - { - Source: "source1", - Path: "module/path1", - FullPath: filepath.Join(os.Getenv("WINDSOR_PROJECT_ROOT"), ".windsor", ".tf_modules", "remote", "path"), - }, - } - } - - return stack, mocks - } - - t.Run("Success", func(t *testing.T) { - stack, _ := setup(t) - - // And when the stack is brought down - if err := stack.Down(); err != nil { - // Then no error should occur - t.Errorf("Expected Down to return nil, got %v", err) - } - }) - - t.Run("ErrorGettingCurrentDirectory", func(t *testing.T) { - stack, mocks := setup(t) - mocks.Shims.Getwd = func() (string, error) { - return "", fmt.Errorf("mock error getting current directory") - } - - // And when Down is called - err := stack.Down() - // Then the expected error is contained in err - expectedError := "error getting current directory" - if !strings.Contains(err.Error(), expectedError) { - t.Fatalf("Expected error to contain %q, got %q", expectedError, err.Error()) - } - }) - - t.Run("ErrorCheckingDirectoryExists", func(t *testing.T) { - stack, mocks := setup(t) - mocks.Shims.Stat = func(path string) (os.FileInfo, error) { - return nil, os.ErrNotExist - } - - // And when Down is called - err := stack.Down() - // Then no error should occur since Down continues when directory doesn't exist - if err != nil { - t.Fatalf("Expected no error when directory doesn't exist, got %v", err) - } - }) - - t.Run("ErrorGeneratingTerraformArgs", func(t *testing.T) { - stack, mocks := setup(t) - mocks.ConfigHandler.Set("terraform.backend.type", "unsupported") - - // And when Down is called - err := stack.Down() - // Then the expected error is contained in err - expectedError := "error generating terraform args" - if !strings.Contains(err.Error(), expectedError) { - t.Fatalf("Expected error to contain %q, got %q", expectedError, err.Error()) - } - }) - - t.Run("ErrorRunningTerraformPlan", func(t *testing.T) { - stack, mocks := setup(t) - mocks.Shell.ExecProgressFunc = func(message string, command string, args ...string) (string, error) { - if command == "terraform" && len(args) > 0 && strings.HasPrefix(args[0], "-chdir=") && len(args) > 1 && args[1] == "plan" { - return "", fmt.Errorf("mock error running terraform plan") - } - return "", nil - } - - // And when Down is called - err := stack.Down() - // Then the expected error is contained in err - expectedError := "error running terraform plan destroy for" - if !strings.Contains(err.Error(), expectedError) { - t.Fatalf("Expected error to contain %q, got %q", expectedError, err.Error()) - } - }) - - t.Run("SkipComponentsWithDestroyFalse", func(t *testing.T) { - stack, mocks := setup(t) - - // Set up components with one having destroy: false - destroyFalse := false - mocks.Blueprint.GetTerraformComponentsFunc = func() []blueprintv1alpha1.TerraformComponent { - return []blueprintv1alpha1.TerraformComponent{ - { - Source: "source1", - Path: "module/path1", - FullPath: filepath.Join(os.Getenv("WINDSOR_PROJECT_ROOT"), ".windsor", ".tf_modules", "remote", "path1"), - Destroy: &destroyFalse, // This component should be skipped - }, - { - Source: "source2", - Path: "module/path2", - FullPath: filepath.Join(os.Getenv("WINDSOR_PROJECT_ROOT"), ".windsor", ".tf_modules", "remote", "path2"), - // Destroy defaults to true, so this should be destroyed - }, - } - } - - // Track terraform commands executed - var terraformCommands []string - mocks.Shell.ExecProgressFunc = func(message string, command string, args ...string) (string, error) { - if command == "terraform" && len(args) > 1 { - terraformCommands = append(terraformCommands, fmt.Sprintf("%s %s", args[0], args[1])) - } - return "", nil - } - - // When Down is called - if err := stack.Down(); err != nil { - t.Errorf("Expected Down to return nil, got %v", err) - } - - // Then only the component without destroy: false should be destroyed - // We should see terraform commands for path2 but not path1 - foundPath1Commands := false - foundPath2Commands := false - - for _, cmd := range terraformCommands { - if strings.Contains(cmd, "path1") { - foundPath1Commands = true - } - if strings.Contains(cmd, "path2") { - foundPath2Commands = true - } - } - - if foundPath1Commands { - t.Errorf("Expected no terraform commands for path1 (destroy: false), but found commands") - } - if !foundPath2Commands { - t.Errorf("Expected terraform commands for path2 (destroy: true), but found none") - } - }) - - t.Run("ErrorRunningTerraformDestroy", func(t *testing.T) { - stack, mocks := setup(t) - mocks.Shell.ExecProgressFunc = func(message string, command string, args ...string) (string, error) { - if command == "terraform" && len(args) > 0 && strings.HasPrefix(args[0], "-chdir=") && len(args) > 1 && args[1] == "destroy" { - return "", fmt.Errorf("mock error running terraform destroy") - } - return "", nil - } - - // And when Down is called - err := stack.Down() - // Then the expected error is contained in err - expectedError := "error running terraform destroy for" - if !strings.Contains(err.Error(), expectedError) { - t.Fatalf("Expected error to contain %q, got %q", expectedError, err.Error()) - } - }) -} diff --git a/pkg/pipelines/down.go b/pkg/pipelines/down.go index b967beb87..9166e12d6 100644 --- a/pkg/pipelines/down.go +++ b/pkg/pipelines/down.go @@ -154,7 +154,18 @@ func (p *DownPipeline) Execute(ctx context.Context) error { if p.stack == nil { return fmt.Errorf("No stack found") } - if err := p.stack.Down(); err != nil { + if p.blueprintHandler == nil { + return fmt.Errorf("No blueprint handler found") + } + // Load blueprint config if not already loaded (e.g., if skipK8s was true) + if err := p.blueprintHandler.LoadConfig(); err != nil { + return fmt.Errorf("Error loading blueprint config: %w", err) + } + if err := p.blueprintHandler.LoadBlueprint(); err != nil { + return fmt.Errorf("Error loading blueprint: %w", err) + } + blueprint := p.blueprintHandler.Generate() + if err := p.stack.Down(blueprint); err != nil { return fmt.Errorf("Error running stack Down command: %w", err) } } else { diff --git a/pkg/pipelines/down_test.go b/pkg/pipelines/down_test.go index d95fb238c..2968aa620 100644 --- a/pkg/pipelines/down_test.go +++ b/pkg/pipelines/down_test.go @@ -7,6 +7,7 @@ import ( "strings" "testing" + blueprintv1alpha1 "github.com/windsorcli/cli/api/v1alpha1" "github.com/windsorcli/cli/pkg/config" "github.com/windsorcli/cli/pkg/environment/envvars" "github.com/windsorcli/cli/pkg/infrastructure/kubernetes" @@ -89,7 +90,7 @@ contexts: // Setup stack mock mockStack := terraforminfra.NewMockStack(baseMocks.Injector) mockStack.InitializeFunc = func() error { return nil } - mockStack.DownFunc = func() error { return nil } + mockStack.DownFunc = func(blueprint *blueprintv1alpha1.Blueprint) error { return nil } baseMocks.Injector.Register("stack", mockStack) // Setup blueprint handler mock @@ -491,7 +492,7 @@ func TestDownPipeline_Execute(t *testing.T) { blueprintDownCalled = true return nil } - mocks.Stack.DownFunc = func() error { + mocks.Stack.DownFunc = func(blueprint *blueprintv1alpha1.Blueprint) error { stackDownCalled = true return nil } @@ -541,7 +542,7 @@ func TestDownPipeline_Execute(t *testing.T) { blueprintDownCalled = true return nil } - mocks.Stack.DownFunc = func() error { + mocks.Stack.DownFunc = func(blueprint *blueprintv1alpha1.Blueprint) error { stackDownCalled = true return nil } @@ -583,7 +584,7 @@ func TestDownPipeline_Execute(t *testing.T) { blueprintDownCalled = true return nil } - mocks.Stack.DownFunc = func() error { + mocks.Stack.DownFunc = func(blueprint *blueprintv1alpha1.Blueprint) error { stackDownCalled = true return nil } @@ -716,7 +717,7 @@ func TestDownPipeline_Execute(t *testing.T) { // Given a down pipeline with failing stack pipeline := NewDownPipeline() mocks := setupDownMocks(t) - mocks.Stack.DownFunc = func() error { + mocks.Stack.DownFunc = func(blueprint *blueprintv1alpha1.Blueprint) error { return fmt.Errorf("stack down failed") } err := pipeline.Initialize(mocks.Injector, context.Background()) @@ -918,7 +919,7 @@ func TestDownPipeline_Execute(t *testing.T) { blueprintDownCalled = true return nil } - mocks.Stack.DownFunc = func() error { + mocks.Stack.DownFunc = func(blueprint *blueprintv1alpha1.Blueprint) error { stackDownCalled = true return nil } diff --git a/pkg/pipelines/up.go b/pkg/pipelines/up.go index db5b5c7a9..7571c20e3 100644 --- a/pkg/pipelines/up.go +++ b/pkg/pipelines/up.go @@ -199,7 +199,21 @@ func (p *UpPipeline) Execute(ctx context.Context) error { if p.stack == nil { return fmt.Errorf("No stack found") } - if err := p.stack.Up(); err != nil { + blueprintHandler := p.withBlueprintHandler() + if blueprintHandler == nil { + return fmt.Errorf("No blueprint handler found") + } + if err := blueprintHandler.Initialize(); err != nil { + return fmt.Errorf("failed to initialize blueprint handler: %w", err) + } + if err := blueprintHandler.LoadConfig(); err != nil { + return fmt.Errorf("failed to load blueprint config: %w", err) + } + if err := blueprintHandler.LoadBlueprint(); err != nil { + return fmt.Errorf("failed to load blueprint: %w", err) + } + blueprint := blueprintHandler.Generate() + if err := p.stack.Up(blueprint); err != nil { return fmt.Errorf("Error running stack Up command: %w", err) } diff --git a/pkg/pipelines/up_test.go b/pkg/pipelines/up_test.go index 6a76bba28..2537ab48d 100644 --- a/pkg/pipelines/up_test.go +++ b/pkg/pipelines/up_test.go @@ -5,6 +5,7 @@ import ( "fmt" "testing" + blueprintv1alpha1 "github.com/windsorcli/cli/api/v1alpha1" "github.com/windsorcli/cli/pkg/config" "github.com/windsorcli/cli/pkg/environment/envvars" "github.com/windsorcli/cli/pkg/environment/tools" @@ -95,7 +96,7 @@ contexts: // Setup stack mock mockStack := terraforminfra.NewMockStack(baseMocks.Injector) mockStack.InitializeFunc = func() error { return nil } - mockStack.UpFunc = func() error { return nil } + mockStack.UpFunc = func(blueprint *blueprintv1alpha1.Blueprint) error { return nil } baseMocks.Injector.Register("stack", mockStack) // Setup terraform env mock @@ -648,7 +649,7 @@ func TestUpPipeline_Execute(t *testing.T) { name: "ReturnsErrorWhenStackUpFails", setupMock: func(mocks *UpMocks) { mocks.Shims.Setenv = func(key, value string) error { return nil } - mocks.Stack.UpFunc = func() error { + mocks.Stack.UpFunc = func(blueprint *blueprintv1alpha1.Blueprint) error { return fmt.Errorf("stack up failed") } }, diff --git a/pkg/resources/resources.go b/pkg/resources/resources.go index 7b8356d1a..701a0c299 100644 --- a/pkg/resources/resources.go +++ b/pkg/resources/resources.go @@ -123,8 +123,6 @@ func (r *Resources) Generate(overwrite ...bool) error { return fmt.Errorf("failed to load blueprint data: %w", err) } - r.Blueprint = r.BlueprintHandler.Generate() - if err := r.TerraformResolver.Initialize(); err != nil { return fmt.Errorf("failed to initialize terraform resolver: %w", err) } diff --git a/pkg/types/context.go b/pkg/types/context.go index 6bd3ab993..eafc841c3 100644 --- a/pkg/types/context.go +++ b/pkg/types/context.go @@ -1,7 +1,6 @@ package types import ( - "github.com/windsorcli/cli/api/v1alpha1" "github.com/windsorcli/cli/pkg/config" "github.com/windsorcli/cli/pkg/di" "github.com/windsorcli/cli/pkg/secrets" @@ -31,9 +30,6 @@ type ExecutionContext struct { ConfigHandler config.ConfigHandler Shell shell.Shell - // Blueprint contains the generated blueprint data from the resources package - Blueprint *v1alpha1.Blueprint - // SecretsProviders contains providers for Sops and 1Password secrets management SecretsProviders struct { Sops secrets.SecretsProvider