Repository: gruntwork-io/terragrunt

Issue:
--report-file fails silently due to invalid cross-device link
## Describe the bug

When --report-file is used it can fail silently if os.TempDir() is on a different partition to the report-file path. The temporary file is created but is not moved to the target path.

## Steps To Reproduce

Run terragrunt with the report-file flag e.g. `terragrunt run --all plan --experiment report --report-file report.json` in a system where the temporary directory is not in the same partition as the terragrunt directory. 

For example in manjaro linux /tmp and /home are on separate partitions by default.

The report.json file is never created.

## Expected behavior

The report.json file is created

## Nice to haves

- [ ] Terminal output
- [ ] Screenshots

## Versions

- Terragrunt version: v0.84.1
- OpenTofu/Terraform version: n/a
- Environment details (Ubuntu 20.04, Windows 10, etc.): Manjaro KDE 25

## Additional context

There are two issues here, one is that os.Rename is not reliable, the other is that writer errors are silently ignored in runall.go and graph.go - this seems an intentional decision?


Discussion:

Solution patch:
diff --git a/internal/report/writer.go b/internal/report/writer.go
index c634bd80..ec205f0b 100644
--- a/internal/report/writer.go
+++ b/internal/report/writer.go
@@ -10,6 +10,7 @@ import (
 	"strings"
 	"time"
 
+	"github.com/gruntwork-io/terragrunt/util"
 	"github.com/invopop/jsonschema"
 )
 
@@ -61,7 +62,7 @@ func (r *Report) WriteToFile(path string) error {
 		path = filepath.Join(r.workingDir, path)
 	}
 
-	return os.Rename(tmpFile.Name(), path)
+	return util.MoveFile(tmpFile.Name(), path)
 }
 
 // WriteCSV writes the report to a writer in CSV format.
@@ -192,7 +193,7 @@ func (r *Report) WriteSchemaToFile(path string) error {
 		path = filepath.Join(r.workingDir, path)
 	}
 
-	return os.Rename(tmpFile.Name(), path)
+	return util.MoveFile(tmpFile.Name(), path)
 }
 
 // WriteSchema writes a JSON schema for the report to a writer.
diff --git a/util/file.go b/util/file.go
index e41d330f..580d99a0 100644
--- a/util/file.go
+++ b/util/file.go
@@ -11,6 +11,7 @@ import (
 	"path/filepath"
 	"regexp"
 	"strings"
+	"syscall"
 
 	urlhelper "github.com/hashicorp/go-getter/helper/url"
 
@@ -1020,3 +1021,23 @@ func SanitizePath(baseDir string, file string) (string, error) {
 
 	return fullPath, nil
 }
+
+// MoveFile attempts to rename a file from source to destination, if this fails
+// due to invalid cross-device link it falls back to copying the file contents
+// and deleting the original file.
+func MoveFile(source string, destination string) error {
+	if renameErr := os.Rename(source, destination); renameErr != nil {
+		var sysErr syscall.Errno
+		if errors.As(renameErr, &sysErr) && sysErr == syscall.EXDEV {
+			if moveErr := CopyFile(source, destination); moveErr != nil {
+				return moveErr
+			}
+
+			return os.Remove(source)
+		}
+
+		return renameErr
+	}
+
+	return nil
+}



Test patch:
diff --git a/util/file_test.go b/util/file_test.go
index 7ab27861..fd7dda36 100644
--- a/util/file_test.go
+++ b/util/file_test.go
@@ -724,3 +724,21 @@ func Test_sanitizePath(t *testing.T) {
 		})
 	}
 }
+
+func TestMoveFile(t *testing.T) {
+	t.Parallel()
+	tempDir := t.TempDir()
+
+	src := filepath.Join(tempDir, "src.txt")
+	dst := filepath.Join(tempDir, "dst.txt")
+
+	require.NoError(t, os.WriteFile(src, []byte("test"), 0644))
+	require.NoError(t, util.MoveFile(src, dst))
+
+	// Verify the file was moved
+	_, err := os.Stat(src)
+	require.True(t, os.IsNotExist(err))
+	contents, err := os.ReadFile(dst)
+	require.NoError(t, err)
+	assert.Equal(t, "test", string(contents))
+}