Files
cairnobs/terraform/internal/provider/provider.go
T
jcoffey-dev 49dd050689 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.
2026-08-15 00:06:26 -07:00

106 lines
3.7 KiB
Go

// 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
}