Repository: gruntwork-io/terragrunt (language: go)

Issue:
mark_as_read api inconsistent with file api
## Describe the enhancement

Right now mark_as_read requires an absolute path to function correctly but file can use a relpath. The docs (until very recently) explain the desired usage pattern is something like file(mark_as_read('myfile.txt')). If we use a relpath for this then this has the opportunity to break!

The inconsistency of api for both mark_as_read vs file is a UX issue.

Example breaking code (ignore the lack of fileset here- I didn't write it!)
```
locals {
  _all_files        = split("\n", run_cmd("--terragrunt-quiet", "ls", "-p", "src"))
  _all_files_marked = [for f in local._all_files : mark_as_read("src/${f}")]
}
```

Example that correctly marks as read:
```
locals {
  _all_files        = split("\n", run_cmd("--terragrunt-quiet", "ls", "-p", "src"))
  _all_files_marked = [for f in local._all_files : mark_as_read(abspath("${get_terragrunt_dir()}/src/${f}"))]
}
```


## Additional context

Flagged by a customer relying on tf files in their infrastructure live repo.


Link to a Proof of Concept if you have one:

- [ ] [PoC]()

Including a PoC can help others understand the feature better and implement it faster.

## RFC Not Needed

- [ ] I have evaluated the complexity of this enhancement, and I believe it does not require an RFC.




Discussion / hints:

Fix patch:
diff --git a/config/config_helpers.go b/config/config_helpers.go
index 2026d8b918..b2609e984e 100644
--- a/config/config_helpers.go
+++ b/config/config_helpers.go
@@ -3,11 +3,13 @@ package config
 import (
 	"encoding/json"
 	"fmt"
+	"maps"
 	"os"
 	"path/filepath"
 	"reflect"
 	"regexp"
 	"runtime"
+	"slices"
 	"strings"
 	"unicode/utf8"
 
@@ -185,17 +187,10 @@ func createTerragruntEvalContext(ctx *ParsingContext, configPath string) (*hcl.E
 	}
 
 	functions := map[string]function.Function{}
-	for k, v := range tfscope.Functions() {
-		functions[k] = v
-	}
-
-	for k, v := range terragruntFunctions {
-		functions[k] = v
-	}
 
-	for k, v := range ctx.PredefinedFunctions {
-		functions[k] = v
-	}
+	maps.Copy(functions, tfscope.Functions())
+	maps.Copy(functions, terragruntFunctions)
+	maps.Copy(functions, ctx.PredefinedFunctions)
 
 	evalCtx := &hcl.EvalContext{
 		Functions: functions,
@@ -355,11 +350,11 @@ func RunCommand(ctx *ParsingContext, args []string) (string, error) {
 		case "--terragrunt-quiet":
 			suppressOutput = true
 
-			args = append(args[:0], args[1:]...)
+			args = slices.Delete(args, 0, 1)
 		case "--terragrunt-global-cache":
 			cachePath = "_global_"
 
-			args = append(args[:0], args[1:]...)
+			args = slices.Delete(args, 0, 1)
 		default:
 			checkOptions = false
 		}
@@ -473,7 +468,7 @@ func FindInParentFolders(
 
 	// To avoid getting into an accidental infinite loop (e.g. do to cyclical symlinks), set a max on the number of
 	// parent folders we'll check
-	for i := 0; i < ctx.TerragruntOptions.MaxFoldersToCheck; i++ {
+	for range ctx.TerragruntOptions.MaxFoldersToCheck {
 		currentDir := filepath.ToSlash(filepath.Dir(previousDir))
 		if currentDir == previousDir {
 			if numParams == matchedPats {
@@ -674,9 +669,11 @@ func ParseTerragruntConfig(ctx *ParsingContext, configPath string, defaultVal *c
 		return *defaultVal, nil
 	}
 
-	path, err := util.CanonicalPath(targetConfig, ctx.TerragruntOptions.WorkingDir)
-	if err != nil {
-		return cty.NilVal, errors.New(err)
+	path := targetConfig
+
+	if !filepath.IsAbs(path) {
+		path = util.JoinPath(ctx.TerragruntOptions.WorkingDir, path)
+		path = filepath.Clean(path)
 	}
 
 	ctx.TerragruntOptions.AppendReadFile(
@@ -702,7 +699,7 @@ func ParseTerragruntConfig(ctx *ParsingContext, configPath string, defaultVal *c
 	// serialize the config for consumption.
 	// NOTE: this will not call terragrunt output, since all the values are cached from the ParseConfigFile call
 	// NOTE: we don't use range here because range will copy the slice, thereby undoing the set attribute.
-	for i := 0; i < len(config.TerragruntDependencies); i++ {
+	for i := range len(config.TerragruntDependencies) {
 		err := config.TerragruntDependencies[i].setRenderedOutputs(ctx)
 		if err != nil {
 			return cty.NilVal, errors.New(err)
@@ -858,13 +855,15 @@ func sopsDecryptFile(ctx *ParsingContext, params []string) (string, error) {
 		return "", errors.New(err)
 	}
 
-	canonicalSourceFile, err := util.CanonicalPath(sourceFile, filepath.Dir(ctx.TerragruntOptions.TerragruntConfigPath))
-	if err != nil {
-		return "", errors.New(err)
+	path := sourceFile
+
+	if !filepath.IsAbs(path) {
+		path = util.JoinPath(ctx.TerragruntOptions.WorkingDir, path)
+		path = filepath.Clean(path)
 	}
 
 	ctx.TerragruntOptions.AppendReadFile(
-		canonicalSourceFile,
+		path,
 		ctx.TerragruntOptions.WorkingDir,
 	)
 
@@ -889,18 +888,18 @@ func sopsDecryptFile(ctx *ParsingContext, params []string) (string, error) {
 		}
 	}
 
-	if val, ok := sopsCache.Get(ctx, canonicalSourceFile); ok {
+	if val, ok := sopsCache.Get(ctx, path); ok {
 		return val, nil
 	}
 
-	rawData, err := decrypt.File(canonicalSourceFile, format)
+	rawData, err := decrypt.File(path, format)
 	if err != nil {
 		return "", errors.New(extractSopsErrors(err))
 	}
 
 	if utf8.Valid(rawData) {
 		value := string(rawData)
-		sopsCache.Put(ctx, canonicalSourceFile, value)
+		sopsCache.Put(ctx, path, value)
 
 		return value, nil
 	}
@@ -1055,9 +1054,9 @@ func readTFVarsFile(ctx *ParsingContext, args []string) (string, error) {
 
 	varFile := args[0]
 
-	varFile, err := util.CanonicalPath(varFile, ctx.TerragruntOptions.WorkingDir)
-	if err != nil {
-		return "", errors.New(err)
+	if !filepath.IsAbs(varFile) {
+		varFile = filepath.Join(ctx.TerragruntOptions.WorkingDir, varFile)
+		varFile = filepath.Clean(varFile)
 	}
 
 	if !util.FileExists(varFile) {
@@ -1075,7 +1074,7 @@ func readTFVarsFile(ctx *ParsingContext, args []string) (string, error) {
 	}
 
 	if strings.HasSuffix(varFile, "json") {
-		var variables map[string]interface{}
+		var variables map[string]any
 		// just want to be sure that the file is valid json
 		if err := json.Unmarshal(fileContents, &variables); err != nil {
 			return "", errors.New(fmt.Errorf("could not unmarshal json body of tfvar file: %w", err))
@@ -1084,7 +1083,7 @@ func readTFVarsFile(ctx *ParsingContext, args []string) (string, error) {
 		return string(fileContents), nil
 	}
 
-	var variables map[string]interface{}
+	var variables map[string]any
 	if err := ParseAndDecodeVarFile(ctx.TerragruntOptions, varFile, fileContents, &variables); err != nil {
 		return "", err
 	}
@@ -1105,9 +1104,14 @@ func markAsRead(ctx *ParsingContext, args []string) (string, error) {
 
 	file := args[0]
 
-	path, err := util.CanonicalPath(file, ctx.TerragruntOptions.WorkingDir)
-	if err != nil {
-		return "", errors.New(err)
+	// Copy the file path to avoid modifying the original.
+	// This is necessary so that the HCL function doesn't
+	// return a different value than the original file path.
+	path := file
+
+	if !filepath.IsAbs(path) {
+		path = filepath.Join(ctx.TerragruntOptions.WorkingDir, path)
+		path = filepath.Clean(path)
 	}
 
 	ctx.TerragruntOptions.AppendReadFile(
@@ -1126,7 +1130,7 @@ func markAsRead(ctx *ParsingContext, args []string) (string, error) {
 
 // ParseAndDecodeVarFile uses the HCL2 file to parse the given varfile string into an HCL file body, and then decode it
 // into the provided output.
-func ParseAndDecodeVarFile(opts *options.TerragruntOptions, varFile string, fileContents []byte, out interface{}) error {
+func ParseAndDecodeVarFile(opts *options.TerragruntOptions, varFile string, fileContents []byte, out any) error {
 	parser := hclparse.NewParser(hclparse.WithLogger(opts.Logger))
 
 	file, err := parser.ParseFromBytes(fileContents, varFile)
@@ -1160,7 +1164,7 @@ func ParseAndDecodeVarFile(opts *options.TerragruntOptions, varFile string, file
 		return nil
 	}
 
-	typedOut, hasType := out.(*map[string]interface{})
+	typedOut, hasType := out.(*map[string]any)
 	if hasType {
 		genericMap, err := ParseCtyValueToMap(ctyVal)
 		if err != nil {
@@ -1190,7 +1194,7 @@ func extractSopsErrors(err error) *errors.MultiError {
 	if errValue.Type().Name() == "getDataKeyError" {
 		groupResultsField := errValue.FieldByName("GroupResults")
 		if groupResultsField.IsValid() && groupResultsField.Kind() == reflect.Slice {
-			for i := 0; i < groupResultsField.Len(); i++ {
+			for i := range groupResultsField.Len() {
 				groupErr := groupResultsField.Index(i)
 				if groupErr.CanInterface() {
 					if err, ok := groupErr.Interface().(error); ok {
diff --git a/configstack/module.go b/configstack/module.go
index 60370f14fc..c37e3a8a02 100644
--- a/configstack/module.go
+++ b/configstack/module.go
@@ -529,16 +529,16 @@ func (modules TerraformModules) flagExcludedUnits(opts *options.TerragruntOption
 
 // flagUnitsThatRead iterates over a module slice and flags all modules that read at least one file in the specified
 // file list in the TerragruntOptions UnitsReading attribute.
-func (modules TerraformModules) flagUnitsThatRead(opts *options.TerragruntOptions) (TerraformModules, error) {
+func (modules TerraformModules) flagUnitsThatRead(opts *options.TerragruntOptions) TerraformModules {
 	// If no UnitsThatRead is specified return the modules list instantly
 	if len(opts.UnitsReading) == 0 {
-		return modules, nil
+		return modules
 	}
 
-	for _, readPath := range opts.UnitsReading {
-		path, err := util.CanonicalPath(readPath, opts.WorkingDir)
-		if err != nil {
-			return nil, err
+	for _, path := range opts.UnitsReading {
+		if !filepath.IsAbs(path) {
+			path = filepath.Join(opts.WorkingDir, path)
+			path = filepath.Clean(path)
 		}
 
 		for _, module := range modules {
@@ -548,7 +548,7 @@ func (modules TerraformModules) flagUnitsThatRead(opts *options.TerragruntOption
 		}
 	}
 
-	return modules, nil
+	return modules
 }
 
 // flagExcludedDirs iterates over a module slice and flags all entries as excluded listed in the terragrunt-exclude-dir CLI flag.
diff --git a/configstack/stack.go b/configstack/stack.go
index 8175d1b2f6..1e888629d0 100644
--- a/configstack/stack.go
+++ b/configstack/stack.go
@@ -451,12 +451,7 @@ func (stack *Stack) ResolveTerraformModules(ctx context.Context, terragruntConfi
 	err = telemetry.Telemetry(ctx, stack.terragruntOptions, "flag_units_that_read", map[string]interface{}{
 		"working_dir": stack.terragruntOptions.WorkingDir,
 	}, func(childCtx context.Context) error {
-		result, err := withExcludedUnits.flagUnitsThatRead(stack.terragruntOptions)
-		if err != nil {
-			return err
-		}
-
-		withUnitsRead = result
+		withUnitsRead = withExcludedUnits.flagUnitsThatRead(stack.terragruntOptions)
 
 		return nil
 	})
diff --git a/options/options.go b/options/options.go
index 048a4c798e..c35d4e0624 100644
--- a/options/options.go
+++ b/options/options.go
@@ -6,10 +6,12 @@ import (
 	"encoding/json"
 	"fmt"
 	"io"
+	"maps"
 	"math"
 	"os"
 	"path/filepath"
 	"regexp"
+	"slices"
 	"strings"
 	"time"
 
@@ -704,10 +706,8 @@ func (opts *TerragruntOptions) AppendReadFile(file, unit string) {
 		return
 	}
 
-	for _, u := range units {
-		if u == unit {
-			return
-		}
+	if slices.Contains(units, unit) {
+		return
 	}
 
 	opts.Logger.Debugf("Tracking that file %s was read by %s.", file, unit)
@@ -739,13 +739,7 @@ func (opts *TerragruntOptions) DidReadFile(file, unit string) bool {
 		return false
 	}
 
-	for _, u := range units {
-		if u == unit {
-			return true
-		}
-	}
-
-	return false
+	return slices.Contains(units, unit)
 }
 
 // CloneReadFiles creates a copy of the ReadFiles map.
@@ -777,7 +771,7 @@ type EngineOptions struct {
 	Source  string
 	Version string
 	Type    string
-	Meta    map[string]interface{}
+	Meta    map[string]any
 }
 
 // ErrorsConfig extracted errors handling configuration.
@@ -799,7 +793,7 @@ type IgnoreConfig struct {
 	Name            string
 	IgnorableErrors []*ErrorsPattern
 	Message         string
-	Signals         map[string]interface{}
+	Signals         map[string]any
 }
 
 type ErrorsPattern struct {
@@ -870,7 +864,7 @@ func (opts *TerragruntOptions) RunWithErrorHandling(ctx context.Context, operati
 	}
 }
 
-func (opts *TerragruntOptions) handleIgnoreSignals(signals map[string]interface{}) error {
+func (opts *TerragruntOptions) handleIgnoreSignals(signals map[string]any) error {
 	workingDir := opts.WorkingDir
 	signalsFile := filepath.Join(workingDir, DefaultSignalsFile)
 	signalsJSON, err := json.MarshalIndent(signals, "", "  ")
@@ -880,12 +874,13 @@ func (opts *TerragruntOptions) handleIgnoreSignals(signals map[string]interface{
 	}
 
 	const ownerPerms = 0644
+
+	opts.Logger.Warnf("Writing error signals to %s", signalsFile)
+
 	if err := os.WriteFile(signalsFile, signalsJSON, ownerPerms); err != nil {
 		return fmt.Errorf("failed to write signals file %s: %w", signalsFile, err)
 	}
 
-	opts.Logger.Warnf("Written error signals to %s", signalsFile)
-
 	return nil
 }
 
@@ -894,7 +889,7 @@ type ErrorAction struct {
 	ShouldIgnore   bool
 	ShouldRetry    bool
 	IgnoreMessage  string
-	IgnoreSignals  map[string]interface{}
+	IgnoreSignals  map[string]any
 	RetryMessage   string
 	RetryAttempts  int
 	RetrySleepSecs int
@@ -917,12 +912,10 @@ func (c *ErrorsConfig) ProcessError(opts *TerragruntOptions, err error, currentA
 		if isIgnorable {
 			action.ShouldIgnore = true
 			action.IgnoreMessage = ignoreBlock.Message
-			action.IgnoreSignals = make(map[string]interface{})
+			action.IgnoreSignals = make(map[string]any)
 
 			// Convert cty.Value map to regular map
-			for k, v := range ignoreBlock.Signals {
-				action.IgnoreSignals[k] = v
-			}
+			maps.Copy(action.IgnoreSignals, ignoreBlock.Signals)
 
 			return action, nil
 		}


Test patch:
diff --git a/test/fixtures/units-reading/indirect/main.tf b/test/fixtures/units-reading/indirect/main.tf
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/test/fixtures/units-reading/indirect/src/test.txt b/test/fixtures/units-reading/indirect/src/test.txt
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/test/fixtures/units-reading/indirect/terragrunt.hcl b/test/fixtures/units-reading/indirect/terragrunt.hcl
new file mode 100644
index 0000000000..a0581f8838
--- /dev/null
+++ b/test/fixtures/units-reading/indirect/terragrunt.hcl
@@ -0,0 +1,4 @@
+locals {
+  all_files        = split("\n", run_cmd("--terragrunt-quiet", "ls", "-p", "src"))
+  all_files_marked = [for f in local.all_files : mark_as_read("src/${f}")]
+}
diff --git a/test/integration_errors_test.go b/test/integration_errors_test.go
index db4734729f..6914d0a490 100644
--- a/test/integration_errors_test.go
+++ b/test/integration_errors_test.go
@@ -100,7 +100,7 @@ func TestIgnoreSignal(t *testing.T) {
 	tmpEnvPath := helpers.CopyEnvironment(t, testIgnoreSignalErrors)
 	rootPath := util.JoinPath(tmpEnvPath, testIgnoreSignalErrors)
 
-	_, stderr, err := helpers.RunTerragruntCommandWithOutput(t, "terragrunt apply -auto-approve --terragrunt-non-interactive --terragrunt-working-dir "+rootPath)
+	_, stderr, err := helpers.RunTerragruntCommandWithOutput(t, "terragrunt apply -auto-approve --non-interactive --working-dir "+rootPath)
 
 	require.NoError(t, err)
 	assert.Contains(t, stderr, "Ignoring error example1")
diff --git a/test/integration_units_reading_test.go b/test/integration_units_reading_test.go
index 8170f23e91..68d91e46a9 100644
--- a/test/integration_units_reading_test.go
+++ b/test/integration_units_reading_test.go
@@ -1,6 +1,7 @@
 package test_test
 
 import (
+	"path/filepath"
 	"regexp"
 	"strings"
 	"testing"
@@ -31,6 +32,7 @@ func TestUnitsReading(t *testing.T) {
 			name:         "empty",
 			unitsReading: []string{},
 			expectedUnits: []string{
+				"indirect",
 				"reading-from-tf",
 				"reading-hcl",
 				"reading-hcl-and-tfvars",
@@ -121,6 +123,15 @@ func TestUnitsReading(t *testing.T) {
 				"reading-tfvars",
 			},
 		},
+		{
+			name: "indirect",
+			unitsReading: []string{
+				filepath.Join("indirect", "src", "test.txt"),
+			},
+			expectedUnits: []string{
+				"indirect",
+			},
+		},
 	}
 
 	includedLogEntryRegex := regexp.MustCompile(`=> Module ./([^ ]+) \(excluded: false`)
@@ -132,18 +143,18 @@ func TestUnitsReading(t *testing.T) {
 			tmpEnvPath := helpers.CopyEnvironment(t, testFixtureUnitsReading)
 			rootPath := util.JoinPath(tmpEnvPath, testFixtureUnitsReading)
 
-			cmd := "terragrunt run-all plan --terragrunt-non-interactive --terragrunt-log-level trace --terragrunt-working-dir " + rootPath
+			cmd := "terragrunt run-all plan --non-interactive --log-level trace --working-dir " + rootPath
 
-			for _, unit := range tt.unitsReading {
-				cmd = cmd + " --terragrunt-queue-include-units-reading " + unit
+			for _, f := range tt.unitsReading {
+				cmd = cmd + " --queue-include-units-reading " + f
 			}
 
 			for _, unit := range tt.unitsIncluding {
-				cmd = cmd + " --terragrunt-include-dir " + unit
+				cmd = cmd + " --queue-include-dir " + unit
 			}
 
 			for _, unit := range tt.unitsExcluding {
-				cmd = cmd + " --terragrunt-exclude-dir " + unit
+				cmd = cmd + " --queue-exclude-dir " + unit
 			}
 
 			_, stderr, err := helpers.RunTerragruntCommandWithOutput(t, cmd)