diff --git a/alert.go b/alert.go new file mode 100644 index 0000000..4fe0a33 --- /dev/null +++ b/alert.go @@ -0,0 +1,410 @@ +package api + +import ( + "context" + "strconv" + "strings" + "time" + "unicode/utf8" + + "github.com/moonrhythm/validator" +) + +// Alert manages a project's metric alert rules: "when deployment CPU >= 90% for +// 10 minutes, notify." A rule is a single condition on one metric of one +// deployment; the target carries the location (like Notification carries its +// delivery config), so a rule is addressed by (project, name) like an env group +// or a scheduler job — location-less at the resource level, location-bound only +// inside Target. +// +// Rules are evaluated by an apiserver cron tick (outside this package) against +// the existing per-minute deployment_usages table; there is no separate metrics +// backend for v1. Evaluation is stateless per tick over a rolling window of the +// last Condition.ForMinutes buckets, and produces one of three states: "ok" +// (condition not met), "firing" (condition held for the full window), or +// "nodata" (too few buckets present — deployment paused/deleted, or no limit +// set for a percent metric). State transitions (ok/nodata -> firing, firing -> +// ok) enqueue "alert.trigger"/"alert.resolve" notification events (see +// Notification); a still-firing rule re-notifies every RenotifyMinutes when +// set. Notification delivery reuses the notification-channels feature +// entirely — a rule carries no delivery config of its own. +// +// Rule config changes (Create/Update/Delete) go through the normal audit/change +// path like every other resource, so a channel subscribed to "alert.*" also +// sees config edits alongside trigger/resolve events. The trigger/resolve +// transitions themselves are evaluator telemetry, not user actions, and are not +// audited (mirrors deployment.health). +// +// Existence of Target.Deployment is checked at Create/Update time but a rule is +// not FK-bound to it: deleting and recreating the deployment keeps the rule, +// which simply reports "nodata" while the deployment is gone (matches how +// routes behave). +type Alert interface { + // Create requires the `alert.create` permission. + Create(ctx context.Context, m *AlertCreate) (*Empty, error) + // Update requires the `alert.update` permission. + Update(ctx context.Context, m *AlertUpdate) (*Empty, error) + // Get requires the `alert.get` permission. + Get(ctx context.Context, m *AlertGet) (*AlertItem, error) + // List requires the `alert.list` permission. + List(ctx context.Context, m *AlertList) (*AlertListResult, error) + // Delete requires the `alert.delete` permission. + Delete(ctx context.Context, m *AlertDelete) (*Empty, error) + // Events lists a rule's recent state transitions, newest first — the + // history feed for the alert detail page. Requires the `alert.get` + // permission. + Events(ctx context.Context, m *AlertEvents) (*AlertEventsResult, error) +} + +// AlertTarget identifies what a rule watches. Location is required in v1 +// (kind=deployment is implicit; Phase 2 adds a Kind field for custom-metric +// targets, which is why Condition/Target are kept flat and additive rather than +// nested further). +type AlertTarget struct { + Location string `json:"location" yaml:"location"` + Deployment string `json:"deployment" yaml:"deployment"` +} + +// AlertCondition is the single metric condition a rule evaluates. Op defaults +// to ">=" when left empty. Threshold's unit depends on Metric (see +// AlertMetrics): percent 0-100 for cpu/memory (usage as a share of the +// deployment's limit, allowed above 100% since limits can be briefly +// overcommitted), req/min for requests, or bytes/min for egress. ForMinutes is +// how long the condition must hold continuously, evaluated as a rolling +// window (1..60 minutes). +type AlertCondition struct { + Metric string `json:"metric" yaml:"metric"` + Op string `json:"op" yaml:"op"` // ">=" or "<="; default ">=" on empty + Threshold float64 `json:"threshold" yaml:"threshold"` + ForMinutes int `json:"forMinutes" yaml:"forMinutes"` +} + +// Metric vocabulary (v1). See the SPEC for the backing deployment_usages series +// and bucket aggregation each metric uses. +const ( + AlertMetricCPU = "cpu" // % of limit, avg across pods + AlertMetricMemory = "memory" // % of limit, avg across pods + AlertMetricRequests = "requests" // req/min, summed across pods + AlertMetricEgress = "egress" // bytes/min, summed across pods +) + +var alertMetrics = []string{ + AlertMetricCPU, + AlertMetricMemory, + AlertMetricRequests, + AlertMetricEgress, +} + +// AlertMetrics returns the v1 metric vocabulary a Condition.Metric may target — +// the discovery list for a rule-creation UI (mirrors NotificationEvents). The +// returned slice is a copy. +func AlertMetrics() []string { + xs := make([]string, len(alertMetrics)) + copy(xs, alertMetrics) + return xs +} + +var validAlertMetrics = func() map[string]bool { + m := make(map[string]bool, len(alertMetrics)) + for _, x := range alertMetrics { + m[x] = true + } + return m +}() + +// alertPercentMetrics is the subset of the vocabulary whose Threshold is a +// percent (0-100, generously capped at AlertPercentThresholdMax) rather than an +// absolute per-minute rate. +var alertPercentMetrics = map[string]bool{ + AlertMetricCPU: true, + AlertMetricMemory: true, +} + +// Comparison operators a condition may use. +const ( + AlertOpGTE = ">=" + AlertOpLTE = "<=" +) + +var validAlertOps = map[string]bool{ + AlertOpGTE: true, + AlertOpLTE: true, +} + +// Evaluator states (AlertItem.Status). See the Alert doc comment for the +// breach/nodata/ok decision and the state-machine transitions. +const ( + AlertStatusOK = "ok" + AlertStatusFiring = "firing" + AlertStatusNoData = "nodata" +) + +// Event transitions recorded per tick and carried on AlertEvent.Transition. +const ( + AlertTransitionTrigger = "trigger" // ok/nodata -> firing + AlertTransitionResolve = "resolve" // firing -> ok + AlertTransitionRenotify = "renotify" // firing -> firing, RenotifyMinutes elapsed +) + +// validAlertName mirrors the env-group/scheduler/notification name rules +// (DNS-label friendly). +func validAlertName(v *validator.Validator, name string) { + v.Must(ReValidName.MatchString(name), "name invalid: "+ReValidNameDesc) + cnt := utf8.RuneCountInString(name) + v.Mustf(cnt >= MinNameLength && cnt <= MaxNameLength, "name must have length between %d-%d characters", MinNameLength, MaxNameLength) +} + +// validAlertTarget checks Target's shape only; whether Location/Deployment +// actually resolve to an existing deployment is a server-side lookup (see the +// Alert doc comment), not client-side validation. +func validAlertTarget(v *validator.Validator, t AlertTarget) { + v.Must(t.Location != "", "target.location required") + v.Must(ReValidName.MatchString(t.Deployment), "target.deployment invalid: "+ReValidNameDesc) + cnt := utf8.RuneCountInString(t.Deployment) + v.Mustf(cnt >= MinNameLength && cnt <= DeploymentMaxNameLength, "target.deployment must have length between %d-%d characters", MinNameLength, DeploymentMaxNameLength) +} + +func validAlertCondition(v *validator.Validator, c AlertCondition) { + v.Must(validAlertMetrics[c.Metric], "condition.metric invalid (want cpu, memory, requests, or egress)") + v.Must(validAlertOps[c.Op], "condition.op invalid (want >= or <=)") + v.Must(c.Threshold > 0, "condition.threshold must be greater than 0") + if alertPercentMetrics[c.Metric] { + v.Mustf(c.Threshold <= AlertPercentThresholdMax, "condition.threshold must not exceed %v for percent metrics", AlertPercentThresholdMax) + } + v.Mustf(c.ForMinutes >= AlertForMinutesMin && c.ForMinutes <= AlertForMinutesMax, "condition.forMinutes must be between %d and %d", AlertForMinutesMin, AlertForMinutesMax) +} + +// validAlertRenotifyMinutes: 0 disables re-notification (transitions only); +// anything else must fall within the bounds. +func validAlertRenotifyMinutes(v *validator.Validator, m int) { + if m == 0 { + return + } + v.Mustf(m >= AlertRenotifyMinutesMin && m <= AlertRenotifyMinutesMax, "renotifyMinutes must be 0 (disabled) or between %d and %d", AlertRenotifyMinutesMin, AlertRenotifyMinutesMax) +} + +type AlertCreate struct { + Project string `json:"project" yaml:"project"` + Name string `json:"name" yaml:"name"` + Target AlertTarget `json:"target" yaml:"target"` + Condition AlertCondition `json:"condition" yaml:"condition"` + RenotifyMinutes int `json:"renotifyMinutes" yaml:"renotifyMinutes"` + Disabled bool `json:"disabled" yaml:"disabled"` +} + +func (m *AlertCreate) Valid() error { + m.Name = strings.TrimSpace(m.Name) + m.Target.Location = strings.TrimSpace(m.Target.Location) + m.Target.Deployment = strings.TrimSpace(m.Target.Deployment) + m.Condition.Metric = strings.TrimSpace(m.Condition.Metric) + m.Condition.Op = strings.TrimSpace(m.Condition.Op) + if m.Condition.Op == "" { + m.Condition.Op = AlertOpGTE + } + + v := validator.New() + v.Must(m.Project != "", "project required") + validAlertName(v, m.Name) + validAlertTarget(v, m.Target) + validAlertCondition(v, m.Condition) + validAlertRenotifyMinutes(v, m.RenotifyMinutes) + + return WrapValidate(v) +} + +// AlertUpdate replaces a rule's whole configuration (a full upsert, like +// SchedulerUpdate) — Target, Condition, RenotifyMinutes, and Disabled are all +// replaced wholesale. Name identifies the rule and is immutable; there is no +// rename. +type AlertUpdate struct { + Project string `json:"project" yaml:"project"` + Name string `json:"name" yaml:"name"` + Target AlertTarget `json:"target" yaml:"target"` + Condition AlertCondition `json:"condition" yaml:"condition"` + RenotifyMinutes int `json:"renotifyMinutes" yaml:"renotifyMinutes"` + Disabled bool `json:"disabled" yaml:"disabled"` +} + +func (m *AlertUpdate) Valid() error { + m.Name = strings.TrimSpace(m.Name) + m.Target.Location = strings.TrimSpace(m.Target.Location) + m.Target.Deployment = strings.TrimSpace(m.Target.Deployment) + m.Condition.Metric = strings.TrimSpace(m.Condition.Metric) + m.Condition.Op = strings.TrimSpace(m.Condition.Op) + if m.Condition.Op == "" { + m.Condition.Op = AlertOpGTE + } + + v := validator.New() + v.Must(m.Project != "", "project required") + validAlertName(v, m.Name) + validAlertTarget(v, m.Target) + validAlertCondition(v, m.Condition) + validAlertRenotifyMinutes(v, m.RenotifyMinutes) + + return WrapValidate(v) +} + +type AlertGet struct { + Project string `json:"project" yaml:"project"` + Name string `json:"name" yaml:"name"` +} + +func (m *AlertGet) Valid() error { + m.Name = strings.TrimSpace(m.Name) + v := validator.New() + v.Must(m.Project != "", "project required") + validAlertName(v, m.Name) + return WrapValidate(v) +} + +type AlertDelete struct { + Project string `json:"project" yaml:"project"` + Name string `json:"name" yaml:"name"` +} + +func (m *AlertDelete) Valid() error { + m.Name = strings.TrimSpace(m.Name) + v := validator.New() + v.Must(m.Project != "", "project required") + validAlertName(v, m.Name) + return WrapValidate(v) +} + +type AlertList struct { + Project string `json:"project" yaml:"project"` +} + +func (m *AlertList) Valid() error { + v := validator.New() + v.Must(m.Project != "", "project required") + return WrapValidate(v) +} + +// AlertEvents lists a rule's recent state transitions, newest first (see +// AlertEventsResult). +type AlertEvents struct { + Project string `json:"project" yaml:"project"` + Name string `json:"name" yaml:"name"` + Limit int `json:"limit" yaml:"limit"` +} + +func (m *AlertEvents) Valid() error { + m.Name = strings.TrimSpace(m.Name) + v := validator.New() + v.Must(m.Project != "", "project required") + validAlertName(v, m.Name) + if err := WrapValidate(v); err != nil { + return err + } + if m.Limit <= 0 { + m.Limit = AlertEventsDefaultLimit + } + if m.Limit > AlertEventsMaxLimit { + m.Limit = AlertEventsMaxLimit + } + return nil +} + +// AlertItem is the read view of a rule, including the evaluator's read-only +// state (Status/LastValue/FiringSince/LastEvaluatedAt), set by the alert-tick +// cron and ignored on write. +type AlertItem struct { + Project string `json:"project" yaml:"project"` + Name string `json:"name" yaml:"name"` + Target AlertTarget `json:"target" yaml:"target"` + Condition AlertCondition `json:"condition" yaml:"condition"` + RenotifyMinutes int `json:"renotifyMinutes" yaml:"renotifyMinutes"` + Disabled bool `json:"disabled" yaml:"disabled"` + // read-only evaluator state + Status string `json:"status" yaml:"status"` // ok|firing|nodata + LastValue *float64 `json:"lastValue" yaml:"lastValue"` + FiringSince *time.Time `json:"firingSince" yaml:"firingSince"` + LastEvaluatedAt *time.Time `json:"lastEvaluatedAt" yaml:"lastEvaluatedAt"` + CreatedAt time.Time `json:"createdAt" yaml:"createdAt"` + CreatedBy string `json:"createdBy" yaml:"createdBy"` + UpdatedAt time.Time `json:"updatedAt" yaml:"updatedAt"` + UpdatedBy string `json:"updatedBy" yaml:"updatedBy"` +} + +func alertTargetString(t AlertTarget) string { + return t.Location + "/" + t.Deployment +} + +func alertConditionString(c AlertCondition) string { + return c.Metric + " " + c.Op + " " + strconv.FormatFloat(c.Threshold, 'f', -1, 64) + " for " + strconv.Itoa(c.ForMinutes) + "m" +} + +func alertValueString(v *float64) string { + if v == nil { + return "-" + } + return strconv.FormatFloat(*v, 'f', 2, 64) +} + +func alertRow(x *AlertItem) []string { + return []string{ + x.Name, + alertTargetString(x.Target), + alertConditionString(x.Condition), + x.Status, + alertValueString(x.LastValue), + age(x.CreatedAt), + } +} + +func (m *AlertItem) Table() [][]string { + return [][]string{ + {"NAME", "TARGET", "CONDITION", "STATUS", "LAST VALUE", "AGE"}, + alertRow(m), + } +} + +type AlertListResult struct { + Project string `json:"project" yaml:"project"` + Items []*AlertItem `json:"items" yaml:"items"` +} + +func (m *AlertListResult) Table() [][]string { + table := [][]string{ + {"NAME", "TARGET", "CONDITION", "STATUS", "LAST VALUE", "AGE"}, + } + for _, x := range m.Items { + table = append(table, alertRow(x)) + } + return table +} + +// AlertEvent is one recorded state transition (see the alert_events table in +// the evaluator schema). +type AlertEvent struct { + At time.Time `json:"at" yaml:"at"` + Transition string `json:"transition" yaml:"transition"` // trigger|resolve|renotify + Value *float64 `json:"value" yaml:"value"` +} + +func alertEventRow(x *AlertEvent) []string { + return []string{ + age(x.At), + x.Transition, + alertValueString(x.Value), + } +} + +// AlertEventsResult is a rule's recent state transitions, newest first — the +// history feed for the alert detail page. +type AlertEventsResult struct { + Project string `json:"project" yaml:"project"` + Name string `json:"name" yaml:"name"` + Items []*AlertEvent `json:"items" yaml:"items"` +} + +func (m *AlertEventsResult) Table() [][]string { + table := [][]string{ + {"TIME", "TRANSITION", "VALUE"}, + } + for _, x := range m.Items { + table = append(table, alertEventRow(x)) + } + return table +} diff --git a/alert_test.go b/alert_test.go new file mode 100644 index 0000000..4f5536e --- /dev/null +++ b/alert_test.go @@ -0,0 +1,250 @@ +package api + +import ( + "strings" + "testing" +) + +func TestAlertMetrics(t *testing.T) { + metrics := AlertMetrics() + if len(metrics) == 0 { + t.Fatal("the metric vocabulary must not be empty") + } + // AlertMetrics returns a copy — mutating it must not affect the vocabulary. + metrics[0] = "mutated" + if AlertMetrics()[0] == "mutated" { + t.Fatal("AlertMetrics must return a copy") + } + + for _, want := range []string{AlertMetricCPU, AlertMetricMemory, AlertMetricRequests, AlertMetricEgress} { + found := false + for _, m := range AlertMetrics() { + if m == want { + found = true + break + } + } + if !found { + t.Fatalf("vocabulary is missing %q", want) + } + } +} + +func validAlertCreate() *AlertCreate { + return &AlertCreate{ + Project: "p", + Name: "cpu-hot", + Target: AlertTarget{Location: "gke.cluster-rcf2", Deployment: "web"}, + Condition: AlertCondition{ + Metric: AlertMetricCPU, + Op: AlertOpGTE, + Threshold: 90, + ForMinutes: 10, + }, + } +} + +func TestAlertCreateValid(t *testing.T) { + if err := validAlertCreate().Valid(); err != nil { + t.Fatalf("a valid create was rejected: %v", err) + } + + // op defaults to >= when left empty. + m := validAlertCreate() + m.Condition.Op = "" + if err := m.Valid(); err != nil { + t.Fatalf("empty op was rejected: %v", err) + } + if m.Condition.Op != AlertOpGTE { + t.Fatalf("empty op must default to %q, got %q", AlertOpGTE, m.Condition.Op) + } + + // <= is a valid op too. + lte := validAlertCreate() + lte.Condition.Op = AlertOpLTE + if err := lte.Valid(); err != nil { + t.Fatalf("<= op was rejected: %v", err) + } + + // every metric in the vocabulary must validate with an in-bounds threshold. + for _, metric := range AlertMetrics() { + mm := validAlertCreate() + mm.Condition.Metric = metric + mm.Condition.Threshold = 10 + if err := mm.Valid(); err != nil { + t.Fatalf("metric %q with a valid threshold was rejected: %v", metric, err) + } + } + + // renotifyMinutes: 0 (disabled) and any value in range must validate. + renotify := validAlertCreate() + renotify.RenotifyMinutes = 0 + if err := renotify.Valid(); err != nil { + t.Fatalf("renotifyMinutes=0 was rejected: %v", err) + } + renotify.RenotifyMinutes = 60 + if err := renotify.Valid(); err != nil { + t.Fatalf("renotifyMinutes=60 was rejected: %v", err) + } + + cases := []struct { + name string + mutate func(*AlertCreate) + want string + }{ + {"missing project", func(m *AlertCreate) { m.Project = "" }, "project required"}, + {"bad name", func(m *AlertCreate) { m.Name = "Bad Name" }, "name invalid"}, + {"missing target location", func(m *AlertCreate) { m.Target.Location = "" }, "target.location required"}, + {"bad target deployment", func(m *AlertCreate) { m.Target.Deployment = "Bad Name" }, "target.deployment invalid"}, + {"missing target deployment", func(m *AlertCreate) { m.Target.Deployment = "" }, "target.deployment invalid"}, + {"unknown metric", func(m *AlertCreate) { m.Condition.Metric = "disk" }, "condition.metric invalid"}, + {"bad op", func(m *AlertCreate) { m.Condition.Op = "==" }, "condition.op invalid"}, + {"zero threshold", func(m *AlertCreate) { m.Condition.Threshold = 0 }, "threshold must be greater than 0"}, + {"negative threshold", func(m *AlertCreate) { m.Condition.Threshold = -1 }, "threshold must be greater than 0"}, + {"percent threshold too large", func(m *AlertCreate) { m.Condition.Threshold = AlertPercentThresholdMax + 1 }, "must not exceed"}, + {"forMinutes zero", func(m *AlertCreate) { m.Condition.ForMinutes = 0 }, "condition.forMinutes"}, + {"forMinutes too large", func(m *AlertCreate) { m.Condition.ForMinutes = AlertForMinutesMax + 1 }, "condition.forMinutes"}, + {"renotifyMinutes too small", func(m *AlertCreate) { m.RenotifyMinutes = 1 }, "renotifyMinutes"}, + {"renotifyMinutes too large", func(m *AlertCreate) { m.RenotifyMinutes = AlertRenotifyMinutesMax + 1 }, "renotifyMinutes"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m := validAlertCreate() + tc.mutate(m) + err := m.Valid() + if err == nil { + t.Fatalf("expected a validation error for %s, got nil", tc.name) + } + if !strings.Contains(err.Error(), tc.want) { + t.Fatalf("expected error to contain %q, got: %v", tc.want, err) + } + }) + } +} + +// non-percent metrics (requests, egress) allow thresholds above +// AlertPercentThresholdMax — the cap only applies to cpu/memory. +func TestAlertCreateNonPercentMetricAllowsLargeThreshold(t *testing.T) { + m := validAlertCreate() + m.Condition.Metric = AlertMetricRequests + m.Condition.Threshold = AlertPercentThresholdMax + 1 + if err := m.Valid(); err != nil { + t.Fatalf("a large requests threshold was rejected: %v", err) + } +} + +func TestAlertUpdateValid(t *testing.T) { + m := &AlertUpdate{ + Project: "p", + Name: "cpu-hot", + Target: AlertTarget{Location: "gke.cluster-rcf2", Deployment: "web"}, + Condition: AlertCondition{ + Metric: AlertMetricMemory, + Threshold: 80, + ForMinutes: 5, + }, + } + if err := m.Valid(); err != nil { + t.Fatalf("a valid update was rejected: %v", err) + } + if m.Condition.Op != AlertOpGTE { + t.Fatalf("empty op must default to %q, got %q", AlertOpGTE, m.Condition.Op) + } + + bad := &AlertUpdate{Project: "p", Name: "cpu-hot"} + if err := bad.Valid(); err == nil { + t.Fatal("expected an incomplete update to be rejected") + } +} + +func TestAlertGetDeleteValid(t *testing.T) { + get := &AlertGet{Project: "p", Name: "cpu-hot"} + if err := get.Valid(); err != nil { + t.Fatalf("valid AlertGet rejected: %v", err) + } + get.Project = "" + if err := get.Valid(); err == nil || !strings.Contains(err.Error(), "project required") { + t.Fatalf("expected project required, got: %v", err) + } + + del := &AlertDelete{Project: "p", Name: "cpu-hot"} + if err := del.Valid(); err != nil { + t.Fatalf("valid AlertDelete rejected: %v", err) + } + del.Name = "Bad Name" + if err := del.Valid(); err == nil || !strings.Contains(err.Error(), "name invalid") { + t.Fatalf("expected name invalid, got: %v", err) + } +} + +func TestAlertListValid(t *testing.T) { + list := &AlertList{Project: "p"} + if err := list.Valid(); err != nil { + t.Fatalf("valid AlertList rejected: %v", err) + } + list.Project = "" + if err := list.Valid(); err == nil || !strings.Contains(err.Error(), "project required") { + t.Fatalf("expected project required, got: %v", err) + } +} + +func TestAlertEventsValidLimitClamp(t *testing.T) { + m := &AlertEvents{Project: "p", Name: "cpu-hot"} + if err := m.Valid(); err != nil { + t.Fatalf("valid AlertEvents rejected: %v", err) + } + if m.Limit != AlertEventsDefaultLimit { + t.Fatalf("expected default limit %d, got %d", AlertEventsDefaultLimit, m.Limit) + } + + m = &AlertEvents{Project: "p", Name: "cpu-hot", Limit: AlertEventsMaxLimit + 1000} + if err := m.Valid(); err != nil { + t.Fatalf("valid AlertEvents rejected: %v", err) + } + if m.Limit != AlertEventsMaxLimit { + t.Fatalf("expected clamped limit %d, got %d", AlertEventsMaxLimit, m.Limit) + } + + m = &AlertEvents{Name: "cpu-hot"} + if err := m.Valid(); err == nil || !strings.Contains(err.Error(), "project required") { + t.Fatalf("expected project required, got: %v", err) + } +} + +func TestAlertEventsInCatalog(t *testing.T) { + events := NotificationEvents() + seen := map[string]bool{} + for _, e := range events { + seen[e] = true + } + for _, want := range []string{"alert.create", "alert.update", "alert.delete", "alert.trigger", "alert.resolve"} { + if !seen[want] { + t.Fatalf("notification event catalog is missing %q", want) + } + } +} + +func TestAlertPublicBindable(t *testing.T) { + // nothing secret in the payloads: reads are safe to grant publicly. + for _, p := range []string{"alert.get", "alert.list"} { + if !IsPublicBindablePermission(p) { + t.Fatalf("%s should be public-bindable", p) + } + } + for _, p := range []string{"alert.create", "alert.update", "alert.delete", "alert.*", "*"} { + if IsPublicBindablePermission(p) { + t.Fatalf("%s must not be public-bindable", p) + } + } +} + +func TestAlertDelegatable(t *testing.T) { + for _, p := range []string{"alert.create", "alert.update", "alert.get", "alert.list", "alert.delete"} { + if !IsDelegatablePermission(p) { + t.Fatalf("%s should be delegatable", p) + } + } + if IsDelegatablePermission("alert.*") { + t.Fatal("alert.* must not be delegatable (wildcard)") + } +} diff --git a/api.go b/api.go index 4247cbb..9845366 100644 --- a/api.go +++ b/api.go @@ -29,4 +29,5 @@ type Interface interface { GitHub() GitHub Scheduler() Scheduler Notification() Notification + Alert() Alert } diff --git a/client/alert.go b/client/alert.go new file mode 100644 index 0000000..9426aa9 --- /dev/null +++ b/client/alert.go @@ -0,0 +1,59 @@ +package client + +import ( + "context" + + "github.com/deploys-app/api" +) + +type alertClient struct { + inv invoker +} + +func (c alertClient) Create(ctx context.Context, m *api.AlertCreate) (*api.Empty, error) { + var res api.Empty + if err := c.inv.invoke(ctx, "alert.create", m, &res); err != nil { + return nil, err + } + return &res, nil +} + +func (c alertClient) Update(ctx context.Context, m *api.AlertUpdate) (*api.Empty, error) { + var res api.Empty + if err := c.inv.invoke(ctx, "alert.update", m, &res); err != nil { + return nil, err + } + return &res, nil +} + +func (c alertClient) Get(ctx context.Context, m *api.AlertGet) (*api.AlertItem, error) { + var res api.AlertItem + if err := c.inv.invoke(ctx, "alert.get", m, &res); err != nil { + return nil, err + } + return &res, nil +} + +func (c alertClient) List(ctx context.Context, m *api.AlertList) (*api.AlertListResult, error) { + var res api.AlertListResult + if err := c.inv.invoke(ctx, "alert.list", m, &res); err != nil { + return nil, err + } + return &res, nil +} + +func (c alertClient) Delete(ctx context.Context, m *api.AlertDelete) (*api.Empty, error) { + var res api.Empty + if err := c.inv.invoke(ctx, "alert.delete", m, &res); err != nil { + return nil, err + } + return &res, nil +} + +func (c alertClient) Events(ctx context.Context, m *api.AlertEvents) (*api.AlertEventsResult, error) { + var res api.AlertEventsResult + if err := c.inv.invoke(ctx, "alert.events", m, &res); err != nil { + return nil, err + } + return &res, nil +} diff --git a/client/client.go b/client/client.go index f3d699b..01cf5f0 100644 --- a/client/client.go +++ b/client/client.go @@ -179,6 +179,10 @@ func (c *Client) Notification() api.Notification { return notificationClient{c} } +func (c *Client) Alert() api.Alert { + return alertClient{c} +} + func (c *Client) invoke(ctx context.Context, api string, r any, res any) error { if err := validRequest(r); err != nil { return err diff --git a/constraint.go b/constraint.go index 8ab446f..9383722 100644 --- a/constraint.go +++ b/constraint.go @@ -208,3 +208,29 @@ const ( NotificationDefaultPullLimit = 100 NotificationMaxPullLimit = 1000 ) + +// Alert (metric alert rules on platform metrics) +const ( + // AlertMaxRules caps how many rules a project may define; server-enforced + // at Create. + AlertMaxRules = 20 + + // AlertForMinutesMin/Max bound how long a condition must hold continuously, + // evaluated as a rolling window, before a rule breaches. + AlertForMinutesMin = 1 + AlertForMinutesMax = 60 + + // AlertRenotifyMinutesMin/Max bound the periodic re-notification interval + // while a rule stays firing; 0 disables re-notification (trigger/resolve + // transitions only). + AlertRenotifyMinutesMin = 10 + AlertRenotifyMinutesMax = 1440 // 24h + + // AlertPercentThresholdMax caps Condition.Threshold for percent-unit + // metrics (cpu, memory); values above 100 are allowed since a deployment's + // limit can be briefly overcommitted. + AlertPercentThresholdMax = 1000 // 100% x10 headroom + + AlertEventsDefaultLimit = 50 + AlertEventsMaxLimit = 100 +) diff --git a/errors.go b/errors.go index ef7d7ab..850b934 100644 --- a/errors.go +++ b/errors.go @@ -98,6 +98,9 @@ var ( ErrErrorDetectionUnavailable = newError("api: error detection is not available for this location") ErrErrorIssueNotFound = newError("api: error issue not found") ErrErrorNotForStatic = newError("api: no error detection for static deployments") + ErrAlertNotFound = newError("api: alert not found") + ErrAlertAlreadyExists = newError("api: alert already exists") + ErrMaximumAlertRulesReached = newError("api: maximum alert rules reached") ) var AllErrors []error diff --git a/notification.go b/notification.go index baa763a..7a6f4f5 100644 --- a/notification.go +++ b/notification.go @@ -210,6 +210,8 @@ func validNotificationEventSegment(s string) bool { // historical lowercase workloadidentity); keep in sync with the apiserver // recordChange/recordAudit call sites. var notificationEvents = []string{ + "alert.create", "alert.update", "alert.delete", + "alert.trigger", "alert.resolve", "cache.set", "cache.delete", "database.create", "deployment.deploy", "deployment.rollback", "deployment.restart", diff --git a/role.go b/role.go index fe8315f..d69d6a6 100644 --- a/role.go +++ b/role.go @@ -133,6 +133,12 @@ var permissions = []string{ "notification.delete", "notification.test", "notification.pull", + "alert.*", + "alert.create", + "alert.update", + "alert.get", + "alert.list", + "alert.delete", } func Permissions() []string {