Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support discovery of symlinked modules #3562

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion cli/commands/catalog/module/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import (
"regexp"
"strings"

"github.com/gruntwork-io/terragrunt/util"

"github.com/gitsight/go-vcsurl"
"github.com/gruntwork-io/go-commons/files"
"github.com/gruntwork-io/terragrunt/internal/errors"
Expand Down Expand Up @@ -82,7 +84,7 @@ func (repo *Repo) FindModules(ctx context.Context) (Modules, error) {
continue
}

err := filepath.Walk(modulesPath,
err := util.WalkWithSymlinks(modulesPath,
func(dir string, remote os.FileInfo, err error) error {
if err != nil {
return err
Expand Down
2 changes: 1 addition & 1 deletion config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -650,7 +650,7 @@ func GetDefaultConfigPath(workingDir string) string {
func FindConfigFilesInPath(rootPath string, terragruntOptions *options.TerragruntOptions) ([]string, error) {
configFiles := []string{}

err := filepath.Walk(rootPath, func(path string, info os.FileInfo, err error) error {
err := util.WalkWithSymlinks(rootPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
Expand Down
2 changes: 1 addition & 1 deletion terraform/source.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ func (src Source) EncodeSourceVersion() (string, error) {
sourceHash := sha256.New()
sourceDir := filepath.Clean(src.CanonicalSourceURL.Path)

err := filepath.Walk(sourceDir, func(path string, info os.FileInfo, err error) error {
err := util.WalkWithSymlinks(sourceDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
// If we've encountered an error while walking the tree, give up
return err
Expand Down
3 changes: 3 additions & 0 deletions test/fixtures/stack/disjoint-symlinks/a/terragrunt.hcl
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
terraform {
source = "../module"
}
5 changes: 5 additions & 0 deletions test/fixtures/stack/disjoint-symlinks/module/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
resource "null_resource" "a" {}

output "a" {
value = null_resource.a.id
}
78 changes: 78 additions & 0 deletions test/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"regexp"
"strings"
"testing"
"time"

runall "github.com/gruntwork-io/terragrunt/cli/commands/run-all"
"github.com/gruntwork-io/terragrunt/cli/commands/terraform"
Expand Down Expand Up @@ -43,6 +44,7 @@ const (
testFixtureDisabledModule = "fixtures/disabled/"
testFixtureDisabledPath = "fixtures/disabled-path/"
testFixtureDisjoint = "fixtures/stack/disjoint"
textFixtureDisjointSymlinks = "fixtures/stack/disjoint-symlinks"
testFixtureDownload = "fixtures/download"
testFixtureEmptyState = "fixtures/empty-state/"
testFixtureEnvVarsBlockPath = "fixtures/env-vars-block/"
Expand Down Expand Up @@ -637,6 +639,82 @@ func TestTerragruntStackCommandsWithPlanFile(t *testing.T) {
helpers.RunTerragrunt(t, "terragrunt apply-all plan.tfplan --terragrunt-log-level info --terragrunt-non-interactive --terragrunt-working-dir "+disjointEnvironmentPath)
}

func TestTerragruntStackCommandsWithSymlinks(t *testing.T) {
t.Parallel()

// please be aware that helpers.CopyEnvironment resolves symlinks statically,
// so the symlinked directories are copied physically, which defeats the purpose of this test,
// therefore we are going to create the symlinks manually in the destination directory
tmpEnvPath, err := filepath.EvalSymlinks(helpers.CopyEnvironment(t, textFixtureDisjointSymlinks))
require.NoError(t, err)
disjointSymlinksEnvironmentPath := util.JoinPath(tmpEnvPath, textFixtureDisjointSymlinks)
require.NoError(t, os.Symlink(util.JoinPath(disjointSymlinksEnvironmentPath, "a"), util.JoinPath(disjointSymlinksEnvironmentPath, "b")))
require.NoError(t, os.Symlink(util.JoinPath(disjointSymlinksEnvironmentPath, "a"), util.JoinPath(disjointSymlinksEnvironmentPath, "c")))

helpers.CleanupTerraformFolder(t, disjointSymlinksEnvironmentPath)

// perform the first initialization
_, stderr, err := helpers.RunTerragruntCommandWithOutput(t, "terragrunt run-all init --terragrunt-log-level info --terragrunt-non-interactive --terragrunt-working-dir "+disjointSymlinksEnvironmentPath)
require.NoError(t, err)
assert.Contains(t, stderr, "Downloading Terraform configurations from ./module into ./a/.terragrunt-cache")
assert.Contains(t, stderr, "Downloading Terraform configurations from ./module into ./b/.terragrunt-cache")
assert.Contains(t, stderr, "Downloading Terraform configurations from ./module into ./c/.terragrunt-cache")

// perform the second initialization and make sure that the cache is not downloaded again
_, stderr, err = helpers.RunTerragruntCommandWithOutput(t, "terragrunt run-all init --terragrunt-log-level info --terragrunt-non-interactive --terragrunt-working-dir "+disjointSymlinksEnvironmentPath)
require.NoError(t, err)
assert.NotContains(t, stderr, "Downloading Terraform configurations from ./module into ./a/.terragrunt-cache")
assert.NotContains(t, stderr, "Downloading Terraform configurations from ./module into ./b/.terragrunt-cache")
assert.NotContains(t, stderr, "Downloading Terraform configurations from ./module into ./c/.terragrunt-cache")

// validate the modules
_, stderr, err = helpers.RunTerragruntCommandWithOutput(t, "terragrunt run-all validate --terragrunt-log-level info --terragrunt-non-interactive --terragrunt-working-dir "+disjointSymlinksEnvironmentPath)
require.NoError(t, err)
assert.Contains(t, stderr, "Module ./a")
assert.Contains(t, stderr, "Module ./b")
assert.Contains(t, stderr, "Module ./c")

// touch the "module/main.tf" file to change the timestamp and make sure that the cache is downloaded again
require.NoError(t, os.Chtimes(util.JoinPath(disjointSymlinksEnvironmentPath, "module/main.tf"), time.Now(), time.Now()))

// perform the initialization and make sure that the cache is downloaded again
_, stderr, err = helpers.RunTerragruntCommandWithOutput(t, "terragrunt run-all init --terragrunt-log-level info --terragrunt-non-interactive --terragrunt-working-dir "+disjointSymlinksEnvironmentPath)
require.NoError(t, err)
assert.Contains(t, stderr, "Downloading Terraform configurations from ./module into ./a/.terragrunt-cache")
assert.Contains(t, stderr, "Downloading Terraform configurations from ./module into ./b/.terragrunt-cache")
assert.Contains(t, stderr, "Downloading Terraform configurations from ./module into ./c/.terragrunt-cache")
}

func TestTerragruntOutputModuleGroupsWithSymlinks(t *testing.T) {
t.Parallel()

// please be aware that helpers.CopyEnvironment resolves symlinks statically,
// so the symlinked directories are copied physically, which defeats the purpose of this test,
// therefore we are going to create the symlinks manually in the destination directory
tmpEnvPath, err := filepath.EvalSymlinks(helpers.CopyEnvironment(t, textFixtureDisjointSymlinks))
require.NoError(t, err)
disjointSymlinksEnvironmentPath := util.JoinPath(tmpEnvPath, textFixtureDisjointSymlinks)
require.NoError(t, os.Symlink(util.JoinPath(disjointSymlinksEnvironmentPath, "a"), util.JoinPath(disjointSymlinksEnvironmentPath, "b")))
require.NoError(t, os.Symlink(util.JoinPath(disjointSymlinksEnvironmentPath, "a"), util.JoinPath(disjointSymlinksEnvironmentPath, "c")))

expectedApplyOutput := fmt.Sprintf(`
{
"Group 1": [
"%[1]s/a",
"%[1]s/b",
"%[1]s/c"
]
}`, disjointSymlinksEnvironmentPath)

helpers.CleanupTerraformFolder(t, disjointSymlinksEnvironmentPath)
stdout, _, err := helpers.RunTerragruntCommandWithOutput(t, fmt.Sprintf("terragrunt output-module-groups --terragrunt-working-dir %s apply", disjointSymlinksEnvironmentPath))
require.NoError(t, err)

output := strings.ReplaceAll(stdout, " ", "")
expectedOutput := strings.ReplaceAll(strings.ReplaceAll(expectedApplyOutput, "\t", ""), " ", "")
assert.True(t, strings.Contains(strings.TrimSpace(output), strings.TrimSpace(expectedOutput)))
}

func TestInvalidSource(t *testing.T) {
t.Parallel()

Expand Down
103 changes: 102 additions & 1 deletion util/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -640,7 +640,7 @@ func (err PathIsNotFile) Error() string {
func ListTfFiles(directoryPath string) ([]string, error) {
var tfFiles []string

err := filepath.Walk(directoryPath, func(path string, info os.FileInfo, err error) error {
err := WalkWithSymlinks(directoryPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
Expand Down Expand Up @@ -829,3 +829,104 @@ func Copy(ctx context.Context, dst io.Writer, src io.Reader) (int64, error) {

return num, err
}

// WalkWithSymlinks traverses a directory tree, following symbolic links and calling
// the provided function for each file or directory encountered. It handles both regular
// symlinks and circular symlinks without getting into infinite loops.
//
//nolint:funlen
func WalkWithSymlinks(root string, externalWalkFn filepath.WalkFunc) error {
// pathPair keeps track of both the physical (real) path on disk
// and the logical path (how it appears in the walk)
type pathPair struct {
physical string
logical string
}

// visited tracks symlink paths to prevent circular references
// key is combination of realPath:symlinkPath
visited := make(map[string]bool)

// visitedLogical tracks logical paths to prevent duplicates
// when the same directory is reached through different symlinks
visitedLogical := make(map[string]bool)

var walkFn func(pathPair) error

walkFn = func(pair pathPair) error {
return filepath.Walk(pair.physical, func(currentPath string, info os.FileInfo, err error) error {
if err != nil {
return externalWalkFn(currentPath, info, err)
}

// Convert the current physical path to a logical path relative to the walk root
rel, err := filepath.Rel(pair.physical, currentPath)
if err != nil {
return fmt.Errorf("failed to get relative path between %s and %s: %w", pair.physical, currentPath, err)
}

logicalPath := filepath.Join(pair.logical, rel)

realPath, realInfo, err := evalRealPathAndInfo(currentPath)
if err != nil {
return err
}

// Call the provided function only if we haven't seen this logical path before
if !visitedLogical[logicalPath] {
visitedLogical[logicalPath] = true

if err := externalWalkFn(logicalPath, realInfo, nil); err != nil {
return err
}
}

// If we encounter a symlink, resolve and follow it
if info.Mode()&os.ModeSymlink != 0 {
// Skip if we've seen this symlink->target combination before
// This prevents infinite loops with circular symlinks
if visited[realPath+":"+currentPath] {
return nil
}

visited[realPath+":"+currentPath] = true

// If the target is a directory, recursively walk it
if realInfo.IsDir() {
return walkFn(pathPair{
physical: realPath,
logical: logicalPath,
})
}
}

return nil
})
}

realRoot, err := filepath.EvalSymlinks(root)
if err != nil {
return fmt.Errorf("failed to get evalutate sym links for %s: %w", root, err)
}

// Start the walk from the root directory
return walkFn(pathPair{
physical: realRoot,
logical: realRoot,
})
}

func evalRealPathAndInfo(currentPath string) (string, os.FileInfo, error) {
realPath, err := filepath.EvalSymlinks(currentPath)
if err != nil {
return "", nil, fmt.Errorf("failed to get evalutate sym links for %s: %w", currentPath, err)
}

// Get info about the symlink target
realInfo, err := os.Stat(realPath)
if err != nil {
return "", nil, fmt.Errorf("failed to describe file %s: %w", realPath, err)
}

return realPath, realInfo, nil
}
Loading