Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions internal/exec/path_utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
16 changes: 10 additions & 6 deletions internal/exec/terraform_generate_backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
}
Expand All @@ -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",
)

Expand Down
101 changes: 100 additions & 1 deletion internal/exec/terraform_generate_backend_test.go
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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: "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 (e.g., no stack config files configured).
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
Expand Down
87 changes: 85 additions & 2 deletions internal/exec/terraform_generate_varfile.go
Original file line number Diff line number Diff line change
@@ -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")()
Expand All @@ -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.
Expand All @@ -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 {
Expand Down
Loading
Loading