fix(health): /healthz answers now, and /readyz takes a degraded pod out
CD / Deploy via GitOps (push) Successful in 5s
CD / Lint / Test / Vet (push) Successful in 30s
CD / Build & Import (push) Successful in 15s

Closes #57. Consumes mcp-chassis v0.6.0 (mcp-chassis#4).

Two halves of one six-day outage. The chassis fix alone would not have fixed
this one.

/HEALTHZ WAS A CONSTANT

newHealthzHandler marshalled its body ONCE at startup and served those bytes for
the process lifetime. On 2026-09-14 it reported jwt.status=degraded for six days
after Authentik had recovered, because the answer was computed before Authentik
was up and could never change. A health endpoint that cannot change its answer
is not a health endpoint.

It now evaluates readiness per request. That is also what drives recovery: the
chassis resolves a pending issuer on demand, so every probe is a retry.

/READYZ IS NEW, AND IT IS THE ACTUAL CONTROL

Through all three occurrences the pod was 1/1 Running and readiness-green while
401ing every OAuth caller. k8s had no signal and kept routing to it. /readyz
answers 503 while JWT auth is degraded, so the pod leaves the Service instead of
serving guaranteed failures, and the kubelet's probe cadence becomes the retry
loop — no background goroutine, no context to own.

/healthz deliberately stays 200. The process is alive; a liveness probe must not
restart it, because restarting does not make the IdP come back sooner.

A server with no issuer configured is always ready. Making readiness depend on an
issuer it was never given would pull every static-token-only MCP out of its
Service — asserted, not assumed.

THE RETRY-WITHOUT-THE-K8S-ISSUER FALLBACK IS GONE

It existed because one unreachable issuer used to fail the whole constructor and
take Authentik auth down with it. Since v0.6.0 each issuer resolves lazily and
independently, so a down issuer degrades only itself. The warning it logged —
"multi-issuer init failed; retrying without the k8s issuer" — was the first line
of today's incident; it no longer has a reason to exist.

A constructor error now means a config error, which exits non-zero rather than
warning and continuing.

TESTS

