// Package api: log-scan rule HTTP handlers. The scanner manager // lives in internal/logscanner; this file is the REST surface that // lets operators create, edit, and test rules from the UI. package api import ( "errors" "log/slog" "net/http" "regexp" "strconv" "strings" "github.com/go-chi/chi/v5" "github.com/alexei/tinyforge/internal/logscanner" "github.com/alexei/tinyforge/internal/store" ) // LogScanReloader is what the API calls after any rule CRUD so the // scanner manager swaps its snapshot, and what the /stats endpoint // queries for runtime counters. Implemented by *logscanner.Manager; // nil-tolerant on the API side so the routes still work in a // scanner-disabled deployment. type LogScanReloader interface { ReloadRules() error Stats() logscanner.Stats } // SetLogScanReloader wires the API → manager reload signal. Called // from main after both subsystems are constructed. func (s *Server) SetLogScanReloader(r LogScanReloader) { s.logScanReloader = r } // ruleInput is the JSON shape accepted by POST + PATCH. Pointers // distinguish "absent" from explicit empty/zero. WorkloadID and // OverridesID are immutable on update (per store.UpdateLogScanRule) // so they only appear here for create. type ruleInput struct { WorkloadID *string `json:"workload_id"` OverridesID *int64 `json:"overrides_id"` Name *string `json:"name"` Pattern *string `json:"pattern"` Severity *string `json:"severity"` Streams *string `json:"streams"` CooldownSeconds *int `json:"cooldown_seconds"` Enabled *bool `json:"enabled"` } // listLogScanRules handles GET /api/log-scan-rules. Optional query // filter `workload_id=...` returns only rules scoped to that // workload (workload-only + override rows, NOT globals). func (s *Server) listLogScanRules(w http.ResponseWriter, r *http.Request) { if wlID := r.URL.Query().Get("workload_id"); wlID != "" { out, err := s.store.ListLogScanRulesByWorkload(wlID) if err != nil { respondError(w, http.StatusInternalServerError, "list log scan rules") return } respondJSON(w, http.StatusOK, out) return } out, err := s.store.ListLogScanRules() if err != nil { respondError(w, http.StatusInternalServerError, "list log scan rules") return } respondJSON(w, http.StatusOK, out) } // getLogScanRule handles GET /api/log-scan-rules/{id}. func (s *Server) getLogScanRule(w http.ResponseWriter, r *http.Request) { id, ok := parseRuleID(w, r) if !ok { return } rule, err := s.store.GetLogScanRule(id) if err != nil { mapStoreError(w, err, "log scan rule") return } respondJSON(w, http.StatusOK, rule) } // createLogScanRule handles POST /api/log-scan-rules. func (s *Server) createLogScanRule(w http.ResponseWriter, r *http.Request) { var in ruleInput if !decodeJSON(w, r, &in) { return } rule := store.LogScanRule{ WorkloadID: derefString(in.WorkloadID), OverridesID: derefInt64(in.OverridesID), Name: derefString(in.Name), Pattern: derefString(in.Pattern), Severity: firstNonEmpty(derefString(in.Severity), store.LogScanSeverityWarn), Streams: firstNonEmpty(derefString(in.Streams), store.LogScanStreamAll), CooldownSeconds: derefIntDefault(in.CooldownSeconds, 60), Enabled: in.Enabled == nil || *in.Enabled, } if msg := validateRulePattern(rule.Pattern); msg != "" { respondError(w, http.StatusBadRequest, msg) return } out, err := s.store.CreateLogScanRule(rule) if err != nil { // Store-side validation errors map to 400; anything else // (driver errors) is a 500 without leaking the raw text. if isClientValidationErr(err) { respondError(w, http.StatusBadRequest, err.Error()) return } respondError(w, http.StatusInternalServerError, "create log scan rule") return } s.reloadLogScan() respondJSON(w, http.StatusCreated, out) } // updateLogScanRule handles PATCH /api/log-scan-rules/{id}. Scope // fields (workload_id, overrides_id) are immutable; pattern/severity/ // streams/cooldown/enabled/name are individually overridable. func (s *Server) updateLogScanRule(w http.ResponseWriter, r *http.Request) { id, ok := parseRuleID(w, r) if !ok { return } existing, err := s.store.GetLogScanRule(id) if err != nil { mapStoreError(w, err, "log scan rule") return } var in ruleInput if !decodeJSON(w, r, &in) { return } if in.Name != nil { existing.Name = *in.Name } if in.Pattern != nil { existing.Pattern = *in.Pattern } if in.Severity != nil && *in.Severity != "" { existing.Severity = *in.Severity } if in.Streams != nil && *in.Streams != "" { existing.Streams = *in.Streams } if in.CooldownSeconds != nil { existing.CooldownSeconds = *in.CooldownSeconds } if in.Enabled != nil { existing.Enabled = *in.Enabled } if msg := validateRulePattern(existing.Pattern); msg != "" { respondError(w, http.StatusBadRequest, msg) return } out, err := s.store.UpdateLogScanRule(existing) if err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "log scan rule") return } if isClientValidationErr(err) { respondError(w, http.StatusBadRequest, err.Error()) return } respondError(w, http.StatusInternalServerError, "update log scan rule") return } s.reloadLogScan() respondJSON(w, http.StatusOK, out) } // deleteLogScanRule handles DELETE /api/log-scan-rules/{id}. Override // rows that reference this id are cascade-deleted by the store layer. func (s *Server) deleteLogScanRule(w http.ResponseWriter, r *http.Request) { id, ok := parseRuleID(w, r) if !ok { return } if err := s.store.DeleteLogScanRule(id); err != nil { mapStoreError(w, err, "log scan rule") return } s.reloadLogScan() w.WriteHeader(http.StatusNoContent) } // testLogScanRule handles POST /api/log-scan-rules/{id}/test. Body // `{"sample_line": "..."}` returns whether the rule pattern matches + // any captured subgroups. Lets operators iterate on regexes in the // UI without spinning up real container traffic. func (s *Server) testLogScanRule(w http.ResponseWriter, r *http.Request) { id, ok := parseRuleID(w, r) if !ok { return } rule, err := s.store.GetLogScanRule(id) if err != nil { mapStoreError(w, err, "log scan rule") return } var body struct { SampleLine string `json:"sample_line"` } if !decodeJSON(w, r, &body) { return } respondJSON(w, http.StatusOK, testRuleAgainstLine(rule, body.SampleLine)) } // getEffectiveLogScanRules handles GET /api/workloads/{id}/effective-rules. // Returns the resolved effective rule set (globals minus overrides + // workload-only + override-substitutes) that the scanner uses for // this workload's containers. func (s *Server) getEffectiveLogScanRules(w http.ResponseWriter, r *http.Request) { workloadID := chi.URLParam(r, "id") if workloadID == "" { respondError(w, http.StatusBadRequest, "workload id required") return } rules, err := s.store.EffectiveLogScanRules(workloadID) if err != nil { respondError(w, http.StatusInternalServerError, "compute effective rules") return } respondJSON(w, http.StatusOK, rules) } // testResult is the shape returned by /test. Keeping it focused — // caller wants a yes/no + captures so they can iterate, nothing more. type ruleTestResult struct { Matched bool `json:"matched"` Captures map[string]string `json:"captures,omitempty"` Error string `json:"error,omitempty"` } func testRuleAgainstLine(rule store.LogScanRule, line string) ruleTestResult { re, err := regexp.Compile(rule.Pattern) if err != nil { return ruleTestResult{Error: "rule pattern is invalid: " + err.Error()} } subs := re.FindStringSubmatch(line) if subs == nil { return ruleTestResult{Matched: false} } captures := map[string]string{} names := re.SubexpNames() for i, s := range subs[1:] { key := names[i+1] if key == "" { key = "$" + strconv.Itoa(i+1) } captures[key] = s } return ruleTestResult{Matched: true, Captures: captures} } func validateRulePattern(pattern string) string { if strings.TrimSpace(pattern) == "" { return "pattern is required" } if _, err := regexp.Compile(pattern); err != nil { return "pattern invalid: " + err.Error() } return "" } // isClientValidationErr returns true when the store error is one of // the validation errors raised by CreateLogScanRule / // UpdateLogScanRule (name/pattern required, invalid enum, negative // cooldown). Used to map those to 400 rather than 500 without // exposing driver text. func isClientValidationErr(err error) bool { if err == nil { return false } msg := err.Error() for _, needle := range []string{ "name is required", "pattern is required", "invalid severity", "invalid streams", "cooldown_seconds must be", "override row requires workload_id", } { if strings.Contains(msg, needle) { return true } } return false } func parseRuleID(w http.ResponseWriter, r *http.Request) (int64, bool) { raw := chi.URLParam(r, "id") id, err := strconv.ParseInt(raw, 10, 64) if err != nil || id <= 0 { respondError(w, http.StatusBadRequest, "invalid rule id") return 0, false } return id, true } func derefInt64(p *int64) int64 { if p == nil { return 0 } return *p } func derefIntDefault(p *int, def int) int { if p == nil { return def } return *p } // getLogScanStats handles GET /api/log-scan-rules/stats. Returns // engine drop counters + last-snapshot compile errors + active // tail count so operators can see when their patterns are too // greedy or syntactically broken. When the scanner manager is not // wired (scanner-disabled deployment), returns a zero-valued // shape rather than 404 so the frontend can render the panel // uniformly. func (s *Server) getLogScanStats(w http.ResponseWriter, r *http.Request) { if s.logScanReloader == nil { respondJSON(w, http.StatusOK, logscanner.Stats{}) return } respondJSON(w, http.StatusOK, s.logScanReloader.Stats()) } // reloadLogScan fires the manager's snapshot rebuild. Nil-tolerant // so the API can run before the manager is wired (and in // scanner-disabled deployments). Failures are logged at warn — // we don't fail the originating CRUD request because that already // succeeded, but operators need a signal so they don't chase a // "why isn't my rule firing?" mystery. func (s *Server) reloadLogScan() { if s.logScanReloader == nil { return } if err := s.logScanReloader.ReloadRules(); err != nil { slog.Warn("log-scan reload failed; manager snapshot may be stale", "error", err) } }