a0ea13955e
The actual fix for the human-onboarding gap niklas/terdut-server#23 found -- not a terdut-server change at all. A team-scoped credential is already owner-equivalent for POST/GET/DELETE /api/teams/{teamID}/invites (requireTeamOwner's synthetic-membership mechanism, ratified not accidental per that repo's SERVICE-ACCOUNTS.md), and invite redemption bypasses signup_mode entirely -- this TerdutTeam controller just never grew a feature to use either fact. New spec.invite{enabled, role (member|owner, default member), maxUses (1-100, default 1)} and status.inviteSecretRef. The Secret lives in the TerdutTeam's OWN namespace, not the operator's: unlike status.credentialsSecretRef (a durable, high-privilege credential, kept operator-side per DESIGN.md §6), an invite is bounded and limited-use, meant for this namespace's own human operators to read and hand out -- same precedent as TerdutAlertSource's status.webhookURLSecretRef, same- namespace and OwnerReference'd so deleting the TerdutTeam garbage-collects it automatically. internal/controller/terdutteam_invite.go: mints on first spec.invite.enabled, refreshes a day ahead of terdut-server's fixed 7-day TTL (reading the Secret's own stored expiresAt, no extra server round-trip per reconcile), revokes server-side and deletes the Secret when flipped back to false. A lost invite Secret is silently re-minted rather than treated as unrecoverable the way TerdutAlertSource's webhook key is -- nothing external holds a durable dependency on one specific invite link staying stable, it's read once by one human and handed out. New tdclient.Invite/CreateInvite/RevokeInvite. New envtest coverage: mint into the team's own namespace, refresh-before-expiry, revoke-on-disable (internal/controller/terdutteam_controller_test.go's new "spec.invite" Describe block), plus the fake server growing invite support (terdutserver_controller_test.go) -- its handleTeamSubPath dispatcher was split further (deadman switches into their own handleDeadmanSubPath, matching the existing handleIntegrationSubPath precedent) to stay under golangci-lint's gocyclo threshold with the new route added. examples/demo updated to prove this end to end: 02-team-platform.yaml turns on spec.invite; run-demo.sh's bootstrap_login/join_demo_teams (the psql signup_mode flip + a direct team_members INSERT) are replaced by redeem_platform_invite (reads status.inviteSecretRef, a real POST /api/signup with the invite token) and join_payments_team (POST /api/teams/{teamID}/members using Payments' own credential and alice's user id resolved via GET /api/users, deliberately not given its own spec.invite, so the demo shows both onboarding paths this feature unlocks) -- zero kubectl exec/psql calls remain anywhere in the script. README.md's "First login" section rewritten to match; it no longer documents the admin-token curl call that 403s against current terdut-server (niklas/terdut-server#23). Depends on niklas/terdut-server#24 (the callerMayManageServiceAccount fix for terdut-operator#3) being released before this is deployed for real -- not required to build or test this change itself, since the envtest fake never modeled that authorization gap to begin with.
616 lines
22 KiB
Go
616 lines
22 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"
|
|
|
|
// 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)
|
|
}
|