Give the runtime real readiness, graceful shutdown and stated limits
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 41s

Closes gap G08. /healthz returned a constant without probing anything, the server
called ListenAndServe with no signal handling, and the operational limits of
in-memory state, startup-loaded keys and local-only logout lived in code comments
rather than anywhere an operator would look.

/readyz probes LLDAP, Authelia and privacyIDEA; /healthz stays liveness and
probes nothing. Keeping them distinct matters: wiring liveness to dependency
health means an orchestrator restarts KeyCape when a dependency blinks, and a
restart also discards every in-flight login, so the reaction is worse than the
condition it reacts to.

LLDAP is probed with a bind rather than a dial, since a rotated or revoked
service password leaves the port open and every lookup failing -- exactly what
readiness should catch and exactly what a dial would miss. The response names the
failing check but never the reason: the endpoint is unauthenticated and upstream
error text carries hostnames and sometimes credentials-in-URLs. Results are
cached for 2s so an unauthenticated endpoint cannot be used to drive unbounded
upstream traffic, and probes run concurrently under a 3s bound so a hung
dependency makes the endpoint answer rather than hang with it.

SIGTERM and SIGINT now drain in-flight requests for 15s, under the 30s read/write
timeouts so a stuck request cannot outlive the window before SIGKILL.

docs/operations.md states the single-replica topology and why, and three limits
easy to get wrong: the constant key-1 key ID makes same-kid rotation a trap for
consumers caching JWKS, removing a client does not revoke its issued tokens, and
/logout is local only. No throughput figures are given, since nothing here
benchmarks KeyCape. Shared storage and refresh tokens stay excluded, as G08
allows.

Verified in the running executable: 503 naming all three checks failed while
/healthz returned 200, the LLDAP check flipping to ok once started, and 40/40
requests succeeding across a SIGTERM.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
This commit is contained in:
tegwick 2026-09-08 09:43:49 +02:00
parent a9296fdf84
commit d568b79223
8 changed files with 622 additions and 4 deletions

View file

@ -11,9 +11,12 @@ import (
"encoding/pem"
"flag"
"fmt"
"keycape/internal/server/readiness"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/rs/zerolog"
@ -196,6 +199,20 @@ func main() {
})
// Healthz.
// /readyz gates traffic on the dependencies a login needs; /healthz stays a
// liveness signal (KEY-WP-0025). Restarting KeyCape because LLDAP blinked
// would convert a dependency blip into an outage of our own, so the two must
// not be the same endpoint.
mux.Handle("/readyz", readiness.New(2*time.Second, 3*time.Second,
readiness.Check{Name: "lldap", Probe: func(ctx context.Context) error {
return lldapAdapter.Ping(ctx)
}},
readiness.Check{Name: "authelia", Probe: httpReachable(cfg.Authelia.TokenBaseURL, cfg.Authelia.BaseURL)},
readiness.Check{Name: "privacyidea", Probe: httpReachable(cfg.PrivacyIDEA.BaseURL)},
))
// Liveness only: this says the process is up, and deliberately probes
// nothing. See /readyz.
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
@ -231,9 +248,74 @@ func main() {
IdleTimeout: 120 * time.Second,
}
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Error().Err(err).Msg("server error")
os.Exit(1)
// Serve in the background so shutdown can be driven by a signal. Without
// this the process died on SIGTERM mid-request, so every rolling restart
// returned errors to whoever was logging in at that moment.
serveErr := make(chan error, 1)
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
serveErr <- err
return
}
serveErr <- nil
}()
signals := make(chan os.Signal, 1)
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
select {
case err := <-serveErr:
if err != nil {
log.Error().Err(err).Msg("server error")
os.Exit(1)
}
case sig := <-signals:
log.Info().Str("signal", sig.String()).Msg("shutting down")
// In-flight requests get a bounded window to finish. In-memory login and
// authorization state does not survive this and is not meant to: a
// browser mid-login must start again after a restart. That is a
// documented limit of the single-replica topology, not a bug to paper
// over here -- see docs/operations.md.
ctx, cancel := context.WithTimeout(context.Background(), shutdownGrace)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Error().Err(err).Msg("graceful shutdown failed; exiting anyway")
os.Exit(1)
}
log.Info().Msg("shutdown complete")
}
}
// shutdownGrace bounds how long in-flight requests may finish after a signal.
// Kept under the 30s read/write timeouts so a stuck request cannot outlive the
// window an orchestrator typically allows before sending SIGKILL.
const shutdownGrace = 15 * time.Second
// httpReachable probes the first non-empty URL with a GET, reporting whether the
// dependency answered at all. It is a reachability check, not a functional one:
// any HTTP response means something is listening and speaking HTTP there.
func httpReachable(candidates ...string) func(context.Context) error {
target := ""
for _, candidate := range candidates {
if candidate != "" {
target = candidate
break
}
}
return func(ctx context.Context) error {
if target == "" {
return fmt.Errorf("no endpoint configured")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
return nil
}
}