diff --git a/README.md b/README.md index 6fc228d..ff004d1 100644 --- a/README.md +++ b/README.md @@ -155,7 +155,7 @@ terraform: # ... ``` -You can also let tfnotify add a label to PRs whose `terraform plan` output result in no change to the current infrastructure. Currently, this feature is for Github labels only. +You can also let tfnotify add a label to PRs depending on the `terraform plan` output result. Currently, this feature is for Github labels only. ```yaml --- @@ -174,8 +174,14 @@ terraform:
{{ .Body }}
+ when_add_or_update_only:
+ label: "add-or-update"
+ when_destroy:
+ label: "destroy"
when_no_changes:
label: "no-changes"
+ when_plan_error:
+ label: "error"
# ...
```
diff --git a/config/config.go b/config/config.go
index 003e91f..d4411d9 100644
--- a/config/config.go
+++ b/config/config.go
@@ -81,14 +81,22 @@ type Fmt struct {
// Plan is a terraform plan config
type Plan struct {
- Template string `yaml:"template"`
- WhenDestroy WhenDestroy `yaml:"when_destroy,omitempty"`
- WhenNoChanges WhenNoChanges `yaml:"when_no_changes,omitempty"`
+ Template string `yaml:"template"`
+ WhenAddOrUpdateOnly WhenAddOrUpdateOnly `yaml:"when_add_or_update_only,omitempty"`
+ WhenDestroy WhenDestroy `yaml:"when_destroy,omitempty"`
+ WhenNoChanges WhenNoChanges `yaml:"when_no_changes,omitempty"`
+ WhenPlanError WhenPlanError `yaml:"when_plan_error,omitempty"`
+}
+
+// WhenAddOrUpdateOnly is a configuration to notify the plan result contains new or updated in place resources
+type WhenAddOrUpdateOnly struct {
+ Label string `yaml:"label,omitempty"`
}
// WhenDestroy is a configuration to notify the plan result contains destroy operation
type WhenDestroy struct {
- Template string `yaml:"template"`
+ Label string `yaml:"label,omitempty"`
+ Template string `yaml:"template,omitempty"`
}
// WhenNoChange is a configuration to add a label when the plan result contains no change
@@ -96,6 +104,11 @@ type WhenNoChanges struct {
Label string `yaml:"label,omitempty"`
}
+// WhenPlanError is a configuration to notify the plan result returns an error
+type WhenPlanError struct {
+ Label string `yaml:"label,omitempty"`
+}
+
// Apply is a terraform apply config
type Apply struct {
Template string `yaml:"template"`
diff --git a/config/config_test.go b/config/config_test.go
index 4bc5ac2..e296373 100644
--- a/config/config_test.go
+++ b/config/config_test.go
@@ -63,7 +63,7 @@ func TestLoadFile(t *testing.T) {
ok: true,
},
{
- file: "../example-with-destroy-and-no-changes.tfnotify.yaml",
+ file: "../example-with-destroy-and-result-labels.tfnotify.yaml",
cfg: Config{
CI: "circleci",
Notifier: Notifier{
@@ -93,9 +93,16 @@ func TestLoadFile(t *testing.T) {
},
Plan: Plan{
Template: "{{ .Title }}\n{{ .Message }}\n{{if .Result}}\n{{ .Result }}\n
\n{{end}}\n{{ .Body }}\n
{{ .Body }}
- when_no_changes:
- label: "no-changes"
+ when_add_or_update_only:
+ label: "add-or-update"
when_destroy:
+ label: "destroy"
template: |
## :warning: WARNING: Resource Deletion will happen :warning:
This plan contains **resource deletion**. Please check the plan result very carefully!
+ when_no_changes:
+ label: "no-changes"
+ when_plan_error:
+ label: "error"
diff --git a/main.go b/main.go
index 0d6ff13..dd0436f 100644
--- a/main.go
+++ b/main.go
@@ -115,7 +115,12 @@ func (t *tfnotify) Run() error {
Template: t.template,
DestroyWarningTemplate: t.destroyWarningTemplate,
WarnDestroy: t.warnDestroy,
- NoChangesLabel: t.config.Terraform.Plan.WhenNoChanges.Label,
+ ResultLabels: github.ResultLabels{
+ AddOrUpdateLabel: t.config.Terraform.Plan.WhenAddOrUpdateOnly.Label,
+ DestroyLabel: t.config.Terraform.Plan.WhenDestroy.Label,
+ NoChangesLabel: t.config.Terraform.Plan.WhenNoChanges.Label,
+ PlanErrorLabel: t.config.Terraform.Plan.WhenPlanError.Label,
+ },
})
if err != nil {
return err
diff --git a/notifier/github/client.go b/notifier/github/client.go
index cea8a5f..1c62bca 100644
--- a/notifier/github/client.go
+++ b/notifier/github/client.go
@@ -48,8 +48,8 @@ type Config struct {
// DestroyWarningTemplate is used only for additional warning
// the plan result contains destroy operation
DestroyWarningTemplate terraform.Template
- // NoChangesLabel is a label to add to PRs when terraform output contains no changes
- NoChangesLabel string
+ // ResultLabels is a set of labels to apply depending on the plan result
+ ResultLabels ResultLabels
}
// PullRequest represents GitHub Pull Request metadata
@@ -117,3 +117,28 @@ func NewClient(cfg Config) (*Client, error) {
func (pr *PullRequest) IsNumber() bool {
return pr.Number != 0
}
+
+// ResultLabels represents the labels to add to the PR depending on the plan result
+type ResultLabels struct {
+ AddOrUpdateLabel string
+ DestroyLabel string
+ NoChangesLabel string
+ PlanErrorLabel string
+}
+
+// HasAnyLabelDefined returns true if any of the internal labels are set
+func (r *ResultLabels) HasAnyLabelDefined() bool {
+ return r.AddOrUpdateLabel != "" || r.DestroyLabel != "" || r.NoChangesLabel != "" || r.PlanErrorLabel != ""
+}
+
+// IsResultLabel returns true if a label matches any of the internal labels
+func (r *ResultLabels) IsResultLabel(label string) bool {
+ switch label {
+ case "":
+ return false
+ case r.AddOrUpdateLabel, r.DestroyLabel, r.NoChangesLabel, r.PlanErrorLabel:
+ return true
+ default:
+ return false
+ }
+}
diff --git a/notifier/github/client_test.go b/notifier/github/client_test.go
index bbe7cf4..e84a117 100644
--- a/notifier/github/client_test.go
+++ b/notifier/github/client_test.go
@@ -179,3 +179,106 @@ func TestIsNumber(t *testing.T) {
}
}
}
+
+func TestHasAnyLabelDefined(t *testing.T) {
+ testCases := []struct {
+ rl ResultLabels
+ want bool
+ }{
+ {
+ rl: ResultLabels{
+ AddOrUpdateLabel: "add-or-update",
+ DestroyLabel: "destroy",
+ NoChangesLabel: "no-changes",
+ PlanErrorLabel: "error",
+ },
+ want: true,
+ },
+ {
+ rl: ResultLabels{
+ AddOrUpdateLabel: "add-or-update",
+ DestroyLabel: "destroy",
+ NoChangesLabel: "",
+ PlanErrorLabel: "error",
+ },
+ want: true,
+ },
+ {
+ rl: ResultLabels{
+ AddOrUpdateLabel: "",
+ DestroyLabel: "",
+ NoChangesLabel: "",
+ PlanErrorLabel: "",
+ },
+ want: false,
+ },
+ {
+ rl: ResultLabels{},
+ want: false,
+ },
+ }
+ for _, testCase := range testCases {
+ if testCase.rl.HasAnyLabelDefined() != testCase.want {
+ t.Errorf("got %v but want %v", testCase.rl.HasAnyLabelDefined(), testCase.want)
+ }
+ }
+}
+
+func TestIsResultLabels(t *testing.T) {
+ testCases := []struct {
+ rl ResultLabels
+ label string
+ want bool
+ }{
+ {
+ rl: ResultLabels{
+ AddOrUpdateLabel: "add-or-update",
+ DestroyLabel: "destroy",
+ NoChangesLabel: "no-changes",
+ PlanErrorLabel: "error",
+ },
+ label: "add-or-update",
+ want: true,
+ },
+ {
+ rl: ResultLabels{
+ AddOrUpdateLabel: "add-or-update",
+ DestroyLabel: "destroy",
+ NoChangesLabel: "no-changes",
+ PlanErrorLabel: "error",
+ },
+ label: "my-label",
+ want: false,
+ },
+ {
+ rl: ResultLabels{
+ AddOrUpdateLabel: "add-or-update",
+ DestroyLabel: "destroy",
+ NoChangesLabel: "no-changes",
+ PlanErrorLabel: "error",
+ },
+ label: "",
+ want: false,
+ },
+ {
+ rl: ResultLabels{
+ AddOrUpdateLabel: "",
+ DestroyLabel: "",
+ NoChangesLabel: "no-changes",
+ PlanErrorLabel: "",
+ },
+ label: "",
+ want: false,
+ },
+ {
+ rl: ResultLabels{},
+ label: "",
+ want: false,
+ },
+ }
+ for _, testCase := range testCases {
+ if testCase.rl.IsResultLabel(testCase.label) != testCase.want {
+ t.Errorf("got %v but want %v", testCase.rl.IsResultLabel(testCase.label), testCase.want)
+ }
+ }
+}
diff --git a/notifier/github/github.go b/notifier/github/github.go
index 3738af8..d5d77d4 100644
--- a/notifier/github/github.go
+++ b/notifier/github/github.go
@@ -10,6 +10,7 @@ import (
type API interface {
IssuesCreateComment(ctx context.Context, number int, comment *github.IssueComment) (*github.IssueComment, *github.Response, error)
IssuesDeleteComment(ctx context.Context, commentID int64) (*github.Response, error)
+ IssuesListLabels(ctx context.Context, number int, opt *github.ListOptions) ([]*github.Label, *github.Response, error)
IssuesListComments(ctx context.Context, number int, opt *github.IssueListCommentsOptions) ([]*github.IssueComment, *github.Response, error)
IssuesAddLabels(ctx context.Context, number int, labels []string) ([]*github.Label, *github.Response, error)
IssuesRemoveLabel(ctx context.Context, number int, label string) (*github.Response, error)
@@ -44,7 +45,12 @@ func (g *GitHub) IssuesAddLabels(ctx context.Context, number int, labels []strin
return g.Client.Issues.AddLabelsToIssue(ctx, g.owner, g.repo, number, labels)
}
-// IssuesAddLabels is a wrapper of https://godoc.org/github.com/google/go-github/github#IssuesService.RemoveLabelForIssue
+// IssuesListLabels is a wrapper of https://godoc.org/github.com/google/go-github/github#IssuesService.ListLabelsByIssue
+func (g *GitHub) IssuesListLabels(ctx context.Context, number int, opt *github.ListOptions) ([]*github.Label, *github.Response, error) {
+ return g.Client.Issues.ListLabelsByIssue(ctx, g.owner, g.repo, number, opt)
+}
+
+// IssuesRemoveLabel is a wrapper of https://godoc.org/github.com/google/go-github/github#IssuesService.RemoveLabelForIssue
func (g *GitHub) IssuesRemoveLabel(ctx context.Context, number int, label string) (*github.Response, error) {
return g.Client.Issues.RemoveLabelForIssue(ctx, g.owner, g.repo, number, label)
}
diff --git a/notifier/github/github_test.go b/notifier/github/github_test.go
index e84a78e..43dda7c 100644
--- a/notifier/github/github_test.go
+++ b/notifier/github/github_test.go
@@ -12,6 +12,7 @@ type fakeAPI struct {
FakeIssuesCreateComment func(ctx context.Context, number int, comment *github.IssueComment) (*github.IssueComment, *github.Response, error)
FakeIssuesDeleteComment func(ctx context.Context, commentID int64) (*github.Response, error)
FakeIssuesListComments func(ctx context.Context, number int, opt *github.IssueListCommentsOptions) ([]*github.IssueComment, *github.Response, error)
+ FakeIssuesListLabels func(ctx context.Context, number int, opts *github.ListOptions) ([]*github.Label, *github.Response, error)
FakeIssuesAddLabels func(ctx context.Context, number int, labels []string) ([]*github.Label, *github.Response, error)
FakeIssuesRemoveLabel func(ctx context.Context, number int, label string) (*github.Response, error)
FakeRepositoriesCreateComment func(ctx context.Context, sha string, comment *github.RepositoryComment) (*github.RepositoryComment, *github.Response, error)
@@ -31,6 +32,10 @@ func (g *fakeAPI) IssuesListComments(ctx context.Context, number int, opt *githu
return g.FakeIssuesListComments(ctx, number, opt)
}
+func (g *fakeAPI) IssuesListLabels(ctx context.Context, number int, opt *github.ListOptions) ([]*github.Label, *github.Response, error) {
+ return g.FakeIssuesListLabels(ctx, number, opt)
+}
+
func (g *fakeAPI) IssuesAddLabels(ctx context.Context, number int, labels []string) ([]*github.Label, *github.Response, error) {
return g.FakeIssuesAddLabels(ctx, number, labels)
}
@@ -76,6 +81,19 @@ func newFakeAPI() fakeAPI {
}
return comments, nil, nil
},
+ FakeIssuesListLabels: func(ctx context.Context, number int, opts *github.ListOptions) ([]*github.Label, *github.Response, error) {
+ labels := []*github.Label{
+ &github.Label{
+ ID: github.Int64(371748792),
+ Name: github.String("label 1"),
+ },
+ &github.Label{
+ ID: github.Int64(371765743),
+ Name: github.String("label 2"),
+ },
+ }
+ return labels, nil, nil
+ },
FakeIssuesAddLabels: func(ctx context.Context, number int, labels []string) ([]*github.Label, *github.Response, error) {
return nil, nil, nil
},
diff --git a/notifier/github/notify.go b/notifier/github/notify.go
index e631b56..212d2b3 100644
--- a/notifier/github/notify.go
+++ b/notifier/github/notify.go
@@ -32,23 +32,28 @@ func (g *NotifyService) Notify(body string) (exit int, err error) {
return result.ExitCode, err
}
}
- if cfg.PR.IsNumber() && cfg.NoChangesLabel != "" {
- // Always attempt to remove the label first so that an IssueLabeled event is created
- resp, err := g.client.API.IssuesRemoveLabel(
- context.Background(),
- cfg.PR.Number,
- cfg.NoChangesLabel,
- )
- // Ignore 404 errors, which are from the PR not having the label
- if err != nil && resp.StatusCode != http.StatusNotFound {
+ if cfg.PR.IsNumber() && cfg.ResultLabels.HasAnyLabelDefined() {
+ err = g.removeResultLabels()
+ if err != nil {
return result.ExitCode, err
}
+ var labelToAdd string
+
+ if result.HasAddOrUpdateOnly {
+ labelToAdd = cfg.ResultLabels.AddOrUpdateLabel
+ } else if result.HasDestroy {
+ labelToAdd = cfg.ResultLabels.DestroyLabel
+ } else if result.HasNoChanges {
+ labelToAdd = cfg.ResultLabels.NoChangesLabel
+ } else if result.HasPlanError {
+ labelToAdd = cfg.ResultLabels.PlanErrorLabel
+ }
- if result.HasNoChanges {
+ if labelToAdd != "" {
_, _, err = g.client.API.IssuesAddLabels(
context.Background(),
cfg.PR.Number,
- []string{cfg.NoChangesLabel},
+ []string{labelToAdd},
)
if err != nil {
return result.ExitCode, err
@@ -118,3 +123,24 @@ func (g *NotifyService) notifyDestoryWarning(body string, result terraform.Parse
Revision: cfg.PR.Revision,
})
}
+
+func (g *NotifyService) removeResultLabels() error {
+ cfg := g.client.Config
+ labels, _, err := g.client.API.IssuesListLabels(context.Background(), cfg.PR.Number, nil)
+ if err != nil {
+ return err
+ }
+
+ for _, l := range labels {
+ labelText := l.GetName()
+ if cfg.ResultLabels.IsResultLabel(labelText) {
+ resp, err := g.client.API.IssuesRemoveLabel(context.Background(), cfg.PR.Number, labelText)
+ // Ignore 404 errors, which are from the PR not having the label
+ if err != nil && resp.StatusCode != http.StatusNotFound {
+ return err
+ }
+ }
+ }
+
+ return nil
+}
diff --git a/notifier/github/notify_test.go b/notifier/github/notify_test.go
index 2cc317b..b3753cb 100644
--- a/notifier/github/notify_test.go
+++ b/notifier/github/notify_test.go
@@ -136,9 +136,14 @@ func TestNotifyNotify(t *testing.T) {
Number: 1,
Message: "message",
},
- Parser: terraform.NewPlanParser(),
- Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate),
- NoChangesLabel: "terraform/no-changes",
+ Parser: terraform.NewPlanParser(),
+ Template: terraform.NewPlanTemplate(terraform.DefaultPlanTemplate),
+ ResultLabels: ResultLabels{
+ AddOrUpdateLabel: "add-or-update",
+ DestroyLabel: "destroy",
+ NoChangesLabel: "no-changes",
+ PlanErrorLabel: "error",
+ },
},
body: "No changes. Infrastructure is up-to-date.",
ok: true,
diff --git a/terraform/parser.go b/terraform/parser.go
index b7cabea..80687dc 100644
--- a/terraform/parser.go
+++ b/terraform/parser.go
@@ -13,11 +13,13 @@ type Parser interface {
// ParseResult represents the result of parsed terraform execution
type ParseResult struct {
- Result string
- HasDestroy bool
- HasNoChanges bool
- ExitCode int
- Error error
+ Result string
+ HasAddOrUpdateOnly bool
+ HasDestroy bool
+ HasNoChanges bool
+ HasPlanError bool
+ ExitCode int
+ Error error
}
// DefaultParser is a parser for terraform commands
@@ -117,22 +119,27 @@ func (p *PlanParser) Parse(body string) ParseResult {
break
}
}
+ var hasPlanError bool
switch {
case p.Pass.MatchString(line):
result = lines[i]
case p.Fail.MatchString(line):
+ hasPlanError = true
result = strings.Join(trimLastNewline(lines[i:]), "\n")
}
hasDestroy := p.HasDestroy.MatchString(line)
hasNoChanges := p.HasNoChanges.MatchString(line)
+ HasAddOrUpdateOnly := !hasNoChanges && !hasDestroy && !hasPlanError
return ParseResult{
- Result: result,
- HasDestroy: hasDestroy,
- HasNoChanges: hasNoChanges,
- ExitCode: exitCode,
- Error: nil,
+ Result: result,
+ HasAddOrUpdateOnly: HasAddOrUpdateOnly,
+ HasDestroy: hasDestroy,
+ HasNoChanges: hasNoChanges,
+ HasPlanError: hasPlanError,
+ ExitCode: exitCode,
+ Error: nil,
}
}
diff --git a/terraform/parser_test.go b/terraform/parser_test.go
index 5988b81..6f13626 100644
--- a/terraform/parser_test.go
+++ b/terraform/parser_test.go
@@ -176,6 +176,104 @@ can't guarantee that exactly these actions will be performed if
"terraform apply" is subsequently run.
`
+const planHasAddAndDestroy = `
+Refreshing Terraform state in-memory prior to plan...
+The refreshed state will be used to calculate this plan, but will not be
+persisted to local or remote state storage.
+
+data.terraform_remote_state.teams_platform_development: Refreshing state...
+google_project.my_project: Refreshing state...
+aws_iam_policy.datadog_aws_integration: Refreshing state...
+aws_iam_user.teams_terraform: Refreshing state...
+aws_iam_role.datadog_aws_integration: Refreshing state...
+google_project_services.my_project: Refreshing state...
+google_bigquery_dataset.gateway_access_log: Refreshing state...
+aws_iam_role_policy_attachment.datadog_aws_integration: Refreshing state...
+google_logging_project_sink.gateway_access_log_bigquery_sink: Refreshing state...
+google_project_iam_member.gateway_access_log_bigquery_sink_writer_is_bigquery_data_editor: Refreshing state...
+google_dns_managed_zone.tfnotifyapps_com: Refreshing state...
+google_dns_record_set.dev_tfnotifyapps_com: Refreshing state...
+google_project_iam_member.team_platform[1]: Refreshing state...
+google_project_iam_member.team_platform[2]: Refreshing state...
+google_project_iam_member.team_platform[0]: Refreshing state...
+
+------------------------------------------------------------------------
+
+An execution plan has been generated and is shown below.
+Resource actions are indicated with the following symbols:
+ + create
+ - destroy
+
+Terraform will perform the following actions:
+
+ + google_compute_global_address.my_another_project
+ id: