fb9e6a38dc
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%.
499 lines
18 KiB
Go
499 lines
18 KiB
Go
// Package tdclient is a minimal terdut-server API client for the operator's
|
|
// own controllers. It mirrors the shape of terdut-tui's
|
|
// internal/api/client.go (baseURL/httpClient fields, a shared do/statusError
|
|
// helper, per terdut/CLAUDE.md's "any change to a server endpoint or JSON
|
|
// shape must be mirrored" convention) but authorizes with a Bearer API key
|
|
// rather than a session cookie — the operator never signs in as a human
|
|
// (DESIGN.md §6).
|
|
package tdclient
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Client talks to one terdut-server install, optionally as a service
|
|
// account. A zero-value token works for endpoints that don't need one
|
|
// (Version, Bootstrap).
|
|
|
|
// fieldName is the JSON key every create/rename request body below shares.
|
|
const fieldName = "name"
|
|
|
|
type Client struct {
|
|
baseURL string
|
|
httpClient *http.Client
|
|
token string
|
|
}
|
|
|
|
// New creates a Client against baseURL, with no credential set.
|
|
func New(baseURL string) *Client {
|
|
return &Client{
|
|
baseURL: strings.TrimRight(baseURL, "/"),
|
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
|
}
|
|
}
|
|
|
|
// WithToken returns a copy of c that authorizes every request as a Bearer
|
|
// credential — a user's own API key or a service-account key
|
|
// (SERVICE-ACCOUNTS.md), the server resolves either the same way.
|
|
func (c *Client) WithToken(token string) *Client {
|
|
cp := *c
|
|
cp.token = token
|
|
return &cp
|
|
}
|
|
|
|
// StatusError is a non-2xx response — the server's {"error": "..."} body
|
|
// decoded into Message, same shape terdut-tui's client uses.
|
|
type StatusError struct {
|
|
Code int
|
|
Message string
|
|
}
|
|
|
|
func (e *StatusError) Error() string {
|
|
if e.Message != "" {
|
|
return fmt.Sprintf("server returned %d: %s", e.Code, e.Message)
|
|
}
|
|
return fmt.Sprintf("server returned %d", e.Code)
|
|
}
|
|
|
|
func statusError(resp *http.Response) error {
|
|
var e struct {
|
|
Error string `json:"error"`
|
|
}
|
|
_ = json.NewDecoder(resp.Body).Decode(&e)
|
|
return &StatusError{Code: resp.StatusCode, Message: e.Error}
|
|
}
|
|
|
|
func (c *Client) newRequest(ctx context.Context, method, path string, body any) (*http.Request, error) {
|
|
var reader *strings.Reader
|
|
if body != nil {
|
|
data, err := json.Marshal(body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
reader = strings.NewReader(string(data))
|
|
}
|
|
var req *http.Request
|
|
var err error
|
|
if reader != nil {
|
|
req, err = http.NewRequestWithContext(ctx, method, c.baseURL+path, reader)
|
|
} else {
|
|
req, err = http.NewRequestWithContext(ctx, method, c.baseURL+path, nil)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
if c.token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
|
}
|
|
return req, nil
|
|
}
|
|
|
|
func (c *Client) do(req *http.Request, out any) error {
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
if resp.StatusCode >= 400 {
|
|
return statusError(resp)
|
|
}
|
|
if out != nil {
|
|
return json.NewDecoder(resp.Body).Decode(out)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Version calls GET /api/version — unauthenticated, per terdut-server's own
|
|
// router.go comment ("a client deciding whether it can talk to this server —
|
|
// terdut-tui, terdut-operator — needs to ask before it holds a credential
|
|
// for it"). Used here purely as a reachability probe: a bad endpoint fails
|
|
// here, clearly, rather than on whatever the controller tries first.
|
|
func (c *Client) Version(ctx context.Context) (string, error) {
|
|
req, err := c.newRequest(ctx, http.MethodGet, "/api/version", nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var v struct {
|
|
Version string `json:"version"`
|
|
}
|
|
if err := c.do(req, &v); err != nil {
|
|
return "", err
|
|
}
|
|
return v.Version, nil
|
|
}
|
|
|
|
// APIKey is the raw key a bootstrap or service-account-key mint hands back —
|
|
// the one moment its value exists outside the request that generated it.
|
|
// Mirrors terdut-server's models.APIKey/models.ServiceAccountKey shape
|
|
// (internal/models in that repo) for the fields this client actually reads.
|
|
type APIKey struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
Key string `json:"key"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
// BootstrapResult is /api/bootstrap's 201 response body.
|
|
type BootstrapResult struct {
|
|
User struct {
|
|
ID int64 `json:"id"`
|
|
Username string `json:"username"`
|
|
Email string `json:"email"`
|
|
} `json:"user"`
|
|
APIKey APIKey `json:"api_key"`
|
|
}
|
|
|
|
// Bootstrap calls POST /api/bootstrap — unauthenticated, single-shot per
|
|
// install (internal/api/users.go's handleBootstrap in terdut-server:
|
|
// gated on SELECT COUNT(*) FROM users). Returns the raw admin key directly;
|
|
// DESIGN.md §6 has the controller use it for exactly one further call
|
|
// (CreateServiceAccount) and discard it, never storing it as the lasting
|
|
// credential.
|
|
//
|
|
// A StatusError with Code 403 means this install already has a user —
|
|
// per this operator's design (DESIGN.md §1), that only happens if this
|
|
// exact TerdutServer's own controller already won this race on an earlier,
|
|
// interrupted reconcile; see the checkpoint-Secret handling in the
|
|
// controller, not a retry loop here.
|
|
func (c *Client) Bootstrap(ctx context.Context, username, email string) (*BootstrapResult, error) {
|
|
req, err := c.newRequest(ctx, http.MethodPost, "/api/bootstrap", map[string]string{
|
|
"username": username,
|
|
"email": email,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var result BootstrapResult
|
|
if err := c.do(req, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
// ServiceAccount mirrors terdut-server's models.ServiceAccount
|
|
// (internal/models/service_account.go), minus fields this client never
|
|
// reads.
|
|
type ServiceAccount struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
Scope string `json:"scope"`
|
|
}
|
|
|
|
// CreateServiceAccountResult is POST /api/service-accounts' 201 response.
|
|
type CreateServiceAccountResult struct {
|
|
ServiceAccount ServiceAccount `json:"service_account"`
|
|
Key APIKey `json:"key"`
|
|
}
|
|
|
|
// CreateInstanceServiceAccount calls POST /api/service-accounts with
|
|
// scope "instance", authenticated with c's current token (the raw admin key
|
|
// from Bootstrap, for the operator's own first-ever call). A StatusError
|
|
// with Code 409 means a prior, interrupted attempt already created this
|
|
// name — DESIGN.md §6's adopt-rather-than-error rule: the caller should
|
|
// fall back to GetServiceAccountByName + CreateServiceAccountKey, not treat
|
|
// this as a hard failure.
|
|
func (c *Client) CreateInstanceServiceAccount(ctx context.Context, name string) (*CreateServiceAccountResult, error) {
|
|
req, err := c.newRequest(ctx, http.MethodPost, "/api/service-accounts", map[string]string{
|
|
fieldName: name,
|
|
"scope": "instance",
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var result CreateServiceAccountResult
|
|
if err := c.do(req, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
// CreateTeamServiceAccount calls POST /api/service-accounts with scope
|
|
// "team" for teamID, authenticated with c's current token -- the
|
|
// TerdutServer's instance-scoped credential, per DESIGN.md §6 point 3: an
|
|
// instance-scoped caller may mint a team-scoped account against any team
|
|
// (internal/api/service_accounts.go's handleCreateServiceAccount,
|
|
// confirmed against source), which is what lets TerdutTeam's own
|
|
// controller do this without ever touching a human credential. Same
|
|
// adopt-on-409 contract as CreateInstanceServiceAccount.
|
|
func (c *Client) CreateTeamServiceAccount(ctx context.Context, name string, teamID int64) (*CreateServiceAccountResult, error) {
|
|
req, err := c.newRequest(ctx, http.MethodPost, "/api/service-accounts", map[string]any{
|
|
fieldName: name,
|
|
"scope": "team",
|
|
"team_id": teamID,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var result CreateServiceAccountResult
|
|
if err := c.do(req, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
// GetServiceAccountByName calls GET /api/service-accounts?name=... --
|
|
// authenticated (terdut-server's AuthMiddleware hard-rejects any
|
|
// unauthenticated request before this endpoint's own, more permissive
|
|
// internal check ever runs; DESIGN.md §6). Returns nil, nil if nothing
|
|
// matches, not an error -- the server's own distinction between "found
|
|
// nothing" and "the call failed".
|
|
func (c *Client) GetServiceAccountByName(ctx context.Context, name string) (*ServiceAccount, error) {
|
|
req, err := c.newRequest(ctx, http.MethodGet, "/api/service-accounts?name="+name, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var accounts []ServiceAccount
|
|
if err := c.do(req, &accounts); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(accounts) == 0 {
|
|
return nil, nil
|
|
}
|
|
return &accounts[0], nil
|
|
}
|
|
|
|
// CreateServiceAccountKey calls POST /api/service-accounts/{id}/keys to
|
|
// mint an additional key on an existing account -- rotation (DESIGN.md §6
|
|
// point 6), and the adopt-on-409 recovery path in point 1.
|
|
func (c *Client) CreateServiceAccountKey(ctx context.Context, serviceAccountID int64, name string) (*APIKey, error) {
|
|
req, err := c.newRequest(ctx, http.MethodPost,
|
|
fmt.Sprintf("/api/service-accounts/%d/keys", serviceAccountID),
|
|
map[string]string{fieldName: name})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var key APIKey
|
|
if err := c.do(req, &key); err != nil {
|
|
return nil, err
|
|
}
|
|
return &key, nil
|
|
}
|
|
|
|
// Team mirrors terdut-server's models.Team (internal/models/team.go), minus
|
|
// Role/Source, which are only ever populated for a human caller's own
|
|
// membership and never apply to a service account's view of a team.
|
|
type Team struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
// CreateTeam calls POST /api/teams, authenticated with c's current token --
|
|
// the TerdutServer's instance-scoped credential (DESIGN.md §6 point 3). A
|
|
// StatusError with Code 409 means a prior, interrupted attempt already
|
|
// created this name — GetTeamByName (TEAM-LOOKUP.md) is the adopt-rather-
|
|
// than-error recovery, the same contract CreateInstanceServiceAccount has.
|
|
func (c *Client) CreateTeam(ctx context.Context, name string) (*Team, error) {
|
|
req, err := c.newRequest(ctx, http.MethodPost, "/api/teams", map[string]string{fieldName: name})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var team Team
|
|
if err := c.do(req, &team); err != nil {
|
|
return nil, err
|
|
}
|
|
return &team, nil
|
|
}
|
|
|
|
// GetTeamByName calls GET /api/teams?name=... (TEAM-LOOKUP.md) — open to
|
|
// any authenticated caller, not just the team's own members. Returns nil,
|
|
// nil on no match, not an error.
|
|
func (c *Client) GetTeamByName(ctx context.Context, name string) (*Team, error) {
|
|
req, err := c.newRequest(ctx, http.MethodGet, "/api/teams?name="+name, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var teams []Team
|
|
if err := c.do(req, &teams); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(teams) == 0 {
|
|
return nil, nil
|
|
}
|
|
return &teams[0], nil
|
|
}
|
|
|
|
// RenameTeam calls PUT /api/teams/{teamID} — owner-gated server-side
|
|
// (requireTeamOwner), so c must hold this team's own team-scoped
|
|
// credential, not the instance-scoped one CreateTeam used.
|
|
func (c *Client) RenameTeam(ctx context.Context, teamID int64, name string) error {
|
|
req, err := c.newRequest(ctx, http.MethodPut,
|
|
fmt.Sprintf("/api/teams/%d", teamID), map[string]string{fieldName: name})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.do(req, nil)
|
|
}
|
|
|
|
// DeleteTeam calls DELETE /api/teams/{teamID} — owner-gated, same
|
|
// credential requirement as RenameTeam. terdut-server refuses this while
|
|
// the team has open incidents (409) — surfaced to the caller as a
|
|
// StatusError, not retried specially here.
|
|
func (c *Client) DeleteTeam(ctx context.Context, teamID int64) error {
|
|
req, err := c.newRequest(ctx, http.MethodDelete, fmt.Sprintf("/api/teams/%d", teamID), nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.do(req, nil)
|
|
}
|
|
|
|
// SetTeamOIDCGroups calls PUT /api/teams/{teamID}/oidc-groups — owner-gated,
|
|
// same credential requirement as RenameTeam. An empty group string clears
|
|
// that binding server-side (terdut-server's own NULLIF handling).
|
|
func (c *Client) SetTeamOIDCGroups(ctx context.Context, teamID int64, memberGroup, ownerGroup string) error {
|
|
req, err := c.newRequest(ctx, http.MethodPut,
|
|
fmt.Sprintf("/api/teams/%d/oidc-groups", teamID),
|
|
map[string]string{"member_group": memberGroup, "owner_group": ownerGroup})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
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)
|
|
}
|