Files
yovision/Bell/internal/alert/alert.go
T
QiuSW 8208118904
Harness governance / validate (pull_request) Has been cancelled
feat(bell): add alert acknowledgement vertical slice
2026-08-11 17:01:53 +08:00

222 lines
6.2 KiB
Go

// Package alert owns Bell's small, deterministic rule and Alert domain.
package alert
import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"regexp"
"strings"
"time"
"unicode/utf8"
)
const (
DefaultPageSize = 16
MaxPageSize = 100
MaxRulesFile = 256 << 10
)
var (
ErrNotFound = errors.New("alert not found")
ErrIdempotencyConflict = errors.New("idempotency key was used for another command")
ruleKeyPattern = regexp.MustCompile(`^[a-z][a-z0-9_-]{2,63}$`)
eventKindPattern = regexp.MustCompile(`^[a-z][a-z0-9_]{2,63}$`)
actorRefPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:@/-]*$`)
idempotencyKeyPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]*$`)
)
type RuleSpec struct {
TenantID int64 `json:"tenant_id"`
SiteID *int64 `json:"site_id,omitempty"`
RuleKey string `json:"rule_key"`
DisplayName string `json:"display_name"`
EventKind string `json:"event_kind"`
MinimumSeverity string `json:"minimum_severity"`
Enabled bool `json:"enabled"`
EffectiveFrom time.Time `json:"effective_from"`
}
func (r RuleSpec) Validate() error {
if r.TenantID < 1 || (r.SiteID != nil && *r.SiteID < 1) {
return errors.New("rule scope must use positive identifiers")
}
if !ruleKeyPattern.MatchString(r.RuleKey) || !eventKindPattern.MatchString(r.EventKind) {
return errors.New("rule key or event kind is invalid")
}
if strings.TrimSpace(r.DisplayName) == "" || utf8.RuneCountInString(r.DisplayName) > 120 || r.EffectiveFrom.IsZero() {
return errors.New("rule name or effective time is invalid")
}
if _, ok := SeverityRank(r.MinimumSeverity); !ok {
return errors.New("rule minimum severity is invalid")
}
return nil
}
func (r RuleSpec) Digest() ([sha256.Size]byte, error) {
value, err := json.Marshal(r)
if err != nil {
return [sha256.Size]byte{}, err
}
return sha256.Sum256(value), nil
}
type rulesDocument struct {
Version int `json:"version"`
Rules []RuleSpec `json:"rules"`
}
func LoadRules(path string) ([]RuleSpec, error) {
file, err := os.Open(path)
if err != nil {
return nil, errors.New("open Bell alert rules file")
}
defer file.Close()
info, err := file.Stat()
if err != nil || info.Size() > MaxRulesFile {
return nil, errors.New("Bell alert rules file is too large")
}
decoder := json.NewDecoder(file)
decoder.DisallowUnknownFields()
var document rulesDocument
if err := decoder.Decode(&document); err != nil {
return nil, errors.New("decode Bell alert rules file")
}
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
return nil, errors.New("Bell alert rules file contains multiple JSON values")
}
if document.Version != 1 || len(document.Rules) < 1 || len(document.Rules) > 256 {
return nil, errors.New("Bell alert rules file must contain 1 to 256 v1 rules")
}
seen := make(map[string]bool, len(document.Rules))
for _, rule := range document.Rules {
if err := rule.Validate(); err != nil {
return nil, fmt.Errorf("invalid Bell alert rule %q: %w", rule.RuleKey, err)
}
key := fmt.Sprintf("%d:%s", rule.TenantID, rule.RuleKey)
if seen[key] {
return nil, fmt.Errorf("duplicate Bell alert rule %q", rule.RuleKey)
}
seen[key] = true
}
return document.Rules, nil
}
func SeverityRank(value string) (int, bool) {
for rank, severity := range []string{"low", "medium", "high", "critical"} {
if value == severity {
return rank, true
}
}
return 0, false
}
func ValidActorRef(value string) bool {
return len(value) <= 80 && actorRefPattern.MatchString(value)
}
func ValidIdempotencyKey(value string) bool {
return len(value) >= 8 && len(value) <= 128 && idempotencyKeyPattern.MatchString(value)
}
type Summary struct {
ID string `json:"id"`
Severity string `json:"severity"`
Title string `json:"title"`
State string `json:"state"`
RuleKey string `json:"rule_key"`
RuleVersion int `json:"rule_version"`
CreatedAt time.Time `json:"created_at"`
}
type EventRef struct {
ID string `json:"id"`
DeviceID int64 `json:"device_id"`
Kind string `json:"kind"`
Severity string `json:"severity"`
OccurredAt time.Time `json:"occurred_at"`
}
type Transition struct {
Sequence int `json:"sequence"`
FromState *string `json:"from_state"`
ToState string `json:"to_state"`
ActorRef string `json:"actor_ref"`
Note *string `json:"note"`
OccurredAt time.Time `json:"occurred_at"`
}
type Detail struct {
Summary
Events []EventRef `json:"events"`
Transitions []Transition `json:"transitions"`
EvidenceStatus string `json:"evidence_status"`
DeliveryStatus string `json:"delivery_status"`
}
type Page struct {
Items []Summary `json:"items"`
NextCursor *string `json:"next_cursor"`
}
type CommandResponse struct {
AlertID string `json:"alert_id"`
State string `json:"state"`
ActorRef string `json:"actor_ref"`
OccurredAt time.Time `json:"occurred_at"`
Code string `json:"code,omitempty"`
}
type Repository interface {
AlertReady(context.Context) error
PublishRule(context.Context, RuleSpec) (bool, error)
EvaluateNext(context.Context) (bool, error)
ListAlerts(context.Context, int64, int64, string, int, string) (Page, error)
GetAlert(context.Context, int64, int64, string) (Detail, error)
Command(context.Context, int64, int64, string, string, string, string, *string) (int, CommandResponse, error)
}
func Publish(ctx context.Context, repository Repository, rules []RuleSpec) error {
for _, rule := range rules {
if _, err := repository.PublishRule(ctx, rule); err != nil {
return fmt.Errorf("publish Bell alert rule %q: %w", rule.RuleKey, err)
}
}
return nil
}
func RunWorker(ctx context.Context, repository Repository, onError func(error)) {
idle := time.NewTicker(250 * time.Millisecond)
defer idle.Stop()
for {
worked, err := repository.EvaluateNext(ctx)
if err != nil {
if ctx.Err() != nil {
return
}
if onError != nil {
onError(err)
}
select {
case <-ctx.Done():
return
case <-time.After(time.Second):
}
continue
}
if worked {
continue
}
select {
case <-ctx.Done():
return
case <-idle.C:
}
}
}