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, diff --git a/pkg/terraform/parser.go b/pkg/terraform/parser.go index 015de43..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 } @@ -705,8 +721,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..7d41c05 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) @@ -390,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)