Files
ITWorx-Pulse-Public/internal/alertopsapi/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

203 lines
6.8 KiB
Go

package alertopsapi
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/itworx/pulse/internal/alert"
"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 Store interface {
alert.AlertReader
AcknowledgeRevision(context.Context, string, string, string, time.Time, int64) (alert.Instance, alert.Occurrence, bool, error)
Unacknowledge(context.Context, string, string, string, time.Time, int64) (alert.Instance, alert.Occurrence, bool, error)
}
type Handler struct {
Store 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
}
path := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/v1/alerts"), "/")
if path == "" && r.Method == http.MethodGet {
h.list(w, r)
return
}
parts := strings.Split(path, "/")
if len(parts) == 1 && parts[0] != "" && r.Method == http.MethodGet {
h.get(w, r, parts[0])
return
}
if len(parts) == 2 && (parts[1] == "acknowledge" || parts[1] == "unacknowledge") && r.Method == http.MethodPost {
if !auth.Allows(principal.Role, auth.PermissionOperate) {
fail(w, r, http.StatusForbidden, "FORBIDDEN", "Alert operations are not allowed for this role.")
return
}
h.operate(w, r, parts[0], principal.Subject, parts[1] == "acknowledge")
return
}
fail(w, r, http.StatusNotFound, "NOT_FOUND", "Alert route not found.")
}
func (h Handler) list(w http.ResponseWriter, r *http.Request) {
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 alert limit must be between 1 and 100.")
return
}
limit = parsed
}
items, err := h.Store.ListAlerts(r.Context(), limit, r.URL.Query().Get("state"))
if err != nil {
h.repositoryFailure(w, r, err)
return
}
write(w, http.StatusOK, map[string]any{"items": items})
}
func (h Handler) get(w http.ResponseWriter, r *http.Request, id string) {
limit := 100
if value := r.URL.Query().Get("occurrenceLimit"); value != "" {
parsed, err := strconv.Atoi(value)
if err != nil || parsed < 1 || parsed > 500 {
fail(w, r, http.StatusBadRequest, "INVALID_LIMIT", "The occurrence limit must be between 1 and 500.")
return
}
limit = parsed
}
item, err := h.Store.GetAlert(r.Context(), id, limit)
if err != nil {
h.repositoryFailure(w, r, err)
return
}
write(w, http.StatusOK, map[string]any{"alert": item})
}
func (h Handler) operate(w http.ResponseWriter, r *http.Request, id, actor string, acknowledge 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
}
evaluationKey := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
if evaluationKey == "" {
var request struct {
EvaluationKey string `json:"evaluationKey"`
}
if err := decode(r, &request); err != nil {
fail(w, r, http.StatusBadRequest, "INVALID_OPERATION", "The alert operation request is invalid.")
return
}
evaluationKey = strings.TrimSpace(request.EvaluationKey)
}
if evaluationKey == "" || len(evaluationKey) > 160 {
fail(w, r, http.StatusBadRequest, "INVALID_OPERATION", "A bounded evaluation key or Idempotency-Key is required.")
return
}
var instance alert.Instance
var occurrence alert.Occurrence
var duplicate bool
if acknowledge {
instance, occurrence, duplicate, err = h.Store.AcknowledgeRevision(r.Context(), id, actor, evaluationKey, time.Now().UTC(), expected)
} else {
instance, occurrence, duplicate, err = h.Store.Unacknowledge(r.Context(), id, actor, evaluationKey, time.Now().UTC(), expected)
}
if err != nil {
h.repositoryFailure(w, r, err)
return
}
action := "alert.unacknowledge"
if acknowledge {
action = "alert.acknowledge"
}
result := "success"
if duplicate {
result = "idempotent"
}
if h.Audit != nil {
if err := h.Audit.Append(r.Context(), audit.Event{Actor: actor, Action: action, ResourceType: "alert_instance", ResourceID: id, Result: result, CorrelationID: correlation.FromContext(r.Context()), After: map[string]any{"state": instance.State, "revision": instance.Revision, "duplicate": duplicate}}); err != nil {
fail(w, r, http.StatusServiceUnavailable, "AUDIT_UNAVAILABLE", "The audit event could not be recorded.")
return
}
}
write(w, http.StatusOK, map[string]any{"instance": instance, "occurrence": occurrence, "duplicate": duplicate})
}
func (h Handler) repositoryFailure(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, alert.ErrInvalidObservation):
fail(w, r, http.StatusBadRequest, "INVALID_ALERT_OPERATION", "The alert operation is invalid.")
case errors.Is(err, alert.ErrRevisionConflict):
fail(w, r, http.StatusConflict, "REVISION_CONFLICT", "The alert changed before this operation was applied.")
case errors.Is(err, alert.ErrStateConflict):
fail(w, r, http.StatusConflict, "STATE_CONFLICT", "The alert is not in a state that supports this operation.")
case errors.Is(err, alert.ErrInstanceNotFound):
fail(w, r, http.StatusNotFound, "NOT_FOUND", "The alert instance was not found.")
case errors.Is(err, alert.ErrUnavailable):
fail(w, r, http.StatusServiceUnavailable, "DATABASE_UNAVAILABLE", "Alerts are unavailable.")
default:
fail(w, r, http.StatusInternalServerError, "ALERT_REQUEST_FAILED", "The alert request failed.")
}
}
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")
}
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil || parsed < 1 {
return 0, errors.New("invalid revision")
}
return parsed, nil
}
func decode(r *http.Request, target any) error {
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)
}