Start the Terraform provider: sentry_dashboard, the first resource
CLAUDE.md names the Terraform provider 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 -- no terraform/ directory existed. This is a first slice, not a
finished provider: one resource, scoped and confirmed with the project
owner before starting (a new pinned external dependency and an
architectural decision not covered in /docs/architecture.md are both
things CLAUDE.md's own "When in doubt" section says to ask about).
New Go module (terraform/, github.com/sentry/sentry/terraform) built on
HashiCorp's terraform-plugin-framework -- the actively-developed
library, not the legacy SDKv2, since there's no existing provider code
to migrate and no reason to start new on the framework HashiCorp itself
steers people away from.
internal/provider/client.go talks the exact same JSON contract
sentryctl's "dashboards apply" and web's Export JSON button already use
against api/dashboards.Handler (POST/GET/PUT/DELETE /dashboards[/{id}]) --
cli/README.md already named this "the seed of a future Terraform
provider: one JSON contract, multiple callers," this is that third
caller, not a new contract invented for Terraform's sake.
sentry_dashboard's schema deliberately leaves default_earliest/
default_latest Optional+Computed with no Terraform-side static default,
even though the API defaults them to "-1h"/"now" when empty -- letting
the API stay the one source of truth for what "unset" means rather than
duplicating that default in two places that could drift. tenant_id is
Computed-only, matching api/dashboards.Handler's own tenantID() doc
comment that a client-supplied value is always overridden server-side.
Panels are not modeled by this resource -- a genuinely separate resource
shape (own lifecycle, own endpoints, own validation needs), scoped out
deliberately, not an oversight. Alert rules, notification targets, and
tenant/RBAC resources are the same: real, disclosed future work, not
attempted in this pass. See terraform/README.md for the full accounting.
Verified: client_test.go runs real HTTP round trips against httptest.
Server (request construction, response parsing, the 404-vs-other-error
distinction Read/Delete need for Terraform's out-of-band-deletion
convention) -- same pattern cli/cmd/sentryctl's own tests already use
against the same api/dashboards endpoints. provider_test.go validates
both schemas are internally well-formed without needing a Terraform
binary. dashboard_resource_test.go's TestAccDashboardResource_basic is a
real acceptance test (terraform-plugin-testing), skip-gated by TF_ACC=1
per that framework's own convention -- even with TF_ACC set it would
still need a live api service (Postgres+ClickHouse) to apply against,
which this environment has no Docker access to bring up, so it has not
actually run here, same disclosed gap as every other live-infra test in
this repo.
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
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 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.
|
||||
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"`
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user