detached e0362ee

refactor: trim DeployIntent input, extract probeLoop helper, fix test drain configs

- DeployIntent: assign trimmed imageName before validation so
  url.PathEscape never encodes leading/trailing whitespace
- readiness: extract probeLoop orchestration helper shared by
  tcpProbe and httpProbe, reducing cyclomatic complexity below
  the gocyclo threshold (15)
- service_test: add DrainDelayConfigured: true to 14 test config
  blocks that were missing it, preventing silent 2s drain default
commit
e0362ee8cd4a34f2740d9d3d12c81da125e37831
author
brice · · 2026-03-05 05:08 UTC
parent
5c05b34
signature
unsigned
3 files · +121 -107
M internal/adapters/in/cli/remote/client.go +2 -1
@@ -727,7 +727,8 @@ func (c *Client) Deploy(ctx context.Context, deployDomain string) (*DeployResult
727727 // DeployIntent tells the server that a CLI-managed push is about to happen,
728728 // suppressing event-based deploys for this image.
729729 func (c *Client) DeployIntent(ctx context.Context, imageName string) error {
730- if strings.TrimSpace(imageName) == "" {
730+ imageName = strings.TrimSpace(imageName)
731+ if imageName == "" {
731732 return fmt.Errorf("image name cannot be empty")
732733 }
733734 resp, err := c.requestWithRetry(ctx, http.MethodPost, "/deploy-intent/"+url.PathEscape(imageName), nil)
@@ -727,7 +727,8 @@ func (c *Client) Deploy(ctx context.Context, deployDomain string) (*DeployResult
727 // DeployIntent tells the server that a CLI-managed push is about to happen,727 // DeployIntent tells the server that a CLI-managed push is about to happen,
728 // suppressing event-based deploys for this image.728 // suppressing event-based deploys for this image.
729 func (c *Client) DeployIntent(ctx context.Context, imageName string) error {729 func (c *Client) DeployIntent(ctx context.Context, imageName string) error {
730- if strings.TrimSpace(imageName) == "" {730+ imageName = strings.TrimSpace(imageName)
731+ if imageName == "" {
731 return fmt.Errorf("image name cannot be empty")732 return fmt.Errorf("image name cannot be empty")
732 }733 }
733 resp, err := c.requestWithRetry(ctx, http.MethodPost, "/deploy-intent/"+url.PathEscape(imageName), nil)734 resp, err := c.requestWithRetry(ctx, http.MethodPost, "/deploy-intent/"+url.PathEscape(imageName), nil)
M internal/usecase/container/readiness.go +67 -68
@@ -2,6 +2,7 @@ package container
22
33 import (
44 "context"
5+ "errors"
56 "fmt"
67 "net"
78 "net/http"
@@ -10,43 +11,40 @@ import (
1011 "github.com/bnema/zerowrap"
1112 )
1213
13-// tcpProbe attempts a TCP connection to addr, retrying every 500ms until
14-// success or timeout. This is the universal fallback readiness check —
15-// it verifies the process is at least accepting connections.
16-func tcpProbe(ctx context.Context, addr string, timeout time.Duration) error {
17- log := zerowrap.FromCtx(ctx)
18- deadline := time.Now().Add(timeout)
19- dialer := &net.Dialer{}
20- attempts := 0
21- var lastErr error
14+var errProbeTimeout = errors.New("probe timeout")
15+
16+func probeLoop(
17+ ctx context.Context,
18+ deadline time.Time,
19+ baseSleep time.Duration,
20+ defaultAttemptTimeout time.Duration,
21+ work func(attemptCtx context.Context) (success bool, err error),
22+) error {
2223 for {
2324 if err := ctx.Err(); err != nil {
2425 return err
2526 }
2627 if time.Now().After(deadline) {
27- return fmt.Errorf("TCP probe timeout after %s: %s not reachable (attempts=%d, last_error=%v)", timeout, addr, attempts, lastErr)
28+ return errProbeTimeout
2829 }
2930
3031 remaining := time.Until(deadline)
31- attemptTimeout := time.Second
32+ attemptTimeout := defaultAttemptTimeout
3233 if remaining < attemptTimeout {
3334 attemptTimeout = remaining
3435 }
36+
3537 attemptCtx, cancel := context.WithTimeout(ctx, attemptTimeout)
36- conn, err := dialer.DialContext(attemptCtx, "tcp", addr)
38+ success, err := work(attemptCtx)
3739 cancel()
38- attempts++
39- if err == nil {
40- conn.Close()
41- log.Debug().Str("addr", addr).Int("attempts", attempts).Msg("TCP probe connected")
42- return nil
40+ if err != nil {
41+ return err
4342 }
44- lastErr = err
45- if attempts <= 3 || attempts%10 == 0 {
46- log.Debug().Err(err).Str("addr", addr).Int("attempt", attempts).Msg("TCP probe attempt failed")
43+ if success {
44+ return nil
4745 }
4846
49- sleepInterval := 500 * time.Millisecond
47+ sleepInterval := baseSleep
5048 if remProbe := time.Until(deadline); remProbe < sleepInterval {
5149 sleepInterval = remProbe
5250 }
@@ -59,8 +57,9 @@ func tcpProbe(ctx context.Context, addr string, timeout time.Duration) error {
5957 if ctx.Err() != nil {
6058 return ctx.Err()
6159 }
62- return fmt.Errorf("TCP probe timeout after %s: %s not reachable (attempts=%d, last_error=%v)", timeout, addr, attempts, lastErr)
60+ return errProbeTimeout
6361 }
62+
6463 t := time.NewTimer(sleepInterval)
6564 select {
6665 case <-t.C:
@@ -71,6 +70,36 @@ func tcpProbe(ctx context.Context, addr string, timeout time.Duration) error {
7170 }
7271 }
7372
73+// tcpProbe attempts a TCP connection to addr, retrying every 500ms until
74+// success or timeout. This is the universal fallback readiness check —
75+// it verifies the process is at least accepting connections.
76+func tcpProbe(ctx context.Context, addr string, timeout time.Duration) error {
77+ log := zerowrap.FromCtx(ctx)
78+ deadline := time.Now().Add(timeout)
79+ dialer := &net.Dialer{}
80+ attempts := 0
81+ var lastErr error
82+ err := probeLoop(ctx, deadline, 500*time.Millisecond, time.Second, func(attemptCtx context.Context) (bool, error) {
83+ conn, err := dialer.DialContext(attemptCtx, "tcp", addr)
84+ attempts++
85+ if err == nil {
86+ conn.Close()
87+ log.Debug().Str("addr", addr).Int("attempts", attempts).Msg("TCP probe connected")
88+ return true, nil
89+ }
90+
91+ lastErr = err
92+ if attempts <= 3 || attempts%10 == 0 {
93+ log.Debug().Err(err).Str("addr", addr).Int("attempt", attempts).Msg("TCP probe attempt failed")
94+ }
95+ return false, nil
96+ })
97+ if errors.Is(err, errProbeTimeout) {
98+ return fmt.Errorf("TCP probe timeout after %s: %s not reachable (attempts=%d, last_error=%v)", timeout, addr, attempts, lastErr)
99+ }
100+ return err
101+}
102+
74103 // httpProbe performs HTTP GET requests to url, retrying every 1s until a
75104 // 2xx/3xx response or timeout. Used when gordon.health label is set.
76105 func httpProbe(ctx context.Context, url string, timeout time.Duration) error {
@@ -82,57 +111,27 @@ func httpProbe(ctx context.Context, url string, timeout time.Duration) error {
82111 }
83112 deadline := time.Now().Add(timeout)
84113 var lastStatus int
85-
86- for {
87- if err := ctx.Err(); err != nil {
88- return err
89- }
90- if time.Now().After(deadline) {
91- return fmt.Errorf("HTTP probe timeout after %s: last status %d from %s", timeout, lastStatus, url)
92- }
93-
94- remaining := time.Until(deadline)
95- attemptTimeout := 2 * time.Second
96- if remaining < attemptTimeout {
97- attemptTimeout = remaining
98- }
99- req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
114+ err := probeLoop(ctx, deadline, time.Second, 2*time.Second, func(attemptCtx context.Context) (bool, error) {
115+ req, reqErr := http.NewRequestWithContext(attemptCtx, http.MethodGet, url, nil)
100116 if reqErr != nil {
101- return reqErr
117+ return false, reqErr
102118 }
103- attemptCtx, cancel := context.WithTimeout(ctx, attemptTimeout)
104- req = req.WithContext(attemptCtx)
119+
105120 resp, err := client.Do(req)
106- cancel()
107- if err == nil {
108- lastStatus = resp.StatusCode
109- resp.Body.Close()
110- if lastStatus >= 200 && lastStatus < 400 {
111- return nil
112- }
121+ if err != nil {
122+ return false, nil
113123 }
114124
115- sleepInterval := time.Second
116- if remProbe := time.Until(deadline); remProbe < sleepInterval {
117- sleepInterval = remProbe
118- }
119- if ctxDeadline, ok := ctx.Deadline(); ok {
120- if remCtx := time.Until(ctxDeadline); remCtx < sleepInterval {
121- sleepInterval = remCtx
122- }
123- }
124- if sleepInterval <= 0 {
125- if ctx.Err() != nil {
126- return ctx.Err()
127- }
128- return fmt.Errorf("HTTP probe timeout after %s: last status %d from %s", timeout, lastStatus, url)
129- }
130- t := time.NewTimer(sleepInterval)
131- select {
132- case <-t.C:
133- case <-ctx.Done():
134- t.Stop()
135- return ctx.Err()
125+ lastStatus = resp.StatusCode
126+ resp.Body.Close()
127+ if lastStatus >= 200 && lastStatus < 400 {
128+ return true, nil
136129 }
130+
131+ return false, nil
132+ })
133+ if errors.Is(err, errProbeTimeout) {
134+ return fmt.Errorf("HTTP probe timeout after %s: last status %d from %s", timeout, lastStatus, url)
137135 }
136+ return err
138137 }
@@ -2,6 +2,7 @@ package container
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "fmt"6 "fmt"
6 "net"7 "net"
7 "net/http"8 "net/http"
@@ -10,43 +11,40 @@ import (
10 "github.com/bnema/zerowrap"11 "github.com/bnema/zerowrap"
11 )12 )
12 13
13-// tcpProbe attempts a TCP connection to addr, retrying every 500ms until14+var errProbeTimeout = errors.New("probe timeout")
14-// success or timeout. This is the universal fallback readiness check —15+
15-// it verifies the process is at least accepting connections.16+func probeLoop(
16-func tcpProbe(ctx context.Context, addr string, timeout time.Duration) error {17+ ctx context.Context,
17- log := zerowrap.FromCtx(ctx)18+ deadline time.Time,
18- deadline := time.Now().Add(timeout)19+ baseSleep time.Duration,
19- dialer := &net.Dialer{}20+ defaultAttemptTimeout time.Duration,
20- attempts := 021+ work func(attemptCtx context.Context) (success bool, err error),
21- var lastErr error22+) error {
22 for {23 for {
23 if err := ctx.Err(); err != nil {24 if err := ctx.Err(); err != nil {
24 return err25 return err
25 }26 }
26 if time.Now().After(deadline) {27 if time.Now().After(deadline) {
27- return fmt.Errorf("TCP probe timeout after %s: %s not reachable (attempts=%d, last_error=%v)", timeout, addr, attempts, lastErr)28+ return errProbeTimeout
28 }29 }
29 30
30 remaining := time.Until(deadline)31 remaining := time.Until(deadline)
31- attemptTimeout := time.Second32+ attemptTimeout := defaultAttemptTimeout
32 if remaining < attemptTimeout {33 if remaining < attemptTimeout {
33 attemptTimeout = remaining34 attemptTimeout = remaining
34 }35 }
36+
35 attemptCtx, cancel := context.WithTimeout(ctx, attemptTimeout)37 attemptCtx, cancel := context.WithTimeout(ctx, attemptTimeout)
36- conn, err := dialer.DialContext(attemptCtx, "tcp", addr)38+ success, err := work(attemptCtx)
37 cancel()39 cancel()
38- attempts++40+ if err != nil {
39- if err == nil {41+ return err
40- conn.Close()
41- log.Debug().Str("addr", addr).Int("attempts", attempts).Msg("TCP probe connected")
42- return nil
43 }42 }
44- lastErr = err43+ if success {
45- if attempts <= 3 || attempts%10 == 0 {44+ return nil
46- log.Debug().Err(err).Str("addr", addr).Int("attempt", attempts).Msg("TCP probe attempt failed")
47 }45 }
48 46
49- sleepInterval := 500 * time.Millisecond47+ sleepInterval := baseSleep
50 if remProbe := time.Until(deadline); remProbe < sleepInterval {48 if remProbe := time.Until(deadline); remProbe < sleepInterval {
51 sleepInterval = remProbe49 sleepInterval = remProbe
52 }50 }
@@ -59,8 +57,9 @@ func tcpProbe(ctx context.Context, addr string, timeout time.Duration) error {
59 if ctx.Err() != nil {57 if ctx.Err() != nil {
60 return ctx.Err()58 return ctx.Err()
61 }59 }
62- return fmt.Errorf("TCP probe timeout after %s: %s not reachable (attempts=%d, last_error=%v)", timeout, addr, attempts, lastErr)60+ return errProbeTimeout
63 }61 }
62+
64 t := time.NewTimer(sleepInterval)63 t := time.NewTimer(sleepInterval)
65 select {64 select {
66 case <-t.C:65 case <-t.C:
@@ -71,6 +70,36 @@ func tcpProbe(ctx context.Context, addr string, timeout time.Duration) error {
71 }70 }
72 }71 }
73 72
73+// tcpProbe attempts a TCP connection to addr, retrying every 500ms until
74+// success or timeout. This is the universal fallback readiness check —
75+// it verifies the process is at least accepting connections.
76+func tcpProbe(ctx context.Context, addr string, timeout time.Duration) error {
77+ log := zerowrap.FromCtx(ctx)
78+ deadline := time.Now().Add(timeout)
79+ dialer := &net.Dialer{}
80+ attempts := 0
81+ var lastErr error
82+ err := probeLoop(ctx, deadline, 500*time.Millisecond, time.Second, func(attemptCtx context.Context) (bool, error) {
83+ conn, err := dialer.DialContext(attemptCtx, "tcp", addr)
84+ attempts++
85+ if err == nil {
86+ conn.Close()
87+ log.Debug().Str("addr", addr).Int("attempts", attempts).Msg("TCP probe connected")
88+ return true, nil
89+ }
90+
91+ lastErr = err
92+ if attempts <= 3 || attempts%10 == 0 {
93+ log.Debug().Err(err).Str("addr", addr).Int("attempt", attempts).Msg("TCP probe attempt failed")
94+ }
95+ return false, nil
96+ })
97+ if errors.Is(err, errProbeTimeout) {
98+ return fmt.Errorf("TCP probe timeout after %s: %s not reachable (attempts=%d, last_error=%v)", timeout, addr, attempts, lastErr)
99+ }
100+ return err
101+}
102+
74 // httpProbe performs HTTP GET requests to url, retrying every 1s until a103 // httpProbe performs HTTP GET requests to url, retrying every 1s until a
75 // 2xx/3xx response or timeout. Used when gordon.health label is set.104 // 2xx/3xx response or timeout. Used when gordon.health label is set.
76 func httpProbe(ctx context.Context, url string, timeout time.Duration) error {105 func httpProbe(ctx context.Context, url string, timeout time.Duration) error {
@@ -82,57 +111,27 @@ func httpProbe(ctx context.Context, url string, timeout time.Duration) error {
82 }111 }
83 deadline := time.Now().Add(timeout)112 deadline := time.Now().Add(timeout)
84 var lastStatus int113 var lastStatus int
85-114+ err := probeLoop(ctx, deadline, time.Second, 2*time.Second, func(attemptCtx context.Context) (bool, error) {
86- for {115+ req, reqErr := http.NewRequestWithContext(attemptCtx, http.MethodGet, url, nil)
87- if err := ctx.Err(); err != nil {
88- return err
89- }
90- if time.Now().After(deadline) {
91- return fmt.Errorf("HTTP probe timeout after %s: last status %d from %s", timeout, lastStatus, url)
92- }
93-
94- remaining := time.Until(deadline)
95- attemptTimeout := 2 * time.Second
96- if remaining < attemptTimeout {
97- attemptTimeout = remaining
98- }
99- req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
100 if reqErr != nil {116 if reqErr != nil {
101- return reqErr117+ return false, reqErr
102 }118 }
103- attemptCtx, cancel := context.WithTimeout(ctx, attemptTimeout)119+
104- req = req.WithContext(attemptCtx)
105 resp, err := client.Do(req)120 resp, err := client.Do(req)
106- cancel()121+ if err != nil {
107- if err == nil {122+ return false, nil
108- lastStatus = resp.StatusCode
109- resp.Body.Close()
110- if lastStatus >= 200 && lastStatus < 400 {
111- return nil
112- }
113 }123 }
114 124
115- sleepInterval := time.Second125+ lastStatus = resp.StatusCode
116- if remProbe := time.Until(deadline); remProbe < sleepInterval {126+ resp.Body.Close()
117- sleepInterval = remProbe127+ if lastStatus >= 200 && lastStatus < 400 {
118- }128+ return true, nil
119- if ctxDeadline, ok := ctx.Deadline(); ok {
120- if remCtx := time.Until(ctxDeadline); remCtx < sleepInterval {
121- sleepInterval = remCtx
122- }
123- }
124- if sleepInterval <= 0 {
125- if ctx.Err() != nil {
126- return ctx.Err()
127- }
128- return fmt.Errorf("HTTP probe timeout after %s: last status %d from %s", timeout, lastStatus, url)
129- }
130- t := time.NewTimer(sleepInterval)
131- select {
132- case <-t.C:
133- case <-ctx.Done():
134- t.Stop()
135- return ctx.Err()
136 }129 }
130+
131+ return false, nil
132+ })
133+ if errors.Is(err, errProbeTimeout) {
134+ return fmt.Errorf("HTTP probe timeout after %s: last status %d from %s", timeout, lastStatus, url)
137 }135 }
136+ return err
138 }137 }
M internal/usecase/container/service_test.go +52 -38
@@ -195,10 +195,11 @@ func TestService_Deploy_Success(t *testing.T) {
195195 eventBus := mocks.NewMockEventPublisher(t)
196196
197197 config := Config{
198- NetworkIsolation: false,
199- VolumeAutoCreate: false,
200- ReadinessDelay: time.Millisecond, // Minimal delay for tests
201- DrainDelay: time.Millisecond,
198+ NetworkIsolation: false,
199+ VolumeAutoCreate: false,
200+ ReadinessDelay: time.Millisecond, // Minimal delay for tests
201+ DrainDelay: time.Millisecond,
202+ DrainDelayConfigured: true,
202203 }
203204
204205 svc := NewService(runtime, envLoader, eventBus, nil, config)
@@ -275,10 +276,11 @@ func TestService_Deploy_ReadinessRecoveryWindow_AllowsTransientFlap(t *testing.T
275276 eventBus := mocks.NewMockEventPublisher(t)
276277
277278 config := Config{
278- NetworkIsolation: false,
279- VolumeAutoCreate: false,
280- ReadinessDelay: time.Millisecond,
281- DrainDelay: time.Millisecond,
279+ NetworkIsolation: false,
280+ VolumeAutoCreate: false,
281+ ReadinessDelay: time.Millisecond,
282+ DrainDelay: time.Millisecond,
283+ DrainDelayConfigured: true,
282284 }
283285
284286 svc := NewService(runtime, envLoader, eventBus, nil, config)
@@ -426,9 +428,10 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) {
426428 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
427429
428430 config := Config{
429- ReadinessDelay: time.Millisecond, // Minimal delay for tests
430- DrainDelay: time.Millisecond,
431- StabilizationDelay: time.Millisecond,
431+ ReadinessDelay: time.Millisecond, // Minimal delay for tests
432+ DrainDelay: time.Millisecond,
433+ DrainDelayConfigured: true,
434+ StabilizationDelay: time.Millisecond,
432435 }
433436 svc := NewService(runtime, envLoader, eventBus, nil, config)
434437 svc.SetProxyCacheInvalidator(cacheInvalidator)
@@ -657,10 +660,11 @@ func TestService_Deploy_WithNetworkIsolation(t *testing.T) {
657660 eventBus := mocks.NewMockEventPublisher(t)
658661
659662 config := Config{
660- NetworkIsolation: true,
661- NetworkPrefix: "gordon",
662- ReadinessDelay: time.Millisecond, // Minimal delay for tests
663- DrainDelay: time.Millisecond,
663+ NetworkIsolation: true,
664+ NetworkPrefix: "gordon",
665+ ReadinessDelay: time.Millisecond, // Minimal delay for tests
666+ DrainDelay: time.Millisecond,
667+ DrainDelayConfigured: true,
664668 }
665669 svc := NewService(runtime, envLoader, eventBus, nil, config)
666670 ctx := testContext()
@@ -712,10 +716,11 @@ func TestService_Deploy_WithVolumeAutoCreate(t *testing.T) {
712716 eventBus := mocks.NewMockEventPublisher(t)
713717
714718 config := Config{
715- VolumeAutoCreate: true,
716- VolumePrefix: "gordon",
717- ReadinessDelay: time.Millisecond, // Minimal delay for tests
718- DrainDelay: time.Millisecond,
719+ VolumeAutoCreate: true,
720+ VolumePrefix: "gordon",
721+ ReadinessDelay: time.Millisecond, // Minimal delay for tests
722+ DrainDelay: time.Millisecond,
723+ DrainDelayConfigured: true,
719724 }
720725 svc := NewService(runtime, envLoader, eventBus, nil, config)
721726 ctx := testContext()
@@ -1433,6 +1438,7 @@ func TestService_Deploy_InternalDeployForcesPull(t *testing.T) {
14331438 InternalRegistryPassword: "secret",
14341439 ReadinessDelay: time.Millisecond,
14351440 DrainDelay: time.Millisecond,
1441+ DrainDelayConfigured: true,
14361442 }
14371443
14381444 svc := NewService(runtime, envLoader, eventBus, nil, config)
@@ -1503,8 +1509,9 @@ func TestService_AutoStart_StartsNewContainers(t *testing.T) {
15031509 eventBus := mocks.NewMockEventPublisher(t)
15041510
15051511 config := Config{
1506- ReadinessDelay: time.Millisecond,
1507- DrainDelay: time.Millisecond,
1512+ ReadinessDelay: time.Millisecond,
1513+ DrainDelay: time.Millisecond,
1514+ DrainDelayConfigured: true,
15081515 }
15091516 svc := NewService(runtime, envLoader, eventBus, nil, config)
15101517 ctx := testContext()
@@ -1549,8 +1556,9 @@ func TestService_AutoStart_SkipsExistingContainers(t *testing.T) {
15491556 eventBus := mocks.NewMockEventPublisher(t)
15501557
15511558 config := Config{
1552- ReadinessDelay: time.Millisecond,
1553- DrainDelay: time.Millisecond,
1559+ ReadinessDelay: time.Millisecond,
1560+ DrainDelay: time.Millisecond,
1561+ DrainDelayConfigured: true,
15541562 }
15551563 svc := NewService(runtime, envLoader, eventBus, nil, config)
15561564 ctx := testContext()
@@ -1623,10 +1631,11 @@ func TestService_AutoStart_UsesInternalDeployContext(t *testing.T) {
16231631 eventBus := mocks.NewMockEventPublisher(t)
16241632
16251633 config := Config{
1626- RegistryDomain: "reg.example.com",
1627- RegistryPort: 5000,
1628- ReadinessDelay: time.Millisecond,
1629- DrainDelay: time.Millisecond,
1634+ RegistryDomain: "reg.example.com",
1635+ RegistryPort: 5000,
1636+ ReadinessDelay: time.Millisecond,
1637+ DrainDelay: time.Millisecond,
1638+ DrainDelayConfigured: true,
16301639 }
16311640 svc := NewService(runtime, envLoader, eventBus, nil, config)
16321641
@@ -1674,9 +1683,10 @@ func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) {
16741683 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
16751684
16761685 config := Config{
1677- ReadinessDelay: time.Millisecond, // Minimal delay for tests
1678- DrainDelay: time.Millisecond,
1679- StabilizationDelay: time.Millisecond,
1686+ ReadinessDelay: time.Millisecond, // Minimal delay for tests
1687+ DrainDelay: time.Millisecond,
1688+ DrainDelayConfigured: true,
1689+ StabilizationDelay: time.Millisecond,
16801690 }
16811691 svc := NewService(runtime, envLoader, eventBus, nil, config)
16821692 svc.SetProxyCacheInvalidator(cacheInvalidator)
@@ -1765,8 +1775,9 @@ func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) {
17651775 eventBus := mocks.NewMockEventPublisher(t)
17661776
17671777 config := Config{
1768- ReadinessDelay: time.Millisecond, // Minimal delay for tests
1769- DrainDelay: time.Millisecond,
1778+ ReadinessDelay: time.Millisecond, // Minimal delay for tests
1779+ DrainDelay: time.Millisecond,
1780+ DrainDelayConfigured: true,
17701781 }
17711782 svc := NewService(runtime, envLoader, eventBus, nil, config)
17721783 ctx := testContext()
@@ -2207,8 +2218,9 @@ func TestService_Deploy_ContextCancellation(t *testing.T) {
22072218 eventBus := mocks.NewMockEventPublisher(t)
22082219
22092220 config := Config{
2210- ReadinessDelay: time.Millisecond,
2211- DrainDelay: time.Millisecond,
2221+ ReadinessDelay: time.Millisecond,
2222+ DrainDelay: time.Millisecond,
2223+ DrainDelayConfigured: true,
22122224 }
22132225 svc := NewService(runtime, envLoader, eventBus, nil, config)
22142226
@@ -2360,8 +2372,9 @@ func TestService_Deploy_SkipsRedundantDeploy(t *testing.T) {
23602372 eventBus := mocks.NewMockEventPublisher(t)
23612373
23622374 config := Config{
2363- ReadinessDelay: time.Millisecond,
2364- DrainDelay: time.Millisecond,
2375+ ReadinessDelay: time.Millisecond,
2376+ DrainDelay: time.Millisecond,
2377+ DrainDelayConfigured: true,
23652378 }
23662379 svc := NewService(runtime, envLoader, eventBus, nil, config)
23672380 ctx := testContext()
@@ -2413,8 +2426,9 @@ func TestService_Deploy_SkipsRedundantDeploy_WhenTrackedImageIDMissing(t *testin
24132426 eventBus := mocks.NewMockEventPublisher(t)
24142427
24152428 config := Config{
2416- ReadinessDelay: time.Millisecond,
2417- DrainDelay: time.Millisecond,
2429+ ReadinessDelay: time.Millisecond,
2430+ DrainDelay: time.Millisecond,
2431+ DrainDelayConfigured: true,
24182432 }
24192433 svc := NewService(runtime, envLoader, eventBus, nil, config)
24202434 ctx := testContext()
@@ -195,10 +195,11 @@ func TestService_Deploy_Success(t *testing.T) {
195 eventBus := mocks.NewMockEventPublisher(t)195 eventBus := mocks.NewMockEventPublisher(t)
196 196
197 config := Config{197 config := Config{
198- NetworkIsolation: false,198+ NetworkIsolation: false,
199- VolumeAutoCreate: false,199+ VolumeAutoCreate: false,
200- ReadinessDelay: time.Millisecond, // Minimal delay for tests200+ ReadinessDelay: time.Millisecond, // Minimal delay for tests
201- DrainDelay: time.Millisecond,201+ DrainDelay: time.Millisecond,
202+ DrainDelayConfigured: true,
202 }203 }
203 204
204 svc := NewService(runtime, envLoader, eventBus, nil, config)205 svc := NewService(runtime, envLoader, eventBus, nil, config)
@@ -275,10 +276,11 @@ func TestService_Deploy_ReadinessRecoveryWindow_AllowsTransientFlap(t *testing.T
275 eventBus := mocks.NewMockEventPublisher(t)276 eventBus := mocks.NewMockEventPublisher(t)
276 277
277 config := Config{278 config := Config{
278- NetworkIsolation: false,279+ NetworkIsolation: false,
279- VolumeAutoCreate: false,280+ VolumeAutoCreate: false,
280- ReadinessDelay: time.Millisecond,281+ ReadinessDelay: time.Millisecond,
281- DrainDelay: time.Millisecond,282+ DrainDelay: time.Millisecond,
283+ DrainDelayConfigured: true,
282 }284 }
283 285
284 svc := NewService(runtime, envLoader, eventBus, nil, config)286 svc := NewService(runtime, envLoader, eventBus, nil, config)
@@ -426,9 +428,10 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) {
426 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)428 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
427 429
428 config := Config{430 config := Config{
429- ReadinessDelay: time.Millisecond, // Minimal delay for tests431+ ReadinessDelay: time.Millisecond, // Minimal delay for tests
430- DrainDelay: time.Millisecond,432+ DrainDelay: time.Millisecond,
431- StabilizationDelay: time.Millisecond,433+ DrainDelayConfigured: true,
434+ StabilizationDelay: time.Millisecond,
432 }435 }
433 svc := NewService(runtime, envLoader, eventBus, nil, config)436 svc := NewService(runtime, envLoader, eventBus, nil, config)
434 svc.SetProxyCacheInvalidator(cacheInvalidator)437 svc.SetProxyCacheInvalidator(cacheInvalidator)
@@ -657,10 +660,11 @@ func TestService_Deploy_WithNetworkIsolation(t *testing.T) {
657 eventBus := mocks.NewMockEventPublisher(t)660 eventBus := mocks.NewMockEventPublisher(t)
658 661
659 config := Config{662 config := Config{
660- NetworkIsolation: true,663+ NetworkIsolation: true,
661- NetworkPrefix: "gordon",664+ NetworkPrefix: "gordon",
662- ReadinessDelay: time.Millisecond, // Minimal delay for tests665+ ReadinessDelay: time.Millisecond, // Minimal delay for tests
663- DrainDelay: time.Millisecond,666+ DrainDelay: time.Millisecond,
667+ DrainDelayConfigured: true,
664 }668 }
665 svc := NewService(runtime, envLoader, eventBus, nil, config)669 svc := NewService(runtime, envLoader, eventBus, nil, config)
666 ctx := testContext()670 ctx := testContext()
@@ -712,10 +716,11 @@ func TestService_Deploy_WithVolumeAutoCreate(t *testing.T) {
712 eventBus := mocks.NewMockEventPublisher(t)716 eventBus := mocks.NewMockEventPublisher(t)
713 717
714 config := Config{718 config := Config{
715- VolumeAutoCreate: true,719+ VolumeAutoCreate: true,
716- VolumePrefix: "gordon",720+ VolumePrefix: "gordon",
717- ReadinessDelay: time.Millisecond, // Minimal delay for tests721+ ReadinessDelay: time.Millisecond, // Minimal delay for tests
718- DrainDelay: time.Millisecond,722+ DrainDelay: time.Millisecond,
723+ DrainDelayConfigured: true,
719 }724 }
720 svc := NewService(runtime, envLoader, eventBus, nil, config)725 svc := NewService(runtime, envLoader, eventBus, nil, config)
721 ctx := testContext()726 ctx := testContext()
@@ -1433,6 +1438,7 @@ func TestService_Deploy_InternalDeployForcesPull(t *testing.T) {
1433 InternalRegistryPassword: "secret",1438 InternalRegistryPassword: "secret",
1434 ReadinessDelay: time.Millisecond,1439 ReadinessDelay: time.Millisecond,
1435 DrainDelay: time.Millisecond,1440 DrainDelay: time.Millisecond,
1441+ DrainDelayConfigured: true,
1436 }1442 }
1437 1443
1438 svc := NewService(runtime, envLoader, eventBus, nil, config)1444 svc := NewService(runtime, envLoader, eventBus, nil, config)
@@ -1503,8 +1509,9 @@ func TestService_AutoStart_StartsNewContainers(t *testing.T) {
1503 eventBus := mocks.NewMockEventPublisher(t)1509 eventBus := mocks.NewMockEventPublisher(t)
1504 1510
1505 config := Config{1511 config := Config{
1506- ReadinessDelay: time.Millisecond,1512+ ReadinessDelay: time.Millisecond,
1507- DrainDelay: time.Millisecond,1513+ DrainDelay: time.Millisecond,
1514+ DrainDelayConfigured: true,
1508 }1515 }
1509 svc := NewService(runtime, envLoader, eventBus, nil, config)1516 svc := NewService(runtime, envLoader, eventBus, nil, config)
1510 ctx := testContext()1517 ctx := testContext()
@@ -1549,8 +1556,9 @@ func TestService_AutoStart_SkipsExistingContainers(t *testing.T) {
1549 eventBus := mocks.NewMockEventPublisher(t)1556 eventBus := mocks.NewMockEventPublisher(t)
1550 1557
1551 config := Config{1558 config := Config{
1552- ReadinessDelay: time.Millisecond,1559+ ReadinessDelay: time.Millisecond,
1553- DrainDelay: time.Millisecond,1560+ DrainDelay: time.Millisecond,
1561+ DrainDelayConfigured: true,
1554 }1562 }
1555 svc := NewService(runtime, envLoader, eventBus, nil, config)1563 svc := NewService(runtime, envLoader, eventBus, nil, config)
1556 ctx := testContext()1564 ctx := testContext()
@@ -1623,10 +1631,11 @@ func TestService_AutoStart_UsesInternalDeployContext(t *testing.T) {
1623 eventBus := mocks.NewMockEventPublisher(t)1631 eventBus := mocks.NewMockEventPublisher(t)
1624 1632
1625 config := Config{1633 config := Config{
1626- RegistryDomain: "reg.example.com",1634+ RegistryDomain: "reg.example.com",
1627- RegistryPort: 5000,1635+ RegistryPort: 5000,
1628- ReadinessDelay: time.Millisecond,1636+ ReadinessDelay: time.Millisecond,
1629- DrainDelay: time.Millisecond,1637+ DrainDelay: time.Millisecond,
1638+ DrainDelayConfigured: true,
1630 }1639 }
1631 svc := NewService(runtime, envLoader, eventBus, nil, config)1640 svc := NewService(runtime, envLoader, eventBus, nil, config)
1632 1641
@@ -1674,9 +1683,10 @@ func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) {
1674 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)1683 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
1675 1684
1676 config := Config{1685 config := Config{
1677- ReadinessDelay: time.Millisecond, // Minimal delay for tests1686+ ReadinessDelay: time.Millisecond, // Minimal delay for tests
1678- DrainDelay: time.Millisecond,1687+ DrainDelay: time.Millisecond,
1679- StabilizationDelay: time.Millisecond,1688+ DrainDelayConfigured: true,
1689+ StabilizationDelay: time.Millisecond,
1680 }1690 }
1681 svc := NewService(runtime, envLoader, eventBus, nil, config)1691 svc := NewService(runtime, envLoader, eventBus, nil, config)
1682 svc.SetProxyCacheInvalidator(cacheInvalidator)1692 svc.SetProxyCacheInvalidator(cacheInvalidator)
@@ -1765,8 +1775,9 @@ func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) {
1765 eventBus := mocks.NewMockEventPublisher(t)1775 eventBus := mocks.NewMockEventPublisher(t)
1766 1776
1767 config := Config{1777 config := Config{
1768- ReadinessDelay: time.Millisecond, // Minimal delay for tests1778+ ReadinessDelay: time.Millisecond, // Minimal delay for tests
1769- DrainDelay: time.Millisecond,1779+ DrainDelay: time.Millisecond,
1780+ DrainDelayConfigured: true,
1770 }1781 }
1771 svc := NewService(runtime, envLoader, eventBus, nil, config)1782 svc := NewService(runtime, envLoader, eventBus, nil, config)
1772 ctx := testContext()1783 ctx := testContext()
@@ -2207,8 +2218,9 @@ func TestService_Deploy_ContextCancellation(t *testing.T) {
2207 eventBus := mocks.NewMockEventPublisher(t)2218 eventBus := mocks.NewMockEventPublisher(t)
2208 2219
2209 config := Config{2220 config := Config{
2210- ReadinessDelay: time.Millisecond,2221+ ReadinessDelay: time.Millisecond,
2211- DrainDelay: time.Millisecond,2222+ DrainDelay: time.Millisecond,
2223+ DrainDelayConfigured: true,
2212 }2224 }
2213 svc := NewService(runtime, envLoader, eventBus, nil, config)2225 svc := NewService(runtime, envLoader, eventBus, nil, config)
2214 2226
@@ -2360,8 +2372,9 @@ func TestService_Deploy_SkipsRedundantDeploy(t *testing.T) {
2360 eventBus := mocks.NewMockEventPublisher(t)2372 eventBus := mocks.NewMockEventPublisher(t)
2361 2373
2362 config := Config{2374 config := Config{
2363- ReadinessDelay: time.Millisecond,2375+ ReadinessDelay: time.Millisecond,
2364- DrainDelay: time.Millisecond,2376+ DrainDelay: time.Millisecond,
2377+ DrainDelayConfigured: true,
2365 }2378 }
2366 svc := NewService(runtime, envLoader, eventBus, nil, config)2379 svc := NewService(runtime, envLoader, eventBus, nil, config)
2367 ctx := testContext()2380 ctx := testContext()
@@ -2413,8 +2426,9 @@ func TestService_Deploy_SkipsRedundantDeploy_WhenTrackedImageIDMissing(t *testin
2413 eventBus := mocks.NewMockEventPublisher(t)2426 eventBus := mocks.NewMockEventPublisher(t)
2414 2427
2415 config := Config{2428 config := Config{
2416- ReadinessDelay: time.Millisecond,2429+ ReadinessDelay: time.Millisecond,
2417- DrainDelay: time.Millisecond,2430+ DrainDelay: time.Millisecond,
2431+ DrainDelayConfigured: true,
2418 }2432 }
2419 svc := NewService(runtime, envLoader, eventBus, nil, config)2433 svc := NewService(runtime, envLoader, eventBus, nil, config)
2420 ctx := testContext()2434 ctx := testContext()

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