Publish ITWorx Pulse source
Public source validation / validate (push) Failing after 3m8s

This commit is contained in:
ITWorx Pulse release export
2026-09-03 02:09:19 +02:00
commit bd774932d5
614 changed files with 77116 additions and 0 deletions
+293
View File
@@ -0,0 +1,293 @@
package alertapi
import (
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"strings"
"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/metriccatalog"
"github.com/itworx/pulse/internal/problem"
)
type Handler struct {
Repository alert.Store
Registry metriccatalog.Registry
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.TrimPrefix(r.URL.Path, "/api/v1/alert-rules")
if path == "" || path == "/" {
switch r.Method {
case http.MethodGet:
h.list(w, r)
case http.MethodPost:
if !requireEdit(w, r, principal.Role) {
return
}
h.create(w, r, principal.Subject)
default:
fail(w, r, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "This method is not supported.")
}
return
}
parts := strings.Split(strings.Trim(path, "/"), "/")
if len(parts) < 1 || parts[0] == "" || len(parts) > 2 {
fail(w, r, http.StatusNotFound, "NOT_FOUND", "Alert rule route not found.")
return
}
id := parts[0]
if len(parts) == 1 && r.Method == http.MethodGet {
h.get(w, r, id)
return
}
if len(parts) == 2 && parts[1] == "versions" && r.Method == http.MethodGet {
h.versions(w, r, id)
return
}
if len(parts) == 2 && parts[1] == "test" && r.Method == http.MethodPost {
if !requireEdit(w, r, principal.Role) {
return
}
h.test(w, r, id)
return
}
if len(parts) == 2 && (parts[1] == "enable" || parts[1] == "disable") && r.Method == http.MethodPost {
if !requireEdit(w, r, principal.Role) {
return
}
h.setEnabled(w, r, id, principal.Subject, parts[1] == "enable")
return
}
if len(parts) == 1 && r.Method == http.MethodPut {
if !requireEdit(w, r, principal.Role) {
return
}
h.update(w, r, id, principal.Subject)
return
}
fail(w, r, http.StatusNotFound, "NOT_FOUND", "Alert rule 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-rule limit must be between 1 and 100.")
return
}
limit = parsed
}
items, err := h.Repository.List(r.Context(), limit)
if err != nil {
h.repositoryFailure(w, r, err, "Alert rules are unavailable.")
return
}
write(w, http.StatusOK, map[string]any{"items": items})
}
func (h Handler) get(w http.ResponseWriter, r *http.Request, id string) {
item, err := h.Repository.Get(r.Context(), id)
if err != nil {
h.repositoryFailure(w, r, err, "Alert rule not found.")
return
}
write(w, http.StatusOK, map[string]any{"rule": item})
}
func (h Handler) versions(w http.ResponseWriter, r *http.Request, id string) {
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 version limit must be between 1 and 100.")
return
}
limit = parsed
}
items, err := h.Repository.Versions(r.Context(), id, limit)
if err != nil {
h.repositoryFailure(w, r, err, "Alert-rule versions are unavailable.")
return
}
write(w, http.StatusOK, map[string]any{"items": items})
}
func (h Handler) create(w http.ResponseWriter, r *http.Request, actor string) {
var document alert.Document
if err := decode(r, &document); err != nil {
fail(w, r, http.StatusBadRequest, "INVALID_RULE", "The alert-rule document is invalid.")
return
}
if document.ID == "" {
document.ID = alert.NewID()
}
rule, version, err := h.Repository.Create(r.Context(), actor, document, "initial version")
if err != nil {
h.repositoryFailure(w, r, err, "Alert rule could not be created.")
return
}
if err := h.record(r, actor, "alert_rule.create", rule.ID, nil, map[string]any{"revision": rule.Revision, "version": version.VersionNumber}); err != nil {
fail(w, r, http.StatusServiceUnavailable, "AUDIT_UNAVAILABLE", "The audit event could not be recorded.")
return
}
write(w, http.StatusCreated, map[string]any{"rule": rule, "version": version})
}
func (h Handler) update(w http.ResponseWriter, r *http.Request, id, actor string) {
expected, err := revision(r)
if err != nil {
fail(w, r, http.StatusBadRequest, "INVALID_REVISION", "A valid If-Match or revision value is required.")
return
}
var document alert.Document
if err := decode(r, &document); err != nil {
fail(w, r, http.StatusBadRequest, "INVALID_RULE", "The alert-rule document is invalid.")
return
}
updated, err := h.Repository.Update(r.Context(), id, actor, expected, document, "rule update")
if err != nil {
h.repositoryFailure(w, r, err, "Alert rule update failed.")
return
}
if err := h.record(r, actor, "alert_rule.update", id, map[string]any{"revision": expected}, map[string]any{"revision": updated.Revision, "version": updated.CurrentVersion}); err != nil {
fail(w, r, http.StatusServiceUnavailable, "AUDIT_UNAVAILABLE", "The audit event could not be recorded.")
return
}
write(w, http.StatusOK, map[string]any{"rule": updated})
}
func (h Handler) setEnabled(w http.ResponseWriter, r *http.Request, id, actor string, enabled 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
}
updated, err := h.Repository.SetEnabled(r.Context(), id, expected, enabled)
if err != nil {
h.repositoryFailure(w, r, err, "Alert rule state update failed.")
return
}
action := "alert_rule.disable"
if enabled {
action = "alert_rule.enable"
}
if err := h.record(r, actor, action, id, map[string]any{"enabled": !enabled, "revision": expected}, map[string]any{"enabled": enabled, "revision": updated.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{"rule": updated})
}
func (h Handler) test(w http.ResponseWriter, r *http.Request, id string) {
var request struct {
Rule *alert.Document `json:"rule"`
Value any `json:"value"`
Unknown bool `json:"unknown"`
}
if err := decode(r, &request); err != nil {
fail(w, r, http.StatusBadRequest, "INVALID_PREVIEW", "The alert-rule preview request is invalid.")
return
}
document := request.Rule
if document == nil {
current, err := h.Repository.Get(r.Context(), id)
if err != nil {
h.repositoryFailure(w, r, err, "Alert rule preview is unavailable.")
return
}
document = &current.Document
}
result, err := alert.Preview(*document, alert.PreviewRequest{Value: request.Value, Unknown: request.Unknown}, h.Registry)
if err != nil {
h.repositoryFailure(w, r, err, "The alert-rule preview is invalid.")
return
}
write(w, http.StatusOK, map[string]any{"preview": result})
}
func (h Handler) record(r *http.Request, actor, action, resourceID 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: "alert_rule", ResourceID: resourceID, Result: "success", CorrelationID: correlation.FromContext(r.Context()), Before: before, After: after})
}
func (h Handler) repositoryFailure(w http.ResponseWriter, r *http.Request, err error, fallback string) {
switch {
case errors.Is(err, alert.ErrInvalidRule):
fail(w, r, http.StatusBadRequest, "INVALID_RULE", fallback)
case errors.Is(err, alert.ErrConflict):
fail(w, r, http.StatusConflict, "REVISION_CONFLICT", "The alert rule was changed by another request.")
case errors.Is(err, alert.ErrNotFound):
fail(w, r, http.StatusNotFound, "NOT_FOUND", fallback)
case errors.Is(err, alert.ErrUnavailable):
fail(w, r, http.StatusServiceUnavailable, "DATABASE_UNAVAILABLE", fallback)
default:
fail(w, r, http.StatusInternalServerError, "ALERT_RULE_REQUEST_FAILED", fallback)
}
}
func requireEdit(w http.ResponseWriter, r *http.Request, role auth.Role) bool {
if auth.Allows(role, auth.PermissionEdit) {
return true
}
fail(w, r, http.StatusForbidden, "FORBIDDEN", "Alert-rule editing is not allowed for this role.")
return false
}
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 revision(r *http.Request) (int64, error) {
value := r.Header.Get("If-Match")
if value == "" {
value = r.URL.Query().Get("revision")
}
return strconv.ParseInt(strings.Trim(value, "\""), 10, 64)
}
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)
}
+121
View File
@@ -0,0 +1,121 @@
package alertapi
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/itworx/pulse/internal/alert"
"github.com/itworx/pulse/internal/audit"
"github.com/itworx/pulse/internal/auth"
"github.com/itworx/pulse/internal/metriccatalog"
)
type fakeStore struct {
rule alert.Rule
getCalls, createCalls, updateCalls, toggleCalls int
}
func (f *fakeStore) Create(_ context.Context, _ string, document alert.Document, _ string) (alert.Rule, alert.Version, error) {
f.createCalls++
f.rule = alert.Rule{Document: document, Revision: 1, CurrentVersion: 1}
return f.rule, alert.Version{RuleID: document.ID, VersionNumber: 1, Document: document}, nil
}
func (f *fakeStore) Get(_ context.Context, _ string) (alert.Rule, error) {
f.getCalls++
return f.rule, nil
}
func (f *fakeStore) List(_ context.Context, _ int) ([]alert.Rule, error) {
return []alert.Rule{f.rule}, nil
}
func (f *fakeStore) Update(_ context.Context, id, _ string, _ int64, document alert.Document, _ string) (alert.Rule, error) {
f.updateCalls++
document.ID = id
f.rule.Document = document
f.rule.Revision++
return f.rule, nil
}
func (f *fakeStore) Versions(_ context.Context, _ string, _ int) ([]alert.Version, error) {
return []alert.Version{{Document: f.rule.Document, VersionNumber: 1}}, nil
}
func (f *fakeStore) SetEnabled(_ context.Context, _ string, _ int64, enabled bool) (alert.Rule, error) {
f.toggleCalls++
f.rule.Enabled = enabled
f.rule.Document.Enabled = enabled
f.rule.Revision++
return f.rule, nil
}
func requestWithPrincipal(method, path string, body any, role auth.Role) *http.Request {
data, _ := json.Marshal(body)
request := httptest.NewRequest(method, path, strings.NewReader(string(data)))
request.Header.Set("Content-Type", "application/json")
return request.WithContext(auth.WithPrincipal(request.Context(), auth.Principal{Subject: "editor-1", Role: role}))
}
func TestViewerCannotCreateAlertRule(t *testing.T) {
document, registry := validDocumentForHandler(t)
store := &fakeStore{}
handler := Handler{Repository: store, Registry: registry}
response := httptest.NewRecorder()
handler.ServeHTTP(response, requestWithPrincipal(http.MethodPost, "/api/v1/alert-rules", document, auth.RoleViewer))
if response.Code != http.StatusForbidden || store.createCalls != 0 {
t.Fatalf("status=%d creates=%d", response.Code, store.createCalls)
}
}
func TestPreviewDoesNotWriteOrAudit(t *testing.T) {
document, registry := validDocumentForHandler(t)
store := &fakeStore{}
auditStore := &audit.MemoryStore{}
handler := Handler{Repository: store, Registry: registry, Audit: auditStore}
response := httptest.NewRecorder()
handler.ServeHTTP(response, requestWithPrincipal(http.MethodPost, "/api/v1/alert-rules/"+document.ID+"/test", map[string]any{"rule": document, "value": 90}, auth.RoleEditor))
if response.Code != http.StatusOK {
t.Fatalf("preview status=%d body=%s", response.Code, response.Body.String())
}
if store.createCalls != 0 || store.updateCalls != 0 || store.toggleCalls != 0 || len(auditStore.Events) != 0 {
t.Fatalf("preview had side effects: store=%#v audit=%d", store, len(auditStore.Events))
}
var body map[string]any
if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["preview"] == nil {
t.Fatal("preview result missing")
}
}
func TestEnableIsAudited(t *testing.T) {
document, registry := validDocumentForHandler(t)
store := &fakeStore{rule: alert.Rule{Document: document, Revision: 1}}
auditStore := &audit.MemoryStore{}
handler := Handler{Repository: store, Registry: registry, Audit: auditStore}
request := requestWithPrincipal(http.MethodPost, "/api/v1/alert-rules/"+document.ID+"/enable?revision=1", map[string]any{}, auth.RoleEditor)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK || store.toggleCalls != 1 {
t.Fatalf("status=%d toggles=%d", response.Code, store.toggleCalls)
}
if len(auditStore.Events) != 1 || auditStore.Events[0].Action != "alert_rule.enable" {
t.Fatalf("audit=%#v", auditStore.Events)
}
}
func validDocumentForHandler(t *testing.T) (alert.Document, metriccatalog.Registry) {
t.Helper()
registry, err := metriccatalog.DefaultRegistry()
if err != nil {
t.Fatal(err)
}
return alert.Document{
SchemaVersion: 1, ID: alert.NewID(), Name: "CPU aandacht", Severity: alert.SeverityAttention,
Scope: map[string]any{"entityType": "host"},
Condition: alert.Condition{InputType: "metric", Metric: registry.Metrics()[0].SemanticName, Operator: ">", Threshold: float64(80)},
EvaluationIntervalSeconds: 30, UnknownBehavior: alert.UnknownRetain,
Message: alert.Message{TitleKey: "alerts.cpu.title", BodyKey: "alerts.cpu.body"},
}, registry
}