Stage 3: TerdutEscalationRule + TerdutDeadmanSwitch
CI / test (push) Has been cancelled

Both child CRDs resolve their own teamRef -> TerdutTeam.status via the new
shared resolveTeamAndClient helper (childref.go), never chaining up to
TerdutServer (DESIGN.md §5) -- TerdutTeam.status.serverEndpoint, added in
this same stage, is what makes that literally true.

TerdutEscalationRule: one PUT /api/teams/{id}/escalation per reconcile
(an upsert server-side, confirmed against source), resolving each "user"
target's username to a user_id via GET /api/users first and reporting
Ready: False, reason: UnknownUser if it doesn't resolve. No DELETE exists
for this resource, so its delete path PUTs an empty policy as the closest
available undo.

TerdutDeadmanSwitch: real create/update-in-place/delete, using
terdut-server v0.33.0's PUT (added specifically for this operator). No
unique-name constraint server-side, so idempotent-create here is
GET-list-and-match-by-name rather than adopt-on-409.

Extends tdclient with User/GetUserByUsername, the escalation request types
+ SetEscalation, and DeadmanSwitch + its CRUD methods. Also folds
ConditionTeamReady into the single shared ConditionReady constant, since
both were literally "Ready" and Stage 3 would otherwise have needed a
third same-valued constant.

internal/controller/terdutserver_controller_test.go's fakeTerdutServer
grows GET /api/users, PUT .../escalation, and the full dead man's switch
collection/item routes, replacing the old parseTeamPath/handleTeamByID
pair with a more general parseTeamSubPath/handleTeamSubPath dispatcher
that still covers every existing Stage 1/2 route unchanged.

