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:
6 files changed
+60
-5
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in new issue
Block a user