7a9ff7ad54
Two paired backends sharing the events.Bus seam:
Event triggers (consumer-side):
- internal/store/event_triggers.go — CRUD with action_secret
redaction on read (placeholder echo treated as "no change" on
PATCH so secrets aren't accidentally wiped).
- internal/events/dispatcher.go — bus subscriber, AND-composed
filters (severity CSV, source CSV, message regex with memoized
compile cache). Structural loop-prevention: never writes to
event_log. Sends via notifier.SendPayload.
- internal/notify: SendPayload + SendSyncForTestPayload methods,
TierEventTrigger constant, doSendRaw shared with the legacy
Event-shaped path.
- internal/api/event_triggers.go — admin-gated CRUD + /test
sending the real TriggerWebhookPayload shape. SSRF guard
rejects loopback / link-local / unspecified targets. PATCH
uses pointer-typed DTO for partial updates.
Log scanner (producer-side):
- internal/logscanner/ — engine (per-rule cooldown +
per-container token bucket, atomic drop counters), tail
(multiplexed docker frame demuxer with TTY fallback + 16 MiB
payload cap + 1 MiB reassembly cap + RFC3339Nano-validated
timestamp strip + UTF-8-safe message truncation), manager
(5s container polling, atomic.Pointer[Snapshot] hot-reload,
HitEmitter writes event_log + publishes EventLog so the
trigger dispatcher picks them up immediately).
- internal/docker/container.go — ContainerLogsOpts exposes
stream selection for stderr-only / stdout-only rules.
- internal/store: log_scan_rules table + CRUD with
EffectiveLogScanRules resolver (globals minus per-workload
overrides plus workload-only additions). Transactional
cascade-delete of overrides when a global rule is removed.
- internal/api/log_scan_rules.go — admin-gated CRUD + /test
(sample_line → matched/captures) + /stats (drop counters +
active tail count + last-snapshot compile errors) +
GET /api/workloads/{id}/effective-rules.
cmd/server/main.go wires both subsystems next to the existing
RegisterPersistentLogger. Coverage spans engine cooldown / bucket
counter tests, snapshot effective-set semantics, manager compile-
error capture, dispatcher matching, store validation +
cascade-delete, API URL validator + secret redaction.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
156 lines
4.1 KiB
Go
156 lines
4.1 KiB
Go
package store
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestCreateLogScanRule_Validates(t *testing.T) {
|
|
s := newTestStore(t)
|
|
cases := []struct {
|
|
name string
|
|
in LogScanRule
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "missing name",
|
|
in: LogScanRule{Pattern: "x"},
|
|
wantErr: "name is required",
|
|
},
|
|
{
|
|
name: "missing pattern",
|
|
in: LogScanRule{Name: "n"},
|
|
wantErr: "pattern is required",
|
|
},
|
|
{
|
|
name: "bad severity",
|
|
in: LogScanRule{Name: "n", Pattern: "x", Severity: "loud"},
|
|
wantErr: "invalid severity",
|
|
},
|
|
{
|
|
name: "bad streams",
|
|
in: LogScanRule{Name: "n", Pattern: "x", Streams: "both"},
|
|
wantErr: "invalid streams",
|
|
},
|
|
{
|
|
name: "negative cooldown",
|
|
in: LogScanRule{Name: "n", Pattern: "x", CooldownSeconds: -1},
|
|
wantErr: "cooldown_seconds must be",
|
|
},
|
|
{
|
|
name: "override without workload",
|
|
in: LogScanRule{Name: "n", Pattern: "x", OverridesID: 5},
|
|
wantErr: "override row requires workload_id",
|
|
},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
_, err := s.CreateLogScanRule(c.in)
|
|
if err == nil {
|
|
t.Fatalf("expected error containing %q, got nil", c.wantErr)
|
|
}
|
|
if !strings.Contains(err.Error(), c.wantErr) {
|
|
t.Fatalf("error mismatch: got %q want substring %q", err.Error(), c.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCreateAndGetLogScanRule(t *testing.T) {
|
|
s := newTestStore(t)
|
|
r, err := s.CreateLogScanRule(LogScanRule{
|
|
Name: "panics", Pattern: `\bpanic\b`, Severity: "error", Streams: "stderr",
|
|
CooldownSeconds: 30, Enabled: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
if r.ID == 0 {
|
|
t.Fatal("id should be set")
|
|
}
|
|
got, err := s.GetLogScanRule(r.ID)
|
|
if err != nil {
|
|
t.Fatalf("get: %v", err)
|
|
}
|
|
if got.Pattern != `\bpanic\b` {
|
|
t.Errorf("pattern mismatch: %q", got.Pattern)
|
|
}
|
|
if !got.Enabled {
|
|
t.Error("enabled lost on round-trip")
|
|
}
|
|
}
|
|
|
|
func TestEffectiveLogScanRules(t *testing.T) {
|
|
s := newTestStore(t)
|
|
g, _ := s.CreateLogScanRule(LogScanRule{
|
|
Name: "global", Pattern: "panic", Severity: "warn", Streams: "all", Enabled: true,
|
|
})
|
|
_, _ = s.CreateLogScanRule(LogScanRule{
|
|
Name: "w1-only", Pattern: "slow_query", WorkloadID: "w1", Severity: "info", Streams: "all", Enabled: true,
|
|
})
|
|
_, _ = s.CreateLogScanRule(LogScanRule{
|
|
Name: "override-for-w1", Pattern: "panic", WorkloadID: "w1", OverridesID: g.ID,
|
|
Severity: "error", Streams: "all", Enabled: true,
|
|
})
|
|
|
|
w1, err := s.EffectiveLogScanRules("w1")
|
|
if err != nil {
|
|
t.Fatalf("effective w1: %v", err)
|
|
}
|
|
if len(w1) != 2 {
|
|
t.Fatalf("w1 effective should be 2 (override + addition), got %d", len(w1))
|
|
}
|
|
// First entry replaces the global with the override (error severity).
|
|
if w1[0].Severity != "error" {
|
|
t.Errorf("override severity not applied: %q", w1[0].Severity)
|
|
}
|
|
|
|
w2, err := s.EffectiveLogScanRules("w2")
|
|
if err != nil {
|
|
t.Fatalf("effective w2: %v", err)
|
|
}
|
|
if len(w2) != 1 {
|
|
t.Fatalf("w2 effective should be 1 (just the global), got %d", len(w2))
|
|
}
|
|
if w2[0].Severity != "warn" {
|
|
t.Errorf("w2 should see original severity: %q", w2[0].Severity)
|
|
}
|
|
}
|
|
|
|
func TestDeleteLogScanRule_CascadesOverrides(t *testing.T) {
|
|
s := newTestStore(t)
|
|
g, _ := s.CreateLogScanRule(LogScanRule{
|
|
Name: "global", Pattern: "panic", Severity: "warn", Streams: "all", Enabled: true,
|
|
})
|
|
ov, _ := s.CreateLogScanRule(LogScanRule{
|
|
Name: "override", Pattern: "panic", WorkloadID: "w1", OverridesID: g.ID,
|
|
Severity: "error", Streams: "all", Enabled: true,
|
|
})
|
|
|
|
if err := s.DeleteLogScanRule(g.ID); err != nil {
|
|
t.Fatalf("delete: %v", err)
|
|
}
|
|
if _, err := s.GetLogScanRule(ov.ID); err == nil {
|
|
t.Error("override should be cascade-deleted with its global")
|
|
}
|
|
}
|
|
|
|
func TestUpdateLogScanRule(t *testing.T) {
|
|
s := newTestStore(t)
|
|
r, _ := s.CreateLogScanRule(LogScanRule{
|
|
Name: "n", Pattern: "x", Severity: "warn", Streams: "all", Enabled: true,
|
|
})
|
|
r.Pattern = "y"
|
|
r.Enabled = false
|
|
got, err := s.UpdateLogScanRule(r)
|
|
if err != nil {
|
|
t.Fatalf("update: %v", err)
|
|
}
|
|
if got.Pattern != "y" {
|
|
t.Errorf("pattern not updated: %q", got.Pattern)
|
|
}
|
|
if got.Enabled {
|
|
t.Error("enabled=false not applied")
|
|
}
|
|
}
|