8064876cb1
CI / test (push) Successful in 1m46s
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.
252 lines
8.1 KiB
Go
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
|
|
}
|