diff --git a/flightdeck/http/server.go b/flightdeck/http/server.go index 7b93fe5..0e1feac 100644 --- a/flightdeck/http/server.go +++ b/flightdeck/http/server.go @@ -34,6 +34,8 @@ func (s *Server) setupRoutes() { Home(s.mux, s.siteClient, s.config) Config(s.mux, s.siteClient, s.config) Help(s.mux, s.config) + Health(s.mux) + Ready(s.mux, s.siteClient, s.config) } func (s *Server) Start() error { @@ -65,8 +67,9 @@ func requestLoggingMiddleware(next http.Handler) http.Handler { duration := time.Since(start) - // Skip logging for static assets at debug level - if len(r.URL.Path) > 7 && r.URL.Path[:8] == "/static/" { + // Skip logging for static assets and health check endpoints at debug level + if (len(r.URL.Path) > 7 && r.URL.Path[:8] == "/static/") || + r.URL.Path == "/healthz" || r.URL.Path == "/readyz" { slog.Debug("request", "method", r.Method, "path", r.URL.Path, @@ -125,3 +128,37 @@ func Config(mux *http.ServeMux, getter internal.SiteInterface, config *internal. func Static(mux *http.ServeMux) { mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("public")))) } + +// Health handles liveness probe at /healthz +// This endpoint only checks that the process is alive and can serve HTTP requests +// It does not check any external dependencies +func Health(mux *http.ServeMux) { + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { + slog.Debug("health check requested") + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) + }) +} + +// Ready handles readiness probe at /readyz +// This endpoint checks that the k8s API is reachable and Site CR can be fetched (if Sites exist) +func Ready(mux *http.ServeMux, getter internal.SiteInterface, config *internal.ServerConfig) { + mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) { + slog.Debug("readiness check requested") + + // Try to fetch the Site CR to verify k8s API connectivity + // Note: If Sites CRD doesn't exist, the Get method returns an empty Site and no error + _, err := getter.Get(config.SiteName, config.Namespace, metav1.GetOptions{}, r.Context()) + if err != nil { + // Only fail readiness if there's a real error (not just missing CRD) + slog.Warn("readiness check failed", "error", err) + w.WriteHeader(http.StatusServiceUnavailable) + w.Write([]byte("Not Ready")) + return + } + + // If we got here, either the Site exists or the CRD doesn't exist (which is OK) + w.WriteHeader(http.StatusOK) + w.Write([]byte("Ready")) + }) +} diff --git a/flightdeck/http/server_test.go b/flightdeck/http/server_test.go new file mode 100644 index 0000000..55aca9a --- /dev/null +++ b/flightdeck/http/server_test.go @@ -0,0 +1,94 @@ +package http + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/posit-dev/team-operator/api/core/v1beta1" + "github.com/posit-dev/team-operator/flightdeck/internal" + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// Mock implementation of SiteInterface +type mockSiteClient struct { + site *v1beta1.Site + err error +} + +func (m *mockSiteClient) Get(name string, namespace string, options metav1.GetOptions, ctx context.Context) (*v1beta1.Site, error) { + if m.err != nil { + return nil, m.err + } + return m.site, nil +} + +func TestHealthEndpoint(t *testing.T) { + // Health endpoint should always return 200 OK + mux := http.NewServeMux() + Health(mux) + + req := httptest.NewRequest("GET", "/healthz", nil) + w := httptest.NewRecorder() + + mux.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "OK", w.Body.String()) +} + +func TestReadyEndpoint_Success(t *testing.T) { + // Ready endpoint should return 200 when Site CR can be fetched + config := &internal.ServerConfig{ + SiteName: "test-site", + Namespace: "test-namespace", + } + + mockClient := &mockSiteClient{ + site: &v1beta1.Site{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-site", + Namespace: "test-namespace", + }, + }, + err: nil, + } + + mux := http.NewServeMux() + Ready(mux, mockClient, config) + + req := httptest.NewRequest("GET", "/readyz", nil) + w := httptest.NewRecorder() + + mux.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "Ready", w.Body.String()) +} + +func TestReadyEndpoint_Failure(t *testing.T) { + // Ready endpoint should return 503 when Site CR cannot be fetched + config := &internal.ServerConfig{ + SiteName: "test-site", + Namespace: "test-namespace", + } + + mockClient := &mockSiteClient{ + site: nil, + err: errors.New("failed to connect to k8s API"), + } + + mux := http.NewServeMux() + Ready(mux, mockClient, config) + + req := httptest.NewRequest("GET", "/readyz", nil) + w := httptest.NewRecorder() + + mux.ServeHTTP(w, req) + + assert.Equal(t, http.StatusServiceUnavailable, w.Code) + assert.Equal(t, "Not Ready", w.Body.String()) +} diff --git a/internal/controller/core/flightdeck_controller.go b/internal/controller/core/flightdeck_controller.go index a1ceee0..9459503 100644 --- a/internal/controller/core/flightdeck_controller.go +++ b/internal/controller/core/flightdeck_controller.go @@ -295,7 +295,7 @@ func (r *FlightdeckReconciler) reconcileFlightdeckResources( LivenessProbe: &corev1.Probe{ ProbeHandler: corev1.ProbeHandler{ HTTPGet: &corev1.HTTPGetAction{ - Path: "/", + Path: "/healthz", Port: intstr.FromString("http"), }, }, @@ -308,7 +308,7 @@ func (r *FlightdeckReconciler) reconcileFlightdeckResources( ReadinessProbe: &corev1.Probe{ ProbeHandler: corev1.ProbeHandler{ HTTPGet: &corev1.HTTPGetAction{ - Path: "/", + Path: "/readyz", Port: intstr.FromString("http"), }, }, diff --git a/internal/controller/core/flightdeck_test.go b/internal/controller/core/flightdeck_test.go index 50b1b27..9dda54d 100644 --- a/internal/controller/core/flightdeck_test.go +++ b/internal/controller/core/flightdeck_test.go @@ -196,12 +196,12 @@ func TestFlightdeckReconciler_DeploymentHasProbes(t *testing.T) { // Verify LivenessProbe exists assert.NotNil(t, container.LivenessProbe) - assert.Equal(t, "/", container.LivenessProbe.HTTPGet.Path) + assert.Equal(t, "/healthz", container.LivenessProbe.HTTPGet.Path) assert.Equal(t, int32(10), container.LivenessProbe.InitialDelaySeconds) // Verify ReadinessProbe exists assert.NotNil(t, container.ReadinessProbe) - assert.Equal(t, "/", container.ReadinessProbe.HTTPGet.Path) + assert.Equal(t, "/readyz", container.ReadinessProbe.HTTPGet.Path) assert.Equal(t, int32(3), container.ReadinessProbe.InitialDelaySeconds) }