detached 29621a4

Improve deploy wait UX and runtime-agnostic healthcheck detection

commit
29621a4014404dd24d46d87119e8e6442e751053
author
brice · · 2026-02-08 16:46 UTC
parent
3630968
signature
unsigned
5 files · +202 -5
M internal/adapters/in/cli/push.go +55 -1
@@ -7,7 +7,9 @@ import (
77 "os/exec"
88 "regexp"
99 "strings"
10+ "time"
1011
12+ "github.com/charmbracelet/lipgloss"
1113 "github.com/spf13/cobra"
1214
1315 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
@@ -236,7 +238,7 @@ func deployAfterPush(ctx context.Context, client *remote.Client, pushDomain stri
236238 }
237239 }
238240
239- result, err := client.Deploy(ctx, pushDomain)
241+ result, err := deployWithSpinner(ctx, client, pushDomain)
240242 if err != nil {
241243 return fmt.Errorf("failed to deploy: %w", err)
242244 }
@@ -248,6 +250,58 @@ func deployAfterPush(ctx context.Context, client *remote.Client, pushDomain stri
248250 return nil
249251 }
250252
253+func deployWithSpinner(ctx context.Context, client *remote.Client, pushDomain string) (*remote.DeployResult, error) {
254+ if !isInteractiveTerminal() {
255+ fmt.Printf("Deploying %s...\n", pushDomain)
256+ return client.Deploy(ctx, pushDomain)
257+ }
258+
259+ type deployOutcome struct {
260+ result *remote.DeployResult
261+ err error
262+ }
263+
264+ done := make(chan deployOutcome, 1)
265+ go func() {
266+ result, err := client.Deploy(ctx, pushDomain)
267+ done <- deployOutcome{result: result, err: err}
268+ }()
269+
270+ frames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
271+ frameStyle := lipgloss.NewStyle().Foreground(styles.ColorPrimary)
272+ message := styles.Theme.Muted.Render(fmt.Sprintf("Deploying %s...", pushDomain))
273+
274+ ticker := time.NewTicker(90 * time.Millisecond)
275+ defer ticker.Stop()
276+
277+ frame := 0
278+ for {
279+ select {
280+ case out := <-done:
281+ fmt.Print("\r\033[K")
282+ return out.result, out.err
283+ case <-ticker.C:
284+ fmt.Printf("\r%s %s", frameStyle.Render(frames[frame%len(frames)]), message)
285+ frame++
286+ case <-ctx.Done():
287+ fmt.Print("\r\033[K")
288+ return nil, ctx.Err()
289+ }
290+ }
291+}
292+
293+func isInteractiveTerminal() bool {
294+ term := os.Getenv("TERM")
295+ if term == "" || term == "dumb" {
296+ return false
297+ }
298+ info, err := os.Stdout.Stat()
299+ if err != nil {
300+ return false
301+ }
302+ return (info.Mode() & os.ModeCharDevice) != 0
303+}
304+
251305 // parseImageRef splits "registry/name:tag" into components.
252306 func parseImageRef(image string) (registry, name, tag string) {
253307 // Handle "reg.bnema.dev/jp-bot:latest" or "reg.bnema.dev/jp-bot"
@@ -7,7 +7,9 @@ import (
7 "os/exec"7 "os/exec"
8 "regexp"8 "regexp"
9 "strings"9 "strings"
10+ "time"
10 11
12+ "github.com/charmbracelet/lipgloss"
11 "github.com/spf13/cobra"13 "github.com/spf13/cobra"
12 14
13 "github.com/bnema/gordon/internal/adapters/in/cli/remote"15 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
@@ -236,7 +238,7 @@ func deployAfterPush(ctx context.Context, client *remote.Client, pushDomain stri
236 }238 }
237 }239 }
238 240
239- result, err := client.Deploy(ctx, pushDomain)241+ result, err := deployWithSpinner(ctx, client, pushDomain)
240 if err != nil {242 if err != nil {
241 return fmt.Errorf("failed to deploy: %w", err)243 return fmt.Errorf("failed to deploy: %w", err)
242 }244 }
@@ -248,6 +250,58 @@ func deployAfterPush(ctx context.Context, client *remote.Client, pushDomain stri
248 return nil250 return nil
249 }251 }
250 252
253+func deployWithSpinner(ctx context.Context, client *remote.Client, pushDomain string) (*remote.DeployResult, error) {
254+ if !isInteractiveTerminal() {
255+ fmt.Printf("Deploying %s...\n", pushDomain)
256+ return client.Deploy(ctx, pushDomain)
257+ }
258+
259+ type deployOutcome struct {
260+ result *remote.DeployResult
261+ err error
262+ }
263+
264+ done := make(chan deployOutcome, 1)
265+ go func() {
266+ result, err := client.Deploy(ctx, pushDomain)
267+ done <- deployOutcome{result: result, err: err}
268+ }()
269+
270+ frames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
271+ frameStyle := lipgloss.NewStyle().Foreground(styles.ColorPrimary)
272+ message := styles.Theme.Muted.Render(fmt.Sprintf("Deploying %s...", pushDomain))
273+
274+ ticker := time.NewTicker(90 * time.Millisecond)
275+ defer ticker.Stop()
276+
277+ frame := 0
278+ for {
279+ select {
280+ case out := <-done:
281+ fmt.Print("\r\033[K")
282+ return out.result, out.err
283+ case <-ticker.C:
284+ fmt.Printf("\r%s %s", frameStyle.Render(frames[frame%len(frames)]), message)
285+ frame++
286+ case <-ctx.Done():
287+ fmt.Print("\r\033[K")
288+ return nil, ctx.Err()
289+ }
290+ }
291+}
292+
293+func isInteractiveTerminal() bool {
294+ term := os.Getenv("TERM")
295+ if term == "" || term == "dumb" {
296+ return false
297+ }
298+ info, err := os.Stdout.Stat()
299+ if err != nil {
300+ return false
301+ }
302+ return (info.Mode() & os.ModeCharDevice) != 0
303+}
304+
251 // parseImageRef splits "registry/name:tag" into components.305 // parseImageRef splits "registry/name:tag" into components.
252 func parseImageRef(image string) (registry, name, tag string) {306 func parseImageRef(image string) (registry, name, tag string) {
253 // Handle "reg.bnema.dev/jp-bot:latest" or "reg.bnema.dev/jp-bot"307 // Handle "reg.bnema.dev/jp-bot:latest" or "reg.bnema.dev/jp-bot"
M internal/adapters/out/docker/runtime.go +17 -1
@@ -689,13 +689,29 @@ func (r *Runtime) GetContainerHealthStatus(ctx context.Context, containerID stri
689689 if err != nil {
690690 return "", false, log.WrapErr(err, "failed to inspect container")
691691 }
692- if resp.State == nil || resp.State.Health == nil {
692+
693+ // Detect configured healthchecks from container config rather than relying on
694+ // State.Health presence. Some runtimes can expose an empty State.Health struct
695+ // even when no healthcheck is configured.
696+ hasHealthcheck := hasConfiguredHealthcheck(resp.Config)
697+ if !hasHealthcheck {
693698 return "", false, nil
694699 }
700+ if resp.State == nil || resp.State.Health == nil {
701+ return "", true, nil
702+ }
695703
696704 return resp.State.Health.Status, true, nil
697705 }
698706
707+func hasConfiguredHealthcheck(cfg *container.Config) bool {
708+ if cfg == nil || cfg.Healthcheck == nil || len(cfg.Healthcheck.Test) == 0 {
709+ return false
710+ }
711+ // Docker uses ["NONE"] to explicitly disable healthchecks inherited from base images.
712+ return !strings.EqualFold(strings.TrimSpace(cfg.Healthcheck.Test[0]), "NONE")
713+}
714+
699715 // GetContainerPort gets the host port for a container's internal port.
700716 func (r *Runtime) GetContainerPort(ctx context.Context, containerID string, internalPort int) (int, error) {
701717 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -689,13 +689,29 @@ func (r *Runtime) GetContainerHealthStatus(ctx context.Context, containerID stri
689 if err != nil {689 if err != nil {
690 return "", false, log.WrapErr(err, "failed to inspect container")690 return "", false, log.WrapErr(err, "failed to inspect container")
691 }691 }
692- if resp.State == nil || resp.State.Health == nil {692+
693+ // Detect configured healthchecks from container config rather than relying on
694+ // State.Health presence. Some runtimes can expose an empty State.Health struct
695+ // even when no healthcheck is configured.
696+ hasHealthcheck := hasConfiguredHealthcheck(resp.Config)
697+ if !hasHealthcheck {
693 return "", false, nil698 return "", false, nil
694 }699 }
700+ if resp.State == nil || resp.State.Health == nil {
701+ return "", true, nil
702+ }
695 703
696 return resp.State.Health.Status, true, nil704 return resp.State.Health.Status, true, nil
697 }705 }
698 706
707+func hasConfiguredHealthcheck(cfg *container.Config) bool {
708+ if cfg == nil || cfg.Healthcheck == nil || len(cfg.Healthcheck.Test) == 0 {
709+ return false
710+ }
711+ // Docker uses ["NONE"] to explicitly disable healthchecks inherited from base images.
712+ return !strings.EqualFold(strings.TrimSpace(cfg.Healthcheck.Test[0]), "NONE")
713+}
714+
699 // GetContainerPort gets the host port for a container's internal port.715 // GetContainerPort gets the host port for a container's internal port.
700 func (r *Runtime) GetContainerPort(ctx context.Context, containerID string, internalPort int) (int, error) {716 func (r *Runtime) GetContainerPort(ctx context.Context, containerID string, internalPort int) (int, error) {
701 ctx = zerowrap.CtxWithFields(ctx, map[string]any{717 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
A internal/adapters/out/docker/runtime_health_status_test.go +77 -0
@@ -0,0 +1,77 @@
1+package docker
2+
3+import (
4+ "context"
5+ "net/http"
6+ "net/http/httptest"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestRuntime_GetContainerHealthStatus_ConfiguredHealthcheck(t *testing.T) {
14+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
15+ assert.Equal(t, http.MethodGet, r.Method)
16+ assert.Equal(t, "/v1.41/containers/test-container/json", r.URL.Path)
17+
18+ w.Header().Set("Content-Type", "application/json")
19+ _, _ = w.Write([]byte(`{
20+ "Id":"test-container",
21+ "State":{"Status":"running","Health":{"Status":"healthy","FailingStreak":0,"Log":[]}},
22+ "Config":{"Healthcheck":{"Test":["CMD-SHELL","curl -f http://localhost/health || exit 1"]}}
23+ }`))
24+ }))
25+ defer server.Close()
26+
27+ runtime := newRuntimeForHTTPServer(t, server)
28+ status, hasHealthcheck, err := runtime.GetContainerHealthStatus(context.Background(), "test-container")
29+
30+ require.NoError(t, err)
31+ assert.True(t, hasHealthcheck)
32+ assert.Equal(t, "healthy", status)
33+}
34+
35+func TestRuntime_GetContainerHealthStatus_NoConfiguredHealthcheckWithStateHealthObject(t *testing.T) {
36+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
37+ assert.Equal(t, http.MethodGet, r.Method)
38+ assert.Equal(t, "/v1.41/containers/test-container/json", r.URL.Path)
39+
40+ w.Header().Set("Content-Type", "application/json")
41+ _, _ = w.Write([]byte(`{
42+ "Id":"test-container",
43+ "State":{"Status":"running","Health":{"Status":"","FailingStreak":0,"Log":null}},
44+ "Config":{"Healthcheck":null}
45+ }`))
46+ }))
47+ defer server.Close()
48+
49+ runtime := newRuntimeForHTTPServer(t, server)
50+ status, hasHealthcheck, err := runtime.GetContainerHealthStatus(context.Background(), "test-container")
51+
52+ require.NoError(t, err)
53+ assert.False(t, hasHealthcheck)
54+ assert.Equal(t, "", status)
55+}
56+
57+func TestRuntime_GetContainerHealthStatus_DisabledHealthcheck(t *testing.T) {
58+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
59+ assert.Equal(t, http.MethodGet, r.Method)
60+ assert.Equal(t, "/v1.41/containers/test-container/json", r.URL.Path)
61+
62+ w.Header().Set("Content-Type", "application/json")
63+ _, _ = w.Write([]byte(`{
64+ "Id":"test-container",
65+ "State":{"Status":"running","Health":{"Status":"starting","FailingStreak":0,"Log":[]}},
66+ "Config":{"Healthcheck":{"Test":["NONE"]}}
67+ }`))
68+ }))
69+ defer server.Close()
70+
71+ runtime := newRuntimeForHTTPServer(t, server)
72+ status, hasHealthcheck, err := runtime.GetContainerHealthStatus(context.Background(), "test-container")
73+
74+ require.NoError(t, err)
75+ assert.False(t, hasHealthcheck)
76+ assert.Equal(t, "", status)
77+}
@@ -0,0 +1,77 @@
1+package docker
2+
3+import (
4+ "context"
5+ "net/http"
6+ "net/http/httptest"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestRuntime_GetContainerHealthStatus_ConfiguredHealthcheck(t *testing.T) {
14+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
15+ assert.Equal(t, http.MethodGet, r.Method)
16+ assert.Equal(t, "/v1.41/containers/test-container/json", r.URL.Path)
17+
18+ w.Header().Set("Content-Type", "application/json")
19+ _, _ = w.Write([]byte(`{
20+ "Id":"test-container",
21+ "State":{"Status":"running","Health":{"Status":"healthy","FailingStreak":0,"Log":[]}},
22+ "Config":{"Healthcheck":{"Test":["CMD-SHELL","curl -f http://localhost/health || exit 1"]}}
23+ }`))
24+ }))
25+ defer server.Close()
26+
27+ runtime := newRuntimeForHTTPServer(t, server)
28+ status, hasHealthcheck, err := runtime.GetContainerHealthStatus(context.Background(), "test-container")
29+
30+ require.NoError(t, err)
31+ assert.True(t, hasHealthcheck)
32+ assert.Equal(t, "healthy", status)
33+}
34+
35+func TestRuntime_GetContainerHealthStatus_NoConfiguredHealthcheckWithStateHealthObject(t *testing.T) {
36+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
37+ assert.Equal(t, http.MethodGet, r.Method)
38+ assert.Equal(t, "/v1.41/containers/test-container/json", r.URL.Path)
39+
40+ w.Header().Set("Content-Type", "application/json")
41+ _, _ = w.Write([]byte(`{
42+ "Id":"test-container",
43+ "State":{"Status":"running","Health":{"Status":"","FailingStreak":0,"Log":null}},
44+ "Config":{"Healthcheck":null}
45+ }`))
46+ }))
47+ defer server.Close()
48+
49+ runtime := newRuntimeForHTTPServer(t, server)
50+ status, hasHealthcheck, err := runtime.GetContainerHealthStatus(context.Background(), "test-container")
51+
52+ require.NoError(t, err)
53+ assert.False(t, hasHealthcheck)
54+ assert.Equal(t, "", status)
55+}
56+
57+func TestRuntime_GetContainerHealthStatus_DisabledHealthcheck(t *testing.T) {
58+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
59+ assert.Equal(t, http.MethodGet, r.Method)
60+ assert.Equal(t, "/v1.41/containers/test-container/json", r.URL.Path)
61+
62+ w.Header().Set("Content-Type", "application/json")
63+ _, _ = w.Write([]byte(`{
64+ "Id":"test-container",
65+ "State":{"Status":"running","Health":{"Status":"starting","FailingStreak":0,"Log":[]}},
66+ "Config":{"Healthcheck":{"Test":["NONE"]}}
67+ }`))
68+ }))
69+ defer server.Close()
70+
71+ runtime := newRuntimeForHTTPServer(t, server)
72+ status, hasHealthcheck, err := runtime.GetContainerHealthStatus(context.Background(), "test-container")
73+
74+ require.NoError(t, err)
75+ assert.False(t, hasHealthcheck)
76+ assert.Equal(t, "", status)
77+}
M internal/usecase/container/service.go +6 -3
@@ -1999,12 +1999,15 @@ func (s *Service) waitForHealthy(ctx context.Context, containerID string, timeou
19991999 if !hasHealthcheck {
20002000 return errors.New("no healthcheck detected")
20012001 }
2002+ // Treat empty health status as transitional startup. Some runtimes can
2003+ // temporarily report an empty status before first probe results.
2004+ if status == "" {
2005+ status = "starting"
2006+ }
20022007 if status == "healthy" {
20032008 return nil
20042009 }
2005- if status != "" {
2006- lastStatus = status
2007- }
2010+ lastStatus = status
20082011
20092012 select {
20102013 case <-time.After(time.Second):
@@ -1999,12 +1999,15 @@ func (s *Service) waitForHealthy(ctx context.Context, containerID string, timeou
1999 if !hasHealthcheck {1999 if !hasHealthcheck {
2000 return errors.New("no healthcheck detected")2000 return errors.New("no healthcheck detected")
2001 }2001 }
2002+ // Treat empty health status as transitional startup. Some runtimes can
2003+ // temporarily report an empty status before first probe results.
2004+ if status == "" {
2005+ status = "starting"
2006+ }
2002 if status == "healthy" {2007 if status == "healthy" {
2003 return nil2008 return nil
2004 }2009 }
2005- if status != "" {2010+ lastStatus = status
2006- lastStatus = status
2007- }
2008 2011
2009 select {2012 select {
2010 case <-time.After(time.Second):2013 case <-time.After(time.Second):
A internal/usecase/container/service_readiness_test.go +47 -0
@@ -0,0 +1,47 @@
1+package container
2+
3+import (
4+ "strings"
5+ "testing"
6+ "time"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/mock"
10+
11+ "github.com/bnema/gordon/internal/boundaries/out/mocks"
12+)
13+
14+func TestService_WaitForReady_AutoFallsBackToDelayWhenNoHealthcheck(t *testing.T) {
15+ runtime := mocks.NewMockContainerRuntime(t)
16+ svc := NewService(runtime, nil, nil, nil, Config{
17+ ReadinessMode: "auto",
18+ ReadinessDelay: time.Millisecond,
19+ })
20+
21+ ctx := testContext()
22+ containerID := "container-1"
23+
24+ // Initial running poll
25+ runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()
26+ // Auto mode probes for healthcheck support first
27+ runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once()
28+ // Delay-mode fallback verification
29+ runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()
30+
31+ err := svc.waitForReady(ctx, containerID)
32+ assert.NoError(t, err)
33+}
34+
35+func TestService_WaitForHealthy_EmptyStatusIsReportedAsStarting(t *testing.T) {
36+ runtime := mocks.NewMockContainerRuntime(t)
37+ svc := NewService(runtime, nil, nil, nil, Config{})
38+
39+ ctx := testContext()
40+ containerID := "container-1"
41+
42+ runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", true, nil).Once()
43+
44+ err := svc.waitForHealthy(ctx, containerID, 10*time.Millisecond)
45+ assert.Error(t, err)
46+ assert.True(t, strings.Contains(err.Error(), "last status: starting"))
47+}
@@ -0,0 +1,47 @@
1+package container
2+
3+import (
4+ "strings"
5+ "testing"
6+ "time"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/mock"
10+
11+ "github.com/bnema/gordon/internal/boundaries/out/mocks"
12+)
13+
14+func TestService_WaitForReady_AutoFallsBackToDelayWhenNoHealthcheck(t *testing.T) {
15+ runtime := mocks.NewMockContainerRuntime(t)
16+ svc := NewService(runtime, nil, nil, nil, Config{
17+ ReadinessMode: "auto",
18+ ReadinessDelay: time.Millisecond,
19+ })
20+
21+ ctx := testContext()
22+ containerID := "container-1"
23+
24+ // Initial running poll
25+ runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()
26+ // Auto mode probes for healthcheck support first
27+ runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once()
28+ // Delay-mode fallback verification
29+ runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()
30+
31+ err := svc.waitForReady(ctx, containerID)
32+ assert.NoError(t, err)
33+}
34+
35+func TestService_WaitForHealthy_EmptyStatusIsReportedAsStarting(t *testing.T) {
36+ runtime := mocks.NewMockContainerRuntime(t)
37+ svc := NewService(runtime, nil, nil, nil, Config{})
38+
39+ ctx := testContext()
40+ containerID := "container-1"
41+
42+ runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", true, nil).Once()
43+
44+ err := svc.waitForHealthy(ctx, containerID, 10*time.Millisecond)
45+ assert.Error(t, err)
46+ assert.True(t, strings.Contains(err.Error(), "last status: starting"))
47+}

Keyboard shortcuts

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

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

Branches 7

Tags 106

↑↓ moveenter openesc close