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.
This commit is contained in:
2026-08-15 10:28:08 -07:00
parent 30ae84cd04
commit eb38611aa8
13 changed files with 556 additions and 30 deletions
@@ -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))...)
}
@@ -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"),
),
},
},
})
}
@@ -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))...)
}
@@ -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"),
),
},
},
})
}
@@ -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 = &notificationTargetDataSource{}
_ datasource.DataSourceWithConfigure = &notificationTargetDataSource{}
)
func newNotificationTargetDataSource() datasource.DataSource {
return &notificationTargetDataSource{}
}
// 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))...)
}
@@ -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"),
),
},
},
})
}
+5 -1
View File
@@ -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,
}
}
@@ -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)
}
}
}