Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
4 changes: 4 additions & 0 deletions cmd/terraform/clean.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ Common use cases:
everything := v.GetBool("everything")
skipLockFile := v.GetBool("skip-lock-file")
dryRun := v.GetBool("dry-run")
cache := v.GetBool("cache")

// Prompt for component/stack if neither is provided.
if component == "" && stack == "" {
Expand All @@ -78,6 +79,7 @@ Common use cases:
Everything: everything,
SkipLockFile: skipLockFile,
DryRun: dryRun,
Cache: cache,
}
return e.ExecuteClean(opts, &atmosConfig)
},
Expand All @@ -89,9 +91,11 @@ func init() {
flags.WithBoolFlag("everything", "", false, "If set atmos will also delete the Terraform state files and directories for the component"),
flags.WithBoolFlag("force", "f", false, "Forcefully delete Terraform state files and directories without interaction"),
flags.WithBoolFlag("skip-lock-file", "", false, "Skip deleting the `.terraform.lock.hcl` file"),
flags.WithBoolFlag("cache", "", false, "Clean Terraform plugin cache directory"),
flags.WithEnvVars("everything", "ATMOS_TERRAFORM_CLEAN_EVERYTHING"),
flags.WithEnvVars("force", "ATMOS_TERRAFORM_CLEAN_FORCE"),
flags.WithEnvVars("skip-lock-file", "ATMOS_TERRAFORM_CLEAN_SKIP_LOCK_FILE"),
flags.WithEnvVars("cache", "ATMOS_TERRAFORM_CLEAN_CACHE"),
)

// Register flags with the command as persistent flags.
Expand Down
80 changes: 80 additions & 0 deletions internal/exec/terraform.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"github.com/cloudposse/atmos/pkg/provisioner"
"github.com/cloudposse/atmos/pkg/schema"
u "github.com/cloudposse/atmos/pkg/utils"
"github.com/cloudposse/atmos/pkg/xdg"

// Import backend provisioner to register S3 provisioner.
_ "github.com/cloudposse/atmos/pkg/provisioner/backend"
Expand Down Expand Up @@ -430,6 +431,10 @@ func ExecuteTerraform(info schema.ConfigAndStacksInfo) error {
info.ComponentEnvList = append(info.ComponentEnvList, fmt.Sprintf("TF_APPEND_USER_AGENT=%s", appendUserAgent))
}

// Set TF_PLUGIN_CACHE_DIR for Terraform provider caching.
pluginCacheEnvList := configurePluginCache(&atmosConfig)
info.ComponentEnvList = append(info.ComponentEnvList, pluginCacheEnvList...)

