mirror of
https://github.com/hanzoai/o11y.git
synced 2026-10-15 12:58:33 +00:00
Twenty-nine handler bodies across six packages named gorilla/mux to read a path
segment, so the router was braided into the rule, alert, trace and sentry logic
that addresses a resource by id. The value each one needs is the SEGMENT; which
router matched the route is not a fact these handlers should hold. All twenty-
nine now read through coretypes.Param — the one route-value reader — and the
tracefunnel test's two injectors through coretypes.SetParams, its writer half.
A PATH segment and a QUERY value stay apart, because that is the failure this
seam exists to prevent: r.URL.Query().Get is untouched everywhere it appears
(the history window, sentry's ?project=, the funnel limits), and three of the
new tests pin it by sending a query value of the SAME name as the path segment
and asserting the path still answers.
Two helpers, both deleting real duplication rather than adding a second reader:
o11yruler.idFromPath replaces the same five-line uuid-v7 block written out
seven times, and o11yalertmanager.channelIDFromPath replaces a seventeen-line
nil-map/ok/parse dance written out three times. Absent and malformed remain
DIFFERENT refusals in the alertmanager one — "id is required in path" vs "id is
not a valid uuid-v7" — which is what the code it replaces answered.
Eleven new tests, and they are not vacuous. Each drives a REAL router at the
REAL template — /v1/o11y/rules/{id}/history/stats, /v1/o11y/channels/{id},
/v1/o11y/traces/{traceID}/waterfall, /v1/sentry/events/{id}, the uuid-
constrained /v1/sentry/{project}/envelope/ — and asserts the MODULE was asked
about what the router matched, so a passing test needs the whole path from
route table to business call. Stubbing Param to "" (go test -overlay, so the
shared tree is never mutated) turns all six packages RED; before these tests
five of them had no test that touched a param at all.
Registration is untouched: no {id} literal, no *mux.Router, no .Methods() moved.
Route table unchanged — TestEveryRouteIsNamedAndCounted still passes at 367.
Co-authored-by: Hanzo Dev <[email protected]>
383 lines
9.1 KiB
Go
383 lines
9.1 KiB
Go
package o11yalertmanager
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/hanzoai/o11y/pkg/alertmanager"
|
|
"github.com/hanzoai/o11y/pkg/errors"
|
|
"github.com/hanzoai/o11y/pkg/http/render"
|
|
"github.com/hanzoai/o11y/pkg/types/alertmanagertypes"
|
|
"github.com/hanzoai/o11y/pkg/types/authtypes"
|
|
"github.com/hanzoai/o11y/pkg/types/coretypes"
|
|
"github.com/hanzoai/o11y/pkg/valuer"
|
|
)
|
|
|
|
type handler struct {
|
|
alertmanager alertmanager.Alertmanager
|
|
}
|
|
|
|
func NewHandler(alertmanager alertmanager.Alertmanager) alertmanager.Handler {
|
|
return &handler{alertmanager: alertmanager}
|
|
}
|
|
|
|
func (handler *handler) GetAlerts(rw http.ResponseWriter, req *http.Request) {
|
|
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
claims, err := authtypes.ClaimsFromContext(ctx)
|
|
if err != nil {
|
|
render.Error(rw, err)
|
|
return
|
|
}
|
|
|
|
params, err := alertmanagertypes.NewGettableAlertsParams(req)
|
|
if err != nil {
|
|
render.Error(rw, err)
|
|
return
|
|
}
|
|
|
|
alerts, err := handler.alertmanager.GetAlerts(ctx, claims.OrgID, params)
|
|
if err != nil {
|
|
render.Error(rw, err)
|
|
return
|
|
}
|
|
|
|
render.Success(rw, http.StatusOK, alerts)
|
|
}
|
|
|
|
func (handler *handler) TestReceiver(rw http.ResponseWriter, req *http.Request) {
|
|
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
claims, err := authtypes.ClaimsFromContext(ctx)
|
|
if err != nil {
|
|
render.Error(rw, err)
|
|
return
|
|
}
|
|
|
|
body, err := io.ReadAll(req.Body)
|
|
if err != nil {
|
|
render.Error(rw, err)
|
|
return
|
|
}
|
|
defer req.Body.Close() //nolint:errcheck
|
|
|
|
receiver, err := alertmanagertypes.NewReceiver(string(body))
|
|
if err != nil {
|
|
render.Error(rw, err)
|
|
return
|
|
}
|
|
|
|
err = handler.alertmanager.TestReceiver(ctx, claims.OrgID, receiver)
|
|
if err != nil {
|
|
render.Error(rw, err)
|
|
return
|
|
}
|
|
|
|
render.Success(rw, http.StatusNoContent, nil)
|
|
}
|
|
|
|
func (handler *handler) ListChannels(rw http.ResponseWriter, req *http.Request) {
|
|
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
claims, err := authtypes.ClaimsFromContext(ctx)
|
|
if err != nil {
|
|
render.Error(rw, err)
|
|
return
|
|
}
|
|
|
|
channels, err := handler.alertmanager.ListChannels(ctx, claims.OrgID)
|
|
if err != nil {
|
|
render.Error(rw, err)
|
|
return
|
|
}
|
|
|
|
// This ensures that the UI receives an empty array instead of null
|
|
if len(channels) == 0 {
|
|
channels = make([]*alertmanagertypes.Channel, 0)
|
|
}
|
|
|
|
render.Success(rw, http.StatusOK, channels)
|
|
}
|
|
|
|
func (handler *handler) ListAllChannels(rw http.ResponseWriter, req *http.Request) {
|
|
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
channels, err := handler.alertmanager.ListAllChannels(ctx)
|
|
if err != nil {
|
|
render.Error(rw, err)
|
|
return
|
|
}
|
|
|
|
render.Success(rw, http.StatusOK, channels)
|
|
}
|
|
|
|
func (handler *handler) GetChannelByID(rw http.ResponseWriter, req *http.Request) {
|
|
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
claims, err := authtypes.ClaimsFromContext(ctx)
|
|
if err != nil {
|
|
render.Error(rw, err)
|
|
return
|
|
}
|
|
|
|
id, err := channelIDFromPath(req)
|
|
if err != nil {
|
|
render.Error(rw, err)
|
|
return
|
|
}
|
|
|
|
channel, err := handler.alertmanager.GetChannelByID(ctx, claims.OrgID, id)
|
|
if err != nil {
|
|
render.Error(rw, err)
|
|
return
|
|
}
|
|
|
|
render.Success(rw, http.StatusOK, channel)
|
|
}
|
|
|
|
func (handler *handler) UpdateChannelByID(rw http.ResponseWriter, req *http.Request) {
|
|
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
claims, err := authtypes.ClaimsFromContext(ctx)
|
|
if err != nil {
|
|
render.Error(rw, err)
|
|
return
|
|
}
|
|
|
|
id, err := channelIDFromPath(req)
|
|
if err != nil {
|
|
render.Error(rw, err)
|
|
return
|
|
}
|
|
|
|
body, err := io.ReadAll(req.Body)
|
|
if err != nil {
|
|
render.Error(rw, err)
|
|
return
|
|
}
|
|
defer req.Body.Close() //nolint:errcheck
|
|
|
|
receiver, err := alertmanagertypes.NewReceiver(string(body))
|
|
if err != nil {
|
|
render.Error(rw, err)
|
|
return
|
|
}
|
|
|
|
err = handler.alertmanager.UpdateChannelByReceiverAndID(ctx, claims.OrgID, receiver, id)
|
|
if err != nil {
|
|
render.Error(rw, err)
|
|
return
|
|
}
|
|
|
|
render.Success(rw, http.StatusNoContent, nil)
|
|
}
|
|
|
|
func (handler *handler) DeleteChannelByID(rw http.ResponseWriter, req *http.Request) {
|
|
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
claims, err := authtypes.ClaimsFromContext(ctx)
|
|
if err != nil {
|
|
render.Error(rw, err)
|
|
return
|
|
}
|
|
|
|
id, err := channelIDFromPath(req)
|
|
if err != nil {
|
|
render.Error(rw, err)
|
|
return
|
|
}
|
|
|
|
err = handler.alertmanager.DeleteChannelByID(ctx, claims.OrgID, id)
|
|
if err != nil {
|
|
render.Error(rw, err)
|
|
return
|
|
}
|
|
|
|
render.Success(rw, http.StatusNoContent, nil)
|
|
}
|
|
|
|
func (handler *handler) CreateChannel(rw http.ResponseWriter, req *http.Request) {
|
|
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
claims, err := authtypes.ClaimsFromContext(ctx)
|
|
if err != nil {
|
|
render.Error(rw, err)
|
|
return
|
|
}
|
|
|
|
// TODO: Move to PostableChannel and binding package
|
|
body, err := io.ReadAll(req.Body)
|
|
if err != nil {
|
|
render.Error(rw, err)
|
|
return
|
|
}
|
|
defer req.Body.Close() //nolint:errcheck
|
|
|
|
receiver, err := alertmanagertypes.NewReceiver(string(body))
|
|
if err != nil {
|
|
render.Error(rw, err)
|
|
return
|
|
}
|
|
|
|
channel, err := handler.alertmanager.CreateChannel(ctx, claims.OrgID, receiver)
|
|
if err != nil {
|
|
render.Error(rw, err)
|
|
return
|
|
}
|
|
|
|
render.Success(rw, http.StatusCreated, channel)
|
|
}
|
|
|
|
func (handler *handler) CreateRoutePolicy(rw http.ResponseWriter, req *http.Request) {
|
|
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
body, err := io.ReadAll(req.Body)
|
|
if err != nil {
|
|
render.Error(rw, err)
|
|
return
|
|
}
|
|
defer req.Body.Close()
|
|
var policy alertmanagertypes.PostableRoutePolicy
|
|
err = json.Unmarshal(body, &policy)
|
|
if err != nil {
|
|
render.Error(rw, err)
|
|
return
|
|
}
|
|
|
|
policy.ExpressionKind = alertmanagertypes.PolicyBasedExpression
|
|
|
|
// Validate the postable route
|
|
if err := policy.Validate(); err != nil {
|
|
render.Error(rw, err)
|
|
return
|
|
}
|
|
|
|
result, err := handler.alertmanager.CreateRoutePolicy(ctx, &policy)
|
|
if err != nil {
|
|
render.Error(rw, err)
|
|
return
|
|
}
|
|
|
|
render.Success(rw, http.StatusCreated, result)
|
|
}
|
|
|
|
func (handler *handler) GetAllRoutePolicies(rw http.ResponseWriter, req *http.Request) {
|
|
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
policies, err := handler.alertmanager.GetAllRoutePolicies(ctx)
|
|
if err != nil {
|
|
render.Error(rw, err)
|
|
return
|
|
}
|
|
|
|
render.Success(rw, http.StatusOK, policies)
|
|
}
|
|
|
|
func (handler *handler) GetRoutePolicyByID(rw http.ResponseWriter, req *http.Request) {
|
|
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
policyID := coretypes.Param(req, "id")
|
|
if policyID == "" {
|
|
render.Error(rw, errors.NewInvalidInputf(errors.CodeInvalidInput, "policy ID is required"))
|
|
return
|
|
}
|
|
|
|
policy, err := handler.alertmanager.GetRoutePolicyByID(ctx, policyID)
|
|
if err != nil {
|
|
render.Error(rw, err)
|
|
return
|
|
}
|
|
|
|
render.Success(rw, http.StatusOK, policy)
|
|
}
|
|
|
|
func (handler *handler) DeleteRoutePolicyByID(rw http.ResponseWriter, req *http.Request) {
|
|
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
policyID := coretypes.Param(req, "id")
|
|
if policyID == "" {
|
|
render.Error(rw, errors.NewInvalidInputf(errors.CodeInvalidInput, "policy ID is required"))
|
|
return
|
|
}
|
|
|
|
err := handler.alertmanager.DeleteRoutePolicyByID(ctx, policyID)
|
|
if err != nil {
|
|
render.Error(rw, err)
|
|
return
|
|
}
|
|
|
|
render.Success(rw, http.StatusNoContent, nil)
|
|
}
|
|
|
|
func (handler *handler) UpdateRoutePolicy(rw http.ResponseWriter, req *http.Request) {
|
|
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
policyID := coretypes.Param(req, "id")
|
|
if policyID == "" {
|
|
render.Error(rw, errors.NewInvalidInputf(errors.CodeInvalidInput, "policy ID is required"))
|
|
return
|
|
}
|
|
body, err := io.ReadAll(req.Body)
|
|
if err != nil {
|
|
render.Error(rw, err)
|
|
return
|
|
}
|
|
defer req.Body.Close()
|
|
var policy alertmanagertypes.PostableRoutePolicy
|
|
err = json.Unmarshal(body, &policy)
|
|
if err != nil {
|
|
render.Error(rw, err)
|
|
return
|
|
}
|
|
policy.ExpressionKind = alertmanagertypes.PolicyBasedExpression
|
|
|
|
// Validate the postable route
|
|
if err := policy.Validate(); err != nil {
|
|
render.Error(rw, err)
|
|
return
|
|
}
|
|
|
|
result, err := handler.alertmanager.UpdateRoutePolicyByID(ctx, policyID, &policy)
|
|
if err != nil {
|
|
render.Error(rw, err)
|
|
return
|
|
}
|
|
render.Success(rw, http.StatusOK, result)
|
|
}
|
|
|
|
// channelIDFromPath reads the {id} PATH segment of /v1/o11y/channels/{id} and
|
|
// validates it as a uuid-v7. Absent and malformed stay DIFFERENT refusals: a
|
|
// request that reached the handler without the segment is a routing fault, a
|
|
// segment that is not a uuid is the caller's. The read goes through
|
|
// coretypes.Param — the ONE route-value reader — so this handler names the value
|
|
// it needs and not the router that matched it.
|
|
func channelIDFromPath(req *http.Request) (valuer.UUID, error) {
|
|
raw := coretypes.Param(req, "id")
|
|
if raw == "" {
|
|
return valuer.UUID{}, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is required in path")
|
|
}
|
|
|
|
id, err := valuer.NewUUID(raw)
|
|
if err != nil {
|
|
return valuer.UUID{}, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7")
|
|
}
|
|
|
|
return id, nil
|
|
}
|