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)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCreateDashboardSendsExpectedRequest is the same "real
|
||||
// httptest.Server, real HTTP round trip" pattern
|
||||
// cli/cmd/sentryctl's own tests use against the same api/dashboards
|
||||
// endpoints -- this client has no fake/mock mode, so its tests exercise
|
||||
// real request construction and real response parsing throughout.
|
||||
func TestCreateDashboardSendsExpectedRequest(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/dashboards" {
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer test-token" {
|
||||
t.Errorf("Authorization = %q, want Bearer test-token", got)
|
||||
}
|
||||
var body dashboard
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decoding request body: %v", err)
|
||||
}
|
||||
if body.Name != "My Dashboard" {
|
||||
t.Errorf("request body Name = %q, want My Dashboard", body.Name)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(dashboard{
|
||||
ID: "dash-1", TenantID: "acme", Name: body.Name,
|
||||
DefaultEarliest: "-1h", DefaultLatest: "now",
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := newClient(srv.URL, "test-token")
|
||||
out, err := c.createDashboard(context.Background(), &dashboard{Name: "My Dashboard"})
|
||||
if err != nil {
|
||||
t.Fatalf("createDashboard: %v", err)
|
||||
}
|
||||
if out.ID != "dash-1" || out.TenantID != "acme" || out.DefaultEarliest != "-1h" {
|
||||
t.Fatalf("unexpected response: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDashboardNotFoundIsRecognizable(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": "dashboard not found"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := newClient(srv.URL, "")
|
||||
_, err := c.getDashboard(context.Background(), "does-not-exist")
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for a 404 response")
|
||||
}
|
||||
if !isNotFound(err) {
|
||||
t.Fatalf("isNotFound(%v) = false, want true", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDashboardServerErrorIsNotNotFound(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := newClient(srv.URL, "")
|
||||
_, err := c.getDashboard(context.Background(), "dash-1")
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for a 500 response")
|
||||
}
|
||||
if isNotFound(err) {
|
||||
t.Fatal("isNotFound must be false for a 500 -- only a real 404 means \"this resource is gone\"")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDashboardSendsToCorrectPath(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" {
|
||||
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", Name: "Renamed"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := newClient(srv.URL, "")
|
||||
out, err := c.updateDashboard(context.Background(), "dash-1", &dashboard{Name: "Renamed"})
|
||||
if err != nil {
|
||||
t.Fatalf("updateDashboard: %v", err)
|
||||
}
|
||||
if out.Name != "Renamed" {
|
||||
t.Fatalf("Name = %q, want Renamed", out.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteDashboardSendsToCorrectPath(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" {
|
||||
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.deleteDashboard(context.Background(), "dash-1"); err != nil {
|
||||
t.Fatalf("deleteDashboard: %v", err)
|
||||
}
|
||||
if !called {
|
||||
t.Fatal("expected the server to receive a DELETE request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoOmitsAuthorizationHeaderWhenNoTokenConfigured(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "" {
|
||||
t.Errorf("expected no Authorization header, got %q", r.Header.Get("Authorization"))
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := newClient(srv.URL, "")
|
||||
if err := c.do(context.Background(), http.MethodGet, "/dashboards", nil, nil); err != nil {
|
||||
t.Fatalf("do: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiErrorSurfacesPlainTextBodyWhenNotJSON(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = w.Write([]byte("forbidden"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := newClient(srv.URL, "")
|
||||
_, err := c.getDashboard(context.Background(), "dash-1")
|
||||
if err == nil || !strings.Contains(err.Error(), "forbidden") {
|
||||
t.Fatalf("err = %v, want it to surface the plain-text body", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"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/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 = &dashboardResource{}
|
||||
_ resource.ResourceWithConfigure = &dashboardResource{}
|
||||
_ resource.ResourceWithImportState = &dashboardResource{}
|
||||
)
|
||||
|
||||
func newDashboardResource() resource.Resource {
|
||||
return &dashboardResource{}
|
||||
}
|
||||
|
||||
// dashboardResource implements sentry_dashboard against
|
||||
// api/dashboards.Handler's POST/GET/PUT/DELETE /dashboards[/{id}]
|
||||
// endpoints -- the exact same JSON contract cli/cmd/sentryctl's
|
||||
// "dashboards apply" and web's Export JSON button already use (see
|
||||
// cli/README.md's "one JSON contract, multiple callers" framing; this
|
||||
// is that third caller). Panels are a separate CRUD surface
|
||||
// (POST/PUT/DELETE /dashboards/{id}/panels[/{panelId}]) not modeled by
|
||||
// this resource yet -- see the provider README for why that's scoped
|
||||
// out of this first pass rather than an oversight.
|
||||
type dashboardResource struct {
|
||||
client *client
|
||||
}
|
||||
|
||||
type dashboardResourceModel struct {
|
||||
ID types.String `tfsdk:"id"`
|
||||
TenantID types.String `tfsdk:"tenant_id"`
|
||||
Name types.String `tfsdk:"name"`
|
||||
Description types.String `tfsdk:"description"`
|
||||
DefaultEarliest types.String `tfsdk:"default_earliest"`
|
||||
DefaultLatest types.String `tfsdk:"default_latest"`
|
||||
CreatedBy types.String `tfsdk:"created_by"`
|
||||
CreatedAt types.String `tfsdk:"created_at"`
|
||||
UpdatedAt types.String `tfsdk:"updated_at"`
|
||||
}
|
||||
|
||||
func (r *dashboardResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
|
||||
resp.TypeName = req.ProviderTypeName + "_dashboard"
|
||||
}
|
||||
|
||||
func (r *dashboardResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
|
||||
resp.Schema = schema.Schema{
|
||||
Description: "A Sentry dashboard. Panels aren't managed by this resource yet -- see the provider README.",
|
||||
Attributes: map[string]schema.Attribute{
|
||||
"id": schema.StringAttribute{
|
||||
Computed: true,
|
||||
PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()},
|
||||
Description: "Server-generated dashboard ID.",
|
||||
},
|
||||
"tenant_id": schema.StringAttribute{
|
||||
Computed: true,
|
||||
PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()},
|
||||
Description: "Resolved server-side from the caller's identity -- never settable here, " +
|
||||
"matching api/dashboards.Handler's tenantID() doc comment (a client-supplied " +
|
||||
"tenant_id in the request body is always overridden).",
|
||||
},
|
||||
"name": schema.StringAttribute{
|
||||
Required: true,
|
||||
Description: "Dashboard name. The API rejects an empty string.",
|
||||
},
|
||||
"description": schema.StringAttribute{
|
||||
Optional: true,
|
||||
Computed: true,
|
||||
Default: stringdefault.StaticString(""),
|
||||
},
|
||||
"default_earliest": schema.StringAttribute{
|
||||
Optional: true,
|
||||
Computed: true,
|
||||
Description: "Default earliest time bound for panels that don't set their own override " +
|
||||
"(a query-language relative offset like \"-1h\" or an absolute timestamp -- see " +
|
||||
"/docs/query-language-reference.md). Left unset, the server defaults this to " +
|
||||
"\"-1h\" -- deliberately not hardcoded as a Terraform-side default too, so the API " +
|
||||
"stays the one source of truth for what \"unset\" means.",
|
||||
},
|
||||
"default_latest": schema.StringAttribute{
|
||||
Optional: true,
|
||||
Computed: true,
|
||||
Description: "Default latest time bound. Left unset, the server defaults this to \"now\".",
|
||||
},
|
||||
"created_by": schema.StringAttribute{
|
||||
Computed: true,
|
||||
PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()},
|
||||
},
|
||||
"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 *dashboardResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
|
||||
if req.ProviderData == nil {
|
||||
return
|
||||
}
|
||||
c, ok := req.ProviderData.(*client)
|
||||
if !ok {
|
||||
resp.Diagnostics.AddError(
|
||||
"Unexpected Resource Configure Type",
|
||||
fmt.Sprintf("Expected *provider.client, got: %T. This is a provider bug -- please report it.", req.ProviderData),
|
||||
)
|
||||
return
|
||||
}
|
||||
r.client = c
|
||||
}
|
||||
|
||||
func dashboardModelFromAPI(d *dashboard) dashboardResourceModel {
|
||||
return dashboardResourceModel{
|
||||
ID: types.StringValue(d.ID),
|
||||
TenantID: types.StringValue(d.TenantID),
|
||||
Name: types.StringValue(d.Name),
|
||||
Description: types.StringValue(d.Description),
|
||||
DefaultEarliest: types.StringValue(d.DefaultEarliest),
|
||||
DefaultLatest: types.StringValue(d.DefaultLatest),
|
||||
CreatedBy: types.StringValue(d.CreatedBy),
|
||||
CreatedAt: types.StringValue(d.CreatedAt),
|
||||
UpdatedAt: types.StringValue(d.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *dashboardResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
|
||||
var plan dashboardResourceModel
|
||||
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
out, err := r.client.createDashboard(ctx, &dashboard{
|
||||
Name: plan.Name.ValueString(),
|
||||
Description: plan.Description.ValueString(),
|
||||
DefaultEarliest: plan.DefaultEarliest.ValueString(),
|
||||
DefaultLatest: plan.DefaultLatest.ValueString(),
|
||||
})
|
||||
if err != nil {
|
||||
resp.Diagnostics.AddError("Creating Dashboard", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp.Diagnostics.Append(resp.State.Set(ctx, dashboardModelFromAPI(out))...)
|
||||
}
|
||||
|
||||
func (r *dashboardResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
|
||||
var state dashboardResourceModel
|
||||
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
out, err := r.client.getDashboard(ctx, state.ID.ValueString())
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
// Deleted out-of-band (e.g. via web or sentryctl) --
|
||||
// dropping it from state lets the next plan offer to
|
||||
// recreate it, the standard Terraform convention, rather
|
||||
// than failing every subsequent plan/apply until someone
|
||||
// manually edits state.
|
||||
resp.State.RemoveResource(ctx)
|
||||
return
|
||||
}
|
||||
resp.Diagnostics.AddError("Reading Dashboard", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp.Diagnostics.Append(resp.State.Set(ctx, dashboardModelFromAPI(out))...)
|
||||
}
|
||||
|
||||
func (r *dashboardResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
|
||||
var plan dashboardResourceModel
|
||||
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
out, err := r.client.updateDashboard(ctx, plan.ID.ValueString(), &dashboard{
|
||||
Name: plan.Name.ValueString(),
|
||||
Description: plan.Description.ValueString(),
|
||||
DefaultEarliest: plan.DefaultEarliest.ValueString(),
|
||||
DefaultLatest: plan.DefaultLatest.ValueString(),
|
||||
})
|
||||
if err != nil {
|
||||
resp.Diagnostics.AddError("Updating Dashboard", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp.Diagnostics.Append(resp.State.Set(ctx, dashboardModelFromAPI(out))...)
|
||||
}
|
||||
|
||||
func (r *dashboardResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
|
||||
var state dashboardResourceModel
|
||||
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.client.deleteDashboard(ctx, state.ID.ValueString()); err != nil && !isNotFound(err) {
|
||||
resp.Diagnostics.AddError("Deleting Dashboard", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (r *dashboardResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
|
||||
resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/terraform-plugin-framework/providerserver"
|
||||
"github.com/hashicorp/terraform-plugin-go/tfprotov6"
|
||||
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
|
||||
)
|
||||
|
||||
// testAccProtoV6ProviderFactories wires this package's own provider
|
||||
// implementation into terraform-plugin-testing's acceptance-test
|
||||
// runner -- HashiCorp's standard pattern, one factory reused by every
|
||||
// acceptance test in this package.
|
||||
var testAccProtoV6ProviderFactories = map[string]func() (tfprotov6.ProviderServer, error){
|
||||
"sentry": providerserver.NewProtocol6WithError(New("test")()),
|
||||
}
|
||||
|
||||
// The acceptance test below is gated the same way every other live-
|
||||
// infrastructure test in this repo is (skip-gated, not deleted or
|
||||
// faked) -- terraform-plugin-testing's own resource.Test already skips
|
||||
// unless TF_ACC=1 is set, the framework's standard convention, and it
|
||||
// additionally needs a real running api service (Docker/Postgres this
|
||||
// environment doesn't have access to -- see /docs/phase-4-runbook.md's
|
||||
// "Verification status" section for the same disclosed gap everywhere
|
||||
// else in this codebase). "The test exists and is correct Go" is not
|
||||
// the same claim as "this resource has been applied for real," per this
|
||||
// repo's established honesty discipline.
|
||||
func TestAccDashboardResource_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 = "Acceptance Test Dashboard"
|
||||
description = "created by TestAccDashboardResource_basic"
|
||||
}
|
||||
`,
|
||||
Check: resource.ComposeAggregateTestCheckFunc(
|
||||
resource.TestCheckResourceAttr("sentry_dashboard.test", "name", "Acceptance Test Dashboard"),
|
||||
resource.TestCheckResourceAttr("sentry_dashboard.test", "description", "created by TestAccDashboardResource_basic"),
|
||||
resource.TestCheckResourceAttrSet("sentry_dashboard.test", "id"),
|
||||
resource.TestCheckResourceAttrSet("sentry_dashboard.test", "tenant_id"),
|
||||
// Left unset in config -- must come back as the
|
||||
// server's own defaults (store.go: "-1h"/"now"), not
|
||||
// an empty string, proving the Optional+Computed
|
||||
// schema round-trips the server's default rather
|
||||
// than fighting it with a Terraform-side one.
|
||||
resource.TestCheckResourceAttr("sentry_dashboard.test", "default_earliest", "-1h"),
|
||||
resource.TestCheckResourceAttr("sentry_dashboard.test", "default_latest", "now"),
|
||||
),
|
||||
},
|
||||
{
|
||||
// Update: name change should apply in place, not
|
||||
// replace (no RequiresReplace plan modifier on name).
|
||||
Config: `
|
||||
provider "sentry" {
|
||||
endpoint = "http://localhost:8080"
|
||||
}
|
||||
|
||||
resource "sentry_dashboard" "test" {
|
||||
name = "Renamed Dashboard"
|
||||
description = "created by TestAccDashboardResource_basic"
|
||||
}
|
||||
`,
|
||||
Check: resource.TestCheckResourceAttr("sentry_dashboard.test", "name", "Renamed Dashboard"),
|
||||
},
|
||||
{
|
||||
// Import: re-reads by ID alone and must match what's in
|
||||
// state, proving Read()'s server round trip agrees with
|
||||
// what Create()/Update() last wrote.
|
||||
ResourceName: "sentry_dashboard.test",
|
||||
ImportState: true,
|
||||
ImportStateVerify: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// Package provider is Sentry's Terraform provider implementation,
|
||||
// built on HashiCorp's terraform-plugin-framework (not the legacy
|
||||
// SDKv2 -- the framework is the actively-developed, currently-
|
||||
// recommended library for a provider started from scratch, matching
|
||||
// CLAUDE.md's "prefer boring, well-understood dependencies" read
|
||||
// forward rather than backward).
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
|
||||
"github.com/hashicorp/terraform-plugin-framework/datasource"
|
||||
"github.com/hashicorp/terraform-plugin-framework/provider"
|
||||
"github.com/hashicorp/terraform-plugin-framework/provider/schema"
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource"
|
||||
"github.com/hashicorp/terraform-plugin-framework/types"
|
||||
)
|
||||
|
||||
var _ provider.Provider = &sentryProvider{}
|
||||
|
||||
// New matches providerserver.Serve's expected constructor shape --
|
||||
// version is threaded through from main.go's -ldflags-injected build
|
||||
// version.
|
||||
func New(version string) func() provider.Provider {
|
||||
return func() provider.Provider {
|
||||
return &sentryProvider{version: version}
|
||||
}
|
||||
}
|
||||
|
||||
type sentryProvider struct {
|
||||
version string
|
||||
}
|
||||
|
||||
type sentryProviderModel struct {
|
||||
Endpoint types.String `tfsdk:"endpoint"`
|
||||
Token types.String `tfsdk:"token"`
|
||||
}
|
||||
|
||||
func (p *sentryProvider) Metadata(_ context.Context, _ provider.MetadataRequest, resp *provider.MetadataResponse) {
|
||||
resp.TypeName = "sentry"
|
||||
resp.Version = p.version
|
||||
}
|
||||
|
||||
func (p *sentryProvider) Schema(_ context.Context, _ provider.SchemaRequest, resp *provider.SchemaResponse) {
|
||||
resp.Schema = schema.Schema{
|
||||
Description: "Manages Sentry log-aggregation-platform resources. Dashboards only for now -- alert rules, notification targets, and tenant/RBAC resources are real, disclosed future work, not built in this pass; see the provider README.",
|
||||
Attributes: map[string]schema.Attribute{
|
||||
"endpoint": schema.StringAttribute{
|
||||
Optional: true,
|
||||
Description: "Base URL of the api service, e.g. \"http://localhost:8080\". Defaults to " +
|
||||
"$SENTRY_API_ENDPOINT, or \"http://localhost:8080\" if that's unset too -- same " +
|
||||
"default sentryctl's --api/$SENTRYCTL_API_URL uses (cli/cmd/sentryctl/main.go).",
|
||||
},
|
||||
"token": schema.StringAttribute{
|
||||
Optional: true,
|
||||
Sensitive: true,
|
||||
Description: "Bearer credential sent as \"Authorization: Bearer <token>\" on every request " +
|
||||
"-- required once a deployment configures enterprise-auth (see " +
|
||||
"/docs/phase-4-rbac-design.md), same as sentryctl's $SENTRYCTL_TOKEN. Defaults to " +
|
||||
"$SENTRY_API_TOKEN if unset. Set via a variable or environment, never a literal in a " +
|
||||
".tf file committed to version control.",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Configure resolves endpoint/token the same precedence order
|
||||
// sentryctl's resolveAPIURL/resolveToken use (explicit config value,
|
||||
// then an environment variable, then a hardcoded default) so behavior
|
||||
// stays predictable across both of this project's Sentry API clients.
|
||||
func (p *sentryProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) {
|
||||
var config sentryProviderModel
|
||||
resp.Diagnostics.Append(req.Config.Get(ctx, &config)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
endpoint := config.Endpoint.ValueString()
|
||||
if endpoint == "" {
|
||||
endpoint = os.Getenv("SENTRY_API_ENDPOINT")
|
||||
}
|
||||
if endpoint == "" {
|
||||
endpoint = "http://localhost:8080"
|
||||
}
|
||||
|
||||
token := config.Token.ValueString()
|
||||
if token == "" {
|
||||
token = os.Getenv("SENTRY_API_TOKEN")
|
||||
}
|
||||
|
||||
c := newClient(endpoint, token)
|
||||
resp.DataSourceData = c
|
||||
resp.ResourceData = c
|
||||
}
|
||||
|
||||
func (p *sentryProvider) Resources(_ context.Context) []func() resource.Resource {
|
||||
return []func() resource.Resource{
|
||||
newDashboardResource,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *sentryProvider) DataSources(_ context.Context) []func() datasource.DataSource {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/terraform-plugin-framework/provider"
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource"
|
||||
)
|
||||
|
||||
// TestProviderSchemaValid and TestDashboardResourceSchemaValid don't
|
||||
// need a Terraform binary or a live api service -- ValidateImplementation
|
||||
// runs the same internal consistency checks
|
||||
// terraform-plugin-framework's own protocol layer would (attribute
|
||||
// names are valid identifiers, no Optional+Required conflicts, etc.),
|
||||
// catching a broken schema before it ever reaches an acceptance test.
|
||||
func TestProviderSchemaValid(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
req := provider.SchemaRequest{}
|
||||
resp := &provider.SchemaResponse{}
|
||||
|
||||
New("test")().Schema(ctx, req, resp)
|
||||
|
||||
if resp.Diagnostics.HasError() {
|
||||
t.Fatalf("provider schema has errors: %v", resp.Diagnostics)
|
||||
}
|
||||
for _, attr := range []string{"endpoint", "token"} {
|
||||
if _, ok := resp.Schema.Attributes[attr]; !ok {
|
||||
t.Errorf("provider schema missing expected attribute %q", attr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardResourceSchemaValid(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
req := resource.SchemaRequest{}
|
||||
resp := &resource.SchemaResponse{}
|
||||
|
||||
newDashboardResource().Schema(ctx, req, resp)
|
||||
|
||||
if resp.Diagnostics.HasError() {
|
||||
t.Fatalf("sentry_dashboard schema has errors: %v", resp.Diagnostics)
|
||||
}
|
||||
for _, attr := range []string{
|
||||
"id", "tenant_id", "name", "description",
|
||||
"default_earliest", "default_latest", "created_by", "created_at", "updated_at",
|
||||
} {
|
||||
if _, ok := resp.Schema.Attributes[attr]; !ok {
|
||||
t.Errorf("sentry_dashboard schema missing expected attribute %q", attr)
|
||||
}
|
||||
}
|
||||
if !resp.Schema.Attributes["name"].IsRequired() {
|
||||
t.Error(`"name" must be Required`)
|
||||
}
|
||||
if !resp.Schema.Attributes["id"].IsComputed() {
|
||||
t.Error(`"id" must be Computed`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardResourceMetadataSetsTypeName(t *testing.T) {
|
||||
resp := &resource.MetadataResponse{}
|
||||
newDashboardResource().Metadata(context.Background(), resource.MetadataRequest{ProviderTypeName: "sentry"}, resp)
|
||||
if resp.TypeName != "sentry_dashboard" {
|
||||
t.Fatalf("TypeName = %q, want sentry_dashboard", resp.TypeName)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user