Files
terdut-operator/internal/tdclient/client.go
T
Niklas Ye 8064876cb1
CI / test (push) Successful in 1m46s
Stage 1: TerdutServer full lifecycle (Deployment, Service, both database
paths, self-registration bootstrap)

Replaces the bring-your-own-only Stage 1 (commit 1be7cf2) wholesale, per
the redesign in the previous two commits: the operator creates every
server it manages, so self-registration (DESIGN.md §6) is the only
bootstrap path, and Deployment/Service/database management builds
together with it (ROADMAP.md Stage 1) rather than behind a separate
later stage.

Grounded in terdut-server's actual chart (charts/terdut-server/templates/
deployment.yaml, values.yaml), not reconstructed from DESIGN.md's
illustrative YAML alone -- env var names, the password-via-PGPASSWORD
convention, the Recreate deployment strategy, /healthz probes, and the
TERDUT_OPERATOR_MODE=true decision (always on here, unlike the chart's
default-off: every write this operator's own future controllers make
goes through a service account already) all match that source exactly.

- api/v1alpha1: full TerdutServerSpec (image, replicas, networking,
  database, sweeper, deadman, notify, oidc, passwordLogin, allowedTeams).
  spec.database is a oneOf (dsn xor postgresClusterRef) via CEL
  XValidation. No spec.credentialsSecretRef -- removed entirely in the
  prior redesign commit, not carried forward.
- internal/controller:
  - terdutserver_deployment.go: Deployment + Service via CreateOrUpdate,
    owned (OwnerReference), env built field-for-field against the chart.
  - terdutserver_database.go: both §8 paths. The Zalando path resolves
    the postgresql.acid.zalan.do CR by convention (database/role both
    "terdut", matching every DESIGN.md example) and only ever confirms
    its generated credentials Secret exists -- never reads the value,
    same "wire a secretKeyRef, don't read it" posture the DSN path takes.
    classifyClusterGetError is its own function specifically so the
    CRD-not-installed case (meta.IsNoMatchError) is unit-testable without
    a real client.
  - terdutserver_bootstrap.go: self-registration, checkpointed against
    both real crash windows (DESIGN.md §6 point 1) -- an admin-key
    checkpoint Secret, and adopt-via-GET+mint-new-key on a 409 from
    creating the service account. BootstrapStateLost is its own error
    type so Reconcile can route it to a condition instead of an infinite
    retry.
  - terdutserver_controller.go: ties it together -- finalizer add, DB
    resolution, Deployment/Service reconcile, wait for a ready replica,
    bootstrap, Ready/Bootstrapped/DatabaseReady conditions. Finalizer on
    delete only removes the generated Secrets: terdut-server's API can't
    delete a user or service account, only revoke keys, so there's
    nothing server-side to undo.
- internal/tdclient: added Bootstrap, CreateInstanceServiceAccount,
  GetServiceAccountByName, CreateServiceAccountKey, matching
  terdut-server's real handlers' request/response shapes (internal/api/
  users.go, service_accounts.go in that repo) field-for-field.
- Tests: envtest suite covering the full DSN-path lifecycle end to end
  (finalizer -> Deployment/Service -> simulated readiness -> real
  bootstrap against an httptest.Server fake), the adopt-on-409 recovery
  path, BootstrapStateLost, both Zalando outcomes (cluster not found;
  cluster + Secret found -> real DSN -> Ready), and deletion. A minimal
  test-only stub of the Zalando CRD (internal/controller/testdata) lets
  envtest create fixture objects without a real postgres-operator
  installed. 74.0%/44.7% coverage, 0 lint issues.
- Two things scoped down from §8's full ambition, called out in code and
  ROADMAP.md rather than silently dropped: no live watch on the
  Zalando-generated Secret for rotation (periodic resync notices
  eventually, not immediately), no Gateway API HTTPRoute creation from
  spec.networking (would add a new dependency; nothing about proving
  bootstrap works depends on external ingress existing). Both are
  near-term follow-ups.

Verified locally: make fmt lint test build all clean.
2026-10-01 09:11:56 +02:00

252 lines
8.1 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).
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
}