detached 6cba420

fix: address code review findings for zero-downtime deploy

- httpProbe: add CheckRedirect to prevent following redirects, treating
  3xx responses as successful readiness signals instead of chasing them
- stabilizeNewContainer: verify old container is still running before
  rollback; return error when both containers are dead instead of
  silently returning a dead container reference
- resolveExistingContainer: guard managedCount increment to prevent
  double-counting when domain is already tracked
commit
6cba4200a4f0f014d8eb6ea20b35bb40361a29ab
author
brice · · 2026-03-03 03:35 UTC
parent
815f4fe
signature
unsigned
4 files · +43 -10
M internal/usecase/container/readiness.go +7 -1
@@ -38,7 +38,13 @@ func tcpProbe(ctx context.Context, addr string, timeout time.Duration) error {
3838 // httpProbe performs HTTP GET requests to url, retrying every 1s until a
3939 // 2xx/3xx response or timeout. Used when gordon.health label is set.
4040 func httpProbe(ctx context.Context, url string, timeout time.Duration) error {
41- client := &http.Client{Timeout: 2 * time.Second}
41+ client := &http.Client{
42+ Timeout: 2 * time.Second,
43+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
44+ // Don't follow redirects — treat 3xx as a successful response.
45+ return http.ErrUseLastResponse
46+ },
47+ }
4248 deadline := time.Now().Add(timeout)
4349 var lastStatus int
4450
@@ -38,7 +38,13 @@ func tcpProbe(ctx context.Context, addr string, timeout time.Duration) error {
38 // httpProbe performs HTTP GET requests to url, retrying every 1s until a38 // httpProbe performs HTTP GET requests to url, retrying every 1s until a
39 // 2xx/3xx response or timeout. Used when gordon.health label is set.39 // 2xx/3xx response or timeout. Used when gordon.health label is set.
40 func httpProbe(ctx context.Context, url string, timeout time.Duration) error {40 func httpProbe(ctx context.Context, url string, timeout time.Duration) error {
41- client := &http.Client{Timeout: 2 * time.Second}41+ client := &http.Client{
42+ Timeout: 2 * time.Second,
43+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
44+ // Don't follow redirects — treat 3xx as a successful response.
45+ return http.ErrUseLastResponse
46+ },
47+ }
42 deadline := time.Now().Add(timeout)48 deadline := time.Now().Add(timeout)
43 var lastStatus int49 var lastStatus int
44 50
M internal/usecase/container/readiness_test.go +2 -2
@@ -141,12 +141,12 @@ func TestHTTPProbe_ContextCancelled(t *testing.T) {
141141
142142 func TestHTTPProbe_RedirectIsSuccess(t *testing.T) {
143143 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
144- w.WriteHeader(302)
144+ http.Redirect(w, r, "/other", http.StatusFound)
145145 }))
146146 defer srv.Close()
147147
148148 ctx := testContext()
149- // Disable follow redirects for the test — the probe should treat 3xx as success
149+ // The probe should treat 3xx as success without following the redirect
150150 err := httpProbe(ctx, srv.URL+"/healthz", 5*time.Second)
151151 assert.NoError(t, err)
152152 }
@@ -141,12 +141,12 @@ func TestHTTPProbe_ContextCancelled(t *testing.T) {
141 141
142 func TestHTTPProbe_RedirectIsSuccess(t *testing.T) {142 func TestHTTPProbe_RedirectIsSuccess(t *testing.T) {
143 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {143 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
144- w.WriteHeader(302)144+ http.Redirect(w, r, "/other", http.StatusFound)
145 }))145 }))
146 defer srv.Close()146 defer srv.Close()
147 147
148 ctx := testContext()148 ctx := testContext()
149- // Disable follow redirects for the test — the probe should treat 3xx as success149+ // The probe should treat 3xx as success without following the redirect
150 err := httpProbe(ctx, srv.URL+"/healthz", 5*time.Second)150 err := httpProbe(ctx, srv.URL+"/healthz", 5*time.Second)
151 assert.NoError(t, err)151 assert.NoError(t, err)
152 }152 }
M internal/usecase/container/service.go +31 -7
@@ -311,7 +311,12 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta
311311
312312 // Post-switch stabilization: verify new container stays running
313313 if hasExisting {
314- if !s.stabilizeNewContainer(ctx, route.Domain, newContainer, existing) {
314+ stable, stabilizeErr := s.stabilizeNewContainer(ctx, route.Domain, newContainer, existing)
315+ if stabilizeErr != nil {
316+ // Both old and new containers are dead
317+ return nil, stabilizeErr
318+ }
319+ if !stable {
315320 // Rollback performed — old container is restored
316321 return existing, nil
317322 }
@@ -432,8 +437,10 @@ func (s *Service) resolveExistingContainer(ctx context.Context, domainName strin
432437
433438 // Update in-memory state so subsequent lookups are fast
434439 s.mu.Lock()
440+ if _, alreadyTracked := s.containers[domainName]; !alreadyTracked {
441+ s.managedCount++
442+ }
435443 s.containers[domainName] = c
436- s.managedCount++
437444 s.mu.Unlock()
438445
439446 return c, true
@@ -564,11 +571,12 @@ func (s *Service) activateDeployedContainer(ctx context.Context, domainName stri
564571 // stabilizeNewContainer monitors the new container briefly after traffic switch.
565572 // If it crashes during this window, rolls back to old container.
566573 // Returns true if stabilization succeeded, false if rollback was performed.
567-func (s *Service) stabilizeNewContainer(ctx context.Context, domainName string, newContainer, oldContainer *domain.Container) bool {
574+// Returns an error only when both new and old containers are dead.
575+func (s *Service) stabilizeNewContainer(ctx context.Context, domainName string, newContainer, oldContainer *domain.Container) (bool, error) {
568576 log := zerowrap.FromCtx(ctx)
569577
570578 if oldContainer == nil {
571- return true
579+ return true, nil
572580 }
573581
574582 s.mu.RLock()
@@ -582,7 +590,7 @@ func (s *Service) stabilizeNewContainer(ctx context.Context, domainName string,
582590 select {
583591 case <-time.After(delay):
584592 case <-ctx.Done():
585- return true
593+ return true, nil
586594 }
587595
588596 running, err := s.runtime.IsContainerRunning(ctx, newContainer.ID)
@@ -592,6 +600,22 @@ func (s *Service) stabilizeNewContainer(ctx context.Context, domainName string,
592600 Str("old_container", oldContainer.ID).
593601 Msg("new container crashed during stabilization, rolling back to old")
594602
603+ // Verify old container is still running before restoring it.
604+ // If both old and new are dead (e.g., OOM), returning a dead
605+ // container as "existing" would leave the domain in a broken state.
606+ oldRunning, oldErr := s.runtime.IsContainerRunning(ctx, oldContainer.ID)
607+ if oldErr != nil || !oldRunning {
608+ log.Error().
609+ Str("old_container", oldContainer.ID).
610+ Msg("old container is also not running, cannot rollback")
611+
612+ // Cleanup failed new container
613+ _ = s.runtime.StopContainer(ctx, newContainer.ID)
614+ _ = s.runtime.RemoveContainer(ctx, newContainer.ID, true)
615+
616+ return false, fmt.Errorf("stabilization failed: new container crashed and old container %s is also not running", oldContainer.ID)
617+ }
618+
595619 // Rollback: restore old container as tracked
596620 s.mu.Lock()
597621 s.containers[domainName] = oldContainer
@@ -609,10 +633,10 @@ func (s *Service) stabilizeNewContainer(ctx context.Context, domainName string,
609633 _ = s.runtime.StopContainer(ctx, newContainer.ID)
610634 _ = s.runtime.RemoveContainer(ctx, newContainer.ID, true)
611635
612- return false
636+ return false, nil
613637 }
614638
615- return true
639+ return true, nil
616640 }
617641
618642 func (s *Service) finalizePreviousContainer(ctx context.Context, domainName string, existing *domain.Container, hasExisting, invalidated bool, newContainerID string) {
@@ -311,7 +311,12 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta
311 311
312 // Post-switch stabilization: verify new container stays running312 // Post-switch stabilization: verify new container stays running
313 if hasExisting {313 if hasExisting {
314- if !s.stabilizeNewContainer(ctx, route.Domain, newContainer, existing) {314+ stable, stabilizeErr := s.stabilizeNewContainer(ctx, route.Domain, newContainer, existing)
315+ if stabilizeErr != nil {
316+ // Both old and new containers are dead
317+ return nil, stabilizeErr
318+ }
319+ if !stable {
315 // Rollback performed — old container is restored320 // Rollback performed — old container is restored
316 return existing, nil321 return existing, nil
317 }322 }
@@ -432,8 +437,10 @@ func (s *Service) resolveExistingContainer(ctx context.Context, domainName strin
432 437
433 // Update in-memory state so subsequent lookups are fast438 // Update in-memory state so subsequent lookups are fast
434 s.mu.Lock()439 s.mu.Lock()
440+ if _, alreadyTracked := s.containers[domainName]; !alreadyTracked {
441+ s.managedCount++
442+ }
435 s.containers[domainName] = c443 s.containers[domainName] = c
436- s.managedCount++
437 s.mu.Unlock()444 s.mu.Unlock()
438 445
439 return c, true446 return c, true
@@ -564,11 +571,12 @@ func (s *Service) activateDeployedContainer(ctx context.Context, domainName stri
564 // stabilizeNewContainer monitors the new container briefly after traffic switch.571 // stabilizeNewContainer monitors the new container briefly after traffic switch.
565 // If it crashes during this window, rolls back to old container.572 // If it crashes during this window, rolls back to old container.
566 // Returns true if stabilization succeeded, false if rollback was performed.573 // Returns true if stabilization succeeded, false if rollback was performed.
567-func (s *Service) stabilizeNewContainer(ctx context.Context, domainName string, newContainer, oldContainer *domain.Container) bool {574+// Returns an error only when both new and old containers are dead.
575+func (s *Service) stabilizeNewContainer(ctx context.Context, domainName string, newContainer, oldContainer *domain.Container) (bool, error) {
568 log := zerowrap.FromCtx(ctx)576 log := zerowrap.FromCtx(ctx)
569 577
570 if oldContainer == nil {578 if oldContainer == nil {
571- return true579+ return true, nil
572 }580 }
573 581
574 s.mu.RLock()582 s.mu.RLock()
@@ -582,7 +590,7 @@ func (s *Service) stabilizeNewContainer(ctx context.Context, domainName string,
582 select {590 select {
583 case <-time.After(delay):591 case <-time.After(delay):
584 case <-ctx.Done():592 case <-ctx.Done():
585- return true593+ return true, nil
586 }594 }
587 595
588 running, err := s.runtime.IsContainerRunning(ctx, newContainer.ID)596 running, err := s.runtime.IsContainerRunning(ctx, newContainer.ID)
@@ -592,6 +600,22 @@ func (s *Service) stabilizeNewContainer(ctx context.Context, domainName string,
592 Str("old_container", oldContainer.ID).600 Str("old_container", oldContainer.ID).
593 Msg("new container crashed during stabilization, rolling back to old")601 Msg("new container crashed during stabilization, rolling back to old")
594 602
603+ // Verify old container is still running before restoring it.
604+ // If both old and new are dead (e.g., OOM), returning a dead
605+ // container as "existing" would leave the domain in a broken state.
606+ oldRunning, oldErr := s.runtime.IsContainerRunning(ctx, oldContainer.ID)
607+ if oldErr != nil || !oldRunning {
608+ log.Error().
609+ Str("old_container", oldContainer.ID).
610+ Msg("old container is also not running, cannot rollback")
611+
612+ // Cleanup failed new container
613+ _ = s.runtime.StopContainer(ctx, newContainer.ID)
614+ _ = s.runtime.RemoveContainer(ctx, newContainer.ID, true)
615+
616+ return false, fmt.Errorf("stabilization failed: new container crashed and old container %s is also not running", oldContainer.ID)
617+ }
618+
595 // Rollback: restore old container as tracked619 // Rollback: restore old container as tracked
596 s.mu.Lock()620 s.mu.Lock()
597 s.containers[domainName] = oldContainer621 s.containers[domainName] = oldContainer
@@ -609,10 +633,10 @@ func (s *Service) stabilizeNewContainer(ctx context.Context, domainName string,
609 _ = s.runtime.StopContainer(ctx, newContainer.ID)633 _ = s.runtime.StopContainer(ctx, newContainer.ID)
610 _ = s.runtime.RemoveContainer(ctx, newContainer.ID, true)634 _ = s.runtime.RemoveContainer(ctx, newContainer.ID, true)
611 635
612- return false636+ return false, nil
613 }637 }
614 638
615- return true639+ return true, nil
616 }640 }
617 641
618 func (s *Service) finalizePreviousContainer(ctx context.Context, domainName string, existing *domain.Container, hasExisting, invalidated bool, newContainerID string) {642 func (s *Service) finalizePreviousContainer(ctx context.Context, domainName string, existing *domain.Container, hasExisting, invalidated bool, newContainerID string) {
M internal/usecase/container/service_test.go +3 -0
@@ -2788,6 +2788,9 @@ func TestService_Deploy_RollbackOnPostSwitchCrash(t *testing.T) {
27882788 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
27892789 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(false, nil).Once()
27902790
2791+ // Rollback: verify old container is still running before restoring
2792+ runtime.EXPECT().IsContainerRunning(mock.Anything, "old-container").Return(true, nil).Once()
2793+
27912794 // Inspect after ready
27922795 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
27932796 ID: "new-container",
@@ -2788,6 +2788,9 @@ func TestService_Deploy_RollbackOnPostSwitchCrash(t *testing.T) {
2788 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)2788 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
2789 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(false, nil).Once()2789 runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(false, nil).Once()
2790 2790
2791+ // Rollback: verify old container is still running before restoring
2792+ runtime.EXPECT().IsContainerRunning(mock.Anything, "old-container").Return(true, nil).Once()
2793+
2791 // Inspect after ready2794 // Inspect after ready
2792 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{2795 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
2793 ID: "new-container",2796 ID: "new-container",

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