Files
ITWorx-Pulse-Public/internal/alertcontrolapi/handler.go
T
ITWorx Pulse release export bd774932d5
Public source validation / validate (push) Failing after 3m8s
Publish ITWorx Pulse source
2026-09-03 02:09:19 +02:00

253 lines
8.6 KiB
Go

package alertcontrolapi
import (
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/itworx/pulse/internal/alertcontrol"
"github.com/itworx/pulse/internal/audit"
"github.com/itworx/pulse/internal/auth"
"github.com/itworx/pulse/internal/correlation"
"github.com/itworx/pulse/internal/problem"
)
type Handler struct {
Store alertcontrol.Store
Audit audit.Store
}
func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
principal, ok := auth.PrincipalFromContext(r.Context())
if !ok {
fail(w, r, http.StatusUnauthorized, "UNAUTHORIZED", "Authentication required.")
return
}
base := "/api/v1/alert-silences"
maintenance := strings.HasPrefix(r.URL.Path, "/api/v1/maintenance-windows")
if maintenance {
base = "/api/v1/maintenance-windows"
}
path := strings.Trim(strings.TrimPrefix(r.URL.Path, base), "/")
if path == "" {
switch r.Method {
case http.MethodGet:
h.list(w, r, maintenance)
case http.MethodPost:
if !requireOperate(w, r, principal.Role) {
return
}
h.create(w, r, principal.Subject, maintenance)
default:
fail(w, r, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "This method is not supported.")
}
return
}
if path == "preview" && r.Method == http.MethodPost {
h.preview(w, r, maintenance)
return
}
parts := strings.Split(path, "/")
if len(parts) == 2 && parts[1] == "revoke" && r.Method == http.MethodPost {
if !requireOperate(w, r, principal.Role) {
return
}
h.revoke(w, r, parts[0], principal.Subject, maintenance)
return
}
fail(w, r, http.StatusNotFound, "NOT_FOUND", "Alert-control route not found.")
}
func (h Handler) list(w http.ResponseWriter, r *http.Request, maintenance bool) {
limit := 100
if value := r.URL.Query().Get("limit"); value != "" {
parsed, err := strconv.Atoi(value)
if err != nil || parsed < 1 || parsed > 100 {
fail(w, r, http.StatusBadRequest, "INVALID_LIMIT", "The limit must be between 1 and 100.")
return
}
limit = parsed
}
if maintenance {
items, err := h.Store.ListMaintenance(r.Context(), limit, time.Now().UTC())
if err != nil {
h.repositoryFailure(w, r, err)
return
}
write(w, http.StatusOK, map[string]any{"items": items})
return
}
items, err := h.Store.ListSilences(r.Context(), limit, time.Now().UTC())
if err != nil {
h.repositoryFailure(w, r, err)
return
}
write(w, http.StatusOK, map[string]any{"items": items})
}
func (h Handler) create(w http.ResponseWriter, r *http.Request, actor string, maintenance bool) {
if maintenance {
var item alertcontrol.MaintenanceWindow
if err := decode(r, &item); err != nil {
fail(w, r, http.StatusBadRequest, "INVALID_MAINTENANCE", "The maintenance-window document is invalid.")
return
}
created, err := h.Store.CreateMaintenance(r.Context(), actor, item)
if err != nil {
h.repositoryFailure(w, r, err)
return
}
if err := h.record(r, actor, "maintenance_window.create", created.ID, "maintenance_window", nil, map[string]any{"state": created.State, "revision": created.Revision}); err != nil {
fail(w, r, http.StatusServiceUnavailable, "AUDIT_UNAVAILABLE", "The audit event could not be recorded.")
return
}
write(w, http.StatusCreated, map[string]any{"maintenance": created})
return
}
var item alertcontrol.Silence
if err := decode(r, &item); err != nil {
fail(w, r, http.StatusBadRequest, "INVALID_SILENCE", "The silence document is invalid.")
return
}
item.Owner = actor
created, err := h.Store.CreateSilence(r.Context(), actor, item)
if err != nil {
h.repositoryFailure(w, r, err)
return
}
if err := h.record(r, actor, "alert_silence.create", created.ID, "alert_silence", nil, map[string]any{"state": created.State, "revision": created.Revision}); err != nil {
fail(w, r, http.StatusServiceUnavailable, "AUDIT_UNAVAILABLE", "The audit event could not be recorded.")
return
}
write(w, http.StatusCreated, map[string]any{"silence": created})
}
func (h Handler) revoke(w http.ResponseWriter, r *http.Request, id, actor string, maintenance bool) {
expected, err := revision(r)
if err != nil {
fail(w, r, http.StatusBadRequest, "INVALID_REVISION", "A valid If-Match or revision value is required.")
return
}
if maintenance {
item, err := h.Store.RevokeMaintenance(r.Context(), id, actor, expected, time.Now().UTC())
if err != nil {
h.repositoryFailure(w, r, err)
return
}
if err := h.record(r, actor, "maintenance_window.revoke", id, "maintenance_window", map[string]any{"revision": expected}, map[string]any{"state": item.State, "revision": item.Revision}); err != nil {
fail(w, r, http.StatusServiceUnavailable, "AUDIT_UNAVAILABLE", "The audit event could not be recorded.")
return
}
write(w, http.StatusOK, map[string]any{"maintenance": item})
return
}
item, err := h.Store.RevokeSilence(r.Context(), id, actor, expected, time.Now().UTC())
if err != nil {
h.repositoryFailure(w, r, err)
return
}
if err := h.record(r, actor, "alert_silence.revoke", id, "alert_silence", map[string]any{"revision": expected}, map[string]any{"state": item.State, "revision": item.Revision}); err != nil {
fail(w, r, http.StatusServiceUnavailable, "AUDIT_UNAVAILABLE", "The audit event could not be recorded.")
return
}
write(w, http.StatusOK, map[string]any{"silence": item})
}
func (h Handler) preview(w http.ResponseWriter, r *http.Request, maintenance bool) {
var request struct {
Matcher alertcontrol.Matcher `json:"matcher"`
Selector alertcontrol.Matcher `json:"selector"`
Signals []alertcontrol.Signal `json:"signals"`
}
if err := decode(r, &request); err != nil {
fail(w, r, http.StatusBadRequest, "INVALID_PREVIEW", "The matcher preview request is invalid.")
return
}
matcher := request.Matcher
if maintenance {
matcher = request.Selector
}
result, err := alertcontrol.PreviewSignals(matcher, request.Signals)
if err != nil {
h.repositoryFailure(w, r, err)
return
}
write(w, http.StatusOK, map[string]any{"preview": result})
}
func (h Handler) record(r *http.Request, actor, action, id, resourceType string, before, after map[string]any) error {
if h.Audit == nil {
return nil
}
return h.Audit.Append(r.Context(), audit.Event{Actor: actor, Action: action, ResourceType: resourceType, ResourceID: id, Result: "success", CorrelationID: correlation.FromContext(r.Context()), Before: before, After: after})
}
func (h Handler) repositoryFailure(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, alertcontrol.ErrInvalid):
fail(w, r, http.StatusBadRequest, "INVALID_ALERT_CONTROL", "The alert-control document is invalid.")
case errors.Is(err, alertcontrol.ErrConflict):
fail(w, r, http.StatusConflict, "REVISION_CONFLICT", "The alert control was changed or expired.")
case errors.Is(err, alertcontrol.ErrNotFound):
fail(w, r, http.StatusNotFound, "NOT_FOUND", "The alert control was not found.")
case errors.Is(err, alertcontrol.ErrUnavailable):
fail(w, r, http.StatusServiceUnavailable, "DATABASE_UNAVAILABLE", "Alert controls are unavailable.")
default:
fail(w, r, http.StatusInternalServerError, "ALERT_CONTROL_REQUEST_FAILED", "The alert-control request failed.")
}
}
func requireOperate(w http.ResponseWriter, r *http.Request, role auth.Role) bool {
if auth.Allows(role, auth.PermissionOperate) {
return true
}
fail(w, r, http.StatusForbidden, "FORBIDDEN", "Alert-control editing is not allowed for this role.")
return false
}
func revision(r *http.Request) (int64, error) {
value := r.Header.Get("If-Match")
if value == "" {
value = r.URL.Query().Get("revision")
}
value = strings.Trim(value, "\"")
if value == "" {
return 0, errors.New("revision required")
}
return strconv.ParseInt(value, 10, 64)
}
func decode(r *http.Request, target any) error {
contentType := strings.ToLower(strings.TrimSpace(strings.Split(r.Header.Get("Content-Type"), ";")[0]))
if contentType != "" && contentType != "application/json" {
return errors.New("unsupported content type")
}
body, err := io.ReadAll(io.LimitReader(r.Body, 2<<20+1))
if err != nil {
return err
}
defer r.Body.Close()
if len(body) > 2<<20 {
return errors.New("request too large")
}
decoder := json.NewDecoder(strings.NewReader(string(body)))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return err
}
var extra any
if err := decoder.Decode(&extra); err != io.EOF {
return errors.New("multiple JSON values")
}
return nil
}
func fail(w http.ResponseWriter, r *http.Request, status int, code, detail string) {
problem.Write(w, r, status, code, http.StatusText(status), detail, nil)
}
func write(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}