WayfinderAK 6f7549d439
All checks were successful
build-image / docker (push) Successful in 53s
Implement phase 1 foundations
2026-07-03 20:14:06 -08:00

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