From e831057d5cb85b5b5bb547b4bf465bb7ab7e30ca Mon Sep 17 00:00:00 2001 From: Tushar Bhatia Date: Fri, 19 Jun 2026 07:57:09 +0530 Subject: [PATCH 1/3] fix(parser): preserve terraform plan indentation in consolidated mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The terragrunt prefix regex used `:\s*` to strip the timestamp+module prefix from each stdout line. The greedy `\s*` ate not only the single separator space terragrunt inserts between `tf:` and the wrapped output, but also the leading 2/4/6/… spaces of `terraform plan`'s own diff indentation. Result: every `# resource will be created` and `+ field = …` line in the rendered Change Result block lands at column 0, making the diff unreadable. Switch the trailing `:\s*` to `: ?` (optional single space). Now exactly the separator space is consumed; terraform's indentation survives intact. Verified end-to-end via the localfile notifier: before: `+ resource "x" "y" {` (column 0) after: ` + resource "x" "y" {` (2-space indent preserved) after: ` + id = (known after apply)` (6-space indent preserved) Added a regression test that asserts 2- and 6-space indents survive. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/terraform/parser.go | 10 +++++++-- pkg/terraform/parser_terragrunt_test.go | 30 +++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/pkg/terraform/parser.go b/pkg/terraform/parser.go index 015de43..d3ebbd8 100644 --- a/pkg/terraform/parser.go +++ b/pkg/terraform/parser.go @@ -705,8 +705,14 @@ func trimBars(list []string) []string { return ret } -// terragruntPrefixRe matches Terragrunt timestamp prefixes -var terragruntPrefixRe = regexp.MustCompile(`^\d{2}:\d{2}:\d{2}\.\d{3} (?:STDOUT|STDERR|INFO|ERROR)\s+(?:\[.*?\]\s+)?(?:tfwrapper\.sh|terraform|tf):\s*`) +// terragruntPrefixRe matches Terragrunt timestamp prefixes. +// The trailing `: ?` (optional single space) eats exactly the one separator +// space terragrunt inserts between the colon and the wrapped command's output, +// but preserves the wrapped command's own indentation. With the previous +// `:\s*`, the 2/4/6-space indentation of `terraform plan` was eaten too, +// causing diff lines like `+ field = ...` to render at column 0 in the +// rendered Change Result block. +var terragruntPrefixRe = regexp.MustCompile(`^\d{2}:\d{2}:\d{2}\.\d{3} (?:STDOUT|STDERR|INFO|ERROR)\s+(?:\[.*?\]\s+)?(?:tfwrapper\.sh|terraform|tf): ?`) // stripTerragruntPrefix removes Terragrunt timestamp prefixes func stripTerragruntPrefix(line string) string { diff --git a/pkg/terraform/parser_terragrunt_test.go b/pkg/terraform/parser_terragrunt_test.go index 085af69..fdd7d87 100644 --- a/pkg/terraform/parser_terragrunt_test.go +++ b/pkg/terraform/parser_terragrunt_test.go @@ -313,6 +313,36 @@ Module /path/to/app2 } } +func TestTerragruntParser_ConsolidatedPreservesIndentation(t *testing.T) { + parser := NewTerragruntParser(true) + + // Sample terragrunt run-all output where the underlying `tf plan` + // emitted standard 2/4/6-space indented diff lines. The prefix regex + // must consume only the single separator space between `tf:` and the + // wrapped output, leaving terraform's indentation intact. + input := `10:23:45.001 STDOUT [shared-vpc] tf: Terraform will perform the following actions: +10:23:45.001 STDOUT [shared-vpc] tf: +10:23:45.001 STDOUT [shared-vpc] tf: # terraform_data.canary will be created +10:23:45.001 STDOUT [shared-vpc] tf: + resource "terraform_data" "canary" { +10:23:45.001 STDOUT [shared-vpc] tf: + id = (known after apply) +10:23:45.001 STDOUT [shared-vpc] tf: } +10:23:45.001 STDOUT [shared-vpc] tf: +10:23:45.001 STDOUT [shared-vpc] tf: Plan: 1 to add, 0 to change, 0 to destroy.` + + result := parser.Parse(input) + + // The Change Result block must keep terraform's 2/4/6-space indentation. + if !strings.Contains(result.ChangedResult, " # terraform_data.canary will be created") { + t.Errorf("ChangedResult lost 2-space indent on '# … will be created' line:\n%s", result.ChangedResult) + } + if !strings.Contains(result.ChangedResult, " + resource \"terraform_data\" \"canary\" {") { + t.Errorf("ChangedResult lost 2-space indent on '+ resource …' line:\n%s", result.ChangedResult) + } + if !strings.Contains(result.ChangedResult, " + id = (known after apply)") { + t.Errorf("ChangedResult lost 6-space indent on '+ id …' line:\n%s", result.ChangedResult) + } +} + func TestTerragruntParser_ConsolidatedModuleResults(t *testing.T) { parser := NewTerragruntParser(true) From 54046b08094fa711bc09d614c3f560bdacda02d3 Mon Sep 17 00:00:00 2001 From: Tushar Bhatia Date: Fri, 19 Jun 2026 07:57:18 +0530 Subject: [PATCH 2/3] hotfix:fix the terragrunt regex expression --- pkg/controller/controller.go | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/pkg/controller/controller.go b/pkg/controller/controller.go index 38e8427..1c53ab2 100644 --- a/pkg/controller/controller.go +++ b/pkg/controller/controller.go @@ -222,6 +222,31 @@ func (c *Controller) getPlanNotifier(ctx context.Context) ([]notifier.Notifier, labels = a } + if c.Config.Output != "" { + // Write output to file instead of github comment (plan path). + // Mirrors the apply path; without this, `tfnotify --output X plan` + // drops the notifier entirely and the caller hits + // "no notifier specified at all". + client, err := localfile.NewClient(&localfile.Config{ + OutputFile: c.Config.Output, + Parser: c.Parser, + UseRawOutput: c.Config.Terraform.UseRawOutput, + CI: c.Config.CI.Link, + Template: c.Template, + ParseErrorTemplate: c.ParseErrorTemplate, + Vars: c.Config.Vars, + EmbeddedVarNames: c.Config.EmbeddedVarNames, + Templates: c.Config.Templates, + Masks: c.Config.Masks, + DisableLabel: c.Config.Terraform.Plan.DisableLabel, + }, nil) + if err != nil { + return nil, err + } + notifiers = append(notifiers, client.Notify) + return notifiers, nil + } + if !c.Config.Terraform.Plan.DisableLabel || c.Config.Output == "" { client, err := github.NewClient(ctx, &github.Config{ BaseURL: c.Config.GHEBaseURL, From 4ac7b85bad8d1390515fbf06d2f35dfdd18fbe15 Mon Sep 17 00:00:00 2001 From: Tushar Bhatia Date: Fri, 19 Jun 2026 11:11:17 +0530 Subject: [PATCH 3/3] fix(parser): attribute root-module resources correctly in consolidated mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Terragrunt run-all prefixes each leaf module's stdout with [module/path] brackets; the root module's lines have no bracket. The previous parser only matched the bracketed form, so after a leaf's section ended, root lines were attributed to the prior leaf — e.g. a root IAM change would show up under cluster-citadel-2g/regions/tokyo/shared-vpc in the per-module summary. Add a LogRootModule regex matching unbracketed terragrunt log lines and flip currentModule back to root ("") when one appears, but only when the previous module was set by a [bracket] log prefix. Module headers of the form `Module ` (no log prefix) keep working as before — unbracketed lines that follow them stay attributed to the declared module. --- pkg/terraform/parser.go | 16 +++++++++++ pkg/terraform/parser_terragrunt_test.go | 37 +++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/pkg/terraform/parser.go b/pkg/terraform/parser.go index d3ebbd8..8032975 100644 --- a/pkg/terraform/parser.go +++ b/pkg/terraform/parser.go @@ -97,6 +97,7 @@ type TerragruntParser struct { MovedFrom *regexp.Regexp ModuleHeader *regexp.Regexp LogModule *regexp.Regexp + LogRootModule *regexp.Regexp PlanSummary *regexp.Regexp ApplySummary *regexp.Regexp ActionHeader *regexp.Regexp @@ -158,6 +159,11 @@ func NewTerragruntParser(consolidated bool) *TerragruntParser { MovedFrom: regexp.MustCompile(`^` + prefix + ` *# \(moved from (.*?)\)$`), ModuleHeader: regexp.MustCompile(`^(?:(?:Group \d+)|(?:- )?Module) (.+?)(?:\s+\[run-all\])?$`), LogModule: regexp.MustCompile(`^\d{2}:\d{2}:\d{2}\.\d{3} (?:STDOUT|STDERR|INFO|ERROR)\s+\[(.+?)\]\s`), + // LogRootModule: terragrunt log line for the root module — same shape + // as LogModule but without a [module/path] bracket. We use it to flip + // currentModule back to "" when the run-all output transitions from a + // leaf back into the root, so root resources aren't misattributed. + LogRootModule: regexp.MustCompile(`^\d{2}:\d{2}:\d{2}\.\d{3} (?:STDOUT|STDERR|INFO|ERROR)\s+(?:tfwrapper\.sh|terraform|tf):`), PlanSummary: regexp.MustCompile(`^Plan: (?:(\d+) to import, )?(\d+) to add, (\d+) to change, (\d+) to destroy\.`), ApplySummary: regexp.MustCompile(`^Apply complete! Resources: (\d+) added, (\d+) changed, (\d+) destroyed\.`), ActionHeader: regexp.MustCompile(`^(?:Terraform|OpenTofu) will perform the following actions:$`), @@ -458,6 +464,11 @@ func (p *TerragruntParser) ParseWithConsolidation(body string, consolidated bool } currentModule := "" + // True when currentModule was last set by a [module/path] log prefix. + // We only honour LogRootModule (unbracketed `tf:` line) as a "switch to + // root" signal in that case — otherwise unbracketed log lines belonging + // to a module declared earlier via `Module ` would be misrouted. + moduleFromBracket := false for i, line := range lines { stripped := stripTerragruntPrefix(line) @@ -468,12 +479,17 @@ func (p *TerragruntParser) ParseWithConsolidation(body string, consolidated bool // starts, so headers alone cannot attribute output lines correctly). if m := p.LogModule.FindStringSubmatch(line); len(m) == 2 { //nolint:mnd currentModule = m[1] + moduleFromBracket = true + } else if moduleFromBracket && p.LogRootModule.MatchString(line) { + currentModule = "" + moduleFromBracket = false } else if m := p.ModuleHeader.FindStringSubmatch(line); len(m) > 1 { name := m[1] if name == "." { name = "" } currentModule = name + moduleFromBracket = false continue } diff --git a/pkg/terraform/parser_terragrunt_test.go b/pkg/terraform/parser_terragrunt_test.go index fdd7d87..7d41c05 100644 --- a/pkg/terraform/parser_terragrunt_test.go +++ b/pkg/terraform/parser_terragrunt_test.go @@ -420,6 +420,43 @@ func TestTerragruntParser_ConsolidatedNotEmittedForSingleUnnamed(t *testing.T) { } } +func TestTerragruntParser_ConsolidatedRootModuleAttribution(t *testing.T) { + parser := NewTerragruntParser(true) + + // Matches terragrunt run-all output where one leaf has a [module/path] + // log prefix and the root module does not. Without LogRootModule, the + // root's resources would inherit the prior leaf's currentModule and be + // misattributed. + input := `10:23:45.001 STDOUT [shared-vpc] tf: Terraform will perform the following actions: +10:23:45.001 STDOUT [shared-vpc] tf: # terraform_data.shared_vpc_canary will be created +10:23:45.001 STDOUT [shared-vpc] tf: Plan: 1 to add, 0 to change, 0 to destroy. +10:23:50.001 STDOUT tf: Terraform will perform the following actions: +10:23:50.001 STDOUT tf: # google_project_iam_member.root_iam will be created +10:23:50.001 STDOUT tf: Plan: 1 to add, 0 to change, 0 to destroy.` + + result := parser.Parse(input) + + if len(result.ModuleResults) != 2 { + t.Fatalf("ModuleResults length = %d, want 2:\n%+v", len(result.ModuleResults), result.ModuleResults) + } + + leaf := result.ModuleResults[0] + if leaf.Module != "shared-vpc" { + t.Errorf("ModuleResults[0].Module = %q, want shared-vpc", leaf.Module) + } + if len(leaf.CreatedResources) != 1 || leaf.CreatedResources[0] != "terraform_data.shared_vpc_canary" { + t.Errorf("ModuleResults[0].CreatedResources = %v, want [terraform_data.shared_vpc_canary]", leaf.CreatedResources) + } + + root := result.ModuleResults[1] + if root.Module != "Root module" { + t.Errorf("ModuleResults[1].Module = %q, want \"Root module\"", root.Module) + } + if len(root.CreatedResources) != 1 || root.CreatedResources[0] != "google_project_iam_member.root_iam" { + t.Errorf("ModuleResults[1].CreatedResources = %v, want [google_project_iam_member.root_iam]", root.CreatedResources) + } +} + func TestTerragruntParser_ErrorKeepsDetails(t *testing.T) { parser := NewTerragruntParser(true)