Mechanical, low-risk follow-up -- no new architectural question, no new
external service, no new write path. Each of sentry_dashboard,
sentry_alert_rule, and sentry_notification_target gets a matching data
source: a single Required id attribute in, every other attribute
Computed out, backed by the exact same getDashboard/getRule/
getNotificationTarget client methods and dashboardModelFromAPI/
alertRuleModelFromAPI/notificationTargetModelFromAPI conversion
functions the resources already use and already have tests for -- these
data sources add no new client code at all, just a thin
datasource.DataSource wrapper reusing what Create/Read/Update/Delete
already exercise.
sentry_notification_target's data source carries the same secret
caveat its resource does (Sensitive, but alerting's GET /targets/{id}
returns it unredacted, so it's a real plaintext value in Terraform
state) -- named again here rather than assumed obvious from the
resource's own docs.
Verified: provider_test.go's new schema-validation tests confirm each
data source's id is Required and everything else Computed, no
Terraform binary needed. Three new real acceptance tests
(TestAccDashboardDataSource_basic and its two siblings) each create a
resource then look it up via the matching data source, using
resource.TestCheckResourceAttrPair to prove the data source's Read
actually agrees with what the resource wrote -- not just that both
compile. Skip-gated by TF_ACC same as the existing six acceptance
tests, and needs the same live api/alerting services this environment
has no Docker access to bring up, so not run here -- same disclosed gap
as everything else Docker-gated in this repo.
terraform-provider-sentry
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,
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 (the
actively-developed library, not the legacy SDKv2 -- there's no existing
provider code here to migrate, so there's no reason to start on the
framework HashiCorp itself steers new providers away from).
Why these three resources first
cli/README.md already frames the dashboards REST contract this way:
POST /dashboards, GET/PUT/DELETE /dashboards/{id} are "the seed
of a future Terraform provider: one JSON contract, multiple callers (web
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),
undermining the point of managing rules as code at all.
What's built
terraform {
required_providers {
sentry = {
source = "registry.terraform.io/sentry/sentry"
}
}
}
provider "sentry" {
endpoint = "http://localhost:8080" # or $SENTRY_API_ENDPOINT
token = var.sentry_api_token # or $SENTRY_API_TOKEN -- optional, only needed once enterprise-auth enforcement is on
}
resource "sentry_dashboard" "example" {
name = "Checkout Errors"
description = "5xx rate and latency for the checkout service"
# default_earliest/default_latest are optional -- left unset, the API
# itself defaults them ("-1h"/"now"); this resource deliberately
# doesn't hardcode a matching Terraform-side default, so the API stays
# the one source of truth for what "unset" means (see the schema's
# doc comment in internal/provider/dashboard_resource.go).
}
Supports terraform import sentry_dashboard.example <dashboard-id>.
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.
provider "sentry" {
endpoint = "http://localhost:8080" # or $SENTRY_API_ENDPOINT
alerting_endpoint = "http://localhost:8081" # or $SENTRY_ALERTING_API_ENDPOINT -- alerting is a separate service, own base URL
token = var.sentry_api_token # or $SENTRY_API_TOKEN -- shared by both services, same as sentryctl's one $SENTRYCTL_TOKEN
}
resource "sentry_notification_target" "ops" {
name = "Ops Webhook"
kind = "webhook"
webhook_url = "https://ops.example.com/hooks/sentry-alerts"
}
resource "sentry_alert_rule" "example" {
name = "Checkout 5xx spike"
query = "service=checkout status>=500 | stats count"
condition_type = "threshold"
comparator = "gt"
threshold_value = 50
eval_interval_seconds = 60
notification_target_id = sentry_notification_target.ops.id
}
Supports terraform import sentry_alert_rule.example <rule-id> and
terraform import sentry_notification_target.example <target-id>.
sentry_alert_rule and sentry_notification_target are both
create/destroy only, not update-in-place. alerting's REST API has no
PUT /rules/{id} or PUT /targets/{id} at all -- confirmed down to
rulestore.Store/notifystore.Store, both of which have
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
specific to Terraform. Every attribute on both resources carries a
RequiresReplace plan modifier, so changing anything (a rule's query or
threshold, a target's webhook URL, even just a description) destroys
and recreates it rather than updating it in place -- for rules, that
also resets alert_state/delivery-log continuity, a real operational
side effect worth knowing about before relying on this in a pipeline
that changes rules often. Faking an in-place update via
delete-then-recreate inside either resource was considered and rejected
for the same reason: it would hide that side effect instead of
surfacing it in the plan output the way RequiresReplace does. Adding
real PUT endpoints to alerting would remove this constraint, but is
a change to a different module's REST API, out of scope for this pass.
sentry_notification_target's secret attribute is Sensitive but
still lands in Terraform state in plaintext. alerting's own
GET /targets/{id} returns secret unredacted (confirmed in
notifystore/store.go -- no redaction at the store or handler layer, an
existing property of alerting's API, not introduced by this
provider), and a resource has to store whatever Read returns to avoid
Terraform showing a permanent diff. Sensitive: true keeps it out of
plan/apply console output; it does not keep it out of the state file --
the standard, well-known Terraform caveat for any sensitive attribute
(encrypt the state backend, restrict who can read it), named here rather
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.
data "sentry_notification_target" "ops" {
id = "target-abc123"
}
resource "sentry_alert_rule" "checkout_5xx" {
# ...
notification_target_id = data.sentry_notification_target.ops.id
}
sentry_notification_target's data source has the same secret
caveat its resource does -- Sensitive, but a real value visible in
Terraform state; see "sentry_notification_target's secret
attribute" above.
Also not built, all real and disclosed, not attempted here:
- Tenant/RBAC resources (
enterprise-auth's tenant/membership/grant surface) -- meaningfully different auth model (offline operator flags today, not a stable REST API a provider could safely drive 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'sAddress(registry.terraform.io/sentry/sentry) is the address a real publication would use, but nothing has actually been published; local use is via~/.terraformrc'sdev_overrides(see "Building & testing" below) or a local provider mirror.
Building & testing
go build ./...
go vet ./...
go test ./...
internal/provider/client_test.go runs real HTTP round trips against a
httptest.Server (same pattern cli/cmd/sentryctl's own tests use
against the same api/dashboards/alerting endpoints) -- real request
construction (method, path, Authorization header, JSON body), real
response parsing, including the 404-vs-other-error distinction
Read/Delete need to implement Terraform's "resource deleted
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
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
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.
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
-- 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
for real."
# local dev override, so `terraform` picks up a locally-built binary
# instead of trying to download from the registry (which nothing has
# been published to -- see "What's built" above)
go build -o terraform-provider-sentry .
cat <<'EOF' >> ~/.terraformrc
provider_installation {
dev_overrides {
"registry.terraform.io/sentry/sentry" = "/absolute/path/to/this/directory"
}
direct {}
}
EOF