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>
89 lines
2.9 KiB
Go
89 lines
2.9 KiB
Go
package logscanner
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/alexei/tinyforge/internal/store"
|
|
)
|
|
|
|
// fakeRuleSource lets us inject rules into ReloadRules without
|
|
// standing up SQLite. Mirrors the fakeTriggerSource pattern from
|
|
// the events package.
|
|
type fakeRuleSource struct {
|
|
rows []store.LogScanRule
|
|
err error
|
|
}
|
|
|
|
func (f *fakeRuleSource) ListLogScanRules() ([]store.LogScanRule, error) {
|
|
if f.err != nil {
|
|
return nil, f.err
|
|
}
|
|
return f.rows, nil
|
|
}
|
|
|
|
func TestManagerStats_CapturesCompileErrors(t *testing.T) {
|
|
rs := &fakeRuleSource{rows: []store.LogScanRule{
|
|
{ID: 1, Name: "valid", Pattern: `panic`, Severity: "warn", Streams: "all", Enabled: true},
|
|
{ID: 2, Name: "broken", Pattern: `([unclosed`, Severity: "warn", Streams: "all", Enabled: true},
|
|
{ID: 3, Name: "also-broken", Pattern: `[`, Severity: "warn", Streams: "all", Enabled: true},
|
|
}}
|
|
m := NewManager(Config{Rules: rs})
|
|
if err := m.ReloadRules(); err != nil {
|
|
t.Fatalf("ReloadRules: %v", err)
|
|
}
|
|
stats := m.Stats()
|
|
if len(stats.LastCompileErrors) != 2 {
|
|
t.Fatalf("expected 2 compile errors, got %d: %+v", len(stats.LastCompileErrors), stats.LastCompileErrors)
|
|
}
|
|
// The error messages should mention the rule id/name from the
|
|
// BuildSnapshot format so operators can find which rule broke.
|
|
joined := strings.Join(stats.LastCompileErrors, "|")
|
|
if !strings.Contains(joined, "broken") {
|
|
t.Errorf("error messages should reference rule name 'broken': %s", joined)
|
|
}
|
|
}
|
|
|
|
func TestManagerStats_CompileErrorsReplacedOnReload(t *testing.T) {
|
|
// A broken rule then a reload with all-valid rules should
|
|
// clear the error list — operators expect the panel to flip
|
|
// from "2 errors" to "all clean" after they fix things.
|
|
rs := &fakeRuleSource{rows: []store.LogScanRule{
|
|
{ID: 1, Name: "broken", Pattern: `([`, Severity: "warn", Streams: "all", Enabled: true},
|
|
}}
|
|
m := NewManager(Config{Rules: rs})
|
|
_ = m.ReloadRules()
|
|
if len(m.Stats().LastCompileErrors) != 1 {
|
|
t.Fatal("expected one compile error before fix")
|
|
}
|
|
|
|
rs.rows = []store.LogScanRule{
|
|
{ID: 1, Name: "fixed", Pattern: `panic`, Severity: "warn", Streams: "all", Enabled: true},
|
|
}
|
|
_ = m.ReloadRules()
|
|
if len(m.Stats().LastCompileErrors) != 0 {
|
|
t.Errorf("expected zero compile errors after reload, got %d",
|
|
len(m.Stats().LastCompileErrors))
|
|
}
|
|
}
|
|
|
|
func TestManagerStats_ReloadErrorPropagates(t *testing.T) {
|
|
rs := &fakeRuleSource{err: errors.New("db down")}
|
|
m := NewManager(Config{Rules: rs})
|
|
if err := m.ReloadRules(); err == nil {
|
|
t.Fatal("expected ReloadRules to propagate the source error")
|
|
}
|
|
}
|
|
|
|
func TestManagerStats_ActiveTailsDefaultsZero(t *testing.T) {
|
|
// Without Start() and without a docker dependency we can't
|
|
// run real tails, but the counter should be a stable 0 read
|
|
// rather than panic/uninitialized.
|
|
rs := &fakeRuleSource{}
|
|
m := NewManager(Config{Rules: rs})
|
|
if got := m.Stats().ActiveTails; got != 0 {
|
|
t.Errorf("ActiveTails on fresh manager = %d, want 0", got)
|
|
}
|
|
}
|