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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user