fix(cli): surface route health probe errors
- commit
- 4648a7bbc9a806fe7be4bb36e1dd8b2464cace18
- author
- brice · · 2026-04-14 18:37 UTC
- parent
- 3567896
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +93 -4
M internal/adapters/in/cli/routes.go +21 -4
| @@ -931,7 +931,12 @@ func runRoutesShow(ctx context.Context, cp ControlPlane, out io.Writer, routeDom | |||
| 931 | 931 | return fmt.Errorf("failed to get route: %w", err) | |
| 932 | 932 | } | |
| 933 | 933 | ||
| 934 | - | health, _ := cp.GetHealth(ctx) | |
| 934 | + | health, err := cp.GetHealth(ctx) | |
| 935 | + | healthErr := "" | |
| 936 | + | if err != nil { | |
| 937 | + | healthErr = err.Error() | |
| 938 | + | health = nil | |
| 939 | + | } | |
| 935 | 940 | var routeHealth *remote.RouteHealth | |
| 936 | 941 | if health != nil { | |
| 937 | 942 | routeHealth = health[routeDomain] | |
| @@ -951,19 +956,26 @@ func runRoutesShow(ctx context.Context, cp ControlPlane, out io.Writer, routeDom | |||
| 951 | 956 | "container_status": containerStatus, | |
| 952 | 957 | "http_status": httpStatus, | |
| 953 | 958 | } | |
| 959 | + | if healthErr != "" { | |
| 960 | + | payload["health_error"] = healthErr | |
| 961 | + | } | |
| 954 | 962 | return writeJSON(out, payload) | |
| 955 | 963 | } | |
| 956 | 964 | ||
| 957 | - | if err := cliWriteLine(out, cliRenderTitle("Route: "+route.Domain)); err != nil { | |
| 965 | + | return writeRouteShowText(out, route.Domain, route.Image, containerStatus, httpStatus, healthErr) | |
| 966 | + | } | |
| 967 | + | ||
| 968 | + | func writeRouteShowText(out io.Writer, domainName, image, containerStatus string, httpStatus int, healthErr string) error { | |
| 969 | + | if err := cliWriteLine(out, cliRenderTitle("Route: "+domainName)); err != nil { | |
| 958 | 970 | return err | |
| 959 | 971 | } | |
| 960 | 972 | if err := cliWriteLine(out, ""); err != nil { | |
| 961 | 973 | return err | |
| 962 | 974 | } | |
| 963 | - | if err := cliWriteLine(out, cliRenderMeta("Domain:", route.Domain)); err != nil { | |
| 975 | + | if err := cliWriteLine(out, cliRenderMeta("Domain:", domainName)); err != nil { | |
| 964 | 976 | return err | |
| 965 | 977 | } | |
| 966 | - | if err := cliWriteLine(out, cliRenderMeta("Image:", route.Image)); err != nil { | |
| 978 | + | if err := cliWriteLine(out, cliRenderMeta("Image:", image)); err != nil { | |
| 967 | 979 | return err | |
| 968 | 980 | } | |
| 969 | 981 | if err := cliWriteLine(out, cliRenderMeta("Container:", containerStatus)); err != nil { | |
| @@ -974,6 +986,11 @@ func runRoutesShow(ctx context.Context, cp ControlPlane, out io.Writer, routeDom | |||
| 974 | 986 | return err | |
| 975 | 987 | } | |
| 976 | 988 | } | |
| 989 | + | if healthErr != "" { | |
| 990 | + | if err := cliWriteLine(out, cliRenderWarning(healthErr)); err != nil { | |
| 991 | + | return err | |
| 992 | + | } | |
| 993 | + | } | |
| 977 | 994 | ||
| 978 | 995 | return nil | |
| 979 | 996 | } | |
| @@ -931,7 +931,12 @@ func runRoutesShow(ctx context.Context, cp ControlPlane, out io.Writer, routeDom | |||||
| 931 | return fmt.Errorf("failed to get route: %w", err) | 931 | return fmt.Errorf("failed to get route: %w", err) | ||
| 932 | } | 932 | } | ||
| 933 | 933 | ||||
| 934 | - | health, _ := cp.GetHealth(ctx) | 934 | + | health, err := cp.GetHealth(ctx) |
| 935 | + | healthErr := "" | |||
| 936 | + | if err != nil { | |||
| 937 | + | healthErr = err.Error() | |||
| 938 | + | health = nil | |||
| 939 | + | } | |||
| 935 | var routeHealth *remote.RouteHealth | 940 | var routeHealth *remote.RouteHealth | ||
| 936 | if health != nil { | 941 | if health != nil { | ||
| 937 | routeHealth = health[routeDomain] | 942 | routeHealth = health[routeDomain] | ||
| @@ -951,19 +956,26 @@ func runRoutesShow(ctx context.Context, cp ControlPlane, out io.Writer, routeDom | |||||
| 951 | "container_status": containerStatus, | 956 | "container_status": containerStatus, | ||
| 952 | "http_status": httpStatus, | 957 | "http_status": httpStatus, | ||
| 953 | } | 958 | } | ||
| 959 | + | if healthErr != "" { | |||
| 960 | + | payload["health_error"] = healthErr | |||
| 961 | + | } | |||
| 954 | return writeJSON(out, payload) | 962 | return writeJSON(out, payload) | ||
| 955 | } | 963 | } | ||
| 956 | 964 | ||||
| 957 | - | if err := cliWriteLine(out, cliRenderTitle("Route: "+route.Domain)); err != nil { | 965 | + | return writeRouteShowText(out, route.Domain, route.Image, containerStatus, httpStatus, healthErr) |
| 966 | + | } | |||
| 967 | + | ||||
| 968 | + | func writeRouteShowText(out io.Writer, domainName, image, containerStatus string, httpStatus int, healthErr string) error { | |||
| 969 | + | if err := cliWriteLine(out, cliRenderTitle("Route: "+domainName)); err != nil { | |||
| 958 | return err | 970 | return err | ||
| 959 | } | 971 | } | ||
| 960 | if err := cliWriteLine(out, ""); err != nil { | 972 | if err := cliWriteLine(out, ""); err != nil { | ||
| 961 | return err | 973 | return err | ||
| 962 | } | 974 | } | ||
| 963 | - | if err := cliWriteLine(out, cliRenderMeta("Domain:", route.Domain)); err != nil { | 975 | + | if err := cliWriteLine(out, cliRenderMeta("Domain:", domainName)); err != nil { |
| 964 | return err | 976 | return err | ||
| 965 | } | 977 | } | ||
| 966 | - | if err := cliWriteLine(out, cliRenderMeta("Image:", route.Image)); err != nil { | 978 | + | if err := cliWriteLine(out, cliRenderMeta("Image:", image)); err != nil { |
| 967 | return err | 979 | return err | ||
| 968 | } | 980 | } | ||
| 969 | if err := cliWriteLine(out, cliRenderMeta("Container:", containerStatus)); err != nil { | 981 | if err := cliWriteLine(out, cliRenderMeta("Container:", containerStatus)); err != nil { | ||
| @@ -974,6 +986,11 @@ func runRoutesShow(ctx context.Context, cp ControlPlane, out io.Writer, routeDom | |||||
| 974 | return err | 986 | return err | ||
| 975 | } | 987 | } | ||
| 976 | } | 988 | } | ||
| 989 | + | if healthErr != "" { | |||
| 990 | + | if err := cliWriteLine(out, cliRenderWarning(healthErr)); err != nil { | |||
| 991 | + | return err | |||
| 992 | + | } | |||
| 993 | + | } | |||
| 977 | 994 | ||||
| 978 | return nil | 995 | return nil | ||
| 979 | } | 996 | } | ||
M internal/adapters/in/cli/routes_test.go +72 -0
| @@ -3,6 +3,8 @@ package cli | |||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | 5 | "context" | |
| 6 | + | "encoding/json" | |
| 7 | + | "errors" | |
| 6 | 8 | "os" | |
| 7 | 9 | "path/filepath" | |
| 8 | 10 | "strings" | |
| @@ -12,10 +14,29 @@ import ( | |||
| 12 | 14 | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| 13 | 15 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/components" | |
| 14 | 16 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | |
| 17 | + | "github.com/bnema/gordon/internal/domain" | |
| 15 | 18 | "github.com/stretchr/testify/assert" | |
| 16 | 19 | "github.com/stretchr/testify/require" | |
| 17 | 20 | ) | |
| 18 | 21 | ||
| 22 | + | type routesShowTestControlPlane struct { | |
| 23 | + | resolveFromImageTestControlPlane | |
| 24 | + | getHealth func(context.Context) (map[string]*remote.RouteHealth, error) | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | var _ ControlPlane = (*routesShowTestControlPlane)(nil) | |
| 28 | + | ||
| 29 | + | func (c *routesShowTestControlPlane) ListRoutesWithDetails(context.Context) ([]remote.RouteInfo, error) { | |
| 30 | + | panic("unexpected call") | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func (c *routesShowTestControlPlane) GetHealth(ctx context.Context) (map[string]*remote.RouteHealth, error) { | |
| 34 | + | if c.getHealth != nil { | |
| 35 | + | return c.getHealth(ctx) | |
| 36 | + | } | |
| 37 | + | panic("unexpected call") | |
| 38 | + | } | |
| 39 | + | ||
| 19 | 40 | func TestTruncateImage(t *testing.T) { | |
| 20 | 41 | tests := []struct { | |
| 21 | 42 | name string | |
| @@ -251,6 +272,57 @@ func TestRouteStatusTitle_PreservesProbeFailureError(t *testing.T) { | |||
| 251 | 272 | assert.Equal(t, expected, routeStatusTitle(item)) | |
| 252 | 273 | } | |
| 253 | 274 | ||
| 275 | + | func TestRunRoutesShow_JSONIncludesHealthError(t *testing.T) { | |
| 276 | + | fake := &routesShowTestControlPlane{ | |
| 277 | + | resolveFromImageTestControlPlane: resolveFromImageTestControlPlane{ | |
| 278 | + | getRoute: func(context.Context, string) (*domain.Route, error) { | |
| 279 | + | return &domain.Route{Domain: "app.example.com", Image: "app:latest"}, nil | |
| 280 | + | }, | |
| 281 | + | }, | |
| 282 | + | getHealth: func(context.Context) (map[string]*remote.RouteHealth, error) { | |
| 283 | + | return nil, errors.New("probe failed") | |
| 284 | + | }, | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | var out bytes.Buffer | |
| 288 | + | err := runRoutesShow(context.Background(), fake, &out, "app.example.com", true) | |
| 289 | + | ||
| 290 | + | require.NoError(t, err) | |
| 291 | + | ||
| 292 | + | var got map[string]any | |
| 293 | + | require.NoError(t, json.Unmarshal(out.Bytes(), &got)) | |
| 294 | + | assert.Equal(t, "app.example.com", got["domain"]) | |
| 295 | + | assert.Equal(t, "app:latest", got["image"]) | |
| 296 | + | assert.Equal(t, "unknown", got["container_status"]) | |
| 297 | + | assert.Equal(t, float64(0), got["http_status"]) | |
| 298 | + | assert.Equal(t, "probe failed", got["health_error"]) | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | func TestRunRoutesShow_TextIncludesHealthWarning(t *testing.T) { | |
| 302 | + | fake := &routesShowTestControlPlane{ | |
| 303 | + | resolveFromImageTestControlPlane: resolveFromImageTestControlPlane{ | |
| 304 | + | getRoute: func(context.Context, string) (*domain.Route, error) { | |
| 305 | + | return &domain.Route{Domain: "app.example.com", Image: "app:latest"}, nil | |
| 306 | + | }, | |
| 307 | + | }, | |
| 308 | + | getHealth: func(context.Context) (map[string]*remote.RouteHealth, error) { | |
| 309 | + | return nil, errors.New("probe failed") | |
| 310 | + | }, | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | var out bytes.Buffer | |
| 314 | + | err := runRoutesShow(context.Background(), fake, &out, "app.example.com", false) | |
| 315 | + | ||
| 316 | + | require.NoError(t, err) | |
| 317 | + | ||
| 318 | + | text := stripANSI(out.String()) | |
| 319 | + | assert.Contains(t, text, "Route: app.example.com") | |
| 320 | + | assert.Contains(t, text, "Domain:") | |
| 321 | + | assert.Contains(t, text, "Image:") | |
| 322 | + | assert.Contains(t, text, "Container:") | |
| 323 | + | assert.Contains(t, text, "probe failed") | |
| 324 | + | } | |
| 325 | + | ||
| 254 | 326 | func TestCollectRoutesListSections_DefaultModeIncludesLocalThenSortedRemotes(t *testing.T) { | |
| 255 | 327 | testsDeps := routesListDeps{ | |
| 256 | 328 | explicitRemote: func() (*remote.ResolvedRemote, bool) { | |
| @@ -3,6 +3,8 @@ package cli | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | "context" | 5 | "context" | ||
| 6 | + | "encoding/json" | |||
| 7 | + | "errors" | |||
| 6 | "os" | 8 | "os" | ||
| 7 | "path/filepath" | 9 | "path/filepath" | ||
| 8 | "strings" | 10 | "strings" | ||
| @@ -12,10 +14,29 @@ import ( | |||||
| 12 | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | 14 | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | ||
| 13 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/components" | 15 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/components" | ||
| 14 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | 16 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | ||
| 17 | + | "github.com/bnema/gordon/internal/domain" | |||
| 15 | "github.com/stretchr/testify/assert" | 18 | "github.com/stretchr/testify/assert" | ||
| 16 | "github.com/stretchr/testify/require" | 19 | "github.com/stretchr/testify/require" | ||
| 17 | ) | 20 | ) | ||
| 18 | 21 | ||||
| 22 | + | type routesShowTestControlPlane struct { | |||
| 23 | + | resolveFromImageTestControlPlane | |||
| 24 | + | getHealth func(context.Context) (map[string]*remote.RouteHealth, error) | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | var _ ControlPlane = (*routesShowTestControlPlane)(nil) | |||
| 28 | + | ||||
| 29 | + | func (c *routesShowTestControlPlane) ListRoutesWithDetails(context.Context) ([]remote.RouteInfo, error) { | |||
| 30 | + | panic("unexpected call") | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func (c *routesShowTestControlPlane) GetHealth(ctx context.Context) (map[string]*remote.RouteHealth, error) { | |||
| 34 | + | if c.getHealth != nil { | |||
| 35 | + | return c.getHealth(ctx) | |||
| 36 | + | } | |||
| 37 | + | panic("unexpected call") | |||
| 38 | + | } | |||
| 39 | + | ||||
| 19 | func TestTruncateImage(t *testing.T) { | 40 | func TestTruncateImage(t *testing.T) { | ||
| 20 | tests := []struct { | 41 | tests := []struct { | ||
| 21 | name string | 42 | name string | ||
| @@ -251,6 +272,57 @@ func TestRouteStatusTitle_PreservesProbeFailureError(t *testing.T) { | |||||
| 251 | assert.Equal(t, expected, routeStatusTitle(item)) | 272 | assert.Equal(t, expected, routeStatusTitle(item)) | ||
| 252 | } | 273 | } | ||
| 253 | 274 | ||||
| 275 | + | func TestRunRoutesShow_JSONIncludesHealthError(t *testing.T) { | |||
| 276 | + | fake := &routesShowTestControlPlane{ | |||
| 277 | + | resolveFromImageTestControlPlane: resolveFromImageTestControlPlane{ | |||
| 278 | + | getRoute: func(context.Context, string) (*domain.Route, error) { | |||
| 279 | + | return &domain.Route{Domain: "app.example.com", Image: "app:latest"}, nil | |||
| 280 | + | }, | |||
| 281 | + | }, | |||
| 282 | + | getHealth: func(context.Context) (map[string]*remote.RouteHealth, error) { | |||
| 283 | + | return nil, errors.New("probe failed") | |||
| 284 | + | }, | |||
| 285 | + | } | |||
| 286 | + | ||||
| 287 | + | var out bytes.Buffer | |||
| 288 | + | err := runRoutesShow(context.Background(), fake, &out, "app.example.com", true) | |||
| 289 | + | ||||
| 290 | + | require.NoError(t, err) | |||
| 291 | + | ||||
| 292 | + | var got map[string]any | |||
| 293 | + | require.NoError(t, json.Unmarshal(out.Bytes(), &got)) | |||
| 294 | + | assert.Equal(t, "app.example.com", got["domain"]) | |||
| 295 | + | assert.Equal(t, "app:latest", got["image"]) | |||
| 296 | + | assert.Equal(t, "unknown", got["container_status"]) | |||
| 297 | + | assert.Equal(t, float64(0), got["http_status"]) | |||
| 298 | + | assert.Equal(t, "probe failed", got["health_error"]) | |||
| 299 | + | } | |||
| 300 | + | ||||
| 301 | + | func TestRunRoutesShow_TextIncludesHealthWarning(t *testing.T) { | |||
| 302 | + | fake := &routesShowTestControlPlane{ | |||
| 303 | + | resolveFromImageTestControlPlane: resolveFromImageTestControlPlane{ | |||
| 304 | + | getRoute: func(context.Context, string) (*domain.Route, error) { | |||
| 305 | + | return &domain.Route{Domain: "app.example.com", Image: "app:latest"}, nil | |||
| 306 | + | }, | |||
| 307 | + | }, | |||
| 308 | + | getHealth: func(context.Context) (map[string]*remote.RouteHealth, error) { | |||
| 309 | + | return nil, errors.New("probe failed") | |||
| 310 | + | }, | |||
| 311 | + | } | |||
| 312 | + | ||||
| 313 | + | var out bytes.Buffer | |||
| 314 | + | err := runRoutesShow(context.Background(), fake, &out, "app.example.com", false) | |||
| 315 | + | ||||
| 316 | + | require.NoError(t, err) | |||
| 317 | + | ||||
| 318 | + | text := stripANSI(out.String()) | |||
| 319 | + | assert.Contains(t, text, "Route: app.example.com") | |||
| 320 | + | assert.Contains(t, text, "Domain:") | |||
| 321 | + | assert.Contains(t, text, "Image:") | |||
| 322 | + | assert.Contains(t, text, "Container:") | |||
| 323 | + | assert.Contains(t, text, "probe failed") | |||
| 324 | + | } | |||
| 325 | + | ||||
| 254 | func TestCollectRoutesListSections_DefaultModeIncludesLocalThenSortedRemotes(t *testing.T) { | 326 | func TestCollectRoutesListSections_DefaultModeIncludesLocalThenSortedRemotes(t *testing.T) { | ||
| 255 | testsDeps := routesListDeps{ | 327 | testsDeps := routesListDeps{ | ||
| 256 | explicitRemote: func() (*remote.ResolvedRemote, bool) { | 328 | explicitRemote: func() (*remote.ResolvedRemote, bool) { | ||