Files
cairnobs/terraform/internal/provider/client.go
T
jcoffey-dev b98f397221 Add sentry_alert_rule, the Terraform provider's second resource
Confirmed with the project owner first: alerting's REST API 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 new to this task.
Decided to model sentry_alert_rule as create/destroy only rather than
fake an in-place update via delete-then-recreate inside the resource:
every attribute carries a RequiresReplace plan modifier, so a config
change destroys and recreates the rule, surfacing in the plan output the
real side effect that has (alert_state/delivery-log continuity resets)
instead of hiding it. Adding a real PUT /rules/{id} to alerting would
remove this constraint but is a change to a different module's REST
API, out of scope here.

internal/provider/client.go's new rule type and createRule/getRule/
deleteRule methods talk the exact same JSON contract
sentryctl alerts apply already uses against alerting/internal/httpapi.
GET /rules/{id} actually returns rulestore.RuleWithState (Rule's fields
promoted via anonymous embedding, plus a "state" object) -- the local
rule type has no field for "state" by design, and a new client test
proves that extra key doesn't break parsing.

alerting is a genuinely separate service from api (its own base URL),
so this needed the provider to talk to more than one Sentry service for
the first time: providerData now wraps two *client instances (api,
alerting), with a new alerting_endpoint provider attribute defaulting
the same way sentryctl's --alerting-api/$SENTRYCTL_ALERTING_API_URL
does. dashboardResource's Configure updated to pull .api out of the new
wrapper type instead of a bare *client.

Schema mirrors sentry_dashboard's established pattern: comparator/
threshold_value/renotify_interval_minutes stay nullable (only meaningful
for threshold-condition rules), enabled/for_minutes/query_language are
Optional+Computed with a Terraform-side default matching the API's own
default (true/0/"") rather than leaving the API as sole source of truth
the way dashboard's default_earliest/default_latest deliberately do --
these three have no *pointer* type in the API's Rule struct, so their
"default when omitted" is unconditional, not a real API-side default
that could drift independently.

Verified: client tests are real httptest.Server round trips (same
pattern as sentry_dashboard's). Schema validation needs no Terraform
binary. TestAccAlertRuleResource_basic is a real acceptance test,
skip-gated by TF_ACC same as the dashboard one, including a
plancheck.ExpectResourceAction assertion that a config change actually
plans destroy-then-create -- the concrete, checked version of the
"create/destroy only" design decision, not just a comment. Not run
against a live stack in this environment, same disclosed gap as
everything else Docker-gated in this repo.
2026-08-15 00:15:45 -07:00

203 lines
7.3 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 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)
}
// 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)
}