From 8b9adb4910682ab2f5d46ac868bb0a54388a414c Mon Sep 17 00:00:00 2001 From: Niklas Ye Date: Tue, 20 Oct 2026 15:34:31 +0200 Subject: [PATCH] Let the instance service account list a team's deadman switches GET /api/teams/{id}/deadman/switches used requireTeamMember, which 404s for the instance-scoped service account because it belongs to no team. terdut-operator lists switches by name on every reconcile, so TerdutTeams never became ready. Instance service accounts now pass, as they already do for the write endpoints. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- charts/terdut-server/Chart.yaml | 4 ++-- internal/api/service_accounts_test.go | 28 +++++++++++++++++++++++++++ internal/api/teams.go | 5 ++++- 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/charts/terdut-server/Chart.yaml b/charts/terdut-server/Chart.yaml index 64eaa07..991af49 100644 --- a/charts/terdut-server/Chart.yaml +++ b/charts/terdut-server/Chart.yaml @@ -15,5 +15,5 @@ type: application # appVersion and image.tag in values.yaml no longer agree, and that is not an oversight: # image.tag stays "latest", which is what a local install actually pulls. appVersion is # metadata and drives nothing. -version: 0.47.1 -appVersion: "v0.47.1" +version: 0.47.2 +appVersion: "v0.47.2" diff --git a/internal/api/service_accounts_test.go b/internal/api/service_accounts_test.go index a4520c4..13986d6 100644 --- a/internal/api/service_accounts_test.go +++ b/internal/api/service_accounts_test.go @@ -490,3 +490,31 @@ func TestDeadman_UpdateInPlacePreservesID(t *testing.T) { t.Errorf("expected the update to replace in place, not add a row, got %d switches", len(list)) } } + +// The operator reconciles a team's dead man's switches by listing them, and its +// instance-scoped account is not a member of any team it manages. +func TestServiceAccount_InstanceScopeListsATeamsDeadmanSwitches(t *testing.T) { + s := newTS(t) + instanceKey := createServiceAccount(t, s, s.key, "terdut-operator", models.ServiceAccountScopeInstance, 0) + team := createTeamAs(t, s, instanceKey, "team-a") + path := "/api/teams/" + id64(team) + "/deadman/switches" + + resp := s.reqAs(t, instanceKey, http.MethodPost, path, + map[string]any{"name": "watchdog", "matcher": "alertname=Watchdog", "timeout_seconds": 900, "severity": "critical"}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create switch: %d", resp.StatusCode) + } + resp.Body.Close() + + resp = s.reqAs(t, instanceKey, http.MethodGet, path, nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("list switches as the instance account: %d", resp.StatusCode) + } + var list []struct { + Name string `json:"name"` + } + decode(t, resp, &list) + if len(list) != 1 || list[0].Name != "watchdog" { + t.Errorf("expected the one switch just created, got %+v", list) + } +} diff --git a/internal/api/teams.go b/internal/api/teams.go index b14dee9..dc3f3c8 100644 --- a/internal/api/teams.go +++ b/internal/api/teams.go @@ -826,7 +826,10 @@ func handleListTeamDeadman(db *sql.DB) http.HandlerFunc { if !ok { return } - if !requireTeamMember(w, r, teamID) { + // The operator's instance-scoped account reconciles the switches by name, + // so it has to read them without being a member: that is owner reach, + // the same as the writes below, and still reads no incidents. + if !isInstanceServiceAccount(r.Context()) && !requireTeamMember(w, r, teamID) { return }