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/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/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/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 81% rename from pkg/kubernetes/kubernetes_manager.go rename to pkg/infrastructure/kubernetes/kubernetes_manager.go index ba92d7de7..5d523dba9 100644 --- a/pkg/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/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 94% rename from pkg/kubernetes/mock_kubernetes_manager.go rename to pkg/infrastructure/kubernetes/mock_kubernetes_manager.go index 45ce2b494..f410265bc 100644 --- a/pkg/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/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 76% rename from pkg/stack/mock_stack.go rename to pkg/infrastructure/terraform/mock_stack.go index 8e725c2b5..4270845eb 100644 --- a/pkg/stack/mock_stack.go +++ b/pkg/infrastructure/terraform/mock_stack.go @@ -1,6 +1,9 @@ -package stack +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/stack/mock_stack_test.go b/pkg/infrastructure/terraform/mock_stack_test.go similarity index 83% rename from pkg/stack/mock_stack_test.go rename to pkg/infrastructure/terraform/mock_stack_test.go index 9de778c56..8e84de28e 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, @@ -8,6 +8,8 @@ package stack 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/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/windsor_stack.go b/pkg/infrastructure/terraform/stack.go similarity index 53% rename from pkg/stack/windsor_stack.go rename to pkg/infrastructure/terraform/stack.go index 2b2c7c295..eaf23555f 100644 --- a/pkg/stack/windsor_stack.go +++ b/pkg/infrastructure/terraform/stack.go @@ -1,25 +1,50 @@ -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, -// handling directory management, terraform environment configuration, and Terraform operations. -// The WindsorStack orchestrates Terraform initialization, planning, and application, -// while managing terraform arguments and backend configurations. +// 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. 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" ) +// ============================================================================= +// Interfaces +// ============================================================================= + +// Stack is an interface that represents a stack of components. +type Stack interface { + Initialize() error + Up(blueprint *blueprintv1alpha1.Blueprint) error + Down(blueprint *blueprintv1alpha1.Blueprint) error +} + // ============================================================================= // Types // ============================================================================= +// BaseStack is a struct that implements the Stack interface. +type BaseStack struct { + injector di.Injector + blueprintHandler blueprint.BlueprintHandler + shell shell.Shell + shims *Shims +} + // WindsorStack is a struct that implements the Stack interface. type WindsorStack struct { BaseStack @@ -27,9 +52,17 @@ type WindsorStack struct { } // ============================================================================= -// Constructor +// Constructors // ============================================================================= +// NewBaseStack creates a new base stack of components. +func NewBaseStack(injector di.Injector) *BaseStack { + return &BaseStack{ + injector: injector, + shims: NewShims(), + } +} + // NewWindsorStack creates a new WindsorStack. func NewWindsorStack(injector di.Injector) *WindsorStack { return &WindsorStack{ @@ -44,14 +77,39 @@ func NewWindsorStack(injector di.Injector) *WindsorStack { // Public Methods // ============================================================================= +// Initialize initializes the stack of components. +func (s *BaseStack) Initialize() error { + shell, ok := s.injector.Resolve("shell").(shell.Shell) + if !ok { + return fmt.Errorf("error resolving shell") + } + s.shell = shell + + blueprintHandler, ok := s.injector.Resolve("blueprintHandler").(blueprint.BlueprintHandler) + if !ok { + return fmt.Errorf("error resolving blueprintHandler") + } + s.blueprintHandler = blueprintHandler + + return nil +} + +// Up creates a new stack of components. +func (s *BaseStack) Up(blueprint *blueprintv1alpha1.Blueprint) error { + return nil +} + +// Down destroys a stack of components. +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 { - // 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") @@ -70,7 +128,12 @@ func (s *WindsorStack) Initialize() error { // 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 { +// 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) @@ -80,7 +143,11 @@ func (s *WindsorStack) Up() error { _ = s.shims.Chdir(currentDir) }() - components := s.blueprintHandler.GetTerraformComponents() + 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) { @@ -92,8 +159,6 @@ func (s *WindsorStack) Up() error { 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 { @@ -109,7 +174,6 @@ func (s *WindsorStack) Up() error { } } - // 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) } @@ -121,8 +185,6 @@ func (s *WindsorStack) Up() error { 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...) @@ -156,7 +218,12 @@ func (s *WindsorStack) Up() error { // 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 { +// 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) @@ -166,7 +233,11 @@ func (s *WindsorStack) Down() error { _ = s.shims.Chdir(currentDir) }() - components := s.blueprintHandler.GetTerraformComponents() + 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] @@ -226,3 +297,122 @@ func (s *WindsorStack) Down() error { 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/stack/windsor_stack_test.go b/pkg/infrastructure/terraform/stack_test.go similarity index 53% rename from pkg/stack/windsor_stack_test.go rename to pkg/infrastructure/terraform/stack_test.go index 56ac2fc74..acd8b9a05 100644 --- a/pkg/stack/windsor_stack_test.go +++ b/pkg/infrastructure/terraform/stack_test.go @@ -1,8 +1,8 @@ -package stack +package terraform -// The WindsorStackTest provides comprehensive test coverage for the WindsorStack implementation. +// The StackTest provides comprehensive test coverage for the Stack interface implementation. // It provides validation of stack initialization, component management, and infrastructure operations, -// The WindsorStackTest ensures proper dependency injection and component lifecycle management, +// The StackTest ensures proper dependency injection and component lifecycle management, // verifying error handling, mock interactions, and infrastructure state management. import ( @@ -13,19 +13,191 @@ import ( "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/environment/envvars" + "github.com/windsorcli/cli/pkg/resources/blueprint" + "github.com/windsorcli/cli/pkg/shell" ) // ============================================================================= // 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 + Shell *shell.MockShell + Blueprint *blueprint.MockBlueprintHandler + Shims *Shims +} + +type SetupOptions struct { + Injector di.Injector + ConfigHandler config.ConfigHandler + ConfigStr string +} + +// setupMocks creates mock components for testing the stack +func setupMocks(t *testing.T, opts ...*SetupOptions) *Mocks { + t.Helper() + + origDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get working directory: %v", err) + } + + tmpDir := t.TempDir() + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Failed to change to temp directory: %v", err) + } + + os.Setenv("WINDSOR_PROJECT_ROOT", tmpDir) + + options := &SetupOptions{} + if len(opts) > 0 && opts[0] != nil { + options = opts[0] + } + + var injector di.Injector + if options.Injector == nil { + injector = di.NewMockInjector() + } else { + injector = options.Injector + } + + mockShell := shell.NewMockShell() + + mockBlueprint := blueprint.NewMockBlueprintHandler(injector) + mockBlueprint.GetTerraformComponentsFunc = func() []blueprintv1alpha1.TerraformComponent { + return []blueprintv1alpha1.TerraformComponent{ + { + Source: "git::https://github.com/terraform-aws-modules/terraform-aws-vpc.git//terraform/remote/path@v1.0.0", + Path: "remote/path", + FullPath: filepath.Join(tmpDir, ".windsor", ".tf_modules", "remote", "path"), + Inputs: map[string]any{ + "remote_variable1": "default_value", + }, + }, + { + Source: "", + Path: "local/path", + FullPath: filepath.Join(tmpDir, "terraform", "local", "path"), + Inputs: map[string]any{ + "local_variable1": "default_value", + }, + }, + } + } + + injector.Register("shell", mockShell) + injector.Register("blueprintHandler", mockBlueprint) + + var configHandler config.ConfigHandler + if options.ConfigHandler == nil { + configHandler = config.NewConfigHandler(injector) + } else { + configHandler = options.ConfigHandler + } + + if err := configHandler.Initialize(); err != nil { + t.Fatalf("Failed to initialize config handler: %v", err) + } + if err := configHandler.SetContext("mock-context"); err != nil { + t.Fatalf("Failed to set context: %v", err) + } + + defaultConfigStr := ` +contexts: + mock-context: + dns: + domain: mock.domain.com` + + if err := configHandler.LoadConfigString(defaultConfigStr); err != nil { + t.Fatalf("Failed to load default config string: %v", err) + } + if options.ConfigStr != "" { + if err := configHandler.LoadConfigString(options.ConfigStr); err != nil { + t.Fatalf("Failed to load config string: %v", err) + } + } + + injector.Register("configHandler", configHandler) + + shims := &Shims{} + + shims.Stat = func(path string) (os.FileInfo, error) { + return nil, nil + } + shims.Chdir = func(_ string) error { + return nil + } + shims.Getwd = func() (string, error) { + return tmpDir, nil + } + shims.Setenv = func(key, value string) error { + return os.Setenv(key, value) + } + shims.Unsetenv = func(key string) error { + return os.Unsetenv(key) + } + shims.Remove = func(_ string) error { + return nil + } + + t.Cleanup(func() { + os.Unsetenv("WINDSOR_PROJECT_ROOT") + if err := os.Chdir(origDir); err != nil { + t.Logf("Warning: Failed to change back to original directory: %v", err) + } + }) + + return &Mocks{ + Injector: injector, + ConfigHandler: configHandler, + Shell: mockShell, + Blueprint: mockBlueprint, + Shims: shims, + } +} + // 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 { @@ -37,16 +209,13 @@ func setupWindsorStackMocks(t *testing.T, opts ...*SetupOptions) *Mocks { 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) } @@ -60,6 +229,164 @@ func setupWindsorStackMocks(t *testing.T, opts ...*SetupOptions) *Mocks { // Test Public Methods // ============================================================================= +func TestStack_NewStack(t *testing.T) { + setup := func(t *testing.T) (*BaseStack, *Mocks) { + t.Helper() + mocks := setupMocks(t) + stack := NewBaseStack(mocks.Injector) + stack.shims = mocks.Shims + 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 TestStack_Initialize(t *testing.T) { + setup := func(t *testing.T) (*BaseStack, *Mocks) { + t.Helper() + mocks := setupMocks(t) + stack := NewBaseStack(mocks.Injector) + stack.shims = mocks.Shims + 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) + } + }) + + t.Run("ErrorResolvingShell", func(t *testing.T) { + mocks := setupMocks(t) + + mocks.Injector.Register("shell", nil) + + stack := NewBaseStack(mocks.Injector) + 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) { + mocks := setupMocks(t) + + mocks.Injector.Register("blueprintHandler", nil) + + stack := NewBaseStack(mocks.Injector) + + if err := stack.Initialize(); err == nil { + t.Errorf("Expected Initialize to return an error") + } + }) +} + +func TestStack_Up(t *testing.T) { + setup := func(t *testing.T) (*BaseStack, *Mocks) { + t.Helper() + mocks := setupMocks(t) + stack := NewBaseStack(mocks.Injector) + stack.shims = mocks.Shims + return stack, mocks + } + + t.Run("Success", func(t *testing.T) { + stack, _ := setup(t) + + if err := stack.Initialize(); err != nil { + t.Fatalf("Expected no error during initialization, got %v", err) + } + + 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) { + stack, _ := setup(t) + + 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) { + stack := NewBaseStack(nil) + + blueprint := createTestBlueprint() + if err := stack.Up(blueprint); err != nil { + t.Errorf("Expected Up to return nil even with nil injector, got %v", err) + } + }) +} + +func TestStack_Down(t *testing.T) { + setup := func(t *testing.T) (*BaseStack, *Mocks) { + t.Helper() + mocks := setupMocks(t) + stack := NewBaseStack(mocks.Injector) + stack.shims = mocks.Shims + return stack, mocks + } + + t.Run("Success", func(t *testing.T) { + stack, _ := setup(t) + + if err := stack.Initialize(); err != nil { + t.Fatalf("Expected no error during initialization, got %v", err) + } + + 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) { + stack, _ := setup(t) + + 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) { + stack := NewBaseStack(nil) + + blueprint := createTestBlueprint() + if err := stack.Down(blueprint); err != nil { + t.Errorf("Expected Down to return nil even with nil injector, got %v", err) + } + }) +} + +func TestStack_Interface(t *testing.T) { + t.Run("BaseStackImplementsStack", func(t *testing.T) { + var _ Stack = (*BaseStack)(nil) + }) +} + +// ============================================================================= +// Test Public Methods +// ============================================================================= + func TestWindsorStack_NewWindsorStack(t *testing.T) { setup := func(t *testing.T) (*WindsorStack, *Mocks) { t.Helper() @@ -71,7 +398,6 @@ func TestWindsorStack_NewWindsorStack(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") } @@ -89,13 +415,10 @@ func TestWindsorStack_Initialize(t *testing.T) { 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") } @@ -104,13 +427,10 @@ func TestWindsorStack_Initialize(t *testing.T) { 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 { @@ -124,13 +444,10 @@ func TestWindsorStack_Initialize(t *testing.T) { 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 { @@ -144,13 +461,10 @@ func TestWindsorStack_Initialize(t *testing.T) { 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 { @@ -164,10 +478,8 @@ func TestWindsorStack_Initialize(t *testing.T) { 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") } @@ -188,10 +500,9 @@ func TestWindsorStack_Up(t *testing.T) { t.Run("Success", func(t *testing.T) { stack, _ := setup(t) + blueprint := createTestBlueprint() - // And when the stack is brought up - if err := stack.Up(); err != nil { - // Then no error should occur + if err := stack.Up(blueprint); err != nil { t.Errorf("Expected Up to return nil, got %v", err) } }) @@ -202,9 +513,8 @@ func TestWindsorStack_Up(t *testing.T) { return "", fmt.Errorf("mock error getting current directory") } - // And when Up is called - err := stack.Up() - // Then the expected error is contained in err + 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()) @@ -217,13 +527,12 @@ func TestWindsorStack_Up(t *testing.T) { return nil, os.ErrNotExist } - // And when Up is called - err := stack.Up() + blueprint := createTestBlueprint() + err := stack.Up(blueprint) 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()) @@ -234,9 +543,8 @@ func TestWindsorStack_Up(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 + 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()) @@ -252,9 +560,8 @@ func TestWindsorStack_Up(t *testing.T) { return "", nil } - // And when Up is called - err := stack.Up() - // Then the expected error is contained in err + 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()) @@ -270,9 +577,8 @@ func TestWindsorStack_Up(t *testing.T) { return "", nil } - // And when Up is called - err := stack.Up() - // Then the expected error is contained in err + 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()) @@ -288,9 +594,8 @@ func TestWindsorStack_Up(t *testing.T) { return "", nil } - // And when Up is called - err := stack.Up() - // Then the expected error is contained in err + 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()) @@ -308,7 +613,6 @@ func TestWindsorStack_Down(t *testing.T) { 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{ { @@ -324,10 +628,9 @@ func TestWindsorStack_Down(t *testing.T) { t.Run("Success", func(t *testing.T) { stack, _ := setup(t) + blueprint := createTestBlueprint() - // And when the stack is brought down - if err := stack.Down(); err != nil { - // Then no error should occur + if err := stack.Down(blueprint); err != nil { t.Errorf("Expected Down to return nil, got %v", err) } }) @@ -338,9 +641,8 @@ func TestWindsorStack_Down(t *testing.T) { return "", fmt.Errorf("mock error getting current directory") } - // And when Down is called - err := stack.Down() - // Then the expected error is contained in err + 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()) @@ -353,9 +655,8 @@ func TestWindsorStack_Down(t *testing.T) { 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 + blueprint := createTestBlueprint() + err := stack.Down(blueprint) if err != nil { t.Fatalf("Expected no error when directory doesn't exist, got %v", err) } @@ -365,9 +666,8 @@ func TestWindsorStack_Down(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 + 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()) @@ -383,9 +683,8 @@ func TestWindsorStack_Down(t *testing.T) { return "", nil } - // And when Down is called - err := stack.Down() - // Then the expected error is contained in err + 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()) @@ -395,26 +694,30 @@ func TestWindsorStack_Down(t *testing.T) { t.Run("SkipComponentsWithDestroyFalse", func(t *testing.T) { stack, mocks := setup(t) - // Set up components with one having destroy: false + projectRoot := os.Getenv("WINDSOR_PROJECT_ROOT") 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 - }, - } + 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) } - // 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 { @@ -423,13 +726,10 @@ func TestWindsorStack_Down(t *testing.T) { return "", nil } - // When Down is called - if err := stack.Down(); err != nil { + if err := stack.Down(blueprint); 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 @@ -459,9 +759,8 @@ func TestWindsorStack_Down(t *testing.T) { return "", nil } - // And when Down is called - err := stack.Down() - // Then the expected error is contained in err + 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/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..9166e12d6 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 @@ -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 c86c4558e..2968aa620 100644 --- a/pkg/pipelines/down_test.go +++ b/pkg/pipelines/down_test.go @@ -7,12 +7,13 @@ 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/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 +27,7 @@ type DownMocks struct { VirtualMachine *virt.MockVirt ContainerRuntime *virt.MockVirt NetworkManager *network.MockNetworkManager - Stack *stack.MockStack + Stack *terraforminfra.MockStack BlueprintHandler *blueprint.MockBlueprintHandler } @@ -87,9 +88,9 @@ 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 } + 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/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..7571c20e3 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 } @@ -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 16d3452ab..2537ab48d 100644 --- a/pkg/pipelines/up_test.go +++ b/pkg/pipelines/up_test.go @@ -5,11 +5,12 @@ 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" + 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 +25,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,9 +94,9 @@ 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 } + 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/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/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/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" ) diff --git a/pkg/stack/stack.go b/pkg/stack/stack.go deleted file mode 100644 index 0aa91b2f1..000000000 --- a/pkg/stack/stack.go +++ /dev/null @@ -1,86 +0,0 @@ -package stack - -// The Stack is a core component that manages infrastructure component stacks. -// 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. - -import ( - "fmt" - - "github.com/windsorcli/cli/pkg/di" - "github.com/windsorcli/cli/pkg/resources/blueprint" - "github.com/windsorcli/cli/pkg/shell" -) - -// ============================================================================= -// Interfaces -// ============================================================================= - -// Stack is an interface that represents a stack of components. -type Stack interface { - Initialize() error - Up() error - Down() error -} - -// ============================================================================= -// Types -// ============================================================================= - -// BaseStack is a struct that implements the Stack interface. -type BaseStack struct { - injector di.Injector - blueprintHandler blueprint.BlueprintHandler - shell shell.Shell - shims *Shims -} - -// ============================================================================= -// Constructor -// ============================================================================= - -// NewBaseStack creates a new base stack of components. -func NewBaseStack(injector di.Injector) *BaseStack { - return &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") - } - s.blueprintHandler = blueprintHandler - - return nil -} - -// Up creates a new stack of components. -func (s *BaseStack) Up() error { - return nil -} - -// Down destroys a stack of components. -func (s *BaseStack) Down() error { - return nil -} - -// Ensure BaseStack implements Stack -var _ Stack = (*BaseStack)(nil) diff --git a/pkg/stack/stack_test.go b/pkg/stack/stack_test.go deleted file mode 100644 index 5d41760bb..000000000 --- a/pkg/stack/stack_test.go +++ /dev/null @@ -1,359 +0,0 @@ -package stack - -// The StackTest provides comprehensive test coverage for the Stack interface implementation. -// It provides validation of stack initialization, component management, and infrastructure operations, -// The StackTest ensures proper dependency injection and component lifecycle management, -// verifying error handling, mock interactions, and infrastructure state management. - -import ( - "os" - "path/filepath" - "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/resources/blueprint" - "github.com/windsorcli/cli/pkg/shell" -) - -// ============================================================================= -// Test Setup -// ============================================================================= - -type Mocks struct { - Injector di.Injector - ConfigHandler config.ConfigHandler - Shell *shell.MockShell - Blueprint *blueprint.MockBlueprintHandler - Shims *Shims -} - -type SetupOptions struct { - Injector di.Injector - ConfigHandler config.ConfigHandler - ConfigStr string -} - -// setupMocks creates mock components for testing the stack -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) - } - - tmpDir := t.TempDir() - if err := os.Chdir(tmpDir); err != nil { - 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() - } else { - 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{ - { - Source: "git::https://github.com/terraform-aws-modules/terraform-aws-vpc.git//terraform/remote/path@v1.0.0", - Path: "remote/path", - FullPath: filepath.Join(tmpDir, ".windsor", ".tf_modules", "remote", "path"), - Inputs: map[string]any{ - "remote_variable1": "default_value", - }, - }, - { - Source: "", - Path: "local/path", - FullPath: filepath.Join(tmpDir, "terraform", "local", "path"), - Inputs: map[string]any{ - "local_variable1": "default_value", - }, - }, - } - } - - // 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) - } else { - configHandler = options.ConfigHandler - } - - // Initialize config handler - if err := configHandler.Initialize(); err != nil { - t.Fatalf("Failed to initialize config handler: %v", err) - } - if err := configHandler.SetContext("mock-context"); err != nil { - t.Fatalf("Failed to set context: %v", err) - } - - // Load default config string - defaultConfigStr := ` -contexts: - mock-context: - dns: - domain: mock.domain.com` - - if err := configHandler.LoadConfigString(defaultConfigStr); err != nil { - t.Fatalf("Failed to load default config string: %v", err) - } - if options.ConfigStr != "" { - if err := configHandler.LoadConfigString(options.ConfigStr); err != nil { - t.Fatalf("Failed to load config string: %v", err) - } - } - - // Register config handler - injector.Register("configHandler", configHandler) - - // Mock system calls - shims := &Shims{} - - shims.Stat = func(path string) (os.FileInfo, error) { - return nil, nil - } - shims.Chdir = func(_ string) error { - return nil - } - shims.Getwd = func() (string, error) { - return tmpDir, nil - } - shims.Setenv = func(key, value string) error { - return os.Setenv(key, value) - } - shims.Unsetenv = func(key string) error { - return os.Unsetenv(key) - } - shims.Remove = func(_ string) error { - return nil - } - - // Register cleanup to restore original state - t.Cleanup(func() { - os.Unsetenv("WINDSOR_PROJECT_ROOT") - if err := os.Chdir(origDir); err != nil { - t.Logf("Warning: Failed to change back to original directory: %v", err) - } - }) - - return &Mocks{ - Injector: injector, - ConfigHandler: configHandler, - Shell: mockShell, - Blueprint: mockBlueprint, - Shims: shims, - } -} - -// ============================================================================= -// Test Public Methods -// ============================================================================= - -func TestStack_NewStack(t *testing.T) { - setup := func(t *testing.T) (*BaseStack, *Mocks) { - t.Helper() - mocks := setupMocks(t) - stack := NewBaseStack(mocks.Injector) - stack.shims = mocks.Shims - 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 TestStack_Initialize(t *testing.T) { - setup := func(t *testing.T) (*BaseStack, *Mocks) { - t.Helper() - mocks := setupMocks(t) - stack := NewBaseStack(mocks.Injector) - stack.shims = mocks.Shims - return stack, mocks - } - - 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 { - 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) { - // 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") - } - }) -} - -func TestStack_Up(t *testing.T) { - setup := func(t *testing.T) (*BaseStack, *Mocks) { - t.Helper() - mocks := setupMocks(t) - stack := NewBaseStack(mocks.Injector) - stack.shims = mocks.Shims - return stack, mocks - } - - 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 - 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 - 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 - t.Errorf("Expected Up to return nil even with nil injector, got %v", err) - } - }) -} - -func TestStack_Down(t *testing.T) { - setup := func(t *testing.T) (*BaseStack, *Mocks) { - t.Helper() - mocks := setupMocks(t) - stack := NewBaseStack(mocks.Injector) - stack.shims = mocks.Shims - return stack, mocks - } - - 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 - 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 - 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 - t.Errorf("Expected Down to return nil even with nil injector, got %v", err) - } - }) -} - -func TestStack_Interface(t *testing.T) { - t.Run("BaseStackImplementsStack", func(t *testing.T) { - // Given a type assertion for Stack interface - var _ Stack = (*BaseStack)(nil) - - // Then the code should compile, indicating BaseStack implements Stack - }) -} 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