// 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). 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{ "name": 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 } // 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{"name": name}) if err != nil { return nil, err } var key APIKey if err := c.do(req, &key); err != nil { return nil, err } return &key, nil }