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

Reply via email to