74359c72ab
The rest of #4. Two halves that belong together because they are the same sentence from opposite ends: a team decides which of its alerts are heartbeats, and the UI has to be able to say which team it is talking about. Switches were three environment variables, which made them one setting for the whole install. That was the last piece of the alerting path a team could not control: it could take its own alerts on its own key and still not say which of them were heartbeats, or how long a silence had to last. They are a row per team now, edited by an owner through PUT /api/teams/{teamID}/deadman, and the sweeper runs each team against its own matchers, timeout and severity. The environment variables become the starting point rather than the setting. Every team without a configuration is seeded from them at startup, so an upgrade keeps watching exactly what it was watching, and SeedDeadmanConfigs never overwrites -- a redeploy must not put the environment's value back over an owner's edit. A team created later watches nothing until somebody says otherwise: inheriting an install-wide heartbeat would page a new team about a source it has never heard of, and a switch nobody chose is the kind that gets muted rather than fixed. A matcher string with no alertname in it is refused at the door instead of stored. Storing it would produce a switch that watches nothing silently, which is the exact failure the feature exists to prevent. NewRouter and Sweep lose their DeadmanConfig parameter -- there is no longer one answer to hand them. The type stays, because parsing a matcher string is still parsing a matcher string. The UI side: rows in the queue carry a team badge, the filter row gains a team chip per team, and "on call now" shows one card per team. All three appear only when the viewer is in more than one team -- otherwise they are the same word repeated down a list, which is noise rather than information, and the single-team install reads exactly as it did before teams existed. Verified against a live two-team server as well as in tests: the combined queue labelled by team, the team_id filter, a heartbeat that is a heartbeat in one team and an ordinary alert in another, and a new team's switches starting empty while the upgraded team keeps the environment's. Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
576 lines
18 KiB
Go
576 lines
18 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)
|
|
}
|
|
}
|
|
|
|
// 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 team the deprecated unauthenticated webhook routes to:
|
|
// the oldest one, which on an upgraded install is the "Default" team every
|
|
// pre-teams row was moved into.
|
|
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,
|
|
})
|
|
}
|
|
}
|