Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
410 changes: 410 additions & 0 deletions alert.go

Large diffs are not rendered by default.

250 changes: 250 additions & 0 deletions alert_test.go
Original file line number Diff line number Diff line change
@@ -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)")
}
}
1 change: 1 addition & 0 deletions api.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,5 @@ type Interface interface {
GitHub() GitHub
Scheduler() Scheduler
Notification() Notification
Alert() Alert
}
59 changes: 59 additions & 0 deletions client/alert.go
Original file line number Diff line number Diff line change
@@ -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
}
4 changes: 4 additions & 0 deletions client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions constraint.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
3 changes: 3 additions & 0 deletions errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions notification.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions role.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down