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.
334 lines
14 KiB
Go
334 lines
14 KiB
Go
package provider
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"time"
|
|
)
|
|
|
|
// client is a thin HTTP client against api/dashboards.Handler's REST
|
|
// endpoints -- deliberately hand-rolled, not generated from an OpenAPI
|
|
// spec (none exists in this repo yet), the same "boring, well-
|
|
// understood" posture cli/cmd/sentryctl's own httpclient.go already
|
|
// takes against the same API. Kept separate from that package (not
|
|
// reused directly) since this one needs typed request/response
|
|
// marshaling for Terraform's plan/state model, where sentryctl only
|
|
// ever needs to pretty-print whatever JSON comes back.
|
|
type client struct {
|
|
baseURL string
|
|
token string
|
|
http *http.Client
|
|
}
|
|
|
|
func newClient(baseURL, token string) *client {
|
|
return &client{baseURL: baseURL, token: token, http: &http.Client{Timeout: 30 * time.Second}}
|
|
}
|
|
|
|
// apiError carries the HTTP status code through so callers can
|
|
// distinguish "the server rejected this request" from "this specific
|
|
// resource doesn't exist" (isNotFound below) -- Read/Delete need that
|
|
// distinction to implement Terraform's standard "drop from state, don't
|
|
// error the whole apply" convention for a resource deleted out-of-band.
|
|
type apiError struct {
|
|
StatusCode int
|
|
Message string
|
|
}
|
|
|
|
func (e *apiError) Error() string {
|
|
return fmt.Sprintf("sentry api: request failed with status %d: %s", e.StatusCode, e.Message)
|
|
}
|
|
|
|
func isNotFound(err error) bool {
|
|
var apiErr *apiError
|
|
return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound
|
|
}
|
|
|
|
// dashboard mirrors api/dashboards.Dashboard's JSON shape -- deliberately
|
|
// 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 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"`
|
|
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 {
|
|
var reqBody io.Reader
|
|
if body != nil {
|
|
b, err := json.Marshal(body)
|
|
if err != nil {
|
|
return fmt.Errorf("encoding request body: %w", err)
|
|
}
|
|
reqBody = bytes.NewReader(b)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reqBody)
|
|
if err != nil {
|
|
return fmt.Errorf("building request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if c.token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
|
}
|
|
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("sending request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return fmt.Errorf("reading response: %w", err)
|
|
}
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
msg := string(respBody)
|
|
var errResp struct {
|
|
Error string `json:"error"`
|
|
}
|
|
if json.Unmarshal(respBody, &errResp) == nil && errResp.Error != "" {
|
|
msg = errResp.Error
|
|
}
|
|
return &apiError{StatusCode: resp.StatusCode, Message: msg}
|
|
}
|
|
if out == nil || len(respBody) == 0 {
|
|
return nil
|
|
}
|
|
if err := json.Unmarshal(respBody, out); err != nil {
|
|
return fmt.Errorf("decoding response: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *client) createDashboard(ctx context.Context, d *dashboard) (*dashboard, error) {
|
|
var out dashboard
|
|
if err := c.do(ctx, http.MethodPost, "/dashboards", d, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
func (c *client) getDashboard(ctx context.Context, id string) (*dashboard, error) {
|
|
var out dashboard
|
|
if err := c.do(ctx, http.MethodGet, "/dashboards/"+id, nil, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
func (c *client) updateDashboard(ctx context.Context, id string, d *dashboard) (*dashboard, error) {
|
|
var out dashboard
|
|
if err := c.do(ctx, http.MethodPut, "/dashboards/"+id, d, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
func (c *client) deleteDashboard(ctx context.Context, id string) error {
|
|
return c.do(ctx, http.MethodDelete, "/dashboards/"+id, nil, nil)
|
|
}
|
|
|
|
// rule mirrors alerting/internal/rulestore.Rule's JSON shape, plus the
|
|
// request-only `enabled` field POST /rules accepts
|
|
// (httpapi.createRuleRequest embeds rulestore.Rule and adds this
|
|
// pointer specifically so "omitted" (defaults to enabled) and
|
|
// "explicitly false" are distinguishable -- see handleCreateRule's doc
|
|
// comment) -- deliberately a local type, not an import of either
|
|
// package, same "talk HTTP, not Go imports, to a service that isn't
|
|
// yours" posture as dashboard above. GET/POST /rules both return this
|
|
// shape flattened (no separate "state" wrapper needed here since this
|
|
// resource doesn't manage or expose alert_state -- see the provider
|
|
// README on why).
|
|
type rule struct {
|
|
ID string `json:"id,omitempty"`
|
|
TenantID string `json:"tenant_id,omitempty"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Query string `json:"query"`
|
|
QueryLanguage string `json:"query_language"`
|
|
ConditionType string `json:"condition_type"`
|
|
Comparator *string `json:"comparator,omitempty"`
|
|
ThresholdValue *float64 `json:"threshold_value,omitempty"`
|
|
EvalIntervalSeconds int `json:"eval_interval_seconds"`
|
|
ForMinutes int `json:"for_minutes"`
|
|
RenotifyIntervalMinutes *int `json:"renotify_interval_minutes,omitempty"`
|
|
NotificationTargetID string `json:"notification_target_id"`
|
|
Enabled *bool `json:"enabled,omitempty"`
|
|
CreatedBy string `json:"created_by,omitempty"`
|
|
}
|
|
|
|
// createRule and getRule are the only mutating/reading calls this
|
|
// client makes against /rules -- there is deliberately no updateRule:
|
|
// alerting/internal/httpapi has no PUT /rules/{id} at all (confirmed
|
|
// down to rulestore.Store, which has Create/List/Get/Delete but no
|
|
// Update method to even wire one to) -- a real, pre-existing gap in
|
|
// alerting's own API, not something this provider works around by
|
|
// faking an update via delete+recreate under the hood. alertRuleResource
|
|
// models this honestly: every attribute is RequiresReplace, so
|
|
// Terraform destroys and recreates on any change rather than pretending
|
|
// an in-place update exists.
|
|
func (c *client) createRule(ctx context.Context, r *rule) (*rule, error) {
|
|
var out rule
|
|
if err := c.do(ctx, http.MethodPost, "/rules", r, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
func (c *client) getRule(ctx context.Context, id string) (*rule, error) {
|
|
var out rule
|
|
if err := c.do(ctx, http.MethodGet, "/rules/"+id, nil, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
func (c *client) deleteRule(ctx context.Context, id string) error {
|
|
return c.do(ctx, http.MethodDelete, "/rules/"+id, nil, nil)
|
|
}
|
|
|
|
// notificationTarget mirrors alerting/internal/notifystore.Target's
|
|
// JSON shape -- deliberately a local type, same "talk HTTP, not Go
|
|
// imports" posture as dashboard/rule above. Headers is left as raw
|
|
// JSON bytes (not decoded into a Go map) since this client has no
|
|
// opinion about its shape -- alerting's own Target type doesn't either
|
|
// (json.RawMessage), and the resource layer round-trips it as a plain
|
|
// JSON-text string a caller provides via Terraform's jsonencode().
|
|
//
|
|
// Secret genuinely comes back from GET/List unredacted -- confirmed in
|
|
// notifystore/store.go's Get/List queries, which select the secret
|
|
// column with no redaction at either the store or handler layer. This
|
|
// is alerting's own existing behavior, not something this provider
|
|
// introduces or could fix from the client side; notificationTargetResource
|
|
// marks the corresponding attribute Sensitive so Terraform at least
|
|
// doesn't print it in plan/apply console output (it is still stored in
|
|
// Terraform state in plaintext -- a standard, disclosed Terraform
|
|
// limitation for any sensitive attribute, not specific to this one).
|
|
type notificationTarget struct {
|
|
ID string `json:"id,omitempty"`
|
|
TenantID string `json:"tenant_id,omitempty"`
|
|
Name string `json:"name"`
|
|
Kind string `json:"kind"`
|
|
WebhookURL string `json:"webhook_url"`
|
|
PayloadTemplate *string `json:"payload_template,omitempty"`
|
|
Headers json.RawMessage `json:"headers,omitempty"`
|
|
Secret *string `json:"secret,omitempty"`
|
|
CreatedBy string `json:"created_by,omitempty"`
|
|
}
|
|
|
|
// createNotificationTarget and getNotificationTarget are the only
|
|
// mutating/reading calls this client makes against /targets -- same
|
|
// "no updateX method, alerting has no PUT /targets/{id} either" shape
|
|
// as rules above (rulestore.Store/notifystore.Store both only have
|
|
// Create/List/Get/Delete). notificationTargetResource is create/destroy
|
|
// only for the same reason alertRuleResource is.
|
|
func (c *client) createNotificationTarget(ctx context.Context, t *notificationTarget) (*notificationTarget, error) {
|
|
var out notificationTarget
|
|
if err := c.do(ctx, http.MethodPost, "/targets", t, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
func (c *client) getNotificationTarget(ctx context.Context, id string) (*notificationTarget, error) {
|
|
var out notificationTarget
|
|
if err := c.do(ctx, http.MethodGet, "/targets/"+id, nil, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
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)}
|
|
}
|