detached 1280049

feat(cli): improve routes list with health probing

- Fix container status to use Docker State (running/stopped) instead of
  human-readable format (Up 2 days)
- Add HealthService with HTTP probing for route health checks
- Add /admin/health endpoint returning container and HTTP status
- Update routes list CLI to show Container and HTTP columns separately
- Display HTTP status code with response time (e.g., 200 (45ms))
commit
1280049c7b8469a3f619099624f32d32b6729a3c
author
bnema · · 2026-01-16 21:42 UTC
parent
04e168a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
14 files · +836 -33
M .mockery.yaml +2 -0
@@ -27,3 +27,5 @@ packages:
2727 ProxyService:
2828 ConfigService:
2929 AuthService:
30+ HealthService:
31+ HTTPProber:
@@ -27,3 +27,5 @@ packages:
27 ProxyService:27 ProxyService:
28 ConfigService:28 ConfigService:
29 AuthService:29 AuthService:
30+ HealthService:
31+ HTTPProber:
M internal/adapters/in/cli/remote/client.go +26 -0
@@ -243,6 +243,32 @@ func (c *Client) GetStatus(ctx context.Context) (*Status, error) {
243243 return &status, nil
244244 }
245245
246+// RouteHealth represents the health status of a route.
247+type RouteHealth struct {
248+ ContainerStatus string `json:"container_status"`
249+ HTTPStatus int `json:"http_status"`
250+ ResponseTimeMs int64 `json:"response_time_ms"`
251+ Healthy bool `json:"healthy"`
252+ Error string `json:"error"`
253+}
254+
255+// GetHealth returns health status for all routes with HTTP probing.
256+func (c *Client) GetHealth(ctx context.Context) (map[string]*RouteHealth, error) {
257+ resp, err := c.request(ctx, http.MethodGet, "/health", nil)
258+ if err != nil {
259+ return nil, err
260+ }
261+
262+ var result struct {
263+ Health map[string]*RouteHealth `json:"health"`
264+ }
265+ if err := parseResponse(resp, &result); err != nil {
266+ return nil, err
267+ }
268+
269+ return result.Health, nil
270+}
271+
246272 // Reload triggers a configuration reload.
247273 func (c *Client) Reload(ctx context.Context) error {
248274 resp, err := c.request(ctx, http.MethodPost, "/reload", nil)
@@ -243,6 +243,32 @@ func (c *Client) GetStatus(ctx context.Context) (*Status, error) {
243 return &status, nil243 return &status, nil
244 }244 }
245 245
246+// RouteHealth represents the health status of a route.
247+type RouteHealth struct {
248+ ContainerStatus string `json:"container_status"`
249+ HTTPStatus int `json:"http_status"`
250+ ResponseTimeMs int64 `json:"response_time_ms"`
251+ Healthy bool `json:"healthy"`
252+ Error string `json:"error"`
253+}
254+
255+// GetHealth returns health status for all routes with HTTP probing.
256+func (c *Client) GetHealth(ctx context.Context) (map[string]*RouteHealth, error) {
257+ resp, err := c.request(ctx, http.MethodGet, "/health", nil)
258+ if err != nil {
259+ return nil, err
260+ }
261+
262+ var result struct {
263+ Health map[string]*RouteHealth `json:"health"`
264+ }
265+ if err := parseResponse(resp, &result); err != nil {
266+ return nil, err
267+ }
268+
269+ return result.Health, nil
270+}
271+
246 // Reload triggers a configuration reload.272 // Reload triggers a configuration reload.
247 func (c *Client) Reload(ctx context.Context) error {273 func (c *Client) Reload(ctx context.Context) error {
248 resp, err := c.request(ctx, http.MethodPost, "/reload", nil)274 resp, err := c.request(ctx, http.MethodPost, "/reload", nil)
M internal/adapters/in/cli/routes.go +40 -11
@@ -6,6 +6,7 @@ import (
66 "os"
77 "strings"
88
9+ "gordon/internal/adapters/in/cli/remote"
910 "gordon/internal/adapters/in/cli/ui/components"
1011 "gordon/internal/adapters/in/cli/ui/styles"
1112 "gordon/internal/domain"
@@ -62,6 +63,27 @@ the local Gordon configuration.`,
6263 return cmd
6364 }
6465
66+// formatHTTPStatus formats the HTTP status for display.
67+func formatHTTPStatus(health *remote.RouteHealth) string {
68+ if health == nil {
69+ return styles.Theme.Muted.Render("-")
70+ }
71+ if health.HTTPStatus == 0 {
72+ if health.Error != "" {
73+ return styles.Theme.BadgeError.Render("err")
74+ }
75+ return styles.Theme.Muted.Render("-")
76+ }
77+ status := fmt.Sprintf("%d", health.HTTPStatus)
78+ if health.ResponseTimeMs > 0 {
79+ status = fmt.Sprintf("%d (%dms)", health.HTTPStatus, health.ResponseTimeMs)
80+ }
81+ if health.HTTPStatus >= 200 && health.HTTPStatus < 400 {
82+ return styles.Theme.BadgeSuccess.Render(status)
83+ }
84+ return styles.Theme.BadgeError.Render(status)
85+}
86+
6587 // newRoutesListCmd creates the routes list command.
6688 func newRoutesListCmd() *cobra.Command {
6789 return &cobra.Command{
@@ -87,24 +109,30 @@ func newRoutesListCmd() *cobra.Command {
87109 return nil
88110 }
89111
90- // Get status for each route
91- status, _ := client.GetStatus(ctx)
92- containerStatus := make(map[string]string)
93- if status != nil {
94- containerStatus = status.ContainerStatus
112+ // Get health status for each route (includes container status and HTTP probe)
113+ health, _ := client.GetHealth(ctx)
114+ if health == nil {
115+ health = make(map[string]*remote.RouteHealth)
95116 }
96117
97118 // Build table rows
98119 const imageColWidth = 35
99120 rows := make([][]string, len(routes))
100121 for i, route := range routes {
101- routeStatus := containerStatus[route.Domain]
102- if routeStatus == "" {
103- routeStatus = "unknown"
122+ routeHealth := health[route.Domain]
123+
124+ // Container status column
125+ containerStatus := "unknown"
126+ if routeHealth != nil {
127+ containerStatus = routeHealth.ContainerStatus
104128 }
105- statusBadge := components.ContainerStatusBadge(routeStatus)
129+ containerBadge := components.ContainerStatusBadge(containerStatus)
130+
131+ // HTTP status column
132+ httpStatus := formatHTTPStatus(routeHealth)
133+
106134 displayImage := truncateImage(route.Image, imageColWidth)
107- rows[i] = []string{route.Domain, displayImage, statusBadge}
135+ rows[i] = []string{route.Domain, displayImage, containerBadge, httpStatus}
108136 }
109137
110138 // Render table
@@ -112,7 +140,8 @@ func newRoutesListCmd() *cobra.Command {
112140 components.WithColumns([]components.TableColumn{
113141 {Title: "Domain", Width: 25},
114142 {Title: "Image", Width: imageColWidth},
115- {Title: "Status", Width: 12},
143+ {Title: "Container", Width: 12},
144+ {Title: "HTTP", Width: 14},
116145 }),
117146 components.WithRows(rows),
118147 )
@@ -6,6 +6,7 @@ import (
6 "os"6 "os"
7 "strings"7 "strings"
8 8
9+ "gordon/internal/adapters/in/cli/remote"
9 "gordon/internal/adapters/in/cli/ui/components"10 "gordon/internal/adapters/in/cli/ui/components"
10 "gordon/internal/adapters/in/cli/ui/styles"11 "gordon/internal/adapters/in/cli/ui/styles"
11 "gordon/internal/domain"12 "gordon/internal/domain"
@@ -62,6 +63,27 @@ the local Gordon configuration.`,
62 return cmd63 return cmd
63 }64 }
64 65
66+// formatHTTPStatus formats the HTTP status for display.
67+func formatHTTPStatus(health *remote.RouteHealth) string {
68+ if health == nil {
69+ return styles.Theme.Muted.Render("-")
70+ }
71+ if health.HTTPStatus == 0 {
72+ if health.Error != "" {
73+ return styles.Theme.BadgeError.Render("err")
74+ }
75+ return styles.Theme.Muted.Render("-")
76+ }
77+ status := fmt.Sprintf("%d", health.HTTPStatus)
78+ if health.ResponseTimeMs > 0 {
79+ status = fmt.Sprintf("%d (%dms)", health.HTTPStatus, health.ResponseTimeMs)
80+ }
81+ if health.HTTPStatus >= 200 && health.HTTPStatus < 400 {
82+ return styles.Theme.BadgeSuccess.Render(status)
83+ }
84+ return styles.Theme.BadgeError.Render(status)
85+}
86+
65 // newRoutesListCmd creates the routes list command.87 // newRoutesListCmd creates the routes list command.
66 func newRoutesListCmd() *cobra.Command {88 func newRoutesListCmd() *cobra.Command {
67 return &cobra.Command{89 return &cobra.Command{
@@ -87,24 +109,30 @@ func newRoutesListCmd() *cobra.Command {
87 return nil109 return nil
88 }110 }
89 111
90- // Get status for each route112+ // Get health status for each route (includes container status and HTTP probe)
91- status, _ := client.GetStatus(ctx)113+ health, _ := client.GetHealth(ctx)
92- containerStatus := make(map[string]string)114+ if health == nil {
93- if status != nil {115+ health = make(map[string]*remote.RouteHealth)
94- containerStatus = status.ContainerStatus
95 }116 }
96 117
97 // Build table rows118 // Build table rows
98 const imageColWidth = 35119 const imageColWidth = 35
99 rows := make([][]string, len(routes))120 rows := make([][]string, len(routes))
100 for i, route := range routes {121 for i, route := range routes {
101- routeStatus := containerStatus[route.Domain]122+ routeHealth := health[route.Domain]
102- if routeStatus == "" {123+
103- routeStatus = "unknown"124+ // Container status column
125+ containerStatus := "unknown"
126+ if routeHealth != nil {
127+ containerStatus = routeHealth.ContainerStatus
104 }128 }
105- statusBadge := components.ContainerStatusBadge(routeStatus)129+ containerBadge := components.ContainerStatusBadge(containerStatus)
130+
131+ // HTTP status column
132+ httpStatus := formatHTTPStatus(routeHealth)
133+
106 displayImage := truncateImage(route.Image, imageColWidth)134 displayImage := truncateImage(route.Image, imageColWidth)
107- rows[i] = []string{route.Domain, displayImage, statusBadge}135+ rows[i] = []string{route.Domain, displayImage, containerBadge, httpStatus}
108 }136 }
109 137
110 // Render table138 // Render table
@@ -112,7 +140,8 @@ func newRoutesListCmd() *cobra.Command {
112 components.WithColumns([]components.TableColumn{140 components.WithColumns([]components.TableColumn{
113 {Title: "Domain", Width: 25},141 {Title: "Domain", Width: 25},
114 {Title: "Image", Width: imageColWidth},142 {Title: "Image", Width: imageColWidth},
115- {Title: "Status", Width: 12},143+ {Title: "Container", Width: 12},
144+ {Title: "HTTP", Width: 14},
116 }),145 }),
117 components.WithRows(rows),146 components.WithRows(rows),
118 )147 )
M internal/adapters/in/http/admin/handler.go +38 -0
@@ -18,6 +18,7 @@ type Handler struct {
1818 configSvc in.ConfigService
1919 authSvc in.AuthService
2020 containerSvc in.ContainerService
21+ healthSvc in.HealthService
2122 eventBus out.EventPublisher
2223 envDir string
2324 log zerowrap.Logger
@@ -28,6 +29,7 @@ func NewHandler(
2829 configSvc in.ConfigService,
2930 authSvc in.AuthService,
3031 containerSvc in.ContainerService,
32+ healthSvc in.HealthService,
3133 eventBus out.EventPublisher,
3234 envDir string,
3335 log zerowrap.Logger,
@@ -36,6 +38,7 @@ func NewHandler(
3638 configSvc: configSvc,
3739 authSvc: authSvc,
3840 containerSvc: containerSvc,
41+ healthSvc: healthSvc,
3942 eventBus: eventBus,
4043 envDir: envDir,
4144 log: log,
@@ -72,6 +75,8 @@ func (h *Handler) handleAdminRoutes(w http.ResponseWriter, r *http.Request) {
7275 h.handleSecrets(w, r, path)
7376 case path == "/status":
7477 h.handleStatus(w, r)
78+ case path == "/health":
79+ h.handleHealth(w, r)
7580 case path == "/reload":
7681 h.handleReload(w, r)
7782 case path == "/config":
@@ -315,6 +320,39 @@ func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path str
315320 }
316321 }
317322
323+// handleHealth handles /admin/health endpoint.
324+// Returns detailed health status for all routes including HTTP probe results.
325+func (h *Handler) handleHealth(w http.ResponseWriter, r *http.Request) {
326+ if r.Method != http.MethodGet {
327+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
328+ return
329+ }
330+
331+ ctx := r.Context()
332+
333+ // Check read permission
334+ if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
335+ h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
336+ return
337+ }
338+
339+ health := h.healthSvc.CheckAllRoutes(ctx)
340+
341+ // Convert to JSON-friendly format
342+ result := make(map[string]any, len(health))
343+ for domain, h := range health {
344+ result[domain] = map[string]any{
345+ "container_status": h.ContainerStatus,
346+ "http_status": h.HTTPStatus,
347+ "response_time_ms": h.ResponseTimeMs,
348+ "healthy": h.Healthy,
349+ "error": h.Error,
350+ }
351+ }
352+
353+ h.sendJSON(w, http.StatusOK, map[string]any{"health": result})
354+}
355+
318356 // handleStatus handles /admin/status endpoint.
319357 func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
320358 if r.Method != http.MethodGet {
@@ -18,6 +18,7 @@ type Handler struct {
18 configSvc in.ConfigService18 configSvc in.ConfigService
19 authSvc in.AuthService19 authSvc in.AuthService
20 containerSvc in.ContainerService20 containerSvc in.ContainerService
21+ healthSvc in.HealthService
21 eventBus out.EventPublisher22 eventBus out.EventPublisher
22 envDir string23 envDir string
23 log zerowrap.Logger24 log zerowrap.Logger
@@ -28,6 +29,7 @@ func NewHandler(
28 configSvc in.ConfigService,29 configSvc in.ConfigService,
29 authSvc in.AuthService,30 authSvc in.AuthService,
30 containerSvc in.ContainerService,31 containerSvc in.ContainerService,
32+ healthSvc in.HealthService,
31 eventBus out.EventPublisher,33 eventBus out.EventPublisher,
32 envDir string,34 envDir string,
33 log zerowrap.Logger,35 log zerowrap.Logger,
@@ -36,6 +38,7 @@ func NewHandler(
36 configSvc: configSvc,38 configSvc: configSvc,
37 authSvc: authSvc,39 authSvc: authSvc,
38 containerSvc: containerSvc,40 containerSvc: containerSvc,
41+ healthSvc: healthSvc,
39 eventBus: eventBus,42 eventBus: eventBus,
40 envDir: envDir,43 envDir: envDir,
41 log: log,44 log: log,
@@ -72,6 +75,8 @@ func (h *Handler) handleAdminRoutes(w http.ResponseWriter, r *http.Request) {
72 h.handleSecrets(w, r, path)75 h.handleSecrets(w, r, path)
73 case path == "/status":76 case path == "/status":
74 h.handleStatus(w, r)77 h.handleStatus(w, r)
78+ case path == "/health":
79+ h.handleHealth(w, r)
75 case path == "/reload":80 case path == "/reload":
76 h.handleReload(w, r)81 h.handleReload(w, r)
77 case path == "/config":82 case path == "/config":
@@ -315,6 +320,39 @@ func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path str
315 }320 }
316 }321 }
317 322
323+// handleHealth handles /admin/health endpoint.
324+// Returns detailed health status for all routes including HTTP probe results.
325+func (h *Handler) handleHealth(w http.ResponseWriter, r *http.Request) {
326+ if r.Method != http.MethodGet {
327+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
328+ return
329+ }
330+
331+ ctx := r.Context()
332+
333+ // Check read permission
334+ if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
335+ h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
336+ return
337+ }
338+
339+ health := h.healthSvc.CheckAllRoutes(ctx)
340+
341+ // Convert to JSON-friendly format
342+ result := make(map[string]any, len(health))
343+ for domain, h := range health {
344+ result[domain] = map[string]any{
345+ "container_status": h.ContainerStatus,
346+ "http_status": h.HTTPStatus,
347+ "response_time_ms": h.ResponseTimeMs,
348+ "healthy": h.Healthy,
349+ "error": h.Error,
350+ }
351+ }
352+
353+ h.sendJSON(w, http.StatusOK, map[string]any{"health": result})
354+}
355+
318 // handleStatus handles /admin/status endpoint.356 // handleStatus handles /admin/status endpoint.
319 func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {357 func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
320 if r.Method != http.MethodGet {358 if r.Method != http.MethodGet {
M internal/adapters/in/http/admin/handler_test.go +20 -20
@@ -34,7 +34,7 @@ func TestHandler_RoutesGet_RequiresReadScope(t *testing.T) {
3434 authSvc := inmocks.NewMockAuthService(t)
3535 containerSvc := inmocks.NewMockContainerService(t)
3636
37- handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())
37+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger())
3838
3939 tests := []struct {
4040 name string
@@ -95,7 +95,7 @@ func TestHandler_RoutesPost_RequiresWriteScope(t *testing.T) {
9595 authSvc := inmocks.NewMockAuthService(t)
9696 containerSvc := inmocks.NewMockContainerService(t)
9797
98- handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())
98+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger())
9999
100100 routeJSON := `{"domain": "app.example.com", "image": "myapp:latest"}`
101101
@@ -153,7 +153,7 @@ func TestHandler_RoutesPut_RequiresWriteScope(t *testing.T) {
153153 authSvc := inmocks.NewMockAuthService(t)
154154 containerSvc := inmocks.NewMockContainerService(t)
155155
156- handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())
156+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger())
157157
158158 routeJSON := `{"image": "myapp:v2"}`
159159
@@ -196,7 +196,7 @@ func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) {
196196 authSvc := inmocks.NewMockAuthService(t)
197197 containerSvc := inmocks.NewMockContainerService(t)
198198
199- handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())
199+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger())
200200
201201 tests := []struct {
202202 name string
@@ -242,7 +242,7 @@ func TestHandler_SecretsGet_RequiresReadScope(t *testing.T) {
242242 // Create temp env directory
243243 tmpDir := t.TempDir()
244244
245- handler := NewHandler(configSvc, authSvc, containerSvc, nil, tmpDir, testLogger())
245+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, tmpDir, testLogger())
246246
247247 tests := []struct {
248248 name string
@@ -297,7 +297,7 @@ func TestHandler_SecretsPost_RequiresWriteScope(t *testing.T) {
297297 // Create temp env directory
298298 tmpDir := t.TempDir()
299299
300- handler := NewHandler(configSvc, authSvc, containerSvc, nil, tmpDir, testLogger())
300+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, tmpDir, testLogger())
301301
302302 secretsJSON := `{"API_KEY": "secret123"}`
303303
@@ -346,7 +346,7 @@ func TestHandler_SecretsDelete_RequiresWriteScope(t *testing.T) {
346346 envFile := filepath.Join(tmpDir, "app_example_com.env")
347347 os.WriteFile(envFile, []byte("API_KEY=secret123\n"), 0600)
348348
349- handler := NewHandler(configSvc, authSvc, containerSvc, nil, tmpDir, testLogger())
349+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, tmpDir, testLogger())
350350
351351 tests := []struct {
352352 name string
@@ -385,7 +385,7 @@ func TestHandler_Status_RequiresReadScope(t *testing.T) {
385385 authSvc := inmocks.NewMockAuthService(t)
386386 containerSvc := inmocks.NewMockContainerService(t)
387387
388- handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())
388+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger())
389389
390390 tests := []struct {
391391 name string
@@ -438,7 +438,7 @@ func TestHandler_Config_RequiresReadScope(t *testing.T) {
438438 authSvc := inmocks.NewMockAuthService(t)
439439 containerSvc := inmocks.NewMockContainerService(t)
440440
441- handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())
441+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger())
442442
443443 tests := []struct {
444444 name string
@@ -494,7 +494,7 @@ func TestHandler_Reload_RequiresWriteScope(t *testing.T) {
494494 authSvc := inmocks.NewMockAuthService(t)
495495 containerSvc := inmocks.NewMockContainerService(t)
496496
497- handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())
497+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger())
498498
499499 tests := []struct {
500500 name string
@@ -547,7 +547,7 @@ func TestHandler_RoutesGet_ReturnsRoutes(t *testing.T) {
547547 authSvc := inmocks.NewMockAuthService(t)
548548 containerSvc := inmocks.NewMockContainerService(t)
549549
550- handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())
550+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger())
551551
552552 expectedRoutes := []domain.Route{
553553 {Domain: "app1.example.com", Image: "app1:latest"},
@@ -577,7 +577,7 @@ func TestHandler_RoutesGet_SingleRoute(t *testing.T) {
577577 authSvc := inmocks.NewMockAuthService(t)
578578 containerSvc := inmocks.NewMockContainerService(t)
579579
580- handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())
580+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger())
581581
582582 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
583583 {Domain: "app.example.com", Image: "app:latest"},
@@ -603,7 +603,7 @@ func TestHandler_RoutesGet_NotFound(t *testing.T) {
603603 authSvc := inmocks.NewMockAuthService(t)
604604 containerSvc := inmocks.NewMockContainerService(t)
605605
606- handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())
606+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger())
607607
608608 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{})
609609
@@ -621,7 +621,7 @@ func TestHandler_RoutesPost_MissingFields(t *testing.T) {
621621 authSvc := inmocks.NewMockAuthService(t)
622622 containerSvc := inmocks.NewMockContainerService(t)
623623
624- handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())
624+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger())
625625
626626 tests := []struct {
627627 name string
@@ -659,7 +659,7 @@ func TestHandler_RoutesPut_MissingDomain(t *testing.T) {
659659 authSvc := inmocks.NewMockAuthService(t)
660660 containerSvc := inmocks.NewMockContainerService(t)
661661
662- handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())
662+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger())
663663
664664 req := httptest.NewRequest("PUT", "/admin/routes/", bytes.NewBufferString(`{"image": "app:latest"}`))
665665 req = req.WithContext(ctxWithScopes("admin:routes:write"))
@@ -675,7 +675,7 @@ func TestHandler_RoutesDelete_MissingDomain(t *testing.T) {
675675 authSvc := inmocks.NewMockAuthService(t)
676676 containerSvc := inmocks.NewMockContainerService(t)
677677
678- handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())
678+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger())
679679
680680 req := httptest.NewRequest("DELETE", "/admin/routes/", nil)
681681 req = req.WithContext(ctxWithScopes("admin:routes:write"))
@@ -691,7 +691,7 @@ func TestHandler_Secrets_MissingDomain(t *testing.T) {
691691 authSvc := inmocks.NewMockAuthService(t)
692692 containerSvc := inmocks.NewMockContainerService(t)
693693
694- handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())
694+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger())
695695
696696 req := httptest.NewRequest("GET", "/admin/secrets/", nil)
697697 req = req.WithContext(ctxWithScopes("admin:secrets:read"))
@@ -707,7 +707,7 @@ func TestHandler_SecretsDelete_MissingKey(t *testing.T) {
707707 authSvc := inmocks.NewMockAuthService(t)
708708 containerSvc := inmocks.NewMockContainerService(t)
709709
710- handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())
710+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger())
711711
712712 req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com", nil)
713713 req = req.WithContext(ctxWithScopes("admin:secrets:write"))
@@ -723,7 +723,7 @@ func TestHandler_MethodNotAllowed(t *testing.T) {
723723 authSvc := inmocks.NewMockAuthService(t)
724724 containerSvc := inmocks.NewMockContainerService(t)
725725
726- handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())
726+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger())
727727
728728 tests := []struct {
729729 method string
@@ -755,7 +755,7 @@ func TestHandler_NotFound(t *testing.T) {
755755 authSvc := inmocks.NewMockAuthService(t)
756756 containerSvc := inmocks.NewMockContainerService(t)
757757
758- handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())
758+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger())
759759
760760 req := httptest.NewRequest("GET", "/admin/unknown", nil)
761761 req = req.WithContext(ctxWithScopes("admin:*:*"))
@@ -34,7 +34,7 @@ func TestHandler_RoutesGet_RequiresReadScope(t *testing.T) {
34 authSvc := inmocks.NewMockAuthService(t)34 authSvc := inmocks.NewMockAuthService(t)
35 containerSvc := inmocks.NewMockContainerService(t)35 containerSvc := inmocks.NewMockContainerService(t)
36 36
37- handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())37+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger())
38 38
39 tests := []struct {39 tests := []struct {
40 name string40 name string
@@ -95,7 +95,7 @@ func TestHandler_RoutesPost_RequiresWriteScope(t *testing.T) {
95 authSvc := inmocks.NewMockAuthService(t)95 authSvc := inmocks.NewMockAuthService(t)
96 containerSvc := inmocks.NewMockContainerService(t)96 containerSvc := inmocks.NewMockContainerService(t)
97 97
98- handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())98+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger())
99 99
100 routeJSON := `{"domain": "app.example.com", "image": "myapp:latest"}`100 routeJSON := `{"domain": "app.example.com", "image": "myapp:latest"}`
101 101
@@ -153,7 +153,7 @@ func TestHandler_RoutesPut_RequiresWriteScope(t *testing.T) {
153 authSvc := inmocks.NewMockAuthService(t)153 authSvc := inmocks.NewMockAuthService(t)
154 containerSvc := inmocks.NewMockContainerService(t)154 containerSvc := inmocks.NewMockContainerService(t)
155 155
156- handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())156+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger())
157 157
158 routeJSON := `{"image": "myapp:v2"}`158 routeJSON := `{"image": "myapp:v2"}`
159 159
@@ -196,7 +196,7 @@ func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) {
196 authSvc := inmocks.NewMockAuthService(t)196 authSvc := inmocks.NewMockAuthService(t)
197 containerSvc := inmocks.NewMockContainerService(t)197 containerSvc := inmocks.NewMockContainerService(t)
198 198
199- handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())199+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger())
200 200
201 tests := []struct {201 tests := []struct {
202 name string202 name string
@@ -242,7 +242,7 @@ func TestHandler_SecretsGet_RequiresReadScope(t *testing.T) {
242 // Create temp env directory242 // Create temp env directory
243 tmpDir := t.TempDir()243 tmpDir := t.TempDir()
244 244
245- handler := NewHandler(configSvc, authSvc, containerSvc, nil, tmpDir, testLogger())245+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, tmpDir, testLogger())
246 246
247 tests := []struct {247 tests := []struct {
248 name string248 name string
@@ -297,7 +297,7 @@ func TestHandler_SecretsPost_RequiresWriteScope(t *testing.T) {
297 // Create temp env directory297 // Create temp env directory
298 tmpDir := t.TempDir()298 tmpDir := t.TempDir()
299 299
300- handler := NewHandler(configSvc, authSvc, containerSvc, nil, tmpDir, testLogger())300+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, tmpDir, testLogger())
301 301
302 secretsJSON := `{"API_KEY": "secret123"}`302 secretsJSON := `{"API_KEY": "secret123"}`
303 303
@@ -346,7 +346,7 @@ func TestHandler_SecretsDelete_RequiresWriteScope(t *testing.T) {
346 envFile := filepath.Join(tmpDir, "app_example_com.env")346 envFile := filepath.Join(tmpDir, "app_example_com.env")
347 os.WriteFile(envFile, []byte("API_KEY=secret123\n"), 0600)347 os.WriteFile(envFile, []byte("API_KEY=secret123\n"), 0600)
348 348
349- handler := NewHandler(configSvc, authSvc, containerSvc, nil, tmpDir, testLogger())349+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, tmpDir, testLogger())
350 350
351 tests := []struct {351 tests := []struct {
352 name string352 name string
@@ -385,7 +385,7 @@ func TestHandler_Status_RequiresReadScope(t *testing.T) {
385 authSvc := inmocks.NewMockAuthService(t)385 authSvc := inmocks.NewMockAuthService(t)
386 containerSvc := inmocks.NewMockContainerService(t)386 containerSvc := inmocks.NewMockContainerService(t)
387 387
388- handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())388+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger())
389 389
390 tests := []struct {390 tests := []struct {
391 name string391 name string
@@ -438,7 +438,7 @@ func TestHandler_Config_RequiresReadScope(t *testing.T) {
438 authSvc := inmocks.NewMockAuthService(t)438 authSvc := inmocks.NewMockAuthService(t)
439 containerSvc := inmocks.NewMockContainerService(t)439 containerSvc := inmocks.NewMockContainerService(t)
440 440
441- handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())441+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger())
442 442
443 tests := []struct {443 tests := []struct {
444 name string444 name string
@@ -494,7 +494,7 @@ func TestHandler_Reload_RequiresWriteScope(t *testing.T) {
494 authSvc := inmocks.NewMockAuthService(t)494 authSvc := inmocks.NewMockAuthService(t)
495 containerSvc := inmocks.NewMockContainerService(t)495 containerSvc := inmocks.NewMockContainerService(t)
496 496
497- handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())497+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger())
498 498
499 tests := []struct {499 tests := []struct {
500 name string500 name string
@@ -547,7 +547,7 @@ func TestHandler_RoutesGet_ReturnsRoutes(t *testing.T) {
547 authSvc := inmocks.NewMockAuthService(t)547 authSvc := inmocks.NewMockAuthService(t)
548 containerSvc := inmocks.NewMockContainerService(t)548 containerSvc := inmocks.NewMockContainerService(t)
549 549
550- handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())550+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger())
551 551
552 expectedRoutes := []domain.Route{552 expectedRoutes := []domain.Route{
553 {Domain: "app1.example.com", Image: "app1:latest"},553 {Domain: "app1.example.com", Image: "app1:latest"},
@@ -577,7 +577,7 @@ func TestHandler_RoutesGet_SingleRoute(t *testing.T) {
577 authSvc := inmocks.NewMockAuthService(t)577 authSvc := inmocks.NewMockAuthService(t)
578 containerSvc := inmocks.NewMockContainerService(t)578 containerSvc := inmocks.NewMockContainerService(t)
579 579
580- handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())580+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger())
581 581
582 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{582 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
583 {Domain: "app.example.com", Image: "app:latest"},583 {Domain: "app.example.com", Image: "app:latest"},
@@ -603,7 +603,7 @@ func TestHandler_RoutesGet_NotFound(t *testing.T) {
603 authSvc := inmocks.NewMockAuthService(t)603 authSvc := inmocks.NewMockAuthService(t)
604 containerSvc := inmocks.NewMockContainerService(t)604 containerSvc := inmocks.NewMockContainerService(t)
605 605
606- handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())606+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger())
607 607
608 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{})608 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{})
609 609
@@ -621,7 +621,7 @@ func TestHandler_RoutesPost_MissingFields(t *testing.T) {
621 authSvc := inmocks.NewMockAuthService(t)621 authSvc := inmocks.NewMockAuthService(t)
622 containerSvc := inmocks.NewMockContainerService(t)622 containerSvc := inmocks.NewMockContainerService(t)
623 623
624- handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())624+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger())
625 625
626 tests := []struct {626 tests := []struct {
627 name string627 name string
@@ -659,7 +659,7 @@ func TestHandler_RoutesPut_MissingDomain(t *testing.T) {
659 authSvc := inmocks.NewMockAuthService(t)659 authSvc := inmocks.NewMockAuthService(t)
660 containerSvc := inmocks.NewMockContainerService(t)660 containerSvc := inmocks.NewMockContainerService(t)
661 661
662- handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())662+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger())
663 663
664 req := httptest.NewRequest("PUT", "/admin/routes/", bytes.NewBufferString(`{"image": "app:latest"}`))664 req := httptest.NewRequest("PUT", "/admin/routes/", bytes.NewBufferString(`{"image": "app:latest"}`))
665 req = req.WithContext(ctxWithScopes("admin:routes:write"))665 req = req.WithContext(ctxWithScopes("admin:routes:write"))
@@ -675,7 +675,7 @@ func TestHandler_RoutesDelete_MissingDomain(t *testing.T) {
675 authSvc := inmocks.NewMockAuthService(t)675 authSvc := inmocks.NewMockAuthService(t)
676 containerSvc := inmocks.NewMockContainerService(t)676 containerSvc := inmocks.NewMockContainerService(t)
677 677
678- handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())678+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger())
679 679
680 req := httptest.NewRequest("DELETE", "/admin/routes/", nil)680 req := httptest.NewRequest("DELETE", "/admin/routes/", nil)
681 req = req.WithContext(ctxWithScopes("admin:routes:write"))681 req = req.WithContext(ctxWithScopes("admin:routes:write"))
@@ -691,7 +691,7 @@ func TestHandler_Secrets_MissingDomain(t *testing.T) {
691 authSvc := inmocks.NewMockAuthService(t)691 authSvc := inmocks.NewMockAuthService(t)
692 containerSvc := inmocks.NewMockContainerService(t)692 containerSvc := inmocks.NewMockContainerService(t)
693 693
694- handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())694+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger())
695 695
696 req := httptest.NewRequest("GET", "/admin/secrets/", nil)696 req := httptest.NewRequest("GET", "/admin/secrets/", nil)
697 req = req.WithContext(ctxWithScopes("admin:secrets:read"))697 req = req.WithContext(ctxWithScopes("admin:secrets:read"))
@@ -707,7 +707,7 @@ func TestHandler_SecretsDelete_MissingKey(t *testing.T) {
707 authSvc := inmocks.NewMockAuthService(t)707 authSvc := inmocks.NewMockAuthService(t)
708 containerSvc := inmocks.NewMockContainerService(t)708 containerSvc := inmocks.NewMockContainerService(t)
709 709
710- handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())710+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger())
711 711
712 req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com", nil)712 req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com", nil)
713 req = req.WithContext(ctxWithScopes("admin:secrets:write"))713 req = req.WithContext(ctxWithScopes("admin:secrets:write"))
@@ -723,7 +723,7 @@ func TestHandler_MethodNotAllowed(t *testing.T) {
723 authSvc := inmocks.NewMockAuthService(t)723 authSvc := inmocks.NewMockAuthService(t)
724 containerSvc := inmocks.NewMockContainerService(t)724 containerSvc := inmocks.NewMockContainerService(t)
725 725
726- handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())726+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger())
727 727
728 tests := []struct {728 tests := []struct {
729 method string729 method string
@@ -755,7 +755,7 @@ func TestHandler_NotFound(t *testing.T) {
755 authSvc := inmocks.NewMockAuthService(t)755 authSvc := inmocks.NewMockAuthService(t)
756 containerSvc := inmocks.NewMockContainerService(t)756 containerSvc := inmocks.NewMockContainerService(t)
757 757
758- handler := NewHandler(configSvc, authSvc, containerSvc, nil, "/tmp/env", testLogger())758+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger())
759 759
760 req := httptest.NewRequest("GET", "/admin/unknown", nil)760 req := httptest.NewRequest("GET", "/admin/unknown", nil)
761 req = req.WithContext(ctxWithScopes("admin:*:*"))761 req = req.WithContext(ctxWithScopes("admin:*:*"))
M internal/adapters/out/docker/runtime.go +1 -1
@@ -267,7 +267,7 @@ func (r *Runtime) ListContainers(ctx context.Context, all bool) ([]*domain.Conta
267267 ID: c.ID,
268268 Image: c.Image,
269269 Name: name,
270- Status: c.Status,
270+ Status: c.State, // Use State (e.g., "running") not Status (e.g., "Up 2 days")
271271 Ports: ports,
272272 Labels: c.Labels,
273273 })
@@ -267,7 +267,7 @@ func (r *Runtime) ListContainers(ctx context.Context, all bool) ([]*domain.Conta
267 ID: c.ID,267 ID: c.ID,
268 Image: c.Image,268 Image: c.Image,
269 Name: name,269 Name: name,
270- Status: c.Status,270+ Status: c.State, // Use State (e.g., "running") not Status (e.g., "Up 2 days")
271 Ports: ports,271 Ports: ports,
272 Labels: c.Labels,272 Labels: c.Labels,
273 })273 })
A internal/adapters/out/httpprober/prober.go +91 -0
@@ -0,0 +1,91 @@
1+// Package httpprober provides HTTP probing functionality for health checks.
2+package httpprober
3+
4+import (
5+ "context"
6+ "crypto/tls"
7+ "fmt"
8+ "net/http"
9+ "time"
10+)
11+
12+// DefaultTimeout is the default timeout for HTTP probes.
13+const DefaultTimeout = 5 * time.Second
14+
15+// Prober implements the HTTPProber interface for health checking.
16+type Prober struct {
17+ client *http.Client
18+ timeout time.Duration
19+}
20+
21+// Option configures the Prober.
22+type Option func(*Prober)
23+
24+// WithTimeout sets the probe timeout.
25+func WithTimeout(timeout time.Duration) Option {
26+ return func(p *Prober) {
27+ p.timeout = timeout
28+ }
29+}
30+
31+// WithHTTPClient sets a custom HTTP client.
32+func WithHTTPClient(client *http.Client) Option {
33+ return func(p *Prober) {
34+ p.client = client
35+ }
36+}
37+
38+// New creates a new HTTP prober.
39+func New(opts ...Option) *Prober {
40+ p := &Prober{
41+ timeout: DefaultTimeout,
42+ }
43+
44+ for _, opt := range opts {
45+ opt(p)
46+ }
47+
48+ if p.client == nil {
49+ p.client = &http.Client{
50+ Timeout: p.timeout,
51+ Transport: &http.Transport{
52+ // #nosec G402 - InsecureSkipVerify is intentional for health probing
53+ // internal services that may use self-signed certificates.
54+ // This prober only checks reachability, not data integrity.
55+ TLSClientConfig: &tls.Config{
56+ InsecureSkipVerify: true,
57+ },
58+ DisableKeepAlives: true,
59+ },
60+ // Don't follow redirects - we want to see the actual response
61+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
62+ return http.ErrUseLastResponse
63+ },
64+ }
65+ }
66+
67+ return p
68+}
69+
70+// Probe sends an HTTP GET request and returns the status code and response time.
71+func (p *Prober) Probe(ctx context.Context, url string) (int, int64, error) {
72+ start := time.Now()
73+
74+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
75+ if err != nil {
76+ return 0, 0, fmt.Errorf("failed to create request: %w", err)
77+ }
78+
79+ // Set a reasonable user agent
80+ req.Header.Set("User-Agent", "Gordon-HealthCheck/1.0")
81+
82+ resp, err := p.client.Do(req)
83+ if err != nil {
84+ elapsed := time.Since(start).Milliseconds()
85+ return 0, elapsed, fmt.Errorf("request failed: %w", err)
86+ }
87+ defer resp.Body.Close()
88+
89+ elapsed := time.Since(start).Milliseconds()
90+ return resp.StatusCode, elapsed, nil
91+}
@@ -0,0 +1,91 @@
1+// Package httpprober provides HTTP probing functionality for health checks.
2+package httpprober
3+
4+import (
5+ "context"
6+ "crypto/tls"
7+ "fmt"
8+ "net/http"
9+ "time"
10+)
11+
12+// DefaultTimeout is the default timeout for HTTP probes.
13+const DefaultTimeout = 5 * time.Second
14+
15+// Prober implements the HTTPProber interface for health checking.
16+type Prober struct {
17+ client *http.Client
18+ timeout time.Duration
19+}
20+
21+// Option configures the Prober.
22+type Option func(*Prober)
23+
24+// WithTimeout sets the probe timeout.
25+func WithTimeout(timeout time.Duration) Option {
26+ return func(p *Prober) {
27+ p.timeout = timeout
28+ }
29+}
30+
31+// WithHTTPClient sets a custom HTTP client.
32+func WithHTTPClient(client *http.Client) Option {
33+ return func(p *Prober) {
34+ p.client = client
35+ }
36+}
37+
38+// New creates a new HTTP prober.
39+func New(opts ...Option) *Prober {
40+ p := &Prober{
41+ timeout: DefaultTimeout,
42+ }
43+
44+ for _, opt := range opts {
45+ opt(p)
46+ }
47+
48+ if p.client == nil {
49+ p.client = &http.Client{
50+ Timeout: p.timeout,
51+ Transport: &http.Transport{
52+ // #nosec G402 - InsecureSkipVerify is intentional for health probing
53+ // internal services that may use self-signed certificates.
54+ // This prober only checks reachability, not data integrity.
55+ TLSClientConfig: &tls.Config{
56+ InsecureSkipVerify: true,
57+ },
58+ DisableKeepAlives: true,
59+ },
60+ // Don't follow redirects - we want to see the actual response
61+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
62+ return http.ErrUseLastResponse
63+ },
64+ }
65+ }
66+
67+ return p
68+}
69+
70+// Probe sends an HTTP GET request and returns the status code and response time.
71+func (p *Prober) Probe(ctx context.Context, url string) (int, int64, error) {
72+ start := time.Now()
73+
74+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
75+ if err != nil {
76+ return 0, 0, fmt.Errorf("failed to create request: %w", err)
77+ }
78+
79+ // Set a reasonable user agent
80+ req.Header.Set("User-Agent", "Gordon-HealthCheck/1.0")
81+
82+ resp, err := p.client.Do(req)
83+ if err != nil {
84+ elapsed := time.Since(start).Milliseconds()
85+ return 0, elapsed, fmt.Errorf("request failed: %w", err)
86+ }
87+ defer resp.Body.Close()
88+
89+ elapsed := time.Since(start).Milliseconds()
90+ return resp.StatusCode, elapsed, nil
91+}
M internal/app/run.go +7 -1
@@ -24,6 +24,7 @@ import (
2424 "gordon/internal/adapters/out/envloader"
2525 "gordon/internal/adapters/out/eventbus"
2626 "gordon/internal/adapters/out/filesystem"
27+ "gordon/internal/adapters/out/httpprober"
2728 "gordon/internal/adapters/out/logwriter"
2829 "gordon/internal/adapters/out/secrets"
2930 "gordon/internal/adapters/out/tokenstore"
@@ -43,6 +44,7 @@ import (
4344 "gordon/internal/usecase/auth"
4445 "gordon/internal/usecase/config"
4546 "gordon/internal/usecase/container"
47+ "gordon/internal/usecase/health"
4648 "gordon/internal/usecase/proxy"
4749 registrySvc "gordon/internal/usecase/registry"
4850 )
@@ -294,8 +296,12 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
294296 svc.envDir = filepath.Join(dataDir, "env")
295297 }
296298
299+ // Create health service for route health checking
300+ prober := httpprober.New()
301+ healthSvc := health.NewService(svc.configSvc, svc.containerSvc, prober, log)
302+
297303 // Create admin handler for admin API
298- svc.adminHandler = admin.NewHandler(svc.configSvc, svc.authSvc, svc.containerSvc, svc.eventBus, svc.envDir, log)
304+ svc.adminHandler = admin.NewHandler(svc.configSvc, svc.authSvc, svc.containerSvc, healthSvc, svc.eventBus, svc.envDir, log)
299305
300306 return svc, nil
301307 }
@@ -24,6 +24,7 @@ import (
24 "gordon/internal/adapters/out/envloader"24 "gordon/internal/adapters/out/envloader"
25 "gordon/internal/adapters/out/eventbus"25 "gordon/internal/adapters/out/eventbus"
26 "gordon/internal/adapters/out/filesystem"26 "gordon/internal/adapters/out/filesystem"
27+ "gordon/internal/adapters/out/httpprober"
27 "gordon/internal/adapters/out/logwriter"28 "gordon/internal/adapters/out/logwriter"
28 "gordon/internal/adapters/out/secrets"29 "gordon/internal/adapters/out/secrets"
29 "gordon/internal/adapters/out/tokenstore"30 "gordon/internal/adapters/out/tokenstore"
@@ -43,6 +44,7 @@ import (
43 "gordon/internal/usecase/auth"44 "gordon/internal/usecase/auth"
44 "gordon/internal/usecase/config"45 "gordon/internal/usecase/config"
45 "gordon/internal/usecase/container"46 "gordon/internal/usecase/container"
47+ "gordon/internal/usecase/health"
46 "gordon/internal/usecase/proxy"48 "gordon/internal/usecase/proxy"
47 registrySvc "gordon/internal/usecase/registry"49 registrySvc "gordon/internal/usecase/registry"
48 )50 )
@@ -294,8 +296,12 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
294 svc.envDir = filepath.Join(dataDir, "env")296 svc.envDir = filepath.Join(dataDir, "env")
295 }297 }
296 298
299+ // Create health service for route health checking
300+ prober := httpprober.New()
301+ healthSvc := health.NewService(svc.configSvc, svc.containerSvc, prober, log)
302+
297 // Create admin handler for admin API303 // Create admin handler for admin API
298- svc.adminHandler = admin.NewHandler(svc.configSvc, svc.authSvc, svc.containerSvc, svc.eventBus, svc.envDir, log)304+ svc.adminHandler = admin.NewHandler(svc.configSvc, svc.authSvc, svc.containerSvc, healthSvc, svc.eventBus, svc.envDir, log)
299 305
300 return svc, nil306 return svc, nil
301 }307 }
A internal/boundaries/in/health.go +29 -0
@@ -0,0 +1,29 @@
1+// Package in defines input ports (interfaces) for use cases.
2+// These interfaces define the contract between driving adapters (HTTP, CLI)
3+// and the business logic (use cases).
4+package in
5+
6+import (
7+ "context"
8+
9+ "gordon/internal/domain"
10+)
11+
12+// HealthService defines the contract for route health checking operations.
13+type HealthService interface {
14+ // CheckRoute performs a health check on a single route.
15+ // It checks both container status and HTTP reachability.
16+ CheckRoute(ctx context.Context, route domain.Route) *domain.RouteHealth
17+
18+ // CheckAllRoutes performs health checks on all configured routes.
19+ // Returns a map of domain to health status.
20+ CheckAllRoutes(ctx context.Context) map[string]*domain.RouteHealth
21+}
22+
23+// HTTPProber defines the contract for HTTP health probing.
24+// This allows for easy mocking in tests.
25+type HTTPProber interface {
26+ // Probe sends an HTTP request to the URL and returns status code and response time.
27+ // Returns (statusCode, responseTimeMs, error).
28+ Probe(ctx context.Context, url string) (int, int64, error)
29+}
@@ -0,0 +1,29 @@
1+// Package in defines input ports (interfaces) for use cases.
2+// These interfaces define the contract between driving adapters (HTTP, CLI)
3+// and the business logic (use cases).
4+package in
5+
6+import (
7+ "context"
8+
9+ "gordon/internal/domain"
10+)
11+
12+// HealthService defines the contract for route health checking operations.
13+type HealthService interface {
14+ // CheckRoute performs a health check on a single route.
15+ // It checks both container status and HTTP reachability.
16+ CheckRoute(ctx context.Context, route domain.Route) *domain.RouteHealth
17+
18+ // CheckAllRoutes performs health checks on all configured routes.
19+ // Returns a map of domain to health status.
20+ CheckAllRoutes(ctx context.Context) map[string]*domain.RouteHealth
21+}
22+
23+// HTTPProber defines the contract for HTTP health probing.
24+// This allows for easy mocking in tests.
25+type HTTPProber interface {
26+ // Probe sends an HTTP request to the URL and returns status code and response time.
27+ // Returns (statusCode, responseTimeMs, error).
28+ Probe(ctx context.Context, url string) (int, int64, error)
29+}
A internal/boundaries/in/mocks/mock_health_service.go +151 -0
@@ -0,0 +1,151 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+ "gordon/internal/domain"
10+
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMockHealthService creates a new instance of MockHealthService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMockHealthService(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MockHealthService {
20+ mock := &MockHealthService{}
21+ mock.Mock.Test(t)
22+
23+ t.Cleanup(func() { mock.AssertExpectations(t) })
24+
25+ return mock
26+}
27+
28+// MockHealthService is an autogenerated mock type for the HealthService type
29+type MockHealthService struct {
30+ mock.Mock
31+}
32+
33+type MockHealthService_Expecter struct {
34+ mock *mock.Mock
35+}
36+
37+func (_m *MockHealthService) EXPECT() *MockHealthService_Expecter {
38+ return &MockHealthService_Expecter{mock: &_m.Mock}
39+}
40+
41+// CheckAllRoutes provides a mock function for the type MockHealthService
42+func (_mock *MockHealthService) CheckAllRoutes(ctx context.Context) map[string]*domain.RouteHealth {
43+ ret := _mock.Called(ctx)
44+
45+ if len(ret) == 0 {
46+ panic("no return value specified for CheckAllRoutes")
47+ }
48+
49+ var r0 map[string]*domain.RouteHealth
50+ if returnFunc, ok := ret.Get(0).(func(context.Context) map[string]*domain.RouteHealth); ok {
51+ r0 = returnFunc(ctx)
52+ } else {
53+ if ret.Get(0) != nil {
54+ r0 = ret.Get(0).(map[string]*domain.RouteHealth)
55+ }
56+ }
57+ return r0
58+}
59+
60+// MockHealthService_CheckAllRoutes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CheckAllRoutes'
61+type MockHealthService_CheckAllRoutes_Call struct {
62+ *mock.Call
63+}
64+
65+// CheckAllRoutes is a helper method to define mock.On call
66+// - ctx context.Context
67+func (_e *MockHealthService_Expecter) CheckAllRoutes(ctx interface{}) *MockHealthService_CheckAllRoutes_Call {
68+ return &MockHealthService_CheckAllRoutes_Call{Call: _e.mock.On("CheckAllRoutes", ctx)}
69+}
70+
71+func (_c *MockHealthService_CheckAllRoutes_Call) Run(run func(ctx context.Context)) *MockHealthService_CheckAllRoutes_Call {
72+ _c.Call.Run(func(args mock.Arguments) {
73+ var arg0 context.Context
74+ if args[0] != nil {
75+ arg0 = args[0].(context.Context)
76+ }
77+ run(
78+ arg0,
79+ )
80+ })
81+ return _c
82+}
83+
84+func (_c *MockHealthService_CheckAllRoutes_Call) Return(stringToRouteHealth map[string]*domain.RouteHealth) *MockHealthService_CheckAllRoutes_Call {
85+ _c.Call.Return(stringToRouteHealth)
86+ return _c
87+}
88+
89+func (_c *MockHealthService_CheckAllRoutes_Call) RunAndReturn(run func(ctx context.Context) map[string]*domain.RouteHealth) *MockHealthService_CheckAllRoutes_Call {
90+ _c.Call.Return(run)
91+ return _c
92+}
93+
94+// CheckRoute provides a mock function for the type MockHealthService
95+func (_mock *MockHealthService) CheckRoute(ctx context.Context, route domain.Route) *domain.RouteHealth {
96+ ret := _mock.Called(ctx, route)
97+
98+ if len(ret) == 0 {
99+ panic("no return value specified for CheckRoute")
100+ }
101+
102+ var r0 *domain.RouteHealth
103+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Route) *domain.RouteHealth); ok {
104+ r0 = returnFunc(ctx, route)
105+ } else {
106+ if ret.Get(0) != nil {
107+ r0 = ret.Get(0).(*domain.RouteHealth)
108+ }
109+ }
110+ return r0
111+}
112+
113+// MockHealthService_CheckRoute_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CheckRoute'
114+type MockHealthService_CheckRoute_Call struct {
115+ *mock.Call
116+}
117+
118+// CheckRoute is a helper method to define mock.On call
119+// - ctx context.Context
120+// - route domain.Route
121+func (_e *MockHealthService_Expecter) CheckRoute(ctx interface{}, route interface{}) *MockHealthService_CheckRoute_Call {
122+ return &MockHealthService_CheckRoute_Call{Call: _e.mock.On("CheckRoute", ctx, route)}
123+}
124+
125+func (_c *MockHealthService_CheckRoute_Call) Run(run func(ctx context.Context, route domain.Route)) *MockHealthService_CheckRoute_Call {
126+ _c.Call.Run(func(args mock.Arguments) {
127+ var arg0 context.Context
128+ if args[0] != nil {
129+ arg0 = args[0].(context.Context)
130+ }
131+ var arg1 domain.Route
132+ if args[1] != nil {
133+ arg1 = args[1].(domain.Route)
134+ }
135+ run(
136+ arg0,
137+ arg1,
138+ )
139+ })
140+ return _c
141+}
142+
143+func (_c *MockHealthService_CheckRoute_Call) Return(routeHealth *domain.RouteHealth) *MockHealthService_CheckRoute_Call {
144+ _c.Call.Return(routeHealth)
145+ return _c
146+}
147+
148+func (_c *MockHealthService_CheckRoute_Call) RunAndReturn(run func(ctx context.Context, route domain.Route) *domain.RouteHealth) *MockHealthService_CheckRoute_Call {
149+ _c.Call.Return(run)
150+ return _c
151+}
@@ -0,0 +1,151 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+ "gordon/internal/domain"
10+
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMockHealthService creates a new instance of MockHealthService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMockHealthService(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MockHealthService {
20+ mock := &MockHealthService{}
21+ mock.Mock.Test(t)
22+
23+ t.Cleanup(func() { mock.AssertExpectations(t) })
24+
25+ return mock
26+}
27+
28+// MockHealthService is an autogenerated mock type for the HealthService type
29+type MockHealthService struct {
30+ mock.Mock
31+}
32+
33+type MockHealthService_Expecter struct {
34+ mock *mock.Mock
35+}
36+
37+func (_m *MockHealthService) EXPECT() *MockHealthService_Expecter {
38+ return &MockHealthService_Expecter{mock: &_m.Mock}
39+}
40+
41+// CheckAllRoutes provides a mock function for the type MockHealthService
42+func (_mock *MockHealthService) CheckAllRoutes(ctx context.Context) map[string]*domain.RouteHealth {
43+ ret := _mock.Called(ctx)
44+
45+ if len(ret) == 0 {
46+ panic("no return value specified for CheckAllRoutes")
47+ }
48+
49+ var r0 map[string]*domain.RouteHealth
50+ if returnFunc, ok := ret.Get(0).(func(context.Context) map[string]*domain.RouteHealth); ok {
51+ r0 = returnFunc(ctx)
52+ } else {
53+ if ret.Get(0) != nil {
54+ r0 = ret.Get(0).(map[string]*domain.RouteHealth)
55+ }
56+ }
57+ return r0
58+}
59+
60+// MockHealthService_CheckAllRoutes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CheckAllRoutes'
61+type MockHealthService_CheckAllRoutes_Call struct {
62+ *mock.Call
63+}
64+
65+// CheckAllRoutes is a helper method to define mock.On call
66+// - ctx context.Context
67+func (_e *MockHealthService_Expecter) CheckAllRoutes(ctx interface{}) *MockHealthService_CheckAllRoutes_Call {
68+ return &MockHealthService_CheckAllRoutes_Call{Call: _e.mock.On("CheckAllRoutes", ctx)}
69+}
70+
71+func (_c *MockHealthService_CheckAllRoutes_Call) Run(run func(ctx context.Context)) *MockHealthService_CheckAllRoutes_Call {
72+ _c.Call.Run(func(args mock.Arguments) {
73+ var arg0 context.Context
74+ if args[0] != nil {
75+ arg0 = args[0].(context.Context)
76+ }
77+ run(
78+ arg0,
79+ )
80+ })
81+ return _c
82+}
83+
84+func (_c *MockHealthService_CheckAllRoutes_Call) Return(stringToRouteHealth map[string]*domain.RouteHealth) *MockHealthService_CheckAllRoutes_Call {
85+ _c.Call.Return(stringToRouteHealth)
86+ return _c
87+}
88+
89+func (_c *MockHealthService_CheckAllRoutes_Call) RunAndReturn(run func(ctx context.Context) map[string]*domain.RouteHealth) *MockHealthService_CheckAllRoutes_Call {
90+ _c.Call.Return(run)
91+ return _c
92+}
93+
94+// CheckRoute provides a mock function for the type MockHealthService
95+func (_mock *MockHealthService) CheckRoute(ctx context.Context, route domain.Route) *domain.RouteHealth {
96+ ret := _mock.Called(ctx, route)
97+
98+ if len(ret) == 0 {
99+ panic("no return value specified for CheckRoute")
100+ }
101+
102+ var r0 *domain.RouteHealth
103+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Route) *domain.RouteHealth); ok {
104+ r0 = returnFunc(ctx, route)
105+ } else {
106+ if ret.Get(0) != nil {
107+ r0 = ret.Get(0).(*domain.RouteHealth)
108+ }
109+ }
110+ return r0
111+}
112+
113+// MockHealthService_CheckRoute_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CheckRoute'
114+type MockHealthService_CheckRoute_Call struct {
115+ *mock.Call
116+}
117+
118+// CheckRoute is a helper method to define mock.On call
119+// - ctx context.Context
120+// - route domain.Route
121+func (_e *MockHealthService_Expecter) CheckRoute(ctx interface{}, route interface{}) *MockHealthService_CheckRoute_Call {
122+ return &MockHealthService_CheckRoute_Call{Call: _e.mock.On("CheckRoute", ctx, route)}
123+}
124+
125+func (_c *MockHealthService_CheckRoute_Call) Run(run func(ctx context.Context, route domain.Route)) *MockHealthService_CheckRoute_Call {
126+ _c.Call.Run(func(args mock.Arguments) {
127+ var arg0 context.Context
128+ if args[0] != nil {
129+ arg0 = args[0].(context.Context)
130+ }
131+ var arg1 domain.Route
132+ if args[1] != nil {
133+ arg1 = args[1].(domain.Route)
134+ }
135+ run(
136+ arg0,
137+ arg1,
138+ )
139+ })
140+ return _c
141+}
142+
143+func (_c *MockHealthService_CheckRoute_Call) Return(routeHealth *domain.RouteHealth) *MockHealthService_CheckRoute_Call {
144+ _c.Call.Return(routeHealth)
145+ return _c
146+}
147+
148+func (_c *MockHealthService_CheckRoute_Call) RunAndReturn(run func(ctx context.Context, route domain.Route) *domain.RouteHealth) *MockHealthService_CheckRoute_Call {
149+ _c.Call.Return(run)
150+ return _c
151+}
A internal/boundaries/in/mocks/mock_http_prober.go +110 -0
@@ -0,0 +1,110 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ mock "github.com/stretchr/testify/mock"
11+)
12+
13+// NewMockHTTPProber creates a new instance of MockHTTPProber. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14+// The first argument is typically a *testing.T value.
15+func NewMockHTTPProber(t interface {
16+ mock.TestingT
17+ Cleanup(func())
18+}) *MockHTTPProber {
19+ mock := &MockHTTPProber{}
20+ mock.Mock.Test(t)
21+
22+ t.Cleanup(func() { mock.AssertExpectations(t) })
23+
24+ return mock
25+}
26+
27+// MockHTTPProber is an autogenerated mock type for the HTTPProber type
28+type MockHTTPProber struct {
29+ mock.Mock
30+}
31+
32+type MockHTTPProber_Expecter struct {
33+ mock *mock.Mock
34+}
35+
36+func (_m *MockHTTPProber) EXPECT() *MockHTTPProber_Expecter {
37+ return &MockHTTPProber_Expecter{mock: &_m.Mock}
38+}
39+
40+// Probe provides a mock function for the type MockHTTPProber
41+func (_mock *MockHTTPProber) Probe(ctx context.Context, url string) (int, int64, error) {
42+ ret := _mock.Called(ctx, url)
43+
44+ if len(ret) == 0 {
45+ panic("no return value specified for Probe")
46+ }
47+
48+ var r0 int
49+ var r1 int64
50+ var r2 error
51+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (int, int64, error)); ok {
52+ return returnFunc(ctx, url)
53+ }
54+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) int); ok {
55+ r0 = returnFunc(ctx, url)
56+ } else {
57+ r0 = ret.Get(0).(int)
58+ }
59+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) int64); ok {
60+ r1 = returnFunc(ctx, url)
61+ } else {
62+ r1 = ret.Get(1).(int64)
63+ }
64+ if returnFunc, ok := ret.Get(2).(func(context.Context, string) error); ok {
65+ r2 = returnFunc(ctx, url)
66+ } else {
67+ r2 = ret.Error(2)
68+ }
69+ return r0, r1, r2
70+}
71+
72+// MockHTTPProber_Probe_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Probe'
73+type MockHTTPProber_Probe_Call struct {
74+ *mock.Call
75+}
76+
77+// Probe is a helper method to define mock.On call
78+// - ctx context.Context
79+// - url string
80+func (_e *MockHTTPProber_Expecter) Probe(ctx interface{}, url interface{}) *MockHTTPProber_Probe_Call {
81+ return &MockHTTPProber_Probe_Call{Call: _e.mock.On("Probe", ctx, url)}
82+}
83+
84+func (_c *MockHTTPProber_Probe_Call) Run(run func(ctx context.Context, url string)) *MockHTTPProber_Probe_Call {
85+ _c.Call.Run(func(args mock.Arguments) {
86+ var arg0 context.Context
87+ if args[0] != nil {
88+ arg0 = args[0].(context.Context)
89+ }
90+ var arg1 string
91+ if args[1] != nil {
92+ arg1 = args[1].(string)
93+ }
94+ run(
95+ arg0,
96+ arg1,
97+ )
98+ })
99+ return _c
100+}
101+
102+func (_c *MockHTTPProber_Probe_Call) Return(n int, n1 int64, err error) *MockHTTPProber_Probe_Call {
103+ _c.Call.Return(n, n1, err)
104+ return _c
105+}
106+
107+func (_c *MockHTTPProber_Probe_Call) RunAndReturn(run func(ctx context.Context, url string) (int, int64, error)) *MockHTTPProber_Probe_Call {
108+ _c.Call.Return(run)
109+ return _c
110+}
@@ -0,0 +1,110 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ mock "github.com/stretchr/testify/mock"
11+)
12+
13+// NewMockHTTPProber creates a new instance of MockHTTPProber. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14+// The first argument is typically a *testing.T value.
15+func NewMockHTTPProber(t interface {
16+ mock.TestingT
17+ Cleanup(func())
18+}) *MockHTTPProber {
19+ mock := &MockHTTPProber{}
20+ mock.Mock.Test(t)
21+
22+ t.Cleanup(func() { mock.AssertExpectations(t) })
23+
24+ return mock
25+}
26+
27+// MockHTTPProber is an autogenerated mock type for the HTTPProber type
28+type MockHTTPProber struct {
29+ mock.Mock
30+}
31+
32+type MockHTTPProber_Expecter struct {
33+ mock *mock.Mock
34+}
35+
36+func (_m *MockHTTPProber) EXPECT() *MockHTTPProber_Expecter {
37+ return &MockHTTPProber_Expecter{mock: &_m.Mock}
38+}
39+
40+// Probe provides a mock function for the type MockHTTPProber
41+func (_mock *MockHTTPProber) Probe(ctx context.Context, url string) (int, int64, error) {
42+ ret := _mock.Called(ctx, url)
43+
44+ if len(ret) == 0 {
45+ panic("no return value specified for Probe")
46+ }
47+
48+ var r0 int
49+ var r1 int64
50+ var r2 error
51+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (int, int64, error)); ok {
52+ return returnFunc(ctx, url)
53+ }
54+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) int); ok {
55+ r0 = returnFunc(ctx, url)
56+ } else {
57+ r0 = ret.Get(0).(int)
58+ }
59+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) int64); ok {
60+ r1 = returnFunc(ctx, url)
61+ } else {
62+ r1 = ret.Get(1).(int64)
63+ }
64+ if returnFunc, ok := ret.Get(2).(func(context.Context, string) error); ok {
65+ r2 = returnFunc(ctx, url)
66+ } else {
67+ r2 = ret.Error(2)
68+ }
69+ return r0, r1, r2
70+}
71+
72+// MockHTTPProber_Probe_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Probe'
73+type MockHTTPProber_Probe_Call struct {
74+ *mock.Call
75+}
76+
77+// Probe is a helper method to define mock.On call
78+// - ctx context.Context
79+// - url string
80+func (_e *MockHTTPProber_Expecter) Probe(ctx interface{}, url interface{}) *MockHTTPProber_Probe_Call {
81+ return &MockHTTPProber_Probe_Call{Call: _e.mock.On("Probe", ctx, url)}
82+}
83+
84+func (_c *MockHTTPProber_Probe_Call) Run(run func(ctx context.Context, url string)) *MockHTTPProber_Probe_Call {
85+ _c.Call.Run(func(args mock.Arguments) {
86+ var arg0 context.Context
87+ if args[0] != nil {
88+ arg0 = args[0].(context.Context)
89+ }
90+ var arg1 string
91+ if args[1] != nil {
92+ arg1 = args[1].(string)
93+ }
94+ run(
95+ arg0,
96+ arg1,
97+ )
98+ })
99+ return _c
100+}
101+
102+func (_c *MockHTTPProber_Probe_Call) Return(n int, n1 int64, err error) *MockHTTPProber_Probe_Call {
103+ _c.Call.Return(n, n1, err)
104+ return _c
105+}
106+
107+func (_c *MockHTTPProber_Probe_Call) RunAndReturn(run func(ctx context.Context, url string) (int, int64, error)) *MockHTTPProber_Probe_Call {
108+ _c.Call.Return(run)
109+ return _c
110+}
M internal/domain/route.go +10 -0
@@ -28,3 +28,13 @@ type ExternalRoute struct {
2828 Host string // e.g., "localhost"
2929 Port int // e.g., 5000
3030 }
31+
32+// RouteHealth represents the health status of a route.
33+type RouteHealth struct {
34+ Domain string // The route domain
35+ ContainerStatus string // Container state: "running", "stopped", etc.
36+ HTTPStatus int // HTTP status code from probe (0 if unreachable)
37+ ResponseTimeMs int64 // Response time in milliseconds
38+ Healthy bool // True if container running AND HTTP 2xx/3xx
39+ Error string // Error message if probe failed
40+}
@@ -28,3 +28,13 @@ type ExternalRoute struct {
28 Host string // e.g., "localhost"28 Host string // e.g., "localhost"
29 Port int // e.g., 500029 Port int // e.g., 5000
30 }30 }
31+
32+// RouteHealth represents the health status of a route.
33+type RouteHealth struct {
34+ Domain string // The route domain
35+ ContainerStatus string // Container state: "running", "stopped", etc.
36+ HTTPStatus int // HTTP status code from probe (0 if unreachable)
37+ ResponseTimeMs int64 // Response time in milliseconds
38+ Healthy bool // True if container running AND HTTP 2xx/3xx
39+ Error string // Error message if probe failed
40+}
A internal/usecase/health/service.go +127 -0
@@ -0,0 +1,127 @@
1+// Package health implements the health check use case for routes.
2+package health
3+
4+import (
5+ "context"
6+ "fmt"
7+ "sync"
8+
9+ "github.com/bnema/zerowrap"
10+
11+ "gordon/internal/boundaries/in"
12+ "gordon/internal/domain"
13+)
14+
15+// Service implements the HealthService interface.
16+type Service struct {
17+ configSvc in.ConfigService
18+ containerSvc in.ContainerService
19+ prober in.HTTPProber
20+ log zerowrap.Logger
21+}
22+
23+// NewService creates a new health service.
24+func NewService(
25+ configSvc in.ConfigService,
26+ containerSvc in.ContainerService,
27+ prober in.HTTPProber,
28+ log zerowrap.Logger,
29+) *Service {
30+ return &Service{
31+ configSvc: configSvc,
32+ containerSvc: containerSvc,
33+ prober: prober,
34+ log: log,
35+ }
36+}
37+
38+// CheckRoute performs a health check on a single route.
39+func (s *Service) CheckRoute(ctx context.Context, route domain.Route) *domain.RouteHealth {
40+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
41+ zerowrap.FieldLayer: "usecase",
42+ zerowrap.FieldUseCase: "CheckRoute",
43+ "domain": route.Domain,
44+ })
45+ log := zerowrap.FromCtx(ctx)
46+
47+ health := &domain.RouteHealth{
48+ Domain: route.Domain,
49+ ContainerStatus: "unknown",
50+ }
51+
52+ // Check container status
53+ container, exists := s.containerSvc.Get(ctx, route.Domain)
54+ if !exists || container == nil {
55+ health.ContainerStatus = "not found"
56+ health.Error = "container not found"
57+ log.Debug().Msg("container not found")
58+ return health
59+ }
60+
61+ health.ContainerStatus = container.Status
62+
63+ // Only probe HTTP if container is running
64+ if container.Status != string(domain.ContainerStatusRunning) {
65+ health.Error = fmt.Sprintf("container is %s", container.Status)
66+ log.Debug().Str("status", container.Status).Msg("container not running, skipping HTTP probe")
67+ return health
68+ }
69+
70+ // Probe HTTP endpoint
71+ url := fmt.Sprintf("https://%s/", route.Domain)
72+ statusCode, responseTime, err := s.prober.Probe(ctx, url)
73+ if err != nil {
74+ health.Error = err.Error()
75+ log.Debug().Err(err).Str("url", url).Msg("HTTP probe failed")
76+ return health
77+ }
78+
79+ health.HTTPStatus = statusCode
80+ health.ResponseTimeMs = responseTime
81+ health.Healthy = statusCode >= 200 && statusCode < 400
82+
83+ log.Debug().
84+ Int("http_status", statusCode).
85+ Int64("response_time_ms", responseTime).
86+ Bool("healthy", health.Healthy).
87+ Msg("health check complete")
88+
89+ return health
90+}
91+
92+// CheckAllRoutes performs health checks on all configured routes.
93+func (s *Service) CheckAllRoutes(ctx context.Context) map[string]*domain.RouteHealth {
94+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
95+ zerowrap.FieldLayer: "usecase",
96+ zerowrap.FieldUseCase: "CheckAllRoutes",
97+ })
98+ log := zerowrap.FromCtx(ctx)
99+
100+ routes := s.configSvc.GetRoutes(ctx)
101+ results := make(map[string]*domain.RouteHealth, len(routes))
102+
103+ if len(routes) == 0 {
104+ log.Debug().Msg("no routes configured")
105+ return results
106+ }
107+
108+ // Check routes concurrently
109+ var mu sync.Mutex
110+ var wg sync.WaitGroup
111+
112+ for _, route := range routes {
113+ wg.Add(1)
114+ go func(r domain.Route) {
115+ defer wg.Done()
116+ health := s.CheckRoute(ctx, r)
117+ mu.Lock()
118+ results[r.Domain] = health
119+ mu.Unlock()
120+ }(route)
121+ }
122+
123+ wg.Wait()
124+
125+ log.Debug().Int("routes_checked", len(results)).Msg("all health checks complete")
126+ return results
127+}
@@ -0,0 +1,127 @@
1+// Package health implements the health check use case for routes.
2+package health
3+
4+import (
5+ "context"
6+ "fmt"
7+ "sync"
8+
9+ "github.com/bnema/zerowrap"
10+
11+ "gordon/internal/boundaries/in"
12+ "gordon/internal/domain"
13+)
14+
15+// Service implements the HealthService interface.
16+type Service struct {
17+ configSvc in.ConfigService
18+ containerSvc in.ContainerService
19+ prober in.HTTPProber
20+ log zerowrap.Logger
21+}
22+
23+// NewService creates a new health service.
24+func NewService(
25+ configSvc in.ConfigService,
26+ containerSvc in.ContainerService,
27+ prober in.HTTPProber,
28+ log zerowrap.Logger,
29+) *Service {
30+ return &Service{
31+ configSvc: configSvc,
32+ containerSvc: containerSvc,
33+ prober: prober,
34+ log: log,
35+ }
36+}
37+
38+// CheckRoute performs a health check on a single route.
39+func (s *Service) CheckRoute(ctx context.Context, route domain.Route) *domain.RouteHealth {
40+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
41+ zerowrap.FieldLayer: "usecase",
42+ zerowrap.FieldUseCase: "CheckRoute",
43+ "domain": route.Domain,
44+ })
45+ log := zerowrap.FromCtx(ctx)
46+
47+ health := &domain.RouteHealth{
48+ Domain: route.Domain,
49+ ContainerStatus: "unknown",
50+ }
51+
52+ // Check container status
53+ container, exists := s.containerSvc.Get(ctx, route.Domain)
54+ if !exists || container == nil {
55+ health.ContainerStatus = "not found"
56+ health.Error = "container not found"
57+ log.Debug().Msg("container not found")
58+ return health
59+ }
60+
61+ health.ContainerStatus = container.Status
62+
63+ // Only probe HTTP if container is running
64+ if container.Status != string(domain.ContainerStatusRunning) {
65+ health.Error = fmt.Sprintf("container is %s", container.Status)
66+ log.Debug().Str("status", container.Status).Msg("container not running, skipping HTTP probe")
67+ return health
68+ }
69+
70+ // Probe HTTP endpoint
71+ url := fmt.Sprintf("https://%s/", route.Domain)
72+ statusCode, responseTime, err := s.prober.Probe(ctx, url)
73+ if err != nil {
74+ health.Error = err.Error()
75+ log.Debug().Err(err).Str("url", url).Msg("HTTP probe failed")
76+ return health
77+ }
78+
79+ health.HTTPStatus = statusCode
80+ health.ResponseTimeMs = responseTime
81+ health.Healthy = statusCode >= 200 && statusCode < 400
82+
83+ log.Debug().
84+ Int("http_status", statusCode).
85+ Int64("response_time_ms", responseTime).
86+ Bool("healthy", health.Healthy).
87+ Msg("health check complete")
88+
89+ return health
90+}
91+
92+// CheckAllRoutes performs health checks on all configured routes.
93+func (s *Service) CheckAllRoutes(ctx context.Context) map[string]*domain.RouteHealth {
94+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
95+ zerowrap.FieldLayer: "usecase",
96+ zerowrap.FieldUseCase: "CheckAllRoutes",
97+ })
98+ log := zerowrap.FromCtx(ctx)
99+
100+ routes := s.configSvc.GetRoutes(ctx)
101+ results := make(map[string]*domain.RouteHealth, len(routes))
102+
103+ if len(routes) == 0 {
104+ log.Debug().Msg("no routes configured")
105+ return results
106+ }
107+
108+ // Check routes concurrently
109+ var mu sync.Mutex
110+ var wg sync.WaitGroup
111+
112+ for _, route := range routes {
113+ wg.Add(1)
114+ go func(r domain.Route) {
115+ defer wg.Done()
116+ health := s.CheckRoute(ctx, r)
117+ mu.Lock()
118+ results[r.Domain] = health
119+ mu.Unlock()
120+ }(route)
121+ }
122+
123+ wg.Wait()
124+
125+ log.Debug().Int("routes_checked", len(results)).Msg("all health checks complete")
126+ return results
127+}
A internal/usecase/health/service_test.go +184 -0
@@ -0,0 +1,184 @@
1+package health
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/bnema/zerowrap"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/mock"
11+
12+ "gordon/internal/boundaries/in/mocks"
13+ "gordon/internal/domain"
14+)
15+
16+func testLogger() zerowrap.Logger {
17+ return zerowrap.Default()
18+}
19+
20+func TestService_CheckRoute_ContainerNotFound(t *testing.T) {
21+ configSvc := mocks.NewMockConfigService(t)
22+ containerSvc := mocks.NewMockContainerService(t)
23+ prober := mocks.NewMockHTTPProber(t)
24+
25+ containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(nil, false)
26+
27+ svc := NewService(configSvc, containerSvc, prober, testLogger())
28+ route := domain.Route{Domain: "app.example.com", Image: "myapp:latest"}
29+
30+ health := svc.CheckRoute(context.Background(), route)
31+
32+ assert.Equal(t, "app.example.com", health.Domain)
33+ assert.Equal(t, "not found", health.ContainerStatus)
34+ assert.Equal(t, 0, health.HTTPStatus)
35+ assert.False(t, health.Healthy)
36+ assert.Equal(t, "container not found", health.Error)
37+}
38+
39+func TestService_CheckRoute_ContainerNotRunning(t *testing.T) {
40+ configSvc := mocks.NewMockConfigService(t)
41+ containerSvc := mocks.NewMockContainerService(t)
42+ prober := mocks.NewMockHTTPProber(t)
43+
44+ container := &domain.Container{
45+ ID: "abc123",
46+ Status: "stopped",
47+ }
48+ containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(container, true)
49+
50+ svc := NewService(configSvc, containerSvc, prober, testLogger())
51+ route := domain.Route{Domain: "app.example.com", Image: "myapp:latest"}
52+
53+ health := svc.CheckRoute(context.Background(), route)
54+
55+ assert.Equal(t, "app.example.com", health.Domain)
56+ assert.Equal(t, "stopped", health.ContainerStatus)
57+ assert.Equal(t, 0, health.HTTPStatus)
58+ assert.False(t, health.Healthy)
59+ assert.Contains(t, health.Error, "container is stopped")
60+}
61+
62+func TestService_CheckRoute_HTTPProbeSuccess(t *testing.T) {
63+ configSvc := mocks.NewMockConfigService(t)
64+ containerSvc := mocks.NewMockContainerService(t)
65+ prober := mocks.NewMockHTTPProber(t)
66+
67+ container := &domain.Container{
68+ ID: "abc123",
69+ Status: "running",
70+ }
71+ containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(container, true)
72+ prober.EXPECT().Probe(mock.Anything, "https://app.example.com/").Return(200, int64(45), nil)
73+
74+ svc := NewService(configSvc, containerSvc, prober, testLogger())
75+ route := domain.Route{Domain: "app.example.com", Image: "myapp:latest"}
76+
77+ health := svc.CheckRoute(context.Background(), route)
78+
79+ assert.Equal(t, "app.example.com", health.Domain)
80+ assert.Equal(t, "running", health.ContainerStatus)
81+ assert.Equal(t, 200, health.HTTPStatus)
82+ assert.Equal(t, int64(45), health.ResponseTimeMs)
83+ assert.True(t, health.Healthy)
84+ assert.Empty(t, health.Error)
85+}
86+
87+func TestService_CheckRoute_HTTPProbeFailure(t *testing.T) {
88+ configSvc := mocks.NewMockConfigService(t)
89+ containerSvc := mocks.NewMockContainerService(t)
90+ prober := mocks.NewMockHTTPProber(t)
91+
92+ container := &domain.Container{
93+ ID: "abc123",
94+ Status: "running",
95+ }
96+ containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(container, true)
97+ prober.EXPECT().Probe(mock.Anything, "https://app.example.com/").Return(0, int64(0), errors.New("connection refused"))
98+
99+ svc := NewService(configSvc, containerSvc, prober, testLogger())
100+ route := domain.Route{Domain: "app.example.com", Image: "myapp:latest"}
101+
102+ health := svc.CheckRoute(context.Background(), route)
103+
104+ assert.Equal(t, "app.example.com", health.Domain)
105+ assert.Equal(t, "running", health.ContainerStatus)
106+ assert.Equal(t, 0, health.HTTPStatus)
107+ assert.False(t, health.Healthy)
108+ assert.Equal(t, "connection refused", health.Error)
109+}
110+
111+func TestService_CheckRoute_HTTPStatus5xx(t *testing.T) {
112+ configSvc := mocks.NewMockConfigService(t)
113+ containerSvc := mocks.NewMockContainerService(t)
114+ prober := mocks.NewMockHTTPProber(t)
115+
116+ container := &domain.Container{
117+ ID: "abc123",
118+ Status: "running",
119+ }
120+ containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(container, true)
121+ prober.EXPECT().Probe(mock.Anything, "https://app.example.com/").Return(502, int64(100), nil)
122+
123+ svc := NewService(configSvc, containerSvc, prober, testLogger())
124+ route := domain.Route{Domain: "app.example.com", Image: "myapp:latest"}
125+
126+ health := svc.CheckRoute(context.Background(), route)
127+
128+ assert.Equal(t, "app.example.com", health.Domain)
129+ assert.Equal(t, "running", health.ContainerStatus)
130+ assert.Equal(t, 502, health.HTTPStatus)
131+ assert.Equal(t, int64(100), health.ResponseTimeMs)
132+ assert.False(t, health.Healthy) // 5xx is not healthy
133+ assert.Empty(t, health.Error)
134+}
135+
136+func TestService_CheckAllRoutes(t *testing.T) {
137+ configSvc := mocks.NewMockConfigService(t)
138+ containerSvc := mocks.NewMockContainerService(t)
139+ prober := mocks.NewMockHTTPProber(t)
140+
141+ routes := []domain.Route{
142+ {Domain: "app1.example.com", Image: "app1:latest"},
143+ {Domain: "app2.example.com", Image: "app2:latest"},
144+ }
145+ configSvc.EXPECT().GetRoutes(mock.Anything).Return(routes)
146+
147+ container1 := &domain.Container{ID: "abc", Status: "running"}
148+ container2 := &domain.Container{ID: "def", Status: "stopped"}
149+ containerSvc.EXPECT().Get(mock.Anything, "app1.example.com").Return(container1, true)
150+ containerSvc.EXPECT().Get(mock.Anything, "app2.example.com").Return(container2, true)
151+
152+ // Only running containers get probed
153+ prober.EXPECT().Probe(mock.Anything, "https://app1.example.com/").Return(200, int64(30), nil)
154+
155+ svc := NewService(configSvc, containerSvc, prober, testLogger())
156+
157+ results := svc.CheckAllRoutes(context.Background())
158+
159+ assert.Len(t, results, 2)
160+
161+ assert.NotNil(t, results["app1.example.com"])
162+ assert.Equal(t, "running", results["app1.example.com"].ContainerStatus)
163+ assert.Equal(t, 200, results["app1.example.com"].HTTPStatus)
164+ assert.True(t, results["app1.example.com"].Healthy)
165+
166+ assert.NotNil(t, results["app2.example.com"])
167+ assert.Equal(t, "stopped", results["app2.example.com"].ContainerStatus)
168+ assert.Equal(t, 0, results["app2.example.com"].HTTPStatus)
169+ assert.False(t, results["app2.example.com"].Healthy)
170+}
171+
172+func TestService_CheckAllRoutes_NoRoutes(t *testing.T) {
173+ configSvc := mocks.NewMockConfigService(t)
174+ containerSvc := mocks.NewMockContainerService(t)
175+ prober := mocks.NewMockHTTPProber(t)
176+
177+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{})
178+
179+ svc := NewService(configSvc, containerSvc, prober, testLogger())
180+
181+ results := svc.CheckAllRoutes(context.Background())
182+
183+ assert.Empty(t, results)
184+}
@@ -0,0 +1,184 @@
1+package health
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/bnema/zerowrap"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/mock"
11+
12+ "gordon/internal/boundaries/in/mocks"
13+ "gordon/internal/domain"
14+)
15+
16+func testLogger() zerowrap.Logger {
17+ return zerowrap.Default()
18+}
19+
20+func TestService_CheckRoute_ContainerNotFound(t *testing.T) {
21+ configSvc := mocks.NewMockConfigService(t)
22+ containerSvc := mocks.NewMockContainerService(t)
23+ prober := mocks.NewMockHTTPProber(t)
24+
25+ containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(nil, false)
26+
27+ svc := NewService(configSvc, containerSvc, prober, testLogger())
28+ route := domain.Route{Domain: "app.example.com", Image: "myapp:latest"}
29+
30+ health := svc.CheckRoute(context.Background(), route)
31+
32+ assert.Equal(t, "app.example.com", health.Domain)
33+ assert.Equal(t, "not found", health.ContainerStatus)
34+ assert.Equal(t, 0, health.HTTPStatus)
35+ assert.False(t, health.Healthy)
36+ assert.Equal(t, "container not found", health.Error)
37+}
38+
39+func TestService_CheckRoute_ContainerNotRunning(t *testing.T) {
40+ configSvc := mocks.NewMockConfigService(t)
41+ containerSvc := mocks.NewMockContainerService(t)
42+ prober := mocks.NewMockHTTPProber(t)
43+
44+ container := &domain.Container{
45+ ID: "abc123",
46+ Status: "stopped",
47+ }
48+ containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(container, true)
49+
50+ svc := NewService(configSvc, containerSvc, prober, testLogger())
51+ route := domain.Route{Domain: "app.example.com", Image: "myapp:latest"}
52+
53+ health := svc.CheckRoute(context.Background(), route)
54+
55+ assert.Equal(t, "app.example.com", health.Domain)
56+ assert.Equal(t, "stopped", health.ContainerStatus)
57+ assert.Equal(t, 0, health.HTTPStatus)
58+ assert.False(t, health.Healthy)
59+ assert.Contains(t, health.Error, "container is stopped")
60+}
61+
62+func TestService_CheckRoute_HTTPProbeSuccess(t *testing.T) {
63+ configSvc := mocks.NewMockConfigService(t)
64+ containerSvc := mocks.NewMockContainerService(t)
65+ prober := mocks.NewMockHTTPProber(t)
66+
67+ container := &domain.Container{
68+ ID: "abc123",
69+ Status: "running",
70+ }
71+ containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(container, true)
72+ prober.EXPECT().Probe(mock.Anything, "https://app.example.com/").Return(200, int64(45), nil)
73+
74+ svc := NewService(configSvc, containerSvc, prober, testLogger())
75+ route := domain.Route{Domain: "app.example.com", Image: "myapp:latest"}
76+
77+ health := svc.CheckRoute(context.Background(), route)
78+
79+ assert.Equal(t, "app.example.com", health.Domain)
80+ assert.Equal(t, "running", health.ContainerStatus)
81+ assert.Equal(t, 200, health.HTTPStatus)
82+ assert.Equal(t, int64(45), health.ResponseTimeMs)
83+ assert.True(t, health.Healthy)
84+ assert.Empty(t, health.Error)
85+}
86+
87+func TestService_CheckRoute_HTTPProbeFailure(t *testing.T) {
88+ configSvc := mocks.NewMockConfigService(t)
89+ containerSvc := mocks.NewMockContainerService(t)
90+ prober := mocks.NewMockHTTPProber(t)
91+
92+ container := &domain.Container{
93+ ID: "abc123",
94+ Status: "running",
95+ }
96+ containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(container, true)
97+ prober.EXPECT().Probe(mock.Anything, "https://app.example.com/").Return(0, int64(0), errors.New("connection refused"))
98+
99+ svc := NewService(configSvc, containerSvc, prober, testLogger())
100+ route := domain.Route{Domain: "app.example.com", Image: "myapp:latest"}
101+
102+ health := svc.CheckRoute(context.Background(), route)
103+
104+ assert.Equal(t, "app.example.com", health.Domain)
105+ assert.Equal(t, "running", health.ContainerStatus)
106+ assert.Equal(t, 0, health.HTTPStatus)
107+ assert.False(t, health.Healthy)
108+ assert.Equal(t, "connection refused", health.Error)
109+}
110+
111+func TestService_CheckRoute_HTTPStatus5xx(t *testing.T) {
112+ configSvc := mocks.NewMockConfigService(t)
113+ containerSvc := mocks.NewMockContainerService(t)
114+ prober := mocks.NewMockHTTPProber(t)
115+
116+ container := &domain.Container{
117+ ID: "abc123",
118+ Status: "running",
119+ }
120+ containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(container, true)
121+ prober.EXPECT().Probe(mock.Anything, "https://app.example.com/").Return(502, int64(100), nil)
122+
123+ svc := NewService(configSvc, containerSvc, prober, testLogger())
124+ route := domain.Route{Domain: "app.example.com", Image: "myapp:latest"}
125+
126+ health := svc.CheckRoute(context.Background(), route)
127+
128+ assert.Equal(t, "app.example.com", health.Domain)
129+ assert.Equal(t, "running", health.ContainerStatus)
130+ assert.Equal(t, 502, health.HTTPStatus)
131+ assert.Equal(t, int64(100), health.ResponseTimeMs)
132+ assert.False(t, health.Healthy) // 5xx is not healthy
133+ assert.Empty(t, health.Error)
134+}
135+
136+func TestService_CheckAllRoutes(t *testing.T) {
137+ configSvc := mocks.NewMockConfigService(t)
138+ containerSvc := mocks.NewMockContainerService(t)
139+ prober := mocks.NewMockHTTPProber(t)
140+
141+ routes := []domain.Route{
142+ {Domain: "app1.example.com", Image: "app1:latest"},
143+ {Domain: "app2.example.com", Image: "app2:latest"},
144+ }
145+ configSvc.EXPECT().GetRoutes(mock.Anything).Return(routes)
146+
147+ container1 := &domain.Container{ID: "abc", Status: "running"}
148+ container2 := &domain.Container{ID: "def", Status: "stopped"}
149+ containerSvc.EXPECT().Get(mock.Anything, "app1.example.com").Return(container1, true)
150+ containerSvc.EXPECT().Get(mock.Anything, "app2.example.com").Return(container2, true)
151+
152+ // Only running containers get probed
153+ prober.EXPECT().Probe(mock.Anything, "https://app1.example.com/").Return(200, int64(30), nil)
154+
155+ svc := NewService(configSvc, containerSvc, prober, testLogger())
156+
157+ results := svc.CheckAllRoutes(context.Background())
158+
159+ assert.Len(t, results, 2)
160+
161+ assert.NotNil(t, results["app1.example.com"])
162+ assert.Equal(t, "running", results["app1.example.com"].ContainerStatus)
163+ assert.Equal(t, 200, results["app1.example.com"].HTTPStatus)
164+ assert.True(t, results["app1.example.com"].Healthy)
165+
166+ assert.NotNil(t, results["app2.example.com"])
167+ assert.Equal(t, "stopped", results["app2.example.com"].ContainerStatus)
168+ assert.Equal(t, 0, results["app2.example.com"].HTTPStatus)
169+ assert.False(t, results["app2.example.com"].Healthy)
170+}
171+
172+func TestService_CheckAllRoutes_NoRoutes(t *testing.T) {
173+ configSvc := mocks.NewMockConfigService(t)
174+ containerSvc := mocks.NewMockContainerService(t)
175+ prober := mocks.NewMockHTTPProber(t)
176+
177+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{})
178+
179+ svc := NewService(configSvc, containerSvc, prober, testLogger())
180+
181+ results := svc.CheckAllRoutes(context.Background())
182+
183+ assert.Empty(t, results)
184+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close