7c87ae2af8
Both existed to carry an upgrade across, and both upgrades are done. /api/alertmanager/webhook took no credential at all: anything able to reach the port could open an incident for anybody. v0.12.0 kept it, deprecated, so the teams release did not stop delivery while the Alertmanager config was edited, and logged a line per payload asking to be moved. The cluster's Alertmanager now posts on an integration key -- verified in the log, every two minutes, with no deprecation line since the rollout -- so the door can be shut rather than left ajar until somebody remembers. A sender still posting there gets the JSON 404 every unknown /api path gets. The tests move with it, which they should have done anyway: the harness mints an integration key for the default team and posts on that, so they exercise the path production uses rather than one only they still used. scripts/sqlite-to-postgres.go goes the same way. It was written to be temporary, it was the last thing needing modernc.org/sqlite, and this install migrated on 2026-09-20. `go mod tidy` drops the driver and its six transitive dependencies with it; the module graph is now chi, pgx, pgerrcode and x/crypto. Anyone still on v0.10.x can take the script out of the v0.12.0 tag, which the README now says. Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
577 lines
18 KiB
Go
577 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 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,
|
|
})
|
|
}
|
|
}
|