Skip to content
Merged
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
25 changes: 25 additions & 0 deletions pkg/controller/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
26 changes: 24 additions & 2 deletions pkg/terraform/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:$`),
Expand Down Expand Up @@ -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 <path>` would be misrouted.
moduleFromBracket := false

for i, line := range lines {
stripped := stripTerragruntPrefix(line)
Expand All @@ -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
}

Expand Down Expand Up @@ -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 {
Expand Down
67 changes: 67 additions & 0 deletions pkg/terraform/parser_terragrunt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down
Loading