Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 39 additions & 2 deletions flightdeck/http/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"))
})
}
94 changes: 94 additions & 0 deletions flightdeck/http/server_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
4 changes: 2 additions & 2 deletions internal/controller/core/flightdeck_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,7 @@ func (r *FlightdeckReconciler) reconcileFlightdeckResources(
LivenessProbe: &corev1.Probe{
ProbeHandler: corev1.ProbeHandler{
HTTPGet: &corev1.HTTPGetAction{
Path: "/",
Path: "/healthz",
Port: intstr.FromString("http"),
},
},
Expand All @@ -308,7 +308,7 @@ func (r *FlightdeckReconciler) reconcileFlightdeckResources(
ReadinessProbe: &corev1.Probe{
ProbeHandler: corev1.ProbeHandler{
HTTPGet: &corev1.HTTPGetAction{
Path: "/",
Path: "/readyz",
Port: intstr.FromString("http"),
},
},
Expand Down
4 changes: 2 additions & 2 deletions internal/controller/core/flightdeck_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down