Add sentry_alert_rule, the Terraform provider's second resource
Confirmed with the project owner first: alerting's REST API has no
PUT /rules/{id} at all -- confirmed down to rulestore.Store, which has
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 new to this task.
Decided to model sentry_alert_rule as create/destroy only rather than
fake an in-place update via delete-then-recreate inside the resource:
every attribute carries a RequiresReplace plan modifier, so a config
change destroys and recreates the rule, surfacing in the plan output the
real side effect that has (alert_state/delivery-log continuity resets)
instead of hiding it. Adding a real PUT /rules/{id} to alerting would
remove this constraint but is a change to a different module's REST
API, out of scope here.
internal/provider/client.go's new rule type and createRule/getRule/
deleteRule methods talk the exact same JSON contract
sentryctl alerts apply already uses against alerting/internal/httpapi.
GET /rules/{id} actually returns rulestore.RuleWithState (Rule's fields
promoted via anonymous embedding, plus a "state" object) -- the local
rule type has no field for "state" by design, and a new client test
proves that extra key doesn't break parsing.
alerting is a genuinely separate service from api (its own base URL),
so this needed the provider to talk to more than one Sentry service for
the first time: providerData now wraps two *client instances (api,
alerting), with a new alerting_endpoint provider attribute defaulting
the same way sentryctl's --alerting-api/$SENTRYCTL_ALERTING_API_URL
does. dashboardResource's Configure updated to pull .api out of the new
wrapper type instead of a bare *client.
Schema mirrors sentry_dashboard's established pattern: comparator/
threshold_value/renotify_interval_minutes stay nullable (only meaningful
for threshold-condition rules), enabled/for_minutes/query_language are
Optional+Computed with a Terraform-side default matching the API's own
default (true/0/"") rather than leaving the API as sole source of truth
the way dashboard's default_earliest/default_latest deliberately do --
these three have no *pointer* type in the API's Rule struct, so their
"default when omitted" is unconditional, not a real API-side default
that could drift independently.
Verified: client tests are real httptest.Server round trips (same
pattern as sentry_dashboard's). Schema validation needs no Terraform
binary. TestAccAlertRuleResource_basic is a real acceptance test,
skip-gated by TF_ACC same as the dashboard one, including a
plancheck.ExpectResourceAction assertion that a config change actually
plans destroy-then-create -- the concrete, checked version of the
"create/destroy only" design decision, not just a comment. Not run
against a live stack in this environment, same disclosed gap as
everything else Docker-gated in this repo.
This commit is contained in:
+81
-28
@@ -3,25 +3,30 @@
|
||||
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. This is a first
|
||||
slice, not a finished provider: one resource (`sentry_dashboard`), 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).
|
||||
no phase before this one had actually built any of it. Two resources so
|
||||
far (`sentry_dashboard`, `sentry_alert_rule`), 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 `sentry_dashboard` first
|
||||
## Why these two resources first
|
||||
|
||||
`cli/README.md` already frames the underlying REST contract this way:
|
||||
`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.
|
||||
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.
|
||||
|
||||
## What's built
|
||||
|
||||
@@ -62,19 +67,57 @@ 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.
|
||||
|
||||
```hcl
|
||||
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_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 = "target-abc123" # must already exist -- see "Also not built" below
|
||||
}
|
||||
```
|
||||
|
||||
Supports `terraform import sentry_alert_rule.example <rule-id>`.
|
||||
|
||||
**`sentry_alert_rule` is create/destroy only, not update-in-place.**
|
||||
`alerting`'s REST API has no `PUT /rules/{id}` at all -- confirmed down
|
||||
to `rulestore.Store`, which has `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 this resource carries a `RequiresReplace` plan modifier, so
|
||||
changing anything (the query, the threshold, even just `description`)
|
||||
destroys and recreates the rule rather than updating it -- which also
|
||||
resets `alert_state`/delivery-log continuity for that rule, 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 the resource itself 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 a
|
||||
real `PUT /rules/{id}` to `alerting` would remove this constraint, but
|
||||
is a change to a different module's REST API, out of scope for this
|
||||
pass.
|
||||
|
||||
**Also not built, all real and disclosed, not attempted here:**
|
||||
- Alert rules / notification targets (`/alerting`'s REST surface --
|
||||
`POST /rules`, etc.) -- a second provider "family" of resources, no
|
||||
code shared with dashboards beyond this same client-pattern
|
||||
discipline.
|
||||
- Notification targets (`/alerting`'s `POST`/`GET`/`DELETE /targets`) --
|
||||
a `sentry_alert_rule`'s `notification_target_id` has to name a target
|
||||
created some other way (`sentryctl`, `curl`, the web UI) until this
|
||||
exists.
|
||||
- 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."
|
||||
- A `sentry_dashboard` data source (read-only lookup by ID/name) --
|
||||
straightforward given the resource already exists, just not built
|
||||
- Data sources (read-only lookup by ID/name) for either resource --
|
||||
straightforward given the resources already exist, just not built
|
||||
yet.
|
||||
- Publishing to the real Terraform Registry -- `main.go`'s `Address`
|
||||
(`registry.terraform.io/sentry/sentry`) is the address a real
|
||||
@@ -92,29 +135,39 @@ 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` endpoints) -- real request
|
||||
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.
|
||||
`internal/provider/provider_test.go` validates the provider and
|
||||
out-of-band" convention correctly, and (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.
|
||||
`internal/provider/provider_test.go` validates the provider and both
|
||||
resource schemas are internally well-formed (attribute names, the
|
||||
`Required`/`Computed` split) without needing a Terraform binary or a
|
||||
live `api` service at all.
|
||||
live `api`/`alerting` service at all.
|
||||
|
||||
`internal/provider/dashboard_resource_test.go`'s
|
||||
`TestAccDashboardResource_basic` is a real acceptance test using
|
||||
`TestAccDashboardResource_basic` and
|
||||
`internal/provider/alert_rule_resource_test.go`'s
|
||||
`TestAccAlertRuleResource_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). Even with `TF_ACC=1` it
|
||||
also needs a real running `api` service (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."
|
||||
vars for Postgres/ClickHouse tests elsewhere). The alert rule test also
|
||||
uses 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` both 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
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
terraform import sentry_alert_rule.checkout_5xx <rule-id>
|
||||
@@ -0,0 +1,19 @@
|
||||
# notification_target_id has to name an already-existing target --
|
||||
# no sentry_notification_target resource exists yet (see the provider
|
||||
# README), so create one via sentryctl/curl/the web UI first and pass
|
||||
# its id in here.
|
||||
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
|
||||
for_minutes = 5
|
||||
notification_target_id = "target-abc123"
|
||||
}
|
||||
|
||||
# Create/destroy only -- alerting has no PUT /rules/{id} today, so
|
||||
# changing any attribute above (including this resource block itself)
|
||||
# destroys and recreates the rule rather than updating it in place. See
|
||||
# the provider README for why.
|
||||
@@ -0,0 +1,315 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/hashicorp/terraform-plugin-framework/path"
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource"
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault"
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier"
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource/schema/float64planmodifier"
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default"
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier"
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault"
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
|
||||
"github.com/hashicorp/terraform-plugin-framework/types"
|
||||
)
|
||||
|
||||
var (
|
||||
_ resource.Resource = &alertRuleResource{}
|
||||
_ resource.ResourceWithConfigure = &alertRuleResource{}
|
||||
_ resource.ResourceWithImportState = &alertRuleResource{}
|
||||
)
|
||||
|
||||
func newAlertRuleResource() resource.Resource {
|
||||
return &alertRuleResource{}
|
||||
}
|
||||
|
||||
// alertRuleResource implements sentry_alert_rule against
|
||||
// alerting/internal/httpapi's POST/GET/DELETE /rules[/{id}] endpoints.
|
||||
//
|
||||
// Deliberately create/destroy only, every attribute RequiresReplace:
|
||||
// alerting has no PUT /rules/{id} at all -- confirmed down to
|
||||
// rulestore.Store, which has Create/List/Get/Delete but no Update
|
||||
// method to even wire one to, a real pre-existing gap in alerting's own
|
||||
// API. Faking an in-place update via delete-then-recreate inside this
|
||||
// resource was considered and rejected -- it would silently reset
|
||||
// alert_state/delivery-log continuity a real operator might care about,
|
||||
// a behavioral side effect this resource shouldn't paper over. See the
|
||||
// provider README for the full reasoning and the option (adding a real
|
||||
// PUT /rules/{id} to alerting) that would remove this constraint.
|
||||
type alertRuleResource struct {
|
||||
client *client
|
||||
}
|
||||
|
||||
type alertRuleResourceModel struct {
|
||||
ID types.String `tfsdk:"id"`
|
||||
TenantID types.String `tfsdk:"tenant_id"`
|
||||
Name types.String `tfsdk:"name"`
|
||||
Description types.String `tfsdk:"description"`
|
||||
Query types.String `tfsdk:"query"`
|
||||
QueryLanguage types.String `tfsdk:"query_language"`
|
||||
ConditionType types.String `tfsdk:"condition_type"`
|
||||
Comparator types.String `tfsdk:"comparator"`
|
||||
ThresholdValue types.Float64 `tfsdk:"threshold_value"`
|
||||
EvalIntervalSeconds types.Int64 `tfsdk:"eval_interval_seconds"`
|
||||
ForMinutes types.Int64 `tfsdk:"for_minutes"`
|
||||
RenotifyIntervalMinutes types.Int64 `tfsdk:"renotify_interval_minutes"`
|
||||
NotificationTargetID types.String `tfsdk:"notification_target_id"`
|
||||
Enabled types.Bool `tfsdk:"enabled"`
|
||||
CreatedBy types.String `tfsdk:"created_by"`
|
||||
}
|
||||
|
||||
func (r *alertRuleResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
|
||||
resp.TypeName = req.ProviderTypeName + "_alert_rule"
|
||||
}
|
||||
|
||||
func (r *alertRuleResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
|
||||
replace := []planmodifier.String{stringplanmodifier.RequiresReplace()}
|
||||
resp.Schema = schema.Schema{
|
||||
Description: "A Sentry alert rule. Create/destroy only -- alerting has no update endpoint for " +
|
||||
"rules today (see this resource's Go doc comment), so every attribute below forces a " +
|
||||
"destroy-and-recreate on change, never an in-place update.",
|
||||
Attributes: map[string]schema.Attribute{
|
||||
"id": schema.StringAttribute{
|
||||
Computed: true,
|
||||
PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()},
|
||||
Description: "Server-generated rule ID.",
|
||||
},
|
||||
"tenant_id": schema.StringAttribute{
|
||||
Computed: true,
|
||||
PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()},
|
||||
},
|
||||
"name": schema.StringAttribute{
|
||||
Required: true,
|
||||
PlanModifiers: replace,
|
||||
Description: "Rule name. The API rejects an empty string.",
|
||||
},
|
||||
"description": schema.StringAttribute{
|
||||
Optional: true,
|
||||
Computed: true,
|
||||
Default: stringdefault.StaticString(""),
|
||||
PlanModifiers: replace,
|
||||
},
|
||||
"query": schema.StringAttribute{
|
||||
Required: true,
|
||||
PlanModifiers: replace,
|
||||
Description: "Pipe-syntax or SQL query text (see query_language) -- the same query the evaluator re-runs on every eval_interval_seconds tick. The API rejects an empty string.",
|
||||
},
|
||||
"query_language": schema.StringAttribute{
|
||||
Optional: true,
|
||||
Computed: true,
|
||||
Default: stringdefault.StaticString(""),
|
||||
PlanModifiers: replace,
|
||||
Description: `"" (auto-detect), "sql", or "spl" -- same values the query API itself accepts.`,
|
||||
},
|
||||
"condition_type": schema.StringAttribute{
|
||||
Required: true,
|
||||
PlanModifiers: replace,
|
||||
Description: `"threshold" (requires comparator + threshold_value) or "absence" (fires when the query returns zero rows).`,
|
||||
},
|
||||
"comparator": schema.StringAttribute{
|
||||
Optional: true,
|
||||
PlanModifiers: replace,
|
||||
Description: `Required (and only meaningful) when condition_type = "threshold": one of "gt", "gte", "lt", "lte", "eq", "ne".`,
|
||||
},
|
||||
"threshold_value": schema.Float64Attribute{
|
||||
Optional: true,
|
||||
PlanModifiers: []planmodifier.Float64{float64planmodifier.RequiresReplace()},
|
||||
Description: `Required (and only meaningful) when condition_type = "threshold".`,
|
||||
},
|
||||
"eval_interval_seconds": schema.Int64Attribute{
|
||||
Required: true,
|
||||
PlanModifiers: []planmodifier.Int64{int64planmodifier.RequiresReplace()},
|
||||
Description: "How often the evaluator re-runs this rule's query. The API rejects anything below 30.",
|
||||
},
|
||||
"for_minutes": schema.Int64Attribute{
|
||||
Optional: true,
|
||||
Computed: true,
|
||||
Default: int64default.StaticInt64(0),
|
||||
PlanModifiers: []planmodifier.Int64{int64planmodifier.RequiresReplace()},
|
||||
Description: "How long the condition must stay true before the rule transitions from pending to firing. 0 means fire immediately on the first true evaluation.",
|
||||
},
|
||||
"renotify_interval_minutes": schema.Int64Attribute{
|
||||
Optional: true,
|
||||
PlanModifiers: []planmodifier.Int64{int64planmodifier.RequiresReplace()},
|
||||
Description: "How often to re-send a notification while still firing. Unset means notify once per firing transition only.",
|
||||
},
|
||||
"notification_target_id": schema.StringAttribute{
|
||||
Required: true,
|
||||
PlanModifiers: replace,
|
||||
Description: "ID of a sentry_notification_target-managed (or manually created) notification target. The API rejects an empty string. No sentry_notification_target resource exists yet -- see the provider README -- so this has to be a target created some other way (sentryctl, curl, or the web UI) for now.",
|
||||
},
|
||||
"enabled": schema.BoolAttribute{
|
||||
Optional: true,
|
||||
Computed: true,
|
||||
Default: booldefault.StaticBool(true),
|
||||
PlanModifiers: []planmodifier.Bool{boolplanmodifier.RequiresReplace()},
|
||||
Description: "Whether the evaluator considers this rule at all. Defaults to true, matching POST /rules' own default when the field is omitted.",
|
||||
},
|
||||
"created_by": schema.StringAttribute{
|
||||
Computed: true,
|
||||
PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (r *alertRuleResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
|
||||
if req.ProviderData == nil {
|
||||
return
|
||||
}
|
||||
data, ok := req.ProviderData.(*providerData)
|
||||
if !ok {
|
||||
resp.Diagnostics.AddError(
|
||||
"Unexpected Resource Configure Type",
|
||||
fmt.Sprintf("Expected *provider.providerData, got: %T. This is a provider bug -- please report it.", req.ProviderData),
|
||||
)
|
||||
return
|
||||
}
|
||||
r.client = data.alerting
|
||||
}
|
||||
|
||||
func alertRuleModelFromAPI(rl *rule) alertRuleResourceModel {
|
||||
m := alertRuleResourceModel{
|
||||
ID: types.StringValue(rl.ID),
|
||||
TenantID: types.StringValue(rl.TenantID),
|
||||
Name: types.StringValue(rl.Name),
|
||||
Description: types.StringValue(rl.Description),
|
||||
Query: types.StringValue(rl.Query),
|
||||
QueryLanguage: types.StringValue(rl.QueryLanguage),
|
||||
ConditionType: types.StringValue(rl.ConditionType),
|
||||
EvalIntervalSeconds: types.Int64Value(int64(rl.EvalIntervalSeconds)),
|
||||
ForMinutes: types.Int64Value(int64(rl.ForMinutes)),
|
||||
NotificationTargetID: types.StringValue(rl.NotificationTargetID),
|
||||
CreatedBy: types.StringValue(rl.CreatedBy),
|
||||
}
|
||||
if rl.Comparator != nil {
|
||||
m.Comparator = types.StringValue(*rl.Comparator)
|
||||
}
|
||||
if rl.ThresholdValue != nil {
|
||||
m.ThresholdValue = types.Float64Value(*rl.ThresholdValue)
|
||||
}
|
||||
if rl.RenotifyIntervalMinutes != nil {
|
||||
m.RenotifyIntervalMinutes = types.Int64Value(int64(*rl.RenotifyIntervalMinutes))
|
||||
}
|
||||
// Enabled always comes back set from the API (Rule.Enabled is a
|
||||
// plain bool, not a pointer, in rulestore -- see store.go) --
|
||||
// unlike Comparator/ThresholdValue/RenotifyIntervalMinutes above,
|
||||
// this is never legitimately null in a response.
|
||||
enabled := true
|
||||
if rl.Enabled != nil {
|
||||
enabled = *rl.Enabled
|
||||
}
|
||||
m.Enabled = types.BoolValue(enabled)
|
||||
return m
|
||||
}
|
||||
|
||||
func alertRuleAPIFromModel(m alertRuleResourceModel) *rule {
|
||||
rl := &rule{
|
||||
Name: m.Name.ValueString(),
|
||||
Description: m.Description.ValueString(),
|
||||
Query: m.Query.ValueString(),
|
||||
QueryLanguage: m.QueryLanguage.ValueString(),
|
||||
ConditionType: m.ConditionType.ValueString(),
|
||||
EvalIntervalSeconds: int(m.EvalIntervalSeconds.ValueInt64()),
|
||||
ForMinutes: int(m.ForMinutes.ValueInt64()),
|
||||
NotificationTargetID: m.NotificationTargetID.ValueString(),
|
||||
}
|
||||
if !m.Comparator.IsNull() {
|
||||
v := m.Comparator.ValueString()
|
||||
rl.Comparator = &v
|
||||
}
|
||||
if !m.ThresholdValue.IsNull() {
|
||||
v := m.ThresholdValue.ValueFloat64()
|
||||
rl.ThresholdValue = &v
|
||||
}
|
||||
if !m.RenotifyIntervalMinutes.IsNull() {
|
||||
v := m.RenotifyIntervalMinutes.ValueInt64()
|
||||
vInt := int(v)
|
||||
rl.RenotifyIntervalMinutes = &vInt
|
||||
}
|
||||
if !m.Enabled.IsNull() {
|
||||
v := m.Enabled.ValueBool()
|
||||
rl.Enabled = &v
|
||||
}
|
||||
return rl
|
||||
}
|
||||
|
||||
func (r *alertRuleResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
|
||||
var plan alertRuleResourceModel
|
||||
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
out, err := r.client.createRule(ctx, alertRuleAPIFromModel(plan))
|
||||
if err != nil {
|
||||
resp.Diagnostics.AddError("Creating Alert Rule", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp.Diagnostics.Append(resp.State.Set(ctx, alertRuleModelFromAPI(out))...)
|
||||
}
|
||||
|
||||
func (r *alertRuleResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
|
||||
var state alertRuleResourceModel
|
||||
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
out, err := r.client.getRule(ctx, state.ID.ValueString())
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
resp.State.RemoveResource(ctx)
|
||||
return
|
||||
}
|
||||
resp.Diagnostics.AddError("Reading Alert Rule", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp.Diagnostics.Append(resp.State.Set(ctx, alertRuleModelFromAPI(out))...)
|
||||
}
|
||||
|
||||
// Update should be unreachable in practice: every non-Computed
|
||||
// attribute above carries RequiresReplace, so a real config change
|
||||
// always plans a destroy-and-recreate instead of an in-place update.
|
||||
// Still required to satisfy resource.Resource's interface -- implemented
|
||||
// as a safe passthrough (just re-read the current server state into the
|
||||
// plan's ID) rather than calling any mutating endpoint, since alerting
|
||||
// has no PUT /rules/{id} to call in the first place.
|
||||
func (r *alertRuleResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
|
||||
var plan alertRuleResourceModel
|
||||
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
out, err := r.client.getRule(ctx, plan.ID.ValueString())
|
||||
if err != nil {
|
||||
resp.Diagnostics.AddError("Reading Alert Rule", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp.Diagnostics.Append(resp.State.Set(ctx, alertRuleModelFromAPI(out))...)
|
||||
}
|
||||
|
||||
func (r *alertRuleResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
|
||||
var state alertRuleResourceModel
|
||||
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
|
||||
if resp.Diagnostics.HasError() {
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.client.deleteRule(ctx, state.ID.ValueString()); err != nil && !isNotFound(err) {
|
||||
resp.Diagnostics.AddError("Deleting Alert Rule", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (r *alertRuleResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
|
||||
resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
|
||||
"github.com/hashicorp/terraform-plugin-testing/plancheck"
|
||||
)
|
||||
|
||||
// Same skip-gated-not-faked posture as TestAccDashboardResource_basic --
|
||||
// see that test's doc comment. notification_target_id below is a
|
||||
// placeholder: no sentry_notification_target resource exists yet (see
|
||||
// the provider README), so a real run of this test would need a
|
||||
// pre-existing target id supplied some other way; not a blocker for
|
||||
// what this test actually proves, since it has never run against a
|
||||
// live stack in this environment regardless.
|
||||
func TestAccAlertRuleResource_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 = "Acceptance 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"
|
||||
}
|
||||
`,
|
||||
Check: resource.ComposeAggregateTestCheckFunc(
|
||||
resource.TestCheckResourceAttr("sentry_alert_rule.test", "name", "Acceptance Test Rule"),
|
||||
resource.TestCheckResourceAttr("sentry_alert_rule.test", "comparator", "gt"),
|
||||
resource.TestCheckResourceAttr("sentry_alert_rule.test", "threshold_value", "5"),
|
||||
resource.TestCheckResourceAttrSet("sentry_alert_rule.test", "id"),
|
||||
resource.TestCheckResourceAttrSet("sentry_alert_rule.test", "tenant_id"),
|
||||
// Left unset in config -- must come back as the
|
||||
// server's own default (true), same "API default,
|
||||
// not a duplicated Terraform-side one" reasoning
|
||||
// sentry_dashboard's default_earliest/default_latest
|
||||
// use.
|
||||
resource.TestCheckResourceAttr("sentry_alert_rule.test", "enabled", "true"),
|
||||
resource.TestCheckResourceAttr("sentry_alert_rule.test", "for_minutes", "0"),
|
||||
),
|
||||
},
|
||||
{
|
||||
// Proves the "create/destroy only" design decision is
|
||||
// real, not just documented: alerting has no PUT
|
||||
// /rules/{id}, so every attribute is RequiresReplace,
|
||||
// and changing one (here, the threshold) must plan a
|
||||
// destroy-then-create, never an in-place update.
|
||||
Config: `
|
||||
provider "sentry" {
|
||||
endpoint = "http://localhost:8080"
|
||||
alerting_endpoint = "http://localhost:8081"
|
||||
}
|
||||
|
||||
resource "sentry_alert_rule" "test" {
|
||||
name = "Acceptance Test Rule"
|
||||
query = "status>=500 | stats count"
|
||||
condition_type = "threshold"
|
||||
comparator = "gt"
|
||||
threshold_value = 10
|
||||
eval_interval_seconds = 60
|
||||
notification_target_id = "placeholder-target-id"
|
||||
}
|
||||
`,
|
||||
ConfigPlanChecks: resource.ConfigPlanChecks{
|
||||
PreApply: []plancheck.PlanCheck{
|
||||
plancheck.ExpectResourceAction("sentry_alert_rule.test", plancheck.ResourceActionDestroyBeforeCreate),
|
||||
},
|
||||
},
|
||||
Check: resource.TestCheckResourceAttr("sentry_alert_rule.test", "threshold_value", "10"),
|
||||
},
|
||||
{
|
||||
ResourceName: "sentry_alert_rule.test",
|
||||
ImportState: true,
|
||||
ImportStateVerify: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -141,3 +141,62 @@ func (c *client) updateDashboard(ctx context.Context, id string, d *dashboard) (
|
||||
func (c *client) deleteDashboard(ctx context.Context, id string) error {
|
||||
return c.do(ctx, http.MethodDelete, "/dashboards/"+id, nil, nil)
|
||||
}
|
||||
|
||||
// rule mirrors alerting/internal/rulestore.Rule's JSON shape, plus the
|
||||
// request-only `enabled` field POST /rules accepts
|
||||
// (httpapi.createRuleRequest embeds rulestore.Rule and adds this
|
||||
// pointer specifically so "omitted" (defaults to enabled) and
|
||||
// "explicitly false" are distinguishable -- see handleCreateRule's doc
|
||||
// comment) -- deliberately a local type, not an import of either
|
||||
// package, same "talk HTTP, not Go imports, to a service that isn't
|
||||
// yours" posture as dashboard above. GET/POST /rules both return this
|
||||
// shape flattened (no separate "state" wrapper needed here since this
|
||||
// resource doesn't manage or expose alert_state -- see the provider
|
||||
// README on why).
|
||||
type rule struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
TenantID string `json:"tenant_id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Query string `json:"query"`
|
||||
QueryLanguage string `json:"query_language"`
|
||||
ConditionType string `json:"condition_type"`
|
||||
Comparator *string `json:"comparator,omitempty"`
|
||||
ThresholdValue *float64 `json:"threshold_value,omitempty"`
|
||||
EvalIntervalSeconds int `json:"eval_interval_seconds"`
|
||||
ForMinutes int `json:"for_minutes"`
|
||||
RenotifyIntervalMinutes *int `json:"renotify_interval_minutes,omitempty"`
|
||||
NotificationTargetID string `json:"notification_target_id"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
CreatedBy string `json:"created_by,omitempty"`
|
||||
}
|
||||
|
||||
// createRule and getRule are the only mutating/reading calls this
|
||||
// client makes against /rules -- there is deliberately no updateRule:
|
||||
// alerting/internal/httpapi has no PUT /rules/{id} at all (confirmed
|
||||
// down to rulestore.Store, which has 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 this provider works around by
|
||||
// faking an update via delete+recreate under the hood. alertRuleResource
|
||||
// models this honestly: every attribute is RequiresReplace, so
|
||||
// Terraform destroys and recreates on any change rather than pretending
|
||||
// an in-place update exists.
|
||||
func (c *client) createRule(ctx context.Context, r *rule) (*rule, error) {
|
||||
var out rule
|
||||
if err := c.do(ctx, http.MethodPost, "/rules", r, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (c *client) getRule(ctx context.Context, id string) (*rule, error) {
|
||||
var out rule
|
||||
if err := c.do(ctx, http.MethodGet, "/rules/"+id, nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (c *client) deleteRule(ctx context.Context, id string) error {
|
||||
return c.do(ctx, http.MethodDelete, "/rules/"+id, nil, nil)
|
||||
}
|
||||
|
||||
@@ -150,3 +150,94 @@ func TestApiErrorSurfacesPlainTextBodyWhenNotJSON(t *testing.T) {
|
||||
t.Fatalf("err = %v, want it to surface the plain-text body", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRuleSendsExpectedRequest(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/rules" {
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
var body rule
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decoding request body: %v", err)
|
||||
}
|
||||
if body.Name != "High Error Rate" || body.ConditionType != "threshold" {
|
||||
t.Errorf("unexpected request body: %+v", body)
|
||||
}
|
||||
if body.Comparator == nil || *body.Comparator != "gt" {
|
||||
t.Errorf("Comparator = %v, want gt", body.Comparator)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
comparator := "gt"
|
||||
threshold := 5.0
|
||||
_ = json.NewEncoder(w).Encode(rule{
|
||||
ID: "rule-1", TenantID: "acme", Name: body.Name,
|
||||
ConditionType: "threshold", Comparator: &comparator, ThresholdValue: &threshold,
|
||||
EvalIntervalSeconds: 60, NotificationTargetID: "target-1",
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
comparator := "gt"
|
||||
threshold := 5.0
|
||||
c := newClient(srv.URL, "")
|
||||
out, err := c.createRule(context.Background(), &rule{
|
||||
Name: "High Error Rate", Query: "status>=500 | stats count", ConditionType: "threshold",
|
||||
Comparator: &comparator, ThresholdValue: &threshold,
|
||||
EvalIntervalSeconds: 60, NotificationTargetID: "target-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("createRule: %v", err)
|
||||
}
|
||||
if out.ID != "rule-1" || out.EvalIntervalSeconds != 60 {
|
||||
t.Fatalf("unexpected response: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRuleParsesFlattenedRuleWithStateResponse(t *testing.T) {
|
||||
// alerting/internal/httpapi's GET /rules/{id} returns
|
||||
// rulestore.RuleWithState -- Rule's fields promoted to the top
|
||||
// level via anonymous embedding, plus a "state" object this
|
||||
// client's rule type deliberately has no field for (see client.go's
|
||||
// doc comment). This test proves that extra "state" key doesn't
|
||||
// break parsing the fields this provider does care about.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"id": "rule-1", "tenant_id": "acme", "name": "High Error Rate",
|
||||
"condition_type": "threshold", "comparator": "gt", "threshold_value": 5,
|
||||
"eval_interval_seconds": 60, "notification_target_id": "target-1", "enabled": true,
|
||||
"state": {"rule_id": "rule-1", "state": "ok", "last_eval_status": "ok", "consecutive_errors": 0}
|
||||
}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := newClient(srv.URL, "")
|
||||
out, err := c.getRule(context.Background(), "rule-1")
|
||||
if err != nil {
|
||||
t.Fatalf("getRule: %v", err)
|
||||
}
|
||||
if out.Name != "High Error Rate" || out.Comparator == nil || *out.Comparator != "gt" {
|
||||
t.Fatalf("unexpected response: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteRuleSendsToCorrectPath(t *testing.T) {
|
||||
called := false
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
if r.Method != http.MethodDelete || r.URL.Path != "/rules/rule-1" {
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := newClient(srv.URL, "")
|
||||
if err := c.deleteRule(context.Background(), "rule-1"); err != nil {
|
||||
t.Fatalf("deleteRule: %v", err)
|
||||
}
|
||||
if !called {
|
||||
t.Fatal("expected the server to receive a DELETE request")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,15 +111,15 @@ func (r *dashboardResource) Configure(_ context.Context, req resource.ConfigureR
|
||||
if req.ProviderData == nil {
|
||||
return
|
||||
}
|
||||
c, ok := req.ProviderData.(*client)
|
||||
data, ok := req.ProviderData.(*providerData)
|
||||
if !ok {
|
||||
resp.Diagnostics.AddError(
|
||||
"Unexpected Resource Configure Type",
|
||||
fmt.Sprintf("Expected *provider.client, got: %T. This is a provider bug -- please report it.", req.ProviderData),
|
||||
fmt.Sprintf("Expected *provider.providerData, got: %T. This is a provider bug -- please report it.", req.ProviderData),
|
||||
)
|
||||
return
|
||||
}
|
||||
r.client = c
|
||||
r.client = data.api
|
||||
}
|
||||
|
||||
func dashboardModelFromAPI(d *dashboard) dashboardResourceModel {
|
||||
|
||||
@@ -33,8 +33,21 @@ type sentryProvider struct {
|
||||
}
|
||||
|
||||
type sentryProviderModel struct {
|
||||
Endpoint types.String `tfsdk:"endpoint"`
|
||||
Token types.String `tfsdk:"token"`
|
||||
Endpoint types.String `tfsdk:"endpoint"`
|
||||
AlertingEndpoint types.String `tfsdk:"alerting_endpoint"`
|
||||
Token types.String `tfsdk:"token"`
|
||||
}
|
||||
|
||||
// providerData is what Configure hands resources/data sources via
|
||||
// req.ProviderData -- two separate clients, not one, because `alerting`
|
||||
// is a genuinely separate service with its own base URL (its own
|
||||
// REST API, its own port, sometimes its own deployment) -- same split
|
||||
// web/src/lib/api.ts's apiBase/alertingBase and cli/cmd/sentryctl's
|
||||
// --api/--alerting-api already draw, not something invented for this
|
||||
// provider.
|
||||
type providerData struct {
|
||||
api *client
|
||||
alerting *client
|
||||
}
|
||||
|
||||
func (p *sentryProvider) Metadata(_ context.Context, _ provider.MetadataRequest, resp *provider.MetadataResponse) {
|
||||
@@ -44,7 +57,7 @@ func (p *sentryProvider) Metadata(_ context.Context, _ provider.MetadataRequest,
|
||||
|
||||
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.",
|
||||
Description: "Manages Sentry log-aggregation-platform resources. Dashboards and alert rules for now -- 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,
|
||||
@@ -52,6 +65,14 @@ func (p *sentryProvider) Schema(_ context.Context, _ provider.SchemaRequest, res
|
||||
"$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).",
|
||||
},
|
||||
"alerting_endpoint": schema.StringAttribute{
|
||||
Optional: true,
|
||||
Description: "Base URL of the alerting service, e.g. \"http://localhost:8081\" -- a " +
|
||||
"separate service from api, not a path under endpoint above (see " +
|
||||
"/docs/phase-3-alerting-design.md's component boundary). Defaults to " +
|
||||
"$SENTRY_ALERTING_API_ENDPOINT, or \"http://localhost:8081\" if that's unset too -- " +
|
||||
"same default sentryctl's --alerting-api/$SENTRYCTL_ALERTING_API_URL uses.",
|
||||
},
|
||||
"token": schema.StringAttribute{
|
||||
Optional: true,
|
||||
Sensitive: true,
|
||||
@@ -84,19 +105,31 @@ func (p *sentryProvider) Configure(ctx context.Context, req provider.ConfigureRe
|
||||
endpoint = "http://localhost:8080"
|
||||
}
|
||||
|
||||
alertingEndpoint := config.AlertingEndpoint.ValueString()
|
||||
if alertingEndpoint == "" {
|
||||
alertingEndpoint = os.Getenv("SENTRY_ALERTING_API_ENDPOINT")
|
||||
}
|
||||
if alertingEndpoint == "" {
|
||||
alertingEndpoint = "http://localhost:8081"
|
||||
}
|
||||
|
||||
token := config.Token.ValueString()
|
||||
if token == "" {
|
||||
token = os.Getenv("SENTRY_API_TOKEN")
|
||||
}
|
||||
|
||||
c := newClient(endpoint, token)
|
||||
resp.DataSourceData = c
|
||||
resp.ResourceData = c
|
||||
data := &providerData{
|
||||
api: newClient(endpoint, token),
|
||||
alerting: newClient(alertingEndpoint, token),
|
||||
}
|
||||
resp.DataSourceData = data
|
||||
resp.ResourceData = data
|
||||
}
|
||||
|
||||
func (p *sentryProvider) Resources(_ context.Context) []func() resource.Resource {
|
||||
return []func() resource.Resource{
|
||||
newDashboardResource,
|
||||
newAlertRuleResource,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ func TestProviderSchemaValid(t *testing.T) {
|
||||
if resp.Diagnostics.HasError() {
|
||||
t.Fatalf("provider schema has errors: %v", resp.Diagnostics)
|
||||
}
|
||||
for _, attr := range []string{"endpoint", "token"} {
|
||||
for _, attr := range []string{"endpoint", "alerting_endpoint", "token"} {
|
||||
if _, ok := resp.Schema.Attributes[attr]; !ok {
|
||||
t.Errorf("provider schema missing expected attribute %q", attr)
|
||||
}
|
||||
@@ -64,3 +64,38 @@ func TestDashboardResourceMetadataSetsTypeName(t *testing.T) {
|
||||
t.Fatalf("TypeName = %q, want sentry_dashboard", resp.TypeName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertRuleResourceSchemaValid(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
req := resource.SchemaRequest{}
|
||||
resp := &resource.SchemaResponse{}
|
||||
|
||||
newAlertRuleResource().Schema(ctx, req, resp)
|
||||
|
||||
if resp.Diagnostics.HasError() {
|
||||
t.Fatalf("sentry_alert_rule schema has errors: %v", resp.Diagnostics)
|
||||
}
|
||||
for _, attr := range []string{
|
||||
"id", "tenant_id", "name", "description", "query", "query_language",
|
||||
"condition_type", "comparator", "threshold_value", "eval_interval_seconds",
|
||||
"for_minutes", "renotify_interval_minutes", "notification_target_id", "enabled", "created_by",
|
||||
} {
|
||||
if _, ok := resp.Schema.Attributes[attr]; !ok {
|
||||
t.Errorf("sentry_alert_rule schema missing expected attribute %q", attr)
|
||||
}
|
||||
}
|
||||
if !resp.Schema.Attributes["name"].IsRequired() {
|
||||
t.Error(`"name" must be Required`)
|
||||
}
|
||||
if !resp.Schema.Attributes["id"].IsComputed() {
|
||||
t.Error(`"id" must be Computed`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertRuleResourceMetadataSetsTypeName(t *testing.T) {
|
||||
resp := &resource.MetadataResponse{}
|
||||
newAlertRuleResource().Metadata(context.Background(), resource.MetadataRequest{ProviderTypeName: "sentry"}, resp)
|
||||
if resp.TypeName != "sentry_alert_rule" {
|
||||
t.Fatalf("TypeName = %q, want sentry_alert_rule", resp.TypeName)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user