diff --git a/core/web/health_controller.go b/core/web/health_controller.go index ee08c39fcf1..086dbe6ab4b 100644 --- a/core/web/health_controller.go +++ b/core/web/health_controller.go @@ -24,6 +24,18 @@ const ( HealthStatusFailing = "failing" ) +// PublicReadyz is a minimal readiness endpoint intended for public load balancer health checks. +// Unlike Readyz, it never returns per-check details regardless of query parameters, to avoid +// leaking internal service state on publicly reachable endpoints. +func (hc *HealthController) PublicReadyz(c *gin.Context) { + ready, _ := hc.App.GetHealthChecker().IsReady() + if !ready { + c.Status(http.StatusServiceUnavailable) + return + } + c.Status(http.StatusOK) +} + // NOTE: We only implement the k8s readiness check, *not* the liveness check. Liveness checks are only recommended in cases // where the app doesn't crash itself on panic, and if implemented incorrectly can cause cascading failures. // See the following for more information: diff --git a/core/web/health_controller_test.go b/core/web/health_controller_test.go index 3126bcd2273..edcf07f1ef5 100644 --- a/core/web/health_controller_test.go +++ b/core/web/health_controller_test.go @@ -58,6 +58,57 @@ func TestHealthController_Readyz(t *testing.T) { } } +func TestHealthController_PublicReadyz(t *testing.T) { + t.Parallel() + var tt = []struct { + name string + ready bool + status int + }{ + { + name: "not ready", + ready: false, + status: http.StatusServiceUnavailable, + }, + { + name: "ready", + ready: true, + status: http.StatusOK, + }, + } + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + app := cltest.NewApplicationWithKey(t) + healthChecker := new(mocks.Checker) + healthChecker.On("Start").Return(nil).Once() + healthChecker.On("IsReady").Return(tc.ready, nil) + healthChecker.On("Close").Return(nil).Once() + + app.HealthChecker = healthChecker + require.NoError(t, app.Start(t.Context())) + + client := app.NewHTTPClient(nil) + + // Base path returns status only, no body. + resp, cleanup := client.Get("/public-readyz") + t.Cleanup(cleanup) + assert.Equal(t, tc.status, resp.StatusCode) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Empty(t, body) + + // ?full=true must NOT expose per-check details on this endpoint. + respFull, cleanupFull := client.Get("/public-readyz?full=true") + t.Cleanup(cleanupFull) + assert.Equal(t, tc.status, respFull.StatusCode) + bodyFull, err := io.ReadAll(respFull.Body) + require.NoError(t, err) + assert.Empty(t, bodyFull) + }) + } +} + func TestHealthController_Health_status(t *testing.T) { t.Parallel() var tt = []struct { diff --git a/core/web/router.go b/core/web/router.go index 3cff38ebe34..e08b1b5b913 100644 --- a/core/web/router.go +++ b/core/web/router.go @@ -220,6 +220,7 @@ func sessionRoutes(app chainlink.Application, r *gin.RouterGroup) { func healthRoutes(app chainlink.Application, r *gin.RouterGroup) { hc := HealthController{app} r.GET("/readyz", hc.Readyz) + r.GET("/public-readyz", hc.PublicReadyz) r.GET("/health", hc.Health) r.GET("/health.txt", func(context *gin.Context) { context.Request.Header.Set("Accept", gin.MIMEPlain)