Skip to content

run --all crashes with "does not contain a terragrunt.hcl file" on a live, non-deleted, shared dependency — root cause: missing filepath.Join in checkUnitVersionConstraints's fallback parse #6610

Description

@luis-guimaraes-optimuse

Terragrunt version: v1.1.1 and v1.1.2 (confirmed present in both; latest release as of this report)
OpenTofu version: 1.12.x
OS: Linux (GitHub Actions ubuntu-latest runner, and reproduced separately on a local Linux dev machine)

Summary

This is the same error text as #6540 and my own earlier #6554 (closed as a duplicate of #6540), but a distinct root cause in a different package — not a dependency deleted in the diff, and not fixed by #6546. I want to flag that clearly up front so this doesn't get folded into #6540 again: #6554 genuinely was a different bug, and here is the actual root cause with a verified fix.

terragrunt run --all -- plan, scoped with a git-range --filter, intermittently crashes with:

ERROR failed to parse config for unit <path>: You attempted to run terragrunt in a folder that does not contain a terragrunt.hcl file. Please add a terragrunt.hcl file and try again.

Path: "terragrunt.hcl"

on a unit whose terragrunt.hcl genuinely exists, unmodified, on both sides of the diff — nothing is deleted anywhere. terragrunt find/list with the identical filter succeed cleanly. In every occurrence I've observed, the crashing unit is a dependency shared by several other units in the same run (a "hub" dependency) — never an isolated, single-consumer unit.

Root cause (confirmed via source + go build -race + a verified one-line fix)

internal/runner/runnerpool/builder_helpers.go, checkUnitVersionConstraints():

unitConfig := unit.Config()

// This is almost definitely already parsed, but we'll check just in case.
if unitConfig == nil {
    configCtx, pctx := configbridge.NewParsingContext(ctx, l, unitOpts)
    pctx = pctx.WithVenv(v).WithDecodeList(
        config.TerragruntVersionConstraints,
        config.FeatureFlagsBlock,
    )

    var err error

    unitConfig, err = config.PartialParseConfigFile(
        configCtx,
        pctx,
        l,
        unit.ConfigFile(),   // <-- bug: bare basename, not joined with unit.Path()
        nil,
    )
    if err != nil {
        return fmt.Errorf("failed to parse config for unit %s: %w", unit.DisplayPath(), err)
    }
}

unit.ConfigFile() returns a bare basename (e.g. "terragrunt.hcl") by design — that's exactly what it's set to, both by component.NewUnit()'s constructor default (configFile: config.DefaultTerragruntConfigPath) and by createComponentFromPath() in internal/discovery/helpers.go (unit.SetConfigFile(base), where base := filepath.Base(path)). Every other call site that needs the actual file path joins it with unit.Path() first — e.g. internal/runner/runnerpool/runner.go's BuildUnitOpts():

configPath := unit.Path()
if !strings.HasSuffix(configPath, ".hcl") && !strings.HasSuffix(configPath, ".json") {
    fileName := config.DefaultTerragruntConfigPath
    if unit.ConfigFile() != "" {
        fileName = unit.ConfigFile()
    }
    configPath = filepath.Join(unit.Path(), fileName)
}

checkUnitVersionConstraints's fallback is the one call site that doesn't. config.PartialParseConfigFile immediately does:

fileInfo, err := os.Stat(configPath)
if err != nil {
    if errors.Is(err, fs.ErrNotExist) {
        return nil, TerragruntConfigNotFoundError{Path: configPath}
    }
    ...
}

os.Stat("terragrunt.hcl") resolves against the process's current working directory, not the unit's own directory. Under a git-range --filter, that cwd is never any individual unit's directory, so the stat always fails for this code path, exactly reproducing the reported error text and the bare Path: "terragrunt.hcl".

Why this only fires sometimes, and only on shared/hub dependencies: the fallback only runs "if unit.Config() is nil" — the comment above it says "almost definitely already parsed, but we'll check just in case." In practice, a unit reached only as a dependency of multiple sibling units (via internal/discovery/phase_relationship.go's dependencyToDiscover(), which creates it as a lightweight placeholder via component.NewUnit(path) rather than through the main filesystem-discovery walk) can still have a nil Config() at the point checkVersionConstraints's own concurrent pass reaches it, depending on discovery ordering/timing. A unit discovered only once, directly, by the filesystem walk, gets its config eagerly populated and never hits this fallback at all. This is why the crash always lands on a shared dependency and never an isolated unit.

Confirmed fix

     unitConfig, err = config.PartialParseConfigFile(
         configCtx,
         pctx,
         l,
-        unit.ConfigFile(),
+        filepath.Join(unit.Path(), unit.ConfigFile()),
         nil,
     )

(filepath is already imported in this file.)

Verified empirically, against a private ~40-unit OpenTofu/Terragrunt monorepo where this fires reliably under CI's real git-range filter (real S3 remote_state backend, GitHub Actions ubuntu-latest, one dependency referenced by 9 sibling units):

Build Crashes / 20 runs Data races reported
v1.1.1 (release, unmodified) 13/20 20/20 (see note below)
v1.1.2 (release, unmodified) 14/20 0/20
v1.1.2 + this one-line patch 0/25 0/25

All builds run with go build -race for the first two rows; the patched row is the identical v1.1.2 source with only the diff above applied.

A separate, already-fixed race — ruled out, not the cause

While investigating, go build -race on the pinned v1.1.1 caught a real, distinct data race in internal/discovery/phase_relationship.go's dependencyToDiscover(): Unit.SetDiscoveryContext()/SetExternal() were called unsynchronized on a dependency object that can be shared by multiple concurrent discoverers. I traced this and found it's already fixed in v1.1.2 by gating that mutation on created (only the goroutine that actually registers the component touches it). I want to flag this clearly as ruled out as the cause of the crash above, since it's easy to conflate the two: v1.1.2 has zero race reports across 20 runs yet still crashes 14/20 times with the identical error text — proof the two are independent. Mentioning it mainly so it isn't rediscovered and mistakenly credited as "the" fix for this issue.

Reproduction attempts (both negative — noting this for anyone else who tries)

I built two synthetic fixtures matching the shape as closely as I could (real git repo, real remote_state { backend = "local" }, a git-range --filter, a hub unit referenced by many sibling dependency blocks, a second commit touching the diff without deleting anything):

  • 1 hub + 15 consumers (16 units total): 0/20 crashes
  • 1 hub + 15 consumers + 30 unrelated noise units (46 units total): 0/20 crashes

Neither reproduced, over 20 runs each, on the same machine (16 cores) where the real monorepo reproduces at ~65-70%. This matches what #6554 and the unrelated #6002 both independently found: this class of bug resists small synthetic fixtures and appears to depend on real-repo scale/timing (git-worktree materialization cost, total discovery graph size, or both) in a way a quick synthetic repro doesn't capture. I'm not including a "steps to reproduce" script for that reason — the root cause above is verifiable by inspection (compare the flagged call site against every other unit.Path()/unit.ConfigFile() join in the codebase) and the fix is verified against the actual failing environment, even though I can't hand you a portable trigger.

Expected vs actual

Expected: run --all succeeds (or fails with an accurate error) on a live, unmodified, shared dependency.
Actual: intermittent hard crash claiming a missing config file, using a bare relative path that was never joined with the unit's actual directory.

Related issues

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions