The predefined rules are too constraining: an ISP cannot say "CPU over 80% on servers for 15 minutes" or "uplink without a description", and every new need meant editing config.yaml. Admins now build their own rules; the shipped ones stay as read-only built-in macros with their names unchanged, so v1.1 policies and alert history keep working. Custom rules live in a new alert_rules table (migration 13) and are builder-only. A metric rule names a catalogue metric (every pollable profile metric plus push-ingested names) with a transform (value, rate, percent_of), window, function, aggregate, comparison and threshold; the pure internal/alertrule package compiles it to MetricsQL from fixed templates, so no user text reaches a query. An inventory rule ANDs one to five conditions over whitelisted device or port fields and is evaluated in Go over store rows. The evaluator takes its rule set from a function each cycle, so edits apply live, and both kinds use the existing state machine, policies, suppression and notifications. Comparisons now cover > >= < <= == != everywhere. utilisation is not offered: port speed is not a stored series. config.yaml rules still work but are deprecated. API (additive): rule list gains id/kind/editable/spec/expr, plus get, create, update, delete, catalogue, test (viewer, read-only) and copy of a built-in (cpu_high, ups_on_battery, printer_supply_empty) to a custom rule. Deleting or renaming a rule that a policy names is a 409. The UI gets an Alert rules tab with a builder dialog, a test button and the generated query, and the policy dialog lists custom rules. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T4PdJUPrakM8fHvN8jYv2x
457 lines
17 KiB
Go
457 lines
17 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/danielgtaylor/huma/v2"
|
|
|
|
"github.com/v10networks/nmon/internal/alertrule"
|
|
"github.com/v10networks/nmon/internal/store"
|
|
)
|
|
|
|
// AlertRuleHost is the rule registry as the API sees it: the catalogue of what a builder may use, validation
|
|
// and a dry run of a draft, and the combined rule list.
|
|
type AlertRuleHost interface {
|
|
// Rules lists every rule: built-in, config and custom (and the reachability and system rules).
|
|
Rules() []AlertRuleInfo
|
|
// Catalogue lists the metrics and inventory fields a builder may use.
|
|
Catalogue(ctx context.Context) RuleCatalogue
|
|
// Build validates a draft; the error text lists every problem.
|
|
Build(ctx context.Context, d alertrule.Draft) (RuleBuilt, error)
|
|
// Test compiles a draft and runs it once against the live data.
|
|
Test(ctx context.Context, d alertrule.Draft) (RuleTestResult, error)
|
|
// Reserved reports a name a custom rule cannot take: any built-in, config, reachability or system rule.
|
|
Reserved(name string) bool
|
|
// Copy returns the builder draft equivalent to a built-in rule. A non-empty reason says why it cannot.
|
|
Copy(ctx context.Context, name string) (d alertrule.Draft, reason string)
|
|
// Changed drops the registry's caches so a write applies on the next evaluation cycle.
|
|
Changed()
|
|
}
|
|
|
|
// RuleBuilt is what a valid draft compiles to.
|
|
type RuleBuilt struct {
|
|
Expr string
|
|
Level string
|
|
}
|
|
|
|
// RuleCatalogue is what the builder offers.
|
|
type RuleCatalogue struct {
|
|
Metrics []alertrule.MetricInfo `json:"metrics"`
|
|
Fields []alertrule.InventoryField `json:"fields" doc:"Inventory fields with the ops each type accepts."`
|
|
Ops []string `json:"ops" doc:"Comparison operators of a metric rule."`
|
|
Window RuleWindowLimits `json:"window"`
|
|
}
|
|
|
|
// RuleWindowLimits are the bounds of a metric rule's window.
|
|
type RuleWindowLimits struct {
|
|
DefaultSeconds int64 `json:"default_seconds"`
|
|
MaxSeconds int64 `json:"max_seconds"`
|
|
}
|
|
|
|
// AlertRuleBody is the body of POST and PUT /alert-rules and of POST /alert-rules/test.
|
|
type AlertRuleBody struct {
|
|
Name string `json:"name" minLength:"1" maxLength:"64" doc:"Unique across all rules. Policies name a rule by this."`
|
|
Kind string `json:"kind" enum:"metric,inventory"`
|
|
Enabled *bool `json:"enabled,omitempty" doc:"Omitted: on for create, kept for update. Off retires open alerts silently."`
|
|
Severity string `json:"severity" enum:"critical,warning,info"`
|
|
For *string `json:"for,omitempty" doc:"How long the condition must hold, a duration such as 15m. Default 0."`
|
|
Summary string `json:"summary,omitempty" doc:"A text/template over rule, device, iface, component, value, threshold, op."`
|
|
Description string `json:"description,omitempty" doc:"As summary."`
|
|
Spec alertrule.RuleSpec `json:"spec"`
|
|
}
|
|
|
|
// AlertRuleCopyBody is the optional body of POST /alert-rules/{id}/copy.
|
|
type AlertRuleCopyBody struct {
|
|
Name string `json:"name,omitempty" maxLength:"64" doc:"Name of the copy; default is the built-in's name plus _custom."`
|
|
}
|
|
|
|
// RuleTestResult is a dry run of a draft.
|
|
type RuleTestResult struct {
|
|
Expr string `json:"expr" doc:"The generated query; empty for an inventory rule."`
|
|
Level string `json:"level" enum:"device,interface,component"`
|
|
Entities int `json:"entities" doc:"Series (metric rule) or rows (inventory rule) the rule saw."`
|
|
Breaching int `json:"breaching" doc:"How many breach right now, before any alert policy is applied."`
|
|
Samples []RuleTestSample `json:"samples" doc:"Up to 20, breaching ones first."`
|
|
}
|
|
|
|
// RuleTestSample is one entity of a dry run.
|
|
type RuleTestSample struct {
|
|
Device string `json:"device" doc:"Device slug."`
|
|
Iface string `json:"iface,omitempty" doc:"Interface ULID."`
|
|
Component string `json:"component,omitempty"`
|
|
Value float64 `json:"value"`
|
|
Breach bool `json:"breach"`
|
|
}
|
|
|
|
func (s *server) registerAlertRules(api huma.API) {
|
|
tags := []string{"alerts"}
|
|
bad := []int{http.StatusConflict, http.StatusUnprocessableEntity, http.StatusServiceUnavailable}
|
|
huma.Register(api, huma.Operation{OperationID: "get-alert-rule-catalogue", Method: http.MethodGet, Path: "/api/v1/alert-rules/catalogue",
|
|
Tags: tags, Summary: "Metrics and inventory fields the builder offers", Errors: []int{http.StatusServiceUnavailable}}, s.ruleCatalogue)
|
|
huma.Register(api, huma.Operation{OperationID: "test-alert-rule", Method: http.MethodPost, Path: "/api/v1/alert-rules/test", Tags: tags,
|
|
Summary: "Compile a draft rule and run it once",
|
|
Description: "Returns the generated query, how many entities the rule sees and how many breach now, ignoring alert policies.",
|
|
Errors: []int{http.StatusUnprocessableEntity, http.StatusServiceUnavailable}}, s.testAlertRule)
|
|
huma.Register(api, huma.Operation{OperationID: "create-alert-rule", Method: http.MethodPost, Path: "/api/v1/alert-rules", Tags: tags,
|
|
DefaultStatus: http.StatusCreated, Summary: "Create a custom alert rule",
|
|
Description: "Builder only: a metric rule names a catalogue metric, an inventory rule names whitelisted fields. " +
|
|
"A rule alerts only where an alert policy binds it.", Errors: bad}, s.createAlertRule)
|
|
huma.Register(api, huma.Operation{OperationID: "get-alert-rule", Method: http.MethodGet, Path: "/api/v1/alert-rules/{id}", Tags: tags,
|
|
Summary: "An alert rule", Errors: []int{http.StatusNotFound, http.StatusServiceUnavailable}}, s.getAlertRule)
|
|
huma.Register(api, huma.Operation{OperationID: "update-alert-rule", Method: http.MethodPut, Path: "/api/v1/alert-rules/{id}", Tags: tags,
|
|
Summary: "Replace a custom alert rule", Description: "A built-in or config rule is 409.",
|
|
Errors: append([]int{http.StatusNotFound}, bad...)}, s.updateAlertRule)
|
|
huma.Register(api, huma.Operation{OperationID: "delete-alert-rule", Method: http.MethodDelete, Path: "/api/v1/alert-rules/{id}",
|
|
Tags: tags,
|
|
DefaultStatus: http.StatusNoContent, Summary: "Delete a custom alert rule",
|
|
Description: "409 while an alert policy names the rule, or for a built-in. Open alerts of the rule close silently as retired.",
|
|
Errors: []int{http.StatusNotFound, http.StatusConflict, http.StatusServiceUnavailable}}, s.deleteAlertRule)
|
|
huma.Register(api, huma.Operation{OperationID: "copy-alert-rule", Method: http.MethodPost, Path: "/api/v1/alert-rules/{id}/copy",
|
|
Tags: tags,
|
|
DefaultStatus: http.StatusCreated, Summary: "Clone a built-in rule into a custom rule",
|
|
Description: "422 with the reason when the builder cannot express the built-in.",
|
|
Errors: append([]int{http.StatusNotFound}, bad...)}, s.copyAlertRule)
|
|
}
|
|
|
|
func (s *server) ruleHost() (AlertRuleHost, error) {
|
|
if s.AlertRules == nil {
|
|
return nil, errNotConfigured("alert rules")
|
|
}
|
|
return s.AlertRules, nil
|
|
}
|
|
|
|
// ruleInfos is the rule list with each rule's policy count.
|
|
func (s *server) ruleInfos(ctx context.Context) ([]AlertRuleInfo, error) {
|
|
var items []AlertRuleInfo
|
|
if s.AlertRules != nil {
|
|
items = s.AlertRules.Rules()
|
|
} else {
|
|
h, err := s.policyHost()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items = h.Rules()
|
|
}
|
|
pols, err := s.Store.ListAlertPolicies(ctx)
|
|
if err != nil {
|
|
return nil, s.storeErr(err)
|
|
}
|
|
counts := map[string]int{}
|
|
for _, p := range pols {
|
|
counts[p.Rule]++
|
|
}
|
|
for i := range items {
|
|
items[i].PolicyCount = counts[items[i].Name]
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
func (s *server) ruleInfo(ctx context.Context, id string) (AlertRuleInfo, error) {
|
|
items, err := s.ruleInfos(ctx)
|
|
if err != nil {
|
|
return AlertRuleInfo{}, err
|
|
}
|
|
for _, it := range items {
|
|
if it.ID == id || (it.Kind != "metric" && it.Kind != "inventory" && it.Name == id) {
|
|
return it, nil
|
|
}
|
|
}
|
|
return AlertRuleInfo{}, huma.Error404NotFound("not found")
|
|
}
|
|
|
|
func (s *server) getAlertRule(ctx context.Context, in *struct {
|
|
ID string `path:"id" doc:"A custom rule's ULID, or a built-in's name."`
|
|
}) (*struct{ Body AlertRuleInfo }, error) {
|
|
if _, err := s.ruleHost(); err != nil {
|
|
return nil, err
|
|
}
|
|
it, err := s.ruleInfo(ctx, in.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &struct{ Body AlertRuleInfo }{it}, nil
|
|
}
|
|
|
|
func (s *server) ruleCatalogue(ctx context.Context, _ *struct{}) (*struct{ Body RuleCatalogue }, error) {
|
|
h, err := s.ruleHost()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &struct{ Body RuleCatalogue }{h.Catalogue(ctx)}, nil
|
|
}
|
|
|
|
// draftOf converts a request body to a builder draft.
|
|
func draftOf(b AlertRuleBody) (alertrule.Draft, error) {
|
|
d := alertrule.Draft{Name: strings.TrimSpace(b.Name), Kind: b.Kind, Severity: b.Severity, Summary: b.Summary,
|
|
Description: b.Description, Spec: b.Spec}
|
|
if b.For != nil {
|
|
f, err := time.ParseDuration(*b.For)
|
|
if err != nil {
|
|
return d, huma.Error422UnprocessableEntity("for: " + err.Error())
|
|
}
|
|
d.For = f
|
|
}
|
|
return d, nil
|
|
}
|
|
|
|
func (s *server) testAlertRule(ctx context.Context, in *struct{ Body AlertRuleBody }) (*struct{ Body RuleTestResult }, error) {
|
|
h, err := s.ruleHost()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
d, err := draftOf(in.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
res, err := h.Test(ctx, d)
|
|
if err != nil {
|
|
return nil, s.ruleErr(err)
|
|
}
|
|
return &struct{ Body RuleTestResult }{res}, nil
|
|
}
|
|
|
|
// ruleErr maps a registry error: a validation problem is 422, anything else is logged and 500.
|
|
func (s *server) ruleErr(err error) error {
|
|
var ve *RuleInvalid
|
|
if errors.As(err, &ve) {
|
|
return huma.Error422UnprocessableEntity(flatten(ve))
|
|
}
|
|
s.Logger.Error("alert rule", "err", err)
|
|
return huma.Error500InternalServerError("internal error")
|
|
}
|
|
|
|
// RuleInvalid is a draft that fails validation.
|
|
type RuleInvalid struct{ Err error }
|
|
|
|
func (e *RuleInvalid) Error() string { return e.Err.Error() }
|
|
func (e *RuleInvalid) Unwrap() error { return e.Err }
|
|
|
|
// ruleRow validates a body and converts it to a store row; enabled is the value to keep when the body omits it.
|
|
func (s *server) ruleRow(ctx context.Context, h AlertRuleHost, b AlertRuleBody, enabled bool) (store.AlertRule, RuleBuilt, error) {
|
|
d, err := draftOf(b)
|
|
if err != nil {
|
|
return store.AlertRule{}, RuleBuilt{}, err
|
|
}
|
|
built, err := h.Build(ctx, d)
|
|
if err != nil {
|
|
return store.AlertRule{}, RuleBuilt{}, s.ruleErr(err)
|
|
}
|
|
spec, err := json.Marshal(d.Spec)
|
|
if err != nil {
|
|
return store.AlertRule{}, RuleBuilt{}, huma.Error500InternalServerError("internal error")
|
|
}
|
|
if b.Enabled != nil {
|
|
enabled = *b.Enabled
|
|
}
|
|
return store.AlertRule{Name: d.Name, Kind: d.Kind, Enabled: enabled, Severity: d.Severity, ForSeconds: int64(d.For.Seconds()),
|
|
Summary: d.Summary, Description: d.Description, Spec: string(spec)}, built, nil
|
|
}
|
|
|
|
func (s *server) createAlertRule(ctx context.Context, in *struct{ Body AlertRuleBody }) (*struct{ Body AlertRuleInfo }, error) {
|
|
h, err := s.ruleHost()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.storeNewRule(ctx, h, in.Body, "alert_rule.create")
|
|
}
|
|
|
|
func (s *server) storeNewRule(ctx context.Context, h AlertRuleHost, b AlertRuleBody, action string) (*struct{ Body AlertRuleInfo }, error) {
|
|
if h.Reserved(strings.TrimSpace(b.Name)) {
|
|
return nil, huma.Error409Conflict(fmt.Sprintf("rule name %q is taken by a built-in, config or reachability rule", b.Name))
|
|
}
|
|
row, _, err := s.ruleRow(ctx, h, b, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
err = s.Store.WithTx(ctx, func(tx store.Store) error {
|
|
if err := tx.CreateAlertRule(ctx, &row); err != nil {
|
|
return err
|
|
}
|
|
return tx.AppendAudit(ctx, actorOf(ctx), action, row.ID, nil, b)
|
|
})
|
|
if err != nil {
|
|
return nil, s.storeErr(err)
|
|
}
|
|
h.Changed()
|
|
return s.ruleResult(ctx, row.ID)
|
|
}
|
|
|
|
func (s *server) ruleResult(ctx context.Context, id string) (*struct{ Body AlertRuleInfo }, error) {
|
|
it, err := s.ruleInfo(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &struct{ Body AlertRuleInfo }{it}, nil
|
|
}
|
|
|
|
// customRule loads a custom rule, or says why the id cannot be edited.
|
|
func (s *server) customRule(ctx context.Context, tx store.AlertRules, id string) (*store.AlertRule, error) {
|
|
r, err := tx.GetAlertRule(ctx, id)
|
|
if err == nil {
|
|
return r, nil
|
|
}
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
if it, ierr := s.ruleInfo(ctx, id); ierr == nil && !it.Editable {
|
|
return nil, huma.Error409Conflict(fmt.Sprintf("rule %q is %s and cannot be changed; copy it to a custom rule", it.Name, it.Kind))
|
|
}
|
|
}
|
|
return nil, s.storeErr(err)
|
|
}
|
|
|
|
// referencing lists the names of policies that bind rule.
|
|
func referencing(ctx context.Context, tx store.AlertPolicies, rule string) ([]string, error) {
|
|
ps, err := tx.ListAlertPolicies(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var names []string
|
|
for _, p := range ps {
|
|
if p.Rule == rule {
|
|
names = append(names, p.Name)
|
|
}
|
|
}
|
|
return names, nil
|
|
}
|
|
|
|
func (s *server) updateAlertRule(ctx context.Context, in *struct {
|
|
ID string `path:"id" doc:"A custom rule's ULID."`
|
|
Body AlertRuleBody
|
|
}) (*struct{ Body AlertRuleInfo }, error) {
|
|
h, err := s.ruleHost()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cur, err := s.customRule(ctx, s.Store, in.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
name := strings.TrimSpace(in.Body.Name)
|
|
if name != cur.Name && h.Reserved(name) {
|
|
return nil, huma.Error409Conflict(fmt.Sprintf("rule name %q is taken by a built-in, config or reachability rule", name))
|
|
}
|
|
row, built, err := s.ruleRow(ctx, h, in.Body, cur.Enabled)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
row.ID, row.CreatedAt = cur.ID, cur.CreatedAt
|
|
oldLevel := "" // read before the transaction: a second store call inside it would wait for the first forever on SQLite
|
|
if old, ierr := s.ruleInfo(ctx, cur.ID); ierr == nil {
|
|
oldLevel = old.Level
|
|
}
|
|
err = s.Store.WithTx(ctx, func(tx store.Store) error {
|
|
if err := checkEditable(ctx, tx, cur, row.Name, oldLevel, built); err != nil {
|
|
return err
|
|
}
|
|
if err := tx.UpdateAlertRule(ctx, &row); err != nil {
|
|
return err
|
|
}
|
|
return tx.AppendAudit(ctx, actorOf(ctx), "alert_rule.update", row.ID, cur, in.Body)
|
|
})
|
|
if err != nil {
|
|
return nil, s.ruleStoreErr(err)
|
|
}
|
|
h.Changed()
|
|
return s.ruleResult(ctx, row.ID)
|
|
}
|
|
|
|
// checkEditable refuses a rename, or a change of level, while policies name the rule: both would orphan or
|
|
// invalidate them (a policy with port matchers needs an interface rule).
|
|
func checkEditable(ctx context.Context, tx store.AlertPolicies, cur *store.AlertRule, newName, oldLevel string, built RuleBuilt) error {
|
|
names, err := referencing(ctx, tx, cur.Name)
|
|
if err != nil || len(names) == 0 {
|
|
return err
|
|
}
|
|
if newName != cur.Name {
|
|
return &ruleBusy{fmt.Sprintf("policies %s name this rule; they would lose it on a rename", strings.Join(names, ", "))}
|
|
}
|
|
if oldLevel != "" && oldLevel != built.Level {
|
|
return &ruleBusy{fmt.Sprintf("policies %s bind this rule at the %s level; change them first", strings.Join(names, ", "), oldLevel)}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ruleBusy is a write the rule's policies forbid.
|
|
type ruleBusy struct{ msg string }
|
|
|
|
func (e *ruleBusy) Error() string { return e.msg }
|
|
|
|
func (s *server) ruleStoreErr(err error) error {
|
|
var rb *ruleBusy
|
|
if errors.As(err, &rb) {
|
|
return huma.Error409Conflict(rb.msg)
|
|
}
|
|
return s.storeErr(err)
|
|
}
|
|
|
|
func (s *server) deleteAlertRule(ctx context.Context, in *struct {
|
|
ID string `path:"id" doc:"A custom rule's ULID."`
|
|
}) (*struct{}, error) {
|
|
h, err := s.ruleHost()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := s.customRule(ctx, s.Store, in.ID); err != nil {
|
|
return nil, err
|
|
}
|
|
err = s.Store.WithTx(ctx, func(tx store.Store) error {
|
|
cur, err := tx.GetAlertRule(ctx, in.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
names, err := referencing(ctx, tx, cur.Name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(names) > 0 {
|
|
return &ruleBusy{fmt.Sprintf("alert policies %s still bind rule %q; delete or change them first", strings.Join(names, ", "), cur.Name)}
|
|
}
|
|
if err := tx.DeleteAlertRule(ctx, in.ID); err != nil {
|
|
return err
|
|
}
|
|
return tx.AppendAudit(ctx, actorOf(ctx), "alert_rule.delete", in.ID, cur, nil)
|
|
})
|
|
if err != nil {
|
|
return nil, s.ruleStoreErr(err)
|
|
}
|
|
h.Changed()
|
|
return nil, nil
|
|
}
|
|
|
|
func (s *server) copyAlertRule(ctx context.Context, in *struct {
|
|
ID string `path:"id" doc:"A built-in rule's name."`
|
|
Body AlertRuleCopyBody
|
|
}) (*struct{ Body AlertRuleInfo }, error) {
|
|
h, err := s.ruleHost()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
src, err := s.ruleInfo(ctx, in.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if src.Editable {
|
|
return nil, huma.Error409Conflict("only a built-in rule can be copied; this one is already custom")
|
|
}
|
|
d, reason := h.Copy(ctx, src.Name)
|
|
if reason != "" {
|
|
return nil, huma.Error422UnprocessableEntity(fmt.Sprintf("%s cannot be copied to the builder: %s", src.Name, reason))
|
|
}
|
|
if in.Body.Name != "" {
|
|
d.Name = in.Body.Name
|
|
}
|
|
return s.storeNewRule(ctx, h, bodyOf(d), "alert_rule.copy")
|
|
}
|
|
|
|
// bodyOf converts a draft back to a request body.
|
|
func bodyOf(d alertrule.Draft) AlertRuleBody {
|
|
f := d.For.String()
|
|
return AlertRuleBody{Name: d.Name, Kind: d.Kind, Severity: d.Severity, For: &f, Summary: d.Summary,
|
|
Description: d.Description, Spec: d.Spec}
|
|
}
|