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