make fmt lint test build all clean; envtest coverage for
internal/controller: 50.5% -> 71.7%.
This commit is contained in:
Niklas Ye
2026-10-01 13:50:08 +02:00
parent fef60caf06
commit fb9e6a38dc
31 changed files with 2399 additions and 51 deletions
+139
View File
@@ -357,3 +357,142 @@ func (c *Client) SetTeamOIDCGroups(ctx context.Context, teamID int64, memberGrou
}
return c.do(req, nil)
}
// User mirrors terdut-server's models.User, minus fields this client never
// reads.
type User struct {
ID int64 `json:"id"`
Username string `json:"username"`
}
// GetUserByUsername calls GET /api/users and finds the one matching exactly
// -- confirmed open to any authenticated caller, not gated by team
// membership or admin (internal/api/router.go's own comment: "readable by
// anyone signed in"), so the team-scoped credential a TerdutEscalationRule's
// controller already holds is enough. There is no server-side filter, so
// this always fetches the whole list; terdut-server's own query has no
// pagination either (confirmed against source), so this matches what the
// server itself considers an acceptable cost. Returns nil, nil on no match.
func (c *Client) GetUserByUsername(ctx context.Context, username string) (*User, error) {
req, err := c.newRequest(ctx, http.MethodGet, "/api/users", nil)
if err != nil {
return nil, err
}
var users []User
if err := c.do(req, &users); err != nil {
return nil, err
}
for _, u := range users {
if u.Username == username {
return &u, nil
}
}
return nil, nil
}
// EscalationTargetRequest/EscalationLevelRequest/SetEscalationRequest mirror
// terdut-server's escalationTargetJSON/escalationLevelJSON/escalationJSON
// (internal/api/escalation.go) -- the PUT body, not the richer GET response
// (escalationView), which this client never needs to decode since the
// controller always computes its own desired state fresh from spec.
type EscalationTargetRequest struct {
Kind string `json:"kind"`
UserID *int64 `json:"user_id,omitempty"`
}
type EscalationLevelRequest struct {
Position int64 `json:"position"`
TimeoutSeconds int64 `json:"timeout_seconds"`
Targets []EscalationTargetRequest `json:"targets"`
}
type SetEscalationRequest struct {
RepeatCount int64 `json:"repeat_count"`
FallbackTopic string `json:"fallback_topic"`
Levels []EscalationLevelRequest `json:"levels"`
}
// SetEscalation calls PUT /api/teams/{teamID}/escalation -- an upsert
// server-side (confirmed against source: `INSERT ... ON CONFLICT (team_id)
// DO UPDATE`), so there is no separate create step for this resource at
// all, unlike Team or the dead man's switch.
func (c *Client) SetEscalation(ctx context.Context, teamID int64, body SetEscalationRequest) error {
req, err := c.newRequest(ctx, http.MethodPut, fmt.Sprintf("/api/teams/%d/escalation", teamID), body)
if err != nil {
return err
}
return c.do(req, nil)
}
// DeadmanSwitch mirrors terdut-server's deadmanSwitchStatus
// (internal/api/deadman.go), minus fields this client never reads.
type DeadmanSwitch struct {
ID int64 `json:"id"`
Name string `json:"name"`
Matcher string `json:"matcher"`
TimeoutSeconds int64 `json:"timeout_seconds"`
Severity string `json:"severity"`
}
// ListDeadmanSwitches calls GET /api/teams/{teamID}/deadman/switches. There
// is no unique-name constraint on this resource server-side (confirmed
// against source), so this is the idempotent-create lookup for it --
// GET-list-and-match-by-name, not adopt-on-409.
func (c *Client) ListDeadmanSwitches(ctx context.Context, teamID int64) ([]DeadmanSwitch, error) {
req, err := c.newRequest(ctx, http.MethodGet, fmt.Sprintf("/api/teams/%d/deadman/switches", teamID), nil)
if err != nil {
return nil, err
}
var switches []DeadmanSwitch
if err := c.do(req, &switches); err != nil {
return nil, err
}
return switches, nil
}
// deadmanSwitchRequest mirrors terdut-server's own deadmanSwitchRequest
// (internal/api/teams.go) -- the same body shape for both create and
// update.
type deadmanSwitchRequest struct {
Name string `json:"name,omitempty"`
Matcher string `json:"matcher"`
TimeoutSeconds int64 `json:"timeout_seconds"`
Severity string `json:"severity"`
}
// CreateDeadmanSwitch calls POST /api/teams/{teamID}/deadman/switches.
func (c *Client) CreateDeadmanSwitch(ctx context.Context, teamID int64, name, matcher string, timeoutSeconds int64, severity string) (*DeadmanSwitch, error) {
req, err := c.newRequest(ctx, http.MethodPost, fmt.Sprintf("/api/teams/%d/deadman/switches", teamID),
deadmanSwitchRequest{Name: name, Matcher: matcher, TimeoutSeconds: timeoutSeconds, Severity: severity})
if err != nil {
return nil, err
}
var sw DeadmanSwitch
if err := c.do(req, &sw); err != nil {
return nil, err
}
return &sw, nil
}
// UpdateDeadmanSwitch calls PUT /api/teams/{teamID}/deadman/switches/{switchID}
// -- real update-in-place, added in terdut-server v0.33.0 specifically for
// this controller (that handler's own doc comment names terdut-operator).
func (c *Client) UpdateDeadmanSwitch(ctx context.Context, teamID, switchID int64, name, matcher string, timeoutSeconds int64, severity string) error {
req, err := c.newRequest(ctx, http.MethodPut,
fmt.Sprintf("/api/teams/%d/deadman/switches/%d", teamID, switchID),
deadmanSwitchRequest{Name: name, Matcher: matcher, TimeoutSeconds: timeoutSeconds, Severity: severity})
if err != nil {
return err
}
return c.do(req, nil)
}
// DeleteDeadmanSwitch calls DELETE /api/teams/{teamID}/deadman/switches/{switchID}.
func (c *Client) DeleteDeadmanSwitch(ctx context.Context, teamID, switchID int64) error {
req, err := c.newRequest(ctx, http.MethodDelete,
fmt.Sprintf("/api/teams/%d/deadman/switches/%d", teamID, switchID), nil)
if err != nil {
return err
}
return c.do(req, nil)
}