From eb38611aa885b9a997ba397686592d319b1763cf Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sat, 15 Aug 2026 10:28:08 -0700 Subject: [PATCH] Add read-only data sources for all three Terraform resources 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. --- CLAUDE.md | 13 +-- terraform/README.md | 76 +++++++++++----- .../sentry_alert_rule/data-source.tf | 7 ++ .../sentry_dashboard/data-source.tf | 7 ++ .../sentry_notification_target/data-source.tf | 16 ++++ .../provider/alert_rule_data_source.go | 87 ++++++++++++++++++ .../provider/alert_rule_data_source_test.go | 45 ++++++++++ .../provider/dashboard_data_source.go | 84 +++++++++++++++++ .../provider/dashboard_data_source_test.go | 38 ++++++++ .../notification_target_data_source.go | 89 +++++++++++++++++++ .../notification_target_data_source_test.go | 45 ++++++++++ terraform/internal/provider/provider.go | 6 +- terraform/internal/provider/provider_test.go | 73 +++++++++++++++ 13 files changed, 556 insertions(+), 30 deletions(-) create mode 100644 terraform/examples/data-sources/sentry_alert_rule/data-source.tf create mode 100644 terraform/examples/data-sources/sentry_dashboard/data-source.tf create mode 100644 terraform/examples/data-sources/sentry_notification_target/data-source.tf create mode 100644 terraform/internal/provider/alert_rule_data_source.go create mode 100644 terraform/internal/provider/alert_rule_data_source_test.go create mode 100644 terraform/internal/provider/dashboard_data_source.go create mode 100644 terraform/internal/provider/dashboard_data_source_test.go create mode 100644 terraform/internal/provider/notification_target_data_source.go create mode 100644 terraform/internal/provider/notification_target_data_source_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 5b22ee7..3a26ee7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,12 +23,13 @@ described there without flagging it to me first. as of this note -- three resources (`sentry_dashboard`, full CRUD; `sentry_alert_rule` and `sentry_notification_target`, both create/ destroy only -- `alerting` has no `PUT /rules/{id}` or - `PUT /targets/{id}` to update against), built on HashiCorp's - `terraform-plugin-framework`, reusing the exact same REST contracts - `sentryctl dashboards apply`/web's dashboard export and - `sentryctl alerts apply` already use. Tenant/RBAC resources are real, - disclosed future work -- see `/terraform/README.md` for the full - accounting of what is and isn't built, and the same + `PUT /targets/{id}` to update against), each paired with a read-only + data source, built on HashiCorp's `terraform-plugin-framework`, + reusing the exact same REST contracts `sentryctl dashboards apply`/ + web's dashboard export and `sentryctl alerts apply` already use. + Tenant/RBAC resources are real, disclosed future work -- see + `/terraform/README.md` for the full accounting of what is and isn't + built, and the same "written but not run against a live stack" verification caveat as everything else Docker-gated in this repo. diff --git a/terraform/README.md b/terraform/README.md index ecbc9c8..1c67372 100644 --- a/terraform/README.md +++ b/terraform/README.md @@ -5,7 +5,8 @@ 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`), not a finished provider, built on +`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`][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 @@ -130,6 +131,35 @@ 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. + +```hcl +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 @@ -137,9 +167,6 @@ than left implicit. 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." -- Data sources (read-only lookup by ID/name) for any of the three - resources -- straightforward given the resources already exist, just - not built yet. - Dashboard panels (see "Panels are not managed by this resource" above). - Publishing to the real Terraform Registry -- `main.go`'s `Address` (`registry.terraform.io/sentry/sentry`) is the address a real @@ -167,26 +194,29 @@ 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 and all -three resource schemas are internally well-formed (attribute names, the -`Required`/`Computed`/`Sensitive` split) without needing a Terraform -binary or a live `api`/`alerting` service at all. +`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. -`internal/provider/dashboard_resource_test.go`'s -`TestAccDashboardResource_basic`, -`internal/provider/alert_rule_resource_test.go`'s -`TestAccAlertRuleResource_basic`, and -`internal/provider/notification_target_resource_test.go`'s -`TestAccNotificationTargetResource_basic` are real acceptance tests -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 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. Even with -`TF_ACC=1` all three tests also need real running `api`/`alerting` +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`][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 diff --git a/terraform/examples/data-sources/sentry_alert_rule/data-source.tf b/terraform/examples/data-sources/sentry_alert_rule/data-source.tf new file mode 100644 index 0000000..c991029 --- /dev/null +++ b/terraform/examples/data-sources/sentry_alert_rule/data-source.tf @@ -0,0 +1,7 @@ +data "sentry_alert_rule" "checkout_5xx" { + id = "an-existing-rule-id" +} + +output "checkout_5xx_notification_target" { + value = data.sentry_alert_rule.checkout_5xx.notification_target_id +} diff --git a/terraform/examples/data-sources/sentry_dashboard/data-source.tf b/terraform/examples/data-sources/sentry_dashboard/data-source.tf new file mode 100644 index 0000000..e28e4bf --- /dev/null +++ b/terraform/examples/data-sources/sentry_dashboard/data-source.tf @@ -0,0 +1,7 @@ +data "sentry_dashboard" "checkout_errors" { + id = "an-existing-dashboard-id" +} + +output "checkout_errors_default_earliest" { + value = data.sentry_dashboard.checkout_errors.default_earliest +} diff --git a/terraform/examples/data-sources/sentry_notification_target/data-source.tf b/terraform/examples/data-sources/sentry_notification_target/data-source.tf new file mode 100644 index 0000000..fe90eaa --- /dev/null +++ b/terraform/examples/data-sources/sentry_notification_target/data-source.tf @@ -0,0 +1,16 @@ +# secret comes back unredacted (see the sentry_notification_target +# resource's schema doc comment) -- this data source's "secret" +# attribute is Sensitive for the same reason. +data "sentry_notification_target" "ops_webhook" { + id = "an-existing-target-id" +} + +resource "sentry_alert_rule" "checkout_5xx" { + 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 = data.sentry_notification_target.ops_webhook.id +} diff --git a/terraform/internal/provider/alert_rule_data_source.go b/terraform/internal/provider/alert_rule_data_source.go new file mode 100644 index 0000000..faa0769 --- /dev/null +++ b/terraform/internal/provider/alert_rule_data_source.go @@ -0,0 +1,87 @@ +package provider + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" +) + +var ( + _ datasource.DataSource = &alertRuleDataSource{} + _ datasource.DataSourceWithConfigure = &alertRuleDataSource{} +) + +func newAlertRuleDataSource() datasource.DataSource { + return &alertRuleDataSource{} +} + +// alertRuleDataSource looks up an existing alert rule by ID -- see +// dashboardDataSource's doc comment for why this reuses +// alertRuleResourceModel/alertRuleModelFromAPI rather than a parallel +// type. +type alertRuleDataSource struct { + client *client +} + +func (d *alertRuleDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_alert_rule" +} + +func (d *alertRuleDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Looks up an existing Sentry alert rule by ID. See the sentry_alert_rule resource for how one is created/managed.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Required: true, + Description: "Rule ID to look up.", + }, + "tenant_id": schema.StringAttribute{Computed: true}, + "name": schema.StringAttribute{Computed: true}, + "description": schema.StringAttribute{Computed: true}, + "query": schema.StringAttribute{Computed: true}, + "query_language": schema.StringAttribute{Computed: true}, + "condition_type": schema.StringAttribute{Computed: true}, + "comparator": schema.StringAttribute{Computed: true}, + "threshold_value": schema.Float64Attribute{Computed: true}, + "eval_interval_seconds": schema.Int64Attribute{Computed: true}, + "for_minutes": schema.Int64Attribute{Computed: true}, + "renotify_interval_minutes": schema.Int64Attribute{Computed: true}, + "notification_target_id": schema.StringAttribute{Computed: true}, + "enabled": schema.BoolAttribute{Computed: true}, + "created_by": schema.StringAttribute{Computed: true}, + }, + } +} + +func (d *alertRuleDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + data, ok := req.ProviderData.(*providerData) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Data Source Configure Type", + fmt.Sprintf("Expected *provider.providerData, got: %T. This is a provider bug -- please report it.", req.ProviderData), + ) + return + } + d.client = data.alerting +} + +func (d *alertRuleDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var config alertRuleResourceModel + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + if resp.Diagnostics.HasError() { + return + } + + out, err := d.client.getRule(ctx, config.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Reading Alert Rule", err.Error()) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, alertRuleModelFromAPI(out))...) +} diff --git a/terraform/internal/provider/alert_rule_data_source_test.go b/terraform/internal/provider/alert_rule_data_source_test.go new file mode 100644 index 0000000..22cbf7d --- /dev/null +++ b/terraform/internal/provider/alert_rule_data_source_test.go @@ -0,0 +1,45 @@ +package provider + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +// Same skip-gated-not-faked posture as TestAccAlertRuleResource_basic -- +// see that test's doc comment. notification_target_id is a placeholder, +// same caveat as the resource test. +func TestAccAlertRuleDataSource_basic(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: ` +provider "sentry" { + endpoint = "http://localhost:8080" + alerting_endpoint = "http://localhost:8081" +} + +resource "sentry_alert_rule" "test" { + name = "Data Source Test Rule" + query = "status>=500 | stats count" + condition_type = "threshold" + comparator = "gt" + threshold_value = 5 + eval_interval_seconds = 60 + notification_target_id = "placeholder-target-id" +} + +data "sentry_alert_rule" "test" { + id = sentry_alert_rule.test.id +} +`, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrPair("data.sentry_alert_rule.test", "name", "sentry_alert_rule.test", "name"), + resource.TestCheckResourceAttrPair("data.sentry_alert_rule.test", "query", "sentry_alert_rule.test", "query"), + resource.TestCheckResourceAttrPair("data.sentry_alert_rule.test", "threshold_value", "sentry_alert_rule.test", "threshold_value"), + ), + }, + }, + }) +} diff --git a/terraform/internal/provider/dashboard_data_source.go b/terraform/internal/provider/dashboard_data_source.go new file mode 100644 index 0000000..0385a4d --- /dev/null +++ b/terraform/internal/provider/dashboard_data_source.go @@ -0,0 +1,84 @@ +package provider + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" +) + +var ( + _ datasource.DataSource = &dashboardDataSource{} + _ datasource.DataSourceWithConfigure = &dashboardDataSource{} +) + +func newDashboardDataSource() datasource.DataSource { + return &dashboardDataSource{} +} + +// dashboardDataSource looks up an existing dashboard by ID -- read-only, +// GET /dashboards/{id} only, no lifecycle of its own. Reuses +// dashboardResourceModel/dashboardModelFromAPI from +// dashboard_resource.go directly rather than defining a parallel type: +// a data source's attribute set here is exactly the resource's (every +// field Computed except id, which the caller supplies), so there's +// nothing a second struct would express that the first doesn't already. +type dashboardDataSource struct { + client *client +} + +func (d *dashboardDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_dashboard" +} + +func (d *dashboardDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Looks up an existing Sentry dashboard by ID. See the sentry_dashboard resource for how one is created/managed.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Required: true, + Description: "Dashboard ID to look up.", + }, + "tenant_id": schema.StringAttribute{Computed: true}, + "name": schema.StringAttribute{Computed: true}, + "description": schema.StringAttribute{Computed: true}, + "default_earliest": schema.StringAttribute{Computed: true}, + "default_latest": schema.StringAttribute{Computed: true}, + "created_by": schema.StringAttribute{Computed: true}, + "created_at": schema.StringAttribute{Computed: true}, + "updated_at": schema.StringAttribute{Computed: true}, + }, + } +} + +func (d *dashboardDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + data, ok := req.ProviderData.(*providerData) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Data Source Configure Type", + fmt.Sprintf("Expected *provider.providerData, got: %T. This is a provider bug -- please report it.", req.ProviderData), + ) + return + } + d.client = data.api +} + +func (d *dashboardDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var config dashboardResourceModel + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + if resp.Diagnostics.HasError() { + return + } + + out, err := d.client.getDashboard(ctx, config.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Reading Dashboard", err.Error()) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, dashboardModelFromAPI(out))...) +} diff --git a/terraform/internal/provider/dashboard_data_source_test.go b/terraform/internal/provider/dashboard_data_source_test.go new file mode 100644 index 0000000..584b7f6 --- /dev/null +++ b/terraform/internal/provider/dashboard_data_source_test.go @@ -0,0 +1,38 @@ +package provider + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +// Same skip-gated-not-faked posture as TestAccDashboardResource_basic -- +// see that test's doc comment. +func TestAccDashboardDataSource_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 = "Data Source Test Dashboard" + description = "created by TestAccDashboardDataSource_basic" +} + +data "sentry_dashboard" "test" { + id = sentry_dashboard.test.id +} +`, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrPair("data.sentry_dashboard.test", "name", "sentry_dashboard.test", "name"), + resource.TestCheckResourceAttrPair("data.sentry_dashboard.test", "tenant_id", "sentry_dashboard.test", "tenant_id"), + resource.TestCheckResourceAttrPair("data.sentry_dashboard.test", "default_earliest", "sentry_dashboard.test", "default_earliest"), + ), + }, + }, + }) +} diff --git a/terraform/internal/provider/notification_target_data_source.go b/terraform/internal/provider/notification_target_data_source.go new file mode 100644 index 0000000..86c86fc --- /dev/null +++ b/terraform/internal/provider/notification_target_data_source.go @@ -0,0 +1,89 @@ +package provider + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" +) + +var ( + _ datasource.DataSource = ¬ificationTargetDataSource{} + _ datasource.DataSourceWithConfigure = ¬ificationTargetDataSource{} +) + +func newNotificationTargetDataSource() datasource.DataSource { + return ¬ificationTargetDataSource{} +} + +// notificationTargetDataSource looks up an existing notification target +// by ID -- see dashboardDataSource's doc comment for why this reuses +// notificationTargetResourceModel/notificationTargetModelFromAPI rather +// than a parallel type. +type notificationTargetDataSource struct { + client *client +} + +func (d *notificationTargetDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_notification_target" +} + +func (d *notificationTargetDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Looks up an existing Sentry notification target by ID. See the sentry_notification_target resource for how one is created/managed.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Required: true, + Description: "Target ID to look up.", + }, + "tenant_id": schema.StringAttribute{Computed: true}, + "name": schema.StringAttribute{Computed: true}, + "kind": schema.StringAttribute{Computed: true}, + "webhook_url": schema.StringAttribute{Computed: true}, + "payload_template": schema.StringAttribute{ + Computed: true, + }, + "headers": schema.StringAttribute{Computed: true}, + "secret": schema.StringAttribute{ + Computed: true, + Sensitive: true, + Description: "alerting's own GET /targets/{id} returns this unredacted (see the " + + "sentry_notification_target resource's schema doc comment) -- Sensitive here for the " + + "same reason, and the same state-file caveat applies.", + }, + "created_by": schema.StringAttribute{Computed: true}, + }, + } +} + +func (d *notificationTargetDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + data, ok := req.ProviderData.(*providerData) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Data Source Configure Type", + fmt.Sprintf("Expected *provider.providerData, got: %T. This is a provider bug -- please report it.", req.ProviderData), + ) + return + } + d.client = data.alerting +} + +func (d *notificationTargetDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var config notificationTargetResourceModel + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + if resp.Diagnostics.HasError() { + return + } + + out, err := d.client.getNotificationTarget(ctx, config.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Reading Notification Target", err.Error()) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, notificationTargetModelFromAPI(out))...) +} diff --git a/terraform/internal/provider/notification_target_data_source_test.go b/terraform/internal/provider/notification_target_data_source_test.go new file mode 100644 index 0000000..e1b8985 --- /dev/null +++ b/terraform/internal/provider/notification_target_data_source_test.go @@ -0,0 +1,45 @@ +package provider + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +// Same skip-gated-not-faked posture as +// TestAccNotificationTargetResource_basic -- see that test's doc +// comment. +func TestAccNotificationTargetDataSource_basic(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: ` +provider "sentry" { + endpoint = "http://localhost:8080" + alerting_endpoint = "http://localhost:8081" +} + +resource "sentry_notification_target" "test" { + name = "Data Source Test Target" + kind = "webhook" + webhook_url = "https://example.com/hook" + secret = "test-secret" +} + +data "sentry_notification_target" "test" { + id = sentry_notification_target.test.id +} +`, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrPair("data.sentry_notification_target.test", "name", "sentry_notification_target.test", "name"), + resource.TestCheckResourceAttrPair("data.sentry_notification_target.test", "webhook_url", "sentry_notification_target.test", "webhook_url"), + // Real, not papered over -- secret round-trips + // unredacted (see the resource test's equivalent + // comment). + resource.TestCheckResourceAttrPair("data.sentry_notification_target.test", "secret", "sentry_notification_target.test", "secret"), + ), + }, + }, + }) +} diff --git a/terraform/internal/provider/provider.go b/terraform/internal/provider/provider.go index 3cbf515..6d6966c 100644 --- a/terraform/internal/provider/provider.go +++ b/terraform/internal/provider/provider.go @@ -135,5 +135,9 @@ func (p *sentryProvider) Resources(_ context.Context) []func() resource.Resource } func (p *sentryProvider) DataSources(_ context.Context) []func() datasource.DataSource { - return nil + return []func() datasource.DataSource{ + newDashboardDataSource, + newAlertRuleDataSource, + newNotificationTargetDataSource, + } } diff --git a/terraform/internal/provider/provider_test.go b/terraform/internal/provider/provider_test.go index 895a00b..7042c89 100644 --- a/terraform/internal/provider/provider_test.go +++ b/terraform/internal/provider/provider_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/resource" ) @@ -133,3 +134,75 @@ func TestNotificationTargetResourceMetadataSetsTypeName(t *testing.T) { t.Fatalf("TypeName = %q, want sentry_notification_target", resp.TypeName) } } + +func TestDashboardDataSourceSchemaValid(t *testing.T) { + ctx := context.Background() + req := datasource.SchemaRequest{} + resp := &datasource.SchemaResponse{} + + newDashboardDataSource().Schema(ctx, req, resp) + + if resp.Diagnostics.HasError() { + t.Fatalf("sentry_dashboard data source schema has errors: %v", resp.Diagnostics) + } + if !resp.Schema.Attributes["id"].IsRequired() { + t.Error(`"id" must be Required -- a data source needs it to know what to look up`) + } + if !resp.Schema.Attributes["name"].IsComputed() { + t.Error(`"name" must be Computed`) + } +} + +func TestAlertRuleDataSourceSchemaValid(t *testing.T) { + ctx := context.Background() + req := datasource.SchemaRequest{} + resp := &datasource.SchemaResponse{} + + newAlertRuleDataSource().Schema(ctx, req, resp) + + if resp.Diagnostics.HasError() { + t.Fatalf("sentry_alert_rule data source schema has errors: %v", resp.Diagnostics) + } + if !resp.Schema.Attributes["id"].IsRequired() { + t.Error(`"id" must be Required`) + } + if !resp.Schema.Attributes["query"].IsComputed() { + t.Error(`"query" must be Computed`) + } +} + +func TestNotificationTargetDataSourceSchemaValid(t *testing.T) { + ctx := context.Background() + req := datasource.SchemaRequest{} + resp := &datasource.SchemaResponse{} + + newNotificationTargetDataSource().Schema(ctx, req, resp) + + if resp.Diagnostics.HasError() { + t.Fatalf("sentry_notification_target data source schema has errors: %v", resp.Diagnostics) + } + if !resp.Schema.Attributes["id"].IsRequired() { + t.Error(`"id" must be Required`) + } + if !resp.Schema.Attributes["secret"].IsSensitive() { + t.Error(`"secret" must be Sensitive`) + } +} + +func TestDataSourcesMetadataSetTypeNames(t *testing.T) { + cases := []struct { + newDS func() datasource.DataSource + wantType string + }{ + {newDashboardDataSource, "sentry_dashboard"}, + {newAlertRuleDataSource, "sentry_alert_rule"}, + {newNotificationTargetDataSource, "sentry_notification_target"}, + } + for _, c := range cases { + resp := &datasource.MetadataResponse{} + c.newDS().Metadata(context.Background(), datasource.MetadataRequest{ProviderTypeName: "sentry"}, resp) + if resp.TypeName != c.wantType { + t.Errorf("TypeName = %q, want %q", resp.TypeName, c.wantType) + } + } +}