// 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" "errors" "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 } // ignoreNotFound turns a 404 into success. Every DELETE here is idempotent: the // thing already being gone is the outcome the caller wanted, and a finalizer // that failed on it could never be removed. func ignoreNotFound(err error) error { if se, ok := errors.AsType[*StatusError](err); ok && se.Code == http.StatusNotFound { return nil } return err } 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 } // 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"` ExternalID *string `json:"external_id,omitempty"` } // CreateTeam calls POST /api/teams with an external_id, which makes it // idempotent: a team that already carries that id is returned (200) instead of // created (201), so a client that crashed before recording the id finds its own // team again. A StatusError with Code 409 means the name belongs to a different // team. Needs an instance-scoped credential. func (c *Client) CreateTeam(ctx context.Context, name, externalID string) (*Team, error) { req, err := c.newRequest(ctx, http.MethodPost, "/api/teams", map[string]string{fieldName: name, "external_id": externalID}) if err != nil { return nil, err } var team Team if err := c.do(req, &team); err != nil { return nil, err } return &team, 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 ignoreNotFound(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) } // 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"` // Username names the person for a "user" target; the server resolves it. Username string `json:"username,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 ignoreNotFound(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 ignoreNotFound(c.do(req, nil)) }