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
+88 -12
View File
@@ -52,19 +52,50 @@ func isNotFound(err error) bool {
// a local type, not an import of that package (this module has no
// dependency on /api at all, matching every other cross-module boundary
// in this repo: talk over HTTP, not Go imports, to a service that isn't
// yours). Panels are intentionally not modeled here yet -- this
// resource only manages dashboard-level fields; see the provider
// README for why panels are scoped-out future work, not an oversight.
// yours). Panels is populated by GET /dashboards/{id} (used by
// getPanel below, since panels have no GET endpoint of their own) but
// deliberately not settable through this type on create/update --
// panelResource manages panels one at a time through their own
// endpoints, never by rewriting a dashboard's whole panel list, so
// there's no code path that would ever marshal this field outbound.
type dashboard struct {
ID string `json:"id,omitempty"`
TenantID string `json:"tenant_id,omitempty"`
Name string `json:"name"`
Description string `json:"description"`
DefaultEarliest string `json:"default_earliest,omitempty"`
DefaultLatest string `json:"default_latest,omitempty"`
CreatedBy string `json:"created_by,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
ID string `json:"id,omitempty"`
TenantID string `json:"tenant_id,omitempty"`
Name string `json:"name"`
Description string `json:"description"`
DefaultEarliest string `json:"default_earliest,omitempty"`
DefaultLatest string `json:"default_latest,omitempty"`
CreatedBy string `json:"created_by,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
Panels []panel `json:"panels,omitempty"`
}
// panel mirrors api/dashboards.Panel's JSON shape. query_language
// deliberately never accepts "sql" -- api/dashboards's own
// validatePanel rejects it outright ("dashboards only support
// pipe-syntax queries, since the dashboard time-range picker is
// injected as leading query terms"), a real constraint 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 is worth knowing about before hitting it as a 400 from Create.
type panel struct {
ID string `json:"id,omitempty"`
DashboardID string `json:"dashboard_id,omitempty"`
Title string `json:"title"`
Query string `json:"query"`
QueryLanguage string `json:"query_language"`
VizType string `json:"viz_type"`
VizConfig json.RawMessage `json:"viz_config,omitempty"`
PositionX int `json:"position_x"`
PositionY int `json:"position_y"`
Width int `json:"width"`
Height int `json:"height"`
EarliestOverride *string `json:"earliest_override,omitempty"`
LatestOverride *string `json:"latest_override,omitempty"`
SortOrder int `json:"sort_order"`
CreatedAt string `json:"created_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}
func (c *client) do(ctx context.Context, method, path string, body, out any) error {
@@ -255,3 +286,48 @@ func (c *client) getNotificationTarget(ctx context.Context, id string) (*notific
func (c *client) deleteNotificationTarget(ctx context.Context, id string) error {
return c.do(ctx, http.MethodDelete, "/targets/"+id, nil, nil)
}
// createPanel, updatePanel, and deletePanel are straightforward --
// unlike rules/targets, api/dashboards.Handler actually has a
// PUT /dashboards/{id}/panels/{panelId}, so panelResource supports a
// real in-place update, the same as dashboardResource does.
func (c *client) createPanel(ctx context.Context, dashboardID string, p *panel) (*panel, error) {
var out panel
if err := c.do(ctx, http.MethodPost, "/dashboards/"+dashboardID+"/panels", p, &out); err != nil {
return nil, err
}
return &out, nil
}
func (c *client) updatePanel(ctx context.Context, dashboardID, panelID string, p *panel) (*panel, error) {
var out panel
if err := c.do(ctx, http.MethodPut, "/dashboards/"+dashboardID+"/panels/"+panelID, p, &out); err != nil {
return nil, err
}
return &out, nil
}
func (c *client) deletePanel(ctx context.Context, dashboardID, panelID string) error {
return c.do(ctx, http.MethodDelete, "/dashboards/"+dashboardID+"/panels/"+panelID, nil, nil)
}
// getPanel has no direct endpoint to call -- api/dashboards.Handler
// never registered a GET /dashboards/{id}/panels/{panelId}, only
// GET /dashboards/{id} (which includes the full panels list). This
// fetches the parent dashboard and finds the panel by ID within it,
// returning the same *apiError{StatusCode: 404} shape a direct GET
// would if either the dashboard itself or the panel within it is gone
// -- isNotFound works identically for callers regardless of which case
// applies, so panelResource's Read doesn't need to know the difference.
func (c *client) getPanel(ctx context.Context, dashboardID, panelID string) (*panel, error) {
d, err := c.getDashboard(ctx, dashboardID)
if err != nil {
return nil, err
}
for i := range d.Panels {
if d.Panels[i].ID == panelID {
return &d.Panels[i], nil
}
}
return nil, &apiError{StatusCode: http.StatusNotFound, Message: fmt.Sprintf("panel %q not found on dashboard %q", panelID, dashboardID)}
}