From 4d608906b8da0c82e27e5a6056ae993a8fcf98c9 Mon Sep 17 00:00:00 2001 From: ian-flores Date: Mon, 9 Feb 2026 14:40:18 -0800 Subject: [PATCH 1/3] feat: add dedicated health check endpoints for flightdeck - Add /healthz endpoint for liveness probe (lightweight, no external calls) - Add /readyz endpoint for readiness probe (checks k8s API connectivity) - Update controller to use new probe paths instead of homepage - Add unit tests for health check endpoints - Update controller tests to verify new probe paths This prevents probe failures due to k8s API issues from being misinterpreted as flightdeck being unhealthy. --- flightdeck/http/server.go | 36 +++++++ flightdeck/http/server_test.go | 94 +++++++++++++++++++ .../controller/core/flightdeck_controller.go | 4 +- internal/controller/core/flightdeck_test.go | 4 +- 4 files changed, 134 insertions(+), 4 deletions(-) create mode 100644 flightdeck/http/server_test.go diff --git a/flightdeck/http/server.go b/flightdeck/http/server.go index 7b93fe5..ef1bb0e 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 { @@ -125,3 +127,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..334d9c5 --- /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()) +} \ No newline at end of file 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) } From 61fd1deee5adf707e784eb09258fe7704587b9e8 Mon Sep 17 00:00:00 2001 From: ian-flores Date: Mon, 9 Feb 2026 15:29:53 -0800 Subject: [PATCH 2/3] fix: address review feedback for health check endpoints - Reduce log verbosity for /healthz and /readyz endpoints to debug level - Add missing trailing newline to server_test.go - Regenerate manifests to sync Helm chart with kustomize CRDs --- .../core/v1beta1/connectruntimeimagespec.go | 62 +++++++++++++++++++ flightdeck/http/server.go | 5 +- flightdeck/http/server_test.go | 2 +- 3 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 client-go/applyconfiguration/core/v1beta1/connectruntimeimagespec.go diff --git a/client-go/applyconfiguration/core/v1beta1/connectruntimeimagespec.go b/client-go/applyconfiguration/core/v1beta1/connectruntimeimagespec.go new file mode 100644 index 0000000..29cf31e --- /dev/null +++ b/client-go/applyconfiguration/core/v1beta1/connectruntimeimagespec.go @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2023-2026 Posit Software, PBC + +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1beta1 + +// ConnectRuntimeImageSpecApplyConfiguration represents a declarative configuration of the ConnectRuntimeImageSpec type for use +// with apply. +type ConnectRuntimeImageSpecApplyConfiguration struct { + RVersion *string `json:"rVersion,omitempty"` + PyVersion *string `json:"pyVersion,omitempty"` + OSVersion *string `json:"osVersion,omitempty"` + QuartoVersion *string `json:"quartoVersion,omitempty"` + Repo *string `json:"repo,omitempty"` +} + +// ConnectRuntimeImageSpecApplyConfiguration constructs a declarative configuration of the ConnectRuntimeImageSpec type for use with +// apply. +func ConnectRuntimeImageSpec() *ConnectRuntimeImageSpecApplyConfiguration { + return &ConnectRuntimeImageSpecApplyConfiguration{} +} + +// WithRVersion sets the RVersion field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the RVersion field is set to the value of the last call. +func (b *ConnectRuntimeImageSpecApplyConfiguration) WithRVersion(value string) *ConnectRuntimeImageSpecApplyConfiguration { + b.RVersion = &value + return b +} + +// WithPyVersion sets the PyVersion field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the PyVersion field is set to the value of the last call. +func (b *ConnectRuntimeImageSpecApplyConfiguration) WithPyVersion(value string) *ConnectRuntimeImageSpecApplyConfiguration { + b.PyVersion = &value + return b +} + +// WithOSVersion sets the OSVersion field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the OSVersion field is set to the value of the last call. +func (b *ConnectRuntimeImageSpecApplyConfiguration) WithOSVersion(value string) *ConnectRuntimeImageSpecApplyConfiguration { + b.OSVersion = &value + return b +} + +// WithQuartoVersion sets the QuartoVersion field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the QuartoVersion field is set to the value of the last call. +func (b *ConnectRuntimeImageSpecApplyConfiguration) WithQuartoVersion(value string) *ConnectRuntimeImageSpecApplyConfiguration { + b.QuartoVersion = &value + return b +} + +// WithRepo sets the Repo field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Repo field is set to the value of the last call. +func (b *ConnectRuntimeImageSpecApplyConfiguration) WithRepo(value string) *ConnectRuntimeImageSpecApplyConfiguration { + b.Repo = &value + return b +} diff --git a/flightdeck/http/server.go b/flightdeck/http/server.go index ef1bb0e..0e1feac 100644 --- a/flightdeck/http/server.go +++ b/flightdeck/http/server.go @@ -67,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, diff --git a/flightdeck/http/server_test.go b/flightdeck/http/server_test.go index 334d9c5..55aca9a 100644 --- a/flightdeck/http/server_test.go +++ b/flightdeck/http/server_test.go @@ -91,4 +91,4 @@ func TestReadyEndpoint_Failure(t *testing.T) { assert.Equal(t, http.StatusServiceUnavailable, w.Code) assert.Equal(t, "Not Ready", w.Body.String()) -} \ No newline at end of file +} From a845a7ba53b82f4aea45cd95df777c50bb265f86 Mon Sep 17 00:00:00 2001 From: ian-flores Date: Mon, 9 Feb 2026 15:38:58 -0800 Subject: [PATCH 3/3] fix: remove unrelated generated file from PR --- .../core/v1beta1/connectruntimeimagespec.go | 62 ------------------- 1 file changed, 62 deletions(-) delete mode 100644 client-go/applyconfiguration/core/v1beta1/connectruntimeimagespec.go diff --git a/client-go/applyconfiguration/core/v1beta1/connectruntimeimagespec.go b/client-go/applyconfiguration/core/v1beta1/connectruntimeimagespec.go deleted file mode 100644 index 29cf31e..0000000 --- a/client-go/applyconfiguration/core/v1beta1/connectruntimeimagespec.go +++ /dev/null @@ -1,62 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2023-2026 Posit Software, PBC - -// Code generated by applyconfiguration-gen. DO NOT EDIT. - -package v1beta1 - -// ConnectRuntimeImageSpecApplyConfiguration represents a declarative configuration of the ConnectRuntimeImageSpec type for use -// with apply. -type ConnectRuntimeImageSpecApplyConfiguration struct { - RVersion *string `json:"rVersion,omitempty"` - PyVersion *string `json:"pyVersion,omitempty"` - OSVersion *string `json:"osVersion,omitempty"` - QuartoVersion *string `json:"quartoVersion,omitempty"` - Repo *string `json:"repo,omitempty"` -} - -// ConnectRuntimeImageSpecApplyConfiguration constructs a declarative configuration of the ConnectRuntimeImageSpec type for use with -// apply. -func ConnectRuntimeImageSpec() *ConnectRuntimeImageSpecApplyConfiguration { - return &ConnectRuntimeImageSpecApplyConfiguration{} -} - -// WithRVersion sets the RVersion field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the RVersion field is set to the value of the last call. -func (b *ConnectRuntimeImageSpecApplyConfiguration) WithRVersion(value string) *ConnectRuntimeImageSpecApplyConfiguration { - b.RVersion = &value - return b -} - -// WithPyVersion sets the PyVersion field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the PyVersion field is set to the value of the last call. -func (b *ConnectRuntimeImageSpecApplyConfiguration) WithPyVersion(value string) *ConnectRuntimeImageSpecApplyConfiguration { - b.PyVersion = &value - return b -} - -// WithOSVersion sets the OSVersion field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the OSVersion field is set to the value of the last call. -func (b *ConnectRuntimeImageSpecApplyConfiguration) WithOSVersion(value string) *ConnectRuntimeImageSpecApplyConfiguration { - b.OSVersion = &value - return b -} - -// WithQuartoVersion sets the QuartoVersion field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the QuartoVersion field is set to the value of the last call. -func (b *ConnectRuntimeImageSpecApplyConfiguration) WithQuartoVersion(value string) *ConnectRuntimeImageSpecApplyConfiguration { - b.QuartoVersion = &value - return b -} - -// WithRepo sets the Repo field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the Repo field is set to the value of the last call. -func (b *ConnectRuntimeImageSpecApplyConfiguration) WithRepo(value string) *ConnectRuntimeImageSpecApplyConfiguration { - b.Repo = &value - return b -}