// Package api: metric-alert rule HTTP handlers. The evaluator lives in // internal/metricalert; this file is the REST surface that lets // operators create, edit, and delete threshold rules. Mirrors the // log-scan rule handlers. package api import ( "errors" "net/http" "strconv" "strings" "github.com/go-chi/chi/v5" "github.com/alexei/tinyforge/internal/store" ) // metricAlertRuleInput is the JSON shape accepted by POST + PATCH. // Pointers distinguish "absent" from explicit empty/zero. WorkloadID is // immutable on update (per store.UpdateMetricAlertRule) so it only takes // effect on create. type metricAlertRuleInput struct { WorkloadID *string `json:"workload_id"` Name *string `json:"name"` Metric *string `json:"metric"` Comparator *string `json:"comparator"` Threshold *float64 `json:"threshold"` Severity *string `json:"severity"` CooldownSeconds *int `json:"cooldown_seconds"` Enabled *bool `json:"enabled"` } // listMetricAlertRules handles GET /api/metric-alert-rules. Optional // query filter `workload_id=...` returns rules applying to that workload // (its own rows plus globals). func (s *Server) listMetricAlertRules(w http.ResponseWriter, r *http.Request) { if wlID := r.URL.Query().Get("workload_id"); wlID != "" { out, err := s.store.ListMetricAlertRulesByWorkload(wlID) if err != nil { respondError(w, http.StatusInternalServerError, "list metric alert rules") return } respondJSON(w, http.StatusOK, out) return } out, err := s.store.ListMetricAlertRules() if err != nil { respondError(w, http.StatusInternalServerError, "list metric alert rules") return } respondJSON(w, http.StatusOK, out) } // getMetricAlertRule handles GET /api/metric-alert-rules/{id}. func (s *Server) getMetricAlertRule(w http.ResponseWriter, r *http.Request) { id, ok := parseMetricAlertRuleID(w, r) if !ok { return } rule, err := s.store.GetMetricAlertRule(id) if err != nil { mapStoreError(w, err, "metric alert rule") return } respondJSON(w, http.StatusOK, rule) } // createMetricAlertRule handles POST /api/metric-alert-rules. func (s *Server) createMetricAlertRule(w http.ResponseWriter, r *http.Request) { var in metricAlertRuleInput if !decodeJSON(w, r, &in) { return } rule := store.MetricAlertRule{ WorkloadID: derefString(in.WorkloadID), Name: derefString(in.Name), Metric: derefString(in.Metric), Comparator: derefString(in.Comparator), Threshold: derefFloat64(in.Threshold), Severity: firstNonEmpty(derefString(in.Severity), store.LogScanSeverityWarn), CooldownSeconds: derefIntDefault(in.CooldownSeconds, 300), Enabled: in.Enabled == nil || *in.Enabled, } if msg := validateMetricAlertInput(rule); msg != "" { respondError(w, http.StatusBadRequest, msg) return } out, err := s.store.CreateMetricAlertRule(rule) if err != nil { if isMetricAlertValidationErr(err) { respondError(w, http.StatusBadRequest, err.Error()) return } respondError(w, http.StatusInternalServerError, "create metric alert rule") return } respondJSON(w, http.StatusCreated, out) } // updateMetricAlertRule handles PATCH /api/metric-alert-rules/{id}. // workload_id is immutable; name/metric/comparator/threshold/severity/ // cooldown/enabled are individually overridable. func (s *Server) updateMetricAlertRule(w http.ResponseWriter, r *http.Request) { id, ok := parseMetricAlertRuleID(w, r) if !ok { return } existing, err := s.store.GetMetricAlertRule(id) if err != nil { mapStoreError(w, err, "metric alert rule") return } var in metricAlertRuleInput if !decodeJSON(w, r, &in) { return } if in.Name != nil { existing.Name = *in.Name } if in.Metric != nil && *in.Metric != "" { existing.Metric = *in.Metric } if in.Comparator != nil && *in.Comparator != "" { existing.Comparator = *in.Comparator } if in.Threshold != nil { existing.Threshold = *in.Threshold } if in.Severity != nil && *in.Severity != "" { existing.Severity = *in.Severity } if in.CooldownSeconds != nil { existing.CooldownSeconds = *in.CooldownSeconds } if in.Enabled != nil { existing.Enabled = *in.Enabled } if msg := validateMetricAlertInput(existing); msg != "" { respondError(w, http.StatusBadRequest, msg) return } out, err := s.store.UpdateMetricAlertRule(existing) if err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "metric alert rule") return } if isMetricAlertValidationErr(err) { respondError(w, http.StatusBadRequest, err.Error()) return } respondError(w, http.StatusInternalServerError, "update metric alert rule") return } respondJSON(w, http.StatusOK, out) } // deleteMetricAlertRule handles DELETE /api/metric-alert-rules/{id}. func (s *Server) deleteMetricAlertRule(w http.ResponseWriter, r *http.Request) { id, ok := parseMetricAlertRuleID(w, r) if !ok { return } if err := s.store.DeleteMetricAlertRule(id); err != nil { mapStoreError(w, err, "metric alert rule") return } w.WriteHeader(http.StatusNoContent) } // validateMetricAlertInput does boundary validation so we return a // clear 400 before hitting the store. The store re-validates the same // invariants as a backstop. func validateMetricAlertInput(rule store.MetricAlertRule) string { if strings.TrimSpace(rule.Name) == "" { return "name is required" } switch rule.Metric { case store.MetricCPUPercent, store.MetricMemoryPercent, store.MetricMemoryBytes: default: return "invalid metric: must be cpu_percent, memory_percent, or memory_bytes" } switch rule.Comparator { case store.MetricComparatorGT, store.MetricComparatorLT: default: return "invalid comparator: must be gt or lt" } switch rule.Severity { case store.LogScanSeverityInfo, store.LogScanSeverityWarn, store.LogScanSeverityError, "": default: return "invalid severity: must be info, warn, or error" } if rule.CooldownSeconds < 0 { return "cooldown_seconds must be >= 0" } return "" } // isMetricAlertValidationErr maps the store's validation errors to 400 // rather than 500 without leaking driver text. func isMetricAlertValidationErr(err error) bool { if err == nil { return false } msg := err.Error() for _, needle := range []string{ "name is required", "invalid metric", "invalid comparator", "invalid severity", "cooldown_seconds must be", } { if strings.Contains(msg, needle) { return true } } return false } func parseMetricAlertRuleID(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 derefFloat64(p *float64) float64 { if p == nil { return 0 } return *p }