This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Rule struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
LogQL string `json:"logql"`
|
||||
Severity string `json:"severity"`
|
||||
Threshold int `json:"threshold"`
|
||||
Window string `json:"window"`
|
||||
Cooldown string `json:"cooldown,omitempty"`
|
||||
AlertChannels []string `json:"alert_channels,omitempty"`
|
||||
AnalysisEnabled bool `json:"analysis_enabled"`
|
||||
Actions []Action `json:"actions,omitempty"`
|
||||
LastCheckedAt time.Time `json:"last_checked_at,omitempty"`
|
||||
LastMatchedAt time.Time `json:"last_matched_at,omitempty"`
|
||||
LastAlertedAt time.Time `json:"last_alerted_at,omitempty"`
|
||||
LastMatchCount int `json:"last_match_count,omitempty"`
|
||||
SuppressedCount int `json:"suppressed_count,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
}
|
||||
|
||||
type Action struct {
|
||||
Type string `json:"type"`
|
||||
Enabled bool `json:"enabled"`
|
||||
DryRun *bool `json:"dry_run,omitempty"`
|
||||
Params map[string]string `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
type AlertChannel struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Params map[string]string `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
type Incident struct {
|
||||
ID string `json:"id"`
|
||||
RuleID string `json:"rule_id"`
|
||||
RuleName string `json:"rule_name"`
|
||||
Severity string `json:"severity"`
|
||||
Count int `json:"count"`
|
||||
Summary string `json:"summary"`
|
||||
AlertResults []string `json:"alert_results,omitempty"`
|
||||
RemediationEvidence []string `json:"remediation_evidence,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type Data struct {
|
||||
Rules []Rule `json:"rules"`
|
||||
AlertChannels []AlertChannel `json:"alert_channels"`
|
||||
Incidents []Incident `json:"incidents"`
|
||||
}
|
||||
|
||||
type FileStore struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewFileStore(path string) *FileStore { return &FileStore{path: path} }
|
||||
|
||||
func (s *FileStore) Load() (Data, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.loadLocked()
|
||||
}
|
||||
|
||||
func (s *FileStore) Save(data Data) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.saveLocked(data)
|
||||
}
|
||||
|
||||
func (s *FileStore) UpsertRule(rule Rule) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
data, err := s.loadLocked()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rule.ID == "" {
|
||||
rule.ID = slug(rule.Name)
|
||||
}
|
||||
if rule.ID == "" {
|
||||
return errors.New("rule id or name is required")
|
||||
}
|
||||
if rule.Threshold <= 0 {
|
||||
rule.Threshold = 1
|
||||
}
|
||||
if rule.Window == "" {
|
||||
rule.Window = "5m"
|
||||
}
|
||||
if rule.Cooldown == "" {
|
||||
rule.Cooldown = "1h"
|
||||
}
|
||||
for i := range data.Rules {
|
||||
if data.Rules[i].ID == rule.ID {
|
||||
rule.LastCheckedAt = data.Rules[i].LastCheckedAt
|
||||
rule.LastMatchedAt = data.Rules[i].LastMatchedAt
|
||||
rule.LastAlertedAt = data.Rules[i].LastAlertedAt
|
||||
rule.LastMatchCount = data.Rules[i].LastMatchCount
|
||||
rule.SuppressedCount = data.Rules[i].SuppressedCount
|
||||
rule.LastError = data.Rules[i].LastError
|
||||
data.Rules[i] = rule
|
||||
return s.saveLocked(data)
|
||||
}
|
||||
}
|
||||
data.Rules = append(data.Rules, rule)
|
||||
return s.saveLocked(data)
|
||||
}
|
||||
|
||||
func (s *FileStore) UpsertAlertChannel(channel AlertChannel) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
data, err := s.loadLocked()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if channel.ID == "" {
|
||||
channel.ID = slug(channel.Name)
|
||||
}
|
||||
if channel.ID == "" {
|
||||
return errors.New("channel id or name is required")
|
||||
}
|
||||
if channel.Params == nil {
|
||||
channel.Params = map[string]string{}
|
||||
}
|
||||
for i := range data.AlertChannels {
|
||||
if data.AlertChannels[i].ID == channel.ID {
|
||||
preserveSecrets(data.AlertChannels[i].Params, channel.Params)
|
||||
data.AlertChannels[i] = channel
|
||||
return s.saveLocked(data)
|
||||
}
|
||||
}
|
||||
data.AlertChannels = append(data.AlertChannels, channel)
|
||||
return s.saveLocked(data)
|
||||
}
|
||||
|
||||
func preserveSecrets(oldParams, newParams map[string]string) {
|
||||
for _, key := range []string{"token", "password", "secret", "authorization"} {
|
||||
if newParams[key] == "" && oldParams[key] != "" {
|
||||
newParams[key] = oldParams[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *FileStore) DeleteAlertChannel(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
data, err := s.loadLocked()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
channels := data.AlertChannels[:0]
|
||||
for _, channel := range data.AlertChannels {
|
||||
if channel.ID != id {
|
||||
channels = append(channels, channel)
|
||||
}
|
||||
}
|
||||
data.AlertChannels = channels
|
||||
for i := range data.Rules {
|
||||
ids := data.Rules[i].AlertChannels[:0]
|
||||
for _, channelID := range data.Rules[i].AlertChannels {
|
||||
if channelID != id {
|
||||
ids = append(ids, channelID)
|
||||
}
|
||||
}
|
||||
data.Rules[i].AlertChannels = ids
|
||||
}
|
||||
return s.saveLocked(data)
|
||||
}
|
||||
|
||||
func (s *FileStore) UpdateRule(id string, fn func(*Rule)) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
data, err := s.loadLocked()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range data.Rules {
|
||||
if data.Rules[i].ID == id {
|
||||
fn(&data.Rules[i])
|
||||
return s.saveLocked(data)
|
||||
}
|
||||
}
|
||||
return errors.New("rule not found")
|
||||
}
|
||||
|
||||
func (s *FileStore) AddIncident(incident Incident) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
data, err := s.loadLocked()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if incident.ID == "" {
|
||||
incident.ID = slug(incident.RuleName) + "-" + incident.CreatedAt.Format("20060102150405")
|
||||
}
|
||||
data.Incidents = append([]Incident{incident}, data.Incidents...)
|
||||
if len(data.Incidents) > 200 {
|
||||
data.Incidents = data.Incidents[:200]
|
||||
}
|
||||
return s.saveLocked(data)
|
||||
}
|
||||
|
||||
func (s *FileStore) loadLocked() (Data, error) {
|
||||
var data Data
|
||||
contents, err := os.ReadFile(s.path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return data, nil
|
||||
}
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
if len(contents) == 0 {
|
||||
return data, nil
|
||||
}
|
||||
return data, json.Unmarshal(contents, &data)
|
||||
}
|
||||
|
||||
func (s *FileStore) saveLocked(data Data) error {
|
||||
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
contents, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(s.path, append(contents, '\n'), 0o600)
|
||||
}
|
||||
|
||||
func slug(value string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
var b strings.Builder
|
||||
lastDash := false
|
||||
for _, r := range value {
|
||||
ok := r >= 'a' && r <= 'z' || r >= '0' && r <= '9'
|
||||
if ok {
|
||||
b.WriteRune(r)
|
||||
lastDash = false
|
||||
continue
|
||||
}
|
||||
if !lastDash {
|
||||
b.WriteByte('-')
|
||||
lastDash = true
|
||||
}
|
||||
}
|
||||
return strings.Trim(b.String(), "-")
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package store
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestUpsertRuleDefaults(t *testing.T) {
|
||||
s := NewFileStore(t.TempDir() + "/config.json")
|
||||
if err := s.UpsertRule(Rule{Name: "API Errors", LogQL: `{service="api"} |= "error"`}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := s.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(data.Rules) != 1 {
|
||||
t.Fatalf("expected one rule, got %d", len(data.Rules))
|
||||
}
|
||||
rule := data.Rules[0]
|
||||
if rule.ID != "api-errors" || rule.Threshold != 1 || rule.Window != "5m" || rule.Cooldown != "1h" {
|
||||
t.Fatalf("unexpected defaults: %#v", rule)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertAlertChannelPreservesSecret(t *testing.T) {
|
||||
s := NewFileStore(t.TempDir() + "/config.json")
|
||||
if err := s.UpsertAlertChannel(AlertChannel{ID: "ntfy", Name: "ntfy", Type: "ntfy", Enabled: true, Params: map[string]string{"token": "secret", "topic": "alerts"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.UpsertAlertChannel(AlertChannel{ID: "ntfy", Name: "ntfy", Type: "ntfy", Enabled: true, Params: map[string]string{"topic": "alerts2"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := s.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := data.AlertChannels[0].Params["token"]; got != "secret" {
|
||||
t.Fatalf("secret was not preserved: %q", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user