Repository: honeycombio/terraform-provider-honeycombio
Pull request #487 (2024-06-14 14:13:14)

r/trigger: re-introduce query_json for deeper validation
In [v0.2.0](https://github.com/honeycombio/terraform-provider-honeycombio/blob/main/CHANGELOG.md#020-jan-27-2022) we dropped `query_json` from `r/trigger` to smooth out few a gnarly bugs associated with the Plugin SDK. With that change the ability to more deeply validate that a Trigger's query met the requirements was sadly lost.

Since then, `r/trigger` has been re-written in the new Plugin Framework and with it we got the ability to determine if the configuration was building via Query ID or Query JSON. This change takes advantage of that ability and re-introduces deeper validation for Trigger query's so long as you use `query_json` in place of `query_id`


Patch:
diff --git a/client/board.go b/client/board.go
index d602dfc..477385e 100644
--- a/client/board.go
+++ b/client/board.go
@@ -57,6 +57,8 @@ type Board struct {
 	Links BoardLinks `json:"links,omitempty"`
 	// A list of queries displayed on the board, in order of appearance.
 	Queries []BoardQuery `json:"queries"`
+	// A list of SLO IDs to be added to the board
+	SLOs []string `json:"slos"`
 }
 
 // BoardStyle determines how a Board should be displayed within the Honeycomb UI.
diff --git a/client/trigger.go b/client/trigger.go
index b7edc90..d6480d6 100644
--- a/client/trigger.go
+++ b/client/trigger.go
@@ -55,8 +55,9 @@ type Trigger struct {
 	// properties described with and validated by MatchesTriggerSubset.
 	// Additionally, time_range of the query can be at most 1 day and may not
 	// be greater than 4 times the frequency.
-	Query   *QuerySpec `json:"query,omitempty"`
-	QueryID string     `json:"query_id,omitempty"`
+	Query *QuerySpec `json:"query,omitempty"`
+	// The ID of the Query of the Trigger. Conflicts with Query
+	QueryID string `json:"query_id,omitempty"`
 	// Alert Type. Describes scheduling behavior for triggers.
 	// Defaults to "on_change"
 	AlertType TriggerAlertType `json:"alert_type,omitempty"`
diff --git a/docs/resources/board.md b/docs/resources/board.md
index 2a09a89..f82b926 100644
--- a/docs/resources/board.md
+++ b/docs/resources/board.md
@@ -100,8 +100,9 @@ The following arguments are supported:
 * `column_layout` - (Optional) the number of columns to layout on the board, either `multi` (the default) or `single`. Only `visual` style boards (see below) have a column layout.
 * `style` - (Optional) Deprecated: All Boards are now displayed as `visual` style. How the board should be displayed in the UI, either `visual` (the default) or `list`.
 * `query` - (Optional) Zero or more configurations blocks (described below) with the queries of the board.
+* `slo` - (Optional) Up to six configuration blocks (described below) to place SLOs on the board.
 
-Each board configuration may have zero or more `query` blocks, which accepts the following arguments:
+Each board configuration may have zero or more `query` blocks, which accept the following arguments:
 
 * `query_id` - (Required) The ID of the Query to run.
 * `query_annotation_id` - (Optional) The ID of the Query Annotation to associate with this query.
@@ -120,6 +121,10 @@ Currently supported toggles are:
   See [Graph Settings](https://docs.honeycomb.io/working-with-your-data/graph-settings/) in the documentation for more information on any individual setting.
 * `query_style` - (Optional) How the query should be displayed within the board, either `graph` (the default), `table` or `combo`.
 
+Each board configuration may have up to six `slo` blocks, which take the following arguments:
+
+* `id` - (Required) The ID of the SLO to place on the board.
+
 ## Attribute Reference
 
 In addition to all arguments above, the following attributes are exported:
diff --git a/docs/resources/trigger.md b/docs/resources/trigger.md
index bc9767d..1c58492 100644
--- a/docs/resources/trigger.md
+++ b/docs/resources/trigger.md
@@ -86,17 +86,12 @@ data "honeycombio_query_specification" "example" {
   }
 }
 
-resource "honeycombio_query" "example" {
-  dataset    = var.dataset
-  query_json = data.honeycombio_query_specification.example.json
-}
-
 resource "honeycombio_trigger" "example" {
   name        = "Requests are slower than usual"
   description = "Average duration of all requests for the last 10 minutes."
 
-  query_id = honeycombio_query.example.id
-  dataset  = var.dataset
+  query_json = data.honeycombio_query_specification.example.json
+  dataset    = var.dataset
 
   frequency = 600 // in seconds, 10 minutes
 
@@ -133,7 +128,10 @@ The following arguments are supported:
 
 * `name` - (Required) Name of the trigger.
 * `dataset` - (Required) The dataset this trigger is associated with.
-* `query_id` - (Required) The ID of the Query that the Trigger will execute.
+* `query_id` - (Optional) The ID of the Query that the Trigger will execute. Conflicts with `query_json`.
+* `query_json` - (Optional) The Query Specfication JSON for the Trigger to execute.
+Providing the Query Specification as JSON -- as opposed to a Query ID -- enables additional validation during the validate and plan stages.
+Conflicts with `query_id`.
 * `threshold` - (Required) A configuration block (described below) describing the threshold of the trigger.
 * `description` - (Optional) Description of the trigger.
 * `disabled` - (Optional) The state of the trigger. If true, the trigger will not be run. Defaults to false.
@@ -147,6 +145,8 @@ Outside of the window, the trigger will not be run.
 If no schedule is specified, the trigger will be run at the specified frequency at all times.
 * `recipient` - (Optional) Zero or more configuration blocks (described below) with the recipients to notify when the trigger fires.
 
+One of `query_id` or `query_json` are required.
+
 -> **NOTE** The query used in a Trigger must follow a strict subset: the query must contain *exactly one* calcuation and may only contain `calculation`, `filter`, `filter_combination` and `breakdowns` fields.
 The query's duration cannot be more than four times the trigger frequency (i.e. `duration <= frequency*4`).
 See [A Caveat on Time](https://docs.honeycomb.io/working-with-your-data/query-specification/#a-caveat-on-time)) for more information on specifying a query's duration.
diff --git a/honeycombio/resource_board.go b/honeycombio/resource_board.go
index de22f8f..0619a60 100644
--- a/honeycombio/resource_board.go
+++ b/honeycombio/resource_board.go
@@ -144,6 +144,21 @@ See [Graph Settings](https://docs.honeycomb.io/working-with-your-data/graph-sett
 					},
 				},
 			},
+			"slo": {
+				Type:        schema.TypeSet,
+				Optional:    true,
+				Description: "An SLO to added to the board.",
+				MaxItems:    6,
+				Elem: &schema.Resource{
+					Schema: map[string]*schema.Schema{
+						"id": {
+							Type:        schema.TypeString,
+							Required:    true,
+							Description: "The ID of the SLO.",
+						},
+					},
+				},
+			},
 		},
 	}
 }
