diff --git a/internal/exec/path_utils_test.go b/internal/exec/path_utils_test.go index 63eb1e96c5..0c78323056 100644 --- a/internal/exec/path_utils_test.go +++ b/internal/exec/path_utils_test.go @@ -184,6 +184,115 @@ func TestConstructPackerComponentWorkingDir(t *testing.T) { assert.Equal(t, filepath.Join("root", "packer-templates", "base"), got2) } +// TestConstructTerraformComponentVarfilePath_WithWorkdirPath tests varfile path with JIT vendored components. +// This test verifies that varfile paths correctly use workdir paths set by JIT provisioning. +func TestConstructTerraformComponentVarfilePath_WithWorkdirPath(t *testing.T) { + // Test varfile path uses workdir path when set (JIT vendored component scenario). + workdirPath := filepath.Join("workdir", "terraform", "dev-vpc") + atmosConfig := schema.AtmosConfiguration{ + BasePath: "base", + Components: schema.Components{ + Terraform: schema.Terraform{ + BasePath: filepath.Join("components", "terraform"), + }, + }, + } + info := schema.ConfigAndStacksInfo{ + ContextPrefix: "tenant1-ue2-dev", + ComponentFolderPrefix: "", + Component: "vpc", + FinalComponent: "vpc", + ComponentSection: map[string]any{ + provWorkdir.WorkdirPathKey: workdirPath, + }, + } + got := constructTerraformComponentVarfilePath(&atmosConfig, &info) + assert.Equal(t, filepath.Join(workdirPath, "tenant1-ue2-dev-vpc.terraform.tfvars.json"), got) + + // Test varfile path uses standard path when no workdir. + info2 := schema.ConfigAndStacksInfo{ + ContextPrefix: "tenant1-ue2-dev", + ComponentFolderPrefix: "", + Component: "vpc", + FinalComponent: "vpc", + ComponentSection: map[string]any{}, + } + got2 := constructTerraformComponentVarfilePath(&atmosConfig, &info2) + assert.Equal(t, filepath.Join("base", "components", "terraform", "vpc", "tenant1-ue2-dev-vpc.terraform.tfvars.json"), got2) +} + +// TestConstructTerraformComponentWorkingDir_JITVendoredComponent tests working dir for JIT vendored components. +// This simulates the scenario where a component is downloaded via JIT provisioning +// and the workdir path is set by the source provisioner. +func TestConstructTerraformComponentWorkingDir_JITVendoredComponent(t *testing.T) { + tests := []struct { + name string + workdirPath string + expectedPath string + componentName string + hasSource bool + sourceConfig map[string]any + }{ + { + name: "JIT vendored component with workdir path", + workdirPath: filepath.Join("tmp", "atmos-vendor", "abc123", "modules", "vpc"), + expectedPath: filepath.Join("tmp", "atmos-vendor", "abc123", "modules", "vpc"), + componentName: "vpc", + hasSource: true, + sourceConfig: map[string]any{ + "source": map[string]any{ + "uri": "git::https://github.com/cloudposse/terraform-aws-vpc.git?ref=v1.0.0", + }, + }, + }, + { + name: "JIT vendored component with string source", + workdirPath: filepath.Join("tmp", "vendor", "my-component"), + expectedPath: filepath.Join("tmp", "vendor", "my-component"), + componentName: "my-component", + hasSource: true, + sourceConfig: map[string]any{ + "source": "git::https://github.com/org/repo.git?ref=main", + }, + }, + { + name: "Regular component without source (no workdir)", + workdirPath: "", + expectedPath: filepath.Join("base", "components", "terraform", "vpc"), + componentName: "vpc", + hasSource: false, + sourceConfig: map[string]any{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + atmosConfig := schema.AtmosConfiguration{ + BasePath: "base", + Components: schema.Components{ + Terraform: schema.Terraform{ + BasePath: filepath.Join("components", "terraform"), + }, + }, + } + + componentSection := tt.sourceConfig + if tt.workdirPath != "" { + componentSection[provWorkdir.WorkdirPathKey] = tt.workdirPath + } + + info := schema.ConfigAndStacksInfo{ + ComponentFolderPrefix: "", + FinalComponent: tt.componentName, + ComponentSection: componentSection, + } + + got := constructTerraformComponentWorkingDir(&atmosConfig, &info) + assert.Equal(t, tt.expectedPath, got) + }) + } +} + func TestConstructPackerComponentVarfilePath(t *testing.T) { // Test complete path construction. atmosConfig1 := schema.AtmosConfiguration{ diff --git a/internal/exec/terraform_generate_backend.go b/internal/exec/terraform_generate_backend.go index 3575a23dbe..18878ba455 100644 --- a/internal/exec/terraform_generate_backend.go +++ b/internal/exec/terraform_generate_backend.go @@ -53,8 +53,8 @@ func ExecuteGenerateBackend(opts *GenerateBackendOptions, atmosConfig *schema.At ComponentFromArg: opts.Component, Stack: opts.Stack, StackFromArg: opts.Stack, - ComponentType: "terraform", - CliArgs: []string{"terraform", "generate", "backend"}, + ComponentType: cfg.TerraformComponentType, + CliArgs: []string{cfg.TerraformComponentType, "generate", "backend"}, } // Process stacks to get component configuration. @@ -63,6 +63,11 @@ func ExecuteGenerateBackend(opts *GenerateBackendOptions, atmosConfig *schema.At return err } + // Ensure component exists, provisioning via JIT if needed. + if err := ensureTerraformComponentExists(atmosConfig, &info); err != nil { + return err + } + if err := validateBackendConfig(&info); err != nil { return err } @@ -83,11 +88,10 @@ func ExecuteGenerateBackend(opts *GenerateBackendOptions, atmosConfig *schema.At // writeBackendConfigFile writes the backend config to a file. func writeBackendConfigFile(atmosConfig *schema.AtmosConfiguration, info *schema.ConfigAndStacksInfo, config map[string]any) error { + // Use constructTerraformComponentWorkingDir to properly handle JIT vendored components + // that may have a workdir path set by the source provisioner. backendFilePath := filepath.Join( - atmosConfig.BasePath, - atmosConfig.Components.Terraform.BasePath, - info.ComponentFolderPrefix, - info.FinalComponent, + constructTerraformComponentWorkingDir(atmosConfig, info), "backend.tf.json", ) diff --git a/internal/exec/terraform_generate_backend_test.go b/internal/exec/terraform_generate_backend_test.go index b3dfb40a60..e5b82c0803 100644 --- a/internal/exec/terraform_generate_backend_test.go +++ b/internal/exec/terraform_generate_backend_test.go @@ -1,12 +1,17 @@ package exec import ( + "os" + "path/filepath" "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + errUtils "github.com/cloudposse/atmos/errors" cfg "github.com/cloudposse/atmos/pkg/config" + provWorkdir "github.com/cloudposse/atmos/pkg/provisioner/workdir" "github.com/cloudposse/atmos/pkg/schema" - "github.com/stretchr/testify/assert" ) func TestValidateBackendConfig(t *testing.T) { @@ -133,6 +138,100 @@ func TestValidateBackendTypeRequirements(t *testing.T) { } } +// TestExecuteTerraformGenerateBackendCmd_Deprecated tests the deprecated command returns an error. +func TestExecuteTerraformGenerateBackendCmd_Deprecated(t *testing.T) { + err := ExecuteTerraformGenerateBackendCmd(nil, nil) + assert.Error(t, err) + assert.ErrorIs(t, err, errUtils.ErrDeprecatedCmdNotCallable) +} + +// TestWriteBackendConfigFile tests the writeBackendConfigFile function across dry-run, normal write, and workdir cases. +func TestWriteBackendConfigFile(t *testing.T) { + tempDir := t.TempDir() + componentDir := filepath.Join(tempDir, "components", "terraform", "vpc") + workDir := filepath.Join(tempDir, "workdir", "vpc") + require.NoError(t, os.MkdirAll(componentDir, 0o755)) + require.NoError(t, os.MkdirAll(workDir, 0o755)) + + atmosConfig := &schema.AtmosConfiguration{ + BasePath: tempDir, + Components: schema.Components{ + Terraform: schema.Terraform{ + BasePath: filepath.Join("components", "terraform"), + }, + }, + } + + tests := []struct { + name string + info *schema.ConfigAndStacksInfo + config map[string]any + wantPath string + wantContains string + }{ + { + name: "dry-run skips writing", + info: &schema.ConfigAndStacksInfo{FinalComponent: "vpc", DryRun: true}, + config: map[string]any{"terraform": map[string]any{"backend": map[string]any{"s3": map[string]any{"bucket": "test-bucket"}}}}, + }, + { + name: "writes to component dir", + info: &schema.ConfigAndStacksInfo{FinalComponent: "vpc", ComponentSection: map[string]any{}}, + config: map[string]any{"terraform": map[string]any{"backend": map[string]any{"s3": map[string]any{"bucket": "my-state-bucket", "key": "vpc/terraform.tfstate", "region": "us-east-1"}}}}, + wantPath: filepath.Join(componentDir, "backend.tf.json"), + wantContains: "my-state-bucket", + }, + { + name: "writes to workdir path", + info: &schema.ConfigAndStacksInfo{ + FinalComponent: "vpc", + ComponentSection: map[string]any{provWorkdir.WorkdirPathKey: workDir}, + }, + config: map[string]any{"terraform": map[string]any{"backend": map[string]any{"s3": map[string]any{"bucket": "test"}}}}, + wantPath: filepath.Join(workDir, "backend.tf.json"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := writeBackendConfigFile(atmosConfig, tt.info, tt.config) + assert.NoError(t, err) + + if tt.wantPath != "" { + assert.FileExists(t, tt.wantPath) + if tt.wantContains != "" { + content, readErr := os.ReadFile(tt.wantPath) + require.NoError(t, readErr) + assert.Contains(t, string(content), tt.wantContains) + } + } + }) + } +} + +// TestExecuteGenerateBackend_ProcessStacksFails tests that ExecuteGenerateBackend returns an error +// when ProcessStacks fails due to missing stack config files. +func TestExecuteGenerateBackend_ProcessStacksFails(t *testing.T) { + tempDir := t.TempDir() + + atmosConfig := &schema.AtmosConfiguration{ + BasePath: tempDir, + Components: schema.Components{ + Terraform: schema.Terraform{ + BasePath: filepath.Join("components", "terraform"), + }, + }, + } + + opts := &GenerateBackendOptions{ + Component: "vpc", + Stack: "dev", + } + + err := ExecuteGenerateBackend(opts, atmosConfig) + assert.Error(t, err, "should fail when stack config is not set up") +} + func TestValidateBackendTypeRequirementsTypeAssertions(t *testing.T) { tests := []struct { name string diff --git a/internal/exec/terraform_generate_varfile.go b/internal/exec/terraform_generate_varfile.go index d8a73c7105..d0c2f41d2a 100644 --- a/internal/exec/terraform_generate_varfile.go +++ b/internal/exec/terraform_generate_varfile.go @@ -1,13 +1,91 @@ package exec import ( + "context" + "errors" + "fmt" + "os" + "time" + errUtils "github.com/cloudposse/atmos/errors" + cfg "github.com/cloudposse/atmos/pkg/config" log "github.com/cloudposse/atmos/pkg/logger" "github.com/cloudposse/atmos/pkg/perf" + provSource "github.com/cloudposse/atmos/pkg/provisioner/source" + provWorkdir "github.com/cloudposse/atmos/pkg/provisioner/workdir" "github.com/cloudposse/atmos/pkg/schema" u "github.com/cloudposse/atmos/pkg/utils" ) +// checkDirectoryExists checks if a directory exists, returning true if it does. +// Returns an error only for real filesystem errors (not "not found"). +func checkDirectoryExists(path string) (bool, error) { + exists, err := u.IsDirectory(path) + if err != nil && !os.IsNotExist(err) { + return false, errors.Join(errUtils.ErrInvalidTerraformComponent, fmt.Errorf("failed to check component path: %w", err)) + } + return exists, nil +} + +// ensureTerraformComponentExists checks if a terraform component exists and provisions it via JIT if needed. +// It returns an error if the component cannot be found or provisioned. +func ensureTerraformComponentExists(atmosConfig *schema.AtmosConfiguration, info *schema.ConfigAndStacksInfo) error { + componentPath, err := u.GetComponentPath(atmosConfig, cfg.TerraformComponentType, info.ComponentFolderPrefix, info.FinalComponent) + if err != nil { + return errors.Join(errUtils.ErrInvalidTerraformComponent, fmt.Errorf("failed to resolve component path: %w", err)) + } + + exists, err := checkDirectoryExists(componentPath) + if err != nil { + return err + } + if exists { + return nil + } + + // Component doesn't exist - try JIT provisioning if source is configured. + if err := tryJITProvision(atmosConfig, info); err != nil { + return errors.Join(errUtils.ErrInvalidTerraformComponent, err) + } + + // Re-check if component exists after JIT provisioning. + if workdirPath, ok := info.ComponentSection[provWorkdir.WorkdirPathKey].(string); ok && workdirPath != "" { + return nil // Workdir path was set by provisioner. + } + + exists, err = checkDirectoryExists(componentPath) + if err != nil { + return err + } + if exists { + return nil + } + + // Component still doesn't exist. + basePath, err := u.GetComponentBasePath(atmosConfig, cfg.TerraformComponentType) + if err != nil { + return errors.Join(errUtils.ErrInvalidTerraformComponent, fmt.Errorf("failed to resolve component base path: %w", err)) + } + return fmt.Errorf("%w: '%s' points to '%s', but it does not exist in '%s'", + errUtils.ErrInvalidTerraformComponent, info.ComponentFromArg, info.FinalComponent, basePath) +} + +// tryJITProvision attempts to provision a component via JIT if it has a source configured. +func tryJITProvision(atmosConfig *schema.AtmosConfiguration, info *schema.ConfigAndStacksInfo) error { + if !provSource.HasSource(info.ComponentSection) { + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + if err := provSource.AutoProvisionSource(ctx, atmosConfig, cfg.TerraformComponentType, info.ComponentSection, info.AuthContext); err != nil { + return errors.Join(errUtils.ErrInvalidTerraformComponent, fmt.Errorf("failed to auto-provision component source: %w", err)) + } + + return nil +} + // ExecuteGenerateVarfile generates a varfile for a terraform component. func ExecuteGenerateVarfile(opts *VarfileOptions, atmosConfig *schema.AtmosConfiguration) error { defer perf.Track(atmosConfig, "exec.ExecuteGenerateVarfile")() @@ -25,8 +103,8 @@ func ExecuteGenerateVarfile(opts *VarfileOptions, atmosConfig *schema.AtmosConfi ComponentFromArg: opts.Component, Stack: opts.Stack, StackFromArg: opts.Stack, - ComponentType: "terraform", - CliArgs: []string{"terraform", "generate", "varfile"}, + ComponentType: cfg.TerraformComponentType, + CliArgs: []string{cfg.TerraformComponentType, "generate", "varfile"}, } // Process stacks to get component configuration. @@ -35,6 +113,11 @@ func ExecuteGenerateVarfile(opts *VarfileOptions, atmosConfig *schema.AtmosConfi return err } + // Ensure component exists, provisioning via JIT if needed. + if err := ensureTerraformComponentExists(atmosConfig, &info); err != nil { + return err + } + // Determine varfile path. var varFilePath string if len(opts.File) > 0 { diff --git a/internal/exec/terraform_generate_varfile_test.go b/internal/exec/terraform_generate_varfile_test.go new file mode 100644 index 0000000000..30934fa45b --- /dev/null +++ b/internal/exec/terraform_generate_varfile_test.go @@ -0,0 +1,465 @@ +package exec + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + errUtils "github.com/cloudposse/atmos/errors" + cfg "github.com/cloudposse/atmos/pkg/config" + provWorkdir "github.com/cloudposse/atmos/pkg/provisioner/workdir" + "github.com/cloudposse/atmos/pkg/schema" +) + +// TestEnsureTerraformComponentExists_ExistingComponent tests that existing components pass validation. +func TestEnsureTerraformComponentExists_ExistingComponent(t *testing.T) { + // Create a temporary directory structure. + tempDir := t.TempDir() + componentPath := filepath.Join(tempDir, "components", "terraform", "vpc") + require.NoError(t, os.MkdirAll(componentPath, 0o755)) + + // Create a minimal main.tf to make it a valid component. + mainTF := filepath.Join(componentPath, "main.tf") + require.NoError(t, os.WriteFile(mainTF, []byte("# vpc component\n"), 0o644)) + + atmosConfig := &schema.AtmosConfiguration{ + BasePath: tempDir, + Components: schema.Components{ + Terraform: schema.Terraform{ + BasePath: filepath.Join("components", "terraform"), + }, + }, + } + + info := &schema.ConfigAndStacksInfo{ + ComponentFromArg: "vpc", + FinalComponent: "vpc", + ComponentFolderPrefix: "", + ComponentSection: map[string]any{}, + } + + err := ensureTerraformComponentExists(atmosConfig, info) + assert.NoError(t, err, "existing component should not return error") +} + +// TestEnsureTerraformComponentExists_MissingComponentNoSource tests error for missing component without source. +func TestEnsureTerraformComponentExists_MissingComponentNoSource(t *testing.T) { + tempDir := t.TempDir() + + atmosConfig := &schema.AtmosConfiguration{ + BasePath: tempDir, + Components: schema.Components{ + Terraform: schema.Terraform{ + BasePath: filepath.Join("components", "terraform"), + }, + }, + } + + info := &schema.ConfigAndStacksInfo{ + ComponentFromArg: "nonexistent", + FinalComponent: "nonexistent", + ComponentFolderPrefix: "", + ComponentSection: map[string]any{}, + } + + err := ensureTerraformComponentExists(atmosConfig, info) + assert.Error(t, err, "missing component without source should return error") + assert.Contains(t, err.Error(), "nonexistent") +} + +// TestEnsureTerraformComponentExists_WorkdirPathSet tests that workdir path set by provisioner is accepted. +func TestEnsureTerraformComponentExists_WorkdirPathSet(t *testing.T) { + tempDir := t.TempDir() + + // Create workdir path. + workdirPath := filepath.Join(tempDir, "workdir", "vpc") + require.NoError(t, os.MkdirAll(workdirPath, 0o755)) + + atmosConfig := &schema.AtmosConfiguration{ + BasePath: tempDir, + Components: schema.Components{ + Terraform: schema.Terraform{ + BasePath: filepath.Join("components", "terraform"), + }, + }, + } + + info := &schema.ConfigAndStacksInfo{ + ComponentFromArg: "vpc", + FinalComponent: "vpc", + ComponentFolderPrefix: "", + ComponentSection: map[string]any{ + provWorkdir.WorkdirPathKey: workdirPath, + }, + } + + // Even though the original component path doesn't exist, the workdir path is set. + err := ensureTerraformComponentExists(atmosConfig, info) + assert.NoError(t, err, "component with workdir path set should pass") +} + +// TestTryJITProvision_NoSource tests that tryJITProvision returns nil when no source is configured. +func TestTryJITProvision_NoSource(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{ + BasePath: t.TempDir(), + } + + info := &schema.ConfigAndStacksInfo{ + ComponentSection: map[string]any{}, + } + + err := tryJITProvision(atmosConfig, info) + assert.NoError(t, err, "no source should return nil without error") +} + +// TestTryJITProvision_WithEmptySource tests that empty source config is handled. +func TestTryJITProvision_WithEmptySource(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{ + BasePath: t.TempDir(), + } + + info := &schema.ConfigAndStacksInfo{ + ComponentSection: map[string]any{ + "source": map[string]any{}, + }, + } + + err := tryJITProvision(atmosConfig, info) + assert.NoError(t, err, "empty source should return nil without error") +} + +// TestEnsureTerraformComponentExists_WithFolderPrefix tests component resolution with a folder prefix. +func TestEnsureTerraformComponentExists_WithFolderPrefix(t *testing.T) { + tempDir := t.TempDir() + componentPath := filepath.Join(tempDir, "components", "terraform", "myprefix", "vpc") + require.NoError(t, os.MkdirAll(componentPath, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(componentPath, "main.tf"), []byte("# vpc\n"), 0o644)) + + atmosConfig := &schema.AtmosConfiguration{ + BasePath: tempDir, + Components: schema.Components{ + Terraform: schema.Terraform{ + BasePath: filepath.Join("components", "terraform"), + }, + }, + } + + info := &schema.ConfigAndStacksInfo{ + ComponentFromArg: "vpc", + FinalComponent: "vpc", + ComponentFolderPrefix: "myprefix", + ComponentSection: map[string]any{}, + } + + err := ensureTerraformComponentExists(atmosConfig, info) + assert.NoError(t, err, "component with folder prefix should be found") +} + +// TestEnsureTerraformComponentExists_ReturnsErrorWithBasePath tests the error message contains base path info. +func TestEnsureTerraformComponentExists_ReturnsErrorWithBasePath(t *testing.T) { + tempDir := t.TempDir() + + atmosConfig := &schema.AtmosConfiguration{ + BasePath: tempDir, + Components: schema.Components{ + Terraform: schema.Terraform{ + BasePath: filepath.Join("components", "terraform"), + }, + }, + } + + info := &schema.ConfigAndStacksInfo{ + ComponentFromArg: "missing-comp", + FinalComponent: "missing-comp", + ComponentFolderPrefix: "", + ComponentSection: map[string]any{}, + } + + err := ensureTerraformComponentExists(atmosConfig, info) + assert.Error(t, err) + assert.Contains(t, err.Error(), "missing-comp") + assert.Contains(t, err.Error(), filepath.Join("components", "terraform")) +} + +// TestTryJITProvision_NilComponentSection tests that tryJITProvision handles nil ComponentSection. +func TestTryJITProvision_NilComponentSection(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{ + BasePath: t.TempDir(), + } + + info := &schema.ConfigAndStacksInfo{ + ComponentSection: nil, + } + + err := tryJITProvision(atmosConfig, info) + assert.NoError(t, err, "nil component section should return nil without error") +} + +// TestTryJITProvision_WithNonSourceKeys tests that component sections without source are handled. +func TestTryJITProvision_WithNonSourceKeys(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{ + BasePath: t.TempDir(), + } + + info := &schema.ConfigAndStacksInfo{ + ComponentSection: map[string]any{ + "vars": map[string]any{"name": "test"}, + "settings": map[string]any{"enabled": true}, + "metadata": map[string]any{"component": "vpc"}, + }, + } + + err := tryJITProvision(atmosConfig, info) + assert.NoError(t, err, "section without source should return nil without error") +} + +// TestTryJITProvision_WithSourceURI tests that tryJITProvision exercises AutoProvisionSource +// when a valid source URI is configured (but fails because the URI is unreachable). +func TestTryJITProvision_WithSourceURI(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{ + BasePath: t.TempDir(), + } + + info := &schema.ConfigAndStacksInfo{ + ComponentSection: map[string]any{ + "source": map[string]any{ + "uri": "file:///nonexistent/path/to/source", + }, + }, + } + + err := tryJITProvision(atmosConfig, info) + // Should return an error because the source URI is unreachable. + assert.Error(t, err, "should fail when source URI is unreachable") + assert.ErrorIs(t, err, errUtils.ErrInvalidTerraformComponent) +} + +// TestCheckDirectoryExists tests all branches of the checkDirectoryExists function. +func TestCheckDirectoryExists(t *testing.T) { + t.Run("existing directory returns true", func(t *testing.T) { + tempDir := t.TempDir() + exists, err := checkDirectoryExists(tempDir) + assert.NoError(t, err) + assert.True(t, exists) + }) + + t.Run("non-existing directory returns false", func(t *testing.T) { + exists, err := checkDirectoryExists(filepath.Join(t.TempDir(), "nonexistent")) + assert.NoError(t, err) + assert.False(t, exists) + }) + + t.Run("file path returns false", func(t *testing.T) { + tempDir := t.TempDir() + filePath := filepath.Join(tempDir, "file.txt") + require.NoError(t, os.WriteFile(filePath, []byte("test"), 0o644)) + exists, err := checkDirectoryExists(filePath) + assert.NoError(t, err) + assert.False(t, exists) + }) +} + +// TestExecuteTerraformGenerateVarfileCmd_Deprecated tests the deprecated command returns an error. +func TestExecuteTerraformGenerateVarfileCmd_Deprecated(t *testing.T) { + err := ExecuteTerraformGenerateVarfileCmd(nil, nil) + assert.Error(t, err) + assert.ErrorIs(t, err, errUtils.ErrDeprecatedCmdNotCallable) +} + +// TestEnsureTerraformComponentExists_JITProvisionFails tests the error wrapping when JIT provisioning fails. +func TestEnsureTerraformComponentExists_JITProvisionFails(t *testing.T) { + tempDir := t.TempDir() + + atmosConfig := &schema.AtmosConfiguration{ + BasePath: tempDir, + Components: schema.Components{ + Terraform: schema.Terraform{ + BasePath: filepath.Join("components", "terraform"), + }, + }, + } + + // Component doesn't exist and has a source with an unreachable URI — JIT provision will fail. + info := &schema.ConfigAndStacksInfo{ + ComponentFromArg: "jit-fail", + FinalComponent: "jit-fail", + ComponentFolderPrefix: "", + ComponentSection: map[string]any{ + "source": map[string]any{ + "uri": "file:///nonexistent/path/to/source", + }, + }, + } + + err := ensureTerraformComponentExists(atmosConfig, info) + assert.Error(t, err) + assert.ErrorIs(t, err, errUtils.ErrInvalidTerraformComponent) + assert.Contains(t, err.Error(), "auto-provision") +} + +// TestEnsureTerraformComponentExists_PostJITComponentAppears tests the path where JIT provisioning +// succeeds (no source, returns nil) and the component directory appears at the standard path. +func TestEnsureTerraformComponentExists_PostJITComponentAppears(t *testing.T) { + tempDir := t.TempDir() + + // Do NOT create the component directory initially. + componentPath := filepath.Join(tempDir, "components", "terraform", "lazy-vpc") + + atmosConfig := &schema.AtmosConfiguration{ + BasePath: tempDir, + Components: schema.Components{ + Terraform: schema.Terraform{ + BasePath: filepath.Join("components", "terraform"), + }, + }, + } + + info := &schema.ConfigAndStacksInfo{ + ComponentFromArg: "lazy-vpc", + FinalComponent: "lazy-vpc", + ComponentFolderPrefix: "", + ComponentSection: map[string]any{}, + } + + // First call: component doesn't exist, no source, no workdir → error. + err := ensureTerraformComponentExists(atmosConfig, info) + assert.Error(t, err, "should fail when component doesn't exist") + + // Now create the directory to simulate JIT provisioning having put it there. + require.NoError(t, os.MkdirAll(componentPath, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(componentPath, "main.tf"), []byte("# lazy-vpc\n"), 0o644)) + + // Second call: component now exists at the standard path. + err = ensureTerraformComponentExists(atmosConfig, info) + assert.NoError(t, err, "should pass when component exists") +} + +// TestExecuteGenerateVarfile_ProcessStacksFails tests that ExecuteGenerateVarfile returns an error +// when ProcessStacks fails due to missing stack config files. +func TestExecuteGenerateVarfile_ProcessStacksFails(t *testing.T) { + tempDir := t.TempDir() + + atmosConfig := &schema.AtmosConfiguration{ + BasePath: tempDir, + Components: schema.Components{ + Terraform: schema.Terraform{ + BasePath: filepath.Join("components", "terraform"), + }, + }, + } + + opts := &VarfileOptions{ + Component: "vpc", + Stack: "dev", + } + + err := ExecuteGenerateVarfile(opts, atmosConfig) + assert.Error(t, err, "should fail when stack config is not set up") +} + +// TestExecuteGenerateVarfile_ProcessStacksFailsWithFile tests the file option path is not reached on ProcessStacks failure. +func TestExecuteGenerateVarfile_ProcessStacksFailsWithFile(t *testing.T) { + tempDir := t.TempDir() + + atmosConfig := &schema.AtmosConfiguration{ + BasePath: tempDir, + Components: schema.Components{ + Terraform: schema.Terraform{ + BasePath: filepath.Join("components", "terraform"), + }, + }, + } + + opts := &VarfileOptions{ + Component: "vpc", + Stack: "dev", + File: filepath.Join(tempDir, "output.tfvars.json"), + ProcessingOptions: ProcessingOptions{ + ProcessTemplates: true, + ProcessFunctions: true, + }, + } + + err := ExecuteGenerateVarfile(opts, atmosConfig) + assert.Error(t, err, "should fail when stack config is not set up") +} + +// TestExecuteGenerateVarfile_Integration tests the full varfile generation flow +// using the existing stack-templates test fixture. +func TestExecuteGenerateVarfile_Integration(t *testing.T) { + fixtureDir, err := filepath.Abs(filepath.Join("..", "..", "tests", "fixtures", "scenarios", "stack-templates")) + require.NoError(t, err) + + // Skip if the fixture directory doesn't exist. + if _, statErr := os.Stat(fixtureDir); os.IsNotExist(statErr) { + t.Skip("Stack-templates fixture not found") + } + + atmosConfig, err := cfg.InitCliConfig(schema.ConfigAndStacksInfo{ + AtmosBasePath: fixtureDir, + AtmosConfigDirsFromArg: []string{fixtureDir}, + }, true) + require.NoError(t, err, "config initialization should succeed") + + // Write the varfile to a temp directory to avoid modifying the fixture. + varfilePath := filepath.Join(t.TempDir(), "test-output.tfvars.json") + opts := &VarfileOptions{ + Component: "component-1", + Stack: "nonprod", + File: varfilePath, + ProcessingOptions: ProcessingOptions{ + ProcessTemplates: true, + ProcessFunctions: true, + }, + } + + err = ExecuteGenerateVarfile(opts, &atmosConfig) + require.NoError(t, err, "varfile generation should succeed") + + // Verify the varfile was written. + assert.FileExists(t, varfilePath) + content, err := os.ReadFile(varfilePath) + require.NoError(t, err) + assert.Contains(t, string(content), "component-1-a") +} + +// TestExecuteGenerateBackend_Integration tests the full backend generation flow +// using the existing stack-templates test fixture. +func TestExecuteGenerateBackend_Integration(t *testing.T) { + fixtureDir, err := filepath.Abs(filepath.Join("..", "..", "tests", "fixtures", "scenarios", "stack-templates")) + require.NoError(t, err) + + // Skip if the fixture directory doesn't exist. + if _, statErr := os.Stat(fixtureDir); os.IsNotExist(statErr) { + t.Skip("Stack-templates fixture not found") + } + + atmosConfig, err := cfg.InitCliConfig(schema.ConfigAndStacksInfo{ + AtmosBasePath: fixtureDir, + AtmosConfigDirsFromArg: []string{fixtureDir}, + }, true) + require.NoError(t, err, "config initialization should succeed") + + opts := &GenerateBackendOptions{ + Component: "component-1", + Stack: "nonprod", + ProcessingOptions: ProcessingOptions{ + ProcessTemplates: true, + ProcessFunctions: true, + }, + } + + err = ExecuteGenerateBackend(opts, &atmosConfig) + require.NoError(t, err, "backend generation should succeed") + + // Verify backend was written to the component directory. + backendFile := filepath.Join(fixtureDir, "components", "terraform", "mock", "backend.tf.json") + t.Cleanup(func() { _ = os.Remove(backendFile) }) + assert.FileExists(t, backendFile) + content, err := os.ReadFile(backendFile) + require.NoError(t, err) + assert.Contains(t, string(content), "nonprod-tfstate") +} diff --git a/internal/exec/terraform_generate_varfile_unix_test.go b/internal/exec/terraform_generate_varfile_unix_test.go new file mode 100644 index 0000000000..45bbc92b2f --- /dev/null +++ b/internal/exec/terraform_generate_varfile_unix_test.go @@ -0,0 +1,83 @@ +//go:build !windows + +package exec + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + errUtils "github.com/cloudposse/atmos/errors" + "github.com/cloudposse/atmos/pkg/schema" +) + +// TestEnsureTerraformComponentExists_DirectoryCheckError tests the error propagation +// when checkDirectoryExists returns a real filesystem error (e.g., permission denied). +func TestEnsureTerraformComponentExists_DirectoryCheckError(t *testing.T) { + if os.Getuid() == 0 { + t.Skip("Skipping permission test when running as root") + } + + tempDir := t.TempDir() + + // Create the components/terraform directory but make it inaccessible. + componentBase := filepath.Join(tempDir, "components", "terraform") + require.NoError(t, os.MkdirAll(filepath.Join(componentBase, "vpc"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(componentBase, "vpc", "main.tf"), []byte("# vpc\n"), 0o644)) + + // Remove permissions on the component base directory to trigger a real filesystem error. + require.NoError(t, os.Chmod(componentBase, 0o000)) + t.Cleanup(func() { + os.Chmod(componentBase, 0o755) + }) + + atmosConfig := &schema.AtmosConfiguration{ + BasePath: tempDir, + Components: schema.Components{ + Terraform: schema.Terraform{ + BasePath: filepath.Join("components", "terraform"), + }, + }, + } + + info := &schema.ConfigAndStacksInfo{ + ComponentFromArg: "vpc", + FinalComponent: "vpc", + ComponentFolderPrefix: "", + ComponentSection: map[string]any{}, + } + + err := ensureTerraformComponentExists(atmosConfig, info) + assert.Error(t, err, "should propagate filesystem error from checkDirectoryExists") + assert.ErrorIs(t, err, errUtils.ErrInvalidTerraformComponent) +} + +// TestCheckDirectoryExists_PermissionError tests the real filesystem error branch. +func TestCheckDirectoryExists_PermissionError(t *testing.T) { + if os.Getuid() == 0 { + t.Skip("Skipping permission test when running as root") + } + + tempDir := t.TempDir() + restrictedDir := filepath.Join(tempDir, "restricted") + require.NoError(t, os.MkdirAll(restrictedDir, 0o755)) + + targetDir := filepath.Join(restrictedDir, "inner") + require.NoError(t, os.MkdirAll(targetDir, 0o755)) + + // Remove read+execute permission on parent to cause a real filesystem error. + require.NoError(t, os.Chmod(restrictedDir, 0o000)) + t.Cleanup(func() { + // Restore permissions so cleanup can succeed. + os.Chmod(restrictedDir, 0o755) + }) + + // Attempting to stat the inner directory should trigger a permission error. + exists, err := checkDirectoryExists(targetDir) + assert.Error(t, err, "should return error for permission denied") + assert.False(t, exists) + assert.ErrorIs(t, err, errUtils.ErrInvalidTerraformComponent) +}