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
Details (Click me)\n\n
{{ .Body }}\n
\n", + WhenAddOrUpdateOnly: WhenAddOrUpdateOnly{ + Label: "add-or-update", + }, WhenDestroy: WhenDestroy{ + Label: "destroy", Template: "## :warning: WARNING: Resource Deletion will happen :warning:\n\nThis plan contains **resource deletion**. Please check the plan result very carefully!\n", }, + WhenPlanError: WhenPlanError{ + Label: "error", + }, WhenNoChanges: WhenNoChanges{ Label: "no-changes", }, @@ -105,7 +112,7 @@ func TestLoadFile(t *testing.T) { }, UseRawOutput: false, }, - path: "../example-with-destroy-and-no-changes.tfnotify.yaml", + path: "../example-with-destroy-and-result-labels.tfnotify.yaml", }, ok: true, }, diff --git a/example-with-destroy-and-no-changes.tfnotify.yaml b/example-with-destroy-and-result-labels.tfnotify.yaml similarity index 83% rename from example-with-destroy-and-no-changes.tfnotify.yaml rename to example-with-destroy-and-result-labels.tfnotify.yaml index 75ee6d3..04b791b 100644 --- a/example-with-destroy-and-no-changes.tfnotify.yaml +++ b/example-with-destroy-and-result-labels.tfnotify.yaml @@ -18,10 +18,15 @@ terraform:
{{ .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: + address: + ip_version: "IPV4" + name: "my-another-project" + project: "my-project" + self_link: + + - google_project_iam_member.team_platform[2] + +Plan: 1 to add, 0 to change, 1 to destroy. + +------------------------------------------------------------------------ + +Note: You didn't specify an "-out" parameter to save this plan, so Terraform +can't guarantee that exactly these actions will be performed if +"terraform apply" is subsequently run. +` + +const planHasAddAndUpdateInPlace = ` +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 + ~ update in-place + +Terraform will perform the following actions: + + + google_compute_global_address.my_another_project + id: + address: + ip_version: "IPV4" + name: "my-another-project" + project: "my-project" + self_link: + + ~ google_project_iam_member.team_platform[2] + +Plan: 1 to add, 1 to change, 0 to destroy. + +------------------------------------------------------------------------ + +Note: You didn't specify an "-out" parameter to save this plan, so Terraform +can't guarantee that exactly these actions will be performed if +"terraform apply" is subsequently run. +` + const applySuccessResult = ` data.terraform_remote_state.teams_platform_development: Refreshing state... google_project.my_service: Refreshing state... @@ -307,22 +405,26 @@ func TestPlanParserParse(t *testing.T) { name: "plan ok pattern", body: planSuccessResult, result: ParseResult{ - Result: "Plan: 1 to add, 0 to change, 0 to destroy.", - HasDestroy: false, - HasNoChanges: false, - ExitCode: 0, - Error: nil, + Result: "Plan: 1 to add, 0 to change, 0 to destroy.", + HasAddOrUpdateOnly: true, + HasDestroy: false, + HasNoChanges: false, + HasPlanError: false, + ExitCode: 0, + Error: nil, }, }, { name: "no stdin", body: "", result: ParseResult{ - Result: "", - HasDestroy: false, - HasNoChanges: false, - ExitCode: 1, - Error: errors.New("cannot parse plan result"), + Result: "", + HasAddOrUpdateOnly: false, + HasDestroy: false, + HasNoChanges: false, + HasPlanError: false, + ExitCode: 1, + Error: errors.New("cannot parse plan result"), }, }, { @@ -335,30 +437,64 @@ func TestPlanParserParse(t *testing.T) { * google_sql_database.main: google_sql_database.main: Error reading SQL Database "main" in instance "main-master-instance": googleapi: Error 409: The instance or operation is not in an appropriate state to handle the request., invalidState * google_sql_user.proxyuser_main: 1 error(s) occurred:`, - ExitCode: 1, - Error: nil, + HasAddOrUpdateOnly: false, + HasDestroy: false, + HasNoChanges: false, + HasPlanError: true, + ExitCode: 1, + Error: nil, }, }, { name: "plan no changes", body: planNoChanges, result: ParseResult{ - Result: "No changes. Infrastructure is up-to-date.", - HasDestroy: false, - HasNoChanges: true, - ExitCode: 0, - Error: nil, + Result: "No changes. Infrastructure is up-to-date.", + HasAddOrUpdateOnly: false, + HasDestroy: false, + HasNoChanges: true, + HasPlanError: false, + ExitCode: 0, + Error: nil, }, }, { name: "plan has destroy", body: planHasDestroy, result: ParseResult{ - Result: "Plan: 0 to add, 0 to change, 1 to destroy.", - HasDestroy: true, - HasNoChanges: false, - ExitCode: 0, - Error: nil, + Result: "Plan: 0 to add, 0 to change, 1 to destroy.", + HasAddOrUpdateOnly: false, + HasDestroy: true, + HasNoChanges: false, + HasPlanError: false, + ExitCode: 0, + Error: nil, + }, + }, + { + name: "plan has add and destroy", + body: planHasAddAndDestroy, + result: ParseResult{ + Result: "Plan: 1 to add, 0 to change, 1 to destroy.", + HasAddOrUpdateOnly: false, + HasDestroy: true, + HasNoChanges: false, + HasPlanError: false, + ExitCode: 0, + Error: nil, + }, + }, + { + name: "plan has add and update in place", + body: planHasAddAndUpdateInPlace, + result: ParseResult{ + Result: "Plan: 1 to add, 1 to change, 0 to destroy.", + HasAddOrUpdateOnly: true, + HasDestroy: false, + HasNoChanges: false, + HasPlanError: false, + ExitCode: 0, + Error: nil, }, }, }