Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package terragrunt for openSUSE:Factory checked in at 2026-02-16 13:10:43 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/terragrunt (Old) and /work/SRC/openSUSE:Factory/.terragrunt.new.1977 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "terragrunt" Mon Feb 16 13:10:43 2026 rev:285 rq:1333158 version:0.99.2 Changes: -------- --- /work/SRC/openSUSE:Factory/terragrunt/terragrunt.changes 2026-01-30 18:28:16.468633578 +0100 +++ /work/SRC/openSUSE:Factory/.terragrunt.new.1977/terragrunt.changes 2026-02-16 13:17:13.905786528 +0100 @@ -1,0 +2,24 @@ +Sat Feb 14 08:30:04 UTC 2026 - Johannes Kastl <[email protected]> + +- Update to version 0.99.2: + * Bug Fixes + - Interrupt signal propagation to OpenTofu/Terraform fixed + The mechanism by which Terragrunt sends interrupt signals to + OpenTofu/Terraform processes it started has been made more + robust. Terragrunt will now send the interrupt signal in the + event that a user explicitly sends an interrupt signal to + Terragrunt in addition to scenarios where Terragrunt’s + context cancellation is triggered (e.g. in the event of a + timeout). + - SOPS decryption race condition fixed + A race condition in the concurrent access to SOPS decrypted + secrets in different environments combined with usage of the + --auth-provider-cmd flag resulted in authentication failures. + Synchronization controls have been introduced to ensure + authentication proceeds correctly for each environment + independently. + * What's Changed + - SOPS decode change porting to v0.99 (#5549) + - chore: Backporting #5518 to `v0.99` (#5547) + +------------------------------------------------------------------- Old: ---- terragrunt-0.99.1.obscpio New: ---- terragrunt-0.99.2.obscpio ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ terragrunt.spec ++++++ --- /var/tmp/diff_new_pack.1N3Dt0/_old 2026-02-16 13:17:15.533856040 +0100 +++ /var/tmp/diff_new_pack.1N3Dt0/_new 2026-02-16 13:17:15.533856040 +0100 @@ -17,7 +17,7 @@ Name: terragrunt -Version: 0.99.1 +Version: 0.99.2 Release: 0 Summary: Thin wrapper for Terraform for working with multiple Terraform modules License: MIT ++++++ _service ++++++ --- /var/tmp/diff_new_pack.1N3Dt0/_old 2026-02-16 13:17:15.601858943 +0100 +++ /var/tmp/diff_new_pack.1N3Dt0/_new 2026-02-16 13:17:15.605859114 +0100 @@ -3,7 +3,7 @@ <param name="url">https://github.com/gruntwork-io/terragrunt</param> <param name="scm">git</param> <param name="exclude">.git</param> - <param name="revision">v0.99.1</param> + <param name="revision">v0.99.2</param> <param name="versionformat">@PARENT_TAG@</param> <param name="versionrewrite-pattern">v(.*)</param> <param name="changesgenerate">enable</param> ++++++ _servicedata ++++++ --- /var/tmp/diff_new_pack.1N3Dt0/_old 2026-02-16 13:17:15.629860139 +0100 +++ /var/tmp/diff_new_pack.1N3Dt0/_new 2026-02-16 13:17:15.637860480 +0100 @@ -1,6 +1,6 @@ <servicedata> <service name="tar_scm"> <param name="url">https://github.com/gruntwork-io/terragrunt</param> - <param name="changesrevision">ab330c6bd7bd18bc06c57b35e34e3236a6fb1dae</param></service></servicedata> + <param name="changesrevision">f84675a8a1746a4e16a9725e87492b0bf6b01928</param></service></servicedata> (No newline at EOF) ++++++ terragrunt-0.99.1.obscpio -> terragrunt-0.99.2.obscpio ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/terragrunt-0.99.1/internal/os/exec/cmd.go new/terragrunt-0.99.2/internal/os/exec/cmd.go --- old/terragrunt-0.99.1/internal/os/exec/cmd.go 2026-01-29 19:26:42.000000000 +0100 +++ new/terragrunt-0.99.2/internal/os/exec/cmd.go 2026-02-13 15:51:17.000000000 +0100 @@ -6,6 +6,7 @@ "os" "os/exec" "path/filepath" + "sync/atomic" "time" "github.com/gruntwork-io/terragrunt/internal/os/signal" @@ -16,14 +17,19 @@ "github.com/gruntwork-io/terragrunt/internal/errors" ) +// DefaultGracefulShutdownDelay is the default time to wait for a process to exit +// gracefully after sending an interrupt signal before escalating to SIGKILL. +const DefaultGracefulShutdownDelay = 30 * time.Second + // Cmd is a command type. type Cmd struct { logger log.Logger interruptSignal os.Signal *exec.Cmd - filename string - forwardSignalDelay time.Duration - usePTY bool + filename string + forwardSignalDelay time.Duration + usePTY bool + gracefulShutdownRegistered atomic.Bool } // Command returns the `Cmd` struct to execute the named program with @@ -39,7 +45,14 @@ cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr + + cmd.WaitDelay = DefaultGracefulShutdownDelay + cmd.Cancel = func() error { + if cmd.gracefulShutdownRegistered.Load() { + return nil + } + if cmd.Process == nil { return nil } @@ -48,6 +61,10 @@ return cmd.Process.Signal(sig) } + if cmd.interruptSignal != nil { + return cmd.Process.Signal(cmd.interruptSignal) + } + return cmd.Process.Signal(os.Kill) } @@ -84,6 +101,8 @@ // 2. If the context does not contain any causes, this means that there was some failure and we need to terminate all executed commands, // in this situation we are sure that commands did not receive any signal, so we send them an interrupt signal immediately. func (cmd *Cmd) RegisterGracefullyShutdown(ctx context.Context) func() { + cmd.gracefulShutdownRegistered.Store(true) + ctxShutdown, cancelShutdown := context.WithCancel(context.Background()) go func() { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/terragrunt-0.99.1/internal/os/exec/cmd_unix_test.go new/terragrunt-0.99.2/internal/os/exec/cmd_unix_test.go --- old/terragrunt-0.99.1/internal/os/exec/cmd_unix_test.go 2026-01-29 19:26:42.000000000 +0100 +++ new/terragrunt-0.99.2/internal/os/exec/cmd_unix_test.go 2026-02-13 15:51:17.000000000 +0100 @@ -4,6 +4,7 @@ package exec_test import ( + "context" "errors" "os" "strconv" @@ -120,3 +121,43 @@ assert.LessOrEqual(t, retCode, interrupts, "Subprocess received wrong number of signals") assert.Equal(t, expectedInterrupts, retCode, "Subprocess didn't receive multiple signals") } + +// TestGracefulShutdownOnContextCancelUnix verifies that when the context is cancelled +// without a signal cause, the Cancel callback sends SIGINT (not SIGKILL) to allow +// processes like Terraform to gracefully shutdown their child processes. +// The test script traps SIGINT and exits with code 42, while SIGKILL would terminate +// it immediately without running the trap handler. +func TestGracefulShutdownOnContextCancelUnix(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + + cmd := exec.Command(ctx, "testdata/test_graceful_shutdown.sh") + + cmd.Configure(exec.WithGracefulShutdownDelay(5 * time.Second)) + + runChannel := make(chan error) + + go func() { + runChannel <- cmd.Run() + }() + + time.Sleep(500 * time.Millisecond) + + cancel() + + err := <-runChannel + require.Error(t, err) + + retCode, err := util.GetExitCode(err) + require.NoError(t, err) + + assert.Equal( + t, + 42, + retCode, + "Expected exit code 42 (SIGINT received), but got %d. "+ + "This suggests SIGKILL was sent instead of SIGINT.", + retCode, + ) +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/terragrunt-0.99.1/internal/os/exec/opts.go new/terragrunt-0.99.2/internal/os/exec/opts.go --- old/terragrunt-0.99.1/internal/os/exec/opts.go 2026-01-29 19:26:42.000000000 +0100 +++ new/terragrunt-0.99.2/internal/os/exec/opts.go 2026-02-13 15:51:17.000000000 +0100 @@ -39,3 +39,12 @@ cmd.forwardSignalDelay = delay } } + +// WithGracefulShutdownDelay sets the time to wait for a process to exit gracefully +// after sending an interrupt signal before escalating to SIGKILL. +// This allows processes like Terraform to clean up child processes (e.g., provider plugins). +func WithGracefulShutdownDelay(delay time.Duration) Option { + return func(cmd *Cmd) { + cmd.WaitDelay = delay + } +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/terragrunt-0.99.1/internal/os/exec/testdata/test_graceful_shutdown.sh new/terragrunt-0.99.2/internal/os/exec/testdata/test_graceful_shutdown.sh --- old/terragrunt-0.99.1/internal/os/exec/testdata/test_graceful_shutdown.sh 1970-01-01 01:00:00.000000000 +0100 +++ new/terragrunt-0.99.2/internal/os/exec/testdata/test_graceful_shutdown.sh 2026-02-13 15:51:17.000000000 +0100 @@ -0,0 +1,9 @@ +#!/bin/bash -e + +# This script traps SIGINT and exits with code 42 when received. +# It exits with code 1 if terminated by SIGKILL (or any other unexpected termination). +# This is used to verify that the graceful shutdown sends SIGINT rather than SIGKILL. + +trap 'exit 42' INT + +while true; do sleep 0.1; done diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/terragrunt-0.99.1/internal/runner/graph/graph.go new/terragrunt-0.99.2/internal/runner/graph/graph.go --- old/terragrunt-0.99.1/internal/runner/graph/graph.go 2026-01-29 19:26:42.000000000 +0100 +++ new/terragrunt-0.99.2/internal/runner/graph/graph.go 2026-02-13 15:51:17.000000000 +0100 @@ -9,6 +9,7 @@ "github.com/gruntwork-io/terragrunt/internal/runner" "github.com/gruntwork-io/terragrunt/internal/runner/common" + "github.com/gruntwork-io/terragrunt/internal/runner/run/creds" "github.com/gruntwork-io/terragrunt/internal/runner/runall" "github.com/gruntwork-io/terragrunt/internal/os/stdout" @@ -21,6 +22,16 @@ ) func Run(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) error { + // Get credentials BEFORE config parsing — sops_decrypt_file() and + // get_aws_account_id() in locals need auth-provider credentials + // available in opts.Env during HCL evaluation. + // *Getter discarded: graph.Run only needs creds in opts.Env for initial config parse. + // Per-unit creds are re-fetched in runnerpool task (intentional: each unit may have + // different opts after clone). + if _, err := creds.ObtainCredsForParsing(ctx, l, opts); err != nil { + return err + } + cfg, err := config.ReadTerragruntConfig(ctx, l, opts, config.DefaultParserOptions(l, opts)) if err != nil { return err diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/terragrunt-0.99.1/internal/runner/run/creds/getter.go new/terragrunt-0.99.2/internal/runner/run/creds/getter.go --- old/terragrunt-0.99.1/internal/runner/run/creds/getter.go 2026-01-29 19:26:42.000000000 +0100 +++ new/terragrunt-0.99.2/internal/runner/run/creds/getter.go 2026-02-13 15:51:17.000000000 +0100 @@ -6,6 +6,7 @@ "maps" "github.com/gruntwork-io/terragrunt/internal/runner/run/creds/providers" + "github.com/gruntwork-io/terragrunt/internal/runner/run/creds/providers/externalcmd" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) @@ -45,3 +46,16 @@ return nil } + +// ObtainCredsForParsing creates a new Getter, obtains external-command +// credentials, and populates opts.Env before HCL parsing. +// Use when sops_decrypt_file() or get_aws_account_id() may appear in locals. +// See https://github.com/gruntwork-io/terragrunt/issues/5515 +func ObtainCredsForParsing(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) (*Getter, error) { + g := NewGetter() + if err := g.ObtainAndUpdateEnvIfNecessary(ctx, l, opts, externalcmd.NewProvider(l, opts)); err != nil { + return nil, err + } + + return g, nil +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/terragrunt-0.99.1/internal/runner/runnerpool/runner.go new/terragrunt-0.99.2/internal/runner/runnerpool/runner.go --- old/terragrunt-0.99.1/internal/runner/runnerpool/runner.go 2026-01-29 19:26:42.000000000 +0100 +++ new/terragrunt-0.99.2/internal/runner/runnerpool/runner.go 2026-02-13 15:51:17.000000000 +0100 @@ -22,7 +22,6 @@ "github.com/gruntwork-io/terragrunt/internal/report" "github.com/gruntwork-io/terragrunt/internal/runner/common" "github.com/gruntwork-io/terragrunt/internal/runner/run/creds" - "github.com/gruntwork-io/terragrunt/internal/runner/run/creds/providers/externalcmd" "github.com/gruntwork-io/terragrunt/internal/telemetry" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/pkg/log" @@ -491,6 +490,15 @@ unitLogger = l } + // Get credentials BEFORE config parsing — sops_decrypt_file() and + // get_aws_account_id() in locals need auth-provider credentials + // available in opts.Env during HCL evaluation. + // See https://github.com/gruntwork-io/terragrunt/issues/5515 + credsGetter, err := creds.ObtainCredsForParsing(childCtx, unitLogger, u.Execution.TerragruntOptions) + if err != nil { + return err + } + cfg, err := config.ReadTerragruntConfig( childCtx, unitLogger, @@ -503,16 +511,6 @@ runCfg := cfg.ToRunConfig(unitLogger) - credsGetter := creds.NewGetter() - if err = credsGetter.ObtainAndUpdateEnvIfNecessary( - childCtx, - unitLogger, - u.Execution.TerragruntOptions, - externalcmd.NewProvider(unitLogger, u.Execution.TerragruntOptions), - ); err != nil { - return err - } - err = unitRunner.Run( childCtx, u.Execution.TerragruntOptions, diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/terragrunt-0.99.1/pkg/config/config_helpers.go new/terragrunt-0.99.2/pkg/config/config_helpers.go --- old/terragrunt-0.99.1/pkg/config/config_helpers.go 2026-01-29 19:26:42.000000000 +0100 +++ new/terragrunt-0.99.2/pkg/config/config_helpers.go 2026-02-13 15:51:17.000000000 +0100 @@ -1258,9 +1258,27 @@ var result string err := telemetry.TelemeterFromContext(ctx).Collect(ctx, "hcl_fn_sops_decrypt_file", attrs, func(childCtx context.Context) error { + if len(params) != 1 { + return errors.New(WrongNumberOfParamsError{Func: "sops_decrypt_file", Expected: "1", Actual: len(params)}) + } + + format, err := getSopsFileFormat(sourceFile) + if err != nil { + return errors.New(err) + } + + path := sourceFile + + if !filepath.IsAbs(path) { + path = filepath.Join(pctx.TerragruntOptions.WorkingDir, path) + path = filepath.Clean(path) + } + + trackFileRead(pctx.FilesRead, path) + var innerErr error - result, innerErr = sopsDecryptFileImpl(childCtx, pctx, l, params) + result, innerErr = sopsDecryptFileImpl(childCtx, pctx, l, path, format, decrypt.File) return innerErr }) @@ -1269,60 +1287,59 @@ } // sopsDecryptFileImpl contains the actual implementation of sopsDecryptFile -func sopsDecryptFileImpl(ctx context.Context, pctx *ParsingContext, _ log.Logger, params []string) (string, error) { - numParams := len(params) - - var sourceFile string +func sopsDecryptFileImpl(ctx context.Context, pctx *ParsingContext, l log.Logger, path string, format string, decryptFn func(string, string) ([]byte, error)) (string, error) { + // Fast path: check cache before acquiring lock. + // Cache has its own sync.RWMutex, safe for concurrent reads. + if val, ok := sopsCache.Get(ctx, path); ok { + l.Debugf("sops decrypt: cache hit for %s (len=%d)", path, len(val)) - if numParams > 0 { - sourceFile = params[0] + return val, nil } - if numParams != 1 { - return "", errors.New(WrongNumberOfParamsError{Func: "sops_decrypt_file", Expected: "1", Actual: numParams}) - } + // Cache miss: acquire lock for env mutation + decrypt. + // The lock serializes os.Setenv/os.Unsetenv to prevent race conditions + // when multiple units decrypt concurrently with different auth credentials. + // See https://github.com/gruntwork-io/terragrunt/issues/5515 + l.Debugf("sops decrypt: cache miss, acquiring lock for %s (format=%s)", path, format) + + locks.EnvLock.Lock() + defer locks.EnvLock.Unlock() + + // Set env vars from opts.Env that are missing from process env. + // Auth-provider credentials (e.g., AWS_SESSION_TOKEN) may not exist + // in process env yet — SOPS needs them for KMS auth. + // Existing process env vars are preserved to avoid overriding real + // credentials with empty auth-provider values. + env := pctx.TerragruntOptions.Env - format, err := getSopsFileFormat(sourceFile) - if err != nil { - return "", errors.New(err) - } + var setKeys []string - path := sourceFile + for k, v := range env { + if _, exists := os.LookupEnv(k); exists { + continue + } - if !filepath.IsAbs(path) { - path = filepath.Join(pctx.TerragruntOptions.WorkingDir, path) - path = filepath.Clean(path) - } + os.Setenv(k, v) //nolint:errcheck - // Track that this file was read during parsing - trackFileRead(pctx.FilesRead, path) + setKeys = append(setKeys, k) + } - // Set environment variables from the TerragruntOptions.Env map. - // This is especially useful for integrations with things like the `auth-provider` flag, - // which can set environment variables that are used for decryption. - // - // Due to the fact that sops doesn't expose a way of explicitly setting authentication configurations - // for decryption, we have to rely on environment variables to pass these configurations. - // This can cause a race condition, so we have to be careful to avoid having anything else - // running concurrently that might interfere with the environment variables. - env := pctx.TerragruntOptions.Env - if len(env) > 0 { - locks.EnvLock.Lock() - defer locks.EnvLock.Unlock() - - for k, v := range env { - if os.Getenv(k) == "" { - os.Setenv(k, v) //nolint:errcheck - defer os.Unsetenv(k) //nolint:errcheck - } + defer func() { + for _, k := range setKeys { + os.Unsetenv(k) //nolint:errcheck } - } + }() + // Double-check: another goroutine may have populated cache while we waited for the lock. if val, ok := sopsCache.Get(ctx, path); ok { + l.Debugf("sops decrypt: cache hit after lock for %s (len=%d)", path, len(val)) + return val, nil } - rawData, err := decrypt.File(path, format) + l.Debugf("sops decrypt: decrypting %s", path) + + rawData, err := decryptFn(path, format) if err != nil { return "", errors.New(extractSopsErrors(err)) } @@ -1334,7 +1351,7 @@ return value, nil } - return "", errors.New(InvalidSopsFormatError{SourceFilePath: sourceFile}) + return "", errors.New(InvalidSopsFormatError{SourceFilePath: path}) } // Mapping of SOPS format to string diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/terragrunt-0.99.1/pkg/config/sops_test.go new/terragrunt-0.99.2/pkg/config/sops_test.go --- old/terragrunt-0.99.1/pkg/config/sops_test.go 1970-01-01 01:00:00.000000000 +0100 +++ new/terragrunt-0.99.2/pkg/config/sops_test.go 2026-02-13 15:51:17.000000000 +0100 @@ -0,0 +1,374 @@ +//go:build sops + +package config //nolint:testpackage // needs access to sopsCache internals + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/gruntwork-io/terragrunt/internal/cache" + "github.com/gruntwork-io/terragrunt/pkg/options" + "github.com/gruntwork-io/terragrunt/test/helpers/logger" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// generateTestSecretFiles creates plain JSON files in a temp directory. +// No SOPS encryption needed — the test injects a mock decryptFn to read raw files. +func generateTestSecretFiles(t *testing.T, count int) []string { + t.Helper() + + dir := t.TempDir() + + var files []string + + for i := 1; i <= count; i++ { + unitDir := filepath.Join(dir, fmt.Sprintf("unit-%02d", i)) + require.NoError(t, os.MkdirAll(unitDir, 0755)) + + secretFile := filepath.Join(unitDir, "secret.enc.json") + require.NoError(t, os.WriteFile(secretFile, + []byte(fmt.Sprintf(`{"value":"secret-from-unit-%02d"}`, i)), 0644)) + + files = append(files, secretFile) + } + + return files +} + +// TestSOPSDecryptConcurrencyRace is a regression test for +// https://github.com/gruntwork-io/terragrunt/issues/5515 +// +// The bug: sopsDecryptFileImpl only acquires EnvLock when len(env) > 0. +// Goroutines without env vars run unlocked, and can observe env var changes +// made by locked goroutines (set via os.Setenv, then deferred os.Unsetenv). +// With KMS-based decryption, the network latency makes this race window large +// enough to hit reliably. +// +// This test injects a delay into decryptFn to simulate KMS latency, +// then detects when env vars disappear mid-operation. +// +// Without the fix (conditional lock): FAILS — env vars change mid-decrypt. +// With the fix (always lock): PASSES — operations are serialized. +func TestSOPSDecryptConcurrencyRace(t *testing.T) { //nolint:paralleltest // mutates package-global sopsCache and process env vars via os.Setenv/os.Unsetenv + const testEnvKey = "SOPS_TEST_AUTH_TOKEN" + + origCache := sopsCache + + t.Cleanup(func() { + sopsCache = origCache + + os.Unsetenv(testEnvKey) //nolint:errcheck + }) + + var envVarRaces atomic.Int32 + + var decryptErrors atomic.Int32 + + // Mock decrypt function that simulates KMS latency and detects env var races. + mockDecryptFn := func(path string, format string) ([]byte, error) { + // Check if WE are the goroutine that set the env var. + // The production code does os.Setenv BEFORE calling decryptFn, + // so if the env var is set here, we're the "setter" goroutine. + if os.Getenv(testEnvKey) != "" { + // Setter goroutine: short delay so our deferred os.Unsetenv + // runs quickly, while unlocked goroutines are still mid-operation. + time.Sleep(5 * time.Millisecond) + } else { + // Non-setter goroutine: longer delay to ensure we're still + // inside decryptFn when setters' deferred os.Unsetenv runs. + // Poll the env var to detect the set→unset transition. + deadline := time.Now().Add(50 * time.Millisecond) + sawSet := false + + for time.Now().Before(deadline) { + val := os.Getenv(testEnvKey) + if val != "" { + sawSet = true + } else if sawSet { + // Env var was set by another goroutine, now it's + // gone because their deferred os.Unsetenv ran + // while we're unprotected by the lock — race! + envVarRaces.Add(1) + + break + } + + time.Sleep(50 * time.Microsecond) + } + } + + // Return raw file content — no real SOPS decryption needed for race detection. + return os.ReadFile(path) + } + + // Generate plain JSON files in temp dir (no SOPS encryption needed) + const numFiles = 10 + + secretFiles := generateTestSecretFiles(t, numFiles) + + t.Logf("Using %d secret files to decrypt concurrently", len(secretFiles)) + + // Run enough iterations to reliably trigger the race + const iterations = 50 + + for iter := 0; iter < iterations; iter++ { + // Clear cache to force fresh decryption each iteration + sopsCache = cache.NewCache[string](sopsCacheName) + + var wg sync.WaitGroup + + barrier := make(chan struct{}) + + for i, sf := range secretFiles { + wg.Add(1) + + go func(idx int, filePath string) { + defer wg.Done() + + <-barrier + + opts, err := options.NewTerragruntOptionsForTest(filePath) + if !assert.NoError(t, err, "NewTerragruntOptionsForTest") { + return + } + + opts.WorkingDir = filepath.Dir(filePath) + + // Half goroutines set env var via opts.Env (like auth-provider). + // In buggy code only these acquire the lock. + // The other half run unlocked — that's the race. + if idx%2 == 0 { + opts.Env = map[string]string{testEnvKey: "valid-token"} + } + + l := logger.CreateLogger() + ctx := context.Background() + ctx = WithConfigValues(ctx) + _, pctx := NewParsingContext(ctx, l, opts) + + // Call sopsDecryptFileImpl directly with mock decryptFn + if _, decryptErr := sopsDecryptFileImpl(ctx, pctx, l, filePath, "json", mockDecryptFn); decryptErr != nil { + decryptErrors.Add(1) + } + }(i, sf) + } + + close(barrier) + wg.Wait() + } + + t.Logf("Env var races detected: %d, decrypt errors: %d (across %d iterations x %d files)", + envVarRaces.Load(), decryptErrors.Load(), iterations, len(secretFiles)) + + require.Zero(t, decryptErrors.Load(), + "sopsDecryptFileImpl returned errors — possible regression in decrypt logic") + + require.Zero(t, envVarRaces.Load(), + "Env vars changed during decrypt — race condition detected (issue #5515)") +} + +// TestSOPSDecryptEnvPropagation is a deterministic regression test for +// https://github.com/gruntwork-io/terragrunt/issues/5515 +// +// The original customer-reported bug: sops_decrypt_file() during HCL evaluation +// couldn't authenticate to KMS because auth-provider credentials were not yet +// loaded into opts.Env. This caused SOPS to return empty/wrong secrets. +// +// This test verifies the env propagation contract of sopsDecryptFileImpl: +// - Existing process env vars are preserved (not overridden by opts.Env) +// - Missing env vars from opts.Env are set during decrypt and unset after +// - Without credentials, decrypt fails (reproduces the original bug) +// - Concurrent goroutines with different credentials are properly isolated +func TestSOPSDecryptEnvPropagation(t *testing.T) { //nolint:paralleltest // mutates package-global sopsCache and process env vars + const authKey = "SOPS_TEST_AUTH_CRED" + + origCache := sopsCache + + t.Cleanup(func() { + sopsCache = origCache + + os.Unsetenv(authKey) //nolint:errcheck + }) + + secretFiles := generateTestSecretFiles(t, 1) + secretFile := secretFiles[0] + + // Mock decryptFn that requires authKey to be set — simulates KMS auth. + authRequiringDecryptFn := func(path string, _ string) ([]byte, error) { + token := os.Getenv(authKey) + if token == "" { + return nil, errors.New("KMS auth failed: no credential set") + } + + return os.ReadFile(path) + } + + // Subtest 1: Existing process env vars are preserved (not overridden). + // Models: CI runner has real AWS_SESSION_TOKEN, auth-provider returns empty token. + // sopsDecryptFileImpl must NOT override the real token with empty — SOPS uses process env. + t.Run("existing_process_env_preserved", func(t *testing.T) { //nolint:paralleltest // mutates sopsCache and process env + sopsCache = cache.NewCache[string](sopsCacheName) + + t.Setenv(authKey, "real-ci-token") + + opts, err := options.NewTerragruntOptionsForTest(secretFile) + require.NoError(t, err) + + opts.WorkingDir = filepath.Dir(secretFile) + // opts.Env has empty value for authKey (like auth-provider returning empty session token) + opts.Env = map[string]string{authKey: ""} + + l := logger.CreateLogger() + ctx := context.Background() + ctx = WithConfigValues(ctx) + _, pctx := NewParsingContext(ctx, l, opts) + + result, err := sopsDecryptFileImpl(ctx, pctx, l, secretFile, "json", authRequiringDecryptFn) + require.NoError(t, err, "decrypt must succeed using existing process env credentials") + assert.Contains(t, result, `"value":"secret-from-unit-01"`) + + // Process env must still have the real token — not overridden + assert.Equal(t, "real-ci-token", os.Getenv(authKey), + "existing process env var must not be overridden") + }) + + // Subtest 2: Credentials injected when absent from process env. + // Models: first run, auth-provider loaded creds into opts.Env, process env was empty. + t.Run("new_creds_set_when_absent_from_process_env", func(t *testing.T) { //nolint:paralleltest // mutates sopsCache and process env + sopsCache = cache.NewCache[string](sopsCacheName) + + os.Unsetenv(authKey) //nolint:errcheck + + opts, err := options.NewTerragruntOptionsForTest(secretFile) + require.NoError(t, err) + + opts.WorkingDir = filepath.Dir(secretFile) + opts.Env = map[string]string{authKey: "fresh-token"} + + l := logger.CreateLogger() + ctx := context.Background() + ctx = WithConfigValues(ctx) + _, pctx := NewParsingContext(ctx, l, opts) + + result, err := sopsDecryptFileImpl(ctx, pctx, l, secretFile, "json", authRequiringDecryptFn) + require.NoError(t, err, "decrypt must succeed with fresh credentials from opts.Env") + assert.Contains(t, result, `"value":"secret-from-unit-01"`) + + // Process env must be unset (not empty string) after decrypt + _, exists := os.LookupEnv(authKey) + assert.False(t, exists, + "env var must be unset after decrypt, not set to empty string") + }) + + // Subtest 3: Missing credentials cause decrypt failure. + // Reproduces the ORIGINAL bug: auth-provider hasn't run yet, opts.Env has no + // auth token, process env has no auth token → SOPS can't authenticate to KMS. + t.Run("missing_creds_fails_decrypt", func(t *testing.T) { //nolint:paralleltest // mutates sopsCache and process env + sopsCache = cache.NewCache[string](sopsCacheName) + + os.Unsetenv(authKey) //nolint:errcheck + + opts, err := options.NewTerragruntOptionsForTest(secretFile) + require.NoError(t, err) + + opts.WorkingDir = filepath.Dir(secretFile) + // Empty env — simulates auth-provider NOT having run (the original bug) + opts.Env = map[string]string{} + + l := logger.CreateLogger() + ctx := context.Background() + ctx = WithConfigValues(ctx) + _, pctx := NewParsingContext(ctx, l, opts) + + _, err = sopsDecryptFileImpl(ctx, pctx, l, secretFile, "json", authRequiringDecryptFn) + require.Error(t, err, + "decrypt must fail without auth credentials — reproduces original issue #5515") + }) + + // Subtest 4: Concurrent goroutines with DIFFERENT auth tokens are isolated. + // Models production: multiple units decrypt in parallel, each with different + // auth-provider credentials. The lock must ensure each sees its OWN token. + t.Run("concurrent_different_creds_isolated", func(t *testing.T) { //nolint:paralleltest // mutates sopsCache and process env + const numGoroutines = 5 + + sopsCache = cache.NewCache[string](sopsCacheName) + + os.Unsetenv(authKey) //nolint:errcheck + + files := generateTestSecretFiles(t, numGoroutines) + + var wg sync.WaitGroup + + barrier := make(chan struct{}) + + var failures atomic.Int32 + + for i, f := range files { + wg.Add(1) + + go func(idx int, filePath string) { + defer wg.Done() + + <-barrier + + expectedToken := fmt.Sprintf("token-%d", idx) + + opts, err := options.NewTerragruntOptionsForTest(filePath) + if !assert.NoError(t, err) { + failures.Add(1) + + return + } + + opts.WorkingDir = filepath.Dir(filePath) + opts.Env = map[string]string{authKey: expectedToken} + + // Each goroutine's decryptFn verifies it sees ITS OWN token + tokenCheckFn := func(path string, _ string) ([]byte, error) { + actual := os.Getenv(authKey) + if actual != expectedToken { + return nil, fmt.Errorf("goroutine %d: expected %q, got %q", idx, expectedToken, actual) + } + + return os.ReadFile(path) + } + + l := logger.CreateLogger() + ctx := context.Background() + ctx = WithConfigValues(ctx) + _, pctx := NewParsingContext(ctx, l, opts) + + result, decryptErr := sopsDecryptFileImpl(ctx, pctx, l, filePath, "json", tokenCheckFn) + if decryptErr != nil { + t.Logf("goroutine %d failed: %v", idx, decryptErr) + failures.Add(1) + + return + } + + expectedPrefix := `{"value":"secret-from-unit-` + if len(result) < len(expectedPrefix) || result[:len(expectedPrefix)] != expectedPrefix { + t.Logf("goroutine %d: wrong content: %s", idx, result) + failures.Add(1) + } + }(i, f) + } + + close(barrier) + wg.Wait() + + require.Zero(t, failures.Load(), + "all goroutines must see their own auth token during decrypt — env isolation failed") + + assert.Empty(t, os.Getenv(authKey), + "process env must be clean after all concurrent decrypts") + }) +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/terragrunt-0.99.1/test/integration_sops_test.go new/terragrunt-0.99.2/test/integration_sops_test.go --- old/terragrunt-0.99.1/test/integration_sops_test.go 2026-01-29 19:26:42.000000000 +0100 +++ new/terragrunt-0.99.2/test/integration_sops_test.go 2026-02-13 15:51:17.000000000 +0100 @@ -14,6 +14,7 @@ "bytes" "encoding/json" "fmt" + "os" "path/filepath" "strings" "testing" @@ -29,6 +30,65 @@ testFixtureSopsMissing = "fixtures/sops-missing" ) +const sopsMultiUnitMainTf = `variable "secret_value" { + type = string +} + +variable "unit_name" { + type = string +} + +output "secret_value" { + value = var.secret_value +} + +output "unit_name" { + value = var.unit_name +} +` + +const sopsMultiUnitTerragruntHcl = `locals { + secret = try(jsondecode(sops_decrypt_file("${get_terragrunt_dir()}/secret.enc.json")), {}) +} + +inputs = { + secret_value = lookup(local.secret, "example_key", "DECRYPTION_FAILED") + unit_name = "%s" +} +` + +// generateSopsMultiUnitFixtures creates numUnits directories, each with a +// main.tf, terragrunt.hcl, and a copy of the existing SOPS-encrypted secrets.json. +// Only requires the test PGP key imported in GPG — no sops CLI needed. +func generateSopsMultiUnitFixtures(t *testing.T, numUnits int) string { + t.Helper() + + dir := t.TempDir() + + // Reuse existing SOPS-encrypted file from the sops fixture as template + encData, err := os.ReadFile("fixtures/sops/secrets.json") + require.NoError(t, err, "failed to read SOPS template file") + + for i := 1; i <= numUnits; i++ { + unitName := fmt.Sprintf("unit-%02d", i) + unitDir := filepath.Join(dir, unitName) + require.NoError(t, os.MkdirAll(unitDir, 0755)) + + require.NoError(t, os.WriteFile( + filepath.Join(unitDir, "main.tf"), + []byte(sopsMultiUnitMainTf), 0644)) + + require.NoError(t, os.WriteFile( + filepath.Join(unitDir, "terragrunt.hcl"), + []byte(fmt.Sprintf(sopsMultiUnitTerragruntHcl, unitName)), 0644)) + + require.NoError(t, os.WriteFile( + filepath.Join(unitDir, "secret.enc.json"), encData, 0644)) + } + + return dir +} + func TestSOPSDecryptedCorrectly(t *testing.T) { t.Parallel() @@ -154,6 +214,60 @@ assert.Contains(t, outputs["ini_value"].Value, "password = potato") } +// TestSOPSDecryptedCorrectlyRunAllMultipleUnits tests that SOPS decryption works correctly +// when multiple units with the same encrypted secret are processed in parallel via run --all. +func TestSOPSDecryptedCorrectlyRunAllMultipleUnits(t *testing.T) { + t.Parallel() + + const numUnits = 12 + + rootPath := generateSopsMultiUnitFixtures(t, numUnits) + + // Run apply on all units in parallel — this is where the race condition manifests + helpers.RunTerragrunt( + t, + fmt.Sprintf( + "terragrunt run --all --non-interactive --working-dir %s -- apply -auto-approve", + rootPath, + ), + ) + + // Verify each unit successfully decrypted the secret + for i := 1; i <= numUnits; i++ { + unitName := fmt.Sprintf("unit-%02d", i) + unitPath := filepath.Join(rootPath, unitName) + stdout := bytes.Buffer{} + stderr := bytes.Buffer{} + + err := helpers.RunTerragruntCommand( + t, + "terragrunt output -no-color -json --non-interactive --working-dir "+unitPath, + &stdout, + &stderr, + ) + require.NoError(t, err, "Failed to get output for %s: %s", unitName, stderr.String()) + + outputs := map[string]helpers.TerraformOutput{} + require.NoError(t, json.Unmarshal(stdout.Bytes(), &outputs), "Failed to parse output for %s", unitName) + + // Check for the specific failure mode from issue #5515: + // If SOPS decryption fails due to race, try() returns {} and lookup returns "DECRYPTION_FAILED" + secretValue, ok := outputs["secret_value"].Value.(string) + require.True(t, ok, "secret_value should be a string for %s", unitName) + + if secretValue == "DECRYPTION_FAILED" { + t.Fatalf("SOPS race condition detected! Unit %s got DECRYPTION_FAILED. "+ + "This indicates sops_decrypt_file failed and try() returned empty {}.", + unitName) + } + + assert.Equal(t, "example_value", secretValue, + "Unit %s should have correct decrypted secret value", unitName) + assert.Equal(t, unitName, outputs["unit_name"].Value, + "Unit %s should have correct unit name", unitName) + } +} + func TestSOPSTerragruntLogSopsErrors(t *testing.T) { t.Parallel() ++++++ terragrunt.obsinfo ++++++ --- /var/tmp/diff_new_pack.1N3Dt0/_old 2026-02-16 13:17:21.578114104 +0100 +++ /var/tmp/diff_new_pack.1N3Dt0/_new 2026-02-16 13:17:21.586114446 +0100 @@ -1,5 +1,5 @@ name: terragrunt -version: 0.99.1 -mtime: 1769711202 -commit: ab330c6bd7bd18bc06c57b35e34e3236a6fb1dae +version: 0.99.2 +mtime: 1770994277 +commit: f84675a8a1746a4e16a9725e87492b0bf6b01928 ++++++ vendor.tar.gz ++++++ /work/SRC/openSUSE:Factory/terragrunt/vendor.tar.gz /work/SRC/openSUSE:Factory/.terragrunt.new.1977/vendor.tar.gz differ: char 13, line 1
