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>
110 lines
2.9 KiB
Go
110 lines
2.9 KiB
Go
package logscanner
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/alexei/tinyforge/internal/store"
|
|
)
|
|
|
|
func TestBuildSnapshot_CompileErrorsReported(t *testing.T) {
|
|
rows := []store.LogScanRule{
|
|
{ID: 1, Pattern: `valid`, Enabled: true},
|
|
{ID: 2, Pattern: `([unclosed`, Enabled: true},
|
|
}
|
|
snap, errs := BuildSnapshot(rows)
|
|
if snap == nil {
|
|
t.Fatal("snapshot should not be nil")
|
|
}
|
|
if len(errs) != 1 {
|
|
t.Fatalf("expected 1 compile error, got %d", len(errs))
|
|
}
|
|
if len(snap.global) != 1 {
|
|
t.Errorf("expected 1 global rule (valid one), got %d", len(snap.global))
|
|
}
|
|
}
|
|
|
|
func TestEffectiveFor_GlobalOnly(t *testing.T) {
|
|
rows := []store.LogScanRule{
|
|
{ID: 1, Pattern: `panic`, Enabled: true},
|
|
{ID: 2, Pattern: `fatal`, Enabled: true},
|
|
}
|
|
snap, _ := BuildSnapshot(rows)
|
|
out := snap.EffectiveFor("w1")
|
|
if len(out) != 2 {
|
|
t.Fatalf("expected 2 rules, got %d", len(out))
|
|
}
|
|
}
|
|
|
|
func TestEffectiveFor_WorkloadAddition(t *testing.T) {
|
|
rows := []store.LogScanRule{
|
|
{ID: 1, Pattern: `panic`, Enabled: true},
|
|
{ID: 2, Pattern: `slow_query`, WorkloadID: "w1", Enabled: true},
|
|
}
|
|
snap, _ := BuildSnapshot(rows)
|
|
out := snap.EffectiveFor("w1")
|
|
if len(out) != 2 {
|
|
t.Fatalf("workload w1 should see both: %d", len(out))
|
|
}
|
|
out2 := snap.EffectiveFor("w2")
|
|
if len(out2) != 1 {
|
|
t.Errorf("workload w2 should see only the global: %d", len(out2))
|
|
}
|
|
}
|
|
|
|
func TestEffectiveFor_OverrideReplacesGlobal(t *testing.T) {
|
|
rows := []store.LogScanRule{
|
|
{ID: 1, Pattern: `panic`, Severity: "warn", Enabled: true},
|
|
{
|
|
ID: 2, WorkloadID: "w1", OverridesID: 1,
|
|
Pattern: `panic`, Severity: "error", Enabled: true,
|
|
},
|
|
}
|
|
snap, _ := BuildSnapshot(rows)
|
|
out := snap.EffectiveFor("w1")
|
|
if len(out) != 1 {
|
|
t.Fatalf("expected 1 rule, got %d", len(out))
|
|
}
|
|
if out[0].Severity != "error" {
|
|
t.Errorf("override severity should win: %q", out[0].Severity)
|
|
}
|
|
// Other workloads still see the original.
|
|
out2 := snap.EffectiveFor("w2")
|
|
if len(out2) != 1 || out2[0].Severity != "warn" {
|
|
t.Errorf("w2 should see original severity, got %+v", out2)
|
|
}
|
|
}
|
|
|
|
func TestEffectiveFor_DisabledOverrideSuppresses(t *testing.T) {
|
|
rows := []store.LogScanRule{
|
|
{ID: 1, Pattern: `panic`, Enabled: true},
|
|
{
|
|
ID: 2, WorkloadID: "w1", OverridesID: 1,
|
|
Pattern: `panic`, Enabled: false,
|
|
},
|
|
}
|
|
snap, _ := BuildSnapshot(rows)
|
|
if len(snap.EffectiveFor("w1")) != 0 {
|
|
t.Errorf("disabled override should suppress global for w1")
|
|
}
|
|
if len(snap.EffectiveFor("w2")) != 1 {
|
|
t.Errorf("w2 should still see the global")
|
|
}
|
|
}
|
|
|
|
func TestEffectiveFor_DisabledGlobalSkipped(t *testing.T) {
|
|
rows := []store.LogScanRule{
|
|
{ID: 1, Pattern: `panic`, Enabled: false},
|
|
}
|
|
snap, _ := BuildSnapshot(rows)
|
|
if len(snap.EffectiveFor("w1")) != 0 {
|
|
t.Errorf("disabled global should not appear in effective set")
|
|
}
|
|
}
|
|
|
|
func TestEffectiveFor_NilSnapshot(t *testing.T) {
|
|
var snap *Snapshot
|
|
if out := snap.EffectiveFor("w1"); out != nil {
|
|
t.Errorf("nil snapshot should return nil, got %+v", out)
|
|
}
|
|
}
|