// Print ENV vars if they are found in the component's stack config.
if len(info.ComponentEnvList) > 0 {
log.Debug("Using ENV vars:")
Expand Down Expand Up @@ -757,3 +762,78 @@ func ExecuteTerraform(info schema.ConfigAndStacksInfo) error {

return nil
}

// configurePluginCache returns environment variables for Terraform plugin caching.
// It checks if the user has already set TF_PLUGIN_CACHE_DIR (via OS env or global env),
// and if not, configures automatic caching based on atmosConfig.Components.Terraform.PluginCache.
func configurePluginCache(atmosConfig *schema.AtmosConfiguration) []string {
// Check both OS env and global env (atmos.yaml env: section) for user override.
// If user has TF_PLUGIN_CACHE_DIR set to a valid path, do nothing - they manage their own cache.
// Invalid values (empty string or "/") are ignored with a warning, and we use our default.
if userCacheDir := getValidUserPluginCacheDir(atmosConfig); userCacheDir != "" {
log.Debug("TF_PLUGIN_CACHE_DIR already set, skipping automatic plugin cache configuration")
return nil
}

if !atmosConfig.Components.Terraform.PluginCache {
return nil
}

pluginCacheDir := atmosConfig.Components.Terraform.PluginCacheDir

// Use XDG cache directory if no custom path configured.
if pluginCacheDir == "" {
cacheDir, err := xdg.GetXDGCacheDir("terraform/plugins", xdg.DefaultCacheDirPerm)
if err != nil {
log.Warn("Failed to create plugin cache directory", "error", err)
return nil
}
pluginCacheDir = cacheDir
}

if pluginCacheDir == "" {
return nil
}

return []string{
fmt.Sprintf("TF_PLUGIN_CACHE_DIR=%s", pluginCacheDir),
"TF_PLUGIN_CACHE_MAY_BREAK_DEPENDENCY_LOCK_FILE=true",
}
}

// getValidUserPluginCacheDir checks if the user has set a valid TF_PLUGIN_CACHE_DIR.
// Returns the valid path if set, or empty string if not set or invalid.
// Invalid values (empty string or "/") are logged as warnings.
func getValidUserPluginCacheDir(atmosConfig *schema.AtmosConfiguration) string {
// Check OS environment first.
if osEnvDir, inOsEnv := os.LookupEnv("TF_PLUGIN_CACHE_DIR"); inOsEnv {
if isValidPluginCacheDir(osEnvDir, "environment variable") {
return osEnvDir
}
return ""
}

// Check global env section in atmos.yaml.
if globalEnvDir, inGlobalEnv := atmosConfig.Env["TF_PLUGIN_CACHE_DIR"]; inGlobalEnv {
if isValidPluginCacheDir(globalEnvDir, "atmos.yaml env section") {
return globalEnvDir
}
return ""
}

return ""
}

// isValidPluginCacheDir checks if a plugin cache directory path is valid.
// Invalid paths (empty string or "/") are logged as warnings and return false.
func isValidPluginCacheDir(path, source string) bool {
if path == "" {
log.Warn("TF_PLUGIN_CACHE_DIR is empty, ignoring and using Atmos default", "source", source)
return false
}
if path == "/" {
log.Warn("TF_PLUGIN_CACHE_DIR is set to root '/', ignoring and using Atmos default", "source", source)
return false
}
return true
}
57 changes: 55 additions & 2 deletions internal/exec/terraform_clean.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,15 @@ import (
"strings"

"github.com/charmbracelet/huh"
"golang.org/x/term"

errUtils "github.com/cloudposse/atmos/errors"
tuiTerm "github.com/cloudposse/atmos/internal/tui/templates/term"
"github.com/cloudposse/atmos/internal/tui/utils"
log "github.com/cloudposse/atmos/pkg/logger"
"github.com/cloudposse/atmos/pkg/perf"
"github.com/cloudposse/atmos/pkg/schema"
"github.com/cloudposse/atmos/pkg/ui"
"github.com/cloudposse/atmos/pkg/xdg"
)

// EnvTFDataDir is the environment variable name for TF_DATA_DIR.
Expand Down Expand Up @@ -308,7 +309,7 @@ func DeletePathTerraform(fullPath string, objectName string) error {
func confirmDeletion() (bool, error) {
// Check if stdin is a TTY
// In non-interactive environments (tests, CI/CD), we should require --force flag
if !term.IsTerminal(int(os.Stdin.Fd())) {
if !tuiTerm.IsTTYSupportForStdin() {
log.Debug("Not a TTY, skipping interactive confirmation (use --force to bypass)")
return false, errUtils.ErrInteractiveNotAvailable
}
Expand Down Expand Up @@ -442,8 +443,16 @@ func ExecuteClean(opts *CleanOptions, atmosConfig *schema.AtmosConfiguration) er
"everything", opts.Everything,
"skipLockFile", opts.SkipLockFile,
"dryRun", opts.DryRun,
"cache", opts.Cache,
)

// Handle plugin cache cleanup if --cache flag is set.
if opts.Cache {
if err := cleanPluginCache(opts.Force, opts.DryRun); err != nil {
return err
}
}

// Build ConfigAndStacksInfo for HandleCleanSubCommand.
info := schema.ConfigAndStacksInfo{
ComponentFromArg: opts.Component,
Expand Down Expand Up @@ -704,3 +713,47 @@ func HandleCleanSubCommand(info *schema.ConfigAndStacksInfo, componentPath strin
executeCleanDeletion(folders, tfDataDirFolders, relativePath, atmosConfig)
return nil
}

// cleanPluginCache cleans the Terraform plugin cache directory.
func cleanPluginCache(force, dryRun bool) error {
defer perf.Track(nil, "exec.cleanPluginCache")()

// Get XDG cache directory for terraform plugins.
cacheDir, err := xdg.GetXDGCacheDir("terraform/plugins", xdg.DefaultCacheDirPerm)
if err != nil {
log.Warn("Failed to determine plugin cache directory", "error", err)
return nil
}

// Check if cache directory exists.
if _, err := os.Stat(cacheDir); os.IsNotExist(err) {
_ = ui.Success("Plugin cache directory does not exist, nothing to clean")
return nil
}

if dryRun {
_ = ui.Writef("Dry run mode: would delete plugin cache directory: %s\n", cacheDir)
return nil
}

// Prompt for confirmation unless --force is set.
if !force {
_ = ui.Writef("This will delete the plugin cache directory: %s\n", cacheDir)
confirmed, err := confirmDeletion()
if err != nil {
return err
}
if !confirmed {
return nil
}
}

// Remove the cache directory.
if err := os.RemoveAll(cacheDir); err != nil {
log.Warn("Failed to clean plugin cache", "path", cacheDir, "error", err)
return err
}

_ = ui.Successf("Cleaned plugin cache: %s", cacheDir)
return nil
}
70 changes: 70 additions & 0 deletions internal/exec/terraform_clean_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ package exec

import (
"os"
"path/filepath"
"testing"

cfg "github.com/cloudposse/atmos/pkg/config"
"github.com/cloudposse/atmos/pkg/schema"
"github.com/cloudposse/atmos/pkg/xdg"
"github.com/stretchr/testify/require"
)

Expand Down Expand Up @@ -324,3 +326,71 @@ func TestCollectComponentsDirectoryObjects(t *testing.T) {
})
}
}

func TestCleanPluginCache_Force(t *testing.T) {
// Create a temporary cache directory to simulate the XDG cache.
tmpDir := t.TempDir()
t.Setenv("XDG_CACHE_HOME", tmpDir)

// Create the plugin cache directory with some content.
cacheDir, err := xdg.GetXDGCacheDir("terraform/plugins", 0o755)
require.NoError(t, err)

// Create a fake provider file.
testFile := filepath.Join(cacheDir, "registry.terraform.io", "hashicorp", "null", "test-provider")
err = os.MkdirAll(filepath.Dir(testFile), 0o755)
require.NoError(t, err)
err = os.WriteFile(testFile, []byte("fake provider"), 0o644)
require.NoError(t, err)

// Verify the file exists.
_, err = os.Stat(testFile)
require.NoError(t, err)

// Run cleanPluginCache with force=true.
err = cleanPluginCache(true, false)
require.NoError(t, err)

// Verify the cache directory was deleted.
_, err = os.Stat(cacheDir)
require.True(t, os.IsNotExist(err), "Cache directory should be deleted")
}

func TestCleanPluginCache_DryRun(t *testing.T) {
// Create a temporary cache directory to simulate the XDG cache.
tmpDir := t.TempDir()
t.Setenv("XDG_CACHE_HOME", tmpDir)

// Create the plugin cache directory with some content.
cacheDir, err := xdg.GetXDGCacheDir("terraform/plugins", 0o755)
require.NoError(t, err)

// Create a fake provider file.
testFile := filepath.Join(cacheDir, "registry.terraform.io", "hashicorp", "null", "test-provider")
err = os.MkdirAll(filepath.Dir(testFile), 0o755)
require.NoError(t, err)
err = os.WriteFile(testFile, []byte("fake provider"), 0o644)
require.NoError(t, err)

// Run cleanPluginCache with dryRun=true.
err = cleanPluginCache(true, true)
require.NoError(t, err)

// Verify the file still exists (dry run should not delete).
_, err = os.Stat(testFile)
require.NoError(t, err, "File should still exist after dry run")
}

func TestCleanPluginCache_NonExistent(t *testing.T) {
// Create a temporary cache directory without the terraform/plugins folder.
tmpDir := t.TempDir()
t.Setenv("XDG_CACHE_HOME", tmpDir)

// Remove any existing cache dir.
cacheDir := filepath.Join(tmpDir, "atmos", "terraform", "plugins")
os.RemoveAll(cacheDir)

// Run cleanPluginCache - should not error even if directory doesn't exist.
err := cleanPluginCache(true, false)
require.NoError(t, err)
}
Loading