chore: initial commit of Go API gateway
Baseline for the Kong replacement on api.riotpiao.com. Brings the working tree under version control for the first time: gateway source, the task board that drives the agent runs, test fixtures, and K8s manifests. Anchor the gateway ignore rule to the repo root. Unanchored, "gateway" also matched the cmd/gateway/ source directory, so the program entrypoint was excluded from every commit. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// HealthChecker provides health and readiness check information.
|
||||
type HealthChecker struct {
|
||||
mu sync.RWMutex
|
||||
configValid bool
|
||||
jwksHasFetched bool
|
||||
authEnabled bool
|
||||
}
|
||||
|
||||
// NewHealthChecker creates a new health checker instance.
|
||||
func NewHealthChecker(configValid bool, authEnabled bool) *HealthChecker {
|
||||
return &HealthChecker{
|
||||
configValid: configValid,
|
||||
jwksHasFetched: false,
|
||||
authEnabled: authEnabled,
|
||||
}
|
||||
}
|
||||
|
||||
// MarkJWKSFetched marks that JWKS has been fetched successfully.
|
||||
func (hc *HealthChecker) MarkJWKSFetched() {
|
||||
hc.mu.Lock()
|
||||
defer hc.mu.Unlock()
|
||||
hc.jwksHasFetched = true
|
||||
}
|
||||
|
||||
// IsReady checks if the server is ready to serve traffic.
|
||||
// It returns true if:
|
||||
// - Configuration is valid
|
||||
// - If auth is enabled, JWKS has been fetched at least once
|
||||
func (hc *HealthChecker) IsReady() bool {
|
||||
hc.mu.RLock()
|
||||
defer hc.mu.RUnlock()
|
||||
|
||||
if !hc.configValid {
|
||||
return false
|
||||
}
|
||||
|
||||
// If auth is enabled, we must have fetched JWKS at least once
|
||||
if hc.authEnabled && !hc.jwksHasFetched {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// IsAlive returns true if the process is running.
|
||||
// This is always true since if it weren't, we wouldn't be running this code.
|
||||
func (hc *HealthChecker) IsAlive() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// LivenessHandler returns a handler for the /healthz endpoint.
|
||||
// It returns 200 whenever the process is alive.
|
||||
// It performs no network I/O.
|
||||
func LivenessHandler(hc *HealthChecker) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !hc.IsAlive() {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"status":"alive"}`))
|
||||
}
|
||||
}
|
||||
|
||||
// ReadinessHandler returns a handler for the /readyz endpoint.
|
||||
// It returns 200 only when configuration is valid and, if auth is enabled,
|
||||
// JWKS has been fetched at least once.
|
||||
// It returns non-2xx status while configuration is invalid or JWKS has never
|
||||
// been fetched.
|
||||
func ReadinessHandler(hc *HealthChecker) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !hc.IsReady() {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
w.Write([]byte(`{"status":"not_ready"}`))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"status":"ready"}`))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package server_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Riotpiaole/homelab-frontend/internal/server"
|
||||
)
|
||||
|
||||
// TestHealthEndpoints verifies health endpoint behavior.
|
||||
// - /healthz returns 200 even with unreachable upstreams
|
||||
// - /readyz returns non-2xx before JWKS fetch and 200 after
|
||||
// - Neither endpoint requires authentication
|
||||
func TestHealthEndpoints(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
configValid bool
|
||||
authEnabled bool
|
||||
jwksFetched bool
|
||||
endpoint string
|
||||
expectedCode int
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "healthz_always_200",
|
||||
configValid: true,
|
||||
authEnabled: false,
|
||||
jwksFetched: false,
|
||||
endpoint: "/healthz",
|
||||
expectedCode: http.StatusOK,
|
||||
description: "liveness probe returns 200 even before JWKS fetch",
|
||||
},
|
||||
{
|
||||
name: "healthz_200_when_config_invalid",
|
||||
configValid: false,
|
||||
authEnabled: false,
|
||||
jwksFetched: false,
|
||||
endpoint: "/healthz",
|
||||
expectedCode: http.StatusOK,
|
||||
description: "liveness probe returns 200 even when config is invalid",
|
||||
},
|
||||
{
|
||||
name: "readyz_200_no_auth",
|
||||
configValid: true,
|
||||
authEnabled: false,
|
||||
jwksFetched: false,
|
||||
endpoint: "/readyz",
|
||||
expectedCode: http.StatusOK,
|
||||
description: "readiness returns 200 when config valid and auth disabled",
|
||||
},
|
||||
{
|
||||
name: "readyz_503_invalid_config",
|
||||
configValid: false,
|
||||
authEnabled: false,
|
||||
jwksFetched: false,
|
||||
endpoint: "/readyz",
|
||||
expectedCode: http.StatusServiceUnavailable,
|
||||
description: "readiness returns 503 when config invalid",
|
||||
},
|
||||
{
|
||||
name: "readyz_503_auth_enabled_no_jwks",
|
||||
configValid: true,
|
||||
authEnabled: true,
|
||||
jwksFetched: false,
|
||||
endpoint: "/readyz",
|
||||
expectedCode: http.StatusServiceUnavailable,
|
||||
description: "readiness returns 503 when auth enabled but JWKS not fetched",
|
||||
},
|
||||
{
|
||||
name: "readyz_200_auth_enabled_with_jwks",
|
||||
configValid: true,
|
||||
authEnabled: true,
|
||||
jwksFetched: true,
|
||||
endpoint: "/readyz",
|
||||
expectedCode: http.StatusOK,
|
||||
description: "readiness returns 200 when auth enabled and JWKS fetched",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create health checker
|
||||
hc := server.NewHealthChecker(tt.configValid, tt.authEnabled)
|
||||
if tt.jwksFetched {
|
||||
hc.MarkJWKSFetched()
|
||||
}
|
||||
|
||||
// Create handler based on endpoint
|
||||
var handler http.HandlerFunc
|
||||
switch tt.endpoint {
|
||||
case "/healthz":
|
||||
handler = server.LivenessHandler(hc)
|
||||
case "/readyz":
|
||||
handler = server.ReadinessHandler(hc)
|
||||
default:
|
||||
t.Fatalf("unknown endpoint: %s", tt.endpoint)
|
||||
}
|
||||
|
||||
// Create server wrapper
|
||||
gatewayServer := server.New("127.0.0.1:0", 5*time.Second, handler)
|
||||
|
||||
// Start server in goroutine
|
||||
go func() {
|
||||
if err := gatewayServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
t.Logf("server error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Give server time to start
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Make request
|
||||
url := fmt.Sprintf("http://%s%s", gatewayServer.Addr(), tt.endpoint)
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to make request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check status code
|
||||
if resp.StatusCode != tt.expectedCode {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Errorf("expected status %d, got %d: %s", tt.expectedCode, resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Verify no Authorization header is required
|
||||
// (we already made the request without one, so this is implicit)
|
||||
|
||||
// Cleanup
|
||||
gatewayServer.Shutdown(context.Background())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHealthEndpointsNoProxy verifies that health endpoints are not proxied.
|
||||
// This is verified indirectly by the test above - if they were proxied,
|
||||
// they would return 404 or fail when trying to reach a non-existent upstream.
|
||||
func TestHealthEndpointsCannotBeShadowed(t *testing.T) {
|
||||
// Create health checker and handler
|
||||
hc := server.NewHealthChecker(true, false)
|
||||
handler := server.LivenessHandler(hc)
|
||||
|
||||
// Create server
|
||||
srv := server.New("127.0.0.1:0", 5*time.Second, handler)
|
||||
|
||||
// Start server
|
||||
go func() {
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
t.Logf("server error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Give server time to start
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Request /healthz and verify it's not proxied
|
||||
resp, err := http.Get(fmt.Sprintf("http://%s/healthz", srv.Addr()))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to make request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
srv.Shutdown(context.Background())
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Router implements an HTTP handler that routes health endpoints
|
||||
// and passes other requests to an upstream handler.
|
||||
type Router struct {
|
||||
healthChecker *HealthChecker
|
||||
upstreamHandler http.Handler
|
||||
}
|
||||
|
||||
// NewRouter creates a new router with health endpoints.
|
||||
// Health endpoints (/healthz and /readyz) are handled locally.
|
||||
// All other paths are passed to the upstream handler.
|
||||
func NewRouter(healthChecker *HealthChecker, upstreamHandler http.Handler) *Router {
|
||||
return &Router{
|
||||
healthChecker: healthChecker,
|
||||
upstreamHandler: upstreamHandler,
|
||||
}
|
||||
}
|
||||
|
||||
// ServeHTTP implements http.Handler.
|
||||
// It routes /healthz and /readyz to health handlers,
|
||||
// and passes all other paths to the upstream handler.
|
||||
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
switch req.URL.Path {
|
||||
case "/healthz":
|
||||
LivenessHandler(r.healthChecker)(w, req)
|
||||
case "/readyz":
|
||||
ReadinessHandler(r.healthChecker)(w, req)
|
||||
default:
|
||||
r.upstreamHandler.ServeHTTP(w, req)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Server wraps an HTTP server with graceful shutdown support.
|
||||
type Server struct {
|
||||
httpServer *http.Server
|
||||
shutdownTimeout time.Duration
|
||||
listener net.Listener
|
||||
listenerMu sync.RWMutex
|
||||
healthChecker *HealthChecker
|
||||
}
|
||||
|
||||
// New creates a new Server with the given configuration.
|
||||
func New(listenAddr string, shutdownTimeout time.Duration, handler http.Handler) *Server {
|
||||
return &Server{
|
||||
httpServer: &http.Server{
|
||||
Addr: listenAddr,
|
||||
Handler: handler,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 15 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
},
|
||||
shutdownTimeout: shutdownTimeout,
|
||||
healthChecker: NewHealthChecker(false, false),
|
||||
}
|
||||
}
|
||||
|
||||
// ListenAndServe starts the HTTP server and blocks until it exits.
|
||||
// It returns the error from the server (if any), which will be
|
||||
// http.ErrServerClosed if Shutdown was called.
|
||||
func (s *Server) ListenAndServe() error {
|
||||
listener, err := net.Listen("tcp", s.httpServer.Addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.listenerMu.Lock()
|
||||
s.listener = listener
|
||||
s.listenerMu.Unlock()
|
||||
return s.httpServer.Serve(listener)
|
||||
}
|
||||
|
||||
// Shutdown gracefully shuts down the server. It stops accepting new
|
||||
// connections and waits for in-flight requests to complete, with a
|
||||
// bounded deadline. If the deadline is exceeded, it returns an error.
|
||||
func (s *Server) Shutdown(ctx context.Context) error {
|
||||
// Create a new context with the shutdown timeout
|
||||
shutdownCtx, cancel := context.WithTimeout(ctx, s.shutdownTimeout)
|
||||
defer cancel()
|
||||
|
||||
return s.httpServer.Shutdown(shutdownCtx)
|
||||
}
|
||||
|
||||
// Addr returns the network address the server is listening on.
|
||||
func (s *Server) Addr() string {
|
||||
s.listenerMu.RLock()
|
||||
defer s.listenerMu.RUnlock()
|
||||
if s.listener != nil {
|
||||
return s.listener.Addr().String()
|
||||
}
|
||||
return s.httpServer.Addr
|
||||
}
|
||||
|
||||
// HealthChecker returns the server's health checker.
|
||||
func (s *Server) HealthChecker() *HealthChecker {
|
||||
return s.healthChecker
|
||||
}
|
||||
|
||||
// SetHealthChecker sets the server's health checker.
|
||||
func (s *Server) SetHealthChecker(hc *HealthChecker) {
|
||||
s.healthChecker = hc
|
||||
}
|
||||
|
||||
// SetHandler sets the server's HTTP handler.
|
||||
func (s *Server) SetHandler(handler http.Handler) {
|
||||
s.httpServer.Handler = handler
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package server_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Riotpiaole/homelab-frontend/internal/server"
|
||||
)
|
||||
|
||||
// TestGracefulShutdown verifies that:
|
||||
// - A request in-flight when shutdown starts receives its full, uncorrupted response body
|
||||
// - A request arriving after shutdown starts is refused on a new connection
|
||||
// - The shutdown completes with exit code 0 (no timeout)
|
||||
func TestGracefulShutdown(t *testing.T) {
|
||||
// Create a handler that responds slowly
|
||||
const responseBody = "slow response body content"
|
||||
const sleepDuration = 500 * time.Millisecond
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Simulate a slow LLM response
|
||||
time.Sleep(sleepDuration)
|
||||
fmt.Fprint(w, responseBody)
|
||||
})
|
||||
|
||||
// Create server with a shutdown timeout longer than the sleep
|
||||
srv := server.New("127.0.0.1:0", 5*time.Second, handler)
|
||||
|
||||
// Start server in a goroutine
|
||||
var listenErr error
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
listenErr = srv.ListenAndServe()
|
||||
// http.ErrServerClosed is expected after shutdown
|
||||
if listenErr != nil && listenErr != http.ErrServerClosed {
|
||||
t.Logf("unexpected listen error: %v", listenErr)
|
||||
}
|
||||
}()
|
||||
|
||||
// Give server time to start listening
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Issue a slow request in a goroutine
|
||||
var responseBody_got string
|
||||
var requestErr error
|
||||
var requestWg sync.WaitGroup
|
||||
requestWg.Add(1)
|
||||
go func() {
|
||||
defer requestWg.Done()
|
||||
resp, err := http.Get(fmt.Sprintf("http://%s/", srv.Addr()))
|
||||
if err != nil {
|
||||
requestErr = err
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
requestErr = err
|
||||
return
|
||||
}
|
||||
responseBody_got = string(body)
|
||||
}()
|
||||
|
||||
// Give the request time to reach the handler
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Now initiate shutdown while request is in-flight
|
||||
shutdownErr := srv.Shutdown(context.Background())
|
||||
|
||||
// Wait for the in-flight request to complete
|
||||
requestWg.Wait()
|
||||
|
||||
// Verify the in-flight request completed successfully
|
||||
if requestErr != nil {
|
||||
t.Fatalf("in-flight request failed: %v", requestErr)
|
||||
}
|
||||
if responseBody_got != responseBody {
|
||||
t.Fatalf("in-flight request got wrong body: %q (expected %q)", responseBody_got, responseBody)
|
||||
}
|
||||
|
||||
// Verify shutdown succeeded (no timeout)
|
||||
if shutdownErr != nil {
|
||||
t.Fatalf("shutdown failed: %v", shutdownErr)
|
||||
}
|
||||
|
||||
// Verify in-flight requests were allowed to complete
|
||||
wg.Wait()
|
||||
|
||||
// Now verify that a new request is refused after shutdown
|
||||
_, err := net.Dial("tcp", srv.Addr())
|
||||
if err == nil {
|
||||
// Connection succeeded when it should have failed
|
||||
t.Fatalf("new connection accepted after shutdown (should have been refused)")
|
||||
}
|
||||
// If we get here, the connection was properly refused, which is what we want
|
||||
}
|
||||
Reference in New Issue
Block a user