From 9f4e1957fb2f588fb6f4a26b0de004188457a934 Mon Sep 17 00:00:00 2001 From: Ryan VanGundy Date: Mon, 21 Jul 2025 11:49:00 -0400 Subject: [PATCH 1/3] feat(blueprint): Implement dependsOn for terraform components Terraform components now include a `dependsOn` field. This can be configured with a list of paths to other terraform components. When specified, output variables from dependencies are injected in to the environment, allowing for variable chaining across TF components. --- api/v1alpha1/blueprint_types.go | 20 +- api/v1alpha1/blueprint_types_test.go | 55 +++-- pkg/env/shims.go | 3 + pkg/env/terraform_env.go | 253 +++++++++++++++++++++- pkg/env/terraform_env_test.go | 305 +++++++++++++++++++++++++++ pkg/pipelines/env.go | 22 ++ 6 files changed, 624 insertions(+), 34 deletions(-) diff --git a/api/v1alpha1/blueprint_types.go b/api/v1alpha1/blueprint_types.go index f9b81eef9..7416fd482 100644 --- a/api/v1alpha1/blueprint_types.go +++ b/api/v1alpha1/blueprint_types.go @@ -115,6 +115,9 @@ type TerraformComponent struct { // FullPath is the complete path, not serialized to YAML. FullPath string `yaml:"-"` + // DependsOn lists dependencies of this terraform component. + DependsOn []string `yaml:"dependsOn,omitempty"` + // Values are configuration values for the module. Values map[string]any `yaml:"values,omitempty"` @@ -235,12 +238,15 @@ func (b *Blueprint) DeepCopy() *Blueprint { valuesCopy := make(map[string]any, len(component.Values)) maps.Copy(valuesCopy, component.Values) + dependsOnCopy := append([]string{}, component.DependsOn...) + terraformComponentsCopy[i] = TerraformComponent{ - Source: component.Source, - Path: component.Path, - FullPath: component.FullPath, - Values: valuesCopy, - Destroy: component.Destroy, + Source: component.Source, + Path: component.Path, + FullPath: component.FullPath, + DependsOn: dependsOnCopy, + Values: valuesCopy, + Destroy: component.Destroy, } } @@ -341,6 +347,10 @@ func (b *Blueprint) Merge(overlay *Blueprint) { mergedComponent.FullPath = overlayComponent.FullPath } + if overlayComponent.DependsOn != nil { + mergedComponent.DependsOn = overlayComponent.DependsOn + } + if overlayComponent.Destroy != nil { mergedComponent.Destroy = overlayComponent.Destroy } diff --git a/api/v1alpha1/blueprint_types_test.go b/api/v1alpha1/blueprint_types_test.go index e6f68e0e5..f45271b72 100644 --- a/api/v1alpha1/blueprint_types_test.go +++ b/api/v1alpha1/blueprint_types_test.go @@ -34,11 +34,12 @@ func TestBlueprint_Merge(t *testing.T) { }, TerraformComponents: []TerraformComponent{ { - Source: "source1", - Path: "module/path1", - Values: map[string]any{"key1": "value1"}, - FullPath: "original/full/path", - Destroy: ptrBool(true), + Source: "source1", + Path: "module/path1", + Values: map[string]any{"key1": "value1"}, + FullPath: "original/full/path", + DependsOn: []string{}, + Destroy: ptrBool(true), }, }, Kustomizations: []Kustomization{ @@ -80,18 +81,20 @@ func TestBlueprint_Merge(t *testing.T) { }, TerraformComponents: []TerraformComponent{ { - Source: "source1", - Path: "module/path1", - Values: map[string]any{"key2": "value2"}, - FullPath: "updated/full/path", - Destroy: ptrBool(false), + Source: "source1", + Path: "module/path1", + Values: map[string]any{"key2": "value2"}, + FullPath: "updated/full/path", + DependsOn: []string{"module/path2"}, + Destroy: ptrBool(false), }, { - Source: "source2", - Path: "module/path2", - Values: map[string]any{"key3": "value3"}, - FullPath: "new/full/path", - Destroy: ptrBool(true), + Source: "source2", + Path: "module/path2", + Values: map[string]any{"key3": "value3"}, + FullPath: "new/full/path", + DependsOn: []string{}, + Destroy: ptrBool(true), }, }, Kustomizations: []Kustomization{ @@ -170,6 +173,9 @@ func TestBlueprint_Merge(t *testing.T) { if component1.FullPath != "updated/full/path" { t.Errorf("Expected FullPath to be 'updated/full/path', but got '%s'", component1.FullPath) } + if len(component1.DependsOn) != 1 || component1.DependsOn[0] != "module/path2" { + t.Errorf("Expected DependsOn to contain ['module/path2'], but got %v", component1.DependsOn) + } if component1.Destroy == nil || *component1.Destroy != false { t.Errorf("Expected Destroy to be false, but got %v", component1.Destroy) } @@ -181,6 +187,9 @@ func TestBlueprint_Merge(t *testing.T) { if component2.FullPath != "new/full/path" { t.Errorf("Expected FullPath to be 'new/full/path', but got '%s'", component2.FullPath) } + if len(component2.DependsOn) != 0 { + t.Errorf("Expected DependsOn to be empty, but got %v", component2.DependsOn) + } if component2.Destroy == nil || *component2.Destroy != true { t.Errorf("Expected Destroy to be true, but got %v", component2.Destroy) } @@ -216,11 +225,12 @@ func TestBlueprint_Merge(t *testing.T) { ApiVersion: "v1alpha1", TerraformComponents: []TerraformComponent{ { - Source: "source1", - Path: "module/path1", - Values: nil, // Initialize with nil - FullPath: "original/full/path", - Destroy: ptrBool(true), + Source: "source1", + Path: "module/path1", + Values: nil, // Initialize with nil + FullPath: "original/full/path", + DependsOn: []string{}, + Destroy: ptrBool(true), }, }, } @@ -233,8 +243,9 @@ func TestBlueprint_Merge(t *testing.T) { Values: map[string]any{ "key1": "value1", }, - FullPath: "overlay/full/path", - Destroy: ptrBool(false), + FullPath: "overlay/full/path", + DependsOn: []string{"dependency1"}, + Destroy: ptrBool(false), }, }, } diff --git a/pkg/env/shims.go b/pkg/env/shims.go index a7047ae54..5bfc641d1 100644 --- a/pkg/env/shims.go +++ b/pkg/env/shims.go @@ -7,6 +7,7 @@ package env import ( "crypto/rand" + "encoding/json" "os" "os/exec" "path/filepath" @@ -28,6 +29,7 @@ type Shims struct { ReadDir func(string) ([]os.DirEntry, error) YamlUnmarshal func([]byte, any) error YamlMarshal func(any) ([]byte, error) + JsonUnmarshal func([]byte, any) error Remove func(string) error RemoveAll func(string) error CryptoRandRead func([]byte) (int, error) @@ -55,6 +57,7 @@ func NewShims() *Shims { ReadDir: os.ReadDir, YamlUnmarshal: yaml.Unmarshal, YamlMarshal: yaml.Marshal, + JsonUnmarshal: json.Unmarshal, Remove: os.Remove, RemoveAll: os.RemoveAll, CryptoRandRead: func(b []byte) (int, error) { return rand.Read(b) }, diff --git a/pkg/env/terraform_env.go b/pkg/env/terraform_env.go index b55d12ab3..bc1098e81 100644 --- a/pkg/env/terraform_env.go +++ b/pkg/env/terraform_env.go @@ -13,6 +13,8 @@ import ( "sort" "strings" + blueprintv1alpha1 "github.com/windsorcli/cli/api/v1alpha1" + "github.com/windsorcli/cli/pkg/blueprint" "github.com/windsorcli/cli/pkg/di" ) @@ -38,6 +40,7 @@ type TerraformArgs struct { // TerraformEnvPrinter is a struct that implements Terraform environment configuration type TerraformEnvPrinter struct { BaseEnvPrinter + blueprintHandler blueprint.BlueprintHandler } // ============================================================================= @@ -55,11 +58,25 @@ func NewTerraformEnvPrinter(injector di.Injector) *TerraformEnvPrinter { // Public Methods // ============================================================================= -// GetEnvVars retrieves environment variables for Terraform. -// It determines the config root and project path, checks for tfvars files, -// and sets variables based on the OS. If not in a terraform project folder, -// it resets all TF_ vars that are already set in the environment. -// Returns a map of environment variables or an error if any step fails. +// Initialize resolves and assigns dependencies including the blueprint handler from the injector. +func (e *TerraformEnvPrinter) Initialize() error { + if err := e.BaseEnvPrinter.Initialize(); err != nil { + return err + } + + blueprintHandler, ok := e.injector.Resolve("blueprintHandler").(blueprint.BlueprintHandler) + if !ok { + return fmt.Errorf("error resolving blueprintHandler") + } + e.blueprintHandler = blueprintHandler + + return nil +} + +// GetEnvVars returns a map of environment variables for Terraform operations. +// If not in a Terraform project directory, it unsets managed TF_ variables present in the environment. +// Otherwise, it generates Terraform arguments and augments them with dependency variables. +// Returns the environment variable map or an error if resolution fails. func (e *TerraformEnvPrinter) GetEnvVars() (map[string]string, error) { envVars := make(map[string]string) @@ -95,6 +112,10 @@ func (e *TerraformEnvPrinter) GetEnvVars() (map[string]string, error) { return nil, fmt.Errorf("error generating terraform args: %w", err) } + if err := e.addDependencyVariables(projectPath, terraformArgs); err != nil { + return nil, fmt.Errorf("error adding dependency variables: %w", err) + } + return terraformArgs.TerraformVars, nil } @@ -112,7 +133,9 @@ func (e *TerraformEnvPrinter) Print() error { return e.BaseEnvPrinter.Print(envVars) } -// GenerateTerraformArgs generates the Terraform arguments for a given project. +// GenerateTerraformArgs constructs Terraform CLI argument lists and environment variables for the specified project and module paths. +// It resolves configuration root, locates relevant tfvars files, generates backend configuration arguments, and assembles +// all required CLI and environment variable values for Terraform operations. Returns a TerraformArgs struct or error. func (e *TerraformEnvPrinter) GenerateTerraformArgs(projectPath, modulePath string) (*TerraformArgs, error) { configRoot, err := e.configHandler.GetConfigRoot() if err != nil { @@ -181,7 +204,6 @@ func (e *TerraformEnvPrinter) GenerateTerraformArgs(projectPath, modulePath stri terraformVars["TF_VAR_context_path"] = strings.TrimSpace(filepath.ToSlash(configRoot)) terraformVars["TF_VAR_context_id"] = strings.TrimSpace(e.configHandler.GetString("id", "")) - // Set os_type based on the OS if e.shims.Goos() == "windows" { terraformVars["TF_VAR_os_type"] = "windows" } else { @@ -207,6 +229,223 @@ func (e *TerraformEnvPrinter) GenerateTerraformArgs(projectPath, modulePath stri // Private Methods // ============================================================================= +// addDependencyVariables sets dependency outputs as TF_VAR_* environment variables for the specified projectPath. +// It locates the current component, resolves dependency order, captures outputs from dependencies, and injects them +// into terraformArgs.TerraformVars using the format TF_VAR_. Non-string outputs are stringified. +// If blueprintHandler is nil, or the component has no dependencies, the function is a no-op. Errors are returned for +// dependency resolution failures; missing outputs are tolerated. +func (e *TerraformEnvPrinter) addDependencyVariables(projectPath string, terraformArgs *TerraformArgs) error { + if e.blueprintHandler == nil { + return nil + } + + components := e.blueprintHandler.GetTerraformComponents() + + var currentComponent *blueprintv1alpha1.TerraformComponent + for _, component := range components { + if component.Path == projectPath { + currentComponent = &component + break + } + } + + if currentComponent == nil { + return nil + } + + if len(currentComponent.DependsOn) == 0 { + return nil + } + + sortedComponents, err := e.resolveTerraformComponentDependencies(components) + if err != nil { + return fmt.Errorf("error resolving terraform component dependencies: %w", err) + } + + componentOutputs := make(map[string]map[string]any) + + for _, component := range sortedComponents { + if component.Path == projectPath { + break + } + outputs, err := e.captureTerraformOutputs(component.FullPath) + if err != nil { + continue + } + componentOutputs[component.Path] = outputs + } + + for _, depPath := range currentComponent.DependsOn { + if outputs, exists := componentOutputs[depPath]; exists { + for outputKey, outputValue := range outputs { + var valueStr string + switch v := outputValue.(type) { + case string: + valueStr = v + case float64: + valueStr = fmt.Sprintf("%.0f", v) + case bool: + valueStr = fmt.Sprintf("%t", v) + default: + valueStr = fmt.Sprintf("%v", v) + } + varName := fmt.Sprintf("TF_VAR_%s", outputKey) + terraformArgs.TerraformVars[varName] = valueStr + } + } + } + + return nil +} + +// resolveTerraformComponentDependencies returns a topologically sorted slice of Terraform components, +// ensuring that all dependencies are ordered before their dependents. It detects and reports missing +// or circular dependencies. The function uses component.Path as the key. +// Returns an error if a dependency is missing or a cycle is detected. +func (e *TerraformEnvPrinter) resolveTerraformComponentDependencies(components []blueprintv1alpha1.TerraformComponent) ([]blueprintv1alpha1.TerraformComponent, error) { + pathToComponent := make(map[string]blueprintv1alpha1.TerraformComponent) + pathToIndex := make(map[string]int) + + for i, component := range components { + pathToComponent[component.Path] = component + pathToIndex[component.Path] = i + } + + graph := make(map[string][]string) + inDegree := make(map[string]int) + + for path := range pathToComponent { + graph[path] = []string{} + inDegree[path] = 0 + } + + for path, component := range pathToComponent { + for _, depPath := range component.DependsOn { + if _, exists := pathToComponent[depPath]; !exists { + return nil, fmt.Errorf("terraform component %q depends on %q which does not exist", path, depPath) + } + graph[depPath] = append(graph[depPath], path) + inDegree[path]++ + } + } + + var queue []string + var sorted []string + + for path, degree := range inDegree { + if degree == 0 { + queue = append(queue, path) + } + } + + for len(queue) > 0 { + current := queue[0] + queue = queue[1:] + sorted = append(sorted, current) + + for _, neighbor := range graph[current] { + inDegree[neighbor]-- + if inDegree[neighbor] == 0 { + queue = append(queue, neighbor) + } + } + } + + if len(sorted) != len(components) { + return nil, fmt.Errorf("circular dependency detected in terraform components") + } + + var sortedComponents []blueprintv1alpha1.TerraformComponent + for _, path := range sorted { + sortedComponents = append(sortedComponents, pathToComponent[path]) + } + + return sortedComponents, nil +} + +// captureTerraformOutputs executes terraform output with proper environment setup for the specified component. +// It locates the component path, generates terraform arguments with backend configuration, sets environment variables, +// creates backend_override.tf, runs terraform output, and performs cleanup. Returns an empty map for any error to avoid blocking the env pipeline. +func (e *TerraformEnvPrinter) captureTerraformOutputs(modulePath string) (map[string]any, error) { + var componentPath string + components := e.blueprintHandler.GetTerraformComponents() + for _, component := range components { + if component.FullPath == modulePath { + componentPath = component.Path + break + } + } + + if componentPath == "" { + return make(map[string]any), nil + } + + terraformArgs, err := e.GenerateTerraformArgs(componentPath, modulePath) + if err != nil { + return make(map[string]any), nil + } + + originalTFDataDir := e.shims.Getenv("TF_DATA_DIR") + + if err := e.setEnvVar("TF_DATA_DIR", terraformArgs.TFDataDir); err != nil { + return make(map[string]any), nil + } + + if err := e.generateBackendOverrideTf(modulePath); err != nil { + e.restoreEnvVar("TF_DATA_DIR", originalTFDataDir) + return make(map[string]any), nil + } + + cleanup := func() { + backendOverridePath := filepath.Join(modulePath, "backend_override.tf") + if _, err := e.shims.Stat(backendOverridePath); err == nil { + _ = e.shims.Remove(backendOverridePath) + } + e.restoreEnvVar("TF_DATA_DIR", originalTFDataDir) + } + defer cleanup() + + outputArgs := []string{fmt.Sprintf("-chdir=%s", modulePath), "output", "-json"} + output, err := e.shell.ExecSilent("terraform", outputArgs...) + if err != nil { + return make(map[string]any), nil + } + + if strings.TrimSpace(output) == "" || strings.TrimSpace(output) == "{}" { + return make(map[string]any), nil + } + + var outputs map[string]any + if err := e.shims.JsonUnmarshal([]byte(output), &outputs); err != nil { + return make(map[string]any), nil + } + + result := make(map[string]any) + for key, value := range outputs { + if valueMap, ok := value.(map[string]any); ok { + if outputValue, exists := valueMap["value"]; exists { + result[key] = outputValue + } + } + } + + return result, nil +} + +// setEnvVar sets an environment variable using os.Setenv as a fallback since shims doesn't have Setenv +func (e *TerraformEnvPrinter) setEnvVar(key, value string) error { + return os.Setenv(key, value) +} + +// restoreEnvVar restores an environment variable to its original value or unsets it if it was empty +func (e *TerraformEnvPrinter) restoreEnvVar(key, originalValue string) { + if originalValue != "" { + _ = os.Setenv(key, originalValue) + } else { + _ = os.Unsetenv(key) + } +} + // generateBackendOverrideTf creates the backend_override.tf file for the project by determining // the backend type and writing the appropriate configuration to the file. func (e *TerraformEnvPrinter) generateBackendOverrideTf(directory ...string) error { diff --git a/pkg/env/terraform_env_test.go b/pkg/env/terraform_env_test.go index ff712d3f1..7c9d3f98a 100644 --- a/pkg/env/terraform_env_test.go +++ b/pkg/env/terraform_env_test.go @@ -9,6 +9,8 @@ import ( "strings" "testing" + blueprintv1alpha1 "github.com/windsorcli/cli/api/v1alpha1" + "github.com/windsorcli/cli/pkg/blueprint" "github.com/windsorcli/cli/pkg/config" ) @@ -21,6 +23,13 @@ func setupTerraformEnvMocks(t *testing.T, opts ...*SetupOptions) *Mocks { // Pass the mock config handler to setupMocks mocks := setupMocks(t, opts...) + // Create and register mock blueprint handler + mockBlueprint := blueprint.NewMockBlueprintHandler(mocks.Injector) + mockBlueprint.GetTerraformComponentsFunc = func() []blueprintv1alpha1.TerraformComponent { + return []blueprintv1alpha1.TerraformComponent{} + } + mocks.Injector.Register("blueprintHandler", mockBlueprint) + mocks.Shims.Getwd = func() (string, error) { // Use platform-agnostic path return filepath.Join("mock", "project", "root", "terraform", "project", "path"), nil @@ -1356,3 +1365,299 @@ func TestTerraformEnv_processBackendConfig(t *testing.T) { } }) } + +func TestTerraformEnv_DependencyResolution(t *testing.T) { + setup := func(t *testing.T) (*TerraformEnvPrinter, *Mocks) { + t.Helper() + mocks := setupTerraformEnvMocks(t) + printer := NewTerraformEnvPrinter(mocks.Injector) + printer.shims = mocks.Shims + if err := printer.Initialize(); err != nil { + t.Fatalf("Failed to initialize printer: %v", err) + } + return printer, mocks + } + + t.Run("ValidDependencyChain", func(t *testing.T) { + printer, mocks := setup(t) + + // Get the blueprint handler from the injector and configure it + blueprintHandler := mocks.Injector.Resolve("blueprintHandler").(*blueprint.MockBlueprintHandler) + blueprintHandler.GetTerraformComponentsFunc = func() []blueprintv1alpha1.TerraformComponent { + return []blueprintv1alpha1.TerraformComponent{ + { + Path: "vpc", + FullPath: "/project/.windsor/.tf_modules/vpc", + DependsOn: []string{}, + }, + { + Path: "subnets", + FullPath: "/project/.windsor/.tf_modules/subnets", + DependsOn: []string{"vpc"}, + }, + { + Path: "app", + FullPath: "/project/.windsor/.tf_modules/app", + DependsOn: []string{"subnets"}, + }, + } + } + + // Mock terraform output for dependencies + mocks.Shell.ExecSilentFunc = func(command string, args ...string) (string, error) { + if command == "terraform" && len(args) > 2 && args[1] == "output" && args[2] == "-json" { + if strings.Contains(args[0], "vpc") { + return `{"vpc_id": {"value": "vpc-12345"}, "subnet_cidrs": {"value": ["10.0.1.0/24", "10.0.2.0/24"]}}`, nil + } + if strings.Contains(args[0], "subnets") { + return `{"subnet_ids": {"value": ["subnet-abc", "subnet-def"]}, "vpc_id": {"value": "vpc-12345"}}`, nil + } + } + return "", nil + } + + // Set up the current working directory to match the "app" component + mocks.Shims.Getwd = func() (string, error) { + return "/project/terraform/app", nil + } + + // When getting environment variables for the "app" component + envVars, err := printer.GetEnvVars() + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + // And dependency variables should be included (only from direct dependencies) + expectedVars := map[string]string{ + "TF_VAR_subnet_ids": "[subnet-abc subnet-def]", + "TF_VAR_vpc_id": "vpc-12345", + } + + for expectedKey, expectedValue := range expectedVars { + if actualValue, exists := envVars[expectedKey]; !exists { + t.Errorf("Expected environment variable %s to be set", expectedKey) + } else if actualValue != expectedValue { + t.Errorf("Expected %s to be %s, got %s", expectedKey, expectedValue, actualValue) + } + } + + // Verify that transitive dependencies are NOT included directly + // Note: With the new naming format, TF_VAR_vpc_id comes from the direct dependency "subnets" + // The transitive "vpc" component's outputs are not directly accessible + transitiveVars := []string{ + "TF_VAR_subnet_cidrs", // This should not be present as it's only in the transitive "vpc" dependency + } + + for _, transitiveVar := range transitiveVars { + if _, exists := envVars[transitiveVar]; exists { + t.Errorf("Expected transitive dependency variable %s to NOT be set directly", transitiveVar) + } + } + }) + + t.Run("CircularDependencyDetection", func(t *testing.T) { + printer, mocks := setup(t) + + // Get the blueprint handler from the injector and configure it + blueprintHandler := mocks.Injector.Resolve("blueprintHandler").(*blueprint.MockBlueprintHandler) + blueprintHandler.GetTerraformComponentsFunc = func() []blueprintv1alpha1.TerraformComponent { + return []blueprintv1alpha1.TerraformComponent{ + { + Path: "a", + FullPath: "/project/.windsor/.tf_modules/a", + DependsOn: []string{"b"}, + }, + { + Path: "b", + FullPath: "/project/.windsor/.tf_modules/b", + DependsOn: []string{"c"}, + }, + { + Path: "c", + FullPath: "/project/.windsor/.tf_modules/c", + DependsOn: []string{"a"}, + }, + } + } + + // Set up the current working directory to match one of the components + mocks.Shims.Getwd = func() (string, error) { + return "/project/terraform/a", nil + } + + // When getting environment variables + _, err := printer.GetEnvVars() + + // Then it should detect circular dependency + if err == nil { + t.Errorf("Expected error for circular dependency, but got nil") + } else if !strings.Contains(err.Error(), "circular dependency") { + t.Errorf("Expected error to contain 'circular dependency', got %v", err) + } + }) + + t.Run("NonExistentDependency", func(t *testing.T) { + printer, mocks := setup(t) + + // Get the blueprint handler from the injector and configure it + blueprintHandler := mocks.Injector.Resolve("blueprintHandler").(*blueprint.MockBlueprintHandler) + blueprintHandler.GetTerraformComponentsFunc = func() []blueprintv1alpha1.TerraformComponent { + return []blueprintv1alpha1.TerraformComponent{ + { + Path: "app", + FullPath: "/project/.windsor/.tf_modules/app", + DependsOn: []string{"nonexistent"}, + }, + } + } + + // Set up the current working directory to match the component + mocks.Shims.Getwd = func() (string, error) { + return "/project/terraform/app", nil + } + + // When getting environment variables + _, err := printer.GetEnvVars() + + // Then it should detect missing dependency + if err == nil { + t.Errorf("Expected error for non-existent dependency, but got nil") + } else if !strings.Contains(err.Error(), "does not exist") { + t.Errorf("Expected error to contain 'does not exist', got %v", err) + } + }) + + t.Run("ComponentsWithoutNames", func(t *testing.T) { + printer, mocks := setup(t) + + // Get the blueprint handler from the injector and configure it + blueprintHandler := mocks.Injector.Resolve("blueprintHandler").(*blueprint.MockBlueprintHandler) + blueprintHandler.GetTerraformComponentsFunc = func() []blueprintv1alpha1.TerraformComponent { + return []blueprintv1alpha1.TerraformComponent{ + { + Path: "vpc/main", + FullPath: "/project/.windsor/.tf_modules/vpc/main", + DependsOn: []string{}, + }, + { + Path: "app/frontend", + FullPath: "/project/.windsor/.tf_modules/app/frontend", + DependsOn: []string{"vpc/main"}, + }, + } + } + + // Mock terraform output + mocks.Shell.ExecSilentFunc = func(command string, args ...string) (string, error) { + if command == "terraform" && len(args) > 2 && args[1] == "output" && args[2] == "-json" { + return `{"vpc_id": {"value": "vpc-12345"}}`, nil + } + return "", nil + } + + // Set up the current working directory to match the dependent component + mocks.Shims.Getwd = func() (string, error) { + return "/project/terraform/app/frontend", nil + } + + // When getting environment variables + envVars, err := printer.GetEnvVars() + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + // And dependency variable should be included + if actualValue, exists := envVars["TF_VAR_vpc_id"]; !exists { + t.Errorf("Expected environment variable TF_VAR_vpc_id to be set") + } else if actualValue != "vpc-12345" { + t.Errorf("Expected TF_VAR_vpc_id to be vpc-12345, got %s", actualValue) + } + }) + + t.Run("EmptyTerraformOutput", func(t *testing.T) { + printer, mocks := setup(t) + + // Get the blueprint handler from the injector and configure it + blueprintHandler := mocks.Injector.Resolve("blueprintHandler").(*blueprint.MockBlueprintHandler) + blueprintHandler.GetTerraformComponentsFunc = func() []blueprintv1alpha1.TerraformComponent { + return []blueprintv1alpha1.TerraformComponent{ + { + Path: "base", + FullPath: "/project/.windsor/.tf_modules/base", + DependsOn: []string{}, + }, + { + Path: "app", + FullPath: "/project/.windsor/.tf_modules/app", + DependsOn: []string{"base"}, + }, + } + } + + // Mock terraform output with empty response + mocks.Shell.ExecSilentFunc = func(command string, args ...string) (string, error) { + if command == "terraform" && len(args) > 2 && args[1] == "output" && args[2] == "-json" { + return "{}", nil + } + return "", nil + } + + // Set up the current working directory to match the dependent component + mocks.Shims.Getwd = func() (string, error) { + return "/project/terraform/app", nil + } + + // When getting environment variables + envVars, err := printer.GetEnvVars() + + // Then no error should be returned even with empty output + if err != nil { + t.Errorf("Expected no error even with empty output, got %v", err) + } + + // And standard terraform env vars should still be present + if _, exists := envVars["TF_VAR_context_path"]; !exists { + t.Errorf("Expected standard terraform environment variables to be present") + } + }) + + t.Run("NoCurrentComponent", func(t *testing.T) { + printer, mocks := setup(t) + + // Get the blueprint handler from the injector and configure it + blueprintHandler := mocks.Injector.Resolve("blueprintHandler").(*blueprint.MockBlueprintHandler) + blueprintHandler.GetTerraformComponentsFunc = func() []blueprintv1alpha1.TerraformComponent { + return []blueprintv1alpha1.TerraformComponent{ + { + Path: "vpc", + FullPath: "/project/.windsor/.tf_modules/vpc", + DependsOn: []string{}, + }, + } + } + + // Set up the current working directory to not match any component + mocks.Shims.Getwd = func() (string, error) { + return "/project/terraform/nonexistent", nil + } + + // When getting environment variables + envVars, err := printer.GetEnvVars() + + // Then no error should be returned + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + + // And no dependency variables should be added + for key := range envVars { + if strings.HasPrefix(key, "TF_VAR_vpc_") { + t.Errorf("Expected no dependency variables, but found %s", key) + } + } + }) +} diff --git a/pkg/pipelines/env.go b/pkg/pipelines/env.go index 0bda1dd35..2dd54fb76 100644 --- a/pkg/pipelines/env.go +++ b/pkg/pipelines/env.go @@ -5,6 +5,7 @@ import ( "fmt" "os" + "github.com/windsorcli/cli/pkg/blueprint" "github.com/windsorcli/cli/pkg/di" "github.com/windsorcli/cli/pkg/env" "github.com/windsorcli/cli/pkg/secrets" @@ -22,6 +23,7 @@ import ( // EnvPipeline provides environment variable printing functionality type EnvPipeline struct { BasePipeline + blueprintHandler blueprint.BlueprintHandler envPrinters []env.EnvPrinter secretsProviders []secrets.SecretsProvider } @@ -54,6 +56,26 @@ func (p *EnvPipeline) Initialize(injector di.Injector, ctx context.Context) erro return err } + kubernetesClient := p.withKubernetesClient() + if kubernetesClient != nil { + p.injector.Register("kubernetesClient", kubernetesClient) + } + + kubernetesManager := p.withKubernetesManager() + if kubernetesManager != nil { + if err := kubernetesManager.Initialize(); err != nil { + return fmt.Errorf("failed to initialize kubernetes manager: %w", err) + } + } + + p.blueprintHandler = p.withBlueprintHandler() + if p.blueprintHandler != nil { + if err := p.blueprintHandler.Initialize(); err != nil { + return fmt.Errorf("failed to initialize blueprint handler: %w", err) + } + _ = p.blueprintHandler.LoadConfig() + } + secretsProviders, err := p.withSecretsProviders() if err != nil { return fmt.Errorf("failed to create secrets providers: %w", err) From 1f979257c425dbd06446b75c6a717bf358575b7c Mon Sep 17 00:00:00 2001 From: Ryan VanGundy Date: Mon, 21 Jul 2025 13:26:40 -0400 Subject: [PATCH 2/3] Windows fix --- pkg/env/terraform_env_test.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pkg/env/terraform_env_test.go b/pkg/env/terraform_env_test.go index 7c9d3f98a..4b8122652 100644 --- a/pkg/env/terraform_env_test.go +++ b/pkg/env/terraform_env_test.go @@ -35,11 +35,13 @@ func setupTerraformEnvMocks(t *testing.T, opts ...*SetupOptions) *Mocks { return filepath.Join("mock", "project", "root", "terraform", "project", "path"), nil } + // Smart Glob function that handles any terraform directory pattern mocks.Shims.Glob = func(pattern string) ([]string, error) { if strings.Contains(pattern, "*.tf") { + // Extract directory from pattern and return a main.tf file in that directory + dir := filepath.Dir(pattern) return []string{ - filepath.Join("real", "terraform", "project", "path", "file1.tf"), - filepath.Join("real", "terraform", "project", "path", "file2.tf"), + filepath.Join(dir, "main.tf"), }, nil } return nil, nil From ec084fd5db3cbf857da1af2c9eee1fcc7312a63c Mon Sep 17 00:00:00 2001 From: Ryan VanGundy Date: Mon, 21 Jul 2025 13:36:17 -0400 Subject: [PATCH 3/3] Windows fix --- pkg/env/terraform_env_test.go | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/pkg/env/terraform_env_test.go b/pkg/env/terraform_env_test.go index 4b8122652..f58d27b75 100644 --- a/pkg/env/terraform_env_test.go +++ b/pkg/env/terraform_env_test.go @@ -1419,8 +1419,9 @@ func TestTerraformEnv_DependencyResolution(t *testing.T) { } // Set up the current working directory to match the "app" component + workingDir := filepath.Join(string(filepath.Separator), "project", "terraform", "app") mocks.Shims.Getwd = func() (string, error) { - return "/project/terraform/app", nil + return workingDir, nil } // When getting environment variables for the "app" component @@ -1486,7 +1487,7 @@ func TestTerraformEnv_DependencyResolution(t *testing.T) { // Set up the current working directory to match one of the components mocks.Shims.Getwd = func() (string, error) { - return "/project/terraform/a", nil + return filepath.Join(string(filepath.Separator), "project", "terraform", "a"), nil } // When getting environment variables @@ -1517,7 +1518,7 @@ func TestTerraformEnv_DependencyResolution(t *testing.T) { // Set up the current working directory to match the component mocks.Shims.Getwd = func() (string, error) { - return "/project/terraform/app", nil + return filepath.Join(string(filepath.Separator), "project", "terraform", "app"), nil } // When getting environment variables @@ -1561,7 +1562,7 @@ func TestTerraformEnv_DependencyResolution(t *testing.T) { // Set up the current working directory to match the dependent component mocks.Shims.Getwd = func() (string, error) { - return "/project/terraform/app/frontend", nil + return filepath.Join(string(filepath.Separator), "project", "terraform", "app", "frontend"), nil } // When getting environment variables @@ -1610,7 +1611,7 @@ func TestTerraformEnv_DependencyResolution(t *testing.T) { // Set up the current working directory to match the dependent component mocks.Shims.Getwd = func() (string, error) { - return "/project/terraform/app", nil + return filepath.Join(string(filepath.Separator), "project", "terraform", "app"), nil } // When getting environment variables @@ -1644,7 +1645,7 @@ func TestTerraformEnv_DependencyResolution(t *testing.T) { // Set up the current working directory to not match any component mocks.Shims.Getwd = func() (string, error) { - return "/project/terraform/nonexistent", nil + return filepath.FromSlash("/project/terraform/nonexistent"), nil } // When getting environment variables