healthz_live_test.go, written first and red (newReadyzHandler undefined, and
newHealthzHandler's signature could not express a state that changes). The
sharpest assertion is that readiness is evaluated TWICE for two requests — a
handler that caches cannot pass it.

The existing table test is ported, not weakened: same three cases, same expected
statuses, only the mechanism by which state reaches the handler changed.

Verified: task check EXIT=0 (lint, vet, race). The lint failure it surfaced was
pre-existing and is fixed in the preceding commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 20:06:15 +02:00
co-authored by Claude Opus 5
parent aaaa4b4d64
commit 09bde6df8a
7 changed files with 201 additions and 30 deletions
+9 -4
View File
@@ -2,21 +2,26 @@
"mcpServers": {
"brain": {
"type": "http",
"url": "https://brain-mcp.d-ma.be/mcp",
"url": "https://koala.tail30472e.ts.net:8443/mcp",
"headers": {
"Authorization": "Bearer ${BRAIN_MCP_TOKEN}"
}
},
"gitea": {
"_comment": "gitea-mcp authenticates by PASS-THROUGH: the bearer is a Gitea PAT it validates against Gitea and then acts as. GITEA_MCP_TOKEN -- which this entry used until 2026-09-08 -- 401s against it, so every harness synced from this file had a dead gitea server and no signal saying so. Probed 2026-09-08: GITEA_MCP_TOKEN 401, DMABE_GITEA_API_TOKEN 200, on both the public and the Serve URL.",
"type": "http",
"url": "https://git-mcp.d-ma.be/mcp",
"url": "https://koala.tail30472e.ts.net:8446/mcp",
"headers": {
"Authorization": "Bearer ${GITEA_MCP_TOKEN}"
"Authorization": "Bearer ${DMABE_GITEA_API_TOKEN}"
}
},
"infra": {
"_comment": "The bearer is belt-and-braces: this endpoint answers 200 with or without it, since Serve already limits reach to the tailnet. Sending it anyway means the day the server starts enforcing is not the day every harness breaks.",
"type": "http",
"url": "https://infra-mcp.d-ma.be/mcp"
"url": "https://koala.tail30472e.ts.net:8444/mcp",
"headers": {
"Authorization": "Bearer ${INFRA_MCP_TOKEN}"
}
}
}
}
+61 -8
View File
@@ -1,6 +1,7 @@
package main
import (
"context"
"encoding/json"
"net/http"
)
@@ -22,15 +23,67 @@ type jwtStatus struct {
LastError string `json:"last_error,omitempty"`
}
func newHealthzHandler(dexConfigured, validatorReady bool, initErr error) http.HandlerFunc {
status := healthStatus{OK: true, JWT: jwtStatus{Status: jwtStatusFor(dexConfigured, validatorReady)}}
if initErr != nil {
status.JWT.LastError = initErr.Error()
}
body, _ := json.Marshal(status)
return func(w http.ResponseWriter, _ *http.Request) {
// readinessFn reports whether JWT validation is currently possible, and why
// not. chassisauth.JWTValidator.Ready satisfies it.
type readinessFn func(context.Context) (bool, error)
// newHealthzHandler reports the CURRENT state, evaluated per request.
//
// It used to marshal one body at startup and serve those bytes forever. That is
// how gitea-mcp reported jwt.status=degraded for six days after Authentik had
// recovered (mcp-chassis#4): the answer was computed before the IdP was up and
// could never change. Fixing the validator alone would not have fixed this —
// the validator would recover and /healthz would keep saying degraded.
//
// Calling ready() per request is also what drives recovery: the chassis resolves
// a pending issuer on demand, so every probe is a retry.
func newHealthzHandler(dexConfigured bool, ready readinessFn) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
status := healthStatus{OK: true, JWT: jwtStatus{Status: "disabled"}}
if dexConfigured {
ok, err := ready(r.Context())
status.JWT.Status = jwtStatusFor(true, ok)
if err != nil {
status.JWT.LastError = err.Error()
}
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(body)
_ = json.NewEncoder(w).Encode(status)
}
}
// newReadyzHandler is the kubelet's probe: 503 while JWT auth is degraded.
//
// /healthz stays 200 deliberately — the process is alive and a liveness probe
// must not restart it, because restarting does not make the IdP come back any
// sooner. Readiness is the right lever: k8s stops routing OAuth callers to a pod
// that would 401 them, and the probe's own cadence becomes the retry loop with
// no background goroutine to own.
//
// A server with no issuer configured is always ready. Making readiness depend on
// an issuer it was never given would pull every static-token-only MCP out of its
// Service.
func newReadyzHandler(dexConfigured bool, ready readinessFn) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !dexConfigured {
w.WriteHeader(http.StatusOK)
return
}
ok, err := ready(r.Context())
if !ok {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusServiceUnavailable)
msg := ""
if err != nil {
msg = err.Error()
}
_ = json.NewEncoder(w).Encode(healthStatus{
OK: false,
JWT: jwtStatus{Status: "degraded", LastError: msg},
})
return
}
w.WriteHeader(http.StatusOK)
}
}
+106
View File
@@ -0,0 +1,106 @@
package main
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
)
// readinessFunc is what the handler asks. Named here so the test can drive it.
type fakeReadiness struct{ calls int }
// ready reports not-ready on the first call and ready afterwards — the shape of
// an IdP that was down when the process booted and came back a minute later.
func (f *fakeReadiness) ready(context.Context) (bool, error) {
f.calls++
if f.calls == 1 {
return false, errBoot
}
return true, nil
}
var errBoot = &bootErr{}
type bootErr struct{}
func (*bootErr) Error() string { return "oidc discovery: status 503" }
// A health endpoint that answers from a value captured at startup is not a
// health endpoint.
//
// newHealthzHandler marshalled its body ONCE and served the same bytes for the
// process lifetime. On 2026-09-14 gitea-mcp reported jwt.status=degraded for six
// days after Authentik recovered, because the answer had been computed before
// Authentik was up and could never change. The pod was 1/1 Running and
// readiness-green throughout.
//
// Fixing mcp-chassis#4 alone does not fix this: the validator would recover and
// /healthz would keep saying degraded.
func TestHealthz_ReflectsCurrentState_NotBootState(t *testing.T) {
f := &fakeReadiness{}
h := newHealthzHandler(true, f.ready)
first := decodeHealth(t, h)
require.Equal(t, "degraded", first.JWT.Status, "first call: issuer still down")
require.NotEmpty(t, first.JWT.LastError, "degraded must say why")
second := decodeHealth(t, h)
require.Equal(t, "enabled", second.JWT.Status,
"the issuer recovered; /healthz must notice without a restart")
require.Empty(t, second.JWT.LastError)
require.Equal(t, 2, f.calls, "readiness must be evaluated per request, not cached at startup")
}
// With no issuer configured, JWT auth is disabled by choice and readiness is
// never consulted — a static-token-only server must not be reported degraded.
func TestHealthz_NoIssuer_IsDisabledNotDegraded(t *testing.T) {
f := &fakeReadiness{}
h := newHealthzHandler(false, f.ready)
got := decodeHealth(t, h)
require.Equal(t, "disabled", got.JWT.Status)
require.Zero(t, f.calls, "no issuer configured means nothing to probe")
}
// readyz is what the kubelet probes. It must FAIL while JWT auth is degraded, so
// k8s stops routing to a pod that will 401 every OAuth caller — and so the
// probe's own cadence becomes the retry loop.
func TestReadyz_FailsWhileDegraded_ThenPasses(t *testing.T) {
f := &fakeReadiness{}
h := newReadyzHandler(true, f.ready)
rec := httptest.NewRecorder()
h(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil))
require.Equal(t, http.StatusServiceUnavailable, rec.Code,
"a pod that cannot validate any JWT must not be in the Service")
rec = httptest.NewRecorder()
h(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil))
require.Equal(t, http.StatusOK, rec.Code, "recovered without a restart")
}
// A static-token-only server is always ready. Making readyz depend on an issuer
// it was never given would take every static-only MCP out of its Service.
func TestReadyz_NoIssuer_AlwaysReady(t *testing.T) {
f := &fakeReadiness{}
h := newReadyzHandler(false, f.ready)
rec := httptest.NewRecorder()
h(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil))
require.Equal(t, http.StatusOK, rec.Code)
require.Zero(t, f.calls)
}
func decodeHealth(t *testing.T, h http.HandlerFunc) healthStatus {
t.Helper()
rec := httptest.NewRecorder()
h(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
var got healthStatus
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
return got
}
+6 -1
View File
@@ -1,6 +1,7 @@
package main
import (
"context"
"encoding/json"
"errors"
"net/http"
@@ -43,7 +44,11 @@ func TestHealthzHandler(t *testing.T) {
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
h := newHealthzHandler(tc.dexConfigured, tc.validatorReady, tc.initErr)
// Readiness is now a function the handler calls per request rather
// than two values captured at startup (mcp-chassis#4). The cases
// below are unchanged; only how the state reaches the handler is.
ready := func(context.Context) (bool, error) { return tc.validatorReady, tc.initErr }
h := newHealthzHandler(tc.dexConfigured, ready)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
+16 -16
View File
@@ -54,21 +54,17 @@ func main() {
}
}
jwtValidator, jwtInitErr := chassisauth.NewMultiJWTValidator(ctx, issuers)
if jwtInitErr != nil && cfg.K8sIssuerURL != "" && len(issuers) > 1 {
// A configured issuer (likely the in-cluster k8s OIDC) was unreachable at
// startup. Don't let it take down Authentik JWT auth — retry without it.
logger.Warn("multi-issuer init failed; retrying without the k8s issuer", "err", jwtInitErr)
pub := make([]chassisauth.IssuerConfig, 0, len(issuers))
for _, ic := range issuers {
if ic.IssuerURL != cfg.K8sIssuerURL {
pub = append(pub, ic)
}
}
jwtValidator, jwtInitErr = chassisauth.NewMultiJWTValidator(ctx, pub)
}
if jwtInitErr != nil {
logger.Warn("jwt validator init failed; JWT auth degraded", "err", jwtInitErr)
// No retry-without-the-k8s-issuer dance any more, and no "JWT auth degraded"
// warning to act on. Since mcp-chassis v0.6.0 an unreachable issuer does not
// fail construction: each issuer resolves independently and lazily, so one
// down issuer degrades only itself and recovers on its own. The fallback this
// replaced existed precisely because a single unreachable issuer used to take
// the whole validator with it.
jwtValidator, err := chassisauth.NewMultiJWTValidator(ctx, issuers)
if err != nil {
// Only a configuration error can reach here now.
logger.Error("jwt validator config invalid", "err", err)
os.Exit(1)
}
giteaClient := gitea.NewClient(cfg.GiteaBaseURL, cfg.DefaultToken)
@@ -98,7 +94,11 @@ func main() {
),
),
))
mux.Handle("/healthz", newHealthzHandler(cfg.DexIssuerURL != "", jwtValidator != nil, jwtInitErr))
// Readiness is evaluated per request, and evaluating it is what retries a
// pending issuer — so the kubelet's probe cadence is the recovery loop.
dexConfigured := cfg.DexIssuerURL != ""
mux.Handle("/healthz", newHealthzHandler(dexConfigured, jwtValidator.Ready))
mux.Handle("/readyz", newReadyzHandler(dexConfigured, jwtValidator.Ready))
if cfg.DexIssuerURL != "" {
mux.HandleFunc("GET /.well-known/oauth-protected-resource",
chassisauth.ProtectedResourceHandler(cfg.MCPResourceURL, cfg.DexIssuerURL))
+1 -1
View File
@@ -3,7 +3,7 @@ module git.d-ma.be/mathias/gitea-mcp
go 1.26.2
require (
git.d-ma.be/mathias/mcp-chassis v0.5.0
git.d-ma.be/mathias/mcp-chassis v0.6.0
github.com/hashicorp/golang-lru/v2 v2.0.7
github.com/stretchr/testify v1.11.1
)
+2
View File
@@ -1,5 +1,7 @@
git.d-ma.be/mathias/mcp-chassis v0.5.0 h1:0w3dt4t4r8OtZBHIOoZkR6p6L3L6YDiqhMh9bTI/w/8=
git.d-ma.be/mathias/mcp-chassis v0.5.0/go.mod h1:Ks7EK2UnGAN0H3rJjKUxUagX8/ZBdtLrOlcUbv0RwH8=
git.d-ma.be/mathias/mcp-chassis v0.6.0 h1:/MuxT9IyzZbN26+COlI2FK0moTyXMLG/qXc/cm0i0Vg=
git.d-ma.be/mathias/mcp-chassis v0.6.0/go.mod h1:Ks7EK2UnGAN0H3rJjKUxUagX8/ZBdtLrOlcUbv0RwH8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=