Repository: honeycombio/terraform-provider-honeycombio
Pull request #306 (2023-05-11 19:00:27)

migrate honeycombio_trigger resource 'as is' to Plugin Framework
First pass at migrating the resource: with this (verbose) groundwork laid the test suite can be fleshed out and the open bugs squashed 🤞🏻 

The current SDKv2 based Trigger resource is being left in place (and its tests skipped) just not wired up for use.

codecov-commenter: ## [Codecov](https://app.codecov.io/gh/honeycombio/terraform-provider-honeycombio/pull/306?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=honeycombio) Report
> Merging [#306](https://app.codecov.io/gh/honeycombio/terraform-provider-honeycombio/pull/306?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=honeycombio) (e88ebec) into [main](https://app.codecov.io/gh/honeycombio/terraform-provider-honeycombio/commit/0d9ca767a725485474658a7724ae5bc42bb2efef?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=honeycombio) (0d9ca76) will **decrease** coverage by `5.43%`.
> The diff coverage is `74.67%`.

```diff
@@            Coverage Diff             @@
##             main     #306      +/-   ##
==========================================
- Coverage   80.68%   75.26%   -5.43%     
==========================================
  Files          47       49       +2     
  Lines        3924     4305     +381     
==========================================
+ Hits         3166     3240      +74     
- Misses        624      932     +308     
+ Partials      134      133       -1     
```


| [Impacted Files](https://app.codecov.io/gh/honeycombio/terraform-provider-honeycombio/pull/306?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=honeycombio) | Coverage Δ | |
|---|---|---|
| [honeycombio/provider.go](https://app.codecov.io/gh/honeycombio/terraform-provider-honeycombio/pull/306?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=honeycombio#diff-aG9uZXljb21iaW8vcHJvdmlkZXIuZ28=) | `76.47% <ø> (-0.28%)` | :arrow_down: |
| [internal/provider/trigger\_resource.go](https://app.codecov.io/gh/honeycombio/terraform-provider-honeycombio/pull/306?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=honeycombio#diff-aW50ZXJuYWwvcHJvdmlkZXIvdHJpZ2dlcl9yZXNvdXJjZS5nbw==) | `73.46% <73.46%> (ø)` | |
| [internal/helper/validation/divisible\_by.go](https://app.codecov.io/gh/honeycombio/terraform-provider-honeycombio/pull/306?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=honeycombio#diff-aW50ZXJuYWwvaGVscGVyL3ZhbGlkYXRpb24vZGl2aXNpYmxlX2J5Lmdv) | `88.23% <88.23%> (ø)` | |
| [internal/provider/provider.go](https://app.codecov.io/gh/honeycombio/terraform-provider-honeycombio/pull/306?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=honeycombio#diff-aW50ZXJuYWwvcHJvdmlkZXIvcHJvdmlkZXIuZ28=) | `58.16% <100.00%> (+3.21%)` | :arrow_up: |

... and [2 files with indirect coverage changes](https://app.codecov.io/gh/honeycombio/terraform-provider-honeycombio/pull/306/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=honeycombio)

:mega: We’re building smart automated test selection to slash your CI/CD build times. [Learn more](https://about.codecov.io/iterative-testing/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=honeycombio)


Patch:
diff --git a/go.mod b/go.mod
index c846364..d038cee 100644
--- a/go.mod
+++ b/go.mod
@@ -34,6 +34,7 @@ require (
 	github.com/hashicorp/logutils v1.0.0 // indirect
 	github.com/hashicorp/terraform-exec v0.18.1 // indirect
 	github.com/hashicorp/terraform-json v0.16.0 // indirect
+	github.com/hashicorp/terraform-plugin-framework-validators v0.10.0
 	github.com/hashicorp/terraform-plugin-log v0.8.0 // indirect
 	github.com/hashicorp/terraform-registry-address v0.2.0 // indirect
 	github.com/hashicorp/terraform-svchost v0.0.1 // indirect
diff --git a/go.sum b/go.sum
index 71e5121..ed29b45 100644
--- a/go.sum
+++ b/go.sum
@@ -82,6 +82,8 @@ github.com/hashicorp/terraform-json v0.16.0 h1:UKkeWRWb23do5LNAFlh/K3N0ymn1qTOO8
 github.com/hashicorp/terraform-json v0.16.0/go.mod h1:v0Ufk9jJnk6tcIZvScHvetlKfiNTC+WS21mnXIlc0B0=
 github.com/hashicorp/terraform-plugin-framework v1.2.0 h1:MZjFFfULnFq8fh04FqrKPcJ/nGpHOvX4buIygT3MSNY=
 github.com/hashicorp/terraform-plugin-framework v1.2.0/go.mod h1:nToI62JylqXDq84weLJ/U3umUsBhZAaTmU0HXIVUOcw=
+github.com/hashicorp/terraform-plugin-framework-validators v0.10.0 h1:4L0tmy/8esP6OcvocVymw52lY0HyQ5OxB7VNl7k4bS0=
+github.com/hashicorp/terraform-plugin-framework-validators v0.10.0/go.mod h1:qdQJCdimB9JeX2YwOpItEu+IrfoJjWQ5PhLpAOMDQAE=
 github.com/hashicorp/terraform-plugin-go v0.15.0 h1:1BJNSUFs09DS8h/XNyJNJaeusQuWc/T9V99ylU9Zwp0=
 github.com/hashicorp/terraform-plugin-go v0.15.0/go.mod h1:tk9E3/Zx4RlF/9FdGAhwxHExqIHHldqiQGt20G6g+nQ=
 github.com/hashicorp/terraform-plugin-log v0.8.0 h1:pX2VQ/TGKu+UU1rCay0OlzosNKe4Nz1pepLXj95oyy0=
diff --git a/honeycombio/provider.go b/honeycombio/provider.go
index b9ee752..c77455a 100644
--- a/honeycombio/provider.go
+++ b/honeycombio/provider.go
@@ -62,7 +62,6 @@ func Provider(version string) *schema.Provider {
 			"honeycombio_slack_recipient":     newSlackRecipient(),
 			"honeycombio_webhook_recipient":   newWebhookRecipient(),
 			"honeycombio_slo":                 newSLO(),
-			"honeycombio_trigger":             newTrigger(),
 		},
 	}
 
diff --git a/internal/helper/type_strings.go b/internal/helper/type_strings.go
new file mode 100644
index 0000000..30258e4
--- /dev/null
+++ b/internal/helper/type_strings.go
@@ -0,0 +1,25 @@
+package helper
+
+import "github.com/honeycombio/terraform-provider-honeycombio/client"
+
+func TriggerThresholdOpStrings() []string {
+	in := client.TriggerThresholdOps()
+	out := make([]string, len(in))
+
+	for i := range in {
+		out[i] = string(in[i])
+	}
+
+	return out
+}
+
+func RecipientTypeStrings(recipientTypes []client.RecipientType) []string {
+	in := recipientTypes
+	out := make([]string, len(in))
+
+	for i := range in {
+		out[i] = string(in[i])
+	}
+
+	return out
+}
diff --git a/internal/helper/validation/divisible_by.go b/internal/helper/validation/divisible_by.go
new file mode 100644
index 0000000..6ea247b
--- /dev/null
+++ b/internal/helper/validation/divisible_by.go
@@ -0,0 +1,49 @@
+package validation
+
+import (
+	"context"
+	"fmt"
+	"math"
+
+	"github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag"
+	"github.com/hashicorp/terraform-plugin-framework/schema/validator"
+)
+
+var _ validator.Int64 = divisibleByValidator{}
+
+type divisibleByValidator struct {
+	divisor int64
+}
+
+func (validator divisibleByValidator) Description(_ context.Context) string {
+	return fmt.Sprintf("value must be divisible by %d", validator.divisor)
+}
+
+func (validator divisibleByValidator) MarkdownDescription(ctx context.Context) string {
+	return validator.Description(ctx)
+}
+
+func (validator divisibleByValidator) ValidateInt64(ctx context.Context, request validator.Int64Request, response *validator.Int64Response) {
+	if request.ConfigValue.IsNull() || request.ConfigValue.IsUnknown() {
+		return
+	}
+
+	if math.Mod(float64(request.ConfigValue.ValueInt64()), float64(validator.divisor)) != 0 {
+		response.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic(
+			request.Path,
+			validator.Description(ctx),
+			fmt.Sprintf("%d", request.ConfigValue.ValueInt64()),
+		))
+	}
+}
+
+// Int64DivisbileBy returns an AttributeValidator which ensures that any configured
+// attribute value:
+//
+//   - Is a number, which can be represented by a 64-bit integer.
+//   - Is evenly divisible by a given number.
+//
+// Null (unconfigured) and unknown (known after apply) values are skipped.
+func Int64DivisibleBy(v int64) validator.Int64 {
+	return divisibleByValidator{divisor: v}
+}
diff --git a/internal/models/triggers.go b/internal/models/triggers.go
new file mode 100644
index 0000000..4760567
--- /dev/null
+++ b/internal/models/triggers.go
@@ -0,0 +1,32 @@
+package models
+
+import "github.com/hashicorp/terraform-plugin-framework/types"
+
+type TriggerResourceModel struct {
+	ID          types.String                 `tfsdk:"id"`
+	Name        types.String                 `tfsdk:"name"`
+	Dataset     types.String                 `tfsdk:"dataset"`
+	Description types.String                 `tfsdk:"description"`
+	Disabled    types.Bool                   `tfsdk:"disabled"`
+	QueryID     types.String                 `tfsdk:"query_id"`
+	AlertType   types.String                 `tfsdk:"alert_type"`
+	Frequency   types.Int64                  `tfsdk:"frequency"`
+	Threshold   []TriggerThresholdModel      `tfsdk:"threshold"`
+	Recipients  []NotificationRecipientModel `tfsdk:"recipient"`
+}
+
+type TriggerThresholdModel struct {
+	Op    types.String  `tfsdk:"op"`
+	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"`
+}
diff --git a/internal/provider/provider.go b/internal/provider/provider.go
index c66b5d2..b551e7e 100644
--- a/internal/provider/provider.go
+++ b/internal/provider/provider.go
@@ -60,7 +60,9 @@ func (p *HoneycombioProvider) Schema(_ context.Context, _ provider.SchemaRequest
 }
 
 func (p *HoneycombioProvider) Resources(ctx context.Context) []func() resource.Resource {
-	return []func() resource.Resource{}
+	return []func() resource.Resource{
+		NewTriggerResource,
+	}
 }
 
 func (p *HoneycombioProvider) DataSources(ctx context.Context) []func() datasource.DataSource {
@@ -142,3 +144,10 @@ func getClientFromDatasourceRequest(req *datasource.ConfigureRequest) *client.Cl
 	}
 	return req.ProviderData.(*client.Client)
 }
+
+func getClientFromResourceRequest(req *resource.ConfigureRequest) *client.Client {
+	if req.ProviderData == nil {
+		return nil
+	}
+	return req.ProviderData.(*client.Client)
+}
diff --git a/internal/provider/trigger_resource.go b/internal/provider/trigger_resource.go
new file mode 100644
index 0000000..70b0f5e
--- /dev/null
+++ b/internal/provider/trigger_resource.go
@@ -0,0 +1,436 @@
+package provider
+
+import (
+	"context"
+	"errors"
+	"strings"
+
+	"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/planmodifier"
+	"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault"
+	"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/validation"
+	"github.com/honeycombio/terraform-provider-honeycombio/internal/models"
+)
+
+// Ensure the implementation satisfies the expected interfaces.
+var (
+	_ resource.Resource                = &triggerResource{}
+	_ resource.ResourceWithConfigure   = &triggerResource{}
+	_ resource.ResourceWithImportState = &triggerResource{}
+)
+
+func NewTriggerResource() resource.Resource {
+	return &triggerResource{}
+}
+
+type triggerResource struct {
+	client *client.Client
+}
+
+func (r *triggerResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
+	resp.TypeName = req.ProviderTypeName + "_trigger"
+}
+
+func (r *triggerResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
+	resp.Schema = schema.Schema{
+		Description: "Manages a Trigger.",
+		Attributes: map[string]schema.Attribute{
+			"id": schema.StringAttribute{
+				Description: "The unique identifier for this Trigger.",
+				Computed:    true,
+				Required:    false,
+				Optional:    false,
+				PlanModifiers: []planmodifier.String{
+					stringplanmodifier.UseStateForUnknown(),
+				},
+			},
+			"name": schema.StringAttribute{
+				Required:    true,
+				Description: "The name of the Trigger.",
+				Validators: []validator.String{
+					stringvalidator.LengthBetween(1, 120),
+				},
+			},
+			"dataset": schema.StringAttribute{
+				Required:    true,
+				Description: "The dataset this Trigger is associated with.",
+				PlanModifiers: []planmodifier.String{
+					stringplanmodifier.RequiresReplace(),
+				},
+			},
+			"description": schema.StringAttribute{
+				Optional:    true,
+				Computed:    true,
+				Description: "A description of the Trigger.",
+				Default:     stringdefault.StaticString(""),
+				Validators: []validator.String{
+					stringvalidator.LengthBetween(1, 1023),
+				},
+			},
+			"disabled": schema.BoolAttribute{
+				Optional:    true,
+				Computed:    true,
+				Description: "The state of the Trigger. If true, the Trigger will not be run.",
+				Default:     booldefault.StaticBool(false),
+			},
+			"query_id": schema.StringAttribute{
+				Required:    true,
+				Description: "The ID of the Query that the Trigger will execute.",
+			},
+			"alert_type": schema.StringAttribute{
+				Optional:    true,
+				Computed:    true,
+				Description: "Control when the Trigger will send a notification.",
+				Default:     stringdefault.StaticString(client.TriggerAlertTypeValueOnChange),
+				Validators: []validator.String{
+					stringvalidator.OneOf(client.TriggerAlertTypeValueOnChange, client.TriggerAlertTypeValueOnTrue),
+				},
+			},
+			"frequency": schema.Int64Attribute{
+				Optional: true,
+				Description: "The interval (in seconds) in which to check the results of the query's calculation against the threshold. " +
+					"This value must be divisible by 60, between 60 and 86400 (between 1 minute and 1 day), and not be more than 4 times the query's duration.",
+				Computed: true,
+				Default:  int64default.StaticInt64(900),
+				Validators: []validator.Int64{
+					int64validator.All(
+						validation.Int64DivisibleBy(60),
+						int64validator.Between(60, 86400),
+					),
+				},
+			},
+		},
+		Blocks: map[string]schema.Block{
+			"threshold": schema.ListNestedBlock{
+				Description: "A block describing the threshold for the Trigger to fire.",
+				Validators: []validator.List{
+					listvalidator.IsRequired(),
+					listvalidator.SizeAtMost(1),
+				},
+				NestedObject: schema.NestedBlockObject{
+					Attributes: map[string]schema.Attribute{
+						"op": schema.StringAttribute{
+							Required:    true,
+							Description: "The operator to apply.",
+							Validators: []validator.String{
+								stringvalidator.OneOf(helper.TriggerThresholdOpStrings()...),
+							},
+						},
+						"value": schema.Float64Attribute{
+							Required:    true,
+							Description: "The value to be used with the operator.",
+						},
+					},
+				},
+			},
+			"recipient": schema.ListNestedBlock{
+				Description: "Zero or more recipients to notify when the Trigger fires.",
+				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),
+							},
+							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.",
+										Default:     stringdefault.StaticString("critical"),
+										Validators: []validator.String{
+											stringvalidator.All(
+												stringvalidator.OneOf("info", "warning", "error", "critical"),
+											),
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+}
+
+func (r *triggerResource) Configure(_ context.Context, req resource.ConfigureRequest, _ *resource.ConfigureResponse) {
+	r.client = getClientFromResourceRequest(&req)
+}
+
+func (r *triggerResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
+	var plan models.TriggerResourceModel
+	diags := req.Plan.Get(ctx, &plan)
+	resp.Diagnostics.Append(diags...)
+	if resp.Diagnostics.HasError() {
+		return
+	}
+
+	newTrigger := &client.Trigger{
+		Name:        plan.Name.ValueString(),
+		Description: plan.Description.ValueString(),
+		Disabled:    plan.Disabled.ValueBool(),
+		QueryID:     plan.QueryID.ValueString(),
+		AlertType:   plan.AlertType.ValueString(),
+		Threshold:   expandTriggerThreshold(plan.Threshold),
+		Frequency:   int(plan.Frequency.ValueInt64()),
+		Recipients:  expandNotificationRecipients(plan.Recipients),
+	}
+
+	trigger, err := r.client.Triggers.Create(ctx, plan.Dataset.ValueString(), newTrigger)
+	if err != nil {
+		resp.Diagnostics.AddError(
+			"Error Creating Honeycomb Trigger",
+			"Could not create Trigger, unexpected error: "+err.Error(),
+		)
+		return
+	}
+
+	plan.ID = types.StringValue(trigger.ID)
+	plan.Name = types.StringValue(trigger.Name)
+	plan.Description = types.StringValue(trigger.Description)
+	plan.Disabled = types.BoolValue(trigger.Disabled)
+	plan.QueryID = types.StringValue(trigger.QueryID)
+	plan.AlertType = types.StringValue(trigger.AlertType)
+	plan.Threshold = flattenTriggerThreshold(trigger.Threshold)
+	plan.Frequency = types.Int64Value(int64(trigger.Frequency))
+	plan.Recipients = flattenNotificationRecipients(trigger.Recipients)
+
+	diags = resp.State.Set(ctx, plan)
+	resp.Diagnostics.Append(diags...)
+}
+
+func (r *triggerResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
+	var state models.TriggerResourceModel
+	diags := req.State.Get(ctx, &state)
+	resp.Diagnostics.Append(diags...)
+	if resp.Diagnostics.HasError() {
+		return
+	}
+
+	trigger, err := r.client.Triggers.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 Trigger",
+			"Could not read Trigger ID "+state.ID.ValueString()+": "+err.Error(),
+		)
+		return
+	}
+
+	state.ID = types.StringValue(trigger.ID)
+	state.Name = types.StringValue(trigger.Name)
+	state.Description = types.StringValue(trigger.Description)
+	state.Disabled = types.BoolValue(trigger.Disabled)
+	state.QueryID = types.StringValue(trigger.QueryID)
+	state.AlertType = types.StringValue(trigger.AlertType)
+	state.Threshold = flattenTriggerThreshold(trigger.Threshold)
+	state.Frequency = types.Int64Value(int64(trigger.Frequency))
+	state.Recipients = flattenNotificationRecipients(trigger.Recipients)
+
+	diags = resp.State.Set(ctx, &state)
+	resp.Diagnostics.Append(diags...)
+}
+
+func (r *triggerResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
+	var plan models.TriggerResourceModel
+	diags := req.Plan.Get(ctx, &plan)
+	resp.Diagnostics.Append(diags...)
+	if resp.Diagnostics.HasError() {
+		return
+	}
+
+	newTrigger := &client.Trigger{
+		Name:        plan.Name.ValueString(),
+		Description: plan.Description.ValueString(),
+		Disabled:    plan.Disabled.ValueBool(),
+		QueryID:     plan.QueryID.ValueString(),
+		AlertType:   plan.AlertType.ValueString(),
+		Frequency:   int(plan.Frequency.ValueInt64()),
+		Threshold:   expandTriggerThreshold(plan.Threshold),
+		Recipients:  expandNotificationRecipients(plan.Recipients),
+	}
+
+	_, err := r.client.Triggers.Update(ctx, plan.Dataset.ValueString(), newTrigger)
+	if err != nil {
+		resp.Diagnostics.AddError(
+			"Error Updating Honeycomb Trigger",
+			"Could not update Trigger, unexpected error: "+err.Error(),
+		)
+		return
+	}
+
+	trigger, err := r.client.Triggers.Get(ctx, plan.Dataset.ValueString(), plan.ID.ValueString())
+	if err != nil {
+		resp.Diagnostics.AddError(
+			"Error Reading Honeycomb Trigger",
+			"Could not read Honeycomb Trigger ID "+plan.ID.ValueString()+": "+err.Error(),
+		)
+		return
+	}
+
+	plan.ID = types.StringValue(trigger.ID)
+	plan.Name = types.StringValue(trigger.Name)
+	plan.Description = types.StringValue(trigger.Description)
+	plan.Disabled = types.BoolValue(trigger.Disabled)
+	plan.QueryID = types.StringValue(trigger.QueryID)
+	plan.AlertType = types.StringValue(trigger.AlertType)
+	plan.Frequency = types.Int64Value(int64(trigger.Frequency))
+	plan.Threshold = []models.TriggerThresholdModel{{
+		Op:    types.StringValue(string(trigger.Threshold.Op)),
+		Value: types.Float64Value(trigger.Threshold.Value),
+	}}
+	plan.Recipients = flattenNotificationRecipients(trigger.Recipients)
+
+	diags = resp.State.Set(ctx, plan)
+	resp.Diagnostics.Append(diags...)
+	if resp.Diagnostics.HasError() {
+		return
+	}
+}
+
+func (r *triggerResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
+	var state models.TriggerResourceModel
+	diags := req.State.Get(ctx, &state)
+	resp.Diagnostics.Append(diags...)
+	if resp.Diagnostics.HasError() {
+		return
+	}
+
+	err := r.client.Triggers.Delete(ctx, state.Dataset.ValueString(), state.ID.ValueString())
+	if err != nil {
+		resp.Diagnostics.AddError(
+			"Error Deleting Honeycomb Trigger",
+			"Could not delete Trigger, unexpected error: "+err.Error(),
+		)
+		return
+	}
+}
+
+func (r *triggerResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
+	// import ID is of the format <dataset>/<trigger ID>
+	// 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 <dataset>/<trigger ID>.",
+		)
+		return
+	}
+
+	id := idSegments[len(idSegments)-1]
+	dataset := strings.Join(idSegments[0:len(idSegments)-1], "/")
+
+	resp.Diagnostics.Append(resp.State.Set(ctx, &models.TriggerResourceModel{
+		ID:      types.StringValue(id),
+		Dataset: types.StringValue(dataset),
+	})...)
+}
+
+func expandTriggerThreshold(t []models.TriggerThresholdModel) *client.TriggerThreshold {
+	if len(t) != 1 {
+		return nil
+	}
+
+	return &client.TriggerThreshold{
+		Op:    client.TriggerThresholdOp(t[0].Op.ValueString()),
+		Value: t[0].Value.ValueFloat64(),
+	}
+}
+
+func flattenTriggerThreshold(t *client.TriggerThreshold) []models.TriggerThresholdModel {
+	return []models.TriggerThresholdModel{{
+		Op:    types.StringValue(string(t.Op)),
+		Value: types.Float64Value(t.Value),
+	}}
+}
+
+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 len(r.Details) == 1 {
+			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
+}


Test patch:
diff --git a/honeycombio/resource_trigger_test.go b/honeycombio/resource_trigger_test.go
index 6981605..f36909b 100644
--- a/honeycombio/resource_trigger_test.go
+++ b/honeycombio/resource_trigger_test.go
@@ -11,6 +11,7 @@ import (
 )
 
 func TestAccHoneycombioTrigger_basic(t *testing.T) {
+	t.Skip("legacy test, skipping")
 	var triggerBefore, triggerAfter honeycombio.Trigger
 
 	dataset := testAccDataset()
@@ -90,6 +91,7 @@ func testAccCheckTriggerAttributes(t *honeycombio.Trigger) resource.TestCheckFun
 }
 
 func TestAccHoneycombioTrigger_triggerRecipientById(t *testing.T) {
+	t.Skip("legacy test, skipping")
 	dataset := testAccDataset()
 
 	trigger, deleteFn := createTriggerWithRecipient(t, dataset, honeycombio.NotificationRecipient{
@@ -207,6 +209,7 @@ resource "honeycombio_trigger" "test" {
 }
 
 func TestAccHoneycombioTrigger_recipientOrderingNoDiff(t *testing.T) {
+	t.Skip("legacy test, skipping")
 	dataset := testAccDataset()
 
 	resource.Test(t, resource.TestCase{
diff --git a/internal/helper/validation/divisible_by_test.go b/internal/helper/validation/divisible_by_test.go
new file mode 100644
index 0000000..31d6a5b
--- /dev/null
+++ b/internal/helper/validation/divisible_by_test.go
@@ -0,0 +1,66 @@
+package validation_test
+
+import (
+	"context"
+	"testing"
+
+	"github.com/hashicorp/terraform-plugin-framework/path"
+	"github.com/hashicorp/terraform-plugin-framework/schema/validator"
+	"github.com/hashicorp/terraform-plugin-framework/types"
+	"github.com/honeycombio/terraform-provider-honeycombio/internal/helper/validation"
+)
+
+func Test_BetweenValidator(t *testing.T) {
+	t.Parallel()
+
+	type testCase struct {
+		val         types.Int64
+		divisor     int64
+		expectError bool
+	}
+	tests := map[string]testCase{
+		"unknown Int64": {
+			val:     types.Int64Unknown(),
+			divisor: 3,
+		},
+		"null Int64": {
+			val:     types.Int64Null(),
+			divisor: 3,
+		},
+		"valid integer as Int64": {
+			val:     types.Int64Value(3600),
+			divisor: 60,
+		},
+		"valid negative integer as Int64": {
+			val:     types.Int64Value(-6),
+			divisor: 2,
+		},
+		"not divisble by integer as Int64": {
+			val:         types.Int64Value(20),
+			divisor:     3,
+			expectError: true,
+		},
+	}
+
+	for name, test := range tests {
+		name, test := name, test
+		t.Run(name, func(t *testing.T) {
+			t.Parallel()
+			request := validator.Int64Request{
+				Path:           path.Root("test"),
+				PathExpression: path.MatchRoot("test"),
+				ConfigValue:    test.val,
+			}
+			response := validator.Int64Response{}
+			validation.Int64DivisibleBy(test.divisor).ValidateInt64(context.TODO(), request, &response)
+
+			if !response.Diagnostics.HasError() && test.expectError {
+				t.Fatal("expected error, got no error")
+			}
+
+			if response.Diagnostics.HasError() && !test.expectError {
+				t.Fatalf("got unexpected error: %s", response.Diagnostics)
+			}
+		})
+	}
+}
diff --git a/internal/provider/derived_column_data_source_test.go b/internal/provider/derived_column_data_source_test.go
index 45db830..f61949f 100644
--- a/internal/provider/derived_column_data_source_test.go
+++ b/internal/provider/derived_column_data_source_test.go
@@ -10,7 +10,7 @@ import (
 	"github.com/honeycombio/terraform-provider-honeycombio/client"
 )
 
-func TestAccDerivedColumnDataSource(t *testing.T) {
+func TestAcc_DerivedColumnDataSource(t *testing.T) {
 	ctx := context.Background()
 	c := testAccClient(t)
 	dataset := testAccDataset()
@@ -34,7 +34,7 @@ func TestAccDerivedColumnDataSource(t *testing.T) {
 
 	resource.Test(t, resource.TestCase{
 		PreCheck:                 testAccPreCheck(t),
-		ProtoV5ProviderFactories: testAccProtoV5ProviderFactories,
+		ProtoV5ProviderFactories: testAccProtoV5ProviderFactory,
 		Steps: []resource.TestStep{
 			{
 				Config: testAccDerivedColumnDataSourceConfig(dataset, testColumn.Alias),
diff --git a/internal/provider/derived_columns_data_source_test.go b/internal/provider/derived_columns_data_source_test.go
index e7ed81a..a314442 100644
--- a/internal/provider/derived_columns_data_source_test.go
+++ b/internal/provider/derived_columns_data_source_test.go
@@ -10,7 +10,7 @@ import (
 	"github.com/honeycombio/terraform-provider-honeycombio/client"
 )
 
-func TestAccDerivedColumnsDataSource(t *testing.T) {
+func TestAcc_DerivedColumnsDataSource(t *testing.T) {
 	ctx := context.Background()
 	c := testAccClient(t)
 	dataset := testAccDataset()
@@ -47,7 +47,7 @@ func TestAccDerivedColumnsDataSource(t *testing.T) {
 
 	resource.Test(t, resource.TestCase{
 		PreCheck:                 testAccPreCheck(t),
-		ProtoV5ProviderFactories: testAccProtoV5ProviderFactories,
+		ProtoV5ProviderFactories: testAccProtoV5ProviderFactory,
 		Steps: []resource.TestStep{
 			{
 				Config: testAccDerivedColumnsDataSourceConfig(dataset, dcPrefix),
diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go
index bd3e75f..3dcbd1f 100644
--- a/internal/provider/provider_test.go
+++ b/internal/provider/provider_test.go
@@ -9,9 +9,10 @@ import (
 	"github.com/hashicorp/terraform-plugin-go/tfprotov5"
 	"github.com/hashicorp/terraform-plugin-mux/tf5muxserver"
 	"github.com/hashicorp/terraform-plugin-testing/helper/resource"
+	"github.com/joho/godotenv"
+
 	"github.com/honeycombio/terraform-provider-honeycombio/client"
 	"github.com/honeycombio/terraform-provider-honeycombio/honeycombio"
-	"github.com/joho/godotenv"
 )
 
 func init() {
@@ -19,10 +20,33 @@ func init() {
 	_ = godotenv.Load("../../.env")
 }
 
-var testAccProtoV5ProviderFactories = map[string]func() (tfprotov5.ProviderServer, error){
+// used by tests which only use the Framework-based datasources or resources
+var testAccProtoV5ProviderFactory = map[string]func() (tfprotov5.ProviderServer, error){
 	"honeycombio": providerserver.NewProtocol5WithError(New("test")),
 }
 
+// used by tests which use a mix of Framework and SDK-based datasources or resources
+//
+// n.b. will continue to be used until the SDK-based provider is removed
+var testAccProtoV5MuxServerFactory = map[string]func() (tfprotov5.ProviderServer, error){
+	"honeycombio": func() (tfprotov5.ProviderServer, error) {
+		ctx := context.Background()
+		providers := []func() tfprotov5.ProviderServer{
+			// modern terraform-plugin-framework provider
+			providerserver.NewProtocol5(New("test")),
+			// legacy terraform-plugin-sdk provider
+			honeycombio.Provider("test").GRPCProvider,
+		}
+
+		muxServer, err := tf5muxserver.NewMuxServer(ctx, providers...)
+		if err != nil {
+			return nil, err
+		}
+
+		return muxServer.ProviderServer(), nil
+	},
+}
+
 func testAccPreCheck(t *testing.T) func() {
 	return func() {
 		if _, ok := os.LookupEnv("HONEYCOMB_API_KEY"); !ok {
@@ -51,26 +75,9 @@ func testAccClient(t *testing.T) *client.Client {
 
 // TestMuxServer verifies that a V5 Mux Server can be properly created while
 // the Plugin SDK and the Plugin Framework are both in use in the provider
-func TestMuxServer(t *testing.T) {
+func TestAcc_MuxServer(t *testing.T) {
 	resource.Test(t, resource.TestCase{
-		ProtoV5ProviderFactories: map[string]func() (tfprotov5.ProviderServer, error){
-			"honeycombio": func() (tfprotov5.ProviderServer, error) {
-				ctx := context.Background()
-				providers := []func() tfprotov5.ProviderServer{
-					// modern terraform-plugin-framework provider
-					providerserver.NewProtocol5(New("test")),
-					// legacy terraform-plugin-sdk provider
-					honeycombio.Provider("test").GRPCProvider,
-				}
-
-				muxServer, err := tf5muxserver.NewMuxServer(ctx, providers...)
-				if err != nil {
-					return nil, err
-				}
-
-				return muxServer.ProviderServer(), nil
-			},
-		},
+		ProtoV5ProviderFactories: testAccProtoV5MuxServerFactory,
 		Steps: []resource.TestStep{
 			{
 				// simple smoketest by accessing a datasource
diff --git a/internal/provider/trigger_resource_test.go b/internal/provider/trigger_resource_test.go
new file mode 100644
index 0000000..b6568f9
--- /dev/null
+++ b/internal/provider/trigger_resource_test.go
@@ -0,0 +1,138 @@
+package provider
+
+import (
+	"context"
+	"fmt"
+	"testing"
+
+	"github.com/hashicorp/terraform-plugin-testing/helper/resource"
+	"github.com/hashicorp/terraform-plugin-testing/terraform"
+)
+
+func TestAcc_TriggerResource(t *testing.T) {
+	dataset := testAccDataset()
+
+	resource.Test(t, resource.TestCase{
+		PreCheck:                 testAccPreCheck(t),
+		ProtoV5ProviderFactories: testAccProtoV5MuxServerFactory,
+		Steps: []resource.TestStep{
+			{
+				Config: basicTriggerTestConfig(dataset),
+				Check: resource.ComposeAggregateTestCheckFunc(
+					testAccEnsureTriggerExists(t, "honeycombio_trigger.test"),
+					resource.TestCheckResourceAttr("honeycombio_trigger.test", "name", "Test trigger from terraform-provider-honeycombio"),
+					resource.TestCheckResourceAttr("honeycombio_trigger.test", "frequency", "600"),
+					resource.TestCheckResourceAttr("honeycombio_trigger.test", "recipient.#", "2"),
+					resource.TestCheckResourceAttrPair("honeycombio_trigger.test", "query_id", "honeycombio_query.test", "id"),
+				),
+			},
+			{
+				ResourceName:        "honeycombio_trigger.test",
+				ImportStateIdPrefix: fmt.Sprintf("%v/", dataset),
+				ImportState:         true,
+				ImportStateVerify:   true,
+			},
+		},
+	})
+}
+
+// TestAcc_TriggerResourceUpgradeFromVersion014 is intended to test the migration
+// case from the last SDK-based version of the Trigger resource to the current Framework-based
+// version.
+//
+// State is first generated with the SDKv2 provider and then a plan is done with the new provider to
+// ensure there are no planned changes after migrating to the Framework-based resource.
+//
+// See: https://developer.hashicorp.com/terraform/plugin/framework/migrating/testing#testing-migration
+func TestAcc_TriggerResourceUpgradeFromVersion014(t *testing.T) {
+	dataset := testAccDataset()
+
+	resource.Test(t, resource.TestCase{
+		Steps: []resource.TestStep{
+			{
+				ExternalProviders: map[string]resource.ExternalProvider{
+					"honeycombio": {
+						VersionConstraint: "~> 0.14",
+						Source:            "honeycombio/honeycombio",
+					},
+				},
+				Config: basicTriggerTestConfig(dataset),
+				Check: resource.ComposeTestCheckFunc(
+					testAccEnsureTriggerExists(t, "honeycombio_trigger.test"),
+				),
+			},
+			{
+				ProtoV5ProviderFactories: testAccProtoV5MuxServerFactory,
+				Config:                   basicTriggerTestConfig(dataset),
+				PlanOnly:                 true,
+			},
+		},
+	})
+}
+
+func basicTriggerTestConfig(dataset string) string {
+	return fmt.Sprintf(`
+data "honeycombio_query_specification" "test" {
+  calculation {
+    op     = "AVG"
+    column = "duration_ms"
+  }
+  time_range = 1200
+}
+
+resource "honeycombio_query" "test" {
+  dataset    = "%[1]s"
+  query_json = data.honeycombio_query_specification.test.json
+}
+
+resource "honeycombio_pagerduty_recipient" "test" {
+  integration_key  = "09c9d4cacd68933151a1ef1048b67dd5"
+  integration_name = "acctest"
+}
+
+resource "honeycombio_trigger" "test" {
+  name    = "Test trigger from terraform-provider-honeycombio"
+  dataset = "%[1]s"
+
+  description = "My nice description"
+
+  query_id = honeycombio_query.test.id
+
+  threshold {
+    op    = ">"
+    value = 100
+  }
+
+  frequency = data.honeycombio_query_specification.test.time_range / 2
+
+  recipient {
+    type   = "email"
+    target = "hello@example.com"
+  }
+
+  recipient {
+    id = honeycombio_pagerduty_recipient.test.id
+
+    notification_details {
+      pagerduty_severity = "info"
+    }
+  }
+}`, dataset)
+}
+
+func testAccEnsureTriggerExists(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.Triggers.Get(context.Background(), resourceState.Primary.Attributes["dataset"], resourceState.Primary.ID)
+		if err != nil {
+			return fmt.Errorf("failed to fetch created trigger: %w", err)
+		}
+
+		return nil
+	}
+}