// Package logscanner tails running container logs, matches lines // against operator-configured regex rules, and emits event_log entries // via the events bus. The package is split into four files: // // - rules.go: the rule snapshot — compiled regexes + per-rule // metadata. Snapshots are immutable; the manager builds a new // snapshot on every rule change and swaps it atomically. // - engine.go: rule evaluation per line + cooldown + token bucket. // - tail.go: per-container goroutine reading docker log stream. // - manager.go: container lifecycle polling + tail lifecycle. package logscanner import ( "fmt" "regexp" "github.com/alexei/tinyforge/internal/store" ) // Rule is a compiled, immutable representation of a store.LogScanRule. // Built once when the snapshot is loaded; held by every tail goroutine // via the snapshot atomic pointer. type Rule struct { ID int64 WorkloadID string // "" = global Name string Pattern *regexp.Regexp Severity string Streams string // "all" | "stdout" | "stderr" CooldownSeconds int } // Snapshot is the rule set as seen by tails. Built from a flat slice // of store.LogScanRule rows: globals are expanded via // store.EffectiveLogScanRules to yield the per-workload effective // set at lookup time. // // The snapshot is immutable. Hot-reload semantics are achieved by // building a fresh snapshot in the manager and swapping the atomic // pointer; in-flight tails finish their current match against the // old snapshot, then pick up the new one on the next line. // // MUST NOT mutate any field (including map values, including slice // elements) after BuildSnapshot returns — tails read these // concurrently with no locking and rely on immutability for safety. type Snapshot struct { // global is the list of compiled global rules (workload_id == ""). global []Rule // perWorkload[workloadID] holds workload-only additions AND // per-workload overrides (already resolved against globals). // EffectiveFor(id) merges global + perWorkload[id] minus any // global overridden under that workload. perWorkload map[string][]Rule // overrides[id] is the workload's overriding rule (compiled). // overrides[id][globalID] -> Rule (the override row). overrides map[string]map[int64]Rule } // BuildSnapshot compiles every rule's pattern and returns an // immutable Snapshot. Rules that fail to compile are skipped with // their error reported to the caller — store-side validation keeps // the pattern field as a free-form regex string, so engine-time // compile failures are an expected, recoverable mode. // // The returned error slice is informational, not fatal: bad rules // are dropped from the snapshot but the rest still work. func BuildSnapshot(rows []store.LogScanRule) (*Snapshot, []error) { s := &Snapshot{ perWorkload: map[string][]Rule{}, overrides: map[string]map[int64]Rule{}, } var errs []error for _, row := range rows { if !row.Enabled { // Skip outright. Overrides with enabled=false still // need to be recorded so they suppress a global — // handled by tracking disable separately below. } re, err := regexp.Compile(row.Pattern) if err != nil { errs = append(errs, fmt.Errorf("rule #%d %q: %w", row.ID, row.Name, err)) continue } r := Rule{ ID: row.ID, WorkloadID: row.WorkloadID, Name: row.Name, Pattern: re, Severity: row.Severity, Streams: row.Streams, CooldownSeconds: row.CooldownSeconds, } switch { case row.WorkloadID == "" && row.OverridesID == 0: if row.Enabled { s.global = append(s.global, r) } case row.WorkloadID != "" && row.OverridesID == 0: if row.Enabled { s.perWorkload[row.WorkloadID] = append(s.perWorkload[row.WorkloadID], r) } case row.WorkloadID != "" && row.OverridesID != 0: // Override row: always record so a disabled override // suppresses the global for this workload. if _, ok := s.overrides[row.WorkloadID]; !ok { s.overrides[row.WorkloadID] = map[int64]Rule{} } if row.Enabled { s.overrides[row.WorkloadID][row.OverridesID] = r } else { // Encode "disabled override" as the zero rule so // EffectiveFor can drop it without re-querying. s.overrides[row.WorkloadID][row.OverridesID] = Rule{ID: row.OverridesID} } } } return s, errs } // EffectiveFor returns the rule list to evaluate against logs of a // specific workload. Equivalent to store.EffectiveLogScanRules but // operates on the compiled snapshot, so the hot path is allocation // free except for the result slice. func (s *Snapshot) EffectiveFor(workloadID string) []Rule { if s == nil { return nil } overrides := s.overrides[workloadID] out := make([]Rule, 0, len(s.global)+len(s.perWorkload[workloadID])) for _, g := range s.global { if ov, ok := overrides[g.ID]; ok { if ov.Pattern == nil { // Suppressed for this workload — skip. continue } out = append(out, ov) continue } out = append(out, g) } out = append(out, s.perWorkload[workloadID]...) return out }