detached 8365f31

feat(admin): add deploy-intent endpoint to suppress event-based deploys

commit
8365f3130f1422c8cfeaed53c0a0ae4764918f35
author
brice · · 2026-03-04 06:40 UTC
parent
82c3ab4
signature
unsigned
6 files · +201 -24
M internal/adapters/in/http/admin/handler.go +26 -0
@@ -213,6 +213,7 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) {
213213 {"/routes/by-image", h.handleRoutesByImage},
214214 {"/routes", h.handleRoutes},
215215 {"/secrets", h.handleSecrets},
216+ {"/deploy-intent", h.handleDeployIntent},
216217 {"/deploy", h.handleDeploy},
217218 {"/restart", h.handleRestart},
218219 {"/tags", h.handleTags},
@@ -228,6 +229,31 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) {
228229 return nil, false
229230 }
230231
232+// handleDeployIntent handles /admin/deploy-intent/:image endpoint.
233+// It registers a deploy intent, suppressing event-based deploys for the image.
234+func (h *Handler) handleDeployIntent(w http.ResponseWriter, r *http.Request, path string) {
235+ if r.Method != http.MethodPost {
236+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
237+ return
238+ }
239+
240+ imageName := strings.TrimPrefix(path, "/deploy-intent/")
241+ if imageName == "" || imageName == "/deploy-intent" {
242+ h.sendError(w, http.StatusBadRequest, "image name required")
243+ return
244+ }
245+
246+ log := zerowrap.FromCtx(r.Context())
247+ log.Info().Str("image", imageName).Msg("deploy intent registered, suppressing image.pushed events")
248+
249+ h.registrySvc.SuppressDeployEvent(imageName)
250+
251+ h.sendJSON(w, http.StatusOK, map[string]string{
252+ "status": "ok",
253+ "image": imageName,
254+ })
255+}
256+
231257 // sendJSON sends a JSON response.
232258 func (h *Handler) sendJSON(w http.ResponseWriter, status int, data any) {
233259 w.Header().Set("Content-Type", "application/json")
@@ -213,6 +213,7 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) {
213 {"/routes/by-image", h.handleRoutesByImage},213 {"/routes/by-image", h.handleRoutesByImage},
214 {"/routes", h.handleRoutes},214 {"/routes", h.handleRoutes},
215 {"/secrets", h.handleSecrets},215 {"/secrets", h.handleSecrets},
216+ {"/deploy-intent", h.handleDeployIntent},
216 {"/deploy", h.handleDeploy},217 {"/deploy", h.handleDeploy},
217 {"/restart", h.handleRestart},218 {"/restart", h.handleRestart},
218 {"/tags", h.handleTags},219 {"/tags", h.handleTags},
@@ -228,6 +229,31 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) {
228 return nil, false229 return nil, false
229 }230 }
230 231
232+// handleDeployIntent handles /admin/deploy-intent/:image endpoint.
233+// It registers a deploy intent, suppressing event-based deploys for the image.
234+func (h *Handler) handleDeployIntent(w http.ResponseWriter, r *http.Request, path string) {
235+ if r.Method != http.MethodPost {
236+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
237+ return
238+ }
239+
240+ imageName := strings.TrimPrefix(path, "/deploy-intent/")
241+ if imageName == "" || imageName == "/deploy-intent" {
242+ h.sendError(w, http.StatusBadRequest, "image name required")
243+ return
244+ }
245+
246+ log := zerowrap.FromCtx(r.Context())
247+ log.Info().Str("image", imageName).Msg("deploy intent registered, suppressing image.pushed events")
248+
249+ h.registrySvc.SuppressDeployEvent(imageName)
250+
251+ h.sendJSON(w, http.StatusOK, map[string]string{
252+ "status": "ok",
253+ "image": imageName,
254+ })
255+}
256+
231 // sendJSON sends a JSON response.257 // sendJSON sends a JSON response.
232 func (h *Handler) sendJSON(w http.ResponseWriter, status int, data any) {258 func (h *Handler) sendJSON(w http.ResponseWriter, status int, data any) {
233 w.Header().Set("Content-Type", "application/json")259 w.Header().Set("Content-Type", "application/json")
M internal/boundaries/in/mocks/mock_registry_service.go +80 -0
@@ -231,6 +231,46 @@ func (_c *MockRegistryService_CancelUpload_Call) RunAndReturn(run func(ctx conte
231231 return _c
232232 }
233233
234+// ClearDeployEventSuppression provides a mock function for the type MockRegistryService
235+func (_mock *MockRegistryService) ClearDeployEventSuppression(imageName string) {
236+ _mock.Called(imageName)
237+ return
238+}
239+
240+// MockRegistryService_ClearDeployEventSuppression_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClearDeployEventSuppression'
241+type MockRegistryService_ClearDeployEventSuppression_Call struct {
242+ *mock.Call
243+}
244+
245+// ClearDeployEventSuppression is a helper method to define mock.On call
246+// - imageName string
247+func (_e *MockRegistryService_Expecter) ClearDeployEventSuppression(imageName interface{}) *MockRegistryService_ClearDeployEventSuppression_Call {
248+ return &MockRegistryService_ClearDeployEventSuppression_Call{Call: _e.mock.On("ClearDeployEventSuppression", imageName)}
249+}
250+
251+func (_c *MockRegistryService_ClearDeployEventSuppression_Call) Run(run func(imageName string)) *MockRegistryService_ClearDeployEventSuppression_Call {
252+ _c.Call.Run(func(args mock.Arguments) {
253+ var arg0 string
254+ if args[0] != nil {
255+ arg0 = args[0].(string)
256+ }
257+ run(
258+ arg0,
259+ )
260+ })
261+ return _c
262+}
263+
264+func (_c *MockRegistryService_ClearDeployEventSuppression_Call) Return() *MockRegistryService_ClearDeployEventSuppression_Call {
265+ _c.Call.Return()
266+ return _c
267+}
268+
269+func (_c *MockRegistryService_ClearDeployEventSuppression_Call) RunAndReturn(run func(imageName string)) *MockRegistryService_ClearDeployEventSuppression_Call {
270+ _c.Run(run)
271+ return _c
272+}
273+
234274 // DeleteManifest provides a mock function for the type MockRegistryService
235275 func (_mock *MockRegistryService) DeleteManifest(ctx context.Context, name string, reference string) error {
236276 ret := _mock.Called(ctx, name, reference)
@@ -895,3 +935,43 @@ func (_c *MockRegistryService_StartUpload_Call) RunAndReturn(run func(ctx contex
895935 _c.Call.Return(run)
896936 return _c
897937 }
938+
939+// SuppressDeployEvent provides a mock function for the type MockRegistryService
940+func (_mock *MockRegistryService) SuppressDeployEvent(imageName string) {
941+ _mock.Called(imageName)
942+ return
943+}
944+
945+// MockRegistryService_SuppressDeployEvent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SuppressDeployEvent'
946+type MockRegistryService_SuppressDeployEvent_Call struct {
947+ *mock.Call
948+}
949+
950+// SuppressDeployEvent is a helper method to define mock.On call
951+// - imageName string
952+func (_e *MockRegistryService_Expecter) SuppressDeployEvent(imageName interface{}) *MockRegistryService_SuppressDeployEvent_Call {
953+ return &MockRegistryService_SuppressDeployEvent_Call{Call: _e.mock.On("SuppressDeployEvent", imageName)}
954+}
955+
956+func (_c *MockRegistryService_SuppressDeployEvent_Call) Run(run func(imageName string)) *MockRegistryService_SuppressDeployEvent_Call {
957+ _c.Call.Run(func(args mock.Arguments) {
958+ var arg0 string
959+ if args[0] != nil {
960+ arg0 = args[0].(string)
961+ }
962+ run(
963+ arg0,
964+ )
965+ })
966+ return _c
967+}
968+
969+func (_c *MockRegistryService_SuppressDeployEvent_Call) Return() *MockRegistryService_SuppressDeployEvent_Call {
970+ _c.Call.Return()
971+ return _c
972+}
973+
974+func (_c *MockRegistryService_SuppressDeployEvent_Call) RunAndReturn(run func(imageName string)) *MockRegistryService_SuppressDeployEvent_Call {
975+ _c.Run(run)
976+ return _c
977+}
@@ -231,6 +231,46 @@ func (_c *MockRegistryService_CancelUpload_Call) RunAndReturn(run func(ctx conte
231 return _c231 return _c
232 }232 }
233 233
234+// ClearDeployEventSuppression provides a mock function for the type MockRegistryService
235+func (_mock *MockRegistryService) ClearDeployEventSuppression(imageName string) {
236+ _mock.Called(imageName)
237+ return
238+}
239+
240+// MockRegistryService_ClearDeployEventSuppression_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClearDeployEventSuppression'
241+type MockRegistryService_ClearDeployEventSuppression_Call struct {
242+ *mock.Call
243+}
244+
245+// ClearDeployEventSuppression is a helper method to define mock.On call
246+// - imageName string
247+func (_e *MockRegistryService_Expecter) ClearDeployEventSuppression(imageName interface{}) *MockRegistryService_ClearDeployEventSuppression_Call {
248+ return &MockRegistryService_ClearDeployEventSuppression_Call{Call: _e.mock.On("ClearDeployEventSuppression", imageName)}
249+}
250+
251+func (_c *MockRegistryService_ClearDeployEventSuppression_Call) Run(run func(imageName string)) *MockRegistryService_ClearDeployEventSuppression_Call {
252+ _c.Call.Run(func(args mock.Arguments) {
253+ var arg0 string
254+ if args[0] != nil {
255+ arg0 = args[0].(string)
256+ }
257+ run(
258+ arg0,
259+ )
260+ })
261+ return _c
262+}
263+
264+func (_c *MockRegistryService_ClearDeployEventSuppression_Call) Return() *MockRegistryService_ClearDeployEventSuppression_Call {
265+ _c.Call.Return()
266+ return _c
267+}
268+
269+func (_c *MockRegistryService_ClearDeployEventSuppression_Call) RunAndReturn(run func(imageName string)) *MockRegistryService_ClearDeployEventSuppression_Call {
270+ _c.Run(run)
271+ return _c
272+}
273+
234 // DeleteManifest provides a mock function for the type MockRegistryService274 // DeleteManifest provides a mock function for the type MockRegistryService
235 func (_mock *MockRegistryService) DeleteManifest(ctx context.Context, name string, reference string) error {275 func (_mock *MockRegistryService) DeleteManifest(ctx context.Context, name string, reference string) error {
236 ret := _mock.Called(ctx, name, reference)276 ret := _mock.Called(ctx, name, reference)
@@ -895,3 +935,43 @@ func (_c *MockRegistryService_StartUpload_Call) RunAndReturn(run func(ctx contex
895 _c.Call.Return(run)935 _c.Call.Return(run)
896 return _c936 return _c
897 }937 }
938+
939+// SuppressDeployEvent provides a mock function for the type MockRegistryService
940+func (_mock *MockRegistryService) SuppressDeployEvent(imageName string) {
941+ _mock.Called(imageName)
942+ return
943+}
944+
945+// MockRegistryService_SuppressDeployEvent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SuppressDeployEvent'
946+type MockRegistryService_SuppressDeployEvent_Call struct {
947+ *mock.Call
948+}
949+
950+// SuppressDeployEvent is a helper method to define mock.On call
951+// - imageName string
952+func (_e *MockRegistryService_Expecter) SuppressDeployEvent(imageName interface{}) *MockRegistryService_SuppressDeployEvent_Call {
953+ return &MockRegistryService_SuppressDeployEvent_Call{Call: _e.mock.On("SuppressDeployEvent", imageName)}
954+}
955+
956+func (_c *MockRegistryService_SuppressDeployEvent_Call) Run(run func(imageName string)) *MockRegistryService_SuppressDeployEvent_Call {
957+ _c.Call.Run(func(args mock.Arguments) {
958+ var arg0 string
959+ if args[0] != nil {
960+ arg0 = args[0].(string)
961+ }
962+ run(
963+ arg0,
964+ )
965+ })
966+ return _c
967+}
968+
969+func (_c *MockRegistryService_SuppressDeployEvent_Call) Return() *MockRegistryService_SuppressDeployEvent_Call {
970+ _c.Call.Return()
971+ return _c
972+}
973+
974+func (_c *MockRegistryService_SuppressDeployEvent_Call) RunAndReturn(run func(imageName string)) *MockRegistryService_SuppressDeployEvent_Call {
975+ _c.Run(run)
976+ return _c
977+}
M internal/boundaries/in/registry.go +4 -0
@@ -29,4 +29,8 @@ type RegistryService interface {
2929 // Tag operations
3030 ListTags(ctx context.Context, name string) ([]string, error)
3131 ListRepositories(ctx context.Context) ([]string, error)
32+
33+ // Deploy event suppression (prevents double-deploy when CLI manages the push)
34+ SuppressDeployEvent(imageName string)
35+ ClearDeployEventSuppression(imageName string)
3236 }
@@ -29,4 +29,8 @@ type RegistryService interface {
29 // Tag operations29 // Tag operations
30 ListTags(ctx context.Context, name string) ([]string, error)30 ListTags(ctx context.Context, name string) ([]string, error)
31 ListRepositories(ctx context.Context) ([]string, error)31 ListRepositories(ctx context.Context) ([]string, error)
32+
33+ // Deploy event suppression (prevents double-deploy when CLI manages the push)
34+ SuppressDeployEvent(imageName string)
35+ ClearDeployEventSuppression(imageName string)
32 }36 }
M internal/usecase/container/readiness.go +12 -1
@@ -6,20 +6,25 @@ import (
66 "net"
77 "net/http"
88 "time"
9+
10+ "github.com/bnema/zerowrap"
911 )
1012
1113 // tcpProbe attempts a TCP connection to addr, retrying every 500ms until
1214 // success or timeout. This is the universal fallback readiness check —
1315 // it verifies the process is at least accepting connections.
1416 func tcpProbe(ctx context.Context, addr string, timeout time.Duration) error {
17+ log := zerowrap.FromCtx(ctx)
1518 deadline := time.Now().Add(timeout)
1619 dialer := &net.Dialer{}
20+ attempts := 0
21+ var lastErr error
1722 for {
1823 if err := ctx.Err(); err != nil {
1924 return err
2025 }
2126 if time.Now().After(deadline) {
22- return fmt.Errorf("TCP probe timeout after %s: %s not reachable", timeout, addr)
27+ return fmt.Errorf("TCP probe timeout after %s: %s not reachable (attempts=%d, last_error=%v)", timeout, addr, attempts, lastErr)
2328 }
2429
2530 remaining := time.Until(deadline)
@@ -30,10 +35,16 @@ func tcpProbe(ctx context.Context, addr string, timeout time.Duration) error {
3035 attemptCtx, cancel := context.WithTimeout(ctx, attemptTimeout)
3136 conn, err := dialer.DialContext(attemptCtx, "tcp", addr)
3237 cancel()
38+ attempts++
3339 if err == nil {
3440 conn.Close()
41+ log.Debug().Str("addr", addr).Int("attempts", attempts).Msg("TCP probe connected")
3542 return nil
3643 }
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")
47+ }
3748
3849 select {
3950 case <-time.After(500 * time.Millisecond):
@@ -6,20 +6,25 @@ import (
6 "net"6 "net"
7 "net/http"7 "net/http"
8 "time"8 "time"
9+
10+ "github.com/bnema/zerowrap"
9 )11 )
10 12
11 // tcpProbe attempts a TCP connection to addr, retrying every 500ms until13 // tcpProbe attempts a TCP connection to addr, retrying every 500ms until
12 // success or timeout. This is the universal fallback readiness check —14 // success or timeout. This is the universal fallback readiness check —
13 // it verifies the process is at least accepting connections.15 // it verifies the process is at least accepting connections.
14 func tcpProbe(ctx context.Context, addr string, timeout time.Duration) error {16 func tcpProbe(ctx context.Context, addr string, timeout time.Duration) error {
17+ log := zerowrap.FromCtx(ctx)
15 deadline := time.Now().Add(timeout)18 deadline := time.Now().Add(timeout)
16 dialer := &net.Dialer{}19 dialer := &net.Dialer{}
20+ attempts := 0
21+ var lastErr error
17 for {22 for {
18 if err := ctx.Err(); err != nil {23 if err := ctx.Err(); err != nil {
19 return err24 return err
20 }25 }
21 if time.Now().After(deadline) {26 if time.Now().After(deadline) {
22- return fmt.Errorf("TCP probe timeout after %s: %s not reachable", timeout, addr)27+ return fmt.Errorf("TCP probe timeout after %s: %s not reachable (attempts=%d, last_error=%v)", timeout, addr, attempts, lastErr)
23 }28 }
24 29
25 remaining := time.Until(deadline)30 remaining := time.Until(deadline)
@@ -30,10 +35,16 @@ func tcpProbe(ctx context.Context, addr string, timeout time.Duration) error {
30 attemptCtx, cancel := context.WithTimeout(ctx, attemptTimeout)35 attemptCtx, cancel := context.WithTimeout(ctx, attemptTimeout)
31 conn, err := dialer.DialContext(attemptCtx, "tcp", addr)36 conn, err := dialer.DialContext(attemptCtx, "tcp", addr)
32 cancel()37 cancel()
38+ attempts++
33 if err == nil {39 if err == nil {
34 conn.Close()40 conn.Close()
41+ log.Debug().Str("addr", addr).Int("attempts", attempts).Msg("TCP probe connected")
35 return nil42 return nil
36 }43 }
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")
47+ }
37 48
38 select {49 select {
39 case <-time.After(500 * time.Millisecond):50 case <-time.After(500 * time.Millisecond):
M internal/usecase/container/service.go +75 -23
@@ -407,6 +407,11 @@ type deployResources struct {
407407 // It first checks the in-memory map (fast path). If not found, it queries the
408408 // runtime for a running container with matching name and managed label. This
409409 // handles cases where Gordon restarted and in-memory state is stale.
410+//
411+// The slow path checks canonical, -new, and -next names because a previous
412+// deploy may have been interrupted (e.g. eventbus timeout, Gordon restart)
413+// leaving the active container under a temp name. Canonical is preferred when
414+// multiple matches exist.
410415 func (s *Service) resolveExistingContainer(ctx context.Context, domainName string) (*domain.Container, bool) {
411416 // Fast path: check in-memory state
412417 s.mu.RLock()
@@ -418,7 +423,12 @@ func (s *Service) resolveExistingContainer(ctx context.Context, domainName strin
418423
419424 // Slow path: query runtime for running containers
420425 log := zerowrap.FromCtx(ctx)
421- expectedName := fmt.Sprintf("gordon-%s", domainName)
426+ canonicalName := fmt.Sprintf("gordon-%s", domainName)
427+ candidateNames := map[string]bool{
428+ canonicalName: true,
429+ canonicalName + "-new": true,
430+ canonicalName + "-next": true,
431+ }
422432
423433 running, err := s.runtime.ListContainers(ctx, false)
424434 if err != nil {
@@ -426,23 +436,32 @@ func (s *Service) resolveExistingContainer(ctx context.Context, domainName strin
426436 return nil, false
427437 }
428438
439+ // Prefer canonical name; fall back to any running managed temp container.
440+ var best *domain.Container
429441 for _, c := range running {
430- if c.Name == expectedName && c.Labels[domain.LabelManaged] == "true" {
431- log.Info().
432- Str("container_id", c.ID).
433- Str("container_name", c.Name).
434- Msg("resolved existing container from runtime (in-memory state was stale)")
435-
436- // Update in-memory state so subsequent lookups are fast
437- s.mu.Lock()
438- if _, alreadyTracked := s.containers[domainName]; !alreadyTracked {
439- s.managedCount++
440- }
441- s.containers[domainName] = c
442- s.mu.Unlock()
442+ if !candidateNames[c.Name] || c.Labels[domain.LabelManaged] != "true" {
443+ continue
444+ }
445+ if best == nil || c.Name == canonicalName {
446+ best = c
447+ }
448+ }
449+
450+ if best != nil {
451+ log.Info().
452+ Str("container_id", best.ID).
453+ Str("container_name", best.Name).
454+ Msg("resolved existing container from runtime (in-memory state was stale)")
443455
444- return c, true
456+ // Update in-memory state so subsequent lookups are fast
457+ s.mu.Lock()
458+ if _, alreadyTracked := s.containers[domainName]; !alreadyTracked {
459+ s.managedCount++
445460 }
461+ s.containers[domainName] = best
462+ s.mu.Unlock()
463+
464+ return best, true
446465 }
447466
448467 return nil, false
@@ -1744,19 +1763,26 @@ func (s *Service) cleanupOrphanedContainers(ctx context.Context, domainName stri
17441763
17451764 for _, c := range allContainers {
17461765 if (c.Name == expectedName || c.Name == expectedNewName || c.Name == expectedNextName) && c.ID != skipContainerID {
1747- // Never kill a running container with the canonical name — it may be
1748- // the active container serving traffic. Only temp (-new/-next) containers
1749- // and stopped canonical containers are safe to remove.
1766+ // The skipContainerID is the container we resolved as the active
1767+ // one serving traffic. Any other container with a matching name is
1768+ // an orphan from a previous (possibly interrupted) deploy.
1769+ //
1770+ // Running containers with the canonical name are protected — they
1771+ // could be legitimate if resolveExistingContainer found a temp
1772+ // container instead. Running temp containers (-new/-next) that are
1773+ // NOT the skip container are safe to remove: they are leftovers
1774+ // from failed deploys.
17501775 isCanonical := c.Name == expectedName
17511776 if isCanonical && c.Status == "running" {
17521777 log.Debug().
17531778 Str(zerowrap.FieldEntityID, c.ID).
1779+ Str("container_name", c.Name).
17541780 Str(zerowrap.FieldStatus, c.Status).
17551781 Msg("skipping running canonical container during orphan cleanup")
17561782 continue
17571783 }
17581784
1759- log.Info().Str(zerowrap.FieldEntityID, c.ID).Str(zerowrap.FieldStatus, c.Status).Msg("found orphaned container, removing")
1785+ log.Info().Str(zerowrap.FieldEntityID, c.ID).Str("container_name", c.Name).Str(zerowrap.FieldStatus, c.Status).Msg("found orphaned container, removing")
17601786
17611787 if err := s.runtime.StopContainer(ctx, c.ID); err != nil {
17621788 log.WrapErrWithFields(err, "failed to stop orphaned container", map[string]any{zerowrap.FieldEntityID: c.ID})
@@ -2344,14 +2370,40 @@ func (s *Service) tryTCPProbe(ctx context.Context, containerID string, container
23442370 return true, tcpProbe(ctx, addr, timeout)
23452371 }
23462372
2347-// resolveContainerEndpoint retrieves the container's IP and first port via
2348-// GetContainerNetworkInfo. The result can be used for HTTP or TCP probes.
2373+// resolveContainerEndpoint returns a host-reachable address for probing.
2374+// In rootless podman/Docker setups, container internal IPs are not routable
2375+// from the host. We use the host port binding (127.0.0.1:<mapped_port>)
2376+// which is always reachable. Falls back to the container's internal IP
2377+// only if no host port mapping exists (e.g. host-network mode).
23492378 func (s *Service) resolveContainerEndpoint(ctx context.Context, containerID string) (string, int, error) {
2350- ip, port, err := s.runtime.GetContainerNetworkInfo(ctx, containerID)
2379+ log := zerowrap.FromCtx(ctx)
2380+
2381+ // First, get the container's internal port from network info
2382+ _, internalPort, err := s.runtime.GetContainerNetworkInfo(ctx, containerID)
23512383 if err != nil {
23522384 return "", 0, err
23532385 }
2354- return ip, port, nil
2386+
2387+ // Try to resolve via host port binding (works in rootless podman/Docker)
2388+ hostPort, hostErr := s.runtime.GetContainerPort(ctx, containerID, internalPort)
2389+ if hostErr == nil && hostPort > 0 {
2390+ log.Debug().
2391+ Int("internal_port", internalPort).
2392+ Int("host_port", hostPort).
2393+ Msg("resolved container endpoint via host port binding")
2394+ return "127.0.0.1", hostPort, nil
2395+ }
2396+
2397+ // Fallback: use internal IP (works in Docker rootful / host network)
2398+ ip, _, fallbackErr := s.runtime.GetContainerNetworkInfo(ctx, containerID)
2399+ if fallbackErr != nil {
2400+ return "", 0, fallbackErr
2401+ }
2402+ log.Debug().
2403+ Str("ip", ip).
2404+ Int("port", internalPort).
2405+ Msg("resolved container endpoint via internal IP (no host port binding)")
2406+ return ip, internalPort, nil
23552407 }
23562408
23572409 // waitForReadyByDelay waits using the legacy running+delay strategy.
@@ -407,6 +407,11 @@ type deployResources struct {
407 // It first checks the in-memory map (fast path). If not found, it queries the407 // It first checks the in-memory map (fast path). If not found, it queries the
408 // runtime for a running container with matching name and managed label. This408 // runtime for a running container with matching name and managed label. This
409 // handles cases where Gordon restarted and in-memory state is stale.409 // handles cases where Gordon restarted and in-memory state is stale.
410+//
411+// The slow path checks canonical, -new, and -next names because a previous
412+// deploy may have been interrupted (e.g. eventbus timeout, Gordon restart)
413+// leaving the active container under a temp name. Canonical is preferred when
414+// multiple matches exist.
410 func (s *Service) resolveExistingContainer(ctx context.Context, domainName string) (*domain.Container, bool) {415 func (s *Service) resolveExistingContainer(ctx context.Context, domainName string) (*domain.Container, bool) {
411 // Fast path: check in-memory state416 // Fast path: check in-memory state
412 s.mu.RLock()417 s.mu.RLock()
@@ -418,7 +423,12 @@ func (s *Service) resolveExistingContainer(ctx context.Context, domainName strin
418 423
419 // Slow path: query runtime for running containers424 // Slow path: query runtime for running containers
420 log := zerowrap.FromCtx(ctx)425 log := zerowrap.FromCtx(ctx)
421- expectedName := fmt.Sprintf("gordon-%s", domainName)426+ canonicalName := fmt.Sprintf("gordon-%s", domainName)
427+ candidateNames := map[string]bool{
428+ canonicalName: true,
429+ canonicalName + "-new": true,
430+ canonicalName + "-next": true,
431+ }
422 432
423 running, err := s.runtime.ListContainers(ctx, false)433 running, err := s.runtime.ListContainers(ctx, false)
424 if err != nil {434 if err != nil {
@@ -426,23 +436,32 @@ func (s *Service) resolveExistingContainer(ctx context.Context, domainName strin
426 return nil, false436 return nil, false
427 }437 }
428 438
439+ // Prefer canonical name; fall back to any running managed temp container.
440+ var best *domain.Container
429 for _, c := range running {441 for _, c := range running {
430- if c.Name == expectedName && c.Labels[domain.LabelManaged] == "true" {442+ if !candidateNames[c.Name] || c.Labels[domain.LabelManaged] != "true" {
431- log.Info().443+ continue
432- Str("container_id", c.ID).444+ }
433- Str("container_name", c.Name).445+ if best == nil || c.Name == canonicalName {
434- Msg("resolved existing container from runtime (in-memory state was stale)")446+ best = c
435-447+ }
436- // Update in-memory state so subsequent lookups are fast448+ }
437- s.mu.Lock()449+
438- if _, alreadyTracked := s.containers[domainName]; !alreadyTracked {450+ if best != nil {
439- s.managedCount++451+ log.Info().
440- }452+ Str("container_id", best.ID).
441- s.containers[domainName] = c453+ Str("container_name", best.Name).
442- s.mu.Unlock()454+ Msg("resolved existing container from runtime (in-memory state was stale)")
443 455
444- return c, true456+ // Update in-memory state so subsequent lookups are fast
457+ s.mu.Lock()
458+ if _, alreadyTracked := s.containers[domainName]; !alreadyTracked {
459+ s.managedCount++
445 }460 }
461+ s.containers[domainName] = best
462+ s.mu.Unlock()
463+
464+ return best, true
446 }465 }
447 466
448 return nil, false467 return nil, false
@@ -1744,19 +1763,26 @@ func (s *Service) cleanupOrphanedContainers(ctx context.Context, domainName stri
1744 1763
1745 for _, c := range allContainers {1764 for _, c := range allContainers {
1746 if (c.Name == expectedName || c.Name == expectedNewName || c.Name == expectedNextName) && c.ID != skipContainerID {1765 if (c.Name == expectedName || c.Name == expectedNewName || c.Name == expectedNextName) && c.ID != skipContainerID {
1747- // Never kill a running container with the canonical name — it may be1766+ // The skipContainerID is the container we resolved as the active
1748- // the active container serving traffic. Only temp (-new/-next) containers1767+ // one serving traffic. Any other container with a matching name is
1749- // and stopped canonical containers are safe to remove.1768+ // an orphan from a previous (possibly interrupted) deploy.
1769+ //
1770+ // Running containers with the canonical name are protected — they
1771+ // could be legitimate if resolveExistingContainer found a temp
1772+ // container instead. Running temp containers (-new/-next) that are
1773+ // NOT the skip container are safe to remove: they are leftovers
1774+ // from failed deploys.
1750 isCanonical := c.Name == expectedName1775 isCanonical := c.Name == expectedName
1751 if isCanonical && c.Status == "running" {1776 if isCanonical && c.Status == "running" {
1752 log.Debug().1777 log.Debug().
1753 Str(zerowrap.FieldEntityID, c.ID).1778 Str(zerowrap.FieldEntityID, c.ID).
1779+ Str("container_name", c.Name).
1754 Str(zerowrap.FieldStatus, c.Status).1780 Str(zerowrap.FieldStatus, c.Status).
1755 Msg("skipping running canonical container during orphan cleanup")1781 Msg("skipping running canonical container during orphan cleanup")
1756 continue1782 continue
1757 }1783 }
1758 1784
1759- log.Info().Str(zerowrap.FieldEntityID, c.ID).Str(zerowrap.FieldStatus, c.Status).Msg("found orphaned container, removing")1785+ log.Info().Str(zerowrap.FieldEntityID, c.ID).Str("container_name", c.Name).Str(zerowrap.FieldStatus, c.Status).Msg("found orphaned container, removing")
1760 1786
1761 if err := s.runtime.StopContainer(ctx, c.ID); err != nil {1787 if err := s.runtime.StopContainer(ctx, c.ID); err != nil {
1762 log.WrapErrWithFields(err, "failed to stop orphaned container", map[string]any{zerowrap.FieldEntityID: c.ID})1788 log.WrapErrWithFields(err, "failed to stop orphaned container", map[string]any{zerowrap.FieldEntityID: c.ID})
@@ -2344,14 +2370,40 @@ func (s *Service) tryTCPProbe(ctx context.Context, containerID string, container
2344 return true, tcpProbe(ctx, addr, timeout)2370 return true, tcpProbe(ctx, addr, timeout)
2345 }2371 }
2346 2372
2347-// resolveContainerEndpoint retrieves the container's IP and first port via2373+// resolveContainerEndpoint returns a host-reachable address for probing.
2348-// GetContainerNetworkInfo. The result can be used for HTTP or TCP probes.2374+// In rootless podman/Docker setups, container internal IPs are not routable
2375+// from the host. We use the host port binding (127.0.0.1:<mapped_port>)
2376+// which is always reachable. Falls back to the container's internal IP
2377+// only if no host port mapping exists (e.g. host-network mode).
2349 func (s *Service) resolveContainerEndpoint(ctx context.Context, containerID string) (string, int, error) {2378 func (s *Service) resolveContainerEndpoint(ctx context.Context, containerID string) (string, int, error) {
2350- ip, port, err := s.runtime.GetContainerNetworkInfo(ctx, containerID)2379+ log := zerowrap.FromCtx(ctx)
2380+
2381+ // First, get the container's internal port from network info
2382+ _, internalPort, err := s.runtime.GetContainerNetworkInfo(ctx, containerID)
2351 if err != nil {2383 if err != nil {
2352 return "", 0, err2384 return "", 0, err
2353 }2385 }
2354- return ip, port, nil2386+
2387+ // Try to resolve via host port binding (works in rootless podman/Docker)
2388+ hostPort, hostErr := s.runtime.GetContainerPort(ctx, containerID, internalPort)
2389+ if hostErr == nil && hostPort > 0 {
2390+ log.Debug().
2391+ Int("internal_port", internalPort).
2392+ Int("host_port", hostPort).
2393+ Msg("resolved container endpoint via host port binding")
2394+ return "127.0.0.1", hostPort, nil
2395+ }
2396+
2397+ // Fallback: use internal IP (works in Docker rootful / host network)
2398+ ip, _, fallbackErr := s.runtime.GetContainerNetworkInfo(ctx, containerID)
2399+ if fallbackErr != nil {
2400+ return "", 0, fallbackErr
2401+ }
2402+ log.Debug().
2403+ Str("ip", ip).
2404+ Int("port", internalPort).
2405+ Msg("resolved container endpoint via internal IP (no host port binding)")
2406+ return ip, internalPort, nil
2355 }2407 }
2356 2408
2357 // waitForReadyByDelay waits using the legacy running+delay strategy.2409 // waitForReadyByDelay waits using the legacy running+delay strategy.
M internal/usecase/container/service_readiness_test.go +4 -0
@@ -70,6 +70,8 @@ func TestService_WaitForReady_AutoCascadeUsesTCPWhenNoHealthcheckAndNoHealthLabe
7070 runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once()
7171 // Cascade resolves container endpoint for TCP probe
7272 runtime.EXPECT().GetContainerNetworkInfo(mock.Anything, containerID).Return(probeIP, probePort, nil).Once()
73+ // Host port binding resolution — return the same loopback addr so probe hits it
74+ runtime.EXPECT().GetContainerPort(mock.Anything, containerID, probePort).Return(probePort, nil).Once()
7375
7476 // TCP probe will try to connect — which will fail since the port is closed.
7577 // Use a short health timeout so the test doesn't hang.
@@ -109,6 +111,8 @@ func TestService_WaitForReady_AutoCascadeUsesHTTPProbeWhenHealthLabelSet(t *test
109111 runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once()
110112 // Cascade resolves container endpoint for HTTP probe
111113 runtime.EXPECT().GetContainerNetworkInfo(mock.Anything, containerID).Return(probeIP, probePort, nil).Once()
114+ // Host port binding resolution — return the same loopback addr so probe hits it
115+ runtime.EXPECT().GetContainerPort(mock.Anything, containerID, probePort).Return(probePort, nil).Once()
112116
113117 err := svc.waitForReady(ctx, containerID, containerConfig)
114118 // Expect HTTP probe timeout (no server listening)
@@ -70,6 +70,8 @@ func TestService_WaitForReady_AutoCascadeUsesTCPWhenNoHealthcheckAndNoHealthLabe
70 runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once()70 runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once()
71 // Cascade resolves container endpoint for TCP probe71 // Cascade resolves container endpoint for TCP probe
72 runtime.EXPECT().GetContainerNetworkInfo(mock.Anything, containerID).Return(probeIP, probePort, nil).Once()72 runtime.EXPECT().GetContainerNetworkInfo(mock.Anything, containerID).Return(probeIP, probePort, nil).Once()
73+ // Host port binding resolution — return the same loopback addr so probe hits it
74+ runtime.EXPECT().GetContainerPort(mock.Anything, containerID, probePort).Return(probePort, nil).Once()
73 75
74 // TCP probe will try to connect — which will fail since the port is closed.76 // TCP probe will try to connect — which will fail since the port is closed.
75 // Use a short health timeout so the test doesn't hang.77 // Use a short health timeout so the test doesn't hang.
@@ -109,6 +111,8 @@ func TestService_WaitForReady_AutoCascadeUsesHTTPProbeWhenHealthLabelSet(t *test
109 runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once()111 runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once()
110 // Cascade resolves container endpoint for HTTP probe112 // Cascade resolves container endpoint for HTTP probe
111 runtime.EXPECT().GetContainerNetworkInfo(mock.Anything, containerID).Return(probeIP, probePort, nil).Once()113 runtime.EXPECT().GetContainerNetworkInfo(mock.Anything, containerID).Return(probeIP, probePort, nil).Once()
114+ // Host port binding resolution — return the same loopback addr so probe hits it
115+ runtime.EXPECT().GetContainerPort(mock.Anything, containerID, probePort).Return(probePort, nil).Once()
112 116
113 err := svc.waitForReady(ctx, containerID, containerConfig)117 err := svc.waitForReady(ctx, containerID, containerConfig)
114 // Expect HTTP probe timeout (no server listening)118 // Expect HTTP probe timeout (no server listening)

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