Files
cairnobs/terraform/README.md
T
jcoffey-dev 13cf9a30cb Rebrand: Sentry -> Cairn OBS
Full rebrand across cosmetic branding, code identifiers, and
infrastructure/data-plane naming, using the supplied Cairn OBS logo
package. Cosmetic: favicon/logo swap (also closes a stale license-audit
finding -- the old favicon was SvelteKit's unreplaced scaffold logo),
new centered welcome landing page, larger/legible sidebar logo, page
titles, CLAUDE.md/README/docs prose.

Code identifiers: Go module path github.com/sentry/sentry ->
github.com/cairnobs/cairnobs across all 13 modules and ~91 files (protoc
regenerated); Rust crates sentry-agent/sentry-parser/sentry-search ->
cairnobs-*; CLI sentryctl -> cairnobsctl; Terraform provider fully
renamed (sentry_dashboard etc. -> cairnobs_dashboard, provider type,
env vars); every session/auth cookie name; agent config paths and
Windows service identity.

Deliberately preserved: the gRPC wire protocol's protobuf packages
(sentry.logs.v1, sentry.agent.v1) and their Go import directory
(proto/sentry/...) -- renaming the wire-level package would break every
currently-deployed agent binary (confirmed two real hosts, including
mail.inbuxa.com, are actively streaming through this exact contract)
until rebuilt and redeployed in lockstep with an ingest cutover. Only
the Go module path wrapping the generated code changes.

Infrastructure: every docker-compose container name (root and three
component-level compose files); the Helm chart (directory, Chart.yaml,
named-template helpers, all templates, values.yaml image repos);
Kubernetes Operator (CRD group sentry.io -> cairnobs.io, both CRD YAML
files, Go identifiers, RBAC markers); the coupled enterprise/tenantcrd
package. Caught and fixed real path-coupling bugs along the way: the
Helm chart's search/ingest volume mounts and the dev-only-credential
detection constant vs. docker-compose.yml's literal values had to move
together or a security warning would have silently stopped firing.

Data plane: Postgres database sentry_metadata -> cairnobs_metadata and
role sentry -> cairnobs; ClickHouse database sentry -> cairnobs; Kafka
topic sentry.logs.raw -> cairnobs.logs.raw and its consumer groups.
Source-level defaults, docker-compose.yml, and every migrate.sh/
provision script default updated together; already-applied migration
files left untouched per this repo's immutable-migration convention.

Verified at every layer: all 13 Go modules build/vet/test clean, both
Rust workspaces (agent, search) build/clippy/test clean, npm run check/
build clean, docker compose config validates on all four compose files.
Live-verified against a real docker stack multiple times through this
work, including a final fresh-volume run confirming the actual renamed
Postgres database/role, ClickHouse database, and Kafka topic all work
end to end with a real login and query, zero console errors.
2026-08-21 20:53:32 -07:00

279 lines
13 KiB
Markdown

# terraform-provider-cairnobs
Cairn OBS's Terraform provider -- `CLAUDE.md`'s "Repo conventions" section
names this a first-class deliverable alongside `cairnobsctl`
("CLI and Terraform provider are first-class, not afterthoughts"), but
no phase before this one had actually built any of it. Four resources
so far (`cairnobs_dashboard`, `cairnobs_dashboard_panel`, `cairnobs_alert_rule`,
`cairnobs_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`][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).
[framework]: https://developer.hashicorp.com/terraform/plugin/framework
## Why these four 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
`cairnobsctl 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. `cairnobs_dashboard_panel` follows the same contract's
panel endpoints, as its own resource rather than a block nested inside
`cairnobs_dashboard` -- see "Panels are their own resource" below.
`cairnobs_alert_rule` follows against `alerting`'s own `POST`/`GET`/
`DELETE /rules[/{id}]` -- the natural next resource, and a real second
service (`alerting` is a genuinely separate deployment from `api`, its
own base URL), so building it exercised that this provider can talk to
more than one Cairn OBS service, not just repeat the dashboards pattern
against the same endpoint. `cairnobs_notification_target` rounds these
out -- `cairnobs_alert_rule.notification_target_id` needs something to
actually point at, and without this resource that id could only ever
come from outside Terraform (`cairnobsctl`, `curl`, the web UI),
undermining the point of managing rules as code at all.
## What's built
```hcl
terraform {
required_providers {
cairnobs = {
source = "registry.terraform.io/cairnobs/cairnobs"
}
}
}
provider "cairnobs" {
endpoint = "http://localhost:8080" # or $CAIRNOBS_API_ENDPOINT
token = var.cairnobs_api_token # or $CAIRNOBS_API_TOKEN -- optional, only needed once enterprise-auth enforcement is on
}
resource "cairnobs_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 cairnobs_dashboard.example <dashboard-id>`.
**Panels are their own resource, not a nested block.** `api/dashboards.
Handler` exposes panel CRUD as its own endpoints (`POST`/`PUT`/
`DELETE /dashboards/{id}/panels[/{panelId}]`) -- a panel belongs to
exactly one dashboard, has its own lifecycle, and is created/updated/
deleted independently, never by rewriting a dashboard's whole panel
list, so `cairnobs_dashboard_panel` follows that shape rather than a
nested list block (which would force every panel to be rewritten on any
single panel's change, hiding fine-grained diffs a separate resource
shows naturally):
```hcl
resource "cairnobs_dashboard_panel" "example" {
dashboard_id = cairnobs_dashboard.example.id
title = "5xx rate over time"
query = "status>=500 | timechart count"
viz_type = "line" # table, line, bar, single_stat, or top_n
# query_language never accepts "sql" for panels -- the API rejects it
# outright (dashboards only support pipe-syntax queries, since the
# time-range picker is injected as leading query terms). Unlike
# cairnobs_alert_rule/cairnobs_notification_target, this resource
# supports a real in-place update (api/dashboards.Handler has a real
# PUT for panels) -- only dashboard_id forces a destroy-and-recreate,
# since there's no API operation to move a panel between dashboards.
}
```
Supports `terraform import cairnobs_dashboard_panel.example
<dashboard-id>/<panel-id>` -- a bare panel ID isn't enough on its own,
since `Read` needs the parent `dashboard_id` to know where to look (see
`client.go`'s `getPanel` doc comment for why: there's no standalone
`GET` for a single panel).
```hcl
provider "cairnobs" {
endpoint = "http://localhost:8080" # or $CAIRNOBS_API_ENDPOINT
alerting_endpoint = "http://localhost:8081" # or $CAIRNOBS_ALERTING_API_ENDPOINT -- alerting is a separate service, own base URL
token = var.cairnobs_api_token # or $CAIRNOBS_API_TOKEN -- shared by both services, same as cairnobsctl's one $CAIRNOBSCTL_TOKEN
}
resource "cairnobs_notification_target" "ops" {
name = "Ops Webhook"
kind = "webhook"
webhook_url = "https://ops.example.com/hooks/cairnobs-alerts"
}
resource "cairnobs_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 = cairnobs_notification_target.ops.id
}
```
Supports `terraform import cairnobs_alert_rule.example <rule-id>` and
`terraform import cairnobs_notification_target.example <target-id>`.
**`cairnobs_alert_rule` and `cairnobs_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.
**`cairnobs_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
"cairnobs_dashboard"`, `data "cairnobs_dashboard_panel"`, `data
"cairnobs_alert_rule"`, `data "cairnobs_notification_target"`) -- a lookup
against the same 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 `Required`
input instead of a full config. Three of the four take a single
`Required` `id`; `cairnobs_dashboard_panel`'s takes both `dashboard_id`
and `id` (both `Required`), matching `getPanel`'s own two-argument shape
-- there's no standalone lookup for a panel by ID alone.
```hcl
data "cairnobs_notification_target" "ops" {
id = "target-abc123"
}
resource "cairnobs_alert_rule" "checkout_5xx" {
# ...
notification_target_id = data.cairnobs_notification_target.ops.id
}
```
`cairnobs_notification_target`'s data source has the same `secret`
caveat its resource does -- `Sensitive`, but a real value visible in
Terraform state; see "`cairnobs_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), so this would need its own design pass, not just "add
another resource file." (Not a licensing question as of Phase 6 --
`enterprise/` is AGPLv3 same as this provider module; the blocker is
purely that the underlying API isn't idempotent-safe yet.)
- Publishing to the real Terraform Registry -- `main.go`'s `Address`
(`registry.terraform.io/cairnobs/cairnobs`) is the address a real
publication would use, but nothing has actually been published; local
use is via `~/.terraformrc`'s `dev_overrides` (see "Building &
testing" below) or a local provider mirror.
## Building & testing
```sh
go build ./...
go vet ./...
go test ./...
```
`internal/provider/client_test.go` runs real HTTP round trips against a
`httptest.Server` (same pattern `cli/cmd/cairnobsctl`'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, (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
-- and (for panels) proving `getPanel` finds the right panel within a
real parent dashboard's `panels` array, and returns a recognizable
not-found both when the panel is missing from an otherwise-real
dashboard response and when the dashboard itself is gone.
`internal/provider/provider_test.go` validates the provider's, all four
resources', and all four data sources' schemas are internally
well-formed (attribute names, the `Required`/`Computed`/`Sensitive`
split -- for data sources, that every attribute except the lookup key(s)
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 three) --
eight real acceptance tests total, using
[`terraform-plugin-testing`][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 panel resource test instead proves a genuine in-place update
(a `title` change with no `plancheck` needed, since the default
expectation -- update, not replace -- is exactly what should happen);
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 eight 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."
[testing]: https://developer.hashicorp.com/terraform/plugin/testing
```sh
# 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-cairnobs .
cat <<'EOF' >> ~/.terraformrc
provider_installation {
dev_overrides {
"registry.terraform.io/cairnobs/cairnobs" = "/absolute/path/to/this/directory"
}
direct {}
}
EOF
```