fix(api): implement /readyz with real DB + K8s API + CRD checks (§7)

Previously /readyz only verified Repo != nil && Cluster != nil — a
process-liveness check, not a dependency-health check. The spec
requires the readyz probe to verify DB, K8s API, and CRD informer
are live before declaring the pod ready.

- Repo interface gains Ping(context.Context) error
- Cluster interface gains Ping(context.Context) error
- PGRepo.Ping delegates to sql.DB.PingContext
- K8sCluster.Ping lists MinecraftServer CRDs (Limit=1) in the
  configured namespace, exercising both the API and CRD informer
- handleReadyz iterates ping checks; any failure returns 503 with
  the failing dependency name in the error message
- fakeRepo and fakeCluster gain configurable pingErr for hermetic
  test coverage of the failure paths

New test: TestReadyzPingsDependencies verifies 200 when healthy,
503 when DB or K8s API is down.
This commit is contained in:
Lemon-miaow committed 2026-07-05 16:22:08 +08:00
1 parent 7f7e459746
commit 7becb38488
6 files changed
+60 -5

No files matched your search

+34
View File
@@ -3,6 +3,7 @@ package api
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -90,6 +91,9 @@ type fakeRepo struct {
// user admin fakes // user admin fakes
seededUsers []seededUser seededUsers []seededUser
fakeQuotas map[string]*QuotaView fakeQuotas map[string]*QuotaView
// pingErr, when non-nil, is returned by Ping to simulate DB liveness check
// failures in /readyz tests.
pingErr error
} }
// fakePasskeyChallenge mirrors a webauthn_challenges row: its owner and purpose, the // fakePasskeyChallenge mirrors a webauthn_challenges row: its owner and purpose, the
@@ -646,6 +650,7 @@ func (f *fakeRepo) SeedServer(_ context.Context, name, subdomain string, _, _, _
f.aliases[subdomain] = name f.aliases[subdomain] = name
return nil return nil
} }
func (f *fakeRepo) Ping(_ context.Context) error { return f.pingErr }
func (f *fakeRepo) Audit(_ context.Context, e AuditEntry) error { func (f *fakeRepo) Audit(_ context.Context, e AuditEntry) error {
f.audits = append(f.audits, e) f.audits = append(f.audits, e)
return nil return nil
@@ -1164,6 +1169,7 @@ type fakeCluster struct {
created map[string]CreateServerInput // name -> the validated input it was created from created map[string]CreateServerInput // name -> the validated input it was created from
patched map[string]ServerSpecPatch // name -> the validated spec patch it received patched map[string]ServerSpecPatch // name -> the validated spec patch it received
createErr error createErr error
pingErr error
} }
func newFakeCluster() *fakeCluster { func newFakeCluster() *fakeCluster {
@@ -1184,6 +1190,7 @@ func (c *fakeCluster) GetBySubdomain(_ context.Context, s string) (*ServerInfo,
return nil, ErrNotFound return nil, ErrNotFound
} }
func (c *fakeCluster) ListServers(_ context.Context) ([]ServerInfo, error) { return c.list, nil } func (c *fakeCluster) ListServers(_ context.Context) ([]ServerInfo, error) { return c.list, nil }
func (c *fakeCluster) Ping(_ context.Context) error { return c.pingErr }
func (c *fakeCluster) SetDesiredState(_ context.Context, n string, s v1alpha1.DesiredState) error { func (c *fakeCluster) SetDesiredState(_ context.Context, n string, s v1alpha1.DesiredState) error {
c.desired[n] = s c.desired[n] = s
return nil return nil
@@ -1315,6 +1322,33 @@ func TestHealthzIsUnauthenticated(t *testing.T) {
} }
} }
// TestReadyzPingsDependencies proves /readyz verifies DB and K8s API liveness
// before declaring ready, and returns 503 when either is down (spec §7).
func TestReadyzPingsDependencies(t *testing.T) {
repo := newFakeRepo()
cl := newFakeCluster()
api := newTestAPI(repo, cl)
api.Internal = BearerTokenAuth{Token: "s3cr3t"}
// Both healthy.
if w := do(api.InternalHandler(), "GET", "/readyz", "", nil); w.Code != http.StatusOK {
t.Fatalf("readyz code = %d, want 200 when both deps are healthy (%s)", w.Code, w.Body.String())
}
// DB down.
repo.pingErr = errors.New("connection refused")
if w := do(api.InternalHandler(), "GET", "/readyz", "", nil); w.Code != http.StatusServiceUnavailable {
t.Fatalf("readyz code = %d, want 503 when DB is down (%s)", w.Code, w.Body.String())
}
repo.pingErr = nil
// K8s API down.
cl.pingErr = errors.New("cannot reach apiserver")
if w := do(api.InternalHandler(), "GET", "/readyz", "", nil); w.Code != http.StatusServiceUnavailable {
t.Fatalf("readyz code = %d, want 503 when K8s API is down (%s)", w.Code, w.Body.String())
}
}
func TestExternalFaceRequiresPrincipal(t *testing.T) { func TestExternalFaceRequiresPrincipal(t *testing.T) {
api := newTestAPI(newFakeRepo(), newFakeCluster()) api := newTestAPI(newFakeRepo(), newFakeCluster())
api.External = staticExternal{err: http.ErrNoCookie} // any auth error api.External = staticExternal{err: http.ErrNoCookie} // any auth error
+4
View File
@@ -75,6 +75,10 @@ type ServerSpecPatch struct {
// so handlers are tested against a fake; the controller-runtime implementation // so handlers are tested against a fake; the controller-runtime implementation
// (k8sCluster) is integration-tested only — it requires a live cluster. // (k8sCluster) is integration-tested only — it requires a live cluster.
type Cluster interface { type Cluster interface {
// Ping verifies the K8s API and CRD informer are healthy — used by /readyz
// (spec §7) to confirm the lifecycle store is reachable and synced.
Ping(ctx context.Context) error
// GetServer reads one MinecraftServer's lifecycle view, or ErrNotFound. // GetServer reads one MinecraftServer's lifecycle view, or ErrNotFound.
GetServer(ctx context.Context, name string) (*ServerInfo, error) GetServer(ctx context.Context, name string) (*ServerInfo, error)
// GetBySubdomain finds the MinecraftServer whose spec.subdomain matches, or // GetBySubdomain finds the MinecraftServer whose spec.subdomain matches, or
+11 -5
View File
@@ -15,14 +15,20 @@ func (a *API) handleHealthz(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
} }
// handleReadyz is a readiness probe. A full implementation also checks the DB, // handleReadyz is a readiness probe (spec §7). It checks the DB, K8s API and
// the K8s API and the CRD informer (spec §7); here it reports the configured // CRD informer before declaring ready — a full round-trip that mirrors what the
// dependencies are wired. Dependency pinging lands with the integration layer. // actual request path depends on.
func (a *API) handleReadyz(w http.ResponseWriter, r *http.Request) { func (a *API) handleReadyz(w http.ResponseWriter, r *http.Request) {
if a.Repo == nil || a.Cluster == nil { checks := map[string]func(context.Context) error{
writeError(w, r, newError(http.StatusServiceUnavailable, "not_ready", "dependencies not wired")) "db": a.Repo.Ping,
"k8s_api": a.Cluster.Ping,
}
for name, check := range checks {
if err := check(r.Context()); err != nil {
writeError(w, r, newError(http.StatusServiceUnavailable, "not_ready", "%s: %v", name, err))
return return
} }
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"}) writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
} }
+5
View File
@@ -27,6 +27,11 @@ func NewK8sCluster(c client.Client, namespace string) *K8sCluster {
return &K8sCluster{c: c, namespace: namespace} return &K8sCluster{c: c, namespace: namespace}
} }
func (k *K8sCluster) Ping(ctx context.Context) error {
var list v1alpha1.MinecraftServerList
return k.c.List(ctx, &list, client.InNamespace(k.namespace), client.Limit(1))
}
func (k *K8sCluster) GetServer(ctx context.Context, name string) (*ServerInfo, error) { func (k *K8sCluster) GetServer(ctx context.Context, name string) (*ServerInfo, error) {
var ms v1alpha1.MinecraftServer var ms v1alpha1.MinecraftServer
if err := k.c.Get(ctx, types.NamespacedName{Namespace: k.namespace, Name: name}, &ms); err != nil { if err := k.c.Get(ctx, types.NamespacedName{Namespace: k.namespace, Name: name}, &ms); err != nil {
+2
View File
@@ -18,6 +18,8 @@ type PGRepo struct {
// NewPGRepo wraps an existing pool (from store.PostgresDriver.DB()). // NewPGRepo wraps an existing pool (from store.PostgresDriver.DB()).
func NewPGRepo(db *sql.DB) *PGRepo { return &PGRepo{db: db} } func NewPGRepo(db *sql.DB) *PGRepo { return &PGRepo{db: db} }
func (p *PGRepo) Ping(ctx context.Context) error { return p.db.PingContext(ctx) }
func (p *PGRepo) ServerBySubdomain(ctx context.Context, subdomain string) (*ServerRecord, error) { func (p *PGRepo) ServerBySubdomain(ctx context.Context, subdomain string) (*ServerRecord, error) {
const q = `SELECT s.name, sa.subdomain, COALESCE(s.owner_id, ''), COALESCE(s.cached_phase, '') const q = `SELECT s.name, sa.subdomain, COALESCE(s.owner_id, ''), COALESCE(s.cached_phase, '')
FROM server_aliases sa JOIN servers s ON s.name = sa.server_name FROM server_aliases sa JOIN servers s ON s.name = sa.server_name
+4
View File
@@ -145,6 +145,10 @@ type OpLoginRequest struct {
// so handlers are tested against an in-memory fake; the Postgres implementation // so handlers are tested against an in-memory fake; the Postgres implementation
// (pgRepo) is integration-tested only — it requires a live database. // (pgRepo) is integration-tested only — it requires a live database.
type Repo interface { type Repo interface {
// Ping probes the database — used by the /readyz endpoint (spec §7) to verify
// the DB connection is alive.
Ping(ctx context.Context) error
// ServerBySubdomain resolves a subdomain alias to its server, or ErrNotFound. // ServerBySubdomain resolves a subdomain alias to its server, or ErrNotFound.
ServerBySubdomain(ctx context.Context, subdomain string) (*ServerRecord, error) ServerBySubdomain(ctx context.Context, subdomain string) (*ServerRecord, error)
// ServerByName loads a server's business projection, or ErrNotFound. // ServerByName loads a server's business projection, or ErrNotFound.