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
- 7b5e817febad40843db5fab583eb7319f70eb1ba
- author
- bnema · · 2026-01-16 21:42 UTC
- parent
- 82eb0be
- signature
- unsigned
14 files · +836 -33
M .mockery.yaml +2 -0
| @@ -27,3 +27,5 @@ packages: | |||
| 27 | 27 | ProxyService: | |
| 28 | 28 | ConfigService: | |
| 29 | 29 | 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) { | |||
| 243 | 243 | 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 | 272 | // Reload triggers a configuration reload. | |
| 247 | 273 | func (c *Client) Reload(ctx context.Context) error { | |
| 248 | 274 | 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, nil | 243 | 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 ( | |||
| 6 | 6 | "os" | |
| 7 | 7 | "strings" | |
| 8 | 8 | ||
| 9 | + | "gordon/internal/adapters/in/cli/remote" | |
| 9 | 10 | "gordon/internal/adapters/in/cli/ui/components" | |
| 10 | 11 | "gordon/internal/adapters/in/cli/ui/styles" | |
| 11 | 12 | "gordon/internal/domain" | |
| @@ -62,6 +63,27 @@ the local Gordon configuration.`, | |||
| 62 | 63 | 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 | 87 | // newRoutesListCmd creates the routes list command. | |
| 66 | 88 | func newRoutesListCmd() *cobra.Command { | |
| 67 | 89 | return &cobra.Command{ | |
| @@ -87,24 +109,30 @@ func newRoutesListCmd() *cobra.Command { | |||
| 87 | 109 | return nil | |
| 88 | 110 | } | |
| 89 | 111 | ||
| 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) | |
| 95 | 116 | } | |
| 96 | 117 | ||
| 97 | 118 | // Build table rows | |
| 98 | 119 | const imageColWidth = 35 | |
| 99 | 120 | rows := make([][]string, len(routes)) | |
| 100 | 121 | 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 | |
| 104 | 128 | } | |
| 105 | - | statusBadge := components.ContainerStatusBadge(routeStatus) | |
| 129 | + | containerBadge := components.ContainerStatusBadge(containerStatus) | |
| 130 | + | ||
| 131 | + | // HTTP status column | |
| 132 | + | httpStatus := formatHTTPStatus(routeHealth) | |
| 133 | + | ||
| 106 | 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 | 138 | // Render table | |
| @@ -112,7 +140,8 @@ func newRoutesListCmd() *cobra.Command { | |||
| 112 | 140 | components.WithColumns([]components.TableColumn{ | |
| 113 | 141 | {Title: "Domain", Width: 25}, | |
| 114 | 142 | {Title: "Image", Width: imageColWidth}, | |
| 115 | - | {Title: "Status", Width: 12}, | |
| 143 | + | {Title: "Container", Width: 12}, | |
| 144 | + | {Title: "HTTP", Width: 14}, | |
| 116 | 145 | }), | |
| 117 | 146 | components.WithRows(rows), | |
| 118 | 147 | ) | |
| @@ -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 cmd | 63 | 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 nil | 109 | return nil | ||
| 88 | } | 110 | } | ||
| 89 | 111 | ||||
| 90 | - | // Get status for each route | 112 | + | // 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 rows | 118 | // Build table rows | ||
| 98 | const imageColWidth = 35 | 119 | 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 table | 138 | // 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 { | |||
| 18 | 18 | configSvc in.ConfigService | |
| 19 | 19 | authSvc in.AuthService | |
| 20 | 20 | containerSvc in.ContainerService | |
| 21 | + | healthSvc in.HealthService | |
| 21 | 22 | eventBus out.EventPublisher | |
| 22 | 23 | envDir string | |
| 23 | 24 | log zerowrap.Logger | |
| @@ -28,6 +29,7 @@ func NewHandler( | |||
| 28 | 29 | configSvc in.ConfigService, | |
| 29 | 30 | authSvc in.AuthService, | |
| 30 | 31 | containerSvc in.ContainerService, | |
| 32 | + | healthSvc in.HealthService, | |
| 31 | 33 | eventBus out.EventPublisher, | |
| 32 | 34 | envDir string, | |
| 33 | 35 | log zerowrap.Logger, | |
| @@ -36,6 +38,7 @@ func NewHandler( | |||
| 36 | 38 | configSvc: configSvc, | |
| 37 | 39 | authSvc: authSvc, | |
| 38 | 40 | containerSvc: containerSvc, | |
| 41 | + | healthSvc: healthSvc, | |
| 39 | 42 | eventBus: eventBus, | |
| 40 | 43 | envDir: envDir, | |
| 41 | 44 | log: log, | |
| @@ -72,6 +75,8 @@ func (h *Handler) handleAdminRoutes(w http.ResponseWriter, r *http.Request) { | |||
| 72 | 75 | h.handleSecrets(w, r, path) | |
| 73 | 76 | case path == "/status": | |
| 74 | 77 | h.handleStatus(w, r) | |
| 78 | + | case path == "/health": | |
| 79 | + | h.handleHealth(w, r) | |
| 75 | 80 | case path == "/reload": | |
| 76 | 81 | h.handleReload(w, r) | |
| 77 | 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 | 356 | // handleStatus handles /admin/status endpoint. | |
| 319 | 357 | func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) { | |
| 320 | 358 | if r.Method != http.MethodGet { | |
| @@ -18,6 +18,7 @@ type Handler struct { | |||||
| 18 | configSvc in.ConfigService | 18 | configSvc in.ConfigService | ||
| 19 | authSvc in.AuthService | 19 | authSvc in.AuthService | ||
| 20 | containerSvc in.ContainerService | 20 | containerSvc in.ContainerService | ||
| 21 | + | healthSvc in.HealthService | |||
| 21 | eventBus out.EventPublisher | 22 | eventBus out.EventPublisher | ||
| 22 | envDir string | 23 | envDir string | ||
| 23 | log zerowrap.Logger | 24 | 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) { | |||
| 34 | 34 | authSvc := inmocks.NewMockAuthService(t) | |
| 35 | 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 | 39 | tests := []struct { | |
| 40 | 40 | name string | |
| @@ -95,7 +95,7 @@ func TestHandler_RoutesPost_RequiresWriteScope(t *testing.T) { | |||
| 95 | 95 | authSvc := inmocks.NewMockAuthService(t) | |
| 96 | 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 | 100 | routeJSON := `{"domain": "app.example.com", "image": "myapp:latest"}` | |
| 101 | 101 | ||
| @@ -153,7 +153,7 @@ func TestHandler_RoutesPut_RequiresWriteScope(t *testing.T) { | |||
| 153 | 153 | authSvc := inmocks.NewMockAuthService(t) | |
| 154 | 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 | 158 | routeJSON := `{"image": "myapp:v2"}` | |
| 159 | 159 | ||
| @@ -196,7 +196,7 @@ func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) { | |||
| 196 | 196 | authSvc := inmocks.NewMockAuthService(t) | |
| 197 | 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 | 201 | tests := []struct { | |
| 202 | 202 | name string | |
| @@ -242,7 +242,7 @@ func TestHandler_SecretsGet_RequiresReadScope(t *testing.T) { | |||
| 242 | 242 | // Create temp env directory | |
| 243 | 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 | 247 | tests := []struct { | |
| 248 | 248 | name string | |
| @@ -297,7 +297,7 @@ func TestHandler_SecretsPost_RequiresWriteScope(t *testing.T) { | |||
| 297 | 297 | // Create temp env directory | |
| 298 | 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 | 302 | secretsJSON := `{"API_KEY": "secret123"}` | |
| 303 | 303 | ||
| @@ -346,7 +346,7 @@ func TestHandler_SecretsDelete_RequiresWriteScope(t *testing.T) { | |||
| 346 | 346 | envFile := filepath.Join(tmpDir, "app_example_com.env") | |
| 347 | 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 | 351 | tests := []struct { | |
| 352 | 352 | name string | |
| @@ -385,7 +385,7 @@ func TestHandler_Status_RequiresReadScope(t *testing.T) { | |||
| 385 | 385 | authSvc := inmocks.NewMockAuthService(t) | |
| 386 | 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 | 390 | tests := []struct { | |
| 391 | 391 | name string | |
| @@ -438,7 +438,7 @@ func TestHandler_Config_RequiresReadScope(t *testing.T) { | |||
| 438 | 438 | authSvc := inmocks.NewMockAuthService(t) | |
| 439 | 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 | 443 | tests := []struct { | |
| 444 | 444 | name string | |
| @@ -494,7 +494,7 @@ func TestHandler_Reload_RequiresWriteScope(t *testing.T) { | |||
| 494 | 494 | authSvc := inmocks.NewMockAuthService(t) | |
| 495 | 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 | 499 | tests := []struct { | |
| 500 | 500 | name string | |
| @@ -547,7 +547,7 @@ func TestHandler_RoutesGet_ReturnsRoutes(t *testing.T) { | |||
| 547 | 547 | authSvc := inmocks.NewMockAuthService(t) | |
| 548 | 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 | 552 | expectedRoutes := []domain.Route{ | |
| 553 | 553 | {Domain: "app1.example.com", Image: "app1:latest"}, | |
| @@ -577,7 +577,7 @@ func TestHandler_RoutesGet_SingleRoute(t *testing.T) { | |||
| 577 | 577 | authSvc := inmocks.NewMockAuthService(t) | |
| 578 | 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 | 582 | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{ | |
| 583 | 583 | {Domain: "app.example.com", Image: "app:latest"}, | |
| @@ -603,7 +603,7 @@ func TestHandler_RoutesGet_NotFound(t *testing.T) { | |||
| 603 | 603 | authSvc := inmocks.NewMockAuthService(t) | |
| 604 | 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 | 608 | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}) | |
| 609 | 609 | ||
| @@ -621,7 +621,7 @@ func TestHandler_RoutesPost_MissingFields(t *testing.T) { | |||
| 621 | 621 | authSvc := inmocks.NewMockAuthService(t) | |
| 622 | 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 | 626 | tests := []struct { | |
| 627 | 627 | name string | |
| @@ -659,7 +659,7 @@ func TestHandler_RoutesPut_MissingDomain(t *testing.T) { | |||
| 659 | 659 | authSvc := inmocks.NewMockAuthService(t) | |
| 660 | 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 | 664 | req := httptest.NewRequest("PUT", "/admin/routes/", bytes.NewBufferString(`{"image": "app:latest"}`)) | |
| 665 | 665 | req = req.WithContext(ctxWithScopes("admin:routes:write")) | |
| @@ -675,7 +675,7 @@ func TestHandler_RoutesDelete_MissingDomain(t *testing.T) { | |||
| 675 | 675 | authSvc := inmocks.NewMockAuthService(t) | |
| 676 | 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 | 680 | req := httptest.NewRequest("DELETE", "/admin/routes/", nil) | |
| 681 | 681 | req = req.WithContext(ctxWithScopes("admin:routes:write")) | |
| @@ -691,7 +691,7 @@ func TestHandler_Secrets_MissingDomain(t *testing.T) { | |||
| 691 | 691 | authSvc := inmocks.NewMockAuthService(t) | |
| 692 | 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 | 696 | req := httptest.NewRequest("GET", "/admin/secrets/", nil) | |
| 697 | 697 | req = req.WithContext(ctxWithScopes("admin:secrets:read")) | |
| @@ -707,7 +707,7 @@ func TestHandler_SecretsDelete_MissingKey(t *testing.T) { | |||
| 707 | 707 | authSvc := inmocks.NewMockAuthService(t) | |
| 708 | 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 | 712 | req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com", nil) | |
| 713 | 713 | req = req.WithContext(ctxWithScopes("admin:secrets:write")) | |
| @@ -723,7 +723,7 @@ func TestHandler_MethodNotAllowed(t *testing.T) { | |||
| 723 | 723 | authSvc := inmocks.NewMockAuthService(t) | |
| 724 | 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 | 728 | tests := []struct { | |
| 729 | 729 | method string | |
| @@ -755,7 +755,7 @@ func TestHandler_NotFound(t *testing.T) { | |||
| 755 | 755 | authSvc := inmocks.NewMockAuthService(t) | |
| 756 | 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 | 760 | req := httptest.NewRequest("GET", "/admin/unknown", nil) | |
| 761 | 761 | 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 string | 40 | 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 string | 202 | name string | ||
| @@ -242,7 +242,7 @@ func TestHandler_SecretsGet_RequiresReadScope(t *testing.T) { | |||||
| 242 | // Create temp env directory | 242 | // 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 string | 248 | name string | ||
| @@ -297,7 +297,7 @@ func TestHandler_SecretsPost_RequiresWriteScope(t *testing.T) { | |||||
| 297 | // Create temp env directory | 297 | // 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 string | 352 | 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 string | 391 | 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 string | 444 | 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 string | 500 | 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 string | 627 | 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 string | 729 | 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 | |||
| 267 | 267 | ID: c.ID, | |
| 268 | 268 | Image: c.Image, | |
| 269 | 269 | Name: name, | |
| 270 | - | Status: c.Status, | |
| 270 | + | Status: c.State, // Use State (e.g., "running") not Status (e.g., "Up 2 days") | |
| 271 | 271 | Ports: ports, | |
| 272 | 272 | Labels: c.Labels, | |
| 273 | 273 | }) | |
| @@ -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 ( | |||
| 24 | 24 | "gordon/internal/adapters/out/envloader" | |
| 25 | 25 | "gordon/internal/adapters/out/eventbus" | |
| 26 | 26 | "gordon/internal/adapters/out/filesystem" | |
| 27 | + | "gordon/internal/adapters/out/httpprober" | |
| 27 | 28 | "gordon/internal/adapters/out/logwriter" | |
| 28 | 29 | "gordon/internal/adapters/out/secrets" | |
| 29 | 30 | "gordon/internal/adapters/out/tokenstore" | |
| @@ -43,6 +44,7 @@ import ( | |||
| 43 | 44 | "gordon/internal/usecase/auth" | |
| 44 | 45 | "gordon/internal/usecase/config" | |
| 45 | 46 | "gordon/internal/usecase/container" | |
| 47 | + | "gordon/internal/usecase/health" | |
| 46 | 48 | "gordon/internal/usecase/proxy" | |
| 47 | 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 | 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 | 303 | // 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 | 306 | return svc, nil | |
| 301 | 307 | } | |
| @@ -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 API | 303 | // 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, nil | 306 | 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 { | |||
| 28 | 28 | Host string // e.g., "localhost" | |
| 29 | 29 | 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 | + | } | |
| @@ -28,3 +28,13 @@ type ExternalRoute struct { | |||||
| 28 | Host string // e.g., "localhost" | 28 | Host string // e.g., "localhost" | ||
| 29 | Port int // e.g., 5000 | 29 | 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 | + | } | |||