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:
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user