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(), "-") }