Files
terdut-operator/internal/tdclient/client.go
T
Niklas Ye 1be7cf2b7f
CI / test (push) Successful in 1m41s
Stage 1: TerdutServer, bring-your-own bootstrap credentials
Implements the narrowed Stage 1 scope from ROADMAP.md, against the
bootstrap-flow fix from DESIGN.md §4.1/§6 (the earlier self-registration
flow couldn't work unauthenticated against terdut-server's real
AuthMiddleware -- see that commit for the full trace).

- api/v1alpha1: TerdutServer with spec.endpoint + spec.credentialsSecretRef
  + spec.allowedTeams (image/replicas/networking/database deferred to
  Stage 5, per DESIGN.md's own narrowing). SecretKeyRef has no namespace
  field -- always the operator's own, by construction.
- internal/controller: TerdutServerReconciler implements exactly the
  bring-your-own path -- adopt spec.credentialsSecretRef if the Secret
  exists and has data under the given key, probe GET /api/version as a
  reachability check, set Ready/Bootstrapped conditions accordingly.
  Self-registration (the /api/bootstrap race) is not implemented; unset
  spec.credentialsSecretRef reports Ready: False, reason:
  CredentialsSecretRefRequired, not an attempt at a flow that would fail
  unauthenticated anyway. No finalizer: this stage creates nothing
  server-side and adopts rather than generates its Secret, so there's
  nothing to clean up on delete yet.
- internal/tdclient: minimal terdut-server API client (Version only, the
  one call this stage needs), styled after terdut-tui's own
  internal/api/client.go per terdut/CLAUDE.md's mirroring convention.
- Tests: envtest suite covering all four not-ready paths plus the happy
  path (fake terdut-server via httptest.Server, per DESIGN.md §11), and a
  focused unit suite for tdclient. 75.6%/82.4% coverage.
- Event recording uses the new events.k8s.io/v1 recorder API
  (mgr.GetEventRecorder), not the deprecated GetEventRecorderFor --
  caught by golangci-lint's staticcheck before it shipped.

Verified locally: make fmt lint test build all clean, 0 lint issues, all
specs pass.
2026-09-30 22:30:22 +02:00

116 lines
3.4 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).
//
// Deliberately minimal for Stage 1 (ROADMAP.md): only Version, the
// reachability probe TerdutServer's controller needs. Bootstrap and the
// service-account endpoints land here once self-registration does (Stage 5).
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).
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) (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "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")
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
}