feat(bell): add alert acknowledgement vertical slice
Harness governance / validate (pull_request) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
// 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:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package alert
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadRulesRejectsUnknownAndDuplicateValues(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "rules.json")
|
||||
valid := `{"version":1,"rules":[{"tenant_id":1,"site_id":2,"rule_key":"zone-entry","display_name":"区域闯入","event_kind":"zone_entry","minimum_severity":"medium","enabled":true,"effective_from":"2026-08-11T00:00:00Z"}]}`
|
||||
if err := os.WriteFile(path, []byte(valid), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rules, err := LoadRules(path)
|
||||
if err != nil || len(rules) != 1 {
|
||||
t.Fatalf("load valid rules: %v %#v", err, rules)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(`{"version":1,"unknown":true,"rules":[]}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := LoadRules(path); err == nil {
|
||||
t.Fatal("unknown rule document property was accepted")
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(valid+` trailing`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := LoadRules(path); err == nil {
|
||||
t.Fatal("trailing rule file data was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeverityRankIsStable(t *testing.T) {
|
||||
for index, value := range []string{"low", "medium", "high", "critical"} {
|
||||
rank, ok := SeverityRank(value)
|
||||
if !ok || rank != index {
|
||||
t.Fatalf("rank %q: %d %v", value, rank, ok)
|
||||
}
|
||||
}
|
||||
if _, ok := SeverityRank("urgent"); ok {
|
||||
t.Fatal("unknown severity was accepted")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user