Repository: gruntwork-io/terragrunt (language: go)

Issue:
Add "reverse dependency" information to render-json
Currently, when you run the `render-json` command for module `foo`, it includes `dependency` information, showing you other modules that `foo` depends on: e.g., if `foo` contains a `dependency` block pointing to `bar`, then `bar` is in the `dependency` output of `render-json`. It would be useful to add "reverse dependency" information, showing what other modules depend on `foo`: e.g., if `baz` contains a `dependency` block pointing to `foo`, then `baz` shows up in the `reverse-dependency` output of `render-json` for `foo`. This way, any tool introspecting Terragrunt configs using the `render-json` command would be able to see the dependency graph in both directions.

I believe we already know how to fetch this information as per https://github.com/gruntwork-io/terragrunt/pull/1823, so I would guess it's just a matter of exposing it in `render-json`?


Discussion / hints:

Fix patch:
diff --git a/cli/commands/render-json/action.go b/cli/commands/render-json/action.go
index cb6b1c3a40..786eceef83 100644
--- a/cli/commands/render-json/action.go
+++ b/cli/commands/render-json/action.go
@@ -12,6 +12,8 @@ import (
 	"io/ioutil"
 	"path/filepath"
 
+	"github.com/gruntwork-io/terragrunt/configstack"
+
 	"github.com/zclconf/go-cty/cty"
 	ctyjson "github.com/zclconf/go-cty/cty/json"
 
@@ -33,6 +35,15 @@ func runRenderJSON(opts *options.TerragruntOptions, cfg *config.TerragruntConfig
 		return fmt.Errorf("Terragrunt was not able to render the config as json because it received no config. This is almost certainly a bug in Terragrunt. Please open an issue on github.com/gruntwork-io/terragrunt with this message and the contents of your terragrunt.hcl.")
 	}
 
+	dependentModules := configstack.FindWhereWorkingDirIsIncluded(opts, cfg)
+	var dependentModulesPath []*string
+	for _, module := range dependentModules {
+		dependentModulesPath = append(dependentModulesPath, &module.Path)
+	}
+
+	cfg.DependentModulesPath = dependentModulesPath
+	cfg.SetFieldMetadata(config.MetadataDependentModules, map[string]interface{}{config.FoundInFile: opts.TerragruntConfigPath})
+
 	var terragruntConfigCty cty.Value
 
 	if opts.RenderJsonWithMetadata {
diff --git a/config/config.go b/config/config.go
index 9b7428dafb..28e9c9bdf7 100644
--- a/config/config.go
+++ b/config/config.go
@@ -33,7 +33,7 @@ const (
 	DefaultTerragruntJsonConfigPath = "terragrunt.hcl.json"
 )
 
-const foundInFile = "found_in_file"
+const FoundInFile = "found_in_file"
 
 const (
 	MetadataTerraform                   = "terraform"
@@ -55,6 +55,7 @@ const (
 	MetadataRetryableErrors             = "retryable_errors"
 	MetadataRetryMaxAttempts            = "retry_max_attempts"
 	MetadataRetrySleepIntervalSec       = "retry_sleep_interval_sec"
+	MetadataDependentModules            = "dependent_modules"
 )
 
 // Order matters, for example if none of the files are found `GetDefaultConfigPath` func returns the last element.
@@ -95,6 +96,9 @@ type TerragruntConfig struct {
 
 	// Map to store fields metadata
 	FieldsMetadata map[string]map[string]interface{}
+
+	// List of dependent modules
+	DependentModulesPath []*string
 }
 
 func (conf *TerragruntConfig) String() string {
@@ -896,7 +900,7 @@ func convertToTerragruntConfig(
 		GenerateConfigs: map[string]codegen.GenerateConfig{},
 	}
 
-	defaultMetadata := map[string]interface{}{foundInFile: configPath}
+	defaultMetadata := map[string]interface{}{FoundInFile: configPath}
 	if terragruntConfigFromFile.RemoteState != nil {
 		remoteState, err := terragruntConfigFromFile.RemoteState.toConfig()
 		if err != nil {
diff --git a/config/config_as_cty.go b/config/config_as_cty.go
index 54dbc4c6f0..cfab2b99eb 100644
--- a/config/config_as_cty.go
+++ b/config/config_as_cty.go
@@ -121,6 +121,16 @@ func TerragruntConfigAsCty(config *TerragruntConfig) (cty.Value, error) {
 		output[MetadataLocals] = localsCty
 	}
 
+	if len(config.DependentModulesPath) > 0 {
+		dependentModulesCty, err := convertToCtyWithJson(config.DependentModulesPath)
+		if err != nil {
+			return cty.NilVal, err
+		}
+		if dependentModulesCty != cty.NilVal {
+			output[MetadataDependentModules] = dependentModulesCty
+		}
+	}
+
 	return convertValuesMapToCtyVal(output)
 }
 
@@ -177,6 +187,10 @@ func TerragruntConfigAsCtyWithMetadata(config *TerragruntConfig) (cty.Value, err
 		return cty.NilVal, err
 	}
 
+	if err := wrapWithMetadata(config, config.DependentModulesPath, MetadataDependentModules, &output); err != nil {
+		return cty.NilVal, err
+	}
+
 	// Terraform
 	terraformConfigCty, err := terraformConfigAsCty(config.Terraform)
 	if err != nil {
diff --git a/configstack/module.go b/configstack/module.go
index 9aeaccb303..56eae441de 100644
--- a/configstack/module.go
+++ b/configstack/module.go
@@ -3,6 +3,7 @@ package configstack
 import (
 	"encoding/json"
 	"fmt"
+	"os"
 	"path/filepath"
 	"sort"
 	"strings"
@@ -536,9 +537,12 @@ func FindWhereWorkingDirIsIncluded(terragruntOptions *options.TerragruntOptions,
 	var matchedModulesMap = make(map[string]*TerraformModule)
 
 	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
+	useIncludes := err != nil // fallback to includes git top level directory detection failed
+	if err == nil {
+		pathsToCheck, err = buildDirList(terragruntOptions, gitTopLevelDir)
+		useIncludes = err != nil // fallback to includes if directory list building failed
+	}
+	if useIncludes { // 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
@@ -549,28 +553,26 @@ func FindWhereWorkingDirIsIncluded(terragruntOptions *options.TerragruntOptions,
 	}
 
 	for _, dir := range pathsToCheck { // iterate over detected paths, build stacks and filter modules by working dir
-		dir = dir + filepath.FromSlash("/")
+		dir += filepath.FromSlash("/")
 		cfgOptions, err := options.NewTerragruntOptionsWithConfigPath(dir)
 		if err != nil {
 			terragruntOptions.Logger.Debugf("Failed to build terragrunt options from %s %v", dir, err)
-			return nil
+			continue
 		}
 
 		cfgOptions.Env = terragruntOptions.Env
 		cfgOptions.LogLevel = terragruntOptions.LogLevel
 		cfgOptions.OriginalTerragruntConfigPath = terragruntOptions.OriginalTerragruntConfigPath
 
-		if terragruntOptions.TerraformCommand == "destroy" {
-			var hook = NewForceLogLevelHook(logrus.DebugLevel)
-			cfgOptions.Logger.Logger.AddHook(hook)
-		}
+		var hook = NewForceLogLevelHook(logrus.DebugLevel)
+		cfgOptions.Logger.Logger.AddHook(hook)
 
 		stack, err := FindStackInSubfolders(cfgOptions, terragruntConfig)
 		if err != nil {
-			// loggign error as debug since in some cases stack building may fail because parent files can be designed
+			// log error as debug since in some cases stack building may fail because parent files can be designed
 			// to work with relative paths from downstream modules
 			terragruntOptions.Logger.Debugf("Failed to build module stack %v", err)
-			return nil
+			continue
 		}
 
 		for _, module := range stack.Modules {
@@ -632,3 +634,21 @@ func (formatter *LogEntriesDropperFormatter) Format(entry *logrus.Entry) ([]byte
 	}
 	return []byte(""), nil
 }
+
+// buildDirList - build list of directories from working directory to git top level directory
+func buildDirList(terragruntOptions *options.TerragruntOptions, topLevelDir string) ([]string, error) {
+	var pathsToCheck []string
+	relativePath, err := util.GetPathRelativeTo(topLevelDir, terragruntOptions.WorkingDir)
+	if err != nil {
+		return pathsToCheck, err
+	}
+	// build list of directories from working directory to git top level directory
+	// from which later will be built stacks
+	pathToAdd := terragruntOptions.WorkingDir
+	splits := strings.Split(relativePath, string(os.PathSeparator))
+	for _, path := range splits {
+		pathToAdd = filepath.Join(pathToAdd, path)
+		pathsToCheck = append(pathsToCheck, pathToAdd)
+	}
+	return pathsToCheck, nil
+}


Test patch:
diff --git a/config/config_as_cty_test.go b/config/config_as_cty_test.go
index 96ecb657c8..f7b8f83c48 100644
--- a/config/config_as_cty_test.go
+++ b/config/config_as_cty_test.go
@@ -21,6 +21,7 @@ func TestTerragruntConfigAsCtyDrift(t *testing.T) {
 	testFalse := false
 	mockOutputs := cty.Zero
 	mockOutputsAllowedTerraformCommands := []string{"init"}
+	dependentModulesPath := []*string{&testSource}
 	testConfig := TerragruntConfig{
 		Terraform: &TerraformConfig{
 			Source: &testSource,
@@ -77,6 +78,7 @@ func TestTerragruntConfigAsCtyDrift(t *testing.T) {
 		Locals: map[string]interface{}{
 			"quote": "the answer is 42",
 		},
+		DependentModulesPath: dependentModulesPath,
 		TerragruntDependencies: []Dependency{
 			Dependency{
 				Name:                                "foo",
@@ -221,6 +223,8 @@ func terragruntConfigStructFieldToMapKey(t *testing.T, fieldName string) (string
 		return "retry_max_attempts", true
 	case "RetrySleepIntervalSec":
 		return "retry_sleep_interval_sec", true
+	case "DependentModulesPath":
+		return "dependent_modules", true
 	default:
 		t.Fatalf("Unknown struct property: %s", fieldName)
 		// This should not execute
diff --git a/test/fixture-destroy-warning/app/main.tf b/test/fixture-destroy-warning/app-v1/main.tf
similarity index 100%
rename from test/fixture-destroy-warning/app/main.tf
rename to test/fixture-destroy-warning/app-v1/main.tf
diff --git a/test/fixture-destroy-warning/app/terragrunt.hcl b/test/fixture-destroy-warning/app-v1/terragrunt.hcl
similarity index 100%
rename from test/fixture-destroy-warning/app/terragrunt.hcl
rename to test/fixture-destroy-warning/app-v1/terragrunt.hcl
diff --git a/test/fixture-destroy-warning/app-v2/main.tf b/test/fixture-destroy-warning/app-v2/main.tf
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/test/fixture-destroy-warning/app-v2/terragrunt.hcl b/test/fixture-destroy-warning/app-v2/terragrunt.hcl
new file mode 100644
index 0000000000..ad5ab705ab
--- /dev/null
+++ b/test/fixture-destroy-warning/app-v2/terragrunt.hcl
@@ -0,0 +1,11 @@
+dependency "vpc" {
+  config_path = "../vpc"
+
+  mock_outputs = {
+    vpc = "mock"
+  }
+}
+
+dependencies {
+  paths = ["../vpc"]
+}
diff --git a/test/integration_test.go b/test/integration_test.go
index 8677936813..48fe6cb4b0 100644
--- a/test/integration_test.go
+++ b/test/integration_test.go
@@ -4758,7 +4758,8 @@ func TestShowWarningWithDependentModulesBeforeDestroy(t *testing.T) {
 
 	rootPath = util.JoinPath(rootPath, TEST_FIXTURE_DESTROY_WARNING)
 	vpcPath := util.JoinPath(rootPath, "vpc")
-	appPath := util.JoinPath(rootPath, "app")
+	appV1Path := util.JoinPath(rootPath, "app-v1")
+	appV2Path := util.JoinPath(rootPath, "app-v2")
 
 	cleanupTerraformFolder(t, rootPath)
 	cleanupTerraformFolder(t, vpcPath)
@@ -4779,7 +4780,8 @@ func TestShowWarningWithDependentModulesBeforeDestroy(t *testing.T) {
 	assert.NoError(t, err)
 
 	output := string(stderr.Bytes())
-	assert.Equal(t, 1, strings.Count(output, appPath))
+	assert.Equal(t, 1, strings.Count(output, appV1Path))
+	assert.Equal(t, 1, strings.Count(output, appV2Path))
 }
 
 func TestTerragruntOutputFromRemoteState(t *testing.T) {
@@ -6137,6 +6139,51 @@ func TestTerragruntHandleEmptyStateFile(t *testing.T) {
 	runTerragrunt(t, fmt.Sprintf("terragrunt apply -auto-approve --terragrunt-non-interactive --terragrunt-working-dir %s", testPath))
 }
 
+func TestRenderJsonDependentModulesTerraform(t *testing.T) {
+	t.Parallel()
+
+	tmpEnvPath := copyEnvironment(t, TEST_FIXTURE_DESTROY_WARNING)
+	cleanupTerraformFolder(t, tmpEnvPath)
+	tmpDir := util.JoinPath(tmpEnvPath, TEST_FIXTURE_DESTROY_WARNING, "vpc")
+
+	jsonOut := filepath.Join(tmpDir, "terragrunt_rendered.json")
+	runTerragrunt(t, fmt.Sprintf("terragrunt render-json --terragrunt-non-interactive --terragrunt-log-level debug --terragrunt-working-dir %s  --terragrunt-json-out %s", tmpDir, jsonOut))
+
+	jsonBytes, err := ioutil.ReadFile(jsonOut)
+	require.NoError(t, err)
+
+	var renderedJson = map[string]interface{}{}
+	require.NoError(t, json.Unmarshal(jsonBytes, &renderedJson))
+
+	var dependentModules = renderedJson[config.MetadataDependentModules].([]interface{})
+	// check if value list contains app-v1 and app-v2
+	assert.Contains(t, dependentModules, util.JoinPath(tmpEnvPath, TEST_FIXTURE_DESTROY_WARNING, "app-v1"))
+	assert.Contains(t, dependentModules, util.JoinPath(tmpEnvPath, TEST_FIXTURE_DESTROY_WARNING, "app-v2"))
+}
+
+func TestRenderJsonDependentModulesMetadataTerraform(t *testing.T) {
+	t.Parallel()
+
+	tmpEnvPath := copyEnvironment(t, TEST_FIXTURE_DESTROY_WARNING)
+	cleanupTerraformFolder(t, tmpEnvPath)
+	tmpDir := util.JoinPath(tmpEnvPath, TEST_FIXTURE_DESTROY_WARNING, "vpc")
+
+	jsonOut := filepath.Join(tmpDir, "terragrunt_rendered.json")
+	runTerragrunt(t, fmt.Sprintf("terragrunt render-json --with-metadata --terragrunt-non-interactive --terragrunt-log-level debug --terragrunt-working-dir %s  --terragrunt-json-out %s", tmpDir, jsonOut))
+
+	jsonBytes, err := ioutil.ReadFile(jsonOut)
+	require.NoError(t, err)
+
+	var renderedJson = map[string]map[string]interface{}{}
+
+	require.NoError(t, json.Unmarshal(jsonBytes, &renderedJson))
+
+	dependentModules := renderedJson[config.MetadataDependentModules]["value"].([]interface{})
+	// check if value list contains app-v1 and app-v2
+	assert.Contains(t, dependentModules, util.JoinPath(tmpEnvPath, TEST_FIXTURE_DESTROY_WARNING, "app-v1"))
+	assert.Contains(t, dependentModules, util.JoinPath(tmpEnvPath, TEST_FIXTURE_DESTROY_WARNING, "app-v2"))
+}
+
 func validateOutput(t *testing.T, outputs map[string]TerraformOutput, key string, value interface{}) {
 	t.Helper()
 	output, hasPlatform := outputs[key]