@@ -200,9 +215,16 @@ func resourceBoardRead(ctx context.Context, d *schema.ResourceData, meta interfa
 			"query_annotation_id": q.QueryAnnotationID,
 		}
 	}
-
 	d.Set("query", queries)
 
+	slos := make([]map[string]interface{}, len(b.SLOs))
+	for i, s := range b.SLOs {
+		slos[i] = map[string]interface{}{
+			"id": s,
+		}
+	}
+	d.Set("slo", slos)
+
 	return nil
 }
 
@@ -258,6 +280,12 @@ func expandBoard(d *schema.ResourceData) (*honeycombio.Board, error) {
 		})
 	}
 
+	slos := []string{}
+	// Board SLOs don't currently have an order: the API returns them sorted by creation time
+	for _, s := range d.Get("slo").(*schema.Set).List() {
+		slos = append(slos, s.(map[string]interface{})["id"].(string))
+	}
+
 	board := &honeycombio.Board{
 		ID:           d.Id(),
 		Name:         d.Get("name").(string),
@@ -265,6 +293,7 @@ func expandBoard(d *schema.ResourceData) (*honeycombio.Board, error) {
 		Style:        honeycombio.BoardStyle(d.Get("style").(string)),
 		ColumnLayout: honeycombio.BoardColumnStyle(d.Get("column_layout").(string)),
 		Queries:      queries,
+		SLOs:         slos,
 	}
 
 	if board.Style == honeycombio.BoardStyleList && board.ColumnLayout != "" {
diff --git a/internal/models/triggers.go b/internal/models/triggers.go
index a49d906..5fb1874 100644
--- a/internal/models/triggers.go
+++ b/internal/models/triggers.go
@@ -9,6 +9,7 @@ type TriggerResourceModel struct {
 	Description        types.String                     `tfsdk:"description"`
 	Disabled           types.Bool                       `tfsdk:"disabled"`
 	QueryID            types.String                     `tfsdk:"query_id"`
+	QueryJson          types.String                     `tfsdk:"query_json"`
 	AlertType          types.String                     `tfsdk:"alert_type"`
 	Frequency          types.Int64                      `tfsdk:"frequency"`
 	Threshold          []TriggerThresholdModel          `tfsdk:"threshold"`
diff --git a/internal/provider/trigger_resource.go b/internal/provider/trigger_resource.go
index 5996fed..2ad4d6c 100644
--- a/internal/provider/trigger_resource.go
+++ b/internal/provider/trigger_resource.go
@@ -2,6 +2,7 @@ package provider
 
 import (
 	"context"
+	"encoding/json"
 	"errors"
 	"regexp"
 	"strings"
@@ -9,6 +10,7 @@ import (
 	"github.com/hashicorp/terraform-plugin-framework-validators/int64validator"
 	"github.com/hashicorp/terraform-plugin-framework-validators/listvalidator"
 	"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"
@@ -30,9 +32,10 @@ import (
 
 // Ensure the implementation satisfies the expected interfaces.
 var (
-	_ resource.Resource                = &triggerResource{}
-	_ resource.ResourceWithConfigure   = &triggerResource{}
-	_ resource.ResourceWithImportState = &triggerResource{}
+	_ resource.Resource                   = &triggerResource{}
+	_ resource.ResourceWithConfigure      = &triggerResource{}
+	_ resource.ResourceWithImportState    = &triggerResource{}
+	_ resource.ResourceWithValidateConfig = &triggerResource{}
 )
 
 func NewTriggerResource() resource.Resource {
@@ -95,9 +98,22 @@ func (r *triggerResource) Schema(_ context.Context, _ resource.SchemaRequest, re
 				Default:     booldefault.StaticBool(false),
 			},
 			"query_id": schema.StringAttribute{
-				Required:    true,
+				Optional:    true,
 				Description: "The ID of the Query that the Trigger will execute.",
 			},
+			"query_json": schema.StringAttribute{
+				Optional: true,
+				Description: "The QuerySpec JSON for the query that the Trigger will execute. " +
+					"Providing the QuerySpec JSON directly allows for additional validation that the QuerySpec is valid as a Trigger Query." +
+					" While the JSON can be constructed manually, it is easiest to use the `honeycombio_query_specification` data source.",
+				PlanModifiers: []planmodifier.String{
+					modifiers.EquivalentQuerySpec(),
+				},
+				Validators: []validator.String{
+					stringvalidator.ConflictsWith(path.MatchRelative().AtParent().AtName("query_id")),
+					validation.ValidQuerySpec(),
+				},
+			},
 			"alert_type": schema.StringAttribute{
 				Optional:    true,
 				Computed:    true,
@@ -214,13 +230,31 @@ func (r *triggerResource) Create(ctx context.Context, req resource.CreateRequest
 		Name:               plan.Name.ValueString(),
 		Description:        plan.Description.ValueString(),
 		Disabled:           plan.Disabled.ValueBool(),
-		QueryID:            plan.QueryID.ValueString(),
 		AlertType:          client.TriggerAlertType(plan.AlertType.ValueString()),
 		Threshold:          expandTriggerThreshold(plan.Threshold),
 		Frequency:          int(plan.Frequency.ValueInt64()),
 		Recipients:         expandNotificationRecipients(ctx, plan.Recipients, &resp.Diagnostics),
 		EvaluationSchedule: expandTriggerEvaluationSchedule(plan.EvaluationSchedule),
 	}
+
+	specifiedByID := !plan.QueryID.IsNull()
+	if specifiedByID {
+		newTrigger.QueryID = plan.QueryID.ValueString()
+		newTrigger.Query = nil
+	} else {
+		newTrigger.QueryID = ""
+
+		var q client.QuerySpec
+		if err := json.Unmarshal([]byte(plan.QueryJson.ValueString()), &q); err != nil {
+			resp.Diagnostics.AddAttributeError(
+				path.Root("query_json"),
+				"Failed to unmarshal JSON",
+				err.Error(),
+			)
+		}
+		newTrigger.Query = &q
+	}
+
 	if plan.EvaluationSchedule != nil {
 		newTrigger.EvaluationScheduleType = client.TriggerEvaluationScheduleWindow
 	}
@@ -236,7 +270,6 @@ func (r *triggerResource) Create(ctx context.Context, req resource.CreateRequest
 	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(string(trigger.AlertType))
 	state.Threshold = flattenTriggerThreshold(trigger.Threshold)
 	state.Frequency = types.Int64Value(int64(trigger.Frequency))
@@ -244,6 +277,24 @@ func (r *triggerResource) Create(ctx context.Context, req resource.CreateRequest
 	// we created them as authored so to avoid matching type-target or ID we can just use the same value
 	state.Recipients = config.Recipients
 
+	if specifiedByID {
+		state.QueryID = types.StringValue(trigger.QueryID)
+		state.QueryJson = types.StringNull()
+	} else {
+		state.QueryID = types.StringNull()
+
+		json, err := trigger.Query.Encode()
+		if err != nil {
+			resp.Diagnostics.AddAttributeError(
+				path.Root("query_json"),
+				"failed to encode query_json",
+				err.Error(),
+			)
+		} else {
+			state.QueryJson = types.StringValue(json)
+		}
+	}
+
 	resp.Diagnostics.Append(resp.State.Set(ctx, state)...)
 }
 
@@ -281,13 +332,31 @@ func (r *triggerResource) Read(ctx context.Context, req resource.ReadRequest, re
 	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(string(trigger.AlertType))
 	state.Threshold = flattenTriggerThreshold(trigger.Threshold)
 	state.Frequency = types.Int64Value(int64(trigger.Frequency))
 	state.EvaluationSchedule = flattenTriggerEvaluationSchedule(trigger)
 	state.Recipients = reconcileReadNotificationRecipientState(ctx, trigger.Recipients, state.Recipients, &resp.Diagnostics)
 
+	specifiedByID := !state.QueryID.IsNull()
+	if specifiedByID {
+		state.QueryID = types.StringValue(trigger.QueryID)
+		state.QueryJson = types.StringNull()
+	} else {
+		state.QueryID = types.StringNull()
+
+		json, err := trigger.Query.Encode()
+		if err != nil {
+			resp.Diagnostics.AddAttributeError(
+				path.Root("query_json"),
+				"failed to encode query_json",
+				err.Error(),
+			)
+		} else {
+			state.QueryJson = types.StringValue(json)
+		}
+	}
+
 	resp.Diagnostics.Append(resp.State.Set(ctx, state)...)
 }
 
@@ -304,13 +373,31 @@ func (r *triggerResource) Update(ctx context.Context, req resource.UpdateRequest
 		Name:               plan.Name.ValueString(),
 		Description:        plan.Description.ValueString(),
 		Disabled:           plan.Disabled.ValueBool(),
-		QueryID:            plan.QueryID.ValueString(),
 		AlertType:          client.TriggerAlertType(plan.AlertType.ValueString()),
 		Frequency:          int(plan.Frequency.ValueInt64()),
 		Threshold:          expandTriggerThreshold(plan.Threshold),
 		Recipients:         expandNotificationRecipients(ctx, plan.Recipients, &resp.Diagnostics),
 		EvaluationSchedule: expandTriggerEvaluationSchedule(plan.EvaluationSchedule),
 	}
+
+	specifiedByID := !plan.QueryID.IsNull()
+	if specifiedByID {
+		updatedTrigger.QueryID = plan.QueryID.ValueString()
+		updatedTrigger.Query = nil
+	} else {
+		updatedTrigger.QueryID = ""
+
+		var q client.QuerySpec
+		if err := json.Unmarshal([]byte(plan.QueryJson.ValueString()), &q); err != nil {
+			resp.Diagnostics.AddAttributeError(
+				path.Root("query_json"),
+				"Failed to unmarshal JSON",
+				err.Error(),
+			)
+		}
+		updatedTrigger.Query = &q
+	}
+
 	if updatedTrigger.EvaluationSchedule != nil {
 		updatedTrigger.EvaluationScheduleType = client.TriggerEvaluationScheduleWindow
 	} else {
@@ -333,7 +420,6 @@ func (r *triggerResource) Update(ctx context.Context, req resource.UpdateRequest
 	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(string(trigger.AlertType))
 	state.Frequency = types.Int64Value(int64(trigger.Frequency))
 	state.Threshold = flattenTriggerThreshold(trigger.Threshold)
@@ -341,6 +427,24 @@ func (r *triggerResource) Update(ctx context.Context, req resource.UpdateRequest
 	// we created them as authored so to avoid matching type-target or ID we can just use the same value
 	state.Recipients = config.Recipients
 
+	if specifiedByID {
+		state.QueryID = types.StringValue(trigger.QueryID)
+		state.QueryJson = types.StringNull()
+	} else {
+		state.QueryID = types.StringNull()
+
+		json, err := trigger.Query.Encode()
+		if err != nil {
+			resp.Diagnostics.AddAttributeError(
+				path.Root("query_json"),
+				"failed to encode query_json",
+				err.Error(),
+			)
+		} else {
+			state.QueryJson = types.StringValue(json)
+		}
+	}
+
 	resp.Diagnostics.Append(resp.State.Set(ctx, state)...)
 }
 
@@ -385,10 +489,107 @@ func (r *triggerResource) ImportState(ctx context.Context, req resource.ImportSt
 	resp.Diagnostics.Append(resp.State.Set(ctx, &models.TriggerResourceModel{
 		ID:         types.StringValue(id),
 		Dataset:    types.StringValue(dataset),
+		QueryID:    types.StringNull(),
+		QueryJson:  types.StringUnknown(), // favor QueryJSON on import
 		Recipients: types.SetUnknown(types.ObjectType{AttrTypes: models.NotificationRecipientAttrType}),
 	})...)
 }
 
+func (r *triggerResource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) {
+	var data models.TriggerResourceModel
+	resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)
+	if resp.Diagnostics.HasError() {
+		return
+	}
+
+	// exit early if we don't have QueryJSON
+	if data.QueryJson.IsNull() || data.QueryJson.IsUnknown() {
+		return
+	}
+
+	var q client.QuerySpec
+	if err := json.Unmarshal([]byte(data.QueryJson.ValueString()), &q); err != nil {
+		resp.Diagnostics.AddAttributeError(
+			path.Root("query_json"),
+			"Failed to unmarshal JSON",
+			err.Error(),
+		)
+		return
+	}
+
+	// validate calculations
+	if len(q.Calculations) != 1 {
+		resp.Diagnostics.AddAttributeError(
+			path.Root("query_json"),
+			"Trigger validation error",
+			"Trigger queries must contain a single calculation.",
+		)
+	} else {
+		if q.Calculations[0].Op == client.CalculationOpHeatmap {
+			resp.Diagnostics.AddAttributeError(
+				path.Root("query_json"),
+				"Trigger validation error",
+				"Trigger queries cannot use HEATMAP calculations.",
+			)
+		}
+		if q.Calculations[0].Op == client.CalculationOpConcurrency {
+			resp.Diagnostics.AddAttributeError(
+				path.Root("query_json"),
+				"Trigger validation error",
+				"Trigger queries cannot use CONCURRENCY calculations.",
+			)
+		}
+	}
+
+	// ensure unsupported fields are unset
+	if q.Orders != nil {
+		resp.Diagnostics.AddAttributeError(
+			path.Root("query_json"),
+			"Trigger validation error",
+			"Trigger queries cannot use orders.",
+		)
+	}
+	if q.Limit != nil {
+		resp.Diagnostics.AddAttributeError(
+			path.Root("query_json"),
+			"Trigger validation error",
+			"Trigger queries cannot use limit.",
+		)
+	}
+	if q.StartTime != nil || q.EndTime != nil {
+		resp.Diagnostics.AddAttributeError(
+			path.Root("query_json"),
+			"Trigger validation error",
+			"Trigger queries cannot use start_time or end_time.",
+		)
+	}
+	if q.Granularity != nil {
+		resp.Diagnostics.AddAttributeError(
+			path.Root("query_json"),
+			"Trigger validation error",
+			"Trigger queries cannot use granularity.",
+		)
+	}
+
+	if q.TimeRange != nil {
+		frequency := int(data.Frequency.ValueInt64())
+		if *q.TimeRange < frequency {
+			resp.Diagnostics.AddAttributeError(
+				path.Root("frequency"),
+				"Trigger validation error",
+				"The Trigger's frequency must be at least equal to the query duration.",
+			)
+		}
+		if *q.TimeRange > frequency*4 {
+			resp.Diagnostics.AddAttributeError(
+				path.Root("frequency"),
+				"Trigger validation error",
+				"The Trigger's frequency cannot be more than four times the query duration.",
+			)
+		}
+	}
+}
+
 func expandTriggerThreshold(t []models.TriggerThresholdModel) *client.TriggerThreshold {
 	if len(t) != 1 {
 		return nil


Test patch:
diff --git a/client/board_test.go b/client/board_test.go
index 2b3d9e9..a1624f1 100644
--- a/client/board_test.go
+++ b/client/board_test.go
@@ -42,6 +42,24 @@ func TestBoards(t *testing.T) {
 	})
 	require.NoError(t, err)
 
+	sli, err := c.DerivedColumns.Create(ctx, dataset, &client.DerivedColumn{
+		Alias:      test.RandomStringWithPrefix("test.", 8),
+		Expression: "BOOL(1)",
+	})
+	require.NoError(t, err)
+	slo, err := c.SLOs.Create(ctx, dataset, &client.SLO{
+		Name:             test.RandomStringWithPrefix("test.", 8),
+		TimePeriodDays:   7,
+		TargetPerMillion: 990000,
+		SLI:              client.SLIRef{Alias: sli.Alias},
+	})
+	require.NoError(t, err)
+	t.Cleanup(func() {
+		//nolint:errcheck
+		c.SLOs.Delete(ctx, dataset, slo.ID)
+		c.DerivedColumns.Delete(ctx, dataset, sli.ID)
+	})
+
 	t.Run("Create", func(t *testing.T) {
 		data := &client.Board{
 			Name:         test.RandomStringWithPrefix("test.", 8),
@@ -57,6 +75,7 @@ func TestBoards(t *testing.T) {
 					GraphSettings: client.BoardGraphSettings{OmitMissingValues: true, UseUTCXAxis: true},
 				},
 			},
+			SLOs: []string{slo.ID},
 		}
 		b, err = c.Boards.Create(ctx, data)
 		require.NoError(t, err)
@@ -104,6 +123,7 @@ func TestBoards(t *testing.T) {
 			QueryID:       *newQuery.ID,
 			GraphSettings: client.BoardGraphSettings{UseUTCXAxis: true},
 		})
+		b.SLOs = []string{}
 
 		result, err := c.Boards.Update(ctx, b)
 		require.NoError(t, err)
diff --git a/honeycombio/resource_board_test.go b/honeycombio/resource_board_test.go
index 5d0ffc7..c0f308b 100644
--- a/honeycombio/resource_board_test.go
+++ b/honeycombio/resource_board_test.go
@@ -7,6 +7,10 @@ import (
 
 	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource"
 	"github.com/hashicorp/terraform-plugin-sdk/v2/terraform"
+	"github.com/hashicorp/terraform-plugin-testing/helper/acctest"
+	"github.com/stretchr/testify/require"
+
+	"github.com/honeycombio/terraform-provider-honeycombio/client"
 )
 
 func TestAccHoneycombioBoard_basic(t *testing.T) {
@@ -183,6 +187,146 @@ resource "honeycombio_board" "test" {
 	})
 }
 
+func TestAccBoard_withSLOs(t *testing.T) {
+	ctx := context.Background()
+	dataset := testAccDataset()
+	c := testAccClient(t)
+
+	sli1, err := c.DerivedColumns.Create(ctx, dataset, &client.DerivedColumn{
+		Alias:      "sli." + acctest.RandString(8),
+		Expression: "BOOL(1)",
+	})
+	require.NoError(t, err)
+	slo1, err := c.SLOs.Create(ctx, dataset, &client.SLO{
+		Name:             acctest.RandString(8) + " SLO",
+		TimePeriodDays:   14,
+		TargetPerMillion: 995000,
+		SLI:              client.SLIRef{Alias: sli1.Alias},
+	})
+	require.NoError(t, err)
+
+	sli2, err := c.DerivedColumns.Create(ctx, dataset, &client.DerivedColumn{
+		Alias:      "sli." + acctest.RandString(8),
+		Expression: "BOOL(1)",
+	})
+	require.NoError(t, err)
+	slo2, err := c.SLOs.Create(ctx, dataset, &client.SLO{
+		Name:             acctest.RandString(8) + " SLO",
+		TimePeriodDays:   14,
+		TargetPerMillion: 995000,
+		SLI:              client.SLIRef{Alias: sli2.Alias},
+	})
+	require.NoError(t, err)
+
+	//nolint:errcheck
+	t.Cleanup(func() {
+		// remove SLOs, and SLIs at end of test run
+		c.SLOs.Delete(ctx, dataset, slo1.ID)
+		c.SLOs.Delete(ctx, dataset, slo2.ID)
+		c.DerivedColumns.Delete(ctx, dataset, sli1.ID)
+		c.DerivedColumns.Delete(ctx, dataset, sli2.ID)
+	})
+
+	resource.Test(t, resource.TestCase{
+		PreCheck:                 testAccPreCheck(t),
+		ProtoV5ProviderFactories: testAccProtoV5ProviderFactory,
+		Steps: []resource.TestStep{
+			// create a board with one SLO on it
+			{
+				Config: fmt.Sprintf(`
+data "honeycombio_query_specification" "test" {
+  calculation {
+    op = "COUNT"
+  }
+}
+
+resource "honeycombio_query" "test" {
+  dataset    = "%s"
+  query_json = data.honeycombio_query_specification.test.json
+}
+
+resource "honeycombio_board" "test" {
+  name          = "board with some SLOs"
+
+  query {
+    query_id = honeycombio_query.test.id
+  }
+
+  slo {
+    id = "%s"
+  }
+}
+`, dataset, slo1.ID),
+				Check: resource.ComposeTestCheckFunc(
+					testAccCheckBoardExists(t, "honeycombio_board.test"),
+					resource.TestCheckResourceAttr("honeycombio_board.test", "slo.#", "1"),
+					resource.TestCheckResourceAttr("honeycombio_board.test", "slo.0.id", slo1.ID),
+				),
+			},
+			{
+				// update that board to have two SLOs
+				Config: fmt.Sprintf(`
+data "honeycombio_query_specification" "test" {
+  calculation {
+    op = "COUNT"
+  }
+}
+
+resource "honeycombio_query" "test" {
+  dataset    = "%s"
+  query_json = data.honeycombio_query_specification.test.json
+}
+
+resource "honeycombio_board" "test" {
+  name          = "board with some SLOs"
+
+  query {
+    query_id = honeycombio_query.test.id
+  }
+
+  slo {
+    id = "%s"
+  }
+
+  slo {
+    id = "%s"
+  }
+}`, dataset, slo2.ID, slo1.ID),
+				Check: resource.ComposeTestCheckFunc(
+					testAccCheckBoardExists(t, "honeycombio_board.test"),
+					resource.TestCheckResourceAttr("honeycombio_board.test", "slo.#", "2"),
+				),
+			},
+			{
+				// remove all SLOs from that board
+				Config: fmt.Sprintf(`
+data "honeycombio_query_specification" "test" {
+  calculation {
+    op = "COUNT"
+  }
+}
+
+resource "honeycombio_query" "test" {
+  dataset    = "%s"
+  query_json = data.honeycombio_query_specification.test.json
+}
+
+resource "honeycombio_board" "test" {
+  name          = "board with no SLOs"
+
+  query {
+    query_id = honeycombio_query.test.id
+  }
+}`, dataset),
+				Check: resource.ComposeTestCheckFunc(
+					testAccCheckBoardExists(t, "honeycombio_board.test"),
+					resource.TestCheckResourceAttr("honeycombio_board.test", "slo.#", "0"),
+				),
+			},
+		},
+	})
+}
+
 func testAccBoardConfig(dataset string) string {
 	return fmt.Sprintf(`
 data "honeycombio_query_specification" "test" {
diff --git a/internal/provider/slos_data_source_test.go b/internal/provider/slos_data_source_test.go
index 9acb1f9..590e2c2 100644
--- a/internal/provider/slos_data_source_test.go
+++ b/internal/provider/slos_data_source_test.go
@@ -111,7 +111,7 @@ data "honeycombio_slos" "exact" {
 }
 `, dataset, testPrefix),
 				Check: resource.ComposeAggregateTestCheckFunc(
-					resource.TestCheckResourceAttr("data.honeycombio_slos.all", "ids.#", "3"),
+					resource.TestCheckResourceAttr("data.honeycombio_slos.all", "ids.#", "5"),
 					resource.TestCheckResourceAttr("data.honeycombio_slos.regex", "ids.#", "2"),
 					resource.TestCheckResourceAttr("data.honeycombio_slos.exact", "ids.#", "1"),
 				),
diff --git a/internal/provider/trigger_resource_test.go b/internal/provider/trigger_resource_test.go
index 0f19936..ae600bc 100644
--- a/internal/provider/trigger_resource_test.go
+++ b/internal/provider/trigger_resource_test.go
@@ -4,6 +4,7 @@ import (
 	"context"
 	"fmt"
 	"os"
+	"regexp"
 	"testing"
 
 	"github.com/stretchr/testify/require"
@@ -18,31 +19,63 @@ import (
 func TestAcc_TriggerResource(t *testing.T) {
 	dataset := testAccDataset()
 
-	resource.Test(t, resource.TestCase{
-		PreCheck:                 testAccPreCheck(t),
-		ProtoV5ProviderFactories: testAccProtoV5MuxServerFactory,
-		Steps: []resource.TestStep{
-			{
-				Config: testAccConfigBasicTriggerTest(dataset, "info"),
-				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.TestCheckResourceAttr("honeycombio_trigger.test", "threshold.0.exceeded_limit", "1"),
-					resource.TestCheckResourceAttrPair("honeycombio_trigger.test", "query_id", "honeycombio_query.test", "id"),
-				),
-			},
-			// then update the PD Severity from info -> critical (the default)
-			{
-				Config: testAccConfigBasicTriggerTest(dataset, "critical"),
+	t.Run("trigger resource with Query ID", func(t *testing.T) {
+		resource.Test(t, resource.TestCase{
+			PreCheck:                 testAccPreCheck(t),
+			ProtoV5ProviderFactories: testAccProtoV5MuxServerFactory,
+			Steps: []resource.TestStep{
+				{
+					Config: testAccConfigBasicTriggerTest(dataset, "info"),
+					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.TestCheckResourceAttr("honeycombio_trigger.test", "threshold.0.exceeded_limit", "1"),
+						resource.TestCheckResourceAttrPair("honeycombio_trigger.test", "query_id", "honeycombio_query.test", "id"),
+						resource.TestCheckNoResourceAttr("honeycombio_trigger.test", "query_json"),
+					),
+				},
+				// then update the PD Severity from info -> critical (the default)
+				{
+					Config: testAccConfigBasicTriggerTest(dataset, "critical"),
+				},
+				{
+					ResourceName:        "honeycombio_trigger.test",
+					ImportStateIdPrefix: fmt.Sprintf("%v/", dataset),
+					ImportState:         true,
+				},
 			},
-			{
-				ResourceName:        "honeycombio_trigger.test",
-				ImportStateIdPrefix: fmt.Sprintf("%v/", dataset),
-				ImportState:         true,
+		})
+	})
+
+	t.Run("trigger resource with Query JSON", func(t *testing.T) {
+		resource.Test(t, resource.TestCase{
+			PreCheck:                 testAccPreCheck(t),
+			ProtoV5ProviderFactories: testAccProtoV5MuxServerFactory,
+			Steps: []resource.TestStep{
+				{
+					Config: testAccConfigBasicTriggerTest_QuerySpec(dataset, "info"),
+					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.TestCheckResourceAttr("honeycombio_trigger.test", "threshold.0.exceeded_limit", "1"),
+						resource.TestCheckNoResourceAttr("honeycombio_trigger.test", "query_id"),
+					),
+				},
+				// then update the PD Severity from info -> critical (the default)
+				{
+					Config: testAccConfigBasicTriggerTest_QuerySpec(dataset, "critical"),
+				},
+				{
+					ResourceName:        "honeycombio_trigger.test",
+					ImportStateIdPrefix: fmt.Sprintf("%v/", dataset),
+					ImportState:         true,
+				},
 			},
-		},
+		})
 	})
 }
 
@@ -461,6 +494,238 @@ func TestAcc_TriggerResourceHandlesRecipientChangedOutsideOfTerraform(t *testing
 	})
 }
 
+func TestAcc_TriggerResourceValidatesQueryJSON(t *testing.T) {
+	t.Parallel()
+
+	resource.Test(t, resource.TestCase{
+		PreCheck:                 testAccPreCheck(t),
+		ProtoV5ProviderFactories: testAccProtoV5MuxServerFactory,
+		Steps: []resource.TestStep{
+			{
+				Config: `
+resource "honeycombio_trigger" "test" {
+  name    = "I fail validation"
+  dataset = "foobar"
+
+  query_id   = "jsdfjsdf"
+  query_json = "{}"
+}`,
+				ExpectError: regexp.MustCompile(`"query_id" cannot be specified when "query_json" is specified`),
+			},
+			{
+				Config: `
+data "honeycombio_query_specification" "test" {
+  calculation {
+    op = "COUNT"
+  }
+
+  calculation {
+    op = "AVG"
+    column = "duration_ms"
+  }
+}
+
+resource "honeycombio_trigger" "test" {
+  name    = "I fail validation"
+  dataset = "foobar"
+
+  threshold {
+    op    = ">"
+    value = 100
+  }
+
+  query_json = data.honeycombio_query_specification.test.json
+}`,
+				ExpectError: regexp.MustCompile(`queries must contain a single calculation`),
+			},
+			{
+				Config: `
+data "honeycombio_query_specification" "test" {
+  calculation {
+    op = "HEATMAP"
+    column = "duration_ms"
+  }
+}
+
+resource "honeycombio_trigger" "test" {
+  name    = "I fail validation"
+  dataset = "foobar"
+
+  threshold {
+    op    = ">"
+    value = 100
+  }
+
+  query_json = data.honeycombio_query_specification.test.json
+}`,
+				ExpectError: regexp.MustCompile(`queries cannot use HEATMAP calculations`),
+			},
+			{
+				Config: `
+data "honeycombio_query_specification" "test" {
+  calculation {
+    op = "CONCURRENCY"
+  }
+}
+
+resource "honeycombio_trigger" "test" {
+  name    = "I fail validation"
+  dataset = "foobar"
+
+  threshold {
+    op    = ">"
+    value = 100
+  }
+
+  query_json = data.honeycombio_query_specification.test.json
+}`,
+				ExpectError: regexp.MustCompile(`queries cannot use CONCURRENCY calculations`),
+			},
+			{
+				Config: `
+data "honeycombio_query_specification" "test" {
+  calculation {
+    op = "COUNT"
+  }
+
+  order {
+    op    = "COUNT"
+    order = "ascending"
+  }
+}
+
+resource "honeycombio_trigger" "test" {
+  name    = "I fail validation"
+  dataset = "foobar"
+
+  threshold {
+    op    = ">"
+    value = 100
+  }
+
+  query_json = data.honeycombio_query_specification.test.json
+}`,
+				ExpectError: regexp.MustCompile(`queries cannot use orders`),
+			},
+			{
+				Config: `
+data "honeycombio_query_specification" "test" {
+  calculation {
+    op = "COUNT"
+  }
+
+  limit = 10
+}
+
+resource "honeycombio_trigger" "test" {
+  name    = "I fail validation"
+  dataset = "foobar"
+
+  threshold {
+    op    = ">"
+    value = 100
+  }
+
+  query_json = data.honeycombio_query_specification.test.json
+}`,
+				ExpectError: regexp.MustCompile(`queries cannot use limit`),
+			},
+			{
+				Config: `
+data "honeycombio_query_specification" "test" {
+  calculation {
+    op = "COUNT"
+  }
+
+  end_time   = 1454808600
+}
+
+resource "honeycombio_trigger" "test" {
+  name    = "I fail validation"
+  dataset = "foobar"
+
+  threshold {
+    op    = ">"
+    value = 100
+  }
+
+  query_json = data.honeycombio_query_specification.test.json
+}`,
+				ExpectError: regexp.MustCompile(`queries cannot use start_time or end_time`),
+			},
+			{
+				Config: `
+data "honeycombio_query_specification" "test" {
+  calculation {
+    op = "COUNT"
+  }
+
+  granularity = 120
+}
+
+resource "honeycombio_trigger" "test" {
+  name    = "I fail validation"
+  dataset = "foobar"
+
+  threshold {
+    op    = ">"
+    value = 100
+  }
+
+  query_json = data.honeycombio_query_specification.test.json
+}`,
+				ExpectError: regexp.MustCompile(`queries cannot use granularity`),
+			},
+			{
+				Config: `
+data "honeycombio_query_specification" "test" {
+  calculation {
+    op = "COUNT"
+  }
+
+  time_range = 1800
+}
+
+resource "honeycombio_trigger" "test" {
+  name    = "I fail validation"
+  dataset = "foobar"
+
+  threshold {
+    op    = ">"
+    value = 100
+  }
+
+  frequency = 7200
+
+  query_json = data.honeycombio_query_specification.test.json
+}`,
+				ExpectError: regexp.MustCompile(`frequency must be at least equal to the query duration`),
+			},
+			{
+				Config: `
+data "honeycombio_query_specification" "test" {
+  calculation {
+    op = "COUNT"
+  }
+}
+
+resource "honeycombio_trigger" "test" {
+  name    = "I fail validation"
+  dataset = "foobar"
+
+  threshold {
+    op    = ">"
+    value = 100
+  }
+
+  query_json = data.honeycombio_query_specification.test.json
+}`,
+				ExpectError: regexp.MustCompile(`frequency cannot be more than four times the query duration`),
+			},
+		},
+	})
+}
+
 func testAccConfigBasicTriggerTest(dataset, pdseverity string) string {
 	return fmt.Sprintf(`
 data "honeycombio_query_specification" "test" {
@@ -511,6 +776,51 @@ resource "honeycombio_trigger" "test" {
 }`, dataset, pdseverity)
 }
 
+func testAccConfigBasicTriggerTest_QuerySpec(dataset, pdseverity string) string {
+	return fmt.Sprintf(`
+data "honeycombio_query_specification" "test" {
+  calculation {
+    op     = "AVG"
+    column = "duration_ms"
+  }
+  time_range = 1200
+}
+
+resource "honeycombio_pagerduty_recipient" "test" {
+  integration_key  = "08b9d4cacd68933151a1ef1028b67da2"
+  integration_name = "testacc-basic"
+}
+
+resource "honeycombio_trigger" "test" {
+  name    = "Test trigger from terraform-provider-honeycombio"
+  dataset = "%[1]s"
+
+  description = "My nice description"
+
+  query_json = data.honeycombio_query_specification.test.json
+
+  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 = "%[2]s"
+    }
+  }
+}`, dataset, pdseverity)
+}
+
 func testAccConfigTriggerRecipientByID(dataset, recipientID string, exceededLimit int) string {
 	return fmt.Sprintf(`
 data "honeycombio_query_specification" "test" {