Skip to content

Commit 94327d9

Browse files
authored
Check container runtime during healthcheck endpoint (#890)
This implements a healthcheck for both docker and k8s, and calls during the healthcheck API endpoint. Also refactor to use mockgen for mocking the runtime.
1 parent 98c016d commit 94327d9

File tree

8 files changed

+342
-84
lines changed

8 files changed

+342
-84
lines changed

pkg/api/server.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ func Serve(
130130
return fmt.Errorf("failed to create client manager: %v", err)
131131
}
132132
routers := map[string]http.Handler{
133-
"/health": v1.HealthcheckRouter(),
133+
"/health": v1.HealthcheckRouter(rt),
134134
"/api/v1beta/version": v1.VersionRouter(),
135135
"/api/v1beta/workloads": v1.WorkloadRouter(manager, rt, debugMode),
136136
"/api/v1beta/registry": v1.RegistryRouter(),

pkg/api/v1/healtcheck_test.go

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,72 @@
11
package v1
22

33
import (
4+
"errors"
45
"net/http"
56
"net/http/httptest"
67
"testing"
78

8-
"github.com/stretchr/testify/require"
9+
"github.com/stretchr/testify/assert"
10+
"go.uber.org/mock/gomock"
11+
12+
"github.com/stacklok/toolhive/pkg/container/runtime/mocks"
913
)
1014

1115
func TestGetHealthcheck(t *testing.T) {
1216
t.Parallel()
13-
resp := httptest.NewRecorder()
14-
getHealthcheck(resp, nil)
15-
require.Equal(t, http.StatusNoContent, resp.Code)
16-
require.Empty(t, resp.Body)
17+
18+
// Create a new gomock controller
19+
ctrl := gomock.NewController(t)
20+
t.Cleanup(func() {
21+
ctrl.Finish()
22+
})
23+
24+
// Create a mock runtime
25+
mockRuntime := mocks.NewMockRuntime(ctrl)
26+
27+
// Create healthcheck routes with the mock runtime
28+
routes := &healthcheckRoutes{containerRuntime: mockRuntime}
29+
30+
t.Run("returns 204 when runtime is running", func(t *testing.T) {
31+
t.Parallel()
32+
33+
// Setup mock to return nil (no error) when IsRunning is called
34+
mockRuntime.EXPECT().
35+
IsRunning(gomock.Any()).
36+
Return(nil)
37+
38+
// Create a test request and response recorder
39+
req := httptest.NewRequest(http.MethodGet, "/health", nil)
40+
resp := httptest.NewRecorder()
41+
42+
// Call the handler
43+
routes.getHealthcheck(resp, req)
44+
45+
// Assert the response
46+
assert.Equal(t, http.StatusNoContent, resp.Code)
47+
assert.Empty(t, resp.Body.String())
48+
})
49+
50+
t.Run("returns 503 when runtime is not running", func(t *testing.T) {
51+
t.Parallel()
52+
53+
// Create an error to return
54+
expectedError := errors.New("container runtime is not available")
55+
56+
// Setup mock to return an error when IsRunning is called
57+
mockRuntime.EXPECT().
58+
IsRunning(gomock.Any()).
59+
Return(expectedError)
60+
61+
// Create a test request and response recorder
62+
req := httptest.NewRequest(http.MethodGet, "/health", nil)
63+
resp := httptest.NewRecorder()
64+
65+
// Call the handler
66+
routes.getHealthcheck(resp, req)
67+
68+
// Assert the response
69+
assert.Equal(t, http.StatusServiceUnavailable, resp.Code)
70+
assert.Equal(t, expectedError.Error()+"\n", resp.Body.String())
71+
})
1772
}

pkg/api/v1/healthcheck.go

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,34 @@ import (
44
"net/http"
55

66
"github.com/go-chi/chi/v5"
7+
8+
rt "github.com/stacklok/toolhive/pkg/container/runtime"
79
)
810

911
// HealthcheckRouter sets up healthcheck route.
10-
func HealthcheckRouter() http.Handler {
12+
func HealthcheckRouter(containerRuntime rt.Runtime) http.Handler {
13+
routes := &healthcheckRoutes{containerRuntime: containerRuntime}
1114
r := chi.NewRouter()
12-
r.Get("/", getHealthcheck)
15+
r.Get("/", routes.getHealthcheck)
1316
return r
1417
}
1518

19+
type healthcheckRoutes struct {
20+
containerRuntime rt.Runtime
21+
}
22+
1623
// getHealthcheck
1724
// @Summary Health check
1825
// @Description Check if the API is healthy
1926
// @Tags system
2027
// @Success 204 {string} string "No Content"
2128
// @Router /health [get]
22-
func getHealthcheck(w http.ResponseWriter, _ *http.Request) {
29+
func (h *healthcheckRoutes) getHealthcheck(w http.ResponseWriter, r *http.Request) {
30+
if err := h.containerRuntime.IsRunning(r.Context()); err != nil {
31+
// If the container runtime is not running, we return a 503 Service Unavailable status.
32+
http.Error(w, err.Error(), http.StatusServiceUnavailable)
33+
return
34+
}
35+
// If the container runtime is running, we consider the API healthy.
2336
w.WriteHeader(http.StatusNoContent)
2437
}

pkg/container/docker/client.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -850,6 +850,18 @@ func (c *Client) AttachToWorkload(ctx context.Context, workloadID string) (io.Wr
850850
return resp.Conn, readCloser, nil
851851
}
852852

853+
// IsRunning checks the health of the container runtime.
854+
// This is used to verify that the runtime is operational and can manage workloads.
855+
func (c *Client) IsRunning(ctx context.Context) error {
856+
// Try to ping the Docker server
857+
_, err := c.client.Ping(ctx)
858+
if err != nil {
859+
return fmt.Errorf("failed to ping Docker server: %v", err)
860+
}
861+
862+
return nil
863+
}
864+
853865
// getPermissionConfigFromProfile converts a permission profile to a container permission config
854866
// with transport-specific settings (internal function)
855867
// addReadOnlyMounts adds read-only mounts to the permission config

pkg/container/kubernetes/client.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -516,6 +516,19 @@ func (*Client) StopWorkload(_ context.Context, _ string) error {
516516
return nil
517517
}
518518

519+
// IsRunning checks the health of the container runtime.
520+
// This is used to verify that the runtime is operational and can manage workloads.
521+
func (c *Client) IsRunning(ctx context.Context) error {
522+
// Use /readyz endpoint to check if the Kubernetes API server is ready.
523+
var status int
524+
result := c.client.Discovery().RESTClient().Get().AbsPath("/readyz").Do(ctx)
525+
if result.StatusCode(&status); status != 200 {
526+
return fmt.Errorf("kubernetes API server is not ready, status code: %d", status)
527+
}
528+
529+
return nil
530+
}
531+
519532
// waitForStatefulSetReady waits for a statefulset to be ready using the watch API
520533
func waitForStatefulSetReady(ctx context.Context, clientset kubernetes.Interface, namespace, name string) error {
521534
// Create a field selector to watch only this specific statefulset

pkg/container/runtime/mocks/mock_runtime.go

Lines changed: 229 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)