Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#299 Added confirmation before destroy with list of dependent modules #1823

Merged
merged 18 commits into from
Sep 28, 2021
Merged
Show file tree
Hide file tree
Changes from all 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
38 changes: 38 additions & 0 deletions cli/cli_app.go
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,9 @@ func runCommand(command string, terragruntOptions *options.TerragruntOptions) (f
if command == CMD_RUN_ALL {
return runAll(terragruntOptions)
}
if command == "destroy" {
terragruntOptions.CheckDependentModules = true
}
return RunTerragrunt(terragruntOptions)
}

Expand Down Expand Up @@ -489,6 +492,12 @@ func RunTerragrunt(terragruntOptions *options.TerragruntOptions) error {
return err
}

if terragruntOptions.CheckDependentModules {
allowDestroy := confirmActionWithDependentModules(terragruntOptions, terragruntConfig)
if !allowDestroy {
return nil
}
}
return runTerragruntWithConfig(terragruntOptions, updatedTerragruntOptions, terragruntConfig, false)
}

Expand Down Expand Up @@ -683,6 +692,35 @@ func runTerragruntWithConfig(originalTerragruntOptions *options.TerragruntOption
})
}

// confirmActionWithDependentModules - Show warning with list of dependent modules from current module before destroy
func confirmActionWithDependentModules(terragruntOptions *options.TerragruntOptions, terragruntConfig *config.TerragruntConfig) bool {
modules, err := configstack.FindWhereWorkingDirIsIncluded(terragruntOptions, terragruntConfig)
if err != nil {
terragruntOptions.Logger.Warnf("Failed to detect where module is used %v", err)
return true
}
if len(modules) != 0 {
if _, err := terragruntOptions.ErrWriter.Write([]byte("Detected dependent modules:\n")); err != nil {
terragruntOptions.Logger.Error(err)
return false
}
for _, module := range modules {
if _, err := terragruntOptions.ErrWriter.Write([]byte(fmt.Sprintf("%s\n", module.Path))); err != nil {
terragruntOptions.Logger.Error(err)
return false
}
}
prompt := "WARNING: Are you sure you want to continue?"
shouldRun, err := shell.PromptUserForYesNo(prompt, terragruntOptions)
if err != nil {
terragruntOptions.Logger.Error(err)
return false
}
return shouldRun
}
return true
}

// Terraform 0.14 now manages a lock file for providers. This can be updated
// in three ways:
// * `terraform init` in a module where no `.terraform.lock.hcl` exists
Expand Down
9 changes: 8 additions & 1 deletion config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,12 @@ type TerragruntConfig struct {
RetryMaxAttempts *int
RetrySleepIntervalSec *int

// Fields used for internal tracking
// Indicates whether or not this is the result of a partial evaluation
IsPartial bool

// Map of processed includes
ProcessedIncludes map[string]IncludeConfig
}

