f3918b863c
The page was a bare form: it did not say which switches existed or
whether they were alive. It now lists them, each with a Healthy, Dead or
Dormant badge, when its heartbeat was last heard and when it last opened
an incident (linked while that incident is open). A matcher that several
clusters satisfy is broken down per cluster, since a live cluster must
not hide a dead one. The form moved into a "New switch" sheet, and each
row has a Remove with a confirm.
That needed a switch to be a thing, so switches are rows now
(migration 009) with their own name, matcher, timeout and severity,
instead of one string with one team-wide timeout in deadman_configs.
Existing configuration is split into one row per matcher; a team whose
timeout was zero simply has none. The sweeper and the status endpoint
share one death rule (deadmanAlert.dead), so the page cannot disagree
with the pager. Incident group keys are unchanged, so incidents that
are open across the upgrade keep working.
The environment defaults (TERDUT_DEADMAN_*) are seeded into teams once
per install, recorded in settings, so a team that deletes its last
switch does not get it back on the next restart. Installs that already
had per-team rows are marked as seeded by the migration.
Removing a switch stops the watching but leaves an incident it already
opened open until someone resolves it.
API: GET/PUT /api/teams/{id}/deadman are replaced by
GET/POST /deadman/switches and DELETE /deadman/switches/{switchID}.
terdut-tui does not call them, so nothing to mirror there.
682 lines
21 KiB
Go
682 lines
21 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// handleListTeams lists the caller's own teams, each with their role in it. An
|
|
// administrator listing every team goes through the admin endpoint instead:
|
|
// this one answers "what am I part of", which is what the UI's team filter and
|
|
// the combined queue are built from.
|
|
func handleListTeams(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
caller, _ := userFromContext(r.Context())
|
|
rows, err := db.QueryContext(r.Context(), `
|
|
SELECT t.id, t.name, t.created_at, m.role
|
|
FROM teams t
|
|
JOIN team_members m ON m.team_id = t.id
|
|
WHERE m.user_id = $1
|
|
ORDER BY t.name`, caller.ID)
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
teams := []models.Team{}
|
|
for rows.Next() {
|
|
var t models.Team
|
|
var created int64
|
|
if err := rows.Scan(&t.ID, &t.Name, &created, &t.Role); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
t.CreatedAt = time.Unix(created, 0).UTC()
|
|
teams = append(teams, t)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
respond(w, http.StatusOK, teams)
|
|
}
|
|
}
|
|
|
|
// handleUserTeams lists one user's teams, for the admin page's per-user view:
|
|
// "what is this person in", which /api/teams cannot answer because it is always
|
|
// about the caller.
|
|
//
|
|
// Self or admin, matching the other per-user endpoints. It says which teams
|
|
// somebody belongs to and in what role — not anything those teams own, so it
|
|
// stays on the accounts side of the line the administrator flag draws.
|
|
func handleUserTeams(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
|
if err != nil {
|
|
respond(w, http.StatusBadRequest, errResp("invalid user id"))
|
|
return
|
|
}
|
|
if !requireSelfOrAdmin(w, r, id) {
|
|
return
|
|
}
|
|
|
|
// A user with no teams and a user who does not exist both list nothing,
|
|
// so the existence check is what tells them apart.
|
|
var exists bool
|
|
if err := db.QueryRowContext(r.Context(),
|
|
"SELECT EXISTS (SELECT 1 FROM users WHERE id = $1)", id).Scan(&exists); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
if !exists {
|
|
respond(w, http.StatusNotFound, errResp("user not found"))
|
|
return
|
|
}
|
|
|
|
rows, err := db.QueryContext(r.Context(), `
|
|
SELECT t.id, t.name, t.created_at, m.role
|
|
FROM teams t
|
|
JOIN team_members m ON m.team_id = t.id
|
|
WHERE m.user_id = $1
|
|
ORDER BY t.name`, id)
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
teams := []models.Team{}
|
|
for rows.Next() {
|
|
var t models.Team
|
|
var created int64
|
|
if err := rows.Scan(&t.ID, &t.Name, &created, &t.Role); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
t.CreatedAt = time.Unix(created, 0).UTC()
|
|
teams = append(teams, t)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
respond(w, http.StatusOK, teams)
|
|
}
|
|
}
|
|
|
|
// handleCreateTeam creates a team and makes its creator the first owner. A team
|
|
// with no owner would need an administrator to repair before anybody could use
|
|
// it, so the two happen in one transaction.
|
|
func handleCreateTeam(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
}
|
|
if err := decodeJSON(r, &req); err != nil {
|
|
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
|
return
|
|
}
|
|
req.Name = strings.TrimSpace(req.Name)
|
|
if req.Name == "" {
|
|
respond(w, http.StatusBadRequest, errResp("name is required"))
|
|
return
|
|
}
|
|
|
|
caller, _ := userFromContext(r.Context())
|
|
|
|
tx, err := db.BeginTx(r.Context(), nil)
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
defer tx.Rollback() //nolint:errcheck
|
|
|
|
var team models.Team
|
|
var created int64
|
|
if err := tx.QueryRowContext(r.Context(),
|
|
"INSERT INTO teams (name) VALUES ($1) RETURNING id, name, created_at",
|
|
req.Name).Scan(&team.ID, &team.Name, &created); err != nil {
|
|
if isUniqueViolation(err) {
|
|
respond(w, http.StatusConflict, errResp("a team with that name already exists"))
|
|
return
|
|
}
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
if _, err := tx.ExecContext(r.Context(),
|
|
"INSERT INTO team_members (team_id, user_id, role) VALUES ($1, $2, $3)",
|
|
team.ID, caller.ID, models.RoleOwner); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
|
|
team.CreatedAt = time.Unix(created, 0).UTC()
|
|
team.Role = models.RoleOwner
|
|
respond(w, http.StatusCreated, team)
|
|
}
|
|
}
|
|
|
|
// handleDeleteTeam removes a team and, by cascade, its incidents, alerts,
|
|
// schedule and integrations.
|
|
//
|
|
// Refused while the team still has open incidents: deleting a team is tidying
|
|
// up, and tidying up should never be how an unacknowledged page disappears.
|
|
// Resolve or archive them first, deliberately.
|
|
func handleDeleteTeam(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
teamID, ok := teamParam(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !requireTeamOwner(w, r, teamID) {
|
|
return
|
|
}
|
|
|
|
var open int
|
|
if err := db.QueryRowContext(r.Context(),
|
|
"SELECT COUNT(*) FROM incidents WHERE team_id = $1 AND resolved_at IS NULL", teamID).
|
|
Scan(&open); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
if open > 0 {
|
|
respond(w, http.StatusConflict, errResp("team still has open incidents"))
|
|
return
|
|
}
|
|
|
|
res, err := db.ExecContext(r.Context(), "DELETE FROM teams WHERE id = $1", teamID)
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
respond(w, http.StatusNotFound, errResp("not found"))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
}
|
|
|
|
// handleListTeamMembers names everybody in a team. Visible to any member: you
|
|
// can see who else is on the rota you are on.
|
|
func handleListTeamMembers(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
teamID, ok := teamParam(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !requireTeamMember(w, r, teamID) {
|
|
return
|
|
}
|
|
|
|
rows, err := db.QueryContext(r.Context(), `
|
|
SELECT m.team_id, m.user_id, u.username, m.role, m.joined_at
|
|
FROM team_members m
|
|
JOIN users u ON u.id = m.user_id
|
|
WHERE m.team_id = $1
|
|
ORDER BY u.username`, teamID)
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
members := []models.TeamMember{}
|
|
for rows.Next() {
|
|
var m models.TeamMember
|
|
var joined int64
|
|
if err := rows.Scan(&m.TeamID, &m.UserID, &m.Username, &m.Role, &joined); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
m.JoinedAt = time.Unix(joined, 0).UTC()
|
|
members = append(members, m)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
respond(w, http.StatusOK, members)
|
|
}
|
|
}
|
|
|
|
// handleAddTeamMember adds a user to a team, or changes the role of somebody
|
|
// already in it.
|
|
func handleAddTeamMember(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
teamID, ok := teamParam(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !requireTeamOwner(w, r, teamID) {
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
UserID int64 `json:"user_id"`
|
|
Role string `json:"role"`
|
|
}
|
|
if err := decodeJSON(r, &req); err != nil || req.UserID == 0 {
|
|
respond(w, http.StatusBadRequest, errResp("user_id is required"))
|
|
return
|
|
}
|
|
if req.Role == "" {
|
|
req.Role = models.RoleMember
|
|
}
|
|
if req.Role != models.RoleOwner && req.Role != models.RoleMember {
|
|
respond(w, http.StatusBadRequest, errResp("role must be owner or member"))
|
|
return
|
|
}
|
|
|
|
_, err := db.ExecContext(r.Context(), `
|
|
INSERT INTO team_members (team_id, user_id, role)
|
|
VALUES ($1, $2, $3)
|
|
ON CONFLICT (team_id, user_id) DO UPDATE SET role = excluded.role`,
|
|
teamID, req.UserID, req.Role)
|
|
if err != nil {
|
|
// The only foreign key that can fail here is the user: the team was
|
|
// resolved from the caller's own membership.
|
|
respond(w, http.StatusNotFound, errResp("user not found"))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
}
|
|
|
|
// handleRemoveTeamMember takes a user out of a team.
|
|
//
|
|
// A team must keep an owner, for the same reason the install must keep an
|
|
// administrator: otherwise nobody can configure it, and repairing that needs
|
|
// somebody with more access than the team has.
|
|
func handleRemoveTeamMember(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
teamID, ok := teamParam(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !requireTeamOwner(w, r, teamID) {
|
|
return
|
|
}
|
|
userID, err := strconv.ParseInt(chi.URLParam(r, "userID"), 10, 64)
|
|
if err != nil {
|
|
respond(w, http.StatusBadRequest, errResp("invalid user id"))
|
|
return
|
|
}
|
|
|
|
last, err := isLastTeamOwner(r.Context(), db, teamID, userID)
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
if last {
|
|
respond(w, http.StatusConflict, errResp("cannot remove the last owner of a team"))
|
|
return
|
|
}
|
|
|
|
res, err := db.ExecContext(r.Context(),
|
|
"DELETE FROM team_members WHERE team_id = $1 AND user_id = $2", teamID, userID)
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
respond(w, http.StatusNotFound, errResp("not a member of this team"))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
}
|
|
|
|
func isLastTeamOwner(ctx context.Context, db *sql.DB, teamID, userID int64) (bool, error) {
|
|
var last bool
|
|
err := db.QueryRowContext(ctx, `
|
|
SELECT EXISTS (SELECT 1 FROM team_members
|
|
WHERE team_id = $1 AND user_id = $2 AND role = 'owner')
|
|
AND NOT EXISTS (SELECT 1 FROM team_members
|
|
WHERE team_id = $1 AND user_id <> $2 AND role = 'owner')`,
|
|
teamID, userID).Scan(&last)
|
|
return last, err
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Integrations
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// handleListIntegrations lists a team's integrations. Never the keys: those
|
|
// exist in plaintext only in the response that created them.
|
|
func handleListIntegrations(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
teamID, ok := teamParam(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !requireTeamMember(w, r, teamID) {
|
|
return
|
|
}
|
|
|
|
rows, err := db.QueryContext(r.Context(), `
|
|
SELECT id, team_id, kind, name, created_at, last_used_at
|
|
FROM integrations
|
|
WHERE team_id = $1
|
|
ORDER BY id`, teamID)
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
integrations := []models.Integration{}
|
|
for rows.Next() {
|
|
var i models.Integration
|
|
var created int64
|
|
var lastUsed *int64
|
|
if err := rows.Scan(&i.ID, &i.TeamID, &i.Kind, &i.Name, &created, &lastUsed); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
i.CreatedAt = time.Unix(created, 0).UTC()
|
|
i.LastUsedAt = unixPtr(lastUsed)
|
|
integrations = append(integrations, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
respond(w, http.StatusOK, integrations)
|
|
}
|
|
}
|
|
|
|
// handleCreateIntegration mints an integration key. The key is returned once,
|
|
// in this response, and only its hash is kept — the same handling as an API key
|
|
// or an acknowledgement token.
|
|
func handleCreateIntegration(db *sql.DB, publicURL string) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
teamID, ok := teamParam(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !requireTeamOwner(w, r, teamID) {
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
Kind string `json:"kind"`
|
|
}
|
|
if err := decodeJSON(r, &req); err != nil {
|
|
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
|
return
|
|
}
|
|
req.Name = strings.TrimSpace(req.Name)
|
|
if req.Name == "" {
|
|
respond(w, http.StatusBadRequest, errResp("name is required"))
|
|
return
|
|
}
|
|
if req.Kind == "" {
|
|
req.Kind = models.IntegrationAlertmanager
|
|
}
|
|
if req.Kind != models.IntegrationAlertmanager {
|
|
respond(w, http.StatusBadRequest, errResp("unsupported integration kind"))
|
|
return
|
|
}
|
|
|
|
raw, hash, err := randomToken()
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
|
|
var i models.Integration
|
|
var created int64
|
|
if err := db.QueryRowContext(r.Context(), `
|
|
INSERT INTO integrations (team_id, kind, name, key_hash)
|
|
VALUES ($1, $2, $3, $4)
|
|
RETURNING id, team_id, kind, name, created_at`,
|
|
teamID, req.Kind, req.Name, hash).
|
|
Scan(&i.ID, &i.TeamID, &i.Kind, &i.Name, &created); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
i.CreatedAt = time.Unix(created, 0).UTC()
|
|
i.Key = raw
|
|
i.URL = strings.TrimSuffix(publicURL, "/") + integrationPath(raw, i.Kind)
|
|
respond(w, http.StatusCreated, i)
|
|
}
|
|
}
|
|
|
|
func handleDeleteIntegration(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
teamID, ok := teamParam(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !requireTeamOwner(w, r, teamID) {
|
|
return
|
|
}
|
|
id, err := strconv.ParseInt(chi.URLParam(r, "integrationID"), 10, 64)
|
|
if err != nil {
|
|
respond(w, http.StatusBadRequest, errResp("invalid integration id"))
|
|
return
|
|
}
|
|
|
|
res, err := db.ExecContext(r.Context(),
|
|
"DELETE FROM integrations WHERE id = $1 AND team_id = $2", id, teamID)
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
respond(w, http.StatusNotFound, errResp("not found"))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
}
|
|
|
|
// integrationPath is where a sender of this kind posts. Built in one place so
|
|
// the URL handed out at creation and the route the router registers cannot
|
|
// drift apart.
|
|
func integrationPath(key, kind string) string {
|
|
return "/api/integrations/" + key + "/" + kind
|
|
}
|
|
|
|
// teamIDForKey resolves an integration key to its team, and stamps the key's
|
|
// last use. An unknown key is not an error worth distinguishing: the caller is
|
|
// told nothing beyond "no".
|
|
func teamIDForKey(ctx context.Context, db *sql.DB, key string) (int64, error) {
|
|
var teamID int64
|
|
err := db.QueryRowContext(ctx,
|
|
"SELECT team_id FROM integrations WHERE key_hash = $1", hashToken(key)).Scan(&teamID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return 0, errUnknownIntegration
|
|
}
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
// Best effort, like an API key's: a failed stamp must not reject an alert.
|
|
db.ExecContext(ctx, //nolint:errcheck
|
|
"UPDATE integrations SET last_used_at = $1 WHERE key_hash = $2",
|
|
time.Now().Unix(), hashToken(key))
|
|
return teamID, nil
|
|
}
|
|
|
|
var errUnknownIntegration = errors.New("unknown integration key")
|
|
|
|
// teamParam reads {teamID} from the path.
|
|
func teamParam(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
|
id, err := strconv.ParseInt(chi.URLParam(r, "teamID"), 10, 64)
|
|
if err != nil {
|
|
respond(w, http.StatusBadRequest, errResp("invalid team id"))
|
|
return 0, false
|
|
}
|
|
return id, true
|
|
}
|
|
|
|
// defaultTeamID is the oldest team, which on an upgraded install is the
|
|
// "Default" team every pre-teams row was moved into and on a fresh one is the
|
|
// team migration 003 creates. Bootstrap puts the first user in it, so somebody
|
|
// signing in to a new server lands somewhere rather than in no team at all.
|
|
func defaultTeamID(ctx context.Context, db *sql.DB) (int64, error) {
|
|
var id int64
|
|
err := db.QueryRowContext(ctx, "SELECT id FROM teams ORDER BY id LIMIT 1").Scan(&id)
|
|
return id, err
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// A team's dead man's switches
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// deadmanSwitchRequest is what creating a switch takes. The timeout is seconds,
|
|
// because that is what the column holds and what arithmetic is done on; a client
|
|
// renders it.
|
|
type deadmanSwitchRequest struct {
|
|
Name string `json:"name"`
|
|
Matcher string `json:"matcher"`
|
|
TimeoutSeconds int64 `json:"timeout_seconds"`
|
|
Severity string `json:"severity"`
|
|
}
|
|
|
|
// deadmanSeverities are the severities an incident can open at.
|
|
var deadmanSeverities = map[string]bool{"critical": true, "error": true, "warning": true, "info": true}
|
|
|
|
// handleListTeamDeadman lists a team's switches with what each one's heartbeats
|
|
// are doing. A team with none gets an empty list, which is a configuration and
|
|
// not an absence: answering 404 would make "off" indistinguishable from "this
|
|
// server does not do this".
|
|
func handleListTeamDeadman(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
teamID, ok := teamParam(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !requireTeamMember(w, r, teamID) {
|
|
return
|
|
}
|
|
|
|
set, err := deadmanSetForTeam(r.Context(), db, teamID)
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
out, err := deadmanStatuses(r.Context(), db, teamID, set, time.Now())
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
respond(w, http.StatusOK, out)
|
|
}
|
|
}
|
|
|
|
// handleCreateTeamDeadman adds one switch.
|
|
//
|
|
// Validated by parsing: a matcher with no alertname is rejected rather than
|
|
// stored, because a switch that silently watches nothing is the failure this
|
|
// feature exists to prevent.
|
|
func handleCreateTeamDeadman(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
teamID, ok := teamParam(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !requireTeamOwner(w, r, teamID) {
|
|
return
|
|
}
|
|
|
|
var req deadmanSwitchRequest
|
|
if err := decodeJSON(r, &req); err != nil {
|
|
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
|
return
|
|
}
|
|
req.Matcher = strings.TrimSpace(req.Matcher)
|
|
req.Name = strings.TrimSpace(req.Name)
|
|
if req.Severity == "" {
|
|
req.Severity = "critical"
|
|
}
|
|
if !deadmanSeverities[req.Severity] {
|
|
respond(w, http.StatusBadRequest, errResp("severity must be critical, error, warning or info"))
|
|
return
|
|
}
|
|
if req.TimeoutSeconds <= 0 {
|
|
respond(w, http.StatusBadRequest, errResp("timeout_seconds must be positive"))
|
|
return
|
|
}
|
|
if strings.Contains(req.Matcher, ";") {
|
|
respond(w, http.StatusBadRequest, errResp("one matcher per switch: add another switch instead of separating with ;"))
|
|
return
|
|
}
|
|
m, err := parseDeadmanMatcher(req.Matcher)
|
|
if err != nil {
|
|
respond(w, http.StatusBadRequest, errResp(
|
|
"unusable matcher ("+err.Error()+"): each must name an alertname, as in alertname=Watchdog,cluster=prod"))
|
|
return
|
|
}
|
|
if req.Name == "" {
|
|
req.Name = m.config()
|
|
}
|
|
if len(req.Name) > 100 {
|
|
respond(w, http.StatusBadRequest, errResp("name is too long"))
|
|
return
|
|
}
|
|
|
|
var id int64
|
|
if err := db.QueryRowContext(r.Context(), `
|
|
INSERT INTO deadman_switches (team_id, name, matcher, timeout_seconds, severity)
|
|
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
|
|
teamID, req.Name, m.config(), req.TimeoutSeconds, req.Severity).Scan(&id); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
|
|
respond(w, http.StatusCreated, deadmanSwitchStatus{
|
|
ID: id, Name: req.Name, Matcher: m.config(),
|
|
TimeoutSeconds: req.TimeoutSeconds, Severity: req.Severity,
|
|
Status: switchDormant, Sources: []deadmanSource{},
|
|
})
|
|
}
|
|
}
|
|
|
|
// handleDeleteTeamDeadman removes a switch. An incident it already opened stays
|
|
// open until somebody resolves it: deleting the switch says "stop watching", not
|
|
// "the problem is gone".
|
|
func handleDeleteTeamDeadman(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
teamID, ok := teamParam(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !requireTeamOwner(w, r, teamID) {
|
|
return
|
|
}
|
|
switchID, err := strconv.ParseInt(chi.URLParam(r, "switchID"), 10, 64)
|
|
if err != nil {
|
|
respond(w, http.StatusBadRequest, errResp("invalid switch id"))
|
|
return
|
|
}
|
|
|
|
res, err := db.ExecContext(r.Context(),
|
|
"DELETE FROM deadman_switches WHERE id = $1 AND team_id = $2", switchID, teamID)
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
respond(w, http.StatusNotFound, errResp("switch not found"))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
}
|