Mark nested local modules as read for Git-based filters
Describe the enhancement
Since Terragrunt 1.1, a unit whose terraform.source points at a local directory has that directory's *.tf, *.tofu and *.hcl files recorded as read (#6207). Git-based filters such as --filter '[main...HEAD]' and --filter-affected therefore select the unit when one of those files changes.
The walk stops at the directory boundary. If a file in that directory contains a module block with a local relative source, the directory it points to is not recorded as read, and a change there does not select any unit.
I think the walk should continue through those blocks. The reasoning behind #6207 was that a unit's plan depends on the code Terraform will load for it, so that code counts as read. A local module call is part of that same code: Terraform loads it in the same init, and a change there changes the unit's plan exactly as a change in the source directory does. Stopping after the first directory leaves the read set describing part of what a plan depends on, and Git filters built on it then have false negatives for precisely the layout the first hop was added for, monorepos with locally composed modules. The information needed to follow the call is already at hand: Terragrunt has walked the directory and has an HCL parser, and Terraform requires source to be a literal string, so no evaluation is involved.
Concretely: after recording a local source directory, parse the module blocks in its files, resolve source values that are local paths (./…, ../…), and record those directories as read too, recursively.
Desired behaviour:
unit.terraform.source = local dir A
→ record A/*.tf as read (already done)
→ for each module block in A/*.tf with a local source B
→ record B/*.tf as read
→ recurse into BExample repository:
repo/
modules/
machines/
main.tf # module "memberships" { source = "../iam-memberships" }
iam-memberships/
main.tf
variables.tf
units/
machines/
terragrunt.hcl # terraform { source = "${get_repo_root()}//modules/machines/." }units/machines/terragrunt.hcl:
terraform {
source = "${get_repo_root()}//modules/machines/."
}modules/machines/main.tf:
module "memberships" {
source = "../iam-memberships"
memberships = local.memberships
}Observed on Terragrunt v1.1.1, with a commit that changes only modules/iam-memberships/main.tf:
$ terragrunt find --json --filter '[origin/main...HEAD]' | jq -r '.[].path'
# (no output)The same commit against modules/machines/main.tf selects units/machines as expected. terragrunt find --json --reading for the unit lists the files of modules/machines/ but nothing from modules/iam-memberships/.
Expected: units/machines is selected in both cases, since a plan of the unit includes the nested module's code.
Additional context
Changes required
- After the local-source walk added for #6207, parse the recorded
*.tf/*.tofu(and.jsonvariants) formoduleblocks. - Terraform requires
sourceto be a literal string, so no expression evaluation is needed. - Follow only local paths (
./,../, or a bare relative path without a registry or URL scheme). Registry, git, S3, and other remote sources are skipped; resolving them would requireinit. - Resolve the path relative to the directory of the file containing the block, record the target directory's files as read, and recurse with a visited set to guard against cycles.
Implications of the feature
--filter-affectedand Git filter expressions stop having false negatives for monorepos that compose local modules. Today a PR touching only a shared local module selects none of its consumers.terragrunt find --readingandlist --readingreport the full set of local files a unit's plan depends on, which downstream tooling (for example Atlantis config generators that derivewhen_modifiedfrom the read set) can rely on without re-implementing Terraform module parsing.- Slightly more file I/O during discovery, bounded by the number of local module directories. Remote sources are untouched, so behaviour for those layouts is unchanged.
Alternatives considered
mark_glob_as_readin every consuming unit, e.g.mark_glob_as_read("${get_repo_root()}/modules/iam-memberships/*.tf"). Works, but duplicates knowledge that already sits in themoduleblock, and a new nested call that nobody marks is silently invisible. This is the class of gap #6207 was meant to close for the first hop.- Combining a Git filter with
source=**/iam-memberships. Only matches units whoseterraform.sourceis that directory, so it does not reach consumers that call it as a nested module. - External parsing of module calls (what for example terragrunt-atlantis-config and its descendants do). Keeps a second HCL parser and a second notion of "affected" alive next to Terragrunt's own.
Level of effort
Probably small. The directory walk and HCL parsing already exist; the change is to feed module-block targets back into the same walk. Main care points are path resolution relative to the calling file and cycle protection.
PoC (Proof of Concept)
RFC Not Needed
- I have evaluated the complexity of this enhancement, and I believe it does not require an RFC.
Source: gruntwork-io/terragrunt