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
+11 -8
View File
@@ -20,14 +20,17 @@ described there without flagging it to me first.
UI-only logic. CLI (`sentryctl`) and Terraform provider are first-class, UI-only logic. CLI (`sentryctl`) and Terraform provider are first-class,
not afterthoughts. **Status**: `sentryctl` has been built out phase by not afterthoughts. **Status**: `sentryctl` has been built out phase by
phase since Phase 3. The Terraform provider (`/terraform`) only exists phase since Phase 3. The Terraform provider (`/terraform`) only exists
as of this note -- three resources (`sentry_dashboard`, full CRUD; as of this note -- four resources (`sentry_dashboard` and
`sentry_alert_rule` and `sentry_notification_target`, both create/ `sentry_dashboard_panel`, both full CRUD, panels as their own resource
destroy only -- `alerting` has no `PUT /rules/{id}` or rather than a nested block since the API manages them independently
`PUT /targets/{id}` to update against), each paired with a read-only of their parent dashboard; `sentry_alert_rule` and
data source, built on HashiCorp's `terraform-plugin-framework`, `sentry_notification_target`, both create/destroy only -- `alerting`
reusing the exact same REST contracts `sentryctl dashboards apply`/ has no `PUT /rules/{id}` or `PUT /targets/{id}` to update against),
web's dashboard export and `sentryctl alerts apply` already use. each paired with a read-only data source, built on HashiCorp's
Tenant/RBAC resources are real, disclosed future work -- see `terraform-plugin-framework`, reusing the exact same REST contracts
`sentryctl dashboards apply`/web's dashboard export and
`sentryctl alerts apply` already use. Tenant/RBAC resources are real,
disclosed future work -- see
`/terraform/README.md` for the full accounting of what is and isn't `/terraform/README.md` for the full accounting of what is and isn't
built, and the same built, and the same
"written but not run against a live stack" verification caveat as "written but not run against a live stack" verification caveat as
+91 -58
View File
@@ -3,8 +3,8 @@
Sentry's Terraform provider -- `CLAUDE.md`'s "Repo conventions" section Sentry's Terraform provider -- `CLAUDE.md`'s "Repo conventions" section
names this a first-class deliverable alongside `sentryctl` names this a first-class deliverable alongside `sentryctl`
("CLI and Terraform provider are first-class, not afterthoughts"), but ("CLI and Terraform provider are first-class, not afterthoughts"), but
no phase before this one had actually built any of it. Three resources no phase before this one had actually built any of it. Four resources
so far (`sentry_dashboard`, `sentry_alert_rule`, so far (`sentry_dashboard`, `sentry_dashboard_panel`, `sentry_alert_rule`,
`sentry_notification_target`), each paired with a read-only data source `sentry_notification_target`), each paired with a read-only data source
of the same name, not a finished provider, built on of the same name, not a finished provider, built on
[HashiCorp's `terraform-plugin-framework`][framework] (the [HashiCorp's `terraform-plugin-framework`][framework] (the
@@ -14,7 +14,7 @@ framework HashiCorp itself steers new providers away from).
[framework]: https://developer.hashicorp.com/terraform/plugin/framework [framework]: https://developer.hashicorp.com/terraform/plugin/framework
## Why these three resources first ## Why these four resources first
`cli/README.md` already frames the dashboards REST contract this way: `cli/README.md` already frames the dashboards REST contract this way:
`POST /dashboards`, `GET`/`PUT`/`DELETE /dashboards/{id}` are "the seed `POST /dashboards`, `GET`/`PUT`/`DELETE /dashboards/{id}` are "the seed
@@ -23,15 +23,18 @@ export, CLI apply, eventually a provider)." This provider is that third
caller -- `internal/provider/client.go` talks the exact same JSON shape caller -- `internal/provider/client.go` talks the exact same JSON shape
`sentryctl dashboards apply` and the web UI's Export JSON button already `sentryctl dashboards apply` and the web UI's Export JSON button already
use against `api/dashboards.Handler`, not a new contract invented for use against `api/dashboards.Handler`, not a new contract invented for
Terraform's sake. `sentry_alert_rule` follows against `alerting`'s own Terraform's sake. `sentry_dashboard_panel` follows the same contract's
`POST`/`GET`/`DELETE /rules[/{id}]` -- the natural second resource, and panel endpoints, as its own resource rather than a block nested inside
a real second service (`alerting` is a genuinely separate deployment `sentry_dashboard` -- see "Panels are their own resource" below.
from `api`, its own base URL), so building it second exercised that this `sentry_alert_rule` follows against `alerting`'s own `POST`/`GET`/
provider can talk to more than one Sentry service, not just repeat the `DELETE /rules[/{id}]` -- the natural next resource, and a real second
dashboards pattern against the same endpoint. `sentry_notification_target` service (`alerting` is a genuinely separate deployment from `api`, its
rounds these two out -- `sentry_alert_rule.notification_target_id` needs own base URL), so building it exercised that this provider can talk to
something to actually point at, and without this resource that id could more than one Sentry service, not just repeat the dashboards pattern
only ever come from outside Terraform (`sentryctl`, `curl`, the web UI), against the same endpoint. `sentry_notification_target` rounds these
out -- `sentry_alert_rule.notification_target_id` needs something to
actually point at, and without this resource that id could only ever
come from outside Terraform (`sentryctl`, `curl`, the web UI),
undermining the point of managing rules as code at all. undermining the point of managing rules as code at all.
## What's built ## What's built
@@ -63,15 +66,37 @@ resource "sentry_dashboard" "example" {
Supports `terraform import sentry_dashboard.example <dashboard-id>`. Supports `terraform import sentry_dashboard.example <dashboard-id>`.
**Panels are not managed by this resource.** `api/dashboards.Handler` **Panels are their own resource, not a nested block.** `api/dashboards.
exposes panel CRUD as its own endpoints Handler` exposes panel CRUD as its own endpoints (`POST`/`PUT`/
(`POST`/`PUT`/`DELETE /dashboards/{id}/panels[/{panelId}]`), a `DELETE /dashboards/{id}/panels[/{panelId}]`) -- a panel belongs to
genuinely separate resource shape (a panel belongs to exactly one exactly one dashboard, has its own lifecycle, and is created/updated/
dashboard, has its own lifecycle, and the query-language/viz-config deleted independently, never by rewriting a dashboard's whole panel
fields deserve their own attribute validation) -- scoped out of this list, so `sentry_dashboard_panel` follows that shape rather than a
first pass deliberately, not an oversight. A `sentry_dashboard_panel` nested list block (which would force every panel to be rewritten on any
resource (or a panels list block on this one -- an open design question, single panel's change, hiding fine-grained diffs a separate resource
not yet decided) is real, disclosed future work. shows naturally):
```hcl
resource "sentry_dashboard_panel" "example" {
dashboard_id = sentry_dashboard.example.id
title = "5xx rate over time"
query = "status>=500 | timechart count"
viz_type = "line" # table, line, bar, single_stat, or top_n
# query_language never accepts "sql" for panels -- the API rejects it
# outright (dashboards only support pipe-syntax queries, since the
# time-range picker is injected as leading query terms). Unlike
# sentry_alert_rule/sentry_notification_target, this resource
# supports a real in-place update (api/dashboards.Handler has a real
# PUT for panels) -- only dashboard_id forces a destroy-and-recreate,
# since there's no API operation to move a panel between dashboards.
}
```
Supports `terraform import sentry_dashboard_panel.example
<dashboard-id>/<panel-id>` -- a bare panel ID isn't enough on its own,
since `Read` needs the parent `dashboard_id` to know where to look (see
`client.go`'s `getPanel` doc comment for why: there's no standalone
`GET` for a single panel).
```hcl ```hcl
provider "sentry" { provider "sentry" {
@@ -134,15 +159,17 @@ than left implicit.
## Data sources ## Data sources
Each resource above has a matching read-only data source (`data Each resource above has a matching read-only data source (`data
"sentry_dashboard"`, `data "sentry_alert_rule"`, `data "sentry_dashboard"`, `data "sentry_dashboard_panel"`, `data
"sentry_notification_target"`), a single `id` attribute in, every other "sentry_alert_rule"`, `data "sentry_notification_target"`) -- a lookup
attribute out -- a lookup against the same `GET`/`{id}` endpoint the against the same endpoint the matching resource's own `Read` already
matching resource's own `Read` already uses, nothing new added to uses, nothing new added to `client.go` beyond that. Mechanical and
`client.go` beyond that. Mechanical and low-risk by design: no new low-risk by design: no new architectural question, no new external
architectural question, no new external service, no new write path -- service, no new write path -- just reusing the resource's own model/
just reusing the resource's own model/conversion functions conversion functions (`dashboardModelFromAPI` etc.) against `Required`
(`dashboardModelFromAPI` etc.) against a `Required` `id` input instead input instead of a full config. Three of the four take a single
of a full config. `Required` `id`; `sentry_dashboard_panel`'s takes both `dashboard_id`
and `id` (both `Required`), matching `getPanel`'s own two-argument shape
-- there's no standalone lookup for a panel by ID alone.
```hcl ```hcl
data "sentry_notification_target" "ops" { data "sentry_notification_target" "ops" {
@@ -167,7 +194,6 @@ attribute" above.
idempotently -- see `/enterprise/README.md`'s "Bootstrapping a tenant" idempotently -- see `/enterprise/README.md`'s "Bootstrapping a tenant"
section) and Phase 4 commercial licensing, so this would need its own section) and Phase 4 commercial licensing, so this would need its own
design pass, not just "add another resource file." design pass, not just "add another resource file."
- Dashboard panels (see "Panels are not managed by this resource" above).
- Publishing to the real Terraform Registry -- `main.go`'s `Address` - Publishing to the real Terraform Registry -- `main.go`'s `Address`
(`registry.terraform.io/sentry/sentry`) is the address a real (`registry.terraform.io/sentry/sentry`) is the address a real
publication would use, but nothing has actually been published; local publication would use, but nothing has actually been published; local
@@ -191,38 +217,45 @@ response parsing, including the 404-vs-other-error distinction
out-of-band" convention correctly, (for rules) proving the out-of-band" convention correctly, (for rules) proving the
`GET /rules/{id}` response's promoted `RuleWithState` fields plus an `GET /rules/{id}` response's promoted `RuleWithState` fields plus an
extra `"state"` key this client deliberately has no field for still extra `"state"` key this client deliberately has no field for still
parse cleanly, and (for targets) proving `secret` really does come back parse cleanly, (for targets) proving `secret` really does come back
unredacted -- documenting real `alerting` behavior with a test, not just unredacted -- documenting real `alerting` behavior with a test, not just
a comment, so a future change to that behavior would be caught here too. a comment, so a future change to that behavior would be caught here too
`internal/provider/provider_test.go` validates the provider's, all -- and (for panels) proving `getPanel` finds the right panel within a
three resources', and all three data sources' schemas are internally real parent dashboard's `panels` array, and returns a recognizable
not-found both when the panel is missing from an otherwise-real
dashboard response and when the dashboard itself is gone.
`internal/provider/provider_test.go` validates the provider's, all four
resources', and all four data sources' schemas are internally
well-formed (attribute names, the `Required`/`Computed`/`Sensitive` well-formed (attribute names, the `Required`/`Computed`/`Sensitive`
split -- for data sources, specifically that `id` is `Required` and split -- for data sources, that every attribute except the lookup key(s)
everything else is `Computed`) without needing a Terraform binary or a is `Computed`) without needing a Terraform binary or a live
live `api`/`alerting` service at all. `api`/`alerting` service at all.
Each resource and data source pair has a matching `TestAcc*_basic` in Each resource and data source pair has a matching `TestAcc*_basic` in
its own `_test.go` file (`dashboard_resource_test.go`/ its own `_test.go` file (`dashboard_resource_test.go`/
`dashboard_data_source_test.go`, and likewise for the other two) -- six `dashboard_data_source_test.go`, and likewise for the other three) --
real acceptance tests total, using [`terraform-plugin-testing`][testing] eight real acceptance tests total, using
-- skipped unless `TF_ACC=1` is set, that framework's own standard [`terraform-plugin-testing`][testing] -- skipped unless `TF_ACC=1` is
convention, the same shape every other live-infrastructure test in this set, that framework's own standard convention, the same shape every
repo uses (`docker`-gated env vars for Postgres/ClickHouse tests other live-infrastructure test in this repo uses (`docker`-gated env
elsewhere). The rule and target *resource* tests both use a vars for Postgres/ClickHouse tests elsewhere). The rule and target
`plancheck.ExpectResourceAction` assertion proving a config change *resource* tests both use a `plancheck.ExpectResourceAction` assertion
actually plans a destroy-then-create, not an in-place update -- the proving a config change actually plans a destroy-then-create, not an
concrete, checked version of the "create/destroy only" design decision in-place update -- the concrete, checked version of the "create/destroy
documented above, not just a claim in a comment; the *data source* only" design decision documented above, not just a claim in a comment;
tests each create a resource then look it up via `resource. the panel resource test instead proves a genuine in-place update
TestCheckResourceAttrPair`, proving the data source's `Read` actually (a `title` change with no `plancheck` needed, since the default
agrees with what the resource wrote, not just that both compile. Even expectation -- update, not replace -- is exactly what should happen);
with `TF_ACC=1` all six tests also need real running `api`/`alerting` the *data source* tests each create a resource then look it up via
services (Postgres + ClickHouse) to apply against, which this `resource.TestCheckResourceAttrPair`, proving the data source's `Read`
environment has no Docker access to bring up -- **not run here**, same actually agrees with what the resource wrote, not just that both
disclosed gap as every other live-infra test across this repo (see compile. Even with `TF_ACC=1` all eight tests also need real running
`/docs/phase-4-runbook.md`'s "Verification status" section for the `api`/`alerting` services (Postgres + ClickHouse) to apply against,
project-wide version of this same caveat). "The test exists and is which this environment has no Docker access to bring up -- **not run
correct Go" is not the same claim as "this resource has been applied here**, same disclosed gap as every other live-infra test across this
repo (see `/docs/phase-4-runbook.md`'s "Verification status" section
for the project-wide version of this same caveat). "The test exists and
is correct Go" is not the same claim as "this resource has been applied
for real." for real."
[testing]: https://developer.hashicorp.com/terraform/plugin/testing [testing]: https://developer.hashicorp.com/terraform/plugin/testing
@@ -0,0 +1,4 @@
# "dashboard_id/panel_id" -- a bare panel id isn't enough to import
# from, since Read needs the parent dashboard_id to know where to look
# (there's no standalone GET for a single panel).
terraform import sentry_dashboard_panel.error_rate <dashboard-id>/<panel-id>
@@ -0,0 +1,20 @@
resource "sentry_dashboard" "checkout_errors" {
name = "Checkout Errors"
}
resource "sentry_dashboard_panel" "error_rate" {
dashboard_id = sentry_dashboard.checkout_errors.id
title = "5xx rate over time"
query = "service=checkout status>=500 | timechart count"
viz_type = "line"
position_x = 0
position_y = 0
width = 6
height = 4
}
# Unlike sentry_alert_rule/sentry_notification_target, this resource
# supports a real in-place update -- api/dashboards.Handler has a real
# PUT /dashboards/{id}/panels/{panelId}. Only dashboard_id forces a
# destroy-and-recreate (there's no API operation to move a panel between
# dashboards).
+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 // a local type, not an import of that package (this module has no
// dependency on /api at all, matching every other cross-module boundary // 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 // in this repo: talk over HTTP, not Go imports, to a service that isn't
// yours). Panels are intentionally not modeled here yet -- this // yours). Panels is populated by GET /dashboards/{id} (used by
// resource only manages dashboard-level fields; see the provider // getPanel below, since panels have no GET endpoint of their own) but
// README for why panels are scoped-out future work, not an oversight. // 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 { type dashboard struct {
ID string `json:"id,omitempty"` ID string `json:"id,omitempty"`
TenantID string `json:"tenant_id,omitempty"` TenantID string `json:"tenant_id,omitempty"`
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description"` Description string `json:"description"`
DefaultEarliest string `json:"default_earliest,omitempty"` DefaultEarliest string `json:"default_earliest,omitempty"`
DefaultLatest string `json:"default_latest,omitempty"` DefaultLatest string `json:"default_latest,omitempty"`
CreatedBy string `json:"created_by,omitempty"` CreatedBy string `json:"created_by,omitempty"`
CreatedAt string `json:"created_at,omitempty"` CreatedAt string `json:"created_at,omitempty"`
UpdatedAt string `json:"updated_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 { 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 { func (c *client) deleteNotificationTarget(ctx context.Context, id string) error {
return c.do(ctx, http.MethodDelete, "/targets/"+id, nil, nil) 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)}
}
+127
View File
@@ -316,3 +316,130 @@ func TestDeleteNotificationTargetSendsToCorrectPath(t *testing.T) {
} }
func strPtr(s string) *string { return &s } func strPtr(s string) *string { return &s }
func TestCreatePanelSendsExpectedRequest(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/dashboards/dash-1/panels" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
var body panel
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decoding request body: %v", err)
}
if body.Query != "status>=500 | stats count" || body.VizType != "line" {
t.Errorf("unexpected request body: %+v", body)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(panel{ID: "panel-1", DashboardID: "dash-1", Query: body.Query, VizType: body.VizType})
}))
defer srv.Close()
c := newClient(srv.URL, "")
out, err := c.createPanel(context.Background(), "dash-1", &panel{Query: "status>=500 | stats count", VizType: "line"})
if err != nil {
t.Fatalf("createPanel: %v", err)
}
if out.ID != "panel-1" {
t.Fatalf("unexpected response: %+v", out)
}
}
func TestUpdatePanelSendsToCorrectPath(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut || r.URL.Path != "/dashboards/dash-1/panels/panel-1" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(panel{ID: "panel-1", Title: "Renamed"})
}))
defer srv.Close()
c := newClient(srv.URL, "")
out, err := c.updatePanel(context.Background(), "dash-1", "panel-1", &panel{Title: "Renamed"})
if err != nil {
t.Fatalf("updatePanel: %v", err)
}
if out.Title != "Renamed" {
t.Fatalf("Title = %q, want Renamed", out.Title)
}
}
func TestDeletePanelSendsToCorrectPath(t *testing.T) {
called := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
if r.Method != http.MethodDelete || r.URL.Path != "/dashboards/dash-1/panels/panel-1" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
c := newClient(srv.URL, "")
if err := c.deletePanel(context.Background(), "dash-1", "panel-1"); err != nil {
t.Fatalf("deletePanel: %v", err)
}
if !called {
t.Fatal("expected the server to receive a DELETE request")
}
}
func TestGetPanelFindsPanelWithinParentDashboard(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/dashboards/dash-1" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(dashboard{
ID: "dash-1",
Panels: []panel{
{ID: "panel-1", Title: "First"},
{ID: "panel-2", Title: "Second"},
},
})
}))
defer srv.Close()
c := newClient(srv.URL, "")
out, err := c.getPanel(context.Background(), "dash-1", "panel-2")
if err != nil {
t.Fatalf("getPanel: %v", err)
}
if out.Title != "Second" {
t.Fatalf("Title = %q, want Second", out.Title)
}
}
func TestGetPanelNotFoundWhenPanelMissingFromDashboard(t *testing.T) {
// Same "not found" shape as a real 404 -- proves isNotFound works
// for a panel absent from an otherwise-real dashboard response, not
// just for a literal 404 status code.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(dashboard{ID: "dash-1", Panels: []panel{{ID: "panel-1"}}})
}))
defer srv.Close()
c := newClient(srv.URL, "")
_, err := c.getPanel(context.Background(), "dash-1", "does-not-exist")
if err == nil {
t.Fatal("expected an error for a panel not present on the dashboard")
}
if !isNotFound(err) {
t.Fatalf("isNotFound(%v) = false, want true", err)
}
}
func TestGetPanelPropagatesDashboardNotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
c := newClient(srv.URL, "")
_, err := c.getPanel(context.Background(), "does-not-exist", "panel-1")
if err == nil || !isNotFound(err) {
t.Fatalf("err = %v, want a recognizable not-found when the parent dashboard itself is gone", err)
}
}
@@ -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))...)
}
@@ -0,0 +1,45 @@
package provider
import (
"testing"
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
)
// Same skip-gated-not-faked posture as TestAccDashboardResource_basic --
// see that test's doc comment.
func TestAccDashboardPanelDataSource_basic(t *testing.T) {
resource.Test(t, resource.TestCase{
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
Steps: []resource.TestStep{
{
Config: `
provider "sentry" {
endpoint = "http://localhost:8080"
}
resource "sentry_dashboard" "test" {
name = "Panel Data Source Test Dashboard"
}
resource "sentry_dashboard_panel" "test" {
dashboard_id = sentry_dashboard.test.id
title = "Errors over time"
query = "status>=500 | timechart count"
viz_type = "line"
}
data "sentry_dashboard_panel" "test" {
dashboard_id = sentry_dashboard.test.id
id = sentry_dashboard_panel.test.id
}
`,
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttrPair("data.sentry_dashboard_panel.test", "title", "sentry_dashboard_panel.test", "title"),
resource.TestCheckResourceAttrPair("data.sentry_dashboard_panel.test", "query", "sentry_dashboard_panel.test", "query"),
resource.TestCheckResourceAttrPair("data.sentry_dashboard_panel.test", "viz_type", "sentry_dashboard_panel.test", "viz_type"),
),
},
},
})
}
@@ -0,0 +1,342 @@
package provider
import (
"context"
"encoding/json"
"fmt"
"strings"
"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/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/types"
)
var (
_ resource.Resource = &dashboardPanelResource{}
_ resource.ResourceWithConfigure = &dashboardPanelResource{}
_ resource.ResourceWithImportState = &dashboardPanelResource{}
)
func newDashboardPanelResource() resource.Resource {
return &dashboardPanelResource{}
}
// dashboardPanelResource implements sentry_dashboard_panel against
// api/dashboards.Handler's POST/PUT/DELETE
// /dashboards/{id}/panels[/{panelId}] endpoints -- a genuinely separate
// resource from sentry_dashboard (own id, own lifecycle, own endpoints),
// not a nested block on the dashboard resource. That split matches the
// API's own shape (a panel is created/updated/deleted independently of
// its parent dashboard, never by rewriting the dashboard's whole panel
// list) and is the more idiomatic Terraform pattern for independently-
// lifecycled child resources: a nested list block would force every
// panel to be rewritten on any single panel's change, hiding
// fine-grained diffs a separate resource shows naturally.
//
// 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 a
// replace: UpdatePanel's SQL matches WHERE id = $panelID AND
// dashboard_id = $dashboardID (store.go), so sending a changed
// 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," so Terraform has to destroy and
// recreate instead of attempting an update that can't work.
type dashboardPanelResource struct {
client *client
}
type dashboardPanelResourceModel struct {
ID types.String `tfsdk:"id"`
DashboardID types.String `tfsdk:"dashboard_id"`
Title types.String `tfsdk:"title"`
Query types.String `tfsdk:"query"`
QueryLanguage types.String `tfsdk:"query_language"`
VizType types.String `tfsdk:"viz_type"`
VizConfig types.String `tfsdk:"viz_config"`
PositionX types.Int64 `tfsdk:"position_x"`
PositionY types.Int64 `tfsdk:"position_y"`
Width types.Int64 `tfsdk:"width"`
Height types.Int64 `tfsdk:"height"`
EarliestOverride types.String `tfsdk:"earliest_override"`
LatestOverride types.String `tfsdk:"latest_override"`
SortOrder types.Int64 `tfsdk:"sort_order"`
CreatedAt types.String `tfsdk:"created_at"`
UpdatedAt types.String `tfsdk:"updated_at"`
}
func (r *dashboardPanelResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_dashboard_panel"
}
func (r *dashboardPanelResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Description: "A panel on a Sentry dashboard, managed independently of the sentry_dashboard it belongs to.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()},
Description: "Server-generated panel ID.",
},
"dashboard_id": schema.StringAttribute{
Required: true,
PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()},
Description: "ID of the sentry_dashboard this panel belongs to. Forces replacement on change -- there is no API operation to move a panel between dashboards, see this resource's Go doc comment.",
},
"title": schema.StringAttribute{
Optional: true,
Computed: true,
Default: stringdefault.StaticString(""),
},
"query": schema.StringAttribute{
Required: true,
Description: "Pipe-syntax query text. The API rejects an empty string, and rejects query_language = \"sql\" outright -- see this resource's Go doc comment.",
},
"query_language": schema.StringAttribute{
Optional: true,
Computed: true,
Default: stringdefault.StaticString(""),
Description: `"" (auto-detect) or "spl" -- never "sql", the API rejects that for panels specifically (unlike sentry_alert_rule's query_language, which accepts it).`,
},
"viz_type": schema.StringAttribute{
Required: true,
Description: `One of "table", "line", "bar", "single_stat", "top_n".`,
},
"viz_config": schema.StringAttribute{
Optional: true,
Computed: true,
Default: stringdefault.StaticString("{}"),
Description: `Visualization-specific config, as a JSON object string -- e.g. jsonencode({...}). Left unset, the API defaults this to "{}".`,
},
"position_x": schema.Int64Attribute{
Optional: true,
Computed: true,
Default: int64default.StaticInt64(0),
},
"position_y": schema.Int64Attribute{
Optional: true,
Computed: true,
Default: int64default.StaticInt64(0),
},
"width": schema.Int64Attribute{
Optional: true,
Computed: true,
Default: int64default.StaticInt64(0),
},
"height": schema.Int64Attribute{
Optional: true,
Computed: true,
Default: int64default.StaticInt64(0),
},
"earliest_override": schema.StringAttribute{
Optional: true,
Description: "Overrides the parent dashboard's default_earliest for this panel only. Unset means inherit the dashboard's default.",
},
"latest_override": schema.StringAttribute{
Optional: true,
Description: "Overrides the parent dashboard's default_latest for this panel only. Unset means inherit the dashboard's default.",
},
"sort_order": schema.Int64Attribute{
Optional: true,
Computed: true,
Default: int64default.StaticInt64(0),
},
"created_at": schema.StringAttribute{
Computed: true,
PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()},
},
"updated_at": schema.StringAttribute{
Computed: true,
Description: "Changes on every update -- deliberately not given UseStateForUnknown, unlike created_at.",
},
},
}
}
func (r *dashboardPanelResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
if req.ProviderData == nil {
return
}
data, ok := req.ProviderData.(*providerData)
if !ok {
resp.Diagnostics.AddError(
"Unexpected Resource Configure Type",
fmt.Sprintf("Expected *provider.providerData, got: %T. This is a provider bug -- please report it.", req.ProviderData),
)
return
}
r.client = data.api
}
func dashboardPanelModelFromAPI(dashboardID string, p *panel) dashboardPanelResourceModel {
m := dashboardPanelResourceModel{
ID: types.StringValue(p.ID),
DashboardID: types.StringValue(dashboardID),
Title: types.StringValue(p.Title),
Query: types.StringValue(p.Query),
QueryLanguage: types.StringValue(p.QueryLanguage),
VizType: types.StringValue(p.VizType),
PositionX: types.Int64Value(int64(p.PositionX)),
PositionY: types.Int64Value(int64(p.PositionY)),
Width: types.Int64Value(int64(p.Width)),
Height: types.Int64Value(int64(p.Height)),
SortOrder: types.Int64Value(int64(p.SortOrder)),
CreatedAt: types.StringValue(p.CreatedAt),
UpdatedAt: types.StringValue(p.UpdatedAt),
}
if len(p.VizConfig) > 0 {
m.VizConfig = types.StringValue(string(p.VizConfig))
} else {
m.VizConfig = types.StringValue("{}")
}
if p.EarliestOverride != nil {
m.EarliestOverride = types.StringValue(*p.EarliestOverride)
}
if p.LatestOverride != nil {
m.LatestOverride = types.StringValue(*p.LatestOverride)
}
return m
}
func dashboardPanelAPIFromModel(m dashboardPanelResourceModel) (*panel, error) {
p := &panel{
Title: m.Title.ValueString(),
Query: m.Query.ValueString(),
QueryLanguage: m.QueryLanguage.ValueString(),
VizType: m.VizType.ValueString(),
PositionX: int(m.PositionX.ValueInt64()),
PositionY: int(m.PositionY.ValueInt64()),
Width: int(m.Width.ValueInt64()),
Height: int(m.Height.ValueInt64()),
SortOrder: int(m.SortOrder.ValueInt64()),
}
if !m.VizConfig.IsNull() {
raw := m.VizConfig.ValueString()
if !json.Valid([]byte(raw)) {
return nil, fmt.Errorf("viz_config must be valid JSON (use jsonencode(...) in the resource config), got: %s", raw)
}
p.VizConfig = json.RawMessage(raw)
}
if !m.EarliestOverride.IsNull() {
v := m.EarliestOverride.ValueString()
p.EarliestOverride = &v
}
if !m.LatestOverride.IsNull() {
v := m.LatestOverride.ValueString()
p.LatestOverride = &v
}
return p, nil
}
func (r *dashboardPanelResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan dashboardPanelResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
in, err := dashboardPanelAPIFromModel(plan)
if err != nil {
resp.Diagnostics.AddError("Invalid Configuration", err.Error())
return
}
dashboardID := plan.DashboardID.ValueString()
out, err := r.client.createPanel(ctx, dashboardID, in)
if err != nil {
resp.Diagnostics.AddError("Creating Dashboard Panel", err.Error())
return
}
resp.Diagnostics.Append(resp.State.Set(ctx, dashboardPanelModelFromAPI(dashboardID, out))...)
}
func (r *dashboardPanelResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var state dashboardPanelResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
dashboardID := state.DashboardID.ValueString()
out, err := r.client.getPanel(ctx, dashboardID, state.ID.ValueString())
if err != nil {
if isNotFound(err) {
resp.State.RemoveResource(ctx)
return
}
resp.Diagnostics.AddError("Reading Dashboard Panel", err.Error())
return
}
resp.Diagnostics.Append(resp.State.Set(ctx, dashboardPanelModelFromAPI(dashboardID, out))...)
}
func (r *dashboardPanelResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan dashboardPanelResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
in, err := dashboardPanelAPIFromModel(plan)
if err != nil {
resp.Diagnostics.AddError("Invalid Configuration", err.Error())
return
}
dashboardID := plan.DashboardID.ValueString()
out, err := r.client.updatePanel(ctx, dashboardID, plan.ID.ValueString(), in)
if err != nil {
resp.Diagnostics.AddError("Updating Dashboard Panel", err.Error())
return
}
resp.Diagnostics.Append(resp.State.Set(ctx, dashboardPanelModelFromAPI(dashboardID, out))...)
}
func (r *dashboardPanelResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var state dashboardPanelResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
if err := r.client.deletePanel(ctx, state.DashboardID.ValueString(), state.ID.ValueString()); err != nil && !isNotFound(err) {
resp.Diagnostics.AddError("Deleting Dashboard Panel", err.Error())
}
}
// ImportState takes "dashboard_id/panel_id" -- unlike the other
// resources, a panel's identity in the API isn't self-sufficient (Read
// needs the parent dashboard_id to know where to look, since there's no
// standalone GET for a panel -- see getPanel's doc comment), so a bare
// panel ID isn't enough to import from.
func (r *dashboardPanelResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
dashboardID, panelID, found := splitImportID(req.ID)
if !found {
resp.Diagnostics.AddError(
"Unexpected Import Identifier",
fmt.Sprintf("Expected import identifier of the form \"dashboard_id/panel_id\", got: %q", req.ID),
)
return
}
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("dashboard_id"), dashboardID)...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), panelID)...)
}
// splitImportID splits "dashboard_id/panel_id" on the last "/" --
// dashboard IDs are server-generated UUIDs with no "/" in them today,
// but splitting on the *last* separator rather than the first is
// defensive against that changing, since the panel ID is what actually
// needs to be unambiguous here.
func splitImportID(id string) (dashboardID, panelID string, found bool) {
i := strings.LastIndex(id, "/")
if i < 0 {
return "", "", false
}
return id[:i], id[i+1:], true
}
@@ -0,0 +1,86 @@
package provider
import (
"fmt"
"testing"
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
tfstate "github.com/hashicorp/terraform-plugin-testing/terraform"
)
// Same skip-gated-not-faked posture as TestAccDashboardResource_basic --
// see that test's doc comment.
func TestAccDashboardPanelResource_basic(t *testing.T) {
resource.Test(t, resource.TestCase{
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
Steps: []resource.TestStep{
{
Config: `
provider "sentry" {
endpoint = "http://localhost:8080"
}
resource "sentry_dashboard" "test" {
name = "Panel Acceptance Test Dashboard"
}
resource "sentry_dashboard_panel" "test" {
dashboard_id = sentry_dashboard.test.id
title = "Errors over time"
query = "status>=500 | timechart count"
viz_type = "line"
}
`,
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttrPair("sentry_dashboard_panel.test", "dashboard_id", "sentry_dashboard.test", "id"),
resource.TestCheckResourceAttr("sentry_dashboard_panel.test", "title", "Errors over time"),
resource.TestCheckResourceAttr("sentry_dashboard_panel.test", "viz_type", "line"),
resource.TestCheckResourceAttrSet("sentry_dashboard_panel.test", "id"),
// Left unset in config -- must come back as the
// API's own default ("{}"), same "API default, not
// a duplicated Terraform-side one" reasoning
// sentry_dashboard's default_earliest/default_latest
// use.
resource.TestCheckResourceAttr("sentry_dashboard_panel.test", "viz_config", "{}"),
),
},
{
// Update: unlike sentry_alert_rule/sentry_notification_target,
// this really is an in-place update -- api/dashboards.Handler
// has a real PUT for panels.
Config: `
provider "sentry" {
endpoint = "http://localhost:8080"
}
resource "sentry_dashboard" "test" {
name = "Panel Acceptance Test Dashboard"
}
resource "sentry_dashboard_panel" "test" {
dashboard_id = sentry_dashboard.test.id
title = "Errors over time (renamed)"
query = "status>=500 | timechart count"
viz_type = "line"
}
`,
Check: resource.TestCheckResourceAttr("sentry_dashboard_panel.test", "title", "Errors over time (renamed)"),
},
{
// "dashboard_id/panel_id" -- see ImportState's doc
// comment on splitImportID for why a bare panel ID
// isn't enough.
ResourceName: "sentry_dashboard_panel.test",
ImportState: true,
ImportStateIdFunc: func(s *tfstate.State) (string, error) {
rs, ok := s.RootModule().Resources["sentry_dashboard_panel.test"]
if !ok {
return "", fmt.Errorf("sentry_dashboard_panel.test not found in state")
}
return rs.Primary.Attributes["dashboard_id"] + "/" + rs.Primary.Attributes["id"], nil
},
ImportStateVerify: true,
},
},
})
}
+2
View File
@@ -129,6 +129,7 @@ func (p *sentryProvider) Configure(ctx context.Context, req provider.ConfigureRe
func (p *sentryProvider) Resources(_ context.Context) []func() resource.Resource { func (p *sentryProvider) Resources(_ context.Context) []func() resource.Resource {
return []func() resource.Resource{ return []func() resource.Resource{
newDashboardResource, newDashboardResource,
newDashboardPanelResource,
newAlertRuleResource, newAlertRuleResource,
newNotificationTargetResource, newNotificationTargetResource,
} }
@@ -137,6 +138,7 @@ func (p *sentryProvider) Resources(_ context.Context) []func() resource.Resource
func (p *sentryProvider) DataSources(_ context.Context) []func() datasource.DataSource { func (p *sentryProvider) DataSources(_ context.Context) []func() datasource.DataSource {
return []func() datasource.DataSource{ return []func() datasource.DataSource{
newDashboardDataSource, newDashboardDataSource,
newDashboardPanelDataSource,
newAlertRuleDataSource, newAlertRuleDataSource,
newNotificationTargetDataSource, newNotificationTargetDataSource,
} }
@@ -66,6 +66,44 @@ func TestDashboardResourceMetadataSetsTypeName(t *testing.T) {
} }
} }
func TestDashboardPanelResourceSchemaValid(t *testing.T) {
ctx := context.Background()
req := resource.SchemaRequest{}
resp := &resource.SchemaResponse{}
newDashboardPanelResource().Schema(ctx, req, resp)
if resp.Diagnostics.HasError() {
t.Fatalf("sentry_dashboard_panel schema has errors: %v", resp.Diagnostics)
}
for _, attr := range []string{
"id", "dashboard_id", "title", "query", "query_language", "viz_type", "viz_config",
"position_x", "position_y", "width", "height",
"earliest_override", "latest_override", "sort_order", "created_at", "updated_at",
} {
if _, ok := resp.Schema.Attributes[attr]; !ok {
t.Errorf("sentry_dashboard_panel schema missing expected attribute %q", attr)
}
}
if !resp.Schema.Attributes["query"].IsRequired() {
t.Error(`"query" must be Required`)
}
if !resp.Schema.Attributes["dashboard_id"].IsRequired() {
t.Error(`"dashboard_id" must be Required`)
}
if !resp.Schema.Attributes["id"].IsComputed() {
t.Error(`"id" must be Computed`)
}
}
func TestDashboardPanelResourceMetadataSetsTypeName(t *testing.T) {
resp := &resource.MetadataResponse{}
newDashboardPanelResource().Metadata(context.Background(), resource.MetadataRequest{ProviderTypeName: "sentry"}, resp)
if resp.TypeName != "sentry_dashboard_panel" {
t.Fatalf("TypeName = %q, want sentry_dashboard_panel", resp.TypeName)
}
}
func TestAlertRuleResourceSchemaValid(t *testing.T) { func TestAlertRuleResourceSchemaValid(t *testing.T) {
ctx := context.Background() ctx := context.Background()
req := resource.SchemaRequest{} req := resource.SchemaRequest{}
@@ -153,6 +191,27 @@ func TestDashboardDataSourceSchemaValid(t *testing.T) {
} }
} }
func TestDashboardPanelDataSourceSchemaValid(t *testing.T) {
ctx := context.Background()
req := datasource.SchemaRequest{}
resp := &datasource.SchemaResponse{}
newDashboardPanelDataSource().Schema(ctx, req, resp)
if resp.Diagnostics.HasError() {
t.Fatalf("sentry_dashboard_panel data source schema has errors: %v", resp.Diagnostics)
}
if !resp.Schema.Attributes["dashboard_id"].IsRequired() {
t.Error(`"dashboard_id" must be Required`)
}
if !resp.Schema.Attributes["id"].IsRequired() {
t.Error(`"id" must be Required`)
}
if !resp.Schema.Attributes["query"].IsComputed() {
t.Error(`"query" must be Computed`)
}
}
func TestAlertRuleDataSourceSchemaValid(t *testing.T) { func TestAlertRuleDataSourceSchemaValid(t *testing.T) {
ctx := context.Background() ctx := context.Background()
req := datasource.SchemaRequest{} req := datasource.SchemaRequest{}
@@ -195,6 +254,7 @@ func TestDataSourcesMetadataSetTypeNames(t *testing.T) {
wantType string wantType string
}{ }{
{newDashboardDataSource, "sentry_dashboard"}, {newDashboardDataSource, "sentry_dashboard"},
{newDashboardPanelDataSource, "sentry_dashboard_panel"},
{newAlertRuleDataSource, "sentry_alert_rule"}, {newAlertRuleDataSource, "sentry_alert_rule"},
{newNotificationTargetDataSource, "sentry_notification_target"}, {newNotificationTargetDataSource, "sentry_notification_target"},
} }