func (conf *TerragruntConfig) String() string {
Expand Down Expand Up @@ -633,7 +637,10 @@ func ParseConfigString(

// If this file includes another, parse and merge it. Otherwise just return this config.
if trackInclude != nil {
return handleInclude(config, trackInclude, terragruntOptions, contextExtensions.DecodedDependencies)
config, err := handleInclude(config, trackInclude, terragruntOptions, contextExtensions.DecodedDependencies)
// Saving processed includes into configuration, direct assignment since nested includes aren't supported
config.ProcessedIncludes = trackInclude.CurrentMap
return config, err
}
return config, nil
}
Expand Down
2 changes: 2 additions & 0 deletions config/config_as_cty_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,8 @@ func terragruntConfigStructFieldToMapKey(t *testing.T, fieldName string) (string
return "generate", true
case "IsPartial":
return "", false
case "ProcessedIncludes":
return "", false
case "RetryableErrors":
return "retryable_errors", true
case "RetryMaxAttempts":
Expand Down
50 changes: 50 additions & 0 deletions configstack/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,56 @@ func getSortedKeys(modules map[string]*TerraformModule) []string {
return keys
}

// FindWhereWorkingDirIsIncluded - find where working directory is included, flow:
// 1. Find root git top level directory and build list of modules
// 2. Iterate over includes from terragruntOptions if git top level directory detection failed
// 3. Filter found module only items which has in dependencies working directory
func FindWhereWorkingDirIsIncluded(terragruntOptions *options.TerragruntOptions, terragruntConfig *config.TerragruntConfig) ([]*TerraformModule, error) {
var pathsToCheck []string
var matchedModulesMap = make(map[string]*TerraformModule)
var gitTopLevelDir = ""
gitTopLevelDir, err := shell.GitTopLevelDir(terragruntOptions, terragruntOptions.WorkingDir)
if err == nil { // top level detection worked
pathsToCheck = append(pathsToCheck, gitTopLevelDir)
} else { // detection failed, trying to use include directories as source for stacks
uniquePaths := make(map[string]bool)
for _, includePath := range terragruntConfig.ProcessedIncludes {
uniquePaths[filepath.Dir(includePath.Path)] = true
}
for path := range uniquePaths {
pathsToCheck = append(pathsToCheck, path)
}
}
for _, dir := range pathsToCheck { // iterate over detected paths, build stacks and filter modules by working dir
dir = dir + filepath.FromSlash("/")
cfgOptions, err := options.NewTerragruntOptions(dir)
if err != nil {
return nil, err
}
stack, err := FindStackInSubfolders(cfgOptions)
if err != nil {
return nil, err
}

for _, module := range stack.Modules {
for _, dep := range module.Dependencies {
if dep.Path == terragruntOptions.WorkingDir { // include in dependencies module which have in dependencies WorkingDir
matchedModulesMap[module.Path] = module
break
}
}
}
}

// extract modules as list
var matchedModules []*TerraformModule
for _, module := range matchedModulesMap {
matchedModules = append(matchedModules, module)
}

return matchedModules, nil
}

// Custom error types

type UnrecognizedDependency struct {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,8 @@ In the example above it'll generate this graph
Note that this graph shows the dependency relationship in the direction of the arrow (top down), however terragrunt will run the action
in reverse order (bottom up)

**Note:** During execution of `destroy` command, Terragrunt will try to find all dependent modules and show a confirmation prompt with a list of all detected dependencies, because once resources will be destroyed, any commands on dependent modules will fail with missing dependencies. For example, if `destroy` was called on the `redis` module, you will be asked to confirm the action because `backend-app` depends on `redis`. You can avoid the prompt by using `--terragrunt-non-interactive`.

### Testing multiple modules locally

If you are using Terragrunt to configure [remote Terraform configurations]({{site.baseurl}}/docs/features/keep-your-terraform-code-dry/#remote-terraform-configurations) and all of your modules have the `source` parameter set to a Git URL, but you want to test with a local checkout of the code, you can use the `--terragrunt-source` parameter:
Expand Down
3 changes: 3 additions & 0 deletions options/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,9 @@ type TerragruntOptions struct {
// Attributes to override in AWS provider nested within modules as part of the aws-provider-patch command. See that
// command for more info.
AwsProviderPatchOverrides map[string]string

// True if is required to show dependent modules and confirm action
CheckDependentModules bool
}

// Create a new TerragruntOptions object with reasonable defaults for real usage
Expand Down
15 changes: 15 additions & 0 deletions shell/run_shell_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -200,3 +200,18 @@ type CmdOutput struct {
Stdout string
Stderr string
}

// GitTopLevelDir - fetch git repository path from passed directory
func GitTopLevelDir(terragruntOptions *options.TerragruntOptions, path string) (string, error) {
stdout := bytes.Buffer{}
stderr := bytes.Buffer{}
opts, err := options.NewTerragruntOptions(path)
opts.Writer = &stdout
opts.ErrWriter = &stderr
cmd, err := RunShellCommandWithOutput(opts, path, true, false, "git", "rev-parse", "--show-toplevel")
terragruntOptions.Logger.Debugf("git show-toplevel result: \n%v\n%v\n", (string)(stdout.Bytes()), (string)(stderr.Bytes()))
if err != nil {
return "", err
}
return strings.TrimSpace(cmd.Stdout), nil
}
Empty file.
11 changes: 11 additions & 0 deletions test/fixture-destroy-warning/app/terragrunt.hcl
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
dependency "vpc" {
config_path = "../vpc"

mock_outputs = {
vpc = "mock"
}
}

dependencies {
paths = ["../vpc"]
}
Empty file.
Empty file.
3 changes: 3 additions & 0 deletions test/fixture-destroy-warning/vpc/terragrunt.hcl
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
include "root" {
path = find_in_parent_folders()
}
31 changes: 31 additions & 0 deletions test/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ const (
TEST_FIXTURE_PARALLELISM = "fixture-parallelism"
TEST_FIXTURE_SOPS = "fixture-sops"
TEST_FIXTURE_INCLUDE_NO_OUTPUT = "fixture-include-no-output"
TEST_FIXTURE_DESTROY_WARNING = "fixture-destroy-warning"
TERRAFORM_BINARY = "terraform"
TERRAFORM_FOLDER = ".terraform"
TERRAFORM_STATE = "terraform.tfstate"
Expand Down Expand Up @@ -4325,3 +4326,33 @@ func TestNoFailureForModulesWithoutOutputs(t *testing.T) {
err = runTerragruntCommand(t, fmt.Sprintf("terragrunt run-all apply --terragrunt-non-interactive --terragrunt-working-dir %s", appPath), &stdout, &stderr)
assert.NoError(t, err)
}

func TestShowWarningWithDependentModulesBeforeDestroy(t *testing.T) {

rootPath := copyEnvironment(t, TEST_FIXTURE_DESTROY_WARNING)

rootPath = util.JoinPath(rootPath, TEST_FIXTURE_DESTROY_WARNING)
vpcPath := util.JoinPath(rootPath, "vpc")
appPath := util.JoinPath(rootPath, "app")

cleanupTerraformFolder(t, rootPath)
cleanupTerraformFolder(t, vpcPath)

stdout := bytes.Buffer{}
stderr := bytes.Buffer{}

err := runTerragruntCommand(t, fmt.Sprintf("terragrunt run-all init --terragrunt-non-interactive --terragrunt-working-dir %s", rootPath), &stdout, &stderr)
assert.NoError(t, err)
err = runTerragruntCommand(t, fmt.Sprintf("terragrunt run-all apply --terragrunt-non-interactive --terragrunt-working-dir %s", rootPath), &stdout, &stderr)
assert.NoError(t, err)

// try to destroy vpc module and check if warning is printed in output
stdout = bytes.Buffer{}
stderr = bytes.Buffer{}

err = runTerragruntCommand(t, fmt.Sprintf("terragrunt destroy --terragrunt-non-interactive --terragrunt-working-dir %s", vpcPath), &stdout, &stderr)
assert.NoError(t, err)

output := string(stderr.Bytes())
assert.Equal(t, 1, strings.Count(output, appPath))
}