Skip to content

Commit 958f0c9

Browse files
authored
feat: performance improvement on -version execution (#4288)
* Version check fixes * Version saving * Add fetching of context cache * Version caching fixes * Add check for single version run * Lint issues fixes * extracted version files * Added test to track that version is invoked in unit with custom version
1 parent a8a3b98 commit 958f0c9

File tree

11 files changed

+147
-7
lines changed

11 files changed

+147
-7
lines changed

cli/app.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import (
77
"os"
88
"strings"
99

10+
"github.com/gruntwork-io/terragrunt/cli/commands/run"
11+
1012
"github.com/gruntwork-io/terragrunt/engine"
1113
"github.com/gruntwork-io/terragrunt/internal/os/signal"
1214
"github.com/gruntwork-io/terragrunt/telemetry"
@@ -106,6 +108,8 @@ func (app *App) RunContext(ctx context.Context, args []string) error {
106108
// configure engine context
107109
ctx = engine.WithEngineValues(ctx)
108110

111+
ctx = run.WithRunVersionCache(ctx)
112+
109113
defer func(ctx context.Context) {
110114
if err := engine.Shutdown(ctx, app.opts); err != nil {
111115
_, _ = app.ErrWriter.Write([]byte(err.Error()))

cli/commands/run/context.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package run
2+
3+
import (
4+
"context"
5+
6+
"github.com/gruntwork-io/terragrunt/internal/cache"
7+
)
8+
9+
type configKey byte
10+
11+
const (
12+
versionCacheContextKey configKey = iota
13+
versionCacheName = "versionCache"
14+
)
15+
16+
// WithRunVersionCache initializes the version cache in the context for the run package.
17+
func WithRunVersionCache(ctx context.Context) context.Context {
18+
ctx = context.WithValue(ctx, versionCacheContextKey, cache.NewCache[string](versionCacheName))
19+
return ctx
20+
}
21+
22+
// GetRunVersionCache retrieves the version cache from the context for the run package.
23+
func GetRunVersionCache(ctx context.Context) *cache.Cache[string] {
24+
return cache.ContextCache[string](ctx, versionCacheContextKey)
25+
}

cli/commands/run/version_check.go

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,16 @@ import (
44
"context"
55
"fmt"
66
"io"
7+
"path/filepath"
78
"regexp"
89
"strings"
910

11+
"encoding/hex"
12+
1013
"github.com/gruntwork-io/terragrunt/internal/errors"
1114
"github.com/gruntwork-io/terragrunt/options"
1215
"github.com/gruntwork-io/terragrunt/tf"
16+
"github.com/gruntwork-io/terragrunt/util"
1317
"github.com/hashicorp/go-version"
1418
)
1519

@@ -27,6 +31,8 @@ var TerraformVersionRegex = regexp.MustCompile(`^(\S+)\s(v?\d+\.\d+\.\d+)`)
2731

2832
const versionParts = 3
2933

34+
var versionFiles = []string{".terraform-version", ".tool-versions", "mise.toml", ".mise.toml"}
35+
3036
// CheckVersionConstraints checks the version constraints of both terragrunt and terraform. Note that as a side effect this will set the
3137
// following settings on terragruntOptions:
3238
// - TerraformPath
@@ -85,18 +91,36 @@ func CheckVersionConstraints(ctx context.Context, terragruntOptions *options.Ter
8591

8692
// PopulateTerraformVersion populates the currently installed version of Terraform into the given terragruntOptions.
8793
func PopulateTerraformVersion(ctx context.Context, terragruntOptions *options.TerragruntOptions) error {
88-
// Discard all log output to make sure we don't pollute stdout or stderr with this extra call to '--version'
94+
versionCache := GetRunVersionCache(ctx)
95+
cacheKey := computeVersionFilesCacheKey(terragruntOptions.WorkingDir)
96+
97+
if cachedOutput, found := versionCache.Get(ctx, cacheKey); found {
98+
terraformVersion, err := ParseTerraformVersion(cachedOutput)
99+
if err != nil {
100+
return err
101+
}
102+
103+
tfImplementation, err := parseTerraformImplementationType(cachedOutput)
104+
105+
if err != nil {
106+
return err
107+
}
108+
109+
terragruntOptions.TerraformVersion = terraformVersion
110+
111+
terragruntOptions.TerraformImplementation = tfImplementation
112+
113+
return nil
114+
}
115+
89116
terragruntOptionsCopy, err := terragruntOptions.CloneWithConfigPath(terragruntOptions.TerragruntConfigPath)
90117
if err != nil {
91118
return err
92119
}
93120

94121
terragruntOptionsCopy.Writer = io.Discard
95122
terragruntOptionsCopy.ErrWriter = io.Discard
96-
// Remove any TF_CLI_ARGS before version checking. These are appended to
97-
// the arguments supplied on the command line and cause issues when running
98-
// the --version command.
99-
// https://www.terraform.io/docs/commands/environment-variables.html#tf_cli_args-and-tf_cli_args_name
123+
100124
for key := range terragruntOptionsCopy.Env {
101125
if strings.HasPrefix(key, "TF_CLI_ARGS") {
102126
delete(terragruntOptionsCopy.Env, key)
@@ -108,6 +132,9 @@ func PopulateTerraformVersion(ctx context.Context, terragruntOptions *options.Te
108132
return err
109133
}
110134

135+
// Save output to cache
136+
versionCache.Put(ctx, cacheKey, output.Stdout.String())
137+
111138
terraformVersion, err := ParseTerraformVersion(output.Stdout.String())
112139
if err != nil {
113140
return err
@@ -212,6 +239,29 @@ func parseTerraformImplementationType(versionCommandOutput string) (options.Terr
212239
}
213240
}
214241

242+
// Helper to compute a cache key from the checksums of .terraform-version and .tool-versions
243+
func computeVersionFilesCacheKey(workingDir string) string {
244+
var hashes []string
245+
246+
for _, file := range versionFiles {
247+
path := filepath.Join(workingDir, file)
248+
if util.FileExists(path) {
249+
hash, err := util.FileSHA256(path)
250+
if err == nil {
251+
hashes = append(hashes, file+":"+hex.EncodeToString(hash))
252+
}
253+
}
254+
}
255+
256+
cacheKey := "no-version-files"
257+
258+
if len(hashes) != 0 {
259+
cacheKey = strings.Join(hashes, "|")
260+
}
261+
262+
return util.EncodeBase64Sha1(cacheKey)
263+
}
264+
215265
// Custom error types
216266

217267
type InvalidTerraformVersionSyntax string
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
variable "input_value" {}
2+
3+
output "output_value" {
4+
value = var.input_value
5+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
2+
dependency "dependency" {
3+
config_path = "../dependency"
4+
}
5+
6+
dependency "dependency-with-custom-version" {
7+
config_path = "../dependency-with-custom-version"
8+
}
9+
10+
inputs = {
11+
input_value = dependency.dependency.outputs.result
12+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
tofu 1.9.4
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
output "result" {
2+
3+
value = "42"
4+
}

test/fixtures/version-invocation/dependency-with-custom-version/terragrunt.hcl

Whitespace-only changes.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
output "result" {
2+
3+
value = "42"
4+
}

test/fixtures/version-invocation/dependency/terragrunt.hcl

Whitespace-only changes.

test/integration_test.go

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import (
1616

1717
"github.com/gruntwork-io/terragrunt/cli/commands/common"
1818
"github.com/gruntwork-io/terragrunt/cli/commands/common/runall"
19-
print "github.com/gruntwork-io/terragrunt/cli/commands/info/print"
19+
"github.com/gruntwork-io/terragrunt/cli/commands/info/print"
2020
"github.com/gruntwork-io/terragrunt/cli/commands/run"
2121
"github.com/gruntwork-io/terragrunt/cli/flags"
2222
"github.com/gruntwork-io/terragrunt/codegen"
@@ -113,6 +113,7 @@ const (
113113
testFixtureEphemeralInputs = "fixtures/ephemeral-inputs"
114114
testFixtureTfPath = "fixtures/tf-path"
115115
testFixtureTraceParent = "fixtures/trace-parent"
116+
testFixtureVersionInvocation = "fixtures/version-invocation"
116117

117118
terraformFolder = ".terraform"
118119

@@ -1958,7 +1959,7 @@ func TestDependencyMockOutputRestricted(t *testing.T) {
19581959
// Verify that validate-all works as well.
19591960
showStdout.Reset()
19601961
showStderr.Reset()
1961-
err = helpers.RunTerragruntCommand(t, "terragrunt validate-all --non-interactive --working-dir "+dependent2Path, &showStdout, &showStderr)
1962+
err = helpers.RunTerragruntCommand(t, "terragrunt validate-all --non-interactive --working-dir "+rootPath, &showStdout, &showStderr)
19621963
require.NoError(t, err)
19631964

19641965
helpers.LogBufferContentsLineByLine(t, showStdout, "show stdout")
@@ -4133,3 +4134,37 @@ func TestTfPath(t *testing.T) {
41334134

41344135
assert.Regexp(t, "(?i)(terraform|opentofu)", stdout+stderr)
41354136
}
4137+
4138+
func TestVersionIsInvokedOnlyOnce(t *testing.T) {
4139+
t.Parallel()
4140+
4141+
tmpEnvPath := helpers.CopyEnvironment(t, testFixtureDependencyOutput)
4142+
helpers.CleanupTerraformFolder(t, tmpEnvPath)
4143+
testPath := util.JoinPath(tmpEnvPath, testFixtureDependencyOutput)
4144+
4145+
_, stderr, err := helpers.RunTerragruntCommandWithOutput(t, "terragrunt run --all --log-level trace --non-interactive --working-dir "+testPath+" -- apply")
4146+
require.NoError(t, err)
4147+
4148+
// check that version command was invoked only once -version
4149+
versionCmdPattern := regexp.MustCompile(`Running command: ` + regexp.QuoteMeta(wrappedBinary()) + ` -version`)
4150+
matches := versionCmdPattern.FindAllStringIndex(stderr, -1)
4151+
4152+
assert.Len(t, matches, 1, "Expected exactly one occurrence of '-version' command, found %d", len(matches))
4153+
}
4154+
4155+
func TestVersionIsInvokedInDifferentDirectory(t *testing.T) {
4156+
t.Parallel()
4157+
4158+
tmpEnvPath := helpers.CopyEnvironment(t, testFixtureVersionInvocation)
4159+
helpers.CleanupTerraformFolder(t, tmpEnvPath)
4160+
testPath := util.JoinPath(tmpEnvPath, testFixtureVersionInvocation)
4161+
4162+
_, stderr, err := helpers.RunTerragruntCommandWithOutput(t, "terragrunt run --all --log-level trace --non-interactive --working-dir "+testPath+" -- apply")
4163+
require.NoError(t, err)
4164+
4165+
versionCmdPattern := regexp.MustCompile(`Running command: ` + regexp.QuoteMeta(wrappedBinary()) + ` -version`)
4166+
matches := versionCmdPattern.FindAllStringIndex(stderr, -1)
4167+
4168+
assert.Len(t, matches, 2, "Expected exactly one occurrence of '-version' command, found %d", len(matches))
4169+
assert.Contains(t, stderr, "prefix=dependency-with-custom-version msg=Running command: "+wrappedBinary()+" -version")
4170+
}

0 commit comments

Comments
 (0)