Stage 1: TerdutServer full lifecycle (Deployment, Service, both database
CI / test (push) Successful in 1m46s
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.
This commit is contained in:
+144
-8
@@ -5,10 +5,6 @@
|
||||
// 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 (
|
||||
@@ -22,7 +18,7 @@ import (
|
||||
|
||||
// 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).
|
||||
// (Version, Bootstrap).
|
||||
type Client struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
@@ -68,12 +64,29 @@ func statusError(resp *http.Response) error {
|
||||
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)
|
||||
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)
|
||||
}
|
||||
@@ -101,7 +114,7 @@ func (c *Client) do(req *http.Request, out any) error {
|
||||
// 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")
|
||||
req, err := c.newRequest(ctx, http.MethodGet, "/api/version", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -113,3 +126,126 @@ func (c *Client) Version(ctx context.Context) (string, error) {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user