261 lines
6.5 KiB
Go
261 lines
6.5 KiB
Go
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(), "-")
|
|
}
|