ac9af8e4f5
The Admin tab could make somebody an administrator and disable them, and
nothing else. Setting a first password, deleting an account and seeing
which teams a person is in all meant curl, and the last one meant opening
each team in turn — the Team tab answers "who is in this team", which is
the wrong way round when the question is about a person.
A name in the user list now opens /admin/users/{id}: their email and when
they joined, where their notifications go, the administrator and disabled
flags, the teams they are in with their role in each, a password field
for a first or forgotten one, and deletion. A section of its own rather
than an expanding row, because memberships and the account actions
together are more than a table row can hold and still be read on a phone.
Adding somebody mints an invite link into a chosen team rather than
creating a bare account. POST /api/users makes a user with no password
and no team, who can sign in nowhere and would see nothing if they did;
the invite machinery from #7 already solves both, and the password is
chosen by the person it belongs to instead of passing through an
administrator.
One new endpoint, GET /api/users/{id}/teams, self or admin. /api/teams is
always about the caller and cannot be asked about anybody else. It 404s
for a user who does not exist, so the page can tell "in no teams" from
"no such person" — an empty list is a real answer and needed to stay one.
No authorisation changed, and the interesting part is why it did not.
requireTeamOwner has accepted the administrator flag since a4fbd60, with
the reason in its own comment: somebody has to be able to repair a team
whose owner has left. It guards nine call sites, so an administrator has
always been able to configure any team on this server — while #1's
decision table and this README both said an admin "is not implicitly in
every team", full stop. The code was right and the prose was wrong in the
safe-sounding direction, which is the worse way round to have it.
So the documentation moved to meet the code. The Teams table marks owner
as owner-or-admin, and the Authentication section states the two
directions separately: an administrator configures any team, and reads
none, because callerTeamIDs is built from real memberships only. Joining
a team to see its queue is a membership change and shows as one.
TestAdmin_ConfiguresATeamTheyAreNotIn pins both halves — the admin
renames, invites, adds and removes on a team they are not in, then sees
zero of its incidents. Nothing tested this from v0.12.0 to here, which is
why four releases of prose could contradict it quietly.
The UI has not been opened in a browser. Its wiring is checked — every
cross-module import resolves, every api.* call exists, every CSS class
has a rule, and the deep link serves index.html — but nobody has clicked
through it, least of all at phone width.
Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
639 lines
20 KiB
Go
639 lines
20 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
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// deadmanResponse is the wire shape of a team's switch configuration. The
|
|
// timeout is seconds rather than a duration string, because that is what the
|
|
// column holds and what arithmetic is done on; a client renders it.
|
|
type deadmanResponse struct {
|
|
TeamID int64 `json:"team_id"`
|
|
Matchers string `json:"matchers"`
|
|
TimeoutSeconds int64 `json:"timeout_seconds"`
|
|
Severity string `json:"severity"`
|
|
}
|
|
|
|
func handleGetTeamDeadman(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
|
|
}
|
|
|
|
out := deadmanResponse{TeamID: teamID, Severity: "critical"}
|
|
err := db.QueryRowContext(r.Context(),
|
|
"SELECT matchers, timeout_seconds, severity FROM deadman_configs WHERE team_id = $1",
|
|
teamID).Scan(&out.Matchers, &out.TimeoutSeconds, &out.Severity)
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
// A team with no row watches nothing, which is a configuration and not
|
|
// an absence: answering 404 would make "off" indistinguishable from
|
|
// "this server does not do this".
|
|
respond(w, http.StatusOK, out)
|
|
}
|
|
}
|
|
|
|
// handleSetTeamDeadman replaces a team's switch configuration.
|
|
//
|
|
// Validated by parsing: a matcher string that survives ParseDeadmanConfig with
|
|
// nothing usable in it is rejected rather than stored, because a switch that
|
|
// silently watches nothing is the failure this feature exists to prevent.
|
|
func handleSetTeamDeadman(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 {
|
|
Matchers string `json:"matchers"`
|
|
TimeoutSeconds int64 `json:"timeout_seconds"`
|
|
Severity string `json:"severity"`
|
|
}
|
|
if err := decodeJSON(r, &req); err != nil {
|
|
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
|
return
|
|
}
|
|
req.Matchers = strings.TrimSpace(req.Matchers)
|
|
if req.Severity == "" {
|
|
req.Severity = "critical"
|
|
}
|
|
if req.TimeoutSeconds < 0 {
|
|
respond(w, http.StatusBadRequest, errResp("timeout_seconds must not be negative"))
|
|
return
|
|
}
|
|
if req.Matchers != "" {
|
|
parsed := parseDeadmanQuietly(req.Matchers, time.Duration(req.TimeoutSeconds)*time.Second, req.Severity)
|
|
if len(parsed.Matchers) == 0 {
|
|
respond(w, http.StatusBadRequest, errResp(
|
|
"no usable matchers: each must name an alertname, as in alertname=Watchdog,cluster=prod"))
|
|
return
|
|
}
|
|
}
|
|
|
|
if _, err := db.ExecContext(r.Context(), `
|
|
INSERT INTO deadman_configs (team_id, matchers, timeout_seconds, severity, updated_at)
|
|
VALUES ($1, $2, $3, $4, `+nowEpoch+`)
|
|
ON CONFLICT (team_id) DO UPDATE SET
|
|
matchers = excluded.matchers,
|
|
timeout_seconds = excluded.timeout_seconds,
|
|
severity = excluded.severity,
|
|
updated_at = excluded.updated_at`,
|
|
teamID, req.Matchers, req.TimeoutSeconds, req.Severity); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
|
|
respond(w, http.StatusOK, deadmanResponse{
|
|
TeamID: teamID,
|
|
Matchers: req.Matchers,
|
|
TimeoutSeconds: req.TimeoutSeconds,
|
|
Severity: req.Severity,
|
|
})
|
|
}
|
|
}
|