Skip to content
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
40 changes: 30 additions & 10 deletions config/config_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,8 @@ func RunCommand(ctx *ParsingContext, l log.Logger, args []string) (string, error
}

suppressOutput := false
disableCache := false
useGlobalCache := false
currentPath := filepath.Dir(ctx.TerragruntOptions.TerragruntConfigPath)
cachePath := currentPath

Expand All @@ -356,8 +358,21 @@ func RunCommand(ctx *ParsingContext, l log.Logger, args []string) (string, error

args = slices.Delete(args, 0, 1)
case "--terragrunt-global-cache":
if disableCache {
return "", errors.New(ConflictingRunCmdCacheOptionsError{})
}

useGlobalCache = true
cachePath = "_global_"

args = slices.Delete(args, 0, 1)
case "--terragrunt-no-cache":
if useGlobalCache {
return "", errors.New(ConflictingRunCmdCacheOptionsError{})
}

disableCache = true

args = slices.Delete(args, 0, 1)
default:
checkOptions = false
Expand All @@ -368,15 +383,18 @@ func RunCommand(ctx *ParsingContext, l log.Logger, args []string) (string, error
// see: https://github.com/gruntwork-io/terragrunt/issues/1427
cacheKey := fmt.Sprintf("%v-%v", cachePath, args)

cachedValue, foundInCache := runCommandCache.Get(ctx, cacheKey)
if foundInCache {
if suppressOutput {
l.Debugf("run_cmd, cached output: [REDACTED]")
} else {
l.Debugf("run_cmd, cached output: [%s]", cachedValue)
}
// Skip cache lookup if --terragrunt-no-cache is set
if !disableCache {
cachedValue, foundInCache := runCommandCache.Get(ctx, cacheKey)
if foundInCache {
if suppressOutput {
l.Debugf("run_cmd, cached output: [REDACTED]")
} else {
l.Debugf("run_cmd, cached output: [%s]", cachedValue)
}

return cachedValue, nil
return cachedValue, nil
}
}

cmdOutput, err := shell.RunCommandWithOutput(ctx, l, ctx.TerragruntOptions, currentPath, suppressOutput, false, args[0], args[1:]...)
Expand All @@ -392,9 +410,11 @@ func RunCommand(ctx *ParsingContext, l log.Logger, args []string) (string, error
l.Debugf("run_cmd output: [%s]", value)
}

// Persisting result in cache to avoid future re-evaluation
// Persisting result in cache to avoid future re-evaluation, unless --terragrunt-no-cache is set
// see: https://github.com/gruntwork-io/terragrunt/issues/1427
runCommandCache.Put(ctx, cacheKey, value)
if !disableCache {
runCommandCache.Put(ctx, cacheKey, value)
}

return value, nil
}
Expand Down
25 changes: 25 additions & 0 deletions config/config_helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,31 @@ func TestRunCommand(t *testing.T) {
terragruntOptions: terragruntOptionsForTest(t, homeDir),
expectedOutput: "foo",
},
{
params: []string{"--terragrunt-no-cache", "/bin/bash", "-c", "echo foo"},
terragruntOptions: terragruntOptionsForTest(t, homeDir),
expectedOutput: "foo",
},
{
params: []string{"--terragrunt-no-cache", "--terragrunt-quiet", "/bin/bash", "-c", "echo foo"},
terragruntOptions: terragruntOptionsForTest(t, homeDir),
expectedOutput: "foo",
},
{
params: []string{"--terragrunt-quiet", "--terragrunt-no-cache", "/bin/bash", "-c", "echo foo"},
terragruntOptions: terragruntOptionsForTest(t, homeDir),
expectedOutput: "foo",
},
{
params: []string{"--terragrunt-no-cache", "--terragrunt-global-cache", "--terragrunt-quiet", "/bin/bash", "-c", "echo foo"},
terragruntOptions: terragruntOptionsForTest(t, homeDir),
expectedErr: config.ConflictingRunCmdCacheOptionsError{},
},
{
params: []string{"--terragrunt-global-cache", "--terragrunt-no-cache", "--terragrunt-quiet", "/bin/bash", "-c", "echo foo"},
terragruntOptions: terragruntOptionsForTest(t, homeDir),
expectedErr: config.ConflictingRunCmdCacheOptionsError{},
},
{
terragruntOptions: terragruntOptionsForTest(t, homeDir),
expectedErr: config.EmptyStringNotAllowedError("{run_cmd()}"),
Expand Down
6 changes: 6 additions & 0 deletions config/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,12 @@ func (err EmptyStringNotAllowedError) Error() string {
return "Empty string value is not allowed for " + string(err)
}

type ConflictingRunCmdCacheOptionsError struct{}

func (err ConflictingRunCmdCacheOptionsError) Error() string {
return "The --terragrunt-global-cache and --terragrunt-no-cache options cannot be used together. Choose one caching option for run_cmd."
}

type TerragruntConfigNotFoundError struct {
Path string
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,8 @@ terraform {

`run_cmd(command, arg1, arg2…​)` runs a shell command and returns the stdout as the result of the interpolation. The command is executed at the same folder as the `terragrunt.hcl` file. This is useful whenever you want to dynamically fill in arbitrary information in your Terragrunt configuration.

### Basic Usage

As an example, you could write a script that determines the bucket and DynamoDB table name based on the AWS account, instead of hardcoding the name of every account:

```hcl
Expand All @@ -712,15 +714,56 @@ remote_state {
}
```

If the command you are running has the potential to output sensitive values, you may wish to redact the output from appearing in the terminal. To do so, use the special `--terragrunt-quiet` argument which must be passed as one of the first arguments to `run_cmd()`:
### Special Parameters

The `run_cmd` function accepts some special flags that can alter how the function evaluates commands on your behalf. Placing these `--terragrunt-` prefixed flags as the first argument(s) of a `run_cmd` call will result in the behavior of `run_cmd` being adjusted. You can mix and match the flags in any order, so long as they precede the command you are running with `run_cmd`.

| Parameter | Description | Example |
|-----------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------|
| `--terragrunt-quiet` | Redacts `run_cmd` stdout from Terragrunt logs while still returning the value to HCL. This keeps sensitive information out of log files. | `run_cmd("--terragrunt-quiet", "./decrypt_secret.sh", "foo")` |
| `--terragrunt-global-cache` | Stores and reuses results in a global cache so the command only runs once per set of arguments, no matter which configuration references it. Useful when the output is directory-independent. | `run_cmd("--terragrunt-global-cache", "aws", "sts", "get-caller-identity", "--query", "Account", "--output", "text")` |
| `--terragrunt-no-cache` | Skips the cache entirely and forces the command to run on every evaluation. Use this when the output changes frequently (timestamps, tokens, random IDs, etc.). | `run_cmd("--terragrunt-no-cache", "date", "+%s")` |

Terragrunt caches `run_cmd` results by default to avoid running the same command multiple times during parsing. The cache key includes the directory of the `terragrunt.hcl` file and the command arguments unless you opt into global caching or disable caching entirely.
Parameters `--terragrunt-global-cache` and `--terragrunt-no-cache` are mutually exclusive, Terragrunt will return an error if both are provided.

#### Examples

**Suppress output for sensitive values:**

```hcl
# Output is redacted in logs, but still available to Terragrunt
super_secret_value = run_cmd("--terragrunt-quiet", "./decrypt_secret.sh", "foo")
```

**Note:** This will prevent terragrunt from displaying the output from the command in its output. However, the value could still be displayed in the OpenTofu/Terraform output if OpenTofu/Terraform does not treat it as a [sensitive value](https://www.terraform.io/docs/configuration/outputs.html#sensitive-suppressing-values-in-cli-output).

Invocations of `run_cmd` are cached based on directory and executed command, so cached values are reused later, rather than executed multiple times. Here's an example:
**Use global cache for directory-independent commands:**

```hcl
# Same result regardless of which directory run_cmd is called from
account_id = run_cmd("--terragrunt-global-cache", "aws", "sts", "get-caller-identity", "--query", "Account", "--output", "text")
```

**Disable caching for dynamic values:**

```hcl
# Generates a new UUID every time run_cmd is evaluated
build_id = run_cmd("--terragrunt-no-cache", "uuidgen")

# Gets current timestamp on each parse of the Terragrunt configuration
timestamp = run_cmd("--terragrunt-no-cache", "date", "+%s")
```

**Combine multiple parameters:**

```hcl
# Disable cache AND suppress output for a sensitive dynamic value
session_token = run_cmd("--terragrunt-no-cache", "--terragrunt-quiet", "./generate-temp-token.sh")

### Caching Behavior

By default, invocations of `run_cmd` are cached based on the current directory and executed command, so cached values are reused later rather than executed multiple times. Here's an example:

```hcl
# terragrunt.hcl
Expand Down Expand Up @@ -760,18 +803,14 @@ uuid3 baa19863-1d99-e0ef-11f2-ede830d1c58a
carrot
```

**Notes:**

- Output contains only contains one instance of `carrot` and `potato`, because other invocations got cached; caching works for all sections
- Output contains multiple times `uuid1` and `uuid2` because during HCL evaluation each `run_cmd` in `locals` is evaluated multiple times and random argument generated from `uuid()` save cached value under different key each time
- Output contains multiple times `uuid3`, +1 more output comparing to `uuid1` and `uuid2` - because `uuid3` is declared in locals and inputs which add one more evaluation
- Output contains only once `uuid4` since it is declared only once in `inputs`, `inputs` is not evaluated twice
**Key observations from the output:**

You can modify this caching behavior to ignore the existing directory if you know the command you are running is not dependent on the current directory path. To do so, use the special `--terragrunt-global-cache` argument which must be passed as one of the first arguments to `run_cmd()` (and can be combined with `--terragrunt-quiet` in any order):
- `carrot` and `potato` appear once because subsequent invocations used cached values
- `uuid1`, `uuid2`, and `uuid3` appear multiple times because each call to `uuid()` generates a different cache key
- `uuid3` appears one extra time because it's declared in both `locals` and `inputs`
- `uuid4` appears once since it's declared in `inputs`, which is evaluated once

```hcl
value = run_cmd("--terragrunt-global-cache", "--terragrunt-quiet", "/usr/local/bin/get-account-map")
```
This caching behavior can be modified using the special parameters described in the [Special Parameters](#special-parameters) section above.

## read_terragrunt_config

Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ require (
github.com/charmbracelet/x/term v0.2.1
github.com/gobwas/glob v0.2.3
github.com/invopop/jsonschema v0.13.0
github.com/mattn/go-shellwords v1.0.12
github.com/wI2L/jsondiff v0.7.0
github.com/xeipuuv/gojsonschema v1.2.0
go.uber.org/mock v0.6.0
Expand Down Expand Up @@ -241,7 +242,6 @@ require (
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mattn/go-shellwords v1.0.12 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/go-testing-interface v1.14.1 // indirect
Expand Down
7 changes: 7 additions & 0 deletions test/fixtures/run-cmd-flags/module-conflict/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
variable "conflict_value" {
type = string
}

output "conflict_value" {
value = var.conflict_value
}
8 changes: 8 additions & 0 deletions test/fixtures/run-cmd-flags/module-conflict/terragrunt.hcl
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
locals {
scripts_dir = "${get_terragrunt_dir()}/../scripts"
conflict = run_cmd("--terragrunt-global-cache", "--terragrunt-no-cache", "${local.scripts_dir}/global_counter.sh")
}

inputs = {
conflict_value = local.conflict
}
7 changes: 7 additions & 0 deletions test/fixtures/run-cmd-flags/module-global-cache-a/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
variable "cached_value" {
type = string
}

output "cached_value_a" {
value = var.cached_value
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
locals {
scripts_dir = "${get_terragrunt_dir()}/../scripts"
cached = run_cmd("--terragrunt-global-cache", "${local.scripts_dir}/global_counter.sh")
}

inputs = {
cached_value = local.cached
}
7 changes: 7 additions & 0 deletions test/fixtures/run-cmd-flags/module-global-cache-b/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
variable "cached_value" {
type = string
}

output "cached_value_b" {
value = var.cached_value
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
locals {
scripts_dir = "${get_terragrunt_dir()}/../scripts"
cached = run_cmd("--terragrunt-global-cache", "${local.scripts_dir}/global_counter.sh")
}

inputs = {
cached_value = local.cached
}
15 changes: 15 additions & 0 deletions test/fixtures/run-cmd-flags/module-no-cache/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
variable "first_value" {
type = string
}

variable "second_value" {
type = string
}

output "first_value" {
value = var.first_value
}

output "second_value" {
value = var.second_value
}
10 changes: 10 additions & 0 deletions test/fixtures/run-cmd-flags/module-no-cache/terragrunt.hcl
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
locals {
scripts_dir = "${get_terragrunt_dir()}/../scripts"
first = run_cmd("--terragrunt-no-cache", "${local.scripts_dir}/no_cache_counter.sh")
second = run_cmd("--terragrunt-no-cache", "${local.scripts_dir}/no_cache_counter.sh")
}

inputs = {
first_value = local.first
second_value = local.second
}
7 changes: 7 additions & 0 deletions test/fixtures/run-cmd-flags/module-quiet/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
variable "secret" {
type = string
}

output "quiet_secret" {
value = var.secret
}
8 changes: 8 additions & 0 deletions test/fixtures/run-cmd-flags/module-quiet/terragrunt.hcl
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
locals {
scripts_dir = "${get_terragrunt_dir()}/../scripts"
secret = run_cmd("--terragrunt-quiet", "${local.scripts_dir}/emit_secret.sh")
}

inputs = {
secret = local.secret
}
5 changes: 5 additions & 0 deletions test/fixtures/run-cmd-flags/scripts/emit_secret.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/usr/bin/env bash

set -euo pipefail

echo "TOP_SECRET_TOKEN"
18 changes: 18 additions & 0 deletions test/fixtures/run-cmd-flags/scripts/global_counter.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#!/usr/bin/env bash

set -euo pipefail

script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
counter_file="${script_dir}/global_counter.txt"

count=0
if [[ -f "${counter_file}" ]]; then
if ! read -r count <"${counter_file}"; then
count=0
fi
fi

count=$((count + 1))
printf "%s" "${count}" >"${counter_file}"

echo "global-value-${count}"
18 changes: 18 additions & 0 deletions test/fixtures/run-cmd-flags/scripts/no_cache_counter.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#!/usr/bin/env bash

set -euo pipefail

script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
counter_file="${script_dir}/no_cache_counter.txt"

count=0
if [[ -f "${counter_file}" ]]; then
if ! read -r count <"${counter_file}"; then
count=0
fi
fi

count=$((count + 1))
printf "%s" "${count}" >"${counter_file}"

echo "no-cache-value-${count}"
Empty file.
Loading
Loading