From f23a6b6b556a50778d650d9d71c20d208eac7abd Mon Sep 17 00:00:00 2001 From: Denis O Date: Tue, 19 Nov 2024 15:58:02 +0200 Subject: [PATCH] Exclude block (#3551) * Add exclude block parsing * add exclude block parsing * Exclude parsing * Handling excludes from includes * Add exclude config * Working exclude config parsing * Flag value conversion * Modules excluding * Updated log messages * Add exclusions handling * version check cleanup * Add handling of "all" actions * Linter fixes * Added handling of exclude output actions * linter update * Add exclude flag default * Add test for default exclusions * Test exclude disabled * Add test to exclude by action * Set exclude flag test * Dependencies exclusion changes * Added example documentation * Add test for outputs fetching * markdown lint fixes * Strict linter fixes * Integration test update * Exclude documentation update * imports fixes --- config/catalog.go | 5 + config/config.go | 13 +- config/config_as_cty.go | 40 ++++- config/config_as_cty_test.go | 3 + config/config_partial.go | 149 ++++++++++++----- config/cty_helpers.go | 14 ++ config/exclude.go | 137 +++++++++++++++ config/feature_flag.go | 82 +++++++++ config/include.go | 53 ++---- config/parsing_context.go | 3 + configstack/module.go | 30 ++++ configstack/stack.go | 18 +- .../config-blocks-and-attributes.md | 59 +++++++ .../exclude/action/exclude-apply/main.tf | 0 .../action/exclude-apply/terragrunt.hcl | 5 + .../exclude/action/exclude-plan/main.tf | 0 .../action/exclude-plan/terragrunt.hcl | 5 + .../exclude/all-except-output/app1/main.tf | 0 .../all-except-output/app1/terragrunt.hcl | 5 + .../exclude/all-except-output/app2/main.tf | 0 .../all-except-output/app2/terragrunt.hcl | 0 test/fixtures/exclude/default/app1/main.tf | 0 .../exclude/default/app1/terragrunt.hcl | 0 test/fixtures/exclude/default/app2/main.tf | 0 .../exclude/default/app2/terragrunt.hcl | 5 + .../exclude/dependencies/app1/main.tf | 0 .../exclude/dependencies/app1/terragrunt.hcl | 23 +++ .../fixtures/exclude/dependencies/dep/main.tf | 3 + .../exclude/dependencies/dep/terragrunt.hcl | 0 test/fixtures/exclude/disabled/app1/main.tf | 0 .../exclude/disabled/app1/terragrunt.hcl | 0 test/fixtures/exclude/disabled/app2/main.tf | 0 .../exclude/disabled/app2/terragrunt.hcl | 5 + .../exclude/feature-flags/app1/main.tf | 0 .../exclude/feature-flags/app1/terragrunt.hcl | 9 + .../exclude/feature-flags/app2/main.tf | 0 .../exclude/feature-flags/app2/terragrunt.hcl | 9 + test/fixtures/exclude/feature-flags/flags.hcl | 8 + test/integration_exclude_test.go | 158 ++++++++++++++++++ 39 files changed, 757 insertions(+), 84 deletions(-) create mode 100644 config/exclude.go create mode 100644 test/fixtures/exclude/action/exclude-apply/main.tf create mode 100644 test/fixtures/exclude/action/exclude-apply/terragrunt.hcl create mode 100644 test/fixtures/exclude/action/exclude-plan/main.tf create mode 100644 test/fixtures/exclude/action/exclude-plan/terragrunt.hcl create mode 100644 test/fixtures/exclude/all-except-output/app1/main.tf create mode 100644 test/fixtures/exclude/all-except-output/app1/terragrunt.hcl create mode 100644 test/fixtures/exclude/all-except-output/app2/main.tf create mode 100644 test/fixtures/exclude/all-except-output/app2/terragrunt.hcl create mode 100644 test/fixtures/exclude/default/app1/main.tf create mode 100644 test/fixtures/exclude/default/app1/terragrunt.hcl create mode 100644 test/fixtures/exclude/default/app2/main.tf create mode 100644 test/fixtures/exclude/default/app2/terragrunt.hcl create mode 100644 test/fixtures/exclude/dependencies/app1/main.tf create mode 100644 test/fixtures/exclude/dependencies/app1/terragrunt.hcl create mode 100644 test/fixtures/exclude/dependencies/dep/main.tf create mode 100644 test/fixtures/exclude/dependencies/dep/terragrunt.hcl create mode 100644 test/fixtures/exclude/disabled/app1/main.tf create mode 100644 test/fixtures/exclude/disabled/app1/terragrunt.hcl create mode 100644 test/fixtures/exclude/disabled/app2/main.tf create mode 100644 test/fixtures/exclude/disabled/app2/terragrunt.hcl create mode 100644 test/fixtures/exclude/feature-flags/app1/main.tf create mode 100644 test/fixtures/exclude/feature-flags/app1/terragrunt.hcl create mode 100644 test/fixtures/exclude/feature-flags/app2/main.tf create mode 100644 test/fixtures/exclude/feature-flags/app2/terragrunt.hcl create mode 100644 test/fixtures/exclude/feature-flags/flags.hcl create mode 100644 test/integration_exclude_test.go diff --git a/config/catalog.go b/config/catalog.go index 0e14bc4751..f70043cf63 100644 --- a/config/catalog.go +++ b/config/catalog.go @@ -167,6 +167,11 @@ func convertToTerragruntCatalogConfig(ctx *ParsingContext, configPath string, te terragruntConfig.SetFieldMetadata(MetadataEngine, defaultMetadata) } + if terragruntConfigFromFile.Exclude != nil { + terragruntConfig.Exclude = terragruntConfigFromFile.Exclude + terragruntConfig.SetFieldMetadata(MetadataExclude, defaultMetadata) + } + if ctx.Locals != nil && *ctx.Locals != cty.NilVal { // we should ignore any errors from `parseCtyValueToMap` as some `locals` values might have been incorrectly evaluated, that results to `json.Unmarshal` error. // for example if the locals block looks like `{"var1":, "var2":"value2"}`, `parseCtyValueToMap` returns the map with "var2" value and an syntax error, diff --git a/config/config.go b/config/config.go index e54e9218fc..8d67fbfb26 100644 --- a/config/config.go +++ b/config/config.go @@ -68,6 +68,7 @@ const ( MetadataDependentModules = "dependent_modules" MetadataInclude = "include" MetadataFeatureFlag = "feature" + MetadataExclude = "exclude" ) var ( @@ -123,6 +124,7 @@ type TerragruntConfig struct { RetrySleepIntervalSec *int Engine *EngineConfig FeatureFlags FeatureFlags + Exclude *ExcludeConfig // Fields used for internal tracking // Indicates whether this is the result of a partial evaluation @@ -194,6 +196,7 @@ type terragruntConfigFile struct { IamWebIdentityToken *string `hcl:"iam_web_identity_token,attr"` TerragruntDependencies []Dependency `hcl:"dependency,block"` FeatureFlags []*FeatureFlag `hcl:"feature,block"` + Exclude *ExcludeConfig `hcl:"exclude,block"` // We allow users to configure code generation via blocks: // @@ -887,7 +890,7 @@ func ParseConfig(ctx *ParsingContext, file *hclparse.File, includeFromChild *Inc return nil, err } - // If this file includes another, parse and merge it. Otherwise just return this config. + // If this file includes another, parse and merge it. Otherwise, just return this config. if ctx.TrackInclude != nil { mergedConfig, err := handleInclude(ctx, config, false) if err != nil { @@ -901,6 +904,7 @@ func ParseConfig(ctx *ParsingContext, file *hclparse.File, includeFromChild *Inc // original locals for the current config being handled, as that is the locals list that is in scope for this // config. mergedConfig.Locals = config.Locals + mergedConfig.Exclude = config.Exclude return mergedConfig, nil } @@ -1153,13 +1157,18 @@ func convertToTerragruntConfig(ctx *ParsingContext, configPath string, terragrun terragruntConfig.SetFieldMetadata(MetadataEngine, defaultMetadata) } - if terragruntConfig.FeatureFlags != nil { + if terragruntConfigFromFile.FeatureFlags != nil { terragruntConfig.FeatureFlags = terragruntConfigFromFile.FeatureFlags for _, flag := range terragruntConfig.FeatureFlags { terragruntConfig.SetFieldMetadataWithType(MetadataFeatureFlag, flag.Name, defaultMetadata) } } + if terragruntConfigFromFile.Exclude != nil { + terragruntConfig.Exclude = terragruntConfigFromFile.Exclude + terragruntConfig.SetFieldMetadata(MetadataExclude, defaultMetadata) + } + generateBlocks := []terragruntGenerateBlock{} generateBlocks = append(generateBlocks, terragruntConfigFromFile.GenerateBlocks...) diff --git a/config/config_as_cty.go b/config/config_as_cty.go index d156aff97b..554efd87d8 100644 --- a/config/config_as_cty.go +++ b/config/config_as_cty.go @@ -50,6 +50,15 @@ func TerragruntConfigAsCty(config *TerragruntConfig) (cty.Value, error) { output[MetadataEngine] = engineConfigCty } + excludeConfigCty, err := excludeConfigAsCty(config.Exclude) + if err != nil { + return cty.NilVal, err + } + + if excludeConfigCty != cty.NilVal { + output[MetadataExclude] = excludeConfigCty + } + terraformConfigCty, err := terraformConfigAsCty(config.Terraform) if err != nil { return cty.NilVal, err @@ -461,6 +470,13 @@ type ctyEngineConfig struct { Meta cty.Value `cty:"meta"` } +// ctyExclude exclude representation for cty. +type ctyExclude struct { + If bool `cty:"if"` + Actions []string `cty:"actions"` + ExcludeDependencies bool `cty:"exclude_dependencies"` +} + // Serialize CatalogConfig to a cty Value, but with maps instead of lists for the blocks. func catalogConfigAsCty(config *CatalogConfig) (cty.Value, error) { if config == nil { @@ -504,6 +520,26 @@ func engineConfigAsCty(config *EngineConfig) (cty.Value, error) { return goTypeToCty(configCty) } +// excludeConfigAsCty serialize exclude configuration to a cty Value. +func excludeConfigAsCty(config *ExcludeConfig) (cty.Value, error) { + if config == nil { + return cty.NilVal, nil + } + + excludeDependencies := false + if config.ExcludeDependencies != nil { + excludeDependencies = *config.ExcludeDependencies + } + + configCty := ctyExclude{ + If: config.If, + Actions: config.Actions, + ExcludeDependencies: excludeDependencies, + } + + return goTypeToCty(configCty) +} + // CtyTerraformConfig is an alternate representation of TerraformConfig that converts internal blocks into a map that // maps the name to the underlying struct, as opposed to a list representation. type CtyTerraformConfig struct { @@ -598,10 +634,10 @@ func dependencyBlocksAsCty(dependencyBlocks Dependencies) (cty.Value, error) { } // Serialize the list of feature flags to a cty Value as a map that maps the feature names to the cty representation. -func featureFlagsBlocksAsCty(dependencyBlocks FeatureFlags) (cty.Value, error) { +func featureFlagsBlocksAsCty(featureFlagBlocks FeatureFlags) (cty.Value, error) { out := map[string]cty.Value{} - for _, feature := range dependencyBlocks { + for _, feature := range featureFlagBlocks { featureCty, err := goTypeToCty(feature) if err != nil { return cty.NilVal, err diff --git a/config/config_as_cty_test.go b/config/config_as_cty_test.go index ebeab23883..aa0b3d01e4 100644 --- a/config/config_as_cty_test.go +++ b/config/config_as_cty_test.go @@ -119,6 +119,7 @@ func TestTerragruntConfigAsCtyDrift(t *testing.T) { }`, }, }, + Exclude: &config.ExcludeConfig{}, } ctyVal, err := config.TerragruntConfigAsCty(&testConfig) require.NoError(t, err) @@ -257,6 +258,8 @@ func terragruntConfigStructFieldToMapKey(t *testing.T, fieldName string) (string return "engine", true case "FeatureFlags": return "feature", true + case "Exclude": + return "exclude", true default: t.Fatalf("Unknown struct property: %s", fieldName) // This should not execute diff --git a/config/config_partial.go b/config/config_partial.go index 0fff87b50a..f5fc15a92a 100644 --- a/config/config_partial.go +++ b/config/config_partial.go @@ -33,6 +33,7 @@ const ( TerragruntVersionConstraints RemoteStateBlock FeatureFlagsBlock + ExcludeBlock ) // terragruntIncludeMultiple is a struct that can be used to only decode the include block with labels. @@ -53,6 +54,12 @@ type terragruntFeatureFlags struct { Remain hcl.Body `hcl:",remain"` } +// terragruntExclude is a struct that can be used to only decode the exclude block. +type terragruntExclude struct { + Exclude *ExcludeConfig `hcl:"exclude,block"` + Remain hcl.Body `hcl:",remain"` +} + // terragruntTerraform is a struct that can be used to only decode the terraform block. type terragruntTerraform struct { Terraform *TerraformConfig `hcl:"terraform,block"` @@ -141,25 +148,69 @@ func DecodeBaseBlocks(ctx *ParsingContext, file *hclparse.File, includeFromChild return nil, err } + flagsAsCtyVal, err := flagsAsCty(ctx, tgFlags.FeatureFlags) + if err != nil { + return nil, err + } + + // Evaluate all the expressions in the locals block separately and generate the variables list to use in the + // evaluation ctx. + locals, err := EvaluateLocalsBlock(ctx.WithTrackInclude(trackInclude).WithFeatures(&flagsAsCtyVal), file) + if err != nil { + return nil, err + } + + localsAsCtyVal, err := convertValuesMapToCtyVal(locals) + if err != nil { + return nil, err + } + + return &DecodedBaseBlocks{ + TrackInclude: trackInclude, + Locals: &localsAsCtyVal, + FeatureFlags: &flagsAsCtyVal, + }, nil +} + +func flagsAsCty(ctx *ParsingContext, tgFlags FeatureFlags) (cty.Value, error) { evaluatedFlags := map[string]cty.Value{} - // copy default feature flags to evaluated flags + // extract all flags in map by name + flagByName := map[string]*FeatureFlag{} + for _, flag := range tgFlags { + flagByName[flag.Name] = flag + } for name, value := range ctx.TerragruntOptions.FeatureFlags { - evaluatedFlag, err := flagToCtyValue(name, value) + // convert flag value to respective type + var evaluatedFlag cty.Value - if err != nil { - return nil, err + existingFlag, exists := flagByName[name] + + if exists { + flag, err := flagToTypedCtyValue(name, existingFlag.Default.Type(), value) + if err != nil { + return cty.NilVal, err + } + + evaluatedFlag = flag + } else { + flag, err := flagToCtyValue(name, value) + if err != nil { + return cty.NilVal, err + } + + evaluatedFlag = flag } evaluatedFlags[name] = evaluatedFlag } - for _, flag := range tgFlags.FeatureFlags { + for _, flag := range tgFlags { if _, exists := evaluatedFlags[flag.Name]; !exists { contextFlag, err := flagToCtyValue(flag.Name, *flag.Default) if err != nil { - return nil, err + return cty.NilVal, err } evaluatedFlags[flag.Name] = contextFlag @@ -169,26 +220,10 @@ func DecodeBaseBlocks(ctx *ParsingContext, file *hclparse.File, includeFromChild flagsAsCtyVal, err := convertValuesMapToCtyVal(evaluatedFlags) if err != nil { - return nil, err - } - - // Evaluate all the expressions in the locals block separately and generate the variables list to use in the - // evaluation ctx. - locals, err := EvaluateLocalsBlock(ctx.WithTrackInclude(trackInclude).WithFeatures(&flagsAsCtyVal), file) - if err != nil { - return nil, err - } - - localsAsCtyVal, err := convertValuesMapToCtyVal(locals) - if err != nil { - return nil, err + return cty.NilVal, err } - return &DecodedBaseBlocks{ - TrackInclude: trackInclude, - Locals: &localsAsCtyVal, - FeatureFlags: &flagsAsCtyVal, - }, nil + return flagsAsCtyVal, nil } func PartialParseConfigFile(ctx *ParsingContext, configPath string, include *IncludeConfig) (*TerragruntConfig, error) { @@ -258,6 +293,8 @@ func TerragruntConfigFromPartialConfig(ctx *ParsingContext, file *hclparse.File, // - TerragruntVersionConstraints: Parses the attributes related to constraining terragrunt and terraform versions in // the config. // - RemoteStateBlock: Parses the `remote_state` block in the config +// - FeatureFlagsBlock: Parses the `feature` block in the config +// - ExcludeBlock : Parses the `exclude` block in the config // // Note that the following blocks are always decoded: // - locals @@ -473,10 +510,31 @@ func PartialParseConfig(ctx *ParsingContext, file *hclparse.File, includeFromChi return nil, err } - if decoded.FeatureFlags != nil { + if output.FeatureFlags != nil { + flags, err := deepMergeFeatureBlocks(output.FeatureFlags, decoded.FeatureFlags) + if err != nil { + return nil, err + } + + output.FeatureFlags = flags + } else { output.FeatureFlags = decoded.FeatureFlags } + case ExcludeBlock: + decoded := terragruntExclude{} + err := file.Decode(&decoded, evalParsingContext) + + if err != nil { + return nil, err + } + + if output.Exclude != nil { + output.Exclude.Merge(decoded.Exclude) + } else { + output.Exclude = decoded.Exclude + } + default: return nil, InvalidPartialBlockName{decode} } @@ -491,10 +549,35 @@ func PartialParseConfig(ctx *ParsingContext, file *hclparse.File, includeFromChi // Saving processed includes into configuration, direct assignment since nested includes aren't supported config.ProcessedIncludes = ctx.TrackInclude.CurrentMap + output = config + } + + return processExcludes(ctx, output, file) +} + +// processExcludes evaluate exclude blocks and merge them into the config. +func processExcludes(ctx *ParsingContext, config *TerragruntConfig, file *hclparse.File) (*TerragruntConfig, error) { + flagsAsCtyVal, err := flagsAsCty(ctx, config.FeatureFlags) + if err != nil { + return nil, err + } + + excludeConfig, err := evaluateExcludeBlocks(ctx.WithFeatures(&flagsAsCtyVal), file) + if err != nil { + return nil, err + } + + if excludeConfig == nil { return config, nil } - return output, nil + if config.Exclude != nil { + config.Exclude.Merge(excludeConfig) + } else { + config.Exclude = excludeConfig + } + + return config, nil } func partialParseIncludedConfig(ctx *ParsingContext, includedConfig *IncludeConfig) (*TerragruntConfig, error) { @@ -528,20 +611,6 @@ func decodeAsTerragruntInclude(file *hclparse.File, evalParsingContext *hcl.Eval return tgInc.Include, nil } -func flagToCtyValue(name string, value interface{}) (cty.Value, error) { - ctyValue, err := goTypeToCty(value) - if err != nil { - return cty.NilVal, err - } - - ctyFlag := ctyFeatureFlag{ - Name: name, - Value: ctyValue, - } - - return goTypeToCty(ctyFlag) -} - // Custom error types type InvalidPartialBlockName struct { diff --git a/config/cty_helpers.go b/config/cty_helpers.go index 2863522507..9367409717 100644 --- a/config/cty_helpers.go +++ b/config/cty_helpers.go @@ -394,3 +394,17 @@ func UpdateUnknownCtyValValues(value cty.Value) (cty.Value, error) { return value, nil } + +// CtyToStruct converts a cty.Value to a go struct. +func CtyToStruct(ctyValue cty.Value, target interface{}) error { + jsonBytes, err := ctyjson.Marshal(ctyValue, ctyValue.Type()) + if err != nil { + return errors.New(err) + } + + if err := json.Unmarshal(jsonBytes, target); err != nil { + return errors.New(err) + } + + return nil +} diff --git a/config/exclude.go b/config/exclude.go new file mode 100644 index 0000000000..63097b58d9 --- /dev/null +++ b/config/exclude.go @@ -0,0 +1,137 @@ +package config + +import ( + "strconv" + "strings" + + "github.com/gruntwork-io/terragrunt/config/hclparse" + "github.com/gruntwork-io/terragrunt/internal/errors" + "github.com/zclconf/go-cty/cty" +) + +const ( + allActions = "all" // handle all actions + allExcludeOutputActions = "all_except_output" // handle all exclude output actions + tgOutput = "output" +) + +// bool values to be used as booleans. +var boolFlagValues = []string{"if", "exclude_dependencies"} + +// ExcludeConfig configurations for hcl files. +type ExcludeConfig struct { + If bool `cty:"if" hcl:"if,attr" json:"if"` + Actions []string `cty:"actions" hcl:"actions,attr" json:"actions"` + ExcludeDependencies *bool `cty:"exclude_dependencies" hcl:"exclude_dependencies,attr" json:"exclude_dependencies"` +} + +// IsActionListed checks if the action is listed in the exclude block. +func (e *ExcludeConfig) IsActionListed(action string) bool { + if len(e.Actions) == 0 { + return false + } + + for _, checkAction := range e.Actions { + if checkAction == allActions { // if actions contains all, return true in all cases + return true + } + + if checkAction == allExcludeOutputActions && action != tgOutput { + return true + } + + if checkAction == strings.ToLower(action) { + return true + } + } + + return false +} + +// Clone returns a new instance of ExcludeConfig with the same values as the original. +func (e *ExcludeConfig) Clone() *ExcludeConfig { + return &ExcludeConfig{ + If: e.If, + Actions: e.Actions, + ExcludeDependencies: e.ExcludeDependencies, + } +} + +// Merge merges the values of the provided ExcludeConfig into the original. +func (e *ExcludeConfig) Merge(exclude *ExcludeConfig) { + // copy not empty fields + e.If = exclude.If + if len(exclude.Actions) > 0 { + e.Actions = exclude.Actions + } + + e.ExcludeDependencies = exclude.ExcludeDependencies +} + +// evaluateExcludeBlocks evaluates the exclude block in the parsed file. +func evaluateExcludeBlocks(ctx *ParsingContext, file *hclparse.File) (*ExcludeConfig, error) { + excludeBlock, err := file.Blocks(MetadataExclude, false) + if err != nil { + return nil, err + } + + if len(excludeBlock) == 0 { + return nil, nil + } + + if len(excludeBlock) > 1 { + // only one block allowed + return nil, errors.Errorf("Only one %s block is allowed found multiple in %s", MetadataExclude, file.ConfigPath) + } + + attrs, err := excludeBlock[0].JustAttributes() + if err != nil { + ctx.TerragruntOptions.Logger.Debugf("Encountered error while decoding exclude block.") + return nil, err + } + + evalCtx, err := createTerragruntEvalContext(ctx, file.ConfigPath) + if err != nil { + ctx.TerragruntOptions.Logger.Errorf("Failed to create eval context %s", file.ConfigPath) + return nil, err + } + + evaluatedAttrs := map[string]cty.Value{} + + for _, attr := range attrs { + value, err := attr.Value(evalCtx) + if err != nil { + ctx.TerragruntOptions.Logger.Debugf("Encountered error while evaluating exclude block in file %s", file.ConfigPath) + + return nil, err + } + + evaluatedAttrs[attr.Name] = value + } + + for _, boolFlag := range boolFlagValues { + if value, ok := evaluatedAttrs[boolFlag]; ok { + if value.Type() == cty.String { // handle bool flag value + val, err := strconv.ParseBool(value.AsString()) + if err != nil { + return nil, errors.New(err) + } + + evaluatedAttrs[boolFlag] = cty.BoolVal(val) + } + } + } + + excludeAsCtyVal, err := convertValuesMapToCtyVal(evaluatedAttrs) + if err != nil { + return nil, err + } + + // convert cty map to ExcludeConfig + excludeConfig := &ExcludeConfig{} + if err := CtyToStruct(excludeAsCtyVal, excludeConfig); err != nil { + return nil, errors.Unwrap(err) + } + + return excludeConfig, nil +} diff --git a/config/feature_flag.go b/config/feature_flag.go index 55887e8434..0970d0f0d3 100644 --- a/config/feature_flag.go +++ b/config/feature_flag.go @@ -1,6 +1,9 @@ package config import ( + "fmt" + "strconv" + "github.com/pkg/errors" "github.com/zclconf/go-cty/cty" ctyjson "github.com/zclconf/go-cty/cty/json" @@ -41,6 +44,44 @@ func (feature *FeatureFlag) DeepMerge(source *FeatureFlag) error { return nil } +// DeepMerge feature flags. +func deepMergeFeatureBlocks(targetFeatureFlags []*FeatureFlag, sourceFeatureFlags []*FeatureFlag) ([]*FeatureFlag, error) { + if sourceFeatureFlags == nil && targetFeatureFlags == nil { + return nil, nil + } + + keys := make([]string, 0, len(targetFeatureFlags)) + + featureBlocks := make(map[string]*FeatureFlag) + + for _, flag := range targetFeatureFlags { + featureBlocks[flag.Name] = flag + keys = append(keys, flag.Name) + } + + for _, flag := range sourceFeatureFlags { + sameKeyDep, hasSameKey := featureBlocks[flag.Name] + if hasSameKey { + sameKeyFlagPtr := sameKeyDep + if err := sameKeyFlagPtr.DeepMerge(flag); err != nil { + return nil, err + } + + featureBlocks[flag.Name] = sameKeyFlagPtr + } else { + featureBlocks[flag.Name] = flag + keys = append(keys, flag.Name) + } + } + + combinedFlags := make([]*FeatureFlag, 0, len(keys)) + for _, key := range keys { + combinedFlags = append(combinedFlags, featureBlocks[key]) + } + + return combinedFlags, nil +} + // DefaultAsString returns the default value of the feature flag as a string. func (feature *FeatureFlag) DefaultAsString() (string, error) { if feature.Default == nil { @@ -59,3 +100,44 @@ func (feature *FeatureFlag) DefaultAsString() (string, error) { return string(jsonBytes), nil } + +// Convert generic flag value to cty.Value. +func flagToCtyValue(name string, value interface{}) (cty.Value, error) { + ctyValue, err := goTypeToCty(value) + if err != nil { + return cty.NilVal, err + } + + ctyFlag := ctyFeatureFlag{ + Name: name, + Value: ctyValue, + } + + return goTypeToCty(ctyFlag) +} + +// Convert a flag to a cty.Value using the provided cty.Type. +func flagToTypedCtyValue(name string, ctyType cty.Type, value interface{}) (cty.Value, error) { + var flagValue = value + if ctyType == cty.Bool { + // convert value to boolean even if it is string + parsedValue, err := strconv.ParseBool(fmt.Sprintf("%v", flagValue)) + if err != nil { + return cty.NilVal, errors.WithStack(err) + } + + flagValue = parsedValue + } + + ctyOut, err := goTypeToCty(flagValue) + if err != nil { + return cty.NilVal, errors.WithStack(err) + } + + ctyFlag := ctyFeatureFlag{ + Name: name, + Value: ctyOut, + } + + return goTypeToCty(ctyFlag) +} diff --git a/config/include.go b/config/include.go index c7e555b688..ad531f1d02 100644 --- a/config/include.go +++ b/config/include.go @@ -180,7 +180,8 @@ func handleIncludeForDependency(ctx *ParsingContext, childDecodedDependency Terr return nil, err } - includedPartialParse, err := partialParseIncludedConfig(ctx.WithDecodeList(DependencyBlock, FeatureFlagsBlock), &includeConfig) + includedPartialParse, err := partialParseIncludedConfig( + ctx.WithDecodeList(DependencyBlock, FeatureFlagsBlock, ExcludeBlock), &includeConfig) if err != nil { return nil, err } @@ -266,6 +267,10 @@ func (cfg *TerragruntConfig) Merge(sourceConfig *TerragruntConfig, terragruntOpt cfg.Skip = sourceConfig.Skip } + if sourceConfig.Exclude != nil { + cfg.Exclude = sourceConfig.Exclude.Clone() + } + if sourceConfig.RemoteState != nil { cfg.RemoteState = sourceConfig.RemoteState } @@ -390,6 +395,14 @@ func (cfg *TerragruntConfig) DeepMerge(sourceConfig *TerragruntConfig, terragrun cfg.Engine.Merge(sourceConfig.Engine) } + if sourceConfig.Exclude != nil { + if cfg.Exclude == nil { + cfg.Exclude = &ExcludeConfig{} + } + + cfg.Exclude.Merge(sourceConfig.Exclude) + } + if sourceConfig.Skip != nil { cfg.Skip = sourceConfig.Skip } @@ -626,44 +639,6 @@ func deepMergeDependencyBlocks(targetDependencies []Dependency, sourceDependenci return combinedDeps, nil } -// DeepMerge feature flags. -func deepMergeFeatureBlocks(targetFeatureFlags []*FeatureFlag, sourceFeatureFlags []*FeatureFlag) ([]*FeatureFlag, error) { - if sourceFeatureFlags == nil && targetFeatureFlags == nil { - return nil, nil - } - - keys := make([]string, 0, len(targetFeatureFlags)) - - featureBlocks := make(map[string]*FeatureFlag) - - for _, flag := range targetFeatureFlags { - featureBlocks[flag.Name] = flag - keys = append(keys, flag.Name) - } - - for _, flag := range sourceFeatureFlags { - sameKeyDep, hasSameKey := featureBlocks[flag.Name] - if hasSameKey { - sameKeyFlagPtr := sameKeyDep - if err := sameKeyFlagPtr.DeepMerge(flag); err != nil { - return nil, err - } - - featureBlocks[flag.Name] = sameKeyFlagPtr - } else { - featureBlocks[flag.Name] = flag - keys = append(keys, flag.Name) - } - } - - combinedFlags := make([]*FeatureFlag, 0, len(keys)) - for _, key := range keys { - combinedFlags = append(combinedFlags, featureBlocks[key]) - } - - return combinedFlags, nil -} - // Merge the extra arguments. // // If a child's extra_arguments has the same name a parent's extra_arguments, diff --git a/config/parsing_context.go b/config/parsing_context.go index e55f60b0ec..f9ee78f2bd 100644 --- a/config/parsing_context.go +++ b/config/parsing_context.go @@ -27,6 +27,9 @@ type ParsingContext struct { // Features are the feature flags that are enabled for the current terragrunt config. Features *cty.Value + // Exclude is the configuration for excluding certain terragrunt configurations. + Exclude *cty.Value + // DecodedDependencies are references of other terragrunt config. This contains the following attributes that map to // various fields related to that config: // - outputs: The map of outputs from the terraform state obtained by running `terragrunt output` on that target config. diff --git a/configstack/module.go b/configstack/module.go index ceb1e4bece..e104e3ed41 100644 --- a/configstack/module.go +++ b/configstack/module.go @@ -498,6 +498,36 @@ func (modules TerraformModules) flagUnitsThatAreIncluded(opts *options.Terragrun return modules, nil } +// flagExcludedUnits iterates over a module slice and flags all modules that are excluded based on the exclude block. +func (modules TerraformModules) flagExcludedUnits(opts *options.TerragruntOptions) TerraformModules { + for _, module := range modules { + excludeConfig := module.Config.Exclude + + if excludeConfig == nil { + continue + } + + if !excludeConfig.IsActionListed(opts.TerraformCommand) { + continue + } + + if excludeConfig.If { + opts.Logger.Debugf("Module %s is excluded by exclude block", module.Path) + module.FlagExcluded = true + } + + if excludeConfig.ExcludeDependencies != nil && *excludeConfig.ExcludeDependencies { + opts.Logger.Debugf("Excluding dependencies for module %s by exclude block", module.Path) + + for _, dependency := range module.Dependencies { + dependency.FlagExcluded = true + } + } + } + + return modules +} + // flagUnitsThatRead iterates over a module slice and flags all modules that read at least one file in the specified // file list in the TerragruntOptions UnitsReading attribute. func (modules TerraformModules) flagUnitsThatRead(opts *options.TerragruntOptions) (TerraformModules, error) { diff --git a/configstack/stack.go b/configstack/stack.go index 0f99a72e2f..33f6bb27be 100644 --- a/configstack/stack.go +++ b/configstack/stack.go @@ -431,12 +431,27 @@ func (stack *Stack) ResolveTerraformModules(ctx context.Context, terragruntConfi return nil, err } + var withExcludedUnits TerraformModules + + err = telemetry.Telemetry(ctx, stack.terragruntOptions, "flag_excluded_units", map[string]interface{}{ + "working_dir": stack.terragruntOptions.WorkingDir, + }, func(childCtx context.Context) error { + result := withUnitsThatAreIncludedByOthers.flagExcludedUnits(stack.terragruntOptions) + withExcludedUnits = result + + return nil + }) + + if err != nil { + return nil, err + } + var withUnitsRead TerraformModules err = telemetry.Telemetry(ctx, stack.terragruntOptions, "flag_units_that_read", map[string]interface{}{ "working_dir": stack.terragruntOptions.WorkingDir, }, func(childCtx context.Context) error { - result, err := withUnitsThatAreIncludedByOthers.flagUnitsThatRead(stack.terragruntOptions) + result, err := withExcludedUnits.flagUnitsThatRead(stack.terragruntOptions) if err != nil { return err } @@ -577,6 +592,7 @@ func (stack *Stack) resolveTerraformModule(ctx context.Context, terragruntConfig // Need for parsing out the dependencies config.DependenciesBlock, config.DependencyBlock, + config.FeatureFlagsBlock, ) // Credentials have to be acquired before the config is parsed, as the config may contain interpolation functions diff --git a/docs/_docs/04_reference/config-blocks-and-attributes.md b/docs/_docs/04_reference/config-blocks-and-attributes.md index ecb6cee599..bbc66823f2 100644 --- a/docs/_docs/04_reference/config-blocks-and-attributes.md +++ b/docs/_docs/04_reference/config-blocks-and-attributes.md @@ -31,6 +31,7 @@ The following is a reference of all the supported blocks and attributes in the c - [generate](#generate) - [engine](#engine) - [feature](#feature) + - [exclude](#exclude) - [Attributes](#attributes) - [inputs](#inputs) - [download\_dir](#download_dir) @@ -1214,6 +1215,64 @@ export TERRAGRUNT_FEATURE=run_hook=true,string_flag=dev terragrunt apply ``` +### exclude + +The `exclude` block in Terragrunt provides advanced configuration options to dynamically determine when and how specific +units in the Terragrunt dependency graph are excluded. This feature allows for fine-grained control over which actions +are executed and can conditionally exclude dependencies. + +Syntax: + +```hcl +exclude { + if = # Boolean expression to determine exclusion. + actions = ["", ...] # List of actions to exclude (e.g., "plan", "apply", "all", "all_except_output"). + exclude_dependencies = # Boolean to determine if dependencies should also be excluded. +} +``` + +Attributes: + +| Attribute | Type | Description | +|------------------------|--------------------|-------------------------------------------------------------------------------------------------------------------------| +| `if` | Boolean Expression | A condition to dynamically determine whether the unit should be excluded. | +| `actions` | List of Strings | Specifies which actions to exclude when the condition is met. Options: `plan`, `apply`, `all`, `all_except_output` etc. | +| `exclude_dependencies` | Boolean | Indicates whether the dependencies of the excluded unit should also be excluded. By default set as `false` | + +Examples: + +```hcl +exclude { + if = feature.feature_name.value # Dynamically exclude based on a feature flag. + actions = ["plan", "apply"] # Exclude `plan` and `apply` actions. + exclude_dependencies = false # Do not exclude dependencies. +} +``` + +In this example, the unit is excluded for the `plan` and `apply` actions only when `feature.feature_name.value` +evaluates to `true`. Dependencies are not excluded. + +```hcl +exclude { + if = feature.is_dev_environment.value # Exclude only for development environments. + actions = ["all"] # Exclude all actions. + exclude_dependencies = true # Exclude dependencies along with the unit. +} +``` + +This configuration ensures the unit and its dependencies are excluded from all actions in the Terragrunt graph when the +feature `is_dev_environment` evaluates to `true`. + +```hcl +exclude { + if = true # Dynamically exclude based on a variable. + actions = ["all_except_output"] # Allow `output` actions while excluding others. + exclude_dependencies = false # Dependencies remain active. +} +``` + +This setup is useful for scenarios where output evaluation is still needed, even if other actions like `plan` or `apply` are excluded. + ## Attributes - [Blocks](#blocks) diff --git a/test/fixtures/exclude/action/exclude-apply/main.tf b/test/fixtures/exclude/action/exclude-apply/main.tf new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/fixtures/exclude/action/exclude-apply/terragrunt.hcl b/test/fixtures/exclude/action/exclude-apply/terragrunt.hcl new file mode 100644 index 0000000000..05678883b6 --- /dev/null +++ b/test/fixtures/exclude/action/exclude-apply/terragrunt.hcl @@ -0,0 +1,5 @@ +exclude { + if = true + actions = ["apply"] + exclude_dependencies = true +} \ No newline at end of file diff --git a/test/fixtures/exclude/action/exclude-plan/main.tf b/test/fixtures/exclude/action/exclude-plan/main.tf new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/fixtures/exclude/action/exclude-plan/terragrunt.hcl b/test/fixtures/exclude/action/exclude-plan/terragrunt.hcl new file mode 100644 index 0000000000..1204dc9383 --- /dev/null +++ b/test/fixtures/exclude/action/exclude-plan/terragrunt.hcl @@ -0,0 +1,5 @@ +exclude { + if = true + actions = ["plan"] + exclude_dependencies = true +} \ No newline at end of file diff --git a/test/fixtures/exclude/all-except-output/app1/main.tf b/test/fixtures/exclude/all-except-output/app1/main.tf new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/fixtures/exclude/all-except-output/app1/terragrunt.hcl b/test/fixtures/exclude/all-except-output/app1/terragrunt.hcl new file mode 100644 index 0000000000..f2120401f1 --- /dev/null +++ b/test/fixtures/exclude/all-except-output/app1/terragrunt.hcl @@ -0,0 +1,5 @@ +exclude { + if = true + actions = ["all_except_output"] + exclude_dependencies = true +} \ No newline at end of file diff --git a/test/fixtures/exclude/all-except-output/app2/main.tf b/test/fixtures/exclude/all-except-output/app2/main.tf new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/fixtures/exclude/all-except-output/app2/terragrunt.hcl b/test/fixtures/exclude/all-except-output/app2/terragrunt.hcl new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/fixtures/exclude/default/app1/main.tf b/test/fixtures/exclude/default/app1/main.tf new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/fixtures/exclude/default/app1/terragrunt.hcl b/test/fixtures/exclude/default/app1/terragrunt.hcl new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/fixtures/exclude/default/app2/main.tf b/test/fixtures/exclude/default/app2/main.tf new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/fixtures/exclude/default/app2/terragrunt.hcl b/test/fixtures/exclude/default/app2/terragrunt.hcl new file mode 100644 index 0000000000..1cb339055d --- /dev/null +++ b/test/fixtures/exclude/default/app2/terragrunt.hcl @@ -0,0 +1,5 @@ +exclude { + if = true + actions = ["all"] + exclude_dependencies = true +} \ No newline at end of file diff --git a/test/fixtures/exclude/dependencies/app1/main.tf b/test/fixtures/exclude/dependencies/app1/main.tf new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/fixtures/exclude/dependencies/app1/terragrunt.hcl b/test/fixtures/exclude/dependencies/app1/terragrunt.hcl new file mode 100644 index 0000000000..e166c9b9ca --- /dev/null +++ b/test/fixtures/exclude/dependencies/app1/terragrunt.hcl @@ -0,0 +1,23 @@ + +feature "exclude" { + default = false +} + +feature "exclude_dependencies" { + default = false +} + +exclude { + if = feature.exclude.value + actions = ["all"] + exclude_dependencies = feature.exclude_dependencies.value +} + +dependency "dep" { + config_path = "../dep" + + mock_outputs = { + data1 = "mock" + } + +} diff --git a/test/fixtures/exclude/dependencies/dep/main.tf b/test/fixtures/exclude/dependencies/dep/main.tf new file mode 100644 index 0000000000..ad5c7a68cd --- /dev/null +++ b/test/fixtures/exclude/dependencies/dep/main.tf @@ -0,0 +1,3 @@ +output "data1" { + value = "data1" +} \ No newline at end of file diff --git a/test/fixtures/exclude/dependencies/dep/terragrunt.hcl b/test/fixtures/exclude/dependencies/dep/terragrunt.hcl new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/fixtures/exclude/disabled/app1/main.tf b/test/fixtures/exclude/disabled/app1/main.tf new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/fixtures/exclude/disabled/app1/terragrunt.hcl b/test/fixtures/exclude/disabled/app1/terragrunt.hcl new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/fixtures/exclude/disabled/app2/main.tf b/test/fixtures/exclude/disabled/app2/main.tf new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/fixtures/exclude/disabled/app2/terragrunt.hcl b/test/fixtures/exclude/disabled/app2/terragrunt.hcl new file mode 100644 index 0000000000..ab49c62122 --- /dev/null +++ b/test/fixtures/exclude/disabled/app2/terragrunt.hcl @@ -0,0 +1,5 @@ +exclude { + if = false + actions = ["all"] + exclude_dependencies = true +} \ No newline at end of file diff --git a/test/fixtures/exclude/feature-flags/app1/main.tf b/test/fixtures/exclude/feature-flags/app1/main.tf new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/fixtures/exclude/feature-flags/app1/terragrunt.hcl b/test/fixtures/exclude/feature-flags/app1/terragrunt.hcl new file mode 100644 index 0000000000..e2e8e3ed3d --- /dev/null +++ b/test/fixtures/exclude/feature-flags/app1/terragrunt.hcl @@ -0,0 +1,9 @@ +include "flags" { + path = find_in_parent_folders("flags.hcl") +} + +exclude { + if = feature.exclude1.value + actions = ["all"] + exclude_dependencies = true +} diff --git a/test/fixtures/exclude/feature-flags/app2/main.tf b/test/fixtures/exclude/feature-flags/app2/main.tf new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/fixtures/exclude/feature-flags/app2/terragrunt.hcl b/test/fixtures/exclude/feature-flags/app2/terragrunt.hcl new file mode 100644 index 0000000000..d27ed5bafd --- /dev/null +++ b/test/fixtures/exclude/feature-flags/app2/terragrunt.hcl @@ -0,0 +1,9 @@ +include "flags" { + path = find_in_parent_folders("flags.hcl") +} + +exclude { + if = feature.exclude2.value + actions = ["all"] + exclude_dependencies = true +} diff --git a/test/fixtures/exclude/feature-flags/flags.hcl b/test/fixtures/exclude/feature-flags/flags.hcl new file mode 100644 index 0000000000..3bafe75987 --- /dev/null +++ b/test/fixtures/exclude/feature-flags/flags.hcl @@ -0,0 +1,8 @@ + +feature "exclude1" { + default = false +} + +feature "exclude2" { + default = true +} diff --git a/test/integration_exclude_test.go b/test/integration_exclude_test.go new file mode 100644 index 0000000000..720c80f518 --- /dev/null +++ b/test/integration_exclude_test.go @@ -0,0 +1,158 @@ +package test_test + +import ( + "github.com/gruntwork-io/terragrunt/test/helpers" + "github.com/gruntwork-io/terragrunt/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "testing" +) + +const ( + testExcludeByDefault = "fixtures/exclude/default" + testExcludeDisabled = "fixtures/exclude/disabled" + testExcludeByAction = "fixtures/exclude/action" + testExcludeByFlags = "fixtures/exclude/feature-flags" + testExcludeDependencies = "fixtures/exclude/dependencies" + testExcludeAllExceptOutput = "fixtures/exclude/all-except-output" +) + +func TestExcludeByDefault(t *testing.T) { + t.Parallel() + + cleanupTerraformFolder(t, testExcludeByDefault) + tmpEnvPath := helpers.CopyEnvironment(t, testExcludeByDefault) + rootPath := util.JoinPath(tmpEnvPath, testExcludeByDefault) + + _, stderr, err := helpers.RunTerragruntCommandWithOutput(t, "terragrunt run-all apply -auto-approve --terragrunt-non-interactive --terragrunt-working-dir "+rootPath) + + require.NoError(t, err) + + assert.Contains(t, stderr, "app1") + assert.NotContains(t, stderr, "app2") +} + +func TestExcludeDisabled(t *testing.T) { + t.Parallel() + + cleanupTerraformFolder(t, testExcludeDisabled) + tmpEnvPath := helpers.CopyEnvironment(t, testExcludeDisabled) + rootPath := util.JoinPath(tmpEnvPath, testExcludeDisabled) + + _, stderr, err := helpers.RunTerragruntCommandWithOutput(t, "terragrunt run-all apply -auto-approve --terragrunt-non-interactive --terragrunt-working-dir "+rootPath) + + require.NoError(t, err) + + assert.Contains(t, stderr, "app1") + assert.Contains(t, stderr, "app2") +} + +func TestExcludeApply(t *testing.T) { + t.Parallel() + + cleanupTerraformFolder(t, testExcludeByAction) + tmpEnvPath := helpers.CopyEnvironment(t, testExcludeByAction) + rootPath := util.JoinPath(tmpEnvPath, testExcludeByAction) + + _, stderr, err := helpers.RunTerragruntCommandWithOutput(t, "terragrunt run-all plan --terragrunt-non-interactive --terragrunt-working-dir "+rootPath) + + require.NoError(t, err) + + assert.Contains(t, stderr, "exclude-apply") + assert.NotContains(t, stderr, "exclude-plan") + + _, stderr, err = helpers.RunTerragruntCommandWithOutput(t, "terragrunt run-all apply -auto-approve --terragrunt-non-interactive --terragrunt-working-dir "+rootPath) + + require.NoError(t, err) + + // should be applied only exclude-plan + assert.Contains(t, stderr, "exclude-plan") + assert.NotContains(t, stderr, "exclude-apply") +} + +func TestExcludeByFeatureFlagDefault(t *testing.T) { + t.Parallel() + + cleanupTerraformFolder(t, testExcludeByFlags) + tmpEnvPath := helpers.CopyEnvironment(t, testExcludeByFlags) + rootPath := util.JoinPath(tmpEnvPath, testExcludeByFlags) + + _, stderr, err := helpers.RunTerragruntCommandWithOutput(t, "terragrunt run-all apply --terragrunt-non-interactive --terragrunt-working-dir "+rootPath) + + require.NoError(t, err) + + assert.Contains(t, stderr, "app1") + assert.NotContains(t, stderr, "app2") +} + +func TestExcludeByFeatureFlag(t *testing.T) { + t.Parallel() + + cleanupTerraformFolder(t, testExcludeByFlags) + tmpEnvPath := helpers.CopyEnvironment(t, testExcludeByFlags) + rootPath := util.JoinPath(tmpEnvPath, testExcludeByFlags) + + _, stderr, err := helpers.RunTerragruntCommandWithOutput(t, "terragrunt run-all apply --feature exclude2=false --terragrunt-non-interactive --terragrunt-working-dir "+rootPath) + + require.NoError(t, err) + + assert.Contains(t, stderr, "app1") + assert.Contains(t, stderr, "app2") +} + +func TestExcludeAllByFeatureFlag(t *testing.T) { + t.Parallel() + + cleanupTerraformFolder(t, testExcludeByFlags) + tmpEnvPath := helpers.CopyEnvironment(t, testExcludeByFlags) + rootPath := util.JoinPath(tmpEnvPath, testExcludeByFlags) + + _, stderr, err := helpers.RunTerragruntCommandWithOutput(t, "terragrunt run-all apply --feature exclude1=true --feature exclude2=true --terragrunt-non-interactive --terragrunt-working-dir "+rootPath) + + require.NoError(t, err) + + assert.NotContains(t, stderr, "app1") + assert.NotContains(t, stderr, "app2") +} + +func TestExcludeDependencies(t *testing.T) { + t.Parallel() + + cleanupTerraformFolder(t, testExcludeDependencies) + tmpEnvPath := helpers.CopyEnvironment(t, testExcludeDependencies) + rootPath := util.JoinPath(tmpEnvPath, testExcludeDependencies) + + _, stderr, err := helpers.RunTerragruntCommandWithOutput(t, "terragrunt run-all apply -auto-approve --feature exclude=false --feature exclude_dependencies=false --terragrunt-non-interactive --terragrunt-working-dir "+rootPath) + require.NoError(t, err) + + assert.Contains(t, stderr, "dep") + assert.Contains(t, stderr, "app1") + + _, stderr, err = helpers.RunTerragruntCommandWithOutput(t, "terragrunt run-all apply -auto-approve --feature exclude=true --feature exclude_dependencies=false --terragrunt-non-interactive --terragrunt-working-dir "+rootPath) + + require.NoError(t, err) + + assert.Contains(t, stderr, "dep") + assert.NotContains(t, stderr, "app1") +} + +func TestExcludeAllExceptOutput(t *testing.T) { + t.Parallel() + + cleanupTerraformFolder(t, testExcludeAllExceptOutput) + tmpEnvPath := helpers.CopyEnvironment(t, testExcludeAllExceptOutput) + rootPath := util.JoinPath(tmpEnvPath, testExcludeAllExceptOutput) + + _, stderr, err := helpers.RunTerragruntCommandWithOutput(t, "terragrunt run-all apply -auto-approve --terragrunt-non-interactive --terragrunt-working-dir "+rootPath) + require.NoError(t, err) + + assert.NotContains(t, stderr, "app1") + assert.Contains(t, stderr, "app2") + + _, stderr, err = helpers.RunTerragruntCommandWithOutput(t, "terragrunt run-all output --terragrunt-non-interactive --terragrunt-working-dir "+rootPath) + require.NoError(t, err) + + assert.Contains(t, stderr, "app1") + assert.Contains(t, stderr, "app2") +}