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:
@@ -20,14 +20,17 @@ described there without flagging it to me first.
|
||||
UI-only logic. CLI (`sentryctl`) and Terraform provider are first-class,
|
||||
not afterthoughts. **Status**: `sentryctl` has been built out phase by
|
||||
phase since Phase 3. The Terraform provider (`/terraform`) only exists
|
||||
as of this note -- three resources (`sentry_dashboard`, full CRUD;
|
||||
`sentry_alert_rule` and `sentry_notification_target`, both create/
|
||||
destroy only -- `alerting` has no `PUT /rules/{id}` or
|
||||
`PUT /targets/{id}` to update against), each paired with a read-only
|
||||
data source, built on HashiCorp's `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
|
||||
as of this note -- four resources (`sentry_dashboard` and
|
||||
`sentry_dashboard_panel`, both full CRUD, panels as their own resource
|
||||
rather than a nested block since the API manages them independently
|
||||
of their parent dashboard; `sentry_alert_rule` and
|
||||
`sentry_notification_target`, both create/destroy only -- `alerting`
|
||||
has no `PUT /rules/{id}` or `PUT /targets/{id}` to update against),
|
||||
each paired with a read-only data source, built on HashiCorp's
|
||||
`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
|
||||
built, and the same
|
||||
"written but not run against a live stack" verification caveat as
|
||||
|
||||
+91
-58
@@ -3,8 +3,8 @@
|
||||
Sentry's Terraform provider -- `CLAUDE.md`'s "Repo conventions" section
|
||||
names this a first-class deliverable alongside `sentryctl`
|
||||
("CLI and Terraform provider are first-class, not afterthoughts"), but
|
||||
no phase before this one had actually built any of it. Three resources
|
||||
so far (`sentry_dashboard`, `sentry_alert_rule`,
|
||||
no phase before this one had actually built any of it. Four resources
|
||||
so far (`sentry_dashboard`, `sentry_dashboard_panel`, `sentry_alert_rule`,
|
||||
`sentry_notification_target`), each paired with a read-only data source
|
||||
of the same name, not a finished provider, built on
|
||||
[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
|
||||
|
||||
## Why these three resources first
|
||||
## Why these four resources first
|
||||
|
||||
`cli/README.md` already frames the dashboards REST contract this way:
|
||||
`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
|
||||
`sentryctl dashboards apply` and the web UI's Export JSON button already
|
||||
use against `api/dashboards.Handler`, not a new contract invented for
|
||||
Terraform's sake. `sentry_alert_rule` follows against `alerting`'s own
|
||||
`POST`/`GET`/`DELETE /rules[/{id}]` -- the natural second resource, and
|
||||
a real second service (`alerting` is a genuinely separate deployment
|
||||
from `api`, its own base URL), so building it second exercised that this
|
||||
provider can talk to more than one Sentry service, not just repeat the
|
||||
dashboards pattern against the same endpoint. `sentry_notification_target`
|
||||
rounds these two 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),
|
||||
Terraform's sake. `sentry_dashboard_panel` follows the same contract's
|
||||
panel endpoints, as its own resource rather than a block nested inside
|
||||
`sentry_dashboard` -- see "Panels are their own resource" below.
|
||||
`sentry_alert_rule` follows against `alerting`'s own `POST`/`GET`/
|
||||
`DELETE /rules[/{id}]` -- the natural next resource, and a real second
|
||||
service (`alerting` is a genuinely separate deployment from `api`, its
|
||||
own base URL), so building it exercised that this provider can talk to
|
||||
more than one Sentry service, not just repeat the dashboards pattern
|
||||
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.
|
||||
|
||||
## What's built
|
||||
@@ -63,15 +66,37 @@ resource "sentry_dashboard" "example" {
|
||||
|
||||
Supports `terraform import sentry_dashboard.example <dashboard-id>`.
|
||||
|
||||
**Panels are not managed by this resource.** `api/dashboards.Handler`
|
||||
exposes panel CRUD as its own endpoints
|
||||
(`POST`/`PUT`/`DELETE /dashboards/{id}/panels[/{panelId}]`), a
|
||||
genuinely separate resource shape (a panel belongs to exactly one
|
||||
dashboard, has its own lifecycle, and the query-language/viz-config
|
||||
fields deserve their own attribute validation) -- scoped out of this
|
||||
first pass deliberately, not an oversight. A `sentry_dashboard_panel`
|
||||
resource (or a panels list block on this one -- an open design question,
|
||||
not yet decided) is real, disclosed future work.
|
||||
**Panels are their own resource, not a nested block.** `api/dashboards.
|
||||
Handler` exposes panel CRUD as its own endpoints (`POST`/`PUT`/
|
||||
`DELETE /dashboards/{id}/panels[/{panelId}]`) -- a panel belongs to
|
||||
exactly one dashboard, has its own lifecycle, and is created/updated/
|
||||
deleted independently, never by rewriting a dashboard's whole panel
|
||||
list, so `sentry_dashboard_panel` follows that shape rather than a
|
||||
nested list block (which would force every panel to be rewritten on any
|
||||
single panel's change, hiding fine-grained diffs a separate resource
|
||||
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
|
||||
provider "sentry" {
|
||||
@@ -134,15 +159,17 @@ than left implicit.
|
||||
## Data sources
|
||||
|
||||
Each resource above has a matching read-only data source (`data
|
||||
"sentry_dashboard"`, `data "sentry_alert_rule"`, `data
|
||||
"sentry_notification_target"`), a single `id` attribute in, every other
|
||||
attribute out -- a lookup against the same `GET`/`{id}` endpoint the
|
||||
matching resource's own `Read` already uses, nothing new added to
|
||||
`client.go` beyond that. Mechanical and low-risk by design: no new
|
||||
architectural question, no new external service, no new write path --
|
||||
just reusing the resource's own model/conversion functions
|
||||
(`dashboardModelFromAPI` etc.) against a `Required` `id` input instead
|
||||
of a full config.
|
||||
"sentry_dashboard"`, `data "sentry_dashboard_panel"`, `data
|
||||
"sentry_alert_rule"`, `data "sentry_notification_target"`) -- a lookup
|
||||
against the same endpoint the matching resource's own `Read` already
|
||||
uses, nothing new added to `client.go` beyond that. Mechanical and
|
||||
low-risk by design: no new architectural question, no new external
|
||||
service, no new write path -- just reusing the resource's own model/
|
||||
conversion functions (`dashboardModelFromAPI` etc.) against `Required`
|
||||
input instead of a full config. Three of the four take a single
|
||||
`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
|
||||
data "sentry_notification_target" "ops" {
|
||||
@@ -167,7 +194,6 @@ attribute" above.
|
||||
idempotently -- see `/enterprise/README.md`'s "Bootstrapping a tenant"
|
||||
section) and Phase 4 commercial licensing, so this would need its own
|
||||
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`
|
||||
(`registry.terraform.io/sentry/sentry`) is the address a real
|
||||
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
|
||||
`GET /rules/{id}` response's promoted `RuleWithState` fields plus an
|
||||
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
|
||||
a comment, so a future change to that behavior would be caught here too.
|
||||
`internal/provider/provider_test.go` validates the provider's, all
|
||||
three resources', and all three data sources' schemas are internally
|
||||
a comment, so a future change to that behavior would be caught here too
|
||||
-- and (for panels) proving `getPanel` finds the right panel within a
|
||||
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`
|
||||
split -- for data sources, specifically that `id` is `Required` and
|
||||
everything else is `Computed`) without needing a Terraform binary or a
|
||||
live `api`/`alerting` service at all.
|
||||
split -- for data sources, that every attribute except the lookup key(s)
|
||||
is `Computed`) without needing a Terraform binary or a live
|
||||
`api`/`alerting` service at all.
|
||||
|
||||
Each resource and data source pair has a matching `TestAcc*_basic` in
|
||||
its own `_test.go` file (`dashboard_resource_test.go`/
|
||||
`dashboard_data_source_test.go`, and likewise for the other two) -- six
|
||||
real acceptance tests total, using [`terraform-plugin-testing`][testing]
|
||||
-- skipped unless `TF_ACC=1` is set, that framework's own standard
|
||||
convention, the same shape every other live-infrastructure test in this
|
||||
repo uses (`docker`-gated env vars for Postgres/ClickHouse tests
|
||||
elsewhere). The rule and target *resource* tests both use a
|
||||
`plancheck.ExpectResourceAction` assertion proving a config change
|
||||
actually plans a destroy-then-create, not an in-place update -- the
|
||||
concrete, checked version of the "create/destroy only" design decision
|
||||
documented above, not just a claim in a comment; the *data source*
|
||||
tests each create a resource then look it up via `resource.
|
||||
TestCheckResourceAttrPair`, proving the data source's `Read` actually
|
||||
agrees with what the resource wrote, not just that both compile. Even
|
||||
with `TF_ACC=1` all six tests also need real running `api`/`alerting`
|
||||
services (Postgres + ClickHouse) to apply against, which this
|
||||
environment has no Docker access to bring up -- **not run 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
|
||||
`dashboard_data_source_test.go`, and likewise for the other three) --
|
||||
eight real acceptance tests total, using
|
||||
[`terraform-plugin-testing`][testing] -- skipped unless `TF_ACC=1` is
|
||||
set, that framework's own standard convention, the same shape every
|
||||
other live-infrastructure test in this repo uses (`docker`-gated env
|
||||
vars for Postgres/ClickHouse tests elsewhere). The rule and target
|
||||
*resource* tests both use a `plancheck.ExpectResourceAction` assertion
|
||||
proving a config change actually plans a destroy-then-create, not an
|
||||
in-place update -- the concrete, checked version of the "create/destroy
|
||||
only" design decision documented above, not just a claim in a comment;
|
||||
the panel resource test instead proves a genuine in-place update
|
||||
(a `title` change with no `plancheck` needed, since the default
|
||||
expectation -- update, not replace -- is exactly what should happen);
|
||||
the *data source* tests each create a resource then look it up via
|
||||
`resource.TestCheckResourceAttrPair`, proving the data source's `Read`
|
||||
actually agrees with what the resource wrote, not just that both
|
||||
compile. Even with `TF_ACC=1` all eight tests also need real running
|
||||
`api`/`alerting` services (Postgres + ClickHouse) to apply against,
|
||||
which this environment has no Docker access to bring up -- **not run
|
||||
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."
|
||||
|
||||
[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).
|
||||
@@ -52,9 +52,12 @@ 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"`
|
||||
@@ -65,6 +68,34 @@ type dashboard struct {
|
||||
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)}
|
||||
}
|
||||
|
||||
@@ -316,3 +316,130 @@ func TestDeleteNotificationTargetSendsToCorrectPath(t *testing.T) {
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -129,6 +129,7 @@ func (p *sentryProvider) Configure(ctx context.Context, req provider.ConfigureRe
|
||||
func (p *sentryProvider) Resources(_ context.Context) []func() resource.Resource {
|
||||
return []func() resource.Resource{
|
||||
newDashboardResource,
|
||||
newDashboardPanelResource,
|
||||
newAlertRuleResource,
|
||||
newNotificationTargetResource,
|
||||
}
|
||||
@@ -137,6 +138,7 @@ func (p *sentryProvider) Resources(_ context.Context) []func() resource.Resource
|
||||
func (p *sentryProvider) DataSources(_ context.Context) []func() datasource.DataSource {
|
||||
return []func() datasource.DataSource{
|
||||
newDashboardDataSource,
|
||||
newDashboardPanelDataSource,
|
||||
newAlertRuleDataSource,
|
||||
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) {
|
||||
ctx := context.Background()
|
||||
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) {
|
||||
ctx := context.Background()
|
||||
req := datasource.SchemaRequest{}
|
||||
@@ -195,6 +254,7 @@ func TestDataSourcesMetadataSetTypeNames(t *testing.T) {
|
||||
wantType string
|
||||
}{
|
||||
{newDashboardDataSource, "sentry_dashboard"},
|
||||
{newDashboardPanelDataSource, "sentry_dashboard_panel"},
|
||||
{newAlertRuleDataSource, "sentry_alert_rule"},
|
||||
{newNotificationTargetDataSource, "sentry_notification_target"},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user