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, m.source 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, &t.Source); 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, m.source 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, &t.Source); 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) } } // Member statuses, as the Members page colours them. const ( memberOnCall = "oncall" memberReachable = "reachable" memberUnpageable = "unpageable" ) // memberStatus is a team member with what matters about them at 03:00: whether // they are on call, whether a page to them would go anywhere, and whether they // have been around. The extra fields are output only. type memberStatus struct { models.TeamMember // Status is unpageable when a page to them would go nowhere — even when // they are on call, since that is the case that matters most — on_call when // the rota has them today, reachable otherwise. Status string `json:"status"` OnCall bool `json:"on_call"` // NextShift is the first day after today the rota has them (YYYY-MM-DD). NextShift *string `json:"next_shift,omitempty"` // Pageable is whether they have an ntfy topic and an enabled account — the // conditions pageLevel and the notifier skip on. Never the topic itself. Pageable bool `json:"pageable"` Problem string `json:"problem,omitempty"` // LastActiveAt is the last time they used a session or an API key. LastActiveAt *time.Time `json:"last_active_at,omitempty"` } // handleListTeamMembers names everybody in a team, with their status. 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, m.source, u.ntfy_topic IS NOT NULL AND u.ntfy_topic <> '', u.disabled_at IS NOT NULL, GREATEST( COALESCE((SELECT MAX(last_seen_at) FROM sessions WHERE user_id = u.id), 0), COALESCE((SELECT MAX(last_used_at) FROM api_keys WHERE user_id = u.id), 0)), EXISTS (SELECT 1 FROM schedule_entries s WHERE s.team_id = m.team_id AND s.user_id = u.id AND s.date = $2), (SELECT MIN(date) FROM schedule_entries s WHERE s.team_id = m.team_id AND s.user_id = u.id AND s.date > $2) FROM team_members m JOIN users u ON u.id = m.user_id WHERE m.team_id = $1 ORDER BY u.username`, teamID, todayUTC()) if err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return } defer rows.Close() members := []memberStatus{} for rows.Next() { var m memberStatus var joined, lastActive int64 var hasTopic, disabled bool if err := rows.Scan(&m.TeamID, &m.UserID, &m.Username, &m.Role, &joined, &m.Source, &hasTopic, &disabled, &lastActive, &m.OnCall, &m.NextShift); err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return } m.JoinedAt = time.Unix(joined, 0).UTC() if lastActive > 0 { t := time.Unix(lastActive, 0).UTC() m.LastActiveAt = &t } switch { case disabled: m.Problem = "account is disabled" case !hasTopic: m.Problem = "has no ntfy topic" } m.Pageable = m.Problem == "" switch { case !m.Pageable: m.Status = memberUnpageable case m.OnCall: m.Status = memberOnCall default: m.Status = memberReachable } 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 } if managed, err := isSSOManagedMember(r.Context(), db, teamID, req.UserID); err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return } else if managed { respond(w, http.StatusConflict, errResp(ssoManagedMsg)) return } // Demoting the last owner is removing them by another route: the team // would have nobody who can edit it. if req.Role == models.RoleMember { last, err := isLastTeamOwner(r.Context(), db, teamID, req.UserID) if err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return } if last { respond(w, http.StatusConflict, errResp("cannot demote the last owner of a team")) 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 } if managed, err := isSSOManagedMember(r.Context(), db, teamID, userID); err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return } else if managed { respond(w, http.StatusConflict, errResp(ssoManagedMsg)) 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) } } // ssoManagedMsg is the refusal for editing access that single sign-on owns. const ssoManagedMsg = "this membership is managed by single sign-on; change the user's groups in the identity provider" // isSSOManagedMember reports whether the membership comes from the group sync. // Editing it here would be undone at the person's next sign-in, so it is refused // instead of appearing to work. func isSSOManagedMember(ctx context.Context, db *sql.DB, teamID, userID int64) (bool, error) { var managed bool err := db.QueryRowContext(ctx, "SELECT EXISTS (SELECT 1 FROM team_members WHERE team_id = $1 AND user_id = $2 AND source = 'oidc')", teamID, userID).Scan(&managed) return managed, err } 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 // --------------------------------------------------------------------------- // sourceQuietAfter is how long a source may go without posting before the // Sources page calls it quiet rather than active. A day is longer than any // repeat_interval worth having, so an Alertmanager that is up and has anything // firing never crosses it; a source with nothing firing may, and that is a // reason to look, not proof of a fault — which is why this is a colour and not // an alarm. Dead man's switches are where silence pages. const sourceQuietAfter = 24 * time.Hour const ( sourceActive = "active" sourceQuiet = "quiet" sourceNever = "never" ) // integrationStatus is an integration as the Sources page shows it. type integrationStatus struct { models.Integration // Status is active when the key posted within sourceQuietAfter, quiet when // it has posted but not lately, never when it has not posted at all. Status string `json:"status"` // LastAlertAt is when an alert last arrived on this source, which is not the // same as when it last posted: a payload with nothing usable in it stamps // last_used_at and not this. Absent until an alert has arrived since // migration 010 started recording it. LastAlertAt *time.Time `json:"last_alert_at,omitempty"` // Alerts24h counts the distinct alerts this source refreshed in the last // day. An alert re-sent every few hours counts once, not once per re-send. Alerts24h int64 `json:"alerts_24h"` } // handleListIntegrations lists a team's integrations with what each has been // delivering. 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 } now := time.Now() rows, err := db.QueryContext(r.Context(), ` SELECT i.id, i.team_id, i.kind, i.name, i.created_at, i.last_used_at, -- Scalar subqueries, not a join and GROUP BY: each is a -- single range over alerts_integration_idx, where the join -- would read every alert a source ever delivered. (SELECT MAX(received_at) FROM alerts WHERE integration_id = i.id), (SELECT COUNT(*) FROM alerts WHERE integration_id = i.id AND received_at >= $2) FROM integrations i WHERE i.team_id = $1 ORDER BY i.id`, teamID, now.Add(-sourceQuietAfter).Unix()) if err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return } defer rows.Close() integrations := []integrationStatus{} for rows.Next() { var i integrationStatus var created int64 var lastUsed, lastAlert *int64 if err := rows.Scan(&i.ID, &i.TeamID, &i.Kind, &i.Name, &created, &lastUsed, &lastAlert, &i.Alerts24h); err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) return } i.CreatedAt = time.Unix(created, 0).UTC() i.LastUsedAt = unixPtr(lastUsed) i.LastAlertAt = unixPtr(lastAlert) switch { case i.LastUsedAt == nil: i.Status = sourceNever case now.Sub(*i.LastUsedAt) > sourceQuietAfter: i.Status = sourceQuiet default: i.Status = sourceActive } 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) } } // handleRenameIntegration renames a source. The key is untouched, so nothing // posting with it notices. func handleRenameIntegration(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 } 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 } if len(req.Name) > 100 { respond(w, http.StatusBadRequest, errResp("name is too long")) return } res, err := db.ExecContext(r.Context(), "UPDATE integrations SET name = $1 WHERE id = $2 AND team_id = $3", req.Name, 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) } } 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 } // alertSource is who an arriving webhook is from: the integration whose key it // used, and the team that integration puts its alerts in. type alertSource struct { integrationID int64 teamID int64 } // sourceForKey resolves an integration key to its source, and stamps the key's // last use. An unknown key is not an error worth distinguishing: the caller is // told nothing beyond "no". func sourceForKey(ctx context.Context, db *sql.DB, key string) (alertSource, error) { var src alertSource err := db.QueryRowContext(ctx, "SELECT id, team_id FROM integrations WHERE key_hash = $1", hashToken(key)). Scan(&src.integrationID, &src.teamID) if errors.Is(err, sql.ErrNoRows) { return alertSource{}, errUnknownIntegration } if err != nil { return alertSource{}, 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 id = $2", time.Now().Unix(), src.integrationID) return src, 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) } }