This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package alertopsapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/itworx/pulse/internal/alert"
|
||||
"github.com/itworx/pulse/internal/audit"
|
||||
"github.com/itworx/pulse/internal/auth"
|
||||
)
|
||||
|
||||
type memoryStore struct {
|
||||
mu sync.Mutex
|
||||
items map[string]alert.Alert
|
||||
occurrences map[string]alert.Occurrence
|
||||
}
|
||||
|
||||
func newMemoryStore() *memoryStore {
|
||||
return &memoryStore{items: map[string]alert.Alert{}, occurrences: map[string]alert.Occurrence{}}
|
||||
}
|
||||
func (s *memoryStore) ListAlerts(_ context.Context, _ int, state string) ([]alert.Alert, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
result := make([]alert.Alert, 0)
|
||||
for _, item := range s.items {
|
||||
if state == "" || string(item.State) == state {
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
func (s *memoryStore) GetAlert(_ context.Context, id string, _ int) (alert.Alert, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
item, ok := s.items[id]
|
||||
if !ok {
|
||||
return alert.Alert{}, alert.ErrInstanceNotFound
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
func (s *memoryStore) AcknowledgeRevision(_ context.Context, id, actor, key string, at time.Time, expected int64) (alert.Instance, alert.Occurrence, bool, error) {
|
||||
return s.mutate(id, actor, key, at, expected, true)
|
||||
}
|
||||
func (s *memoryStore) Unacknowledge(_ context.Context, id, actor, key string, at time.Time, expected int64) (alert.Instance, alert.Occurrence, bool, error) {
|
||||
return s.mutate(id, actor, key, at, expected, false)
|
||||
}
|
||||
func (s *memoryStore) mutate(id, actor, key string, at time.Time, expected int64, acknowledge bool) (alert.Instance, alert.Occurrence, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
item, ok := s.items[id]
|
||||
if !ok {
|
||||
return alert.Instance{}, alert.Occurrence{}, false, alert.ErrInstanceNotFound
|
||||
}
|
||||
if occurrence, ok := s.occurrences[key]; ok {
|
||||
return item.Instance, occurrence, true, nil
|
||||
}
|
||||
if item.Revision != expected {
|
||||
return alert.Instance{}, alert.Occurrence{}, false, alert.ErrRevisionConflict
|
||||
}
|
||||
if acknowledge {
|
||||
if item.State != alert.StateFiring && item.State != alert.StatePending {
|
||||
return alert.Instance{}, alert.Occurrence{}, false, alert.ErrStateConflict
|
||||
}
|
||||
item.State, item.RetainedState, item.AcknowledgedBy, item.AcknowledgedAt, item.Reason = alert.StateAcknowledged, alert.StateAcknowledged, actor, &at, "acknowledged"
|
||||
} else {
|
||||
if item.State != alert.StateAcknowledged {
|
||||
return alert.Instance{}, alert.Occurrence{}, false, alert.ErrStateConflict
|
||||
}
|
||||
item.State, item.RetainedState, item.AcknowledgedBy, item.AcknowledgedAt, item.Reason = alert.StateFiring, alert.StateFiring, "", nil, "unacknowledged"
|
||||
}
|
||||
item.Revision++
|
||||
occurrence := alert.Occurrence{ID: key, InstanceID: id, EvaluationKey: key, EventType: "acknowledge", From: alert.StateFiring, To: item.State, ObservedAt: at, Reason: item.Reason}
|
||||
if !acknowledge {
|
||||
occurrence.EventType = "unacknowledge"
|
||||
occurrence.From = alert.StateAcknowledged
|
||||
}
|
||||
s.items[id] = item
|
||||
s.occurrences[key] = occurrence
|
||||
return item.Instance, occurrence, false, nil
|
||||
}
|
||||
|
||||
func TestHandlerRoleMatrixIdempotenceAndAudit(t *testing.T) {
|
||||
store := newMemoryStore()
|
||||
store.items["instance-1"] = alert.Alert{Instance: alert.Instance{ID: "instance-1", State: alert.StateFiring, RetainedState: alert.StateFiring, Revision: 1, LastValue: 90, SourceHealth: map[string]any{}}, RuleName: "CPU", Severity: alert.SeverityCritical}
|
||||
auditStore := &audit.MemoryStore{}
|
||||
handler := Handler{Store: store, Audit: auditStore}
|
||||
viewer := requestWithPrincipal(http.MethodPost, "/api/v1/alerts/instance-1/acknowledge?revision=1", map[string]string{"evaluationKey": "ack-1"}, auth.RoleViewer)
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, viewer)
|
||||
if response.Code != http.StatusForbidden {
|
||||
t.Fatalf("viewer status = %d", response.Code)
|
||||
}
|
||||
operator := requestWithPrincipal(http.MethodPost, "/api/v1/alerts/instance-1/acknowledge?revision=1", map[string]string{"evaluationKey": "ack-1"}, auth.RoleOperator)
|
||||
response = httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, operator)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("ack status = %d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
retry := requestWithPrincipal(http.MethodPost, "/api/v1/alerts/instance-1/acknowledge?revision=1", map[string]string{"evaluationKey": "ack-1"}, auth.RoleOperator)
|
||||
response = httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, retry)
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"duplicate":true`) {
|
||||
t.Fatalf("duplicate ack = %d %s", response.Code, response.Body.String())
|
||||
}
|
||||
unack := requestWithPrincipal(http.MethodPost, "/api/v1/alerts/instance-1/unacknowledge?revision=2", map[string]string{"evaluationKey": "unack-1"}, auth.RoleOperator)
|
||||
response = httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, unack)
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"state":"firing"`) {
|
||||
t.Fatalf("unack = %d %s", response.Code, response.Body.String())
|
||||
}
|
||||
list := requestWithPrincipal(http.MethodGet, "/api/v1/alerts?state=firing", nil, auth.RoleViewer)
|
||||
response = httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, list)
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"ruleName":"CPU"`) {
|
||||
t.Fatalf("list = %d %s", response.Code, response.Body.String())
|
||||
}
|
||||
if len(auditStore.Events) != 3 || auditStore.Events[1].Result != "idempotent" {
|
||||
t.Fatalf("audit = %#v", auditStore.Events)
|
||||
}
|
||||
}
|
||||
|
||||
func requestWithPrincipal(method, path string, body any, role auth.Role) *http.Request {
|
||||
encoded := ""
|
||||
if body != nil {
|
||||
value, _ := json.Marshal(body)
|
||||
encoded = string(value)
|
||||
}
|
||||
request := httptest.NewRequest(method, path, strings.NewReader(encoded)).WithContext(auth.WithPrincipal(context.Background(), auth.Principal{Subject: "operator-1", Role: role}))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
return request
|
||||
}
|
||||
Reference in New Issue
Block a user