Add sentry_dashboard_panel, resolving panels as their own resource

The open design question named in the last three commits' README --
"a sentry_dashboard_panel resource (or a panels list block on this one)"
-- is resolved: separate resource, matching api/dashboards.Handler's own
shape (a panel is created/updated/deleted independently of its parent
dashboard via its own endpoints, never by rewriting the dashboard's
whole panel list). A nested list block would have forced every panel to
be rewritten on any single panel's change, hiding fine-grained diffs a
separate resource shows naturally -- the more idiomatic Terraform
pattern for independently-lifecycled child resources, and the one that
matches what the API actually does.

Unlike sentry_alert_rule/sentry_notification_target, this resource
supports a real in-place Update -- api/dashboards.Handler actually has a
PUT /dashboards/{id}/panels/{panelId}. Only dashboard_id forces
RequiresReplace: UpdatePanel's SQL matches WHERE id = $panelID AND
dashboard_id = $dashboardID, so changing dashboard_id through the
existing panel's URL wouldn't move it, it would just fail to match --
there's no API operation for "move a panel to a different dashboard."

Panels have no standalone GET endpoint -- only GET /dashboards/{id},
which includes the full panels array. client.go's new getPanel fetches
the parent dashboard and finds the panel by ID within it, returning the
same *apiError{StatusCode: 404} shape a direct GET would whether the
dashboard itself or just the panel within it is gone, so isNotFound
works identically either way. This also means a bare panel ID isn't
enough to import from -- ImportState takes "dashboard_id/panel_id" and
splits on the last "/", the one resource here with a composite import
identifier.

query_language never accepts "sql" for panels specifically -- confirmed
in api/dashboards's own validatePanel ("dashboards only support
pipe-syntax queries, since the dashboard time-range picker is injected
as leading query terms"), a real constraint from the API this client
doesn't re-validate client-side (same "let the API be the one source of
truth for validation" posture the other resources already take), but
documented in the schema so it's not a surprise 400 from Create.

sentry_dashboard_panel gets a matching data source too
(dashboard_id + id both Required, unlike the other three data sources'
single Required id, since getPanel itself needs both).

Verified: client tests are real httptest.Server round trips, including
getPanel finding the right panel within a real dashboard response and
returning a recognizable not-found both when the panel is missing and
when the parent dashboard itself is gone. Schema validation needs no
Terraform binary. TestAccDashboardPanelResource_basic and
TestAccDashboardPanelDataSource_basic are real acceptance tests,
skip-gated by TF_ACC same as the other six -- the resource test proves a
genuine in-place update (a title change, no plancheck needed since
in-place update is the default expectation here, unlike the
create/destroy-only resources). Not run against a live stack in this
environment, same disclosed gap as everything else Docker-gated in this
repo.
This commit is contained in:
2026-08-15 10:40:27 -07:00
parent eb38611aa8
commit 278b24cf67
12 changed files with 969 additions and 78 deletions
@@ -0,0 +1,93 @@
package provider
import (
"context"
"fmt"
"github.com/hashicorp/terraform-plugin-framework/datasource"
"github.com/hashicorp/terraform-plugin-framework/datasource/schema"
)
var (
_ datasource.DataSource = &dashboardPanelDataSource{}
_ datasource.DataSourceWithConfigure = &dashboardPanelDataSource{}
)
func newDashboardPanelDataSource() datasource.DataSource {
return &dashboardPanelDataSource{}
}
// dashboardPanelDataSource looks up an existing panel by
// (dashboard_id, id) -- both Required, unlike the other three data
// sources' single Required id, because getPanel itself needs both
// (there's no standalone GET for a panel, only
// GET /dashboards/{id}, see that method's doc comment in client.go).
type dashboardPanelDataSource struct {
client *client
}
func (d *dashboardPanelDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_dashboard_panel"
}
func (d *dashboardPanelDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
resp.Schema = schema.Schema{
Description: "Looks up an existing Sentry dashboard panel by (dashboard_id, id). See the sentry_dashboard_panel resource for how one is created/managed.",
Attributes: map[string]schema.Attribute{
"dashboard_id": schema.StringAttribute{
Required: true,
Description: "ID of the parent sentry_dashboard.",
},
"id": schema.StringAttribute{
Required: true,
Description: "Panel ID to look up.",
},
"title": schema.StringAttribute{Computed: true},
"query": schema.StringAttribute{Computed: true},
"query_language": schema.StringAttribute{Computed: true},
"viz_type": schema.StringAttribute{Computed: true},
"viz_config": schema.StringAttribute{Computed: true},
"position_x": schema.Int64Attribute{Computed: true},
"position_y": schema.Int64Attribute{Computed: true},
"width": schema.Int64Attribute{Computed: true},
"height": schema.Int64Attribute{Computed: true},
"earliest_override": schema.StringAttribute{Computed: true},
"latest_override": schema.StringAttribute{Computed: true},
"sort_order": schema.Int64Attribute{Computed: true},
"created_at": schema.StringAttribute{Computed: true},
"updated_at": schema.StringAttribute{Computed: true},
},
}
}
func (d *dashboardPanelDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
if req.ProviderData == nil {
return
}
data, ok := req.ProviderData.(*providerData)
if !ok {
resp.Diagnostics.AddError(
"Unexpected Data Source Configure Type",
fmt.Sprintf("Expected *provider.providerData, got: %T. This is a provider bug -- please report it.", req.ProviderData),
)
return
}
d.client = data.api
}
func (d *dashboardPanelDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) {
var config dashboardPanelResourceModel
resp.Diagnostics.Append(req.Config.Get(ctx, &config)...)
if resp.Diagnostics.HasError() {
return
}
dashboardID := config.DashboardID.ValueString()
out, err := d.client.getPanel(ctx, dashboardID, config.ID.ValueString())
if err != nil {
resp.Diagnostics.AddError("Reading Dashboard Panel", err.Error())
return
}
resp.Diagnostics.Append(resp.State.Set(ctx, dashboardPanelModelFromAPI(dashboardID, out))...)
}