diff --git a/honeycombio/provider.go b/honeycombio/provider.go index c77455a1..60ddbcd9 100644 --- a/honeycombio/provider.go +++ b/honeycombio/provider.go @@ -48,7 +48,6 @@ func Provider(version string) *schema.Provider { }, ResourcesMap: map[string]*schema.Resource{ "honeycombio_board": newBoard(), - "honeycombio_burn_alert": newBurnAlert(), "honeycombio_column": newColumn(), "honeycombio_dataset": newDataset(), "honeycombio_dataset_definition": newDatasetDefinition(), diff --git a/honeycombio/resource_burn_alert.go b/honeycombio/resource_burn_alert.go deleted file mode 100644 index 02876954..00000000 --- a/honeycombio/resource_burn_alert.go +++ /dev/null @@ -1,198 +0,0 @@ -package honeycombio - -import ( - "context" - "fmt" - "strings" - - "github.com/hashicorp/terraform-plugin-sdk/v2/diag" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" - honeycombio "github.com/honeycombio/terraform-provider-honeycombio/client" -) - -func newBurnAlert() *schema.Resource { - return &schema.Resource{ - CreateContext: resourceBurnAlertCreate, - ReadContext: resourceBurnAlertRead, - UpdateContext: resourceBurnAlertUpdate, - DeleteContext: resourceBurnAlertDelete, - Importer: &schema.ResourceImporter{ - StateContext: resourceBurnAlertImport, - }, - Description: "Burn Alerts are used to notify you when your error budget will be exhausted within a given time period.", - - Schema: map[string]*schema.Schema{ - "exhaustion_minutes": { - Type: schema.TypeInt, - Required: true, - Description: "The amount of time, in minutes, remaining before the SLO's error budget will be exhausted and the alert will fire.", - ValidateFunc: validation.IntAtLeast(0), - }, - "slo_id": { - Type: schema.TypeString, - Required: true, - ForceNew: true, - Description: "The ID of the SLO that this Burn Alert is associated with.", - }, - "dataset": { - Type: schema.TypeString, - Required: true, - ForceNew: true, - Description: "The dataset this Burn Alert is associated with. This must be the same as the SLO's dataset.", - }, - "recipient": { - Type: schema.TypeList, - Required: true, - Description: "A Recipient to notify when an alert fires", - Elem: &schema.Resource{ - // TODO can we validate either id or type+target is set? - Schema: map[string]*schema.Schema{ - "id": { - Type: schema.TypeString, - Optional: true, - Computed: true, - Description: "The ID of an already existing recipient. Should not be used in combination with `type` and `target`.", - }, - "type": { - Type: schema.TypeString, - Optional: true, - Computed: true, - ValidateFunc: validation.StringInSlice(recipientTypeStrings(honeycombio.BurnAlertRecipientTypes()), false), - Description: "The type of recipient. Allowed types are `email`, `pagerduty`, `slack` and `webhook`. Should not be used in combination with `id`.", - }, - "target": { - Type: schema.TypeString, - Optional: true, - Computed: true, - Description: "Target of the recipient, this has another meaning depending on the type of recipient. Should not be used in combination with `id`.", - }, - "notification_details": { - Type: schema.TypeList, - Optional: true, - MinItems: 1, - MaxItems: 1, - // value may be set to defaults for a given type - // e.g. PagerDuty type recipients have a `pagerduty_severity` of `critical` - Computed: true, - Elem: &schema.Resource{ - Schema: map[string]*schema.Schema{ - "pagerduty_severity": { - Type: schema.TypeString, - // technically optional, but as its the only value supported for the moment we may as well require it - Required: true, - ValidateFunc: validation.StringInSlice([]string{"info", "warning", "error", "critical"}, false), - }, - }, - }, - }, - }, - }, - }, - }, - } -} - -func resourceBurnAlertImport(ctx context.Context, d *schema.ResourceData, i interface{}) ([]*schema.ResourceData, error) { - // import ID is of the format / - // note that the dataset name can also contain '/' - idSegments := strings.Split(d.Id(), "/") - if len(idSegments) < 2 { - return nil, fmt.Errorf("invalid import ID, supplied ID must be written as /") - } - - dataset := strings.Join(idSegments[0:len(idSegments)-1], "/") - id := idSegments[len(idSegments)-1] - - d.Set("dataset", dataset) - d.SetId(id) - - return []*schema.ResourceData{d}, nil -} - -func resourceBurnAlertCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client := meta.(*honeycombio.Client) - - dataset := d.Get("dataset").(string) - b, err := expandBurnAlert(d) - if err != nil { - return diag.FromErr(err) - } - - b, err = client.BurnAlerts.Create(ctx, dataset, b) - if err != nil { - return diag.FromErr(err) - } - - d.SetId(b.ID) - return resourceBurnAlertRead(ctx, d, meta) -} - -func resourceBurnAlertRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client := meta.(*honeycombio.Client) - - dataset := d.Get("dataset").(string) - - b, err := client.BurnAlerts.Get(ctx, dataset, d.Id()) - if err == honeycombio.ErrNotFound { - d.SetId("") - return nil - } else if err != nil { - return diag.FromErr(err) - } - - d.SetId(b.ID) - d.Set("exhaustion_minutes", b.ExhaustionMinutes) - d.Set("slo_id", b.SLO.ID) - - declaredRecipients, ok := d.Get("recipient").([]interface{}) - if !ok { - return diag.Errorf("failed to parse recipients for Burn Alert %s", b.ID) - } - err = d.Set("recipient", flattenNotificationRecipients(matchNotificationRecipientsWithSchema(b.Recipients, declaredRecipients))) - if err != nil { - return diag.FromErr(err) - } - - return nil -} - -func resourceBurnAlertUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client := meta.(*honeycombio.Client) - - dataset := d.Get("dataset").(string) - b, err := expandBurnAlert(d) - if err != nil { - return diag.FromErr(err) - } - - b, err = client.BurnAlerts.Update(ctx, dataset, b) - if err != nil { - return diag.FromErr(err) - } - - d.SetId(b.ID) - return resourceBurnAlertRead(ctx, d, meta) -} - -func resourceBurnAlertDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client := meta.(*honeycombio.Client) - - dataset := d.Get("dataset").(string) - - err := client.BurnAlerts.Delete(ctx, dataset, d.Id()) - if err != nil { - return diag.FromErr(err) - } - return nil -} - -func expandBurnAlert(d *schema.ResourceData) (*honeycombio.BurnAlert, error) { - b := &honeycombio.BurnAlert{ - ID: d.Id(), - ExhaustionMinutes: d.Get("exhaustion_minutes").(int), - SLO: honeycombio.SLORef{ID: d.Get("slo_id").(string)}, - Recipients: expandNotificationRecipients(d.Get("recipient").([]interface{})), - } - return b, nil -} diff --git a/honeycombio/resource_burn_alert_test.go b/honeycombio/resource_burn_alert_test.go deleted file mode 100644 index 0372826c..00000000 --- a/honeycombio/resource_burn_alert_test.go +++ /dev/null @@ -1,169 +0,0 @@ -package honeycombio - -import ( - "context" - "fmt" - "testing" - - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" - "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" - honeycombio "github.com/honeycombio/terraform-provider-honeycombio/client" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestAccHoneycombioBurnAlert_basic(t *testing.T) { - ctx := context.Background() - c := testAccClient(t) - dataset := testAccDataset() - - sli, err := c.DerivedColumns.Create(ctx, dataset, &honeycombio.DerivedColumn{ - Alias: "sli.acc_ba_test", - Expression: "LT($duration_ms, 1000)", - }) - if err != nil { - t.Error(err) - } - slo, err := c.SLOs.Create(ctx, dataset, &honeycombio.SLO{ - Name: "BA TestAcc SLO", - TimePeriodDays: 14, - TargetPerMillion: 995000, - SLI: honeycombio.SLIRef{Alias: sli.Alias}, - }) - require.NoError(t, err) - // remove SLO, SLI DC at end of test run - t.Cleanup(func() { - c.SLOs.Delete(ctx, dataset, slo.ID) - c.DerivedColumns.Delete(ctx, dataset, sli.ID) - }) - - resource.Test(t, resource.TestCase{ - PreCheck: testAccPreCheck(t), - ProviderFactories: testAccProviderFactories, - Steps: []resource.TestStep{ - { - Config: fmt.Sprintf(` -resource "honeycombio_burn_alert" "test" { - dataset = "%s" - slo_id = "%s" - exhaustion_minutes = 240 # 4 hours - - recipient { - type = "slack" - target = "#test2" - } - -} -`, dataset, slo.ID), - Check: resource.ComposeTestCheckFunc( - testAccCheckBurnAlertExists(t, dataset, "honeycombio_burn_alert.test"), - ), - }, - }, - }) -} - -func TestAccHoneycombioBurnAlert_RecipientById(t *testing.T) { - ctx := context.Background() - c := testAccClient(t) - dataset := testAccDataset() - - sli, err := c.DerivedColumns.Create(ctx, dataset, &honeycombio.DerivedColumn{ - Alias: "sli.acc_ba_test", - Expression: "LT($duration_ms, 1000)", - }) - if err != nil { - t.Error(err) - } - slo, err := c.SLOs.Create(ctx, dataset, &honeycombio.SLO{ - Name: "BA TestAcc SLO", - TimePeriodDays: 14, - TargetPerMillion: 995000, - SLI: honeycombio.SLIRef{Alias: sli.Alias}, - }) - require.NoError(t, err) - // remove SLO, SLI DC at end of test run - t.Cleanup(func() { - c.SLOs.Delete(ctx, dataset, slo.ID) - c.DerivedColumns.Delete(ctx, dataset, sli.ID) - }) - - // add a recipient by ID to verify the diff is stable - resource.Test(t, resource.TestCase{ - PreCheck: testAccPreCheck(t), - ProviderFactories: testAccProviderFactories, - Steps: []resource.TestStep{ - { - Config: fmt.Sprintf(` -resource "honeycombio_email_recipient" "test" { - address = "ba-acctest@example.com" -} - -resource "honeycombio_burn_alert" "test" { - dataset = "%s" - slo_id = "%s" - exhaustion_minutes = 240 # 4 hours - - recipient { - id = honeycombio_email_recipient.test.id - } -} -`, dataset, slo.ID), - Check: resource.ComposeTestCheckFunc( - testAccCheckBurnAlertExists(t, dataset, "honeycombio_burn_alert.test"), - ), - }, - }, - }) - - // test PD Recipient with Severity - resource.Test(t, resource.TestCase{ - PreCheck: testAccPreCheck(t), - ProviderFactories: testAccProviderFactories, - Steps: []resource.TestStep{ - { - Config: fmt.Sprintf(` -resource "honeycombio_pagerduty_recipient" "test" { - integration_key = "09c9d4cacd68933151a1ef1048b67dd5" - integration_name = "BA acctest" -} - -resource "honeycombio_burn_alert" "test" { - dataset = "%s" - slo_id = "%s" - exhaustion_minutes = 0 # budget burnt - - recipient { - id = honeycombio_pagerduty_recipient.test.id - // default severity is critical - } -}`, dataset, slo.ID), - Check: resource.ComposeTestCheckFunc( - testAccCheckBurnAlertExists(t, dataset, "honeycombio_burn_alert.test"), - ), - }, - }, - }) -} - -func testAccCheckBurnAlertExists(t *testing.T, dataset string, resourceName string) resource.TestCheckFunc { - return func(s *terraform.State) error { - resourceState, ok := s.RootModule().Resources[resourceName] - if !ok { - return fmt.Errorf("not found: %s", resourceName) - } - - client := testAccClient(t) - createdBA, err := client.BurnAlerts.Get(context.Background(), dataset, resourceState.Primary.ID) - if err != nil { - return fmt.Errorf("could not find created BurnAlert: %w", err) - } - - assert.Equal(t, resourceState.Primary.ID, createdBA.ID) - assert.Equal(t, resourceState.Primary.Attributes["slo_id"], createdBA.SLO.ID) - assert.Equal(t, resourceState.Primary.Attributes["exhaustion_minutes"], fmt.Sprintf("%v", createdBA.ExhaustionMinutes)) - assert.NotNil(t, resourceState.Primary.Attributes["recipient"]) - - return nil - } -} diff --git a/internal/models/burn_alerts.go b/internal/models/burn_alerts.go new file mode 100644 index 00000000..6a1578f2 --- /dev/null +++ b/internal/models/burn_alerts.go @@ -0,0 +1,11 @@ +package models + +import "github.com/hashicorp/terraform-plugin-framework/types" + +type BurnAlertResourceModel struct { + ID types.String `tfsdk:"id"` + Dataset types.String `tfsdk:"dataset"` + SLOID types.String `tfsdk:"slo_id"` + ExhaustionMinutes types.Int64 `tfsdk:"exhaustion_minutes"` + Recipients []NotificationRecipientModel `tfsdk:"recipient"` +} diff --git a/internal/models/notification_recipients.go b/internal/models/notification_recipients.go new file mode 100644 index 00000000..fe90ff03 --- /dev/null +++ b/internal/models/notification_recipients.go @@ -0,0 +1,14 @@ +package models + +import "github.com/hashicorp/terraform-plugin-framework/types" + +type NotificationRecipientModel struct { + ID types.String `tfsdk:"id"` + Type types.String `tfsdk:"type"` + Target types.String `tfsdk:"target"` + Details []NotificationRecipientDetailsModel `tfsdk:"notification_details"` +} + +type NotificationRecipientDetailsModel struct { + PDSeverity types.String `tfsdk:"pagerduty_severity"` +} diff --git a/internal/models/triggers.go b/internal/models/triggers.go index cda313c7..9e8bc81a 100644 --- a/internal/models/triggers.go +++ b/internal/models/triggers.go @@ -21,17 +21,6 @@ type TriggerThresholdModel struct { Value types.Float64 `tfsdk:"value"` } -type NotificationRecipientModel struct { - ID types.String `tfsdk:"id"` - Type types.String `tfsdk:"type"` - Target types.String `tfsdk:"target"` - Details []NotificationRecipientDetailsModel `tfsdk:"notification_details"` -} - -type NotificationRecipientDetailsModel struct { - PDSeverity types.String `tfsdk:"pagerduty_severity"` -} - type TriggerEvaluationScheduleModel struct { DaysOfWeek []types.String `tfsdk:"days_of_week"` StartTime types.String `tfsdk:"start_time"` diff --git a/internal/provider/burn_alert_resource.go b/internal/provider/burn_alert_resource.go new file mode 100644 index 00000000..763a4d79 --- /dev/null +++ b/internal/provider/burn_alert_resource.go @@ -0,0 +1,247 @@ +package provider + +import ( + "context" + "errors" + "strings" + + "golang.org/x/exp/slices" + + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "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/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/honeycombio/terraform-provider-honeycombio/client" + "github.com/honeycombio/terraform-provider-honeycombio/internal/models" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ resource.Resource = &burnAlertResource{} + _ resource.ResourceWithConfigure = &burnAlertResource{} + _ resource.ResourceWithImportState = &burnAlertResource{} +) + +type burnAlertResource struct { + client *client.Client +} + +func NewBurnAlertResource() resource.Resource { + return &burnAlertResource{} +} + +func (*burnAlertResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_burn_alert" +} + +func (r *burnAlertResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + r.client = getClientFromResourceRequest(&req) +} + +func (*burnAlertResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Burn Alerts are used to notify you when your error budget will be exhausted within a given time period.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "The unique identifier for this Burn Alert.", + Computed: true, + Required: false, + Optional: false, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "dataset": schema.StringAttribute{ + Required: true, + Description: "The dataset this Burn Alert is associated with.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "exhaustion_minutes": schema.Int64Attribute{ + Required: true, + Description: "The amount of time, in minutes, remaining before the SLO's error budget will be exhausted and the alert will fire.", + Validators: []validator.Int64{ + int64validator.AtLeast(0), + }, + }, + "slo_id": schema.StringAttribute{ + Required: true, + Description: "The ID of the SLO that this Burn Alert is associated with.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + }, + Blocks: map[string]schema.Block{ + "recipient": notificationRecipientSchema(), + }, + } +} + +func (r *burnAlertResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + // import ID is of the format / + // note that the dataset name can also contain '/' + idSegments := strings.Split(req.ID, "/") + if len(idSegments) < 2 { + resp.Diagnostics.AddError( + "Invalid Import ID", + "The supplied ID must be wrtten as /.", + ) + return + } + + id := idSegments[len(idSegments)-1] + dataset := strings.Join(idSegments[0:len(idSegments)-1], "/") + + resp.Diagnostics.Append(resp.State.Set(ctx, &models.BurnAlertResourceModel{ + ID: types.StringValue(id), + Dataset: types.StringValue(dataset), + })...) +} + +func (r *burnAlertResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan, config models.BurnAlertResourceModel + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + burnAlert, err := r.client.BurnAlerts.Create(ctx, plan.Dataset.ValueString(), &client.BurnAlert{ + ExhaustionMinutes: int(plan.ExhaustionMinutes.ValueInt64()), + SLO: client.SLORef{ID: plan.SLOID.ValueString()}, + Recipients: expandNotificationRecipients(plan.Recipients), + }) + if err != nil { + resp.Diagnostics.AddError( + "Error Creating Honeycomb Burn Alert", + "Could not create Burn Alert, unexpected error: "+err.Error(), + ) + return + } + + var state models.BurnAlertResourceModel + state.Dataset = plan.Dataset + state.ID = types.StringValue(burnAlert.ID) + state.ExhaustionMinutes = types.Int64Value(int64(burnAlert.ExhaustionMinutes)) + state.SLOID = types.StringValue(burnAlert.SLO.ID) + // we created them as authored so to avoid matching type-target or ID we can just use the same value + state.Recipients = config.Recipients + + resp.Diagnostics.Append(resp.State.Set(ctx, state)...) +} + +func (r *burnAlertResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state models.BurnAlertResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + ba, err := r.client.BurnAlerts.Get(ctx, state.Dataset.ValueString(), state.ID.ValueString()) + if errors.Is(err, client.ErrNotFound) { + resp.State.RemoveResource(ctx) + return + } else if err != nil { + resp.Diagnostics.AddError( + "Error Reading Honeycomb Burn Alert", + "Could not read Burn Alert ID "+state.ID.ValueString()+": "+err.Error(), + ) + return + } + + state.ID = types.StringValue(ba.ID) + state.ExhaustionMinutes = types.Int64Value(int64(ba.ExhaustionMinutes)) + state.SLOID = types.StringValue(ba.SLO.ID) + + recipients := make([]models.NotificationRecipientModel, len(ba.Recipients)) + if state.Recipients != nil { + // match the recipients to those in the state sorting out type+target vs ID + for i, r := range ba.Recipients { + idx := slices.IndexFunc(state.Recipients, func(s models.NotificationRecipientModel) bool { + if !s.ID.IsNull() { + return s.ID.ValueString() == r.ID + } + return s.Type.ValueString() == string(r.Type) && s.Target.ValueString() == r.Target + }) + if idx < 0 { + // this should never happen?! But if it does, we'll just skip it and hope to get a reproducable case + resp.Diagnostics.AddError( + "Error Reading Honeycomb Burn Alert", + "Could not find Recipient "+r.ID+" in state", + ) + continue + } + recipients[i] = state.Recipients[idx] + } + } else { + recipients = flattenNotificationRecipients(ba.Recipients) + } + state.Recipients = recipients + + resp.Diagnostics.Append(resp.State.Set(ctx, state)...) +} + +func (r *burnAlertResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, config models.BurnAlertResourceModel + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + _, err := r.client.BurnAlerts.Update(ctx, plan.Dataset.ValueString(), &client.BurnAlert{ + ID: plan.ID.ValueString(), + ExhaustionMinutes: int(plan.ExhaustionMinutes.ValueInt64()), + SLO: client.SLORef{ID: plan.SLOID.ValueString()}, + Recipients: expandNotificationRecipients(plan.Recipients), + }) + if err != nil { + resp.Diagnostics.AddError( + "Error Updating Honeycomb Burn Alert", + "Could not update Burn Alert, unexpected error: "+err.Error(), + ) + return + } + + burnAlert, err := r.client.BurnAlerts.Get(ctx, plan.Dataset.ValueString(), plan.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError( + "Error Updating Honeycomb Burn Alert", + "Could not read Honeycomb Burn Alert ID "+plan.ID.ValueString()+": "+err.Error(), + ) + return + } + + var state models.BurnAlertResourceModel + state.Dataset = plan.Dataset + state.ID = types.StringValue(burnAlert.ID) + state.ExhaustionMinutes = types.Int64Value(int64(burnAlert.ExhaustionMinutes)) + state.SLOID = types.StringValue(burnAlert.SLO.ID) + // we created them as authored so to avoid matching type-target or ID we can just use the same value + state.Recipients = config.Recipients + + resp.Diagnostics.Append(resp.State.Set(ctx, state)...) +} + +func (r *burnAlertResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state models.BurnAlertResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + err := r.client.BurnAlerts.Delete(ctx, state.Dataset.ValueString(), state.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError( + "Error Deleting Honeycomb Burn Alert", + "Could not delete Burn Alert, unexpected error: "+err.Error(), + ) + return + } +} diff --git a/internal/provider/burn_alert_resource_test.go b/internal/provider/burn_alert_resource_test.go new file mode 100644 index 00000000..1ba57de8 --- /dev/null +++ b/internal/provider/burn_alert_resource_test.go @@ -0,0 +1,145 @@ +package provider + +import ( + "context" + "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" + "github.com/stretchr/testify/require" + + "github.com/honeycombio/terraform-provider-honeycombio/client" +) + +func TestAcc_BurnAlertResource(t *testing.T) { + dataset, sloID := burnAlertAccTestSetup(t) + + resource.Test(t, resource.TestCase{ + PreCheck: testAccPreCheck(t), + ProtoV5ProviderFactories: testAccProtoV5MuxServerFactory, + Steps: []resource.TestStep{ + { + Config: testAccConfigBasicBurnAlertTest(dataset, sloID, "info"), + Check: resource.ComposeAggregateTestCheckFunc( + testAccEnsureBurnAlertExists(t, "honeycombio_burn_alert.test"), + resource.TestCheckResourceAttr("honeycombio_burn_alert.test", "slo_id", sloID), + resource.TestCheckResourceAttr("honeycombio_burn_alert.test", "exhaustion_minutes", "240"), + resource.TestCheckResourceAttr("honeycombio_burn_alert.test", "recipient.#", "1"), + ), + }, + // then update the PD Severity from info -> critical (the default) + { + Config: testAccConfigBasicBurnAlertTest(dataset, sloID, "critical"), + }, + { + ResourceName: "honeycombio_burn_alert.test", + ImportStateIdPrefix: fmt.Sprintf("%v/", dataset), + ImportState: true, + }, + }, + }) +} + +// TestAcc_BurnAlertResourceUpgradeFromVersion015 is intended to test the migration +// case from the last SDK-based version of the Burn Alert resource to the current Framework-based +// version. +// +// See: https://developer.hashicorp.com/terraform/plugin/framework/migrating/testing#testing-migration +func TestAcc_BurnAlertResourceUpgradeFromVersion015(t *testing.T) { + dataset, sloID := burnAlertAccTestSetup(t) + + config := testAccConfigBasicBurnAlertTest(dataset, sloID, "info") + + resource.Test(t, resource.TestCase{ + Steps: []resource.TestStep{ + { + ExternalProviders: map[string]resource.ExternalProvider{ + "honeycombio": { + VersionConstraint: "~> 0.15.0", + Source: "honeycombio/honeycombio", + }, + }, + Config: config, + Check: resource.ComposeTestCheckFunc( + testAccEnsureBurnAlertExists(t, "honeycombio_burn_alert.test"), + ), + }, + { + ProtoV5ProviderFactories: testAccProtoV5MuxServerFactory, + Config: config, + }, + }, + }) +} + +func testAccEnsureBurnAlertExists(t *testing.T, name string) resource.TestCheckFunc { + return func(s *terraform.State) error { + resourceState, ok := s.RootModule().Resources[name] + if !ok { + return fmt.Errorf("\"%s\" not found in state", name) + } + + client := testAccClient(t) + _, err := client.BurnAlerts.Get(context.Background(), resourceState.Primary.Attributes["dataset"], resourceState.Primary.ID) + if err != nil { + return fmt.Errorf("failed to fetch created Burn Alert: %w", err) + } + + return nil + } +} + +func burnAlertAccTestSetup(t *testing.T) (string, string) { + t.Helper() + + ctx := context.Background() + dataset := testAccDataset() + c := testAccClient(t) + + sli, err := c.DerivedColumns.Create(ctx, dataset, &client.DerivedColumn{ + Alias: "sli." + acctest.RandString(8), + Expression: "BOOL(1)", + }) + if err != nil { + t.Error(err) + } + slo, err := c.SLOs.Create(ctx, dataset, &client.SLO{ + Name: acctest.RandString(8) + " SLO", + TimePeriodDays: 14, + TargetPerMillion: 995000, + SLI: client.SLIRef{Alias: sli.Alias}, + }) + require.NoError(t, err) + //nolint:errcheck + t.Cleanup(func() { + // remove SLO, SLI DC at end of test run + c.SLOs.Delete(ctx, dataset, slo.ID) + c.DerivedColumns.Delete(ctx, dataset, sli.ID) + }) + + return dataset, slo.ID +} + +func testAccConfigBasicBurnAlertTest(dataset, sloID, pdseverity string) string { + return fmt.Sprintf(` +resource "honeycombio_pagerduty_recipient" "test" { + integration_key = "08b9d4cacd68933151a1ef1028b67da2" + integration_name = "testacc-basic" +} + +resource "honeycombio_burn_alert" "test" { + dataset = "%[1]s" + slo_id = "%[2]s" + exhaustion_minutes = 4 * 60 + + recipient { + id = honeycombio_pagerduty_recipient.test.id + + notification_details { + pagerduty_severity = "%[3]s" + } + } +}`, dataset, sloID, pdseverity) +} diff --git a/internal/provider/notification_recipients.go b/internal/provider/notification_recipients.go new file mode 100644 index 00000000..f12017c0 --- /dev/null +++ b/internal/provider/notification_recipients.go @@ -0,0 +1,124 @@ +package provider + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/objectvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/listplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/honeycombio/terraform-provider-honeycombio/client" + "github.com/honeycombio/terraform-provider-honeycombio/internal/helper" + "github.com/honeycombio/terraform-provider-honeycombio/internal/helper/modifiers" + "github.com/honeycombio/terraform-provider-honeycombio/internal/models" +) + +func notificationRecipientSchema() schema.SetNestedBlock { + return schema.SetNestedBlock{ + Description: "Zero or more recipients to notify when the Trigger fires.", + PlanModifiers: []planmodifier.Set{modifiers.NotificationRecipients()}, + NestedObject: schema.NestedBlockObject{ + Validators: []validator.Object{ + objectvalidator.AtLeastOneOf( + path.MatchRelative().AtName("id"), + path.MatchRelative().AtName("type"), + ), + }, + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: "The ID of an existing recipient.", + Validators: []validator.String{ + stringvalidator.ConflictsWith(path.MatchRelative().AtParent().AtName("type")), + stringvalidator.ConflictsWith(path.MatchRelative().AtParent().AtName("target")), + }, + }, + "type": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: "The type of the trigger recipient.", + Validators: []validator.String{ + stringvalidator.OneOf(helper.RecipientTypeStrings(client.TriggerRecipientTypes())...), + }, + }, + "target": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: "Target of the trigger recipient, this has another meaning depending on the type of recipient.", + Validators: []validator.String{ + stringvalidator.AlsoRequires(path.MatchRelative().AtParent().AtName("type")), + }, + }, + }, + Blocks: map[string]schema.Block{ + "notification_details": schema.ListNestedBlock{ + Description: "Additional details to send along with the notification.", + Validators: []validator.List{ + listvalidator.SizeAtMost(1), + }, + PlanModifiers: []planmodifier.List{listplanmodifier.UseStateForUnknown()}, + NestedObject: schema.NestedBlockObject{ + Attributes: map[string]schema.Attribute{ + "pagerduty_severity": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: "The severity to set with the PagerDuty notification. If no severity is provided, 'critical' is assumed.", + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + Validators: []validator.String{ + stringvalidator.All( + stringvalidator.OneOf("info", "warning", "error", "critical"), + ), + }, + }, + }, + }, + }, + }, + }, + } +} + +func expandNotificationRecipients(n []models.NotificationRecipientModel) []client.NotificationRecipient { + recipients := make([]client.NotificationRecipient, len(n)) + + for i, r := range n { + rcpt := client.NotificationRecipient{ + ID: r.ID.ValueString(), + Type: client.RecipientType(r.Type.ValueString()), + Target: r.Target.ValueString(), + } + if r.Details != nil { + rcpt.Details = &client.NotificationRecipientDetails{ + PDSeverity: client.PagerDutySeverity(r.Details[0].PDSeverity.ValueString()), + } + } + recipients[i] = rcpt + } + + return recipients +} + +func flattenNotificationRecipients(n []client.NotificationRecipient) []models.NotificationRecipientModel { + recipients := make([]models.NotificationRecipientModel, len(n)) + + for i, r := range n { + rcpt := models.NotificationRecipientModel{ + ID: types.StringValue(r.ID), + Type: types.StringValue(string(r.Type)), + Target: types.StringValue(r.Target), + } + if r.Details != nil { + rcpt.Details = make([]models.NotificationRecipientDetailsModel, 1) + rcpt.Details[0].PDSeverity = types.StringValue(string(r.Details.PDSeverity)) + } + recipients[i] = rcpt + } + + return recipients +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index ed6cd22d..a1b8f0f9 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -61,6 +61,7 @@ func (p *HoneycombioProvider) Schema(_ context.Context, _ provider.SchemaRequest func (p *HoneycombioProvider) Resources(ctx context.Context) []func() resource.Resource { return []func() resource.Resource{ + NewBurnAlertResource, NewTriggerResource, } } diff --git a/internal/provider/trigger_resource.go b/internal/provider/trigger_resource.go index ba8f46b8..b496082f 100644 --- a/internal/provider/trigger_resource.go +++ b/internal/provider/trigger_resource.go @@ -10,14 +10,11 @@ import ( "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" - "github.com/hashicorp/terraform-plugin-framework-validators/objectvalidator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" - "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/booldefault" "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/listplanmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" @@ -27,7 +24,6 @@ import ( "github.com/honeycombio/terraform-provider-honeycombio/client" "github.com/honeycombio/terraform-provider-honeycombio/internal/helper" - "github.com/honeycombio/terraform-provider-honeycombio/internal/helper/modifiers" "github.com/honeycombio/terraform-provider-honeycombio/internal/helper/validation" "github.com/honeycombio/terraform-provider-honeycombio/internal/models" ) @@ -186,69 +182,7 @@ func (r *triggerResource) Schema(_ context.Context, _ resource.SchemaRequest, re }, }, }, - "recipient": schema.SetNestedBlock{ - Description: "Zero or more recipients to notify when the Trigger fires.", - PlanModifiers: []planmodifier.Set{modifiers.NotificationRecipients()}, - NestedObject: schema.NestedBlockObject{ - Validators: []validator.Object{ - objectvalidator.AtLeastOneOf( - path.MatchRelative().AtName("id"), - path.MatchRelative().AtName("type"), - ), - }, - Attributes: map[string]schema.Attribute{ - "id": schema.StringAttribute{ - Optional: true, - Computed: true, - Description: "The ID of an existing recipient.", - Validators: []validator.String{ - stringvalidator.ConflictsWith(path.MatchRelative().AtParent().AtName("type")), - stringvalidator.ConflictsWith(path.MatchRelative().AtParent().AtName("target")), - }, - }, - "type": schema.StringAttribute{ - Optional: true, - Computed: true, - Description: "The type of the trigger recipient.", - Validators: []validator.String{ - stringvalidator.OneOf(helper.RecipientTypeStrings(client.TriggerRecipientTypes())...), - }, - }, - "target": schema.StringAttribute{ - Optional: true, - Computed: true, - Description: "Target of the trigger recipient, this has another meaning depending on the type of recipient.", - Validators: []validator.String{ - stringvalidator.AlsoRequires(path.MatchRelative().AtParent().AtName("type")), - }, - }, - }, - Blocks: map[string]schema.Block{ - "notification_details": schema.ListNestedBlock{ - Description: "Additional details to send along with the notification.", - Validators: []validator.List{ - listvalidator.SizeAtMost(1), - }, - PlanModifiers: []planmodifier.List{listplanmodifier.UseStateForUnknown()}, - NestedObject: schema.NestedBlockObject{ - Attributes: map[string]schema.Attribute{ - "pagerduty_severity": schema.StringAttribute{ - Optional: true, - Computed: true, - Description: "The severity to set with the PagerDuty notification. If no severity is provided, 'critical' is assumed.", - PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, - Validators: []validator.String{ - stringvalidator.All( - stringvalidator.OneOf("info", "warning", "error", "critical"), - ), - }, - }, - }, - }, - }, - }, - }, - }, + "recipient": notificationRecipientSchema(), }, } } @@ -480,45 +414,6 @@ func flattenTriggerThreshold(t *client.TriggerThreshold) []models.TriggerThresho }} } -func expandNotificationRecipients(n []models.NotificationRecipientModel) []client.NotificationRecipient { - recipients := make([]client.NotificationRecipient, len(n)) - - for i, r := range n { - rcpt := client.NotificationRecipient{ - ID: r.ID.ValueString(), - Type: client.RecipientType(r.Type.ValueString()), - Target: r.Target.ValueString(), - } - if r.Details != nil { - rcpt.Details = &client.NotificationRecipientDetails{ - PDSeverity: client.PagerDutySeverity(r.Details[0].PDSeverity.ValueString()), - } - } - recipients[i] = rcpt - } - - return recipients -} - -func flattenNotificationRecipients(n []client.NotificationRecipient) []models.NotificationRecipientModel { - recipients := make([]models.NotificationRecipientModel, len(n)) - - for i, r := range n { - rcpt := models.NotificationRecipientModel{ - ID: types.StringValue(r.ID), - Type: types.StringValue(string(r.Type)), - Target: types.StringValue(r.Target), - } - if r.Details != nil { - rcpt.Details = make([]models.NotificationRecipientDetailsModel, 1) - rcpt.Details[0].PDSeverity = types.StringValue(string(r.Details.PDSeverity)) - } - recipients[i] = rcpt - } - - return recipients -} - func expandTriggerEvaluationSchedule(s []models.TriggerEvaluationScheduleModel) *client.TriggerEvaluationSchedule { if s != nil { days := make([]string, len(s[0].DaysOfWeek)) diff --git a/internal/provider/trigger_resource_test.go b/internal/provider/trigger_resource_test.go index 94929358..fef1de5d 100644 --- a/internal/provider/trigger_resource_test.go +++ b/internal/provider/trigger_resource_test.go @@ -57,7 +57,7 @@ func TestAcc_TriggerResourceUpgradeFromVersion014(t *testing.T) { { ExternalProviders: map[string]resource.ExternalProvider{ "honeycombio": { - VersionConstraint: "~> 0.14", + VersionConstraint: "~> 0.14.0", Source: "honeycombio/honeycombio", }, }, @@ -69,7 +69,6 @@ func TestAcc_TriggerResourceUpgradeFromVersion014(t *testing.T) { { ProtoV5ProviderFactories: testAccProtoV5MuxServerFactory, Config: config, - PlanOnly: true, }, }, })