diff --git a/pagerduty/provider.go b/pagerduty/provider.go index 0d7df2c07..14b7ea791 100644 --- a/pagerduty/provider.go +++ b/pagerduty/provider.go @@ -90,7 +90,6 @@ func Provider() *schema.Provider { "pagerduty_business_service": dataSourcePagerDutyBusinessService(), "pagerduty_priority": dataSourcePagerDutyPriority(), "pagerduty_ruleset": dataSourcePagerDutyRuleset(), - "pagerduty_tag": dataSourcePagerDutyTag(), "pagerduty_event_orchestration": dataSourcePagerDutyEventOrchestration(), "pagerduty_event_orchestrations": dataSourcePagerDutyEventOrchestrations(), "pagerduty_event_orchestration_integration": dataSourcePagerDutyEventOrchestrationIntegration(), @@ -120,7 +119,6 @@ func Provider() *schema.Provider { "pagerduty_ruleset_rule": resourcePagerDutyRulesetRule(), "pagerduty_business_service": resourcePagerDutyBusinessService(), "pagerduty_response_play": resourcePagerDutyResponsePlay(), - "pagerduty_tag": resourcePagerDutyTag(), "pagerduty_tag_assignment": resourcePagerDutyTagAssignment(), "pagerduty_service_event_rule": resourcePagerDutyServiceEventRule(), "pagerduty_slack_connection": resourcePagerDutySlackConnection(), diff --git a/pagerdutyplugin/data_source_pagerduty_tag.go b/pagerdutyplugin/data_source_pagerduty_tag.go new file mode 100644 index 000000000..f559ad68e --- /dev/null +++ b/pagerdutyplugin/data_source_pagerduty_tag.go @@ -0,0 +1,87 @@ +package pagerduty + +import ( + "context" + "fmt" + "log" + + "github.com/PagerDuty/go-pagerduty" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +type dataSourceTag struct { + client *pagerduty.Client +} + +var _ datasource.DataSourceWithConfigure = (*dataSourceStandards)(nil) + +func (d *dataSourceTag) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + resp.Diagnostics.Append(ConfigurePagerdutyClient(&d.client, req.ProviderData)...) +} + +func (d *dataSourceTag) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = "pagerduty_tag" +} + +func (d *dataSourceTag) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "label": schema.StringAttribute{ + Required: true, + Description: "The label of the tag to find in the PagerDuty API", + }, + "id": schema.StringAttribute{Computed: true}, + }, + } +} + +func (d *dataSourceTag) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var searchTag string + if d := req.Config.GetAttribute(ctx, path.Root("label"), &searchTag); d.HasError() { + resp.Diagnostics.Append(d...) + return + } + + log.Printf("[INFO] Reading PagerDuty tag") + + // TODO: retry + list, err := d.client.ListTags(pagerduty.ListTagOptions{Query: searchTag}) + if err != nil { + // TODO: if 400 non retryable + resp.Diagnostics.AddError("Error calling ListTags", err.Error()) + // TODO: wait 30 + retry + return + } + + var found *pagerduty.Tag + + for _, tag := range list.Tags { + if tag.Label == searchTag { + found = tag + break + } + } + + if found == nil { + // return retry.NonRetryableError( + resp.Diagnostics.AddError( + fmt.Sprintf("Unable to locate any tag with label: %s", searchTag), + "", + ) + return + } + + model := dataSourceTagModel{ + ID: types.StringValue(found.ID), + Label: types.StringValue(found.Label), + } + resp.Diagnostics.Append(resp.State.Set(ctx, &model)...) +} + +type dataSourceTagModel struct { + ID types.String `tfsdk:"id"` + Label types.String `tfsdk:"label"` +} diff --git a/pagerdutyplugin/data_source_pagerduty_tag_test.go b/pagerdutyplugin/data_source_pagerduty_tag_test.go new file mode 100644 index 000000000..5a3cf4975 --- /dev/null +++ b/pagerdutyplugin/data_source_pagerduty_tag_test.go @@ -0,0 +1,63 @@ +package pagerduty + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" +) + +func TestAccDataSourcePagerDutyTag_Basic(t *testing.T) { + tag := fmt.Sprintf("tf-%s", acctest.RandString(5)) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV5ProviderFactories: testAccProtoV5ProviderFactories(), + Steps: []resource.TestStep{ + { + Config: testAccDataSourcePagerDutyTagConfig(tag), + Check: resource.ComposeTestCheckFunc( + testAccDataSourcePagerDutyTag("pagerduty_tag.test", "data.pagerduty_tag.by_label"), + ), + }, + }, + }) +} + +func testAccDataSourcePagerDutyTag(src, n string) resource.TestCheckFunc { + return func(s *terraform.State) error { + srcR := s.RootModule().Resources[src] + srcA := srcR.Primary.Attributes + + r := s.RootModule().Resources[n] + a := r.Primary.Attributes + + if a["id"] == "" { + return fmt.Errorf("Expected to get a tag ID from PagerDuty") + } + + testAtts := []string{"id", "label"} + + for _, att := range testAtts { + if a[att] != srcA[att] { + return fmt.Errorf("Expected the tag %s to be: %s, but got: %s", att, srcA[att], a[att]) + } + } + + return nil + } +} + +func testAccDataSourcePagerDutyTagConfig(tag string) string { + return fmt.Sprintf(` +resource "pagerduty_tag" "test" { + label = "%s" +} + +data "pagerduty_tag" "by_label" { + label = pagerduty_tag.test.label +} +`, tag) +} diff --git a/pagerdutyplugin/provider.go b/pagerdutyplugin/provider.go index 277a02bab..84ccf04b0 100644 --- a/pagerdutyplugin/provider.go +++ b/pagerdutyplugin/provider.go @@ -49,6 +49,7 @@ func (p *Provider) Schema(ctx context.Context, req provider.SchemaRequest, resp func (p *Provider) DataSources(ctx context.Context) [](func() datasource.DataSource) { return [](func() datasource.DataSource){ + func() datasource.DataSource { return &dataSourceTag{} }, func() datasource.DataSource { return &dataSourceStandards{} }, func() datasource.DataSource { return &dataSourceStandardsResourceScores{} }, func() datasource.DataSource { return &dataSourceStandardsResourcesScores{} }, @@ -57,6 +58,7 @@ func (p *Provider) DataSources(ctx context.Context) [](func() datasource.DataSou func (p *Provider) Resources(ctx context.Context) [](func() resource.Resource) { return [](func() resource.Resource){ + func() resource.Resource { return &resourceTag{} }, func() resource.Resource { return &resourceServiceDependency{} }, } } diff --git a/pagerdutyplugin/resource_pagerduty_tag.go b/pagerdutyplugin/resource_pagerduty_tag.go new file mode 100644 index 000000000..94275c781 --- /dev/null +++ b/pagerdutyplugin/resource_pagerduty_tag.go @@ -0,0 +1,167 @@ +package pagerduty + +import ( + "context" + "errors" + "log" + "time" + + "github.com/PagerDuty/go-pagerduty" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" + "github.com/terraform-providers/terraform-provider-pagerduty/util" +) + +type resourceTag struct { + client *pagerduty.Client +} + +var ( + _ resource.ResourceWithConfigure = (*resourceTag)(nil) + _ resource.ResourceWithImportState = (*resourceTag)(nil) +) + +func (r *resourceTag) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + resp.Diagnostics.Append(ConfigurePagerdutyClient(&r.client, req.ProviderData)...) +} + +func (r *resourceTag) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "pagerduty_tag" +} + +func (r *resourceTag) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "label": schema.StringAttribute{ + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "html_url": schema.StringAttribute{Computed: true}, + "id": schema.StringAttribute{Computed: true}, + "summary": schema.StringAttribute{Computed: true}, + }, + } +} + +func (r *resourceTag) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var model resourceTagModel + if d := req.Config.Get(ctx, &model); d.HasError() { + resp.Diagnostics.Append(d...) + } + tagBody := buildTag(&model) + log.Printf("[INFO] Creating PagerDuty tag %s", tagBody.Label) + + err := retry.RetryContext(ctx, 2*time.Minute, func() *retry.RetryError { + tag, err := r.client.CreateTagWithContext(ctx, tagBody) + if err != nil { + var apiErr pagerduty.APIError + if errors.As(err, &apiErr) && apiErr.StatusCode == 400 { + return retry.NonRetryableError(err) + } + return retry.RetryableError(err) + } + model = flattenTag(tag) + return nil + }) + if err != nil { + resp.Diagnostics.AddError("Error calling CreateTagWithContext", err.Error()) + } + resp.State.Set(ctx, &model) +} + +func (r *resourceTag) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var tagID types.String + if d := req.State.GetAttribute(ctx, path.Root("id"), &tagID); d.HasError() { + resp.Diagnostics.Append(d...) + } + log.Printf("[INFO] Reading PagerDuty tag %s", tagID) + + var model resourceTagModel + err := retry.RetryContext(ctx, 2*time.Minute, func() *retry.RetryError { + tag, err := r.client.GetTagWithContext(ctx, tagID.ValueString()) + if err != nil { + if util.IsBadRequestError(err) { + return retry.NonRetryableError(err) + } + if util.IsNotFoundError(err) { + log.Printf("[WARN] Removing %s because it's gone", tagID.String()) + resp.State.RemoveResource(ctx) + return nil + } + return retry.RetryableError(err) + } + model = flattenTag(tag) + return nil + }) + if err != nil { + resp.Diagnostics.AddError("Error calling GetTagWithContext", err.Error()) + } + resp.State.Set(ctx, &model) +} + +func (r *resourceTag) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { +} + +func (r *resourceTag) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var model resourceTagModel + if d := req.State.Get(ctx, &model); d.HasError() { + resp.Diagnostics.Append(d...) + } + log.Printf("[INFO] Removing PagerDuty tag %s", model.ID) + + err := retry.RetryContext(ctx, 2*time.Minute, func() *retry.RetryError { + err := r.client.DeleteTagWithContext(ctx, model.ID.ValueString()) + if err != nil { + if util.IsBadRequestError(err) { + return retry.NonRetryableError(err) + } + if util.IsNotFoundError(err) { + resp.State.RemoveResource(ctx) + return nil + } + return retry.RetryableError(err) + } + return nil + }) + if err != nil { + resp.Diagnostics.AddError("Error calling DeleteTagWithContext", err.Error()) + return + } + resp.State.RemoveResource(ctx) +} + +func (r *resourceTag) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} + +type resourceTagModel struct { + ID types.String `tfsdk:"id"` + HtmlUrl types.String `tfsdk:"html_url"` + Label types.String `tfsdk:"label"` + Summary types.String `tfsdk:"summary"` +} + +func buildTag(model *resourceTagModel) *pagerduty.Tag { + tag := &pagerduty.Tag{ + Label: model.Label.ValueString(), + } + tag.Type = "tag" + return tag +} + +func flattenTag(tag *pagerduty.Tag) resourceTagModel { + model := resourceTagModel{ + ID: types.StringValue(tag.ID), + HtmlUrl: types.StringValue(tag.HTMLURL), + Label: types.StringValue(tag.Label), + Summary: types.StringValue(tag.Summary), + } + return model +} diff --git a/pagerdutyplugin/resource_pagerduty_tag_test.go b/pagerdutyplugin/resource_pagerduty_tag_test.go new file mode 100644 index 000000000..f6d317324 --- /dev/null +++ b/pagerdutyplugin/resource_pagerduty_tag_test.go @@ -0,0 +1,139 @@ +package pagerduty + +import ( + "context" + "fmt" + "log" + "strings" + "testing" + + "github.com/PagerDuty/go-pagerduty" + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" +) + +func init() { + resource.AddTestSweepers("pagerduty_tag", &resource.Sweeper{ + Name: "pagerduty_tag", + F: testSweepTag, + }) +} + +func testSweepTag(region string) error { + client := testAccProvider.client + ctx := context.Background() + + resp, err := client.ListTags(pagerduty.ListTagOptions{}) + if err != nil { + return err + } + + for _, tag := range resp.Tags { + if strings.HasPrefix(tag.Label, "test") || strings.HasPrefix(tag.Label, "tf-") { + log.Printf("Destroying tag %s (%s)", tag.Label, tag.ID) + if err := client.DeleteTagWithContext(ctx, tag.ID); err != nil { + return err + } + } + } + + return nil +} + +func TestAccPagerDutyTag_Basic(t *testing.T) { + tagLabel := fmt.Sprintf("tf-%s", acctest.RandString(5)) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV5ProviderFactories: testAccProtoV5ProviderFactories(), + CheckDestroy: testAccCheckPagerDutyTagDestroy, + Steps: []resource.TestStep{ + { + Config: testAccCheckPagerDutyTagConfig(tagLabel), + Check: resource.ComposeTestCheckFunc( + testAccCheckPagerDutyTagExists("pagerduty_tag.foo"), + resource.TestCheckResourceAttr( + "pagerduty_tag.foo", "label", tagLabel), + ), + }, + // Validating that externally removed tags are detected and planed for + // re-creation + { + Config: testAccCheckPagerDutyTagConfig(tagLabel), + Check: resource.ComposeTestCheckFunc( + testAccExternallyDestroyTag("pagerduty_tag.foo"), + ), + ExpectNonEmptyPlan: true, + }, + }, + }) +} + +func testAccCheckPagerDutyTagDestroy(s *terraform.State) error { + client := testAccProvider.client + ctx := context.Background() + for _, r := range s.RootModule().Resources { + if r.Type != "pagerduty_tag" { + continue + } + if _, err := client.GetTagWithContext(ctx, r.Primary.ID); err == nil { + return fmt.Errorf("Tag still exists") + } + } + return nil +} + +func testAccCheckPagerDutyTagExists(n string) resource.TestCheckFunc { + return func(s *terraform.State) error { + client := testAccProvider.client + ctx := context.Background() + + rs, ok := s.RootModule().Resources[n] + if !ok { + return fmt.Errorf("Not found: %s", n) + } + if rs.Primary.ID == "" { + return fmt.Errorf("No Tag ID is set") + } + + found, err := client.GetTagWithContext(ctx, rs.Primary.ID) + if err != nil { + return err + } + if found.ID != rs.Primary.ID { + return fmt.Errorf("Tag not found: %v - %v", rs.Primary.ID, found) + } + + return nil + } +} + +func testAccExternallyDestroyTag(n string) resource.TestCheckFunc { + return func(s *terraform.State) error { + client := testAccProvider.client + ctx := context.Background() + + rs, ok := s.RootModule().Resources[n] + if !ok { + return fmt.Errorf("Not found: %s", n) + } + if rs.Primary.ID == "" { + return fmt.Errorf("No Tag ID is set") + } + + if err := client.DeleteTagWithContext(ctx, rs.Primary.ID); err != nil { + return err + } + + return nil + } +} + +func testAccCheckPagerDutyTagConfig(tagLabel string) string { + return fmt.Sprintf(` +resource "pagerduty_tag" "foo" { + label = "%s" +} +`, tagLabel) +} diff --git a/util/http_util.go b/util/http_util.go new file mode 100644 index 000000000..cb5439dd9 --- /dev/null +++ b/util/http_util.go @@ -0,0 +1,32 @@ +package util + +import ( + "errors" + "net/http" + "regexp" + + "github.com/PagerDuty/go-pagerduty" +) + +func IsBadRequestError(err error) bool { + var apiErr pagerduty.APIError + if errors.As(err, &apiErr) { + return apiErr.StatusCode == http.StatusBadRequest + } + return false +} + +var notFoundErrorRegexp = regexp.MustCompile(".*: 404 Not Found$") + +func IsNotFoundError(err error) bool { + var apiErr pagerduty.APIError + if errors.As(err, &apiErr) { + if apiErr.StatusCode == http.StatusNotFound { + return true + } + } + // There are some errors that doesn't stick to expected error interface + // and fallback to a simple text error message that can be capture by + // this regexp. + return notFoundErrorRegexp.MatchString(err.Error()) +}