// 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" // fieldKind is the JSON key an integration's create request body shares // with the terdutv1alpha1.TerdutAlertSourceSpec field of the same name. const fieldKind = "kind" 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) } // Integration mirrors terdut-server's models.Integration, minus // CreatedAt/LastUsedAt, which this client never reads. Key/URL are only // ever populated by CreateIntegration's own response -- the one moment // either value exists outside terdut-server's own database (DESIGN.md // §4.5: shown once, never re-readable, same handling as the bootstrap // admin key). type Integration struct { ID int64 `json:"id"` TeamID int64 `json:"team_id"` Kind string `json:"kind"` Name string `json:"name"` Key string `json:"key,omitempty"` URL string `json:"url,omitempty"` } // CreateIntegration calls POST /api/teams/{teamID}/integrations -- // owner-gated (requireTeamOwner), so c must hold this team's own // team-scoped credential. No conflict handling exists server-side at all // for this resource (no unique constraint on name, confirmed against // source) -- deliberately not treated as this resource's idempotent-create // recovery path; see the controller's own reasoning for why a crash // between this call succeeding and the webhook Secret being written can't // be recovered by listing and adopting a same-named row the way // TerdutDeadmanSwitch does. func (c *Client) CreateIntegration(ctx context.Context, teamID int64, name, kind string) (*Integration, error) { req, err := c.newRequest(ctx, http.MethodPost, fmt.Sprintf("/api/teams/%d/integrations", teamID), map[string]string{fieldName: name, fieldKind: kind}) if err != nil { return nil, err } var integ Integration if err := c.do(req, &integ); err != nil { return nil, err } return &integ, nil } // RenameIntegration calls PATCH /api/teams/{teamID}/integrations/{integrationID} // -- owner-gated, same credential requirement as CreateIntegration. Never // touches the key (terdut-server's own handler comment: "the key is // untouched, so nothing posting with it notices"), so this is safe to call // every reconcile unconditionally rather than only on detected drift -- // the same "cheap, so just always sync it" reasoning TerdutEscalationRule's // whole-policy PUT uses. func (c *Client) RenameIntegration(ctx context.Context, teamID, integrationID int64, name string) error { req, err := c.newRequest(ctx, http.MethodPatch, fmt.Sprintf("/api/teams/%d/integrations/%d", teamID, integrationID), map[string]string{fieldName: name}) if err != nil { return err } return c.do(req, nil) } // DeleteIntegration calls DELETE /api/teams/{teamID}/integrations/{integrationID} // -- owner-gated, same credential requirement as CreateIntegration. Used // both by the finalizer and by the kind-change rotation path (DESIGN.md §5). func (c *Client) DeleteIntegration(ctx context.Context, teamID, integrationID int64) error { req, err := c.newRequest(ctx, http.MethodDelete, fmt.Sprintf("/api/teams/%d/integrations/%d", teamID, integrationID), nil) if err != nil { return err } return c.do(req, nil) } // Invite is a standing link into a team (POST /api/teams/{teamID}/invites' // own response shape). URL carries the raw token exactly once, at creation // -- terdut-server never shows it again (same one-time-shown shape as an // integration's webhook key) -- so a caller that needs it later has to have // kept this response, not re-fetched it. type Invite struct { ID int64 `json:"id"` TeamID int64 `json:"team_id"` Role string `json:"role"` ExpiresAt time.Time `json:"expires_at"` MaxUses int64 `json:"max_uses"` URL string `json:"url,omitempty"` } // CreateInvite calls POST /api/teams/{teamID}/invites -- owner-gated // (requireTeamOwner), so c must hold this team's own team-scoped // credential, which already satisfies that check via its synthetic owner // membership (terdut-server's SERVICE-ACCOUNTS.md). No conflict handling // needed: unlike a team or a service account, an invite has no unique name // to collide on -- every call mints a brand new row. func (c *Client) CreateInvite(ctx context.Context, teamID int64, role string, maxUses int64) (*Invite, error) { req, err := c.newRequest(ctx, http.MethodPost, fmt.Sprintf("/api/teams/%d/invites", teamID), map[string]any{"role": role, "max_uses": maxUses}) if err != nil { return nil, err } var inv Invite if err := c.do(req, &inv); err != nil { return nil, err } return &inv, nil } // RevokeInvite calls DELETE /api/teams/{teamID}/invites/{inviteID} -- same // credential requirement as CreateInvite. A 404 (already revoked, or never // existed) is the caller's to treat as success if it wants to, the same way // DeleteTeam's own 404 handling works -- this method itself just reports // whatever terdut-server said. func (c *Client) RevokeInvite(ctx context.Context, teamID, inviteID int64) error { req, err := c.newRequest(ctx, http.MethodDelete, fmt.Sprintf("/api/teams/%d/invites/%d", teamID, inviteID), nil) if err != nil { return err } return c.do(req, nil) }