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
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:
parent
a9296fdf84
commit
d568b79223
8 changed files with 622 additions and 4 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue