detached 36dfce1

Merge pull request #91 from bnema/feat/strict-zero-downtime-deploy

feat: strict zero-downtime deploy with readiness probes and rollback
commit
36dfce122655aa4fd3eaf41da6b185dd4ffa9c51
author
bnema · · 2026-03-05 05:19 UTC
committer
GitHub · · 2026-03-05 05:19 UTC
merge of
638943e + e0362ee · diff against the first parent
signature
unsigned
23 files · +2108 -173
M docs/config/index.md +0 -1
@@ -106,7 +106,6 @@ preserve = true # Keep volumes on container removal
106106 [network_isolation]
107107 enabled = true # Per-app isolated networks
108108 network_prefix = "gordon" # Network name prefix
109-dns_suffix = ".internal" # DNS suffix for services
110109
111110 # Auto-route
112111 [auto_route]
@@ -106,7 +106,6 @@ preserve = true # Keep volumes on container removal
106 [network_isolation]106 [network_isolation]
107 enabled = true # Per-app isolated networks107 enabled = true # Per-app isolated networks
108 network_prefix = "gordon" # Network name prefix108 network_prefix = "gordon" # Network name prefix
109-dns_suffix = ".internal" # DNS suffix for services
110 109
111 # Auto-route110 # Auto-route
112 [auto_route]111 [auto_route]
M docs/config/network-isolation.md +0 -16
@@ -8,7 +8,6 @@ Isolate applications in separate Docker networks for enhanced security.
88 [network_isolation]
99 enabled = true
1010 network_prefix = "gordon"
11-dns_suffix = ".internal"
1211 ```
1312
1413 ## Options
@@ -17,7 +16,6 @@ dns_suffix = ".internal"
1716 |--------|------|---------|-------------|
1817 | `enabled` | bool | `false` | Enable per-app network isolation |
1918 | `network_prefix` | string | `"gordon"` | Prefix for created networks |
20-| `dns_suffix` | string | `".internal"` | DNS suffix for service discovery |
2119
2220 ## How It Works
2321
@@ -99,19 +97,6 @@ db = connect("postgresql://postgres:5432/mydb")
9997 cache = connect("redis://redis:6379")
10098 ```
10199
102-## DNS Resolution
103-
104-The `dns_suffix` option adds a suffix for internal DNS resolution:
105-
106-```toml
107-[network_isolation]
108-dns_suffix = ".internal"
109-```
110-
111-Services can be accessed as:
112-- `postgres` (short form)
113-- `postgres.internal` (with suffix)
114-
115100 ## Examples
116101
117102 ### Basic Isolation
@@ -137,7 +122,6 @@ Each app gets its own network with its own database.
137122 [network_isolation]
138123 enabled = true
139124 network_prefix = "prod"
140-dns_suffix = ".internal"
141125
142126 [routes]
143127 "app.company.com" = "company-app:v2.1.0"
@@ -8,7 +8,6 @@ Isolate applications in separate Docker networks for enhanced security.
8 [network_isolation]8 [network_isolation]
9 enabled = true9 enabled = true
10 network_prefix = "gordon"10 network_prefix = "gordon"
11-dns_suffix = ".internal"
12 ```11 ```
13 12
14 ## Options13 ## Options
@@ -17,7 +16,6 @@ dns_suffix = ".internal"
17 |--------|------|---------|-------------|16 |--------|------|---------|-------------|
18 | `enabled` | bool | `false` | Enable per-app network isolation |17 | `enabled` | bool | `false` | Enable per-app network isolation |
19 | `network_prefix` | string | `"gordon"` | Prefix for created networks |18 | `network_prefix` | string | `"gordon"` | Prefix for created networks |
20-| `dns_suffix` | string | `".internal"` | DNS suffix for service discovery |
21 19
22 ## How It Works20 ## How It Works
23 21
@@ -99,19 +97,6 @@ db = connect("postgresql://postgres:5432/mydb")
99 cache = connect("redis://redis:6379")97 cache = connect("redis://redis:6379")
100 ```98 ```
101 99
102-## DNS Resolution
103-
104-The `dns_suffix` option adds a suffix for internal DNS resolution:
105-
106-```toml
107-[network_isolation]
108-dns_suffix = ".internal"
109-```
110-
111-Services can be accessed as:
112-- `postgres` (short form)
113-- `postgres.internal` (with suffix)
114-
115 ## Examples100 ## Examples
116 101
117 ### Basic Isolation102 ### Basic Isolation
@@ -137,7 +122,6 @@ Each app gets its own network with its own database.
137 [network_isolation]122 [network_isolation]
138 enabled = true123 enabled = true
139 network_prefix = "prod"124 network_prefix = "prod"
140-dns_suffix = ".internal"
141 125
142 [routes]126 [routes]
143 "app.company.com" = "company-app:v2.1.0"127 "app.company.com" = "company-app:v2.1.0"
M docs/config/reference.md +0 -2
@@ -105,7 +105,6 @@ enabled = false # Create routes from image labels a
105105 [network_isolation]
106106 enabled = false # Enable per-app Docker networks
107107 network_prefix = "gordon" # Prefix for created networks
108-dns_suffix = ".internal" # DNS suffix for internal resolution
109108
110109 # =============================================================================
111110 # VOLUMES
@@ -215,7 +214,6 @@ keep_last = 3 # Keep N newest tags per repository
215214 | `auto_route.enabled` | `false` | Auto-route disabled |
216215 | `network_isolation.enabled` | `false` | Isolation disabled |
217216 | `network_isolation.network_prefix` | `"gordon"` | Network prefix |
218-| `network_isolation.dns_suffix` | `".internal"` | DNS suffix |
219217 | `volumes.auto_create` | `true` | Auto-create volumes |
220218 | `volumes.prefix` | `"gordon"` | Volume prefix |
221219 | `volumes.preserve` | `true` | Keep volumes |
@@ -105,7 +105,6 @@ enabled = false # Create routes from image labels a
105 [network_isolation]105 [network_isolation]
106 enabled = false # Enable per-app Docker networks106 enabled = false # Enable per-app Docker networks
107 network_prefix = "gordon" # Prefix for created networks107 network_prefix = "gordon" # Prefix for created networks
108-dns_suffix = ".internal" # DNS suffix for internal resolution
109 108
110 # =============================================================================109 # =============================================================================
111 # VOLUMES110 # VOLUMES
@@ -215,7 +214,6 @@ keep_last = 3 # Keep N newest tags per repository
215 | `auto_route.enabled` | `false` | Auto-route disabled |214 | `auto_route.enabled` | `false` | Auto-route disabled |
216 | `network_isolation.enabled` | `false` | Isolation disabled |215 | `network_isolation.enabled` | `false` | Isolation disabled |
217 | `network_isolation.network_prefix` | `"gordon"` | Network prefix |216 | `network_isolation.network_prefix` | `"gordon"` | Network prefix |
218-| `network_isolation.dns_suffix` | `".internal"` | DNS suffix |
219 | `volumes.auto_create` | `true` | Auto-create volumes |217 | `volumes.auto_create` | `true` | Auto-create volumes |
220 | `volumes.prefix` | `"gordon"` | Volume prefix |218 | `volumes.prefix` | `"gordon"` | Volume prefix |
221 | `volumes.preserve` | `true` | Keep volumes |219 | `volumes.preserve` | `true` | Keep volumes |
M docs/reference/docker-labels.md +21 -1
@@ -41,7 +41,27 @@ Labels you can set in your Dockerfile:
4141
4242 | Label | Example | Description |
4343 |-------|---------|-------------|
44-| `gordon.proxy.port` | `"3000"` | Port to proxy HTTP traffic to |
44+| `gordon.domains` | `"app.example.com,www.app.example.com"` | Comma-separated domains for auto-route |
45+| `gordon.port` | `"3000"` | Port to proxy HTTP traffic to |
46+| `gordon.proxy.port` | `"3000"` | Port to proxy HTTP traffic to (legacy alias for `gordon.port`) |
47+| `gordon.health` | `"/healthz"` | HTTP health check endpoint path for readiness probing |
48+| `gordon.env-file` | `"/app/.env.example"` | Path to env template file inside the image |
49+
50+### Health Check Label
51+
52+When `gordon.health` is set, Gordon performs HTTP GET requests to the specified
53+path during deployment and waits for a 2xx or 3xx response before routing traffic
54+to the new container:
55+
56+```dockerfile
57+FROM node:20-alpine
58+LABEL gordon.health="/api/health"
59+EXPOSE 3000
60+CMD ["node", "server.js"]
61+```
62+
63+Gordon probes `http://<container-ip>:3000/api/health` until it gets a successful
64+response or the `deploy.http_probe_timeout` is reached.
4565
4666 ### Proxy Port Label
4767
@@ -41,7 +41,27 @@ Labels you can set in your Dockerfile:
41 41
42 | Label | Example | Description |42 | Label | Example | Description |
43 |-------|---------|-------------|43 |-------|---------|-------------|
44-| `gordon.proxy.port` | `"3000"` | Port to proxy HTTP traffic to |44+| `gordon.domains` | `"app.example.com,www.app.example.com"` | Comma-separated domains for auto-route |
45+| `gordon.port` | `"3000"` | Port to proxy HTTP traffic to |
46+| `gordon.proxy.port` | `"3000"` | Port to proxy HTTP traffic to (legacy alias for `gordon.port`) |
47+| `gordon.health` | `"/healthz"` | HTTP health check endpoint path for readiness probing |
48+| `gordon.env-file` | `"/app/.env.example"` | Path to env template file inside the image |
49+
50+### Health Check Label
51+
52+When `gordon.health` is set, Gordon performs HTTP GET requests to the specified
53+path during deployment and waits for a 2xx or 3xx response before routing traffic
54+to the new container:
55+
56+```dockerfile
57+FROM node:20-alpine
58+LABEL gordon.health="/api/health"
59+EXPOSE 3000
60+CMD ["node", "server.js"]
61+```
62+
63+Gordon probes `http://<container-ip>:3000/api/health` until it gets a successful
64+response or the `deploy.http_probe_timeout` is reached.
45 65
46 ### Proxy Port Label66 ### Proxy Port Label
47 67
M internal/adapters/in/cli/controlplane.go +1 -0
@@ -34,6 +34,7 @@ type ControlPlane interface {
3434
3535 GetStatus(ctx context.Context) (*remote.Status, error)
3636 Reload(ctx context.Context) error
37+ DeployIntent(ctx context.Context, imageName string) error
3738 Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error)
3839 Restart(ctx context.Context, restartDomain string, withAttachments bool) (*remote.RestartResult, error)
3940 ListTags(ctx context.Context, repository string) ([]string, error)
@@ -34,6 +34,7 @@ type ControlPlane interface {
34 34
35 GetStatus(ctx context.Context) (*remote.Status, error)35 GetStatus(ctx context.Context) (*remote.Status, error)
36 Reload(ctx context.Context) error36 Reload(ctx context.Context) error
37+ DeployIntent(ctx context.Context, imageName string) error
37 Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error)38 Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error)
38 Restart(ctx context.Context, restartDomain string, withAttachments bool) (*remote.RestartResult, error)39 Restart(ctx context.Context, restartDomain string, withAttachments bool) (*remote.RestartResult, error)
39 ListTags(ctx context.Context, repository string) ([]string, error)40 ListTags(ctx context.Context, repository string) ([]string, error)
M internal/adapters/in/cli/controlplane_local.go +18 -1
@@ -18,6 +18,7 @@ type localControlPlane struct {
1818 containerSvc in.ContainerService
1919 backupSvc in.BackupService
2020 registrySvc in.RegistryService
21+ deployCoord in.DeployCoordinator
2122 healthSvc in.HealthService
2223 logSvc in.LogService
2324 }
@@ -27,12 +28,21 @@ func NewLocalControlPlane(kernel *app.Kernel) ControlPlane {
2728 return &localControlPlane{}
2829 }
2930
31+ registrySvc := kernel.Registry()
32+ var deployCoord in.DeployCoordinator
33+ if registrySvc != nil {
34+ if coordinator, ok := any(registrySvc).(in.DeployCoordinator); ok {
35+ deployCoord = coordinator
36+ }
37+ }
38+
3039 return &localControlPlane{
3140 configSvc: kernel.Config(),
3241 secretSvc: kernel.Secrets(),
3342 containerSvc: kernel.Container(),
3443 backupSvc: kernel.Backup(),
35- registrySvc: kernel.Registry(),
44+ registrySvc: registrySvc,
45+ deployCoord: deployCoord,
3646 healthSvc: kernel.Health(),
3747 logSvc: kernel.Logs(),
3848 }
@@ -223,6 +233,13 @@ func (l *localControlPlane) Reload(_ context.Context) error {
223233 return app.SendReloadSignal()
224234 }
225235
236+func (l *localControlPlane) DeployIntent(_ context.Context, imageName string) error {
237+ if l.deployCoord != nil {
238+ l.deployCoord.SuppressDeployEvent(imageName)
239+ }
240+ return nil
241+}
242+
226243 func (l *localControlPlane) Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error) {
227244 if l.containerSvc != nil && l.configSvc != nil {
228245 route, err := l.configSvc.GetRoute(ctx, deployDomain)
@@ -18,6 +18,7 @@ type localControlPlane struct {
18 containerSvc in.ContainerService18 containerSvc in.ContainerService
19 backupSvc in.BackupService19 backupSvc in.BackupService
20 registrySvc in.RegistryService20 registrySvc in.RegistryService
21+ deployCoord in.DeployCoordinator
21 healthSvc in.HealthService22 healthSvc in.HealthService
22 logSvc in.LogService23 logSvc in.LogService
23 }24 }
@@ -27,12 +28,21 @@ func NewLocalControlPlane(kernel *app.Kernel) ControlPlane {
27 return &localControlPlane{}28 return &localControlPlane{}
28 }29 }
29 30
31+ registrySvc := kernel.Registry()
32+ var deployCoord in.DeployCoordinator
33+ if registrySvc != nil {
34+ if coordinator, ok := any(registrySvc).(in.DeployCoordinator); ok {
35+ deployCoord = coordinator
36+ }
37+ }
38+
30 return &localControlPlane{39 return &localControlPlane{
31 configSvc: kernel.Config(),40 configSvc: kernel.Config(),
32 secretSvc: kernel.Secrets(),41 secretSvc: kernel.Secrets(),
33 containerSvc: kernel.Container(),42 containerSvc: kernel.Container(),
34 backupSvc: kernel.Backup(),43 backupSvc: kernel.Backup(),
35- registrySvc: kernel.Registry(),44+ registrySvc: registrySvc,
45+ deployCoord: deployCoord,
36 healthSvc: kernel.Health(),46 healthSvc: kernel.Health(),
37 logSvc: kernel.Logs(),47 logSvc: kernel.Logs(),
38 }48 }
@@ -223,6 +233,13 @@ func (l *localControlPlane) Reload(_ context.Context) error {
223 return app.SendReloadSignal()233 return app.SendReloadSignal()
224 }234 }
225 235
236+func (l *localControlPlane) DeployIntent(_ context.Context, imageName string) error {
237+ if l.deployCoord != nil {
238+ l.deployCoord.SuppressDeployEvent(imageName)
239+ }
240+ return nil
241+}
242+
226 func (l *localControlPlane) Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error) {243 func (l *localControlPlane) Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error) {
227 if l.containerSvc != nil && l.configSvc != nil {244 if l.containerSvc != nil && l.configSvc != nil {
228 route, err := l.configSvc.GetRoute(ctx, deployDomain)245 route, err := l.configSvc.GetRoute(ctx, deployDomain)
M internal/adapters/in/cli/controlplane_remote.go +4 -0
@@ -88,6 +88,10 @@ func (r *remoteControlPlane) Reload(ctx context.Context) error {
8888 return r.client.Reload(ctx)
8989 }
9090
91+func (r *remoteControlPlane) DeployIntent(ctx context.Context, imageName string) error {
92+ return r.client.DeployIntent(ctx, imageName)
93+}
94+
9195 func (r *remoteControlPlane) Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error) {
9296 return r.client.Deploy(ctx, deployDomain)
9397 }
@@ -88,6 +88,10 @@ func (r *remoteControlPlane) Reload(ctx context.Context) error {
88 return r.client.Reload(ctx)88 return r.client.Reload(ctx)
89 }89 }
90 90
91+func (r *remoteControlPlane) DeployIntent(ctx context.Context, imageName string) error {
92+ return r.client.DeployIntent(ctx, imageName)
93+}
94+
91 func (r *remoteControlPlane) Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error) {95 func (r *remoteControlPlane) Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error) {
92 return r.client.Deploy(ctx, deployDomain)96 return r.client.Deploy(ctx, deployDomain)
93 }97 }
M internal/adapters/in/cli/push.go +9 -0
@@ -154,6 +154,15 @@ func runPush(ctx context.Context, imageArg, domainFlag, tag string, build bool,
154154 }
155155 fmt.Printf("Domain: %s\n", styles.Theme.Bold.Render(pushDomain))
156156
157+ // Signal the server to suppress event-based deploys for this image.
158+ // The CLI will trigger an explicit deploy after push completes.
159+ if !noDeploy {
160+ if err := handle.plane.DeployIntent(ctx, imageName); err != nil {
161+ // Non-fatal: worst case we get a redundant deploy via event
162+ fmt.Fprintf(os.Stderr, "warning: failed to register deploy intent: %v\n", err)
163+ }
164+ }
165+
157166 if build {
158167 if err := buildAndPush(ctx, version, platform, dockerfile, buildArgs, versionRef, latestRef); err != nil {
159168 return err
@@ -154,6 +154,15 @@ func runPush(ctx context.Context, imageArg, domainFlag, tag string, build bool,
154 }154 }
155 fmt.Printf("Domain: %s\n", styles.Theme.Bold.Render(pushDomain))155 fmt.Printf("Domain: %s\n", styles.Theme.Bold.Render(pushDomain))
156 156
157+ // Signal the server to suppress event-based deploys for this image.
158+ // The CLI will trigger an explicit deploy after push completes.
159+ if !noDeploy {
160+ if err := handle.plane.DeployIntent(ctx, imageName); err != nil {
161+ // Non-fatal: worst case we get a redundant deploy via event
162+ fmt.Fprintf(os.Stderr, "warning: failed to register deploy intent: %v\n", err)
163+ }
164+ }
165+
157 if build {166 if build {
158 if err := buildAndPush(ctx, version, platform, dockerfile, buildArgs, versionRef, latestRef); err != nil {167 if err := buildAndPush(ctx, version, platform, dockerfile, buildArgs, versionRef, latestRef); err != nil {
159 return err168 return err
M internal/adapters/in/cli/remote/client.go +14 -0
@@ -724,6 +724,20 @@ func (c *Client) Deploy(ctx context.Context, deployDomain string) (*DeployResult
724724 return &result, nil
725725 }
726726
727+// DeployIntent tells the server that a CLI-managed push is about to happen,
728+// suppressing event-based deploys for this image.
729+func (c *Client) DeployIntent(ctx context.Context, imageName string) error {
730+ imageName = strings.TrimSpace(imageName)
731+ if imageName == "" {
732+ return fmt.Errorf("image name cannot be empty")
733+ }
734+ resp, err := c.requestWithRetry(ctx, http.MethodPost, "/deploy-intent/"+url.PathEscape(imageName), nil)
735+ if err != nil {
736+ return err
737+ }
738+ return parseResponse(resp, nil)
739+}
740+
727741 // Restart API
728742
729743 // RestartResult contains the result of a restart.
@@ -724,6 +724,20 @@ func (c *Client) Deploy(ctx context.Context, deployDomain string) (*DeployResult
724 return &result, nil724 return &result, nil
725 }725 }
726 726
727+// DeployIntent tells the server that a CLI-managed push is about to happen,
728+// suppressing event-based deploys for this image.
729+func (c *Client) DeployIntent(ctx context.Context, imageName string) error {
730+ imageName = strings.TrimSpace(imageName)
731+ if imageName == "" {
732+ return fmt.Errorf("image name cannot be empty")
733+ }
734+ resp, err := c.requestWithRetry(ctx, http.MethodPost, "/deploy-intent/"+url.PathEscape(imageName), nil)
735+ if err != nil {
736+ return err
737+ }
738+ return parseResponse(resp, nil)
739+}
740+
727 // Restart API741 // Restart API
728 742
729 // RestartResult contains the result of a restart.743 // RestartResult contains the result of a restart.
M internal/adapters/in/http/admin/handler.go +60 -2
@@ -7,6 +7,7 @@ import (
77 "fmt"
88 "io"
99 "net/http"
10+ "net/url"
1011 "strconv"
1112 "strings"
1213 "time"
@@ -27,6 +28,11 @@ const maxAdminRequestSize = 1 << 20 // 1MB
2728 // maxLogLines is the maximum allowed number of log lines that can be requested.
2829 const maxLogLines = 10000
2930
31+type registryDeployService interface {
32+ in.RegistryService
33+ in.DeployCoordinator
34+}
35+
3036 // Handler implements the HTTP handler for the admin API.
3137 type Handler struct {
3238 configSvc in.ConfigService
@@ -37,7 +43,7 @@ type Handler struct {
3743 healthSvc in.HealthService
3844 secretSvc in.SecretService
3945 logSvc in.LogService
40- registrySvc in.RegistryService
46+ registrySvc registryDeployService
4147 eventBus out.EventPublisher
4248 log zerowrap.Logger
4349 }
@@ -129,7 +135,7 @@ func NewHandler(
129135 healthSvc in.HealthService,
130136 secretSvc in.SecretService,
131137 logSvc in.LogService,
132- registrySvc in.RegistryService,
138+ registrySvc registryDeployService,
133139 eventBus out.EventPublisher,
134140 log zerowrap.Logger,
135141 backupSvc in.BackupService,
@@ -213,6 +219,7 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) {
213219 {"/routes/by-image", h.handleRoutesByImage},
214220 {"/routes", h.handleRoutes},
215221 {"/secrets", h.handleSecrets},
222+ {"/deploy-intent", h.handleDeployIntent},
216223 {"/deploy", h.handleDeploy},
217224 {"/restart", h.handleRestart},
218225 {"/tags", h.handleTags},
@@ -228,6 +235,48 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) {
228235 return nil, false
229236 }
230237
238+// handleDeployIntent handles /admin/deploy-intent/:image endpoint.
239+// It registers a deploy intent, suppressing event-based deploys for the image.
240+func (h *Handler) handleDeployIntent(w http.ResponseWriter, r *http.Request, path string) {
241+ if r.Method != http.MethodPost {
242+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
243+ return
244+ }
245+
246+ ctx := r.Context()
247+ if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
248+ h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
249+ return
250+ }
251+
252+ if h.registrySvc == nil {
253+ h.sendError(w, http.StatusServiceUnavailable, "registry service unavailable")
254+ return
255+ }
256+
257+ rawName := strings.TrimPrefix(path, "/deploy-intent/")
258+ if rawName == "" || rawName == "/deploy-intent" {
259+ h.sendError(w, http.StatusBadRequest, "image name required")
260+ return
261+ }
262+
263+ imageName, err := url.PathUnescape(rawName)
264+ if err != nil {
265+ h.sendError(w, http.StatusBadRequest, "invalid image name encoding")
266+ return
267+ }
268+
269+ log := zerowrap.FromCtx(ctx)
270+ log.Info().Str("image", imageName).Msg("deploy intent registered, suppressing image.pushed events")
271+
272+ h.registrySvc.SuppressDeployEvent(imageName)
273+
274+ h.sendJSON(w, http.StatusOK, map[string]string{
275+ "status": "ok",
276+ "image": imageName,
277+ })
278+}
279+
231280 // sendJSON sends a JSON response.
232281 func (h *Handler) sendJSON(w http.ResponseWriter, status int, data any) {
233282 w.Header().Set("Content-Type", "application/json")
@@ -1078,6 +1127,15 @@ func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request, path stri
10781127 return
10791128 }
10801129
1130+ // Clear deploy event suppression now that the explicit deploy has completed.
1131+ // This re-enables event-based deploys for future direct docker pushes.
1132+ if route.Image != "" && h.registrySvc != nil {
1133+ // Use the registry package's image name normaliser so digest-form refs
1134+ // and multi-segment paths are handled correctly.
1135+ imageName := registry.ExtractImageName(route.Image)
1136+ h.registrySvc.ClearDeployEventSuppression(imageName)
1137+ }
1138+
10811139 log.Info().Str("domain", deployDomain).Str("container_id", container.ID).Msg("container deployed via admin API")
10821140 h.sendJSON(w, http.StatusOK, dto.DeployResponse{
10831141 Status: "deployed",
@@ -7,6 +7,7 @@ import (
7 "fmt"7 "fmt"
8 "io"8 "io"
9 "net/http"9 "net/http"
10+ "net/url"
10 "strconv"11 "strconv"
11 "strings"12 "strings"
12 "time"13 "time"
@@ -27,6 +28,11 @@ const maxAdminRequestSize = 1 << 20 // 1MB
27 // maxLogLines is the maximum allowed number of log lines that can be requested.28 // maxLogLines is the maximum allowed number of log lines that can be requested.
28 const maxLogLines = 1000029 const maxLogLines = 10000
29 30
31+type registryDeployService interface {
32+ in.RegistryService
33+ in.DeployCoordinator
34+}
35+
30 // Handler implements the HTTP handler for the admin API.36 // Handler implements the HTTP handler for the admin API.
31 type Handler struct {37 type Handler struct {
32 configSvc in.ConfigService38 configSvc in.ConfigService
@@ -37,7 +43,7 @@ type Handler struct {
37 healthSvc in.HealthService43 healthSvc in.HealthService
38 secretSvc in.SecretService44 secretSvc in.SecretService
39 logSvc in.LogService45 logSvc in.LogService
40- registrySvc in.RegistryService46+ registrySvc registryDeployService
41 eventBus out.EventPublisher47 eventBus out.EventPublisher
42 log zerowrap.Logger48 log zerowrap.Logger
43 }49 }
@@ -129,7 +135,7 @@ func NewHandler(
129 healthSvc in.HealthService,135 healthSvc in.HealthService,
130 secretSvc in.SecretService,136 secretSvc in.SecretService,
131 logSvc in.LogService,137 logSvc in.LogService,
132- registrySvc in.RegistryService,138+ registrySvc registryDeployService,
133 eventBus out.EventPublisher,139 eventBus out.EventPublisher,
134 log zerowrap.Logger,140 log zerowrap.Logger,
135 backupSvc in.BackupService,141 backupSvc in.BackupService,
@@ -213,6 +219,7 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) {
213 {"/routes/by-image", h.handleRoutesByImage},219 {"/routes/by-image", h.handleRoutesByImage},
214 {"/routes", h.handleRoutes},220 {"/routes", h.handleRoutes},
215 {"/secrets", h.handleSecrets},221 {"/secrets", h.handleSecrets},
222+ {"/deploy-intent", h.handleDeployIntent},
216 {"/deploy", h.handleDeploy},223 {"/deploy", h.handleDeploy},
217 {"/restart", h.handleRestart},224 {"/restart", h.handleRestart},
218 {"/tags", h.handleTags},225 {"/tags", h.handleTags},
@@ -228,6 +235,48 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) {
228 return nil, false235 return nil, false
229 }236 }
230 237
238+// handleDeployIntent handles /admin/deploy-intent/:image endpoint.
239+// It registers a deploy intent, suppressing event-based deploys for the image.
240+func (h *Handler) handleDeployIntent(w http.ResponseWriter, r *http.Request, path string) {
241+ if r.Method != http.MethodPost {
242+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
243+ return
244+ }
245+
246+ ctx := r.Context()
247+ if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
248+ h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
249+ return
250+ }
251+
252+ if h.registrySvc == nil {
253+ h.sendError(w, http.StatusServiceUnavailable, "registry service unavailable")
254+ return
255+ }
256+
257+ rawName := strings.TrimPrefix(path, "/deploy-intent/")
258+ if rawName == "" || rawName == "/deploy-intent" {
259+ h.sendError(w, http.StatusBadRequest, "image name required")
260+ return
261+ }
262+
263+ imageName, err := url.PathUnescape(rawName)
264+ if err != nil {
265+ h.sendError(w, http.StatusBadRequest, "invalid image name encoding")
266+ return
267+ }
268+
269+ log := zerowrap.FromCtx(ctx)
270+ log.Info().Str("image", imageName).Msg("deploy intent registered, suppressing image.pushed events")
271+
272+ h.registrySvc.SuppressDeployEvent(imageName)
273+
274+ h.sendJSON(w, http.StatusOK, map[string]string{
275+ "status": "ok",
276+ "image": imageName,
277+ })
278+}
279+
231 // sendJSON sends a JSON response.280 // sendJSON sends a JSON response.
232 func (h *Handler) sendJSON(w http.ResponseWriter, status int, data any) {281 func (h *Handler) sendJSON(w http.ResponseWriter, status int, data any) {
233 w.Header().Set("Content-Type", "application/json")282 w.Header().Set("Content-Type", "application/json")
@@ -1078,6 +1127,15 @@ func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request, path stri
1078 return1127 return
1079 }1128 }
1080 1129
1130+ // Clear deploy event suppression now that the explicit deploy has completed.
1131+ // This re-enables event-based deploys for future direct docker pushes.
1132+ if route.Image != "" && h.registrySvc != nil {
1133+ // Use the registry package's image name normaliser so digest-form refs
1134+ // and multi-segment paths are handled correctly.
1135+ imageName := registry.ExtractImageName(route.Image)
1136+ h.registrySvc.ClearDeployEventSuppression(imageName)
1137+ }
1138+
1081 log.Info().Str("domain", deployDomain).Str("container_id", container.ID).Msg("container deployed via admin API")1139 log.Info().Str("domain", deployDomain).Str("container_id", container.ID).Msg("container deployed via admin API")
1082 h.sendJSON(w, http.StatusOK, dto.DeployResponse{1140 h.sendJSON(w, http.StatusOK, dto.DeployResponse{
1083 Status: "deployed",1141 Status: "deployed",
M internal/app/run.go +6 -2
@@ -1187,12 +1187,14 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc
11871187 VolumePreserve: v.GetBool("volumes.preserve"),
11881188 NetworkIsolation: v.GetBool("network_isolation.enabled"),
11891189 NetworkPrefix: v.GetString("network_isolation.network_prefix"),
1190- DNSSuffix: v.GetString("network_isolation.dns_suffix"),
11911190 NetworkGroups: svc.configSvc.GetNetworkGroups(),
11921191 Attachments: svc.configSvc.GetAttachments(),
11931192 ReadinessDelay: v.GetDuration("deploy.readiness_delay"),
11941193 ReadinessMode: v.GetString("deploy.readiness_mode"),
11951194 HealthTimeout: v.GetDuration("deploy.health_timeout"),
1195+ StabilizationDelay: v.GetDuration("deploy.stabilization_delay"),
1196+ TCPProbeTimeout: v.GetDuration("deploy.tcp_probe_timeout"),
1197+ HTTPProbeTimeout: v.GetDuration("deploy.http_probe_timeout"),
11961198 DrainDelay: v.GetDuration("deploy.drain_delay"),
11971199 DrainMode: v.GetString("deploy.drain_mode"),
11981200 DrainTimeout: v.GetDuration("deploy.drain_timeout"),
@@ -2346,7 +2348,6 @@ func loadConfig(v *viper.Viper, configPath string) error {
23462348 v.SetDefault("auto_route.enabled", false)
23472349 v.SetDefault("network_isolation.enabled", false)
23482350 v.SetDefault("network_isolation.network_prefix", "gordon")
2349- v.SetDefault("network_isolation.dns_suffix", ".internal")
23502351 v.SetDefault("volumes.auto_create", true)
23512352 v.SetDefault("volumes.prefix", "gordon")
23522353 v.SetDefault("volumes.preserve", true)
@@ -2374,6 +2375,9 @@ func loadConfig(v *viper.Viper, configPath string) error {
23742375 v.SetDefault("deploy.readiness_delay", "5s")
23752376 v.SetDefault("deploy.readiness_mode", "auto")
23762377 v.SetDefault("deploy.health_timeout", "90s")
2378+ v.SetDefault("deploy.stabilization_delay", "2s")
2379+ v.SetDefault("deploy.tcp_probe_timeout", "30s")
2380+ v.SetDefault("deploy.http_probe_timeout", "60s")
23772381 v.SetDefault("deploy.drain_mode", "auto")
23782382 v.SetDefault("deploy.drain_timeout", "30s")
23792383
@@ -1187,12 +1187,14 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc
1187 VolumePreserve: v.GetBool("volumes.preserve"),1187 VolumePreserve: v.GetBool("volumes.preserve"),
1188 NetworkIsolation: v.GetBool("network_isolation.enabled"),1188 NetworkIsolation: v.GetBool("network_isolation.enabled"),
1189 NetworkPrefix: v.GetString("network_isolation.network_prefix"),1189 NetworkPrefix: v.GetString("network_isolation.network_prefix"),
1190- DNSSuffix: v.GetString("network_isolation.dns_suffix"),
1191 NetworkGroups: svc.configSvc.GetNetworkGroups(),1190 NetworkGroups: svc.configSvc.GetNetworkGroups(),
1192 Attachments: svc.configSvc.GetAttachments(),1191 Attachments: svc.configSvc.GetAttachments(),
1193 ReadinessDelay: v.GetDuration("deploy.readiness_delay"),1192 ReadinessDelay: v.GetDuration("deploy.readiness_delay"),
1194 ReadinessMode: v.GetString("deploy.readiness_mode"),1193 ReadinessMode: v.GetString("deploy.readiness_mode"),
1195 HealthTimeout: v.GetDuration("deploy.health_timeout"),1194 HealthTimeout: v.GetDuration("deploy.health_timeout"),
1195+ StabilizationDelay: v.GetDuration("deploy.stabilization_delay"),
1196+ TCPProbeTimeout: v.GetDuration("deploy.tcp_probe_timeout"),
1197+ HTTPProbeTimeout: v.GetDuration("deploy.http_probe_timeout"),
1196 DrainDelay: v.GetDuration("deploy.drain_delay"),1198 DrainDelay: v.GetDuration("deploy.drain_delay"),
1197 DrainMode: v.GetString("deploy.drain_mode"),1199 DrainMode: v.GetString("deploy.drain_mode"),
1198 DrainTimeout: v.GetDuration("deploy.drain_timeout"),1200 DrainTimeout: v.GetDuration("deploy.drain_timeout"),
@@ -2346,7 +2348,6 @@ func loadConfig(v *viper.Viper, configPath string) error {
2346 v.SetDefault("auto_route.enabled", false)2348 v.SetDefault("auto_route.enabled", false)
2347 v.SetDefault("network_isolation.enabled", false)2349 v.SetDefault("network_isolation.enabled", false)
2348 v.SetDefault("network_isolation.network_prefix", "gordon")2350 v.SetDefault("network_isolation.network_prefix", "gordon")
2349- v.SetDefault("network_isolation.dns_suffix", ".internal")
2350 v.SetDefault("volumes.auto_create", true)2351 v.SetDefault("volumes.auto_create", true)
2351 v.SetDefault("volumes.prefix", "gordon")2352 v.SetDefault("volumes.prefix", "gordon")
2352 v.SetDefault("volumes.preserve", true)2353 v.SetDefault("volumes.preserve", true)
@@ -2374,6 +2375,9 @@ func loadConfig(v *viper.Viper, configPath string) error {
2374 v.SetDefault("deploy.readiness_delay", "5s")2375 v.SetDefault("deploy.readiness_delay", "5s")
2375 v.SetDefault("deploy.readiness_mode", "auto")2376 v.SetDefault("deploy.readiness_mode", "auto")
2376 v.SetDefault("deploy.health_timeout", "90s")2377 v.SetDefault("deploy.health_timeout", "90s")
2378+ v.SetDefault("deploy.stabilization_delay", "2s")
2379+ v.SetDefault("deploy.tcp_probe_timeout", "30s")
2380+ v.SetDefault("deploy.http_probe_timeout", "60s")
2377 v.SetDefault("deploy.drain_mode", "auto")2381 v.SetDefault("deploy.drain_mode", "auto")
2378 v.SetDefault("deploy.drain_timeout", "30s")2382 v.SetDefault("deploy.drain_timeout", "30s")
2379 2383
A internal/boundaries/in/deploy.go +10 -0
@@ -0,0 +1,10 @@
1+package in
2+
3+// DeployCoordinator defines deploy coordination operations.
4+//
5+// These methods let the CLI/admin API suppress image-pushed deploy events
6+// while an explicit deploy flow is in progress.
7+type DeployCoordinator interface {
8+ SuppressDeployEvent(imageName string)
9+ ClearDeployEventSuppression(imageName string)
10+}
@@ -0,0 +1,10 @@
1+package in
2+
3+// DeployCoordinator defines deploy coordination operations.
4+//
5+// These methods let the CLI/admin API suppress image-pushed deploy events
6+// while an explicit deploy flow is in progress.
7+type DeployCoordinator interface {
8+ SuppressDeployEvent(imageName string)
9+ ClearDeployEventSuppression(imageName string)
10+}
A internal/boundaries/in/mocks/mock_deploy_coordinator.go +110 -0
@@ -0,0 +1,110 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ mock "github.com/stretchr/testify/mock"
9+)
10+
11+// NewMockDeployCoordinator creates a new instance of MockDeployCoordinator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12+// The first argument is typically a *testing.T value.
13+func NewMockDeployCoordinator(t interface {
14+ mock.TestingT
15+ Cleanup(func())
16+}) *MockDeployCoordinator {
17+ mock := &MockDeployCoordinator{}
18+ mock.Mock.Test(t)
19+
20+ t.Cleanup(func() { mock.AssertExpectations(t) })
21+
22+ return mock
23+}
24+
25+// MockDeployCoordinator is an autogenerated mock type for the DeployCoordinator type.
26+type MockDeployCoordinator struct {
27+ mock.Mock
28+}
29+
30+type MockDeployCoordinator_Expecter struct {
31+ mock *mock.Mock
32+}
33+
34+func (_m *MockDeployCoordinator) EXPECT() *MockDeployCoordinator_Expecter {
35+ return &MockDeployCoordinator_Expecter{mock: &_m.Mock}
36+}
37+
38+// ClearDeployEventSuppression provides a mock function for the type MockDeployCoordinator.
39+func (_mock *MockDeployCoordinator) ClearDeployEventSuppression(imageName string) {
40+ _mock.Called(imageName)
41+}
42+
43+// MockDeployCoordinator_ClearDeployEventSuppression_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClearDeployEventSuppression'.
44+type MockDeployCoordinator_ClearDeployEventSuppression_Call struct {
45+ *mock.Call
46+}
47+
48+// ClearDeployEventSuppression is a helper method to define mock.On call.
49+// - imageName string
50+func (_e *MockDeployCoordinator_Expecter) ClearDeployEventSuppression(imageName interface{}) *MockDeployCoordinator_ClearDeployEventSuppression_Call {
51+ return &MockDeployCoordinator_ClearDeployEventSuppression_Call{Call: _e.mock.On("ClearDeployEventSuppression", imageName)}
52+}
53+
54+func (_c *MockDeployCoordinator_ClearDeployEventSuppression_Call) Run(run func(imageName string)) *MockDeployCoordinator_ClearDeployEventSuppression_Call {
55+ _c.Call.Run(func(args mock.Arguments) {
56+ var arg0 string
57+ if args[0] != nil {
58+ arg0 = args[0].(string)
59+ }
60+ run(arg0)
61+ })
62+ return _c
63+}
64+
65+func (_c *MockDeployCoordinator_ClearDeployEventSuppression_Call) Return() *MockDeployCoordinator_ClearDeployEventSuppression_Call {
66+ _c.Call.Return()
67+ return _c
68+}
69+
70+func (_c *MockDeployCoordinator_ClearDeployEventSuppression_Call) RunAndReturn(run func(imageName string)) *MockDeployCoordinator_ClearDeployEventSuppression_Call {
71+ _c.Run(run)
72+ return _c
73+}
74+
75+// SuppressDeployEvent provides a mock function for the type MockDeployCoordinator.
76+func (_mock *MockDeployCoordinator) SuppressDeployEvent(imageName string) {
77+ _mock.Called(imageName)
78+}
79+
80+// MockDeployCoordinator_SuppressDeployEvent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SuppressDeployEvent'.
81+type MockDeployCoordinator_SuppressDeployEvent_Call struct {
82+ *mock.Call
83+}
84+
85+// SuppressDeployEvent is a helper method to define mock.On call.
86+// - imageName string
87+func (_e *MockDeployCoordinator_Expecter) SuppressDeployEvent(imageName interface{}) *MockDeployCoordinator_SuppressDeployEvent_Call {
88+ return &MockDeployCoordinator_SuppressDeployEvent_Call{Call: _e.mock.On("SuppressDeployEvent", imageName)}
89+}
90+
91+func (_c *MockDeployCoordinator_SuppressDeployEvent_Call) Run(run func(imageName string)) *MockDeployCoordinator_SuppressDeployEvent_Call {
92+ _c.Call.Run(func(args mock.Arguments) {
93+ var arg0 string
94+ if args[0] != nil {
95+ arg0 = args[0].(string)
96+ }
97+ run(arg0)
98+ })
99+ return _c
100+}
101+
102+func (_c *MockDeployCoordinator_SuppressDeployEvent_Call) Return() *MockDeployCoordinator_SuppressDeployEvent_Call {
103+ _c.Call.Return()
104+ return _c
105+}
106+
107+func (_c *MockDeployCoordinator_SuppressDeployEvent_Call) RunAndReturn(run func(imageName string)) *MockDeployCoordinator_SuppressDeployEvent_Call {
108+ _c.Run(run)
109+ return _c
110+}
@@ -0,0 +1,110 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ mock "github.com/stretchr/testify/mock"
9+)
10+
11+// NewMockDeployCoordinator creates a new instance of MockDeployCoordinator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12+// The first argument is typically a *testing.T value.
13+func NewMockDeployCoordinator(t interface {
14+ mock.TestingT
15+ Cleanup(func())
16+}) *MockDeployCoordinator {
17+ mock := &MockDeployCoordinator{}
18+ mock.Mock.Test(t)
19+
20+ t.Cleanup(func() { mock.AssertExpectations(t) })
21+
22+ return mock
23+}
24+
25+// MockDeployCoordinator is an autogenerated mock type for the DeployCoordinator type.
26+type MockDeployCoordinator struct {
27+ mock.Mock
28+}
29+
30+type MockDeployCoordinator_Expecter struct {
31+ mock *mock.Mock
32+}
33+
34+func (_m *MockDeployCoordinator) EXPECT() *MockDeployCoordinator_Expecter {
35+ return &MockDeployCoordinator_Expecter{mock: &_m.Mock}
36+}
37+
38+// ClearDeployEventSuppression provides a mock function for the type MockDeployCoordinator.
39+func (_mock *MockDeployCoordinator) ClearDeployEventSuppression(imageName string) {
40+ _mock.Called(imageName)
41+}
42+
43+// MockDeployCoordinator_ClearDeployEventSuppression_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClearDeployEventSuppression'.
44+type MockDeployCoordinator_ClearDeployEventSuppression_Call struct {
45+ *mock.Call
46+}
47+
48+// ClearDeployEventSuppression is a helper method to define mock.On call.
49+// - imageName string
50+func (_e *MockDeployCoordinator_Expecter) ClearDeployEventSuppression(imageName interface{}) *MockDeployCoordinator_ClearDeployEventSuppression_Call {
51+ return &MockDeployCoordinator_ClearDeployEventSuppression_Call{Call: _e.mock.On("ClearDeployEventSuppression", imageName)}
52+}
53+
54+func (_c *MockDeployCoordinator_ClearDeployEventSuppression_Call) Run(run func(imageName string)) *MockDeployCoordinator_ClearDeployEventSuppression_Call {
55+ _c.Call.Run(func(args mock.Arguments) {
56+ var arg0 string
57+ if args[0] != nil {
58+ arg0 = args[0].(string)
59+ }
60+ run(arg0)
61+ })
62+ return _c
63+}
64+
65+func (_c *MockDeployCoordinator_ClearDeployEventSuppression_Call) Return() *MockDeployCoordinator_ClearDeployEventSuppression_Call {
66+ _c.Call.Return()
67+ return _c
68+}
69+
70+func (_c *MockDeployCoordinator_ClearDeployEventSuppression_Call) RunAndReturn(run func(imageName string)) *MockDeployCoordinator_ClearDeployEventSuppression_Call {
71+ _c.Run(run)
72+ return _c
73+}
74+
75+// SuppressDeployEvent provides a mock function for the type MockDeployCoordinator.
76+func (_mock *MockDeployCoordinator) SuppressDeployEvent(imageName string) {
77+ _mock.Called(imageName)
78+}
79+
80+// MockDeployCoordinator_SuppressDeployEvent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SuppressDeployEvent'.
81+type MockDeployCoordinator_SuppressDeployEvent_Call struct {
82+ *mock.Call
83+}
84+
85+// SuppressDeployEvent is a helper method to define mock.On call.
86+// - imageName string
87+func (_e *MockDeployCoordinator_Expecter) SuppressDeployEvent(imageName interface{}) *MockDeployCoordinator_SuppressDeployEvent_Call {
88+ return &MockDeployCoordinator_SuppressDeployEvent_Call{Call: _e.mock.On("SuppressDeployEvent", imageName)}
89+}
90+
91+func (_c *MockDeployCoordinator_SuppressDeployEvent_Call) Run(run func(imageName string)) *MockDeployCoordinator_SuppressDeployEvent_Call {
92+ _c.Call.Run(func(args mock.Arguments) {
93+ var arg0 string
94+ if args[0] != nil {
95+ arg0 = args[0].(string)
96+ }
97+ run(arg0)
98+ })
99+ return _c
100+}
101+
102+func (_c *MockDeployCoordinator_SuppressDeployEvent_Call) Return() *MockDeployCoordinator_SuppressDeployEvent_Call {
103+ _c.Call.Return()
104+ return _c
105+}
106+
107+func (_c *MockDeployCoordinator_SuppressDeployEvent_Call) RunAndReturn(run func(imageName string)) *MockDeployCoordinator_SuppressDeployEvent_Call {
108+ _c.Run(run)
109+ return _c
110+}
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+}
A internal/usecase/container/readiness.go +137 -0
@@ -0,0 +1,137 @@
1+package container
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "net"
8+ "net/http"
9+ "time"
10+
11+ "github.com/bnema/zerowrap"
12+)
13+
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 {
23+ for {
24+ if err := ctx.Err(); err != nil {
25+ return err
26+ }
27+ if time.Now().After(deadline) {
28+ return errProbeTimeout
29+ }
30+
31+ remaining := time.Until(deadline)
32+ attemptTimeout := defaultAttemptTimeout
33+ if remaining < attemptTimeout {
34+ attemptTimeout = remaining
35+ }
36+
37+ attemptCtx, cancel := context.WithTimeout(ctx, attemptTimeout)
38+ success, err := work(attemptCtx)
39+ cancel()
40+ if err != nil {
41+ return err
42+ }
43+ if success {
44+ return nil
45+ }
46+
47+ sleepInterval := baseSleep
48+ if remProbe := time.Until(deadline); remProbe < sleepInterval {
49+ sleepInterval = remProbe
50+ }
51+ if ctxDeadline, ok := ctx.Deadline(); ok {
52+ if remCtx := time.Until(ctxDeadline); remCtx < sleepInterval {
53+ sleepInterval = remCtx
54+ }
55+ }
56+ if sleepInterval <= 0 {
57+ if ctx.Err() != nil {
58+ return ctx.Err()
59+ }
60+ return errProbeTimeout
61+ }
62+
63+ t := time.NewTimer(sleepInterval)
64+ select {
65+ case <-t.C:
66+ case <-ctx.Done():
67+ t.Stop()
68+ return ctx.Err()
69+ }
70+ }
71+}
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+
103+// httpProbe performs HTTP GET requests to url, retrying every 1s until a
104+// 2xx/3xx response or timeout. Used when gordon.health label is set.
105+func httpProbe(ctx context.Context, url string, timeout time.Duration) error {
106+ client := &http.Client{
107+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
108+ // Don't follow redirects — treat 3xx as a successful response.
109+ return http.ErrUseLastResponse
110+ },
111+ }
112+ deadline := time.Now().Add(timeout)
113+ var lastStatus int
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)
116+ if reqErr != nil {
117+ return false, reqErr
118+ }
119+
120+ resp, err := client.Do(req)
121+ if err != nil {
122+ return false, nil
123+ }
124+
125+ lastStatus = resp.StatusCode
126+ resp.Body.Close()
127+ if lastStatus >= 200 && lastStatus < 400 {
128+ return true, nil
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)
135+ }
136+ return err
137+}
@@ -0,0 +1,137 @@
1+package container
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "net"
8+ "net/http"
9+ "time"
10+
11+ "github.com/bnema/zerowrap"
12+)
13+
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 {
23+ for {
24+ if err := ctx.Err(); err != nil {
25+ return err
26+ }
27+ if time.Now().After(deadline) {
28+ return errProbeTimeout
29+ }
30+
31+ remaining := time.Until(deadline)
32+ attemptTimeout := defaultAttemptTimeout
33+ if remaining < attemptTimeout {
34+ attemptTimeout = remaining
35+ }
36+
37+ attemptCtx, cancel := context.WithTimeout(ctx, attemptTimeout)
38+ success, err := work(attemptCtx)
39+ cancel()
40+ if err != nil {
41+ return err
42+ }
43+ if success {
44+ return nil
45+ }
46+
47+ sleepInterval := baseSleep
48+ if remProbe := time.Until(deadline); remProbe < sleepInterval {
49+ sleepInterval = remProbe
50+ }
51+ if ctxDeadline, ok := ctx.Deadline(); ok {
52+ if remCtx := time.Until(ctxDeadline); remCtx < sleepInterval {
53+ sleepInterval = remCtx
54+ }
55+ }
56+ if sleepInterval <= 0 {
57+ if ctx.Err() != nil {
58+ return ctx.Err()
59+ }
60+ return errProbeTimeout
61+ }
62+
63+ t := time.NewTimer(sleepInterval)
64+ select {
65+ case <-t.C:
66+ case <-ctx.Done():
67+ t.Stop()
68+ return ctx.Err()
69+ }
70+ }
71+}
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+
103+// httpProbe performs HTTP GET requests to url, retrying every 1s until a
104+// 2xx/3xx response or timeout. Used when gordon.health label is set.
105+func httpProbe(ctx context.Context, url string, timeout time.Duration) error {
106+ client := &http.Client{
107+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
108+ // Don't follow redirects — treat 3xx as a successful response.
109+ return http.ErrUseLastResponse
110+ },
111+ }
112+ deadline := time.Now().Add(timeout)
113+ var lastStatus int
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)
116+ if reqErr != nil {
117+ return false, reqErr
118+ }
119+
120+ resp, err := client.Do(req)
121+ if err != nil {
122+ return false, nil
123+ }
124+
125+ lastStatus = resp.StatusCode
126+ resp.Body.Close()
127+ if lastStatus >= 200 && lastStatus < 400 {
128+ return true, nil
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)
135+ }
136+ return err
137+}
A internal/usecase/container/readiness_test.go +167 -0
@@ -0,0 +1,167 @@
1+package container
2+
3+import (
4+ "context"
5+ "net"
6+ "net/http"
7+ "net/http/httptest"
8+ "sync/atomic"
9+ "syscall"
10+ "testing"
11+ "time"
12+
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+// --- TCP Probe Tests ---
18+
19+func TestTCPProbe_Success(t *testing.T) {
20+ ln, err := net.Listen("tcp", "127.0.0.1:0")
21+ require.NoError(t, err)
22+ defer ln.Close()
23+
24+ // Accept connections in background so the probe succeeds
25+ go func() {
26+ for {
27+ conn, err := ln.Accept()
28+ if err != nil {
29+ return
30+ }
31+ conn.Close()
32+ }
33+ }()
34+
35+ ctx := testContext()
36+ err = tcpProbe(ctx, ln.Addr().String(), 5*time.Second)
37+ assert.NoError(t, err)
38+}
39+
40+func TestTCPProbe_Timeout(t *testing.T) {
41+ // Reserve an ephemeral port and close it immediately to get a deterministic
42+ // unreachable target, avoiding assumptions about privileged ports.
43+ ln, err := net.Listen("tcp", "127.0.0.1:0")
44+ require.NoError(t, err)
45+ addr := ln.Addr().String()
46+ ln.Close()
47+
48+ ctx := testContext()
49+ err = tcpProbe(ctx, addr, 200*time.Millisecond)
50+ assert.Error(t, err)
51+ assert.Contains(t, err.Error(), "TCP probe timeout")
52+}
53+
54+func TestTCPProbe_ContextCancelled(t *testing.T) {
55+ ctx, cancel := context.WithCancel(testContext())
56+ cancel()
57+ err := tcpProbe(ctx, "127.0.0.1:1", 5*time.Second)
58+ assert.Error(t, err)
59+}
60+
61+func TestTCPProbe_DelayedListener(t *testing.T) {
62+ ln, err := net.Listen("tcp", "127.0.0.1:0")
63+ require.NoError(t, err)
64+ // Close initially so probe fails at first
65+ addr := ln.Addr().String()
66+ ln.Close()
67+
68+ // Re-open after 1 second
69+ go func() {
70+ time.Sleep(time.Second)
71+ lc := net.ListenConfig{
72+ Control: func(network, address string, c syscall.RawConn) error {
73+ return c.Control(func(fd uintptr) {
74+ _ = syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_REUSEADDR, 1)
75+ })
76+ },
77+ }
78+ newLn, err := lc.Listen(context.Background(), "tcp", addr)
79+ if err != nil {
80+ t.Logf("delayed listener failed to bind: %v", err)
81+ return
82+ }
83+ defer newLn.Close()
84+ for {
85+ conn, err := newLn.Accept()
86+ if err != nil {
87+ return
88+ }
89+ conn.Close()
90+ }
91+ }()
92+
93+ ctx := testContext()
94+ err = tcpProbe(ctx, addr, 5*time.Second)
95+ assert.NoError(t, err)
96+}
97+
98+// --- HTTP Probe Tests ---
99+
100+func TestHTTPProbe_Success(t *testing.T) {
101+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
102+ if r.URL.Path == "/healthz" {
103+ w.WriteHeader(200)
104+ return
105+ }
106+ w.WriteHeader(404)
107+ }))
108+ defer srv.Close()
109+
110+ ctx := testContext()
111+ err := httpProbe(ctx, srv.URL+"/healthz", 5*time.Second)
112+ assert.NoError(t, err)
113+}
114+
115+func TestHTTPProbe_ServerError_ThenSuccess(t *testing.T) {
116+ var calls int32
117+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
118+ n := atomic.AddInt32(&calls, 1)
119+ if n < 3 {
120+ w.WriteHeader(503)
121+ return
122+ }
123+ w.WriteHeader(200)
124+ }))
125+ defer srv.Close()
126+
127+ ctx := testContext()
128+ err := httpProbe(ctx, srv.URL+"/healthz", 10*time.Second)
129+ assert.NoError(t, err)
130+ assert.GreaterOrEqual(t, atomic.LoadInt32(&calls), int32(3))
131+}
132+
133+func TestHTTPProbe_Timeout(t *testing.T) {
134+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
135+ w.WriteHeader(503)
136+ }))
137+ defer srv.Close()
138+
139+ ctx := testContext()
140+ err := httpProbe(ctx, srv.URL+"/healthz", 500*time.Millisecond)
141+ assert.Error(t, err)
142+ assert.Contains(t, err.Error(), "HTTP probe timeout")
143+}
144+
145+func TestHTTPProbe_ContextCancelled(t *testing.T) {
146+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
147+ w.WriteHeader(503)
148+ }))
149+ defer srv.Close()
150+
151+ ctx, cancel := context.WithCancel(testContext())
152+ cancel()
153+ err := httpProbe(ctx, srv.URL+"/healthz", 5*time.Second)
154+ assert.Error(t, err)
155+}
156+
157+func TestHTTPProbe_RedirectIsSuccess(t *testing.T) {
158+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
159+ http.Redirect(w, r, "/other", http.StatusFound)
160+ }))
161+ defer srv.Close()
162+
163+ ctx := testContext()
164+ // The probe should treat 3xx as success without following the redirect
165+ err := httpProbe(ctx, srv.URL+"/healthz", 5*time.Second)
166+ assert.NoError(t, err)
167+}
@@ -0,0 +1,167 @@
1+package container
2+
3+import (
4+ "context"
5+ "net"
6+ "net/http"
7+ "net/http/httptest"
8+ "sync/atomic"
9+ "syscall"
10+ "testing"
11+ "time"
12+
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+// --- TCP Probe Tests ---
18+
19+func TestTCPProbe_Success(t *testing.T) {
20+ ln, err := net.Listen("tcp", "127.0.0.1:0")
21+ require.NoError(t, err)
22+ defer ln.Close()
23+
24+ // Accept connections in background so the probe succeeds
25+ go func() {
26+ for {
27+ conn, err := ln.Accept()
28+ if err != nil {
29+ return
30+ }
31+ conn.Close()
32+ }
33+ }()
34+
35+ ctx := testContext()
36+ err = tcpProbe(ctx, ln.Addr().String(), 5*time.Second)
37+ assert.NoError(t, err)
38+}
39+
40+func TestTCPProbe_Timeout(t *testing.T) {
41+ // Reserve an ephemeral port and close it immediately to get a deterministic
42+ // unreachable target, avoiding assumptions about privileged ports.
43+ ln, err := net.Listen("tcp", "127.0.0.1:0")
44+ require.NoError(t, err)
45+ addr := ln.Addr().String()
46+ ln.Close()
47+
48+ ctx := testContext()
49+ err = tcpProbe(ctx, addr, 200*time.Millisecond)
50+ assert.Error(t, err)
51+ assert.Contains(t, err.Error(), "TCP probe timeout")
52+}
53+
54+func TestTCPProbe_ContextCancelled(t *testing.T) {
55+ ctx, cancel := context.WithCancel(testContext())
56+ cancel()
57+ err := tcpProbe(ctx, "127.0.0.1:1", 5*time.Second)
58+ assert.Error(t, err)
59+}
60+
61+func TestTCPProbe_DelayedListener(t *testing.T) {
62+ ln, err := net.Listen("tcp", "127.0.0.1:0")
63+ require.NoError(t, err)
64+ // Close initially so probe fails at first
65+ addr := ln.Addr().String()
66+ ln.Close()
67+
68+ // Re-open after 1 second
69+ go func() {
70+ time.Sleep(time.Second)
71+ lc := net.ListenConfig{
72+ Control: func(network, address string, c syscall.RawConn) error {
73+ return c.Control(func(fd uintptr) {
74+ _ = syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_REUSEADDR, 1)
75+ })
76+ },
77+ }
78+ newLn, err := lc.Listen(context.Background(), "tcp", addr)
79+ if err != nil {
80+ t.Logf("delayed listener failed to bind: %v", err)
81+ return
82+ }
83+ defer newLn.Close()
84+ for {
85+ conn, err := newLn.Accept()
86+ if err != nil {
87+ return
88+ }
89+ conn.Close()
90+ }
91+ }()
92+
93+ ctx := testContext()
94+ err = tcpProbe(ctx, addr, 5*time.Second)
95+ assert.NoError(t, err)
96+}
97+
98+// --- HTTP Probe Tests ---
99+
100+func TestHTTPProbe_Success(t *testing.T) {
101+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
102+ if r.URL.Path == "/healthz" {
103+ w.WriteHeader(200)
104+ return
105+ }
106+ w.WriteHeader(404)
107+ }))
108+ defer srv.Close()
109+
110+ ctx := testContext()
111+ err := httpProbe(ctx, srv.URL+"/healthz", 5*time.Second)
112+ assert.NoError(t, err)
113+}
114+
115+func TestHTTPProbe_ServerError_ThenSuccess(t *testing.T) {
116+ var calls int32
117+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
118+ n := atomic.AddInt32(&calls, 1)
119+ if n < 3 {
120+ w.WriteHeader(503)
121+ return
122+ }
123+ w.WriteHeader(200)
124+ }))
125+ defer srv.Close()
126+
127+ ctx := testContext()
128+ err := httpProbe(ctx, srv.URL+"/healthz", 10*time.Second)
129+ assert.NoError(t, err)
130+ assert.GreaterOrEqual(t, atomic.LoadInt32(&calls), int32(3))
131+}
132+
133+func TestHTTPProbe_Timeout(t *testing.T) {
134+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
135+ w.WriteHeader(503)
136+ }))
137+ defer srv.Close()
138+
139+ ctx := testContext()
140+ err := httpProbe(ctx, srv.URL+"/healthz", 500*time.Millisecond)
141+ assert.Error(t, err)
142+ assert.Contains(t, err.Error(), "HTTP probe timeout")
143+}
144+
145+func TestHTTPProbe_ContextCancelled(t *testing.T) {
146+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
147+ w.WriteHeader(503)
148+ }))
149+ defer srv.Close()
150+
151+ ctx, cancel := context.WithCancel(testContext())
152+ cancel()
153+ err := httpProbe(ctx, srv.URL+"/healthz", 5*time.Second)
154+ assert.Error(t, err)
155+}
156+
157+func TestHTTPProbe_RedirectIsSuccess(t *testing.T) {
158+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
159+ http.Redirect(w, r, "/other", http.StatusFound)
160+ }))
161+ defer srv.Close()
162+
163+ ctx := testContext()
164+ // The probe should treat 3xx as success without following the redirect
165+ err := httpProbe(ctx, srv.URL+"/healthz", 5*time.Second)
166+ assert.NoError(t, err)
167+}
M internal/usecase/container/service.go +342 -36
@@ -38,7 +38,6 @@ type Config struct {
3838 VolumePreserve bool
3939 NetworkIsolation bool
4040 NetworkPrefix string
41- DNSSuffix string
4241 NetworkGroups map[string][]string
4342 Attachments map[string][]string
4443 ReadinessDelay time.Duration // Delay after container starts before considering it ready
@@ -48,6 +47,9 @@ type Config struct {
4847 DrainDelayConfigured bool // True when deploy.drain_delay was explicitly configured
4948 DrainMode string // Drain strategy: auto, inflight, delay
5049 DrainTimeout time.Duration // Max wait for in-flight requests to drain
50+ StabilizationDelay time.Duration // Post-switch monitoring window (default 2s)
51+ TCPProbeTimeout time.Duration // TCP probe timeout (default 30s)
52+ HTTPProbeTimeout time.Duration // HTTP probe timeout (default 60s)
5153 }
5254
5355 var tracer = otel.Tracer("gordon.container")
@@ -257,26 +259,9 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta
257259 log := zerowrap.FromCtx(ctx)
258260
259261 // Record deploy metrics and trace status
260- defer func() {
261- if err != nil {
262- span.RecordError(err)
263- span.SetStatus(codes.Error, err.Error())
264- }
265- if s.metrics == nil {
266- return
267- }
268- attrs := metric.WithAttributes(
269- attribute.String("domain", route.Domain),
270- attribute.String("image", route.Image),
271- )
272- s.metrics.DeployTotal.Add(ctx, 1, attrs)
273- s.metrics.DeployDuration.Record(ctx, time.Since(deployStart).Seconds(), attrs)
274- if err != nil {
275- s.metrics.DeployErrors.Add(ctx, 1, attrs)
276- }
277- }()
262+ defer s.recordDeployMetrics(ctx, span, route, deployStart, &err)
278263
279- existing, hasExisting := s.getTrackedContainer(route.Domain)
264+ existing, hasExisting := s.resolveExistingContainer(ctx, route.Domain)
280265
281266 resources, err := s.prepareDeployResources(ctx, route, existing)
282267 if err != nil {
@@ -287,9 +272,15 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta
287272 // the exact same image (by Docker image ID), return it immediately.
288273 // This prevents the double-deploy caused by the event-based deploy
289274 // (triggered by image.pushed) racing with the explicit CLI deploy call.
290- if hasExisting && existing.ImageID != "" {
291- if skip, container := s.skipRedundantDeploy(ctx, existing, resources.actualImageRef); skip {
292- return container, nil
275+ if hasExisting {
276+ existingForSkip := existing
277+ if existingForSkip.ImageID == "" && existingForSkip.Image != "" && normalizeImageRef(existingForSkip.Image) == normalizeImageRef(route.Image) {
278+ existingForSkip = s.containerForRedundantCheck(ctx, existingForSkip)
279+ }
280+ if existingForSkip.ImageID != "" {
281+ if skip, container := s.skipRedundantDeploy(ctx, existingForSkip, resources.actualImageRef); skip {
282+ return container, nil
283+ }
293284 }
294285 }
295286
@@ -299,6 +290,22 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta
299290 }
300291
301292 invalidated := s.activateDeployedContainer(ctx, route.Domain, newContainer)
293+
294+ // Post-switch stabilization: verify new container stays running
295+ if hasExisting {
296+ stable, stabilizeErr := s.stabilizeNewContainer(ctx, route.Domain, newContainer, existing)
297+ if stabilizeErr != nil {
298+ // Both old and new containers are dead; assign to named return so
299+ // the deferred recordDeployMetrics and span see the failure.
300+ err = stabilizeErr
301+ return nil, err
302+ }
303+ if !stable {
304+ // Rollback performed — old container is restored
305+ return existing, nil
306+ }
307+ }
308+
302309 s.finalizePreviousContainer(ctx, route.Domain, existing, hasExisting, invalidated, newContainer.ID)
303310
304311 // Start container log collection (non-blocking, errors don't fail deployment)
@@ -315,6 +322,29 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta
315322 return newContainer, nil
316323 }
317324
325+// recordDeployMetrics records span error status and deploy metrics at the end of a Deploy call.
326+// It is called via defer and receives a pointer to the named return error so it can observe
327+// the final error value after all deferred functions have run.
328+func (s *Service) recordDeployMetrics(ctx context.Context, span trace.Span, route domain.Route, start time.Time, errPtr *error) {
329+ err := *errPtr
330+ if err != nil {
331+ span.RecordError(err)
332+ span.SetStatus(codes.Error, err.Error())
333+ }
334+ if s.metrics == nil {
335+ return
336+ }
337+ attrs := metric.WithAttributes(
338+ attribute.String("domain", route.Domain),
339+ attribute.String("image", route.Image),
340+ )
341+ s.metrics.DeployTotal.Add(ctx, 1, attrs)
342+ s.metrics.DeployDuration.Record(ctx, time.Since(start).Seconds(), attrs)
343+ if err != nil {
344+ s.metrics.DeployErrors.Add(ctx, 1, attrs)
345+ }
346+}
347+
318348 // skipRedundantDeploy checks whether the existing container is already running
319349 // the same image (by Docker image ID) as the one we are about to deploy.
320350 // When a push triggers both an event-based deploy and an explicit CLI deploy,
@@ -347,6 +377,26 @@ func (s *Service) skipRedundantDeploy(ctx context.Context, existing *domain.Cont
347377 return true, existing
348378 }
349379
380+func (s *Service) containerForRedundantCheck(ctx context.Context, existing *domain.Container) *domain.Container {
381+ if existing == nil || existing.ImageID != "" || existing.ID == "" {
382+ return existing
383+ }
384+
385+ log := zerowrap.FromCtx(ctx)
386+ inspected, err := s.runtime.InspectContainer(ctx, existing.ID)
387+ if err != nil {
388+ log.Debug().Err(err).Str("container_id", existing.ID).Msg("cannot inspect existing container for redundancy check")
389+ return existing
390+ }
391+ if inspected == nil || inspected.ImageID == "" {
392+ return existing
393+ }
394+
395+ existingCopy := *existing
396+ existingCopy.ImageID = inspected.ImageID
397+ return &existingCopy
398+}
399+
350400 type deployResources struct {
351401 networkName string
352402 actualImageRef string
@@ -355,11 +405,68 @@ type deployResources struct {
355405 volumes map[string]string
356406 }
357407
358-func (s *Service) getTrackedContainer(domainName string) (*domain.Container, bool) {
408+// resolveExistingContainer returns the currently running container for a domain.
409+// It first checks the in-memory map (fast path). If not found, it queries the
410+// runtime for a running container with matching name and managed label. This
411+// handles cases where Gordon restarted and in-memory state is stale.
412+//
413+// The slow path checks canonical, -new, and -next names because a previous
414+// deploy may have been interrupted (e.g. eventbus timeout, Gordon restart)
415+// leaving the active container under a temp name. Canonical is preferred when
416+// multiple matches exist.
417+func (s *Service) resolveExistingContainer(ctx context.Context, domainName string) (*domain.Container, bool) {
418+ // Fast path: check in-memory state
359419 s.mu.RLock()
360420 container, ok := s.containers[domainName]
361421 s.mu.RUnlock()
362- return container, ok
422+ if ok {
423+ return container, true
424+ }
425+
426+ // Slow path: query runtime for running containers
427+ log := zerowrap.FromCtx(ctx)
428+ canonicalName := fmt.Sprintf("gordon-%s", domainName)
429+ candidateNames := map[string]bool{
430+ canonicalName: true,
431+ canonicalName + "-new": true,
432+ canonicalName + "-next": true,
433+ }
434+
435+ running, err := s.runtime.ListContainers(ctx, false)
436+ if err != nil {
437+ log.Warn().Err(err).Msg("failed to list running containers for existing container resolution")
438+ return nil, false
439+ }
440+
441+ // Prefer canonical name; fall back to any running managed temp container.
442+ var best *domain.Container
443+ for _, c := range running {
444+ if !candidateNames[c.Name] || c.Labels[domain.LabelManaged] != "true" {
445+ continue
446+ }
447+ if best == nil || c.Name == canonicalName {
448+ best = c
449+ }
450+ }
451+
452+ if best != nil {
453+ log.Info().
454+ Str("container_id", best.ID).
455+ Str("container_name", best.Name).
456+ Msg("resolved existing container from runtime (in-memory state was stale)")
457+
458+ // Update in-memory state so subsequent lookups are fast
459+ s.mu.Lock()
460+ if _, alreadyTracked := s.containers[domainName]; !alreadyTracked {
461+ s.managedCount++
462+ }
463+ s.containers[domainName] = best
464+ s.mu.Unlock()
465+
466+ return best, true
467+ }
468+
469+ return nil, false
363470 }
364471
365472 func (s *Service) prepareDeployResources(ctx context.Context, route domain.Route, existing *domain.Container) (*deployResources, error) {
@@ -438,7 +545,7 @@ func (s *Service) createStartedContainer(ctx context.Context, route domain.Route
438545 return nil, log.WrapErr(err, "failed to start container")
439546 }
440547 if !domain.IsSkipReadiness(ctx) {
441- if err := s.waitForReady(ctx, newContainer.ID); err != nil {
548+ if err := s.waitForReady(ctx, newContainer.ID, containerConfig); err != nil {
442549 s.cleanupFailedContainer(ctx, newContainer.ID)
443550 return nil, log.WrapErr(err, "container failed readiness check")
444551 }
@@ -480,6 +587,85 @@ func (s *Service) activateDeployedContainer(ctx context.Context, domainName stri
480587 return false
481588 }
482589
590+// stabilizeNewContainer monitors the new container briefly after traffic switch.
591+// If it crashes during this window, rolls back to old container.
592+// Returns true if stabilization succeeded, false if rollback was performed.
593+// Returns an error only when both new and old containers are dead.
594+func (s *Service) stabilizeNewContainer(ctx context.Context, domainName string, newContainer, oldContainer *domain.Container) (bool, error) {
595+ log := zerowrap.FromCtx(ctx)
596+
597+ if oldContainer == nil {
598+ return true, nil
599+ }
600+
601+ s.mu.RLock()
602+ delay := s.config.StabilizationDelay
603+ s.mu.RUnlock()
604+ if delay == 0 {
605+ delay = 2 * time.Second
606+ }
607+
608+ // Brief stabilization: verify new container is still running after delay
609+ select {
610+ case <-time.After(delay):
611+ case <-ctx.Done():
612+ return true, nil
613+ }
614+
615+ running, err := s.runtime.IsContainerRunning(ctx, newContainer.ID)
616+ if err != nil || !running {
617+ log.Error().
618+ Str("new_container", newContainer.ID).
619+ Str("old_container", oldContainer.ID).
620+ Msg("new container crashed during stabilization, rolling back to old")
621+
622+ // Verify old container is still running before restoring it.
623+ // If both old and new are dead (e.g., OOM), returning a dead
624+ // container as "existing" would leave the domain in a broken state.
625+ oldRunning, oldErr := s.runtime.IsContainerRunning(ctx, oldContainer.ID)
626+ if oldErr != nil || !oldRunning {
627+ log.Error().
628+ Str("old_container", oldContainer.ID).
629+ Msg("old container is also not running, cannot rollback")
630+
631+ // Cleanup failed new container
632+ if stopErr := s.runtime.StopContainer(ctx, newContainer.ID); stopErr != nil {
633+ log.WrapErrWithFields(stopErr, "failed to stop failed new container during rollback", map[string]any{zerowrap.FieldEntityID: newContainer.ID})
634+ }
635+ if removeErr := s.runtime.RemoveContainer(ctx, newContainer.ID, true); removeErr != nil {
636+ log.WrapErrWithFields(removeErr, "failed to remove failed new container during rollback", map[string]any{zerowrap.FieldEntityID: newContainer.ID})
637+ }
638+
639+ return false, fmt.Errorf("stabilization failed: new container crashed and old container %s is also not running", oldContainer.ID)
640+ }
641+
642+ // Rollback: restore old container as tracked
643+ s.mu.Lock()
644+ s.containers[domainName] = oldContainer
645+ s.mu.Unlock()
646+
647+ // Re-invalidate proxy cache to point back to old
648+ s.mu.RLock()
649+ inv := s.cacheInvalidator
650+ s.mu.RUnlock()
651+ if inv != nil {
652+ inv.InvalidateTarget(ctx, domainName)
653+ }
654+
655+ // Cleanup failed new container
656+ if stopErr := s.runtime.StopContainer(ctx, newContainer.ID); stopErr != nil {
657+ log.WrapErrWithFields(stopErr, "failed to stop failed new container during rollback", map[string]any{zerowrap.FieldEntityID: newContainer.ID})
658+ }
659+ if removeErr := s.runtime.RemoveContainer(ctx, newContainer.ID, true); removeErr != nil {
660+ log.WrapErrWithFields(removeErr, "failed to remove failed new container during rollback", map[string]any{zerowrap.FieldEntityID: newContainer.ID})
661+ }
662+
663+ return false, nil
664+ }
665+
666+ return true, nil
667+}
668+
483669 func (s *Service) finalizePreviousContainer(ctx context.Context, domainName string, existing *domain.Container, hasExisting, invalidated bool, newContainerID string) {
484670 if !hasExisting {
485671 return
@@ -1587,7 +1773,19 @@ func (s *Service) cleanupOrphanedContainers(ctx context.Context, domainName stri
15871773
15881774 for _, c := range allContainers {
15891775 if (c.Name == expectedName || c.Name == expectedNewName || c.Name == expectedNextName) && c.ID != skipContainerID {
1590- log.Info().Str(zerowrap.FieldEntityID, c.ID).Str(zerowrap.FieldStatus, c.Status).Msg("found orphaned container, removing")
1776+ // Skip any running container regardless of name — a running
1777+ // temp container may be actively serving traffic while the
1778+ // new container stabilizes, or may be from a concurrent deploy.
1779+ if c.Status == "running" {
1780+ log.Debug().
1781+ Str(zerowrap.FieldEntityID, c.ID).
1782+ Str("container_name", c.Name).
1783+ Str(zerowrap.FieldStatus, c.Status).
1784+ Msg("skipping running container during orphan cleanup")
1785+ continue
1786+ }
1787+
1788+ log.Info().Str(zerowrap.FieldEntityID, c.ID).Str("container_name", c.Name).Str(zerowrap.FieldStatus, c.Status).Msg("found orphaned container, removing")
15911789
15921790 if err := s.runtime.StopContainer(ctx, c.ID); err != nil {
15931791 log.WrapErrWithFields(err, "failed to stop orphaned container", map[string]any{zerowrap.FieldEntityID: c.ID})
@@ -2065,7 +2263,7 @@ func (s *Service) findContainerByName(ctx context.Context, name string) *domain.
20652263 return nil
20662264 }
20672265
2068-func (s *Service) waitForReady(ctx context.Context, containerID string) error {
2266+func (s *Service) waitForReady(ctx context.Context, containerID string, containerConfig *domain.ContainerConfig) error {
20692267 if err := s.pollContainerRunning(ctx, containerID); err != nil {
20702268 return err
20712269 }
@@ -2092,15 +2290,123 @@ func (s *Service) waitForReady(ctx context.Context, containerID string) error {
20922290 }
20932291 return s.waitForHealthy(ctx, containerID, cfg.HealthTimeout)
20942292 default: // auto
2095- _, hasHealthcheck, err := s.runtime.GetContainerHealthStatus(ctx, containerID)
2096- if err != nil {
2097- return err
2098- }
2099- if hasHealthcheck {
2100- return s.waitForHealthy(ctx, containerID, cfg.HealthTimeout)
2101- }
2102- return s.waitForReadyByDelay(ctx, containerID)
2293+ return s.readinessCascade(ctx, containerID, containerConfig, cfg)
2294+ }
2295+}
2296+
2297+// readinessCascade auto-detects the strongest available readiness signal:
2298+// 1. Docker healthcheck (if present) → wait for healthy status
2299+// 2. HTTP probe (if gordon.health label set) → GET until 2xx/3xx
2300+// 3. TCP probe (if port info available) → connect until accepted
2301+// 4. Delay fallback (last resort) → waitForReadyByDelay
2302+func (s *Service) readinessCascade(ctx context.Context, containerID string, containerConfig *domain.ContainerConfig, cfg Config) error {
2303+ log := zerowrap.FromCtx(ctx)
2304+
2305+ // 1. Docker healthcheck
2306+ _, hasHealthcheck, err := s.runtime.GetContainerHealthStatus(ctx, containerID)
2307+ if err != nil {
2308+ return err
2309+ }
2310+ if hasHealthcheck {
2311+ log.Info().Msg("readiness cascade: using Docker healthcheck")
2312+ return s.waitForHealthy(ctx, containerID, cfg.HealthTimeout)
2313+ }
2314+
2315+ // 2. HTTP probe via gordon.health label
2316+ if probed, probeErr := s.tryHTTPProbe(ctx, containerID, containerConfig, cfg); probed {
2317+ return probeErr
2318+ }
2319+
2320+ // 3. TCP probe (if port info available)
2321+ if probed, probeErr := s.tryTCPProbe(ctx, containerID, containerConfig, cfg); probed {
2322+ return probeErr
2323+ }
2324+
2325+ // 4. Delay fallback
2326+ log.Info().Msg("readiness cascade: using delay fallback")
2327+ return s.waitForReadyByDelay(ctx, containerID)
2328+}
2329+
2330+// tryHTTPProbe attempts an HTTP probe if the gordon.health label is set.
2331+// Returns (true, err) if the probe was attempted, (false, nil) if skipped.
2332+func (s *Service) tryHTTPProbe(ctx context.Context, containerID string, containerConfig *domain.ContainerConfig, cfg Config) (bool, error) {
2333+ log := zerowrap.FromCtx(ctx)
2334+ if containerConfig == nil {
2335+ return false, nil
2336+ }
2337+ healthPath, ok := containerConfig.Labels[domain.LabelHealth]
2338+ if !ok || healthPath == "" {
2339+ return false, nil
2340+ }
2341+ ip, port, probeErr := s.resolveContainerEndpoint(ctx, containerID)
2342+ if probeErr != nil || ip == "" || port <= 0 {
2343+ log.Debug().Err(probeErr).Msg("readiness cascade: HTTP probe skipped, could not resolve container endpoint")
2344+ return false, nil
2345+ }
2346+ url := fmt.Sprintf("http://%s:%d%s", ip, port, healthPath)
2347+ timeout := cfg.HTTPProbeTimeout
2348+ if timeout == 0 {
2349+ timeout = 60 * time.Second
2350+ }
2351+ log.Info().Str("url", url).Dur("timeout", timeout).Msg("readiness cascade: using HTTP probe")
2352+ return true, httpProbe(ctx, url, timeout)
2353+}
2354+
2355+// tryTCPProbe attempts a TCP probe if port info is available.
2356+// Returns (true, err) if the probe was attempted, (false, nil) if skipped.
2357+func (s *Service) tryTCPProbe(ctx context.Context, containerID string, containerConfig *domain.ContainerConfig, cfg Config) (bool, error) {
2358+ log := zerowrap.FromCtx(ctx)
2359+ if containerConfig == nil || len(containerConfig.Ports) == 0 {
2360+ return false, nil
2361+ }
2362+ ip, port, probeErr := s.resolveContainerEndpoint(ctx, containerID)
2363+ if probeErr != nil || ip == "" || port <= 0 {
2364+ log.Debug().Err(probeErr).Msg("readiness cascade: TCP probe skipped, could not resolve container endpoint")
2365+ return false, nil
2366+ }
2367+ addr := fmt.Sprintf("%s:%d", ip, port)
2368+ timeout := cfg.TCPProbeTimeout
2369+ if timeout == 0 {
2370+ timeout = 30 * time.Second
2371+ }
2372+ log.Info().Str("addr", addr).Dur("timeout", timeout).Msg("readiness cascade: using TCP probe")
2373+ return true, tcpProbe(ctx, addr, timeout)
2374+}
2375+
2376+// resolveContainerEndpoint returns a host-reachable address for probing.
2377+// In rootless podman/Docker setups, container internal IPs are not routable
2378+// from the host. We use the host port binding (127.0.0.1:<mapped_port>)
2379+// which is always reachable. Falls back to the container's internal IP
2380+// only if no host port mapping exists (e.g. host-network mode).
2381+func (s *Service) resolveContainerEndpoint(ctx context.Context, containerID string) (string, int, error) {
2382+ log := zerowrap.FromCtx(ctx)
2383+
2384+ // First, get the container's internal port from network info
2385+ _, internalPort, err := s.runtime.GetContainerNetworkInfo(ctx, containerID)
2386+ if err != nil {
2387+ return "", 0, err
2388+ }
2389+
2390+ // Try to resolve via host port binding (works in rootless podman/Docker)
2391+ hostPort, hostErr := s.runtime.GetContainerPort(ctx, containerID, internalPort)
2392+ if hostErr == nil && hostPort > 0 {
2393+ log.Debug().
2394+ Int("internal_port", internalPort).
2395+ Int("host_port", hostPort).
2396+ Msg("resolved container endpoint via host port binding")
2397+ return "127.0.0.1", hostPort, nil
2398+ }
2399+
2400+ // Fallback: use internal IP (works in Docker rootful / host network)
2401+ ip, _, fallbackErr := s.runtime.GetContainerNetworkInfo(ctx, containerID)
2402+ if fallbackErr != nil {
2403+ return "", 0, fallbackErr
21032404 }
2405+ log.Debug().
2406+ Str("ip", ip).
2407+ Int("port", internalPort).
2408+ Msg("resolved container endpoint via internal IP (no host port binding)")
2409+ return ip, internalPort, nil
21042410 }
21052411
21062412 // waitForReadyByDelay waits using the legacy running+delay strategy.
@@ -38,7 +38,6 @@ type Config struct {
38 VolumePreserve bool38 VolumePreserve bool
39 NetworkIsolation bool39 NetworkIsolation bool
40 NetworkPrefix string40 NetworkPrefix string
41- DNSSuffix string
42 NetworkGroups map[string][]string41 NetworkGroups map[string][]string
43 Attachments map[string][]string42 Attachments map[string][]string
44 ReadinessDelay time.Duration // Delay after container starts before considering it ready43 ReadinessDelay time.Duration // Delay after container starts before considering it ready
@@ -48,6 +47,9 @@ type Config struct {
48 DrainDelayConfigured bool // True when deploy.drain_delay was explicitly configured47 DrainDelayConfigured bool // True when deploy.drain_delay was explicitly configured
49 DrainMode string // Drain strategy: auto, inflight, delay48 DrainMode string // Drain strategy: auto, inflight, delay
50 DrainTimeout time.Duration // Max wait for in-flight requests to drain49 DrainTimeout time.Duration // Max wait for in-flight requests to drain
50+ StabilizationDelay time.Duration // Post-switch monitoring window (default 2s)
51+ TCPProbeTimeout time.Duration // TCP probe timeout (default 30s)
52+ HTTPProbeTimeout time.Duration // HTTP probe timeout (default 60s)
51 }53 }
52 54
53 var tracer = otel.Tracer("gordon.container")55 var tracer = otel.Tracer("gordon.container")
@@ -257,26 +259,9 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta
257 log := zerowrap.FromCtx(ctx)259 log := zerowrap.FromCtx(ctx)
258 260
259 // Record deploy metrics and trace status261 // Record deploy metrics and trace status
260- defer func() {262+ defer s.recordDeployMetrics(ctx, span, route, deployStart, &err)
261- if err != nil {
262- span.RecordError(err)
263- span.SetStatus(codes.Error, err.Error())
264- }
265- if s.metrics == nil {
266- return
267- }
268- attrs := metric.WithAttributes(
269- attribute.String("domain", route.Domain),
270- attribute.String("image", route.Image),
271- )
272- s.metrics.DeployTotal.Add(ctx, 1, attrs)
273- s.metrics.DeployDuration.Record(ctx, time.Since(deployStart).Seconds(), attrs)
274- if err != nil {
275- s.metrics.DeployErrors.Add(ctx, 1, attrs)
276- }
277- }()
278 263
279- existing, hasExisting := s.getTrackedContainer(route.Domain)264+ existing, hasExisting := s.resolveExistingContainer(ctx, route.Domain)
280 265
281 resources, err := s.prepareDeployResources(ctx, route, existing)266 resources, err := s.prepareDeployResources(ctx, route, existing)
282 if err != nil {267 if err != nil {
@@ -287,9 +272,15 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta
287 // the exact same image (by Docker image ID), return it immediately.272 // the exact same image (by Docker image ID), return it immediately.
288 // This prevents the double-deploy caused by the event-based deploy273 // This prevents the double-deploy caused by the event-based deploy
289 // (triggered by image.pushed) racing with the explicit CLI deploy call.274 // (triggered by image.pushed) racing with the explicit CLI deploy call.
290- if hasExisting && existing.ImageID != "" {275+ if hasExisting {
291- if skip, container := s.skipRedundantDeploy(ctx, existing, resources.actualImageRef); skip {276+ existingForSkip := existing
292- return container, nil277+ if existingForSkip.ImageID == "" && existingForSkip.Image != "" && normalizeImageRef(existingForSkip.Image) == normalizeImageRef(route.Image) {
278+ existingForSkip = s.containerForRedundantCheck(ctx, existingForSkip)
279+ }
280+ if existingForSkip.ImageID != "" {
281+ if skip, container := s.skipRedundantDeploy(ctx, existingForSkip, resources.actualImageRef); skip {
282+ return container, nil
283+ }
293 }284 }
294 }285 }
295 286
@@ -299,6 +290,22 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta
299 }290 }
300 291
301 invalidated := s.activateDeployedContainer(ctx, route.Domain, newContainer)292 invalidated := s.activateDeployedContainer(ctx, route.Domain, newContainer)
293+
294+ // Post-switch stabilization: verify new container stays running
295+ if hasExisting {
296+ stable, stabilizeErr := s.stabilizeNewContainer(ctx, route.Domain, newContainer, existing)
297+ if stabilizeErr != nil {
298+ // Both old and new containers are dead; assign to named return so
299+ // the deferred recordDeployMetrics and span see the failure.
300+ err = stabilizeErr
301+ return nil, err
302+ }
303+ if !stable {
304+ // Rollback performed — old container is restored
305+ return existing, nil
306+ }
307+ }
308+
302 s.finalizePreviousContainer(ctx, route.Domain, existing, hasExisting, invalidated, newContainer.ID)309 s.finalizePreviousContainer(ctx, route.Domain, existing, hasExisting, invalidated, newContainer.ID)
303 310
304 // Start container log collection (non-blocking, errors don't fail deployment)311 // Start container log collection (non-blocking, errors don't fail deployment)
@@ -315,6 +322,29 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta
315 return newContainer, nil322 return newContainer, nil
316 }323 }
317 324
325+// recordDeployMetrics records span error status and deploy metrics at the end of a Deploy call.
326+// It is called via defer and receives a pointer to the named return error so it can observe
327+// the final error value after all deferred functions have run.
328+func (s *Service) recordDeployMetrics(ctx context.Context, span trace.Span, route domain.Route, start time.Time, errPtr *error) {
329+ err := *errPtr
330+ if err != nil {
331+ span.RecordError(err)
332+ span.SetStatus(codes.Error, err.Error())
333+ }
334+ if s.metrics == nil {
335+ return
336+ }
337+ attrs := metric.WithAttributes(
338+ attribute.String("domain", route.Domain),
339+ attribute.String("image", route.Image),
340+ )
341+ s.metrics.DeployTotal.Add(ctx, 1, attrs)
342+ s.metrics.DeployDuration.Record(ctx, time.Since(start).Seconds(), attrs)
343+ if err != nil {
344+ s.metrics.DeployErrors.Add(ctx, 1, attrs)
345+ }
346+}
347+
318 // skipRedundantDeploy checks whether the existing container is already running348 // skipRedundantDeploy checks whether the existing container is already running
319 // the same image (by Docker image ID) as the one we are about to deploy.349 // the same image (by Docker image ID) as the one we are about to deploy.
320 // When a push triggers both an event-based deploy and an explicit CLI deploy,350 // When a push triggers both an event-based deploy and an explicit CLI deploy,
@@ -347,6 +377,26 @@ func (s *Service) skipRedundantDeploy(ctx context.Context, existing *domain.Cont
347 return true, existing377 return true, existing
348 }378 }
349 379
380+func (s *Service) containerForRedundantCheck(ctx context.Context, existing *domain.Container) *domain.Container {
381+ if existing == nil || existing.ImageID != "" || existing.ID == "" {
382+ return existing
383+ }
384+
385+ log := zerowrap.FromCtx(ctx)
386+ inspected, err := s.runtime.InspectContainer(ctx, existing.ID)
387+ if err != nil {
388+ log.Debug().Err(err).Str("container_id", existing.ID).Msg("cannot inspect existing container for redundancy check")
389+ return existing
390+ }
391+ if inspected == nil || inspected.ImageID == "" {
392+ return existing
393+ }
394+
395+ existingCopy := *existing
396+ existingCopy.ImageID = inspected.ImageID
397+ return &existingCopy
398+}
399+
350 type deployResources struct {400 type deployResources struct {
351 networkName string401 networkName string
352 actualImageRef string402 actualImageRef string
@@ -355,11 +405,68 @@ type deployResources struct {
355 volumes map[string]string405 volumes map[string]string
356 }406 }
357 407
358-func (s *Service) getTrackedContainer(domainName string) (*domain.Container, bool) {408+// resolveExistingContainer returns the currently running container for a domain.
409+// It first checks the in-memory map (fast path). If not found, it queries the
410+// runtime for a running container with matching name and managed label. This
411+// handles cases where Gordon restarted and in-memory state is stale.
412+//
413+// The slow path checks canonical, -new, and -next names because a previous
414+// deploy may have been interrupted (e.g. eventbus timeout, Gordon restart)
415+// leaving the active container under a temp name. Canonical is preferred when
416+// multiple matches exist.
417+func (s *Service) resolveExistingContainer(ctx context.Context, domainName string) (*domain.Container, bool) {
418+ // Fast path: check in-memory state
359 s.mu.RLock()419 s.mu.RLock()
360 container, ok := s.containers[domainName]420 container, ok := s.containers[domainName]
361 s.mu.RUnlock()421 s.mu.RUnlock()
362- return container, ok422+ if ok {
423+ return container, true
424+ }
425+
426+ // Slow path: query runtime for running containers
427+ log := zerowrap.FromCtx(ctx)
428+ canonicalName := fmt.Sprintf("gordon-%s", domainName)
429+ candidateNames := map[string]bool{
430+ canonicalName: true,
431+ canonicalName + "-new": true,
432+ canonicalName + "-next": true,
433+ }
434+
435+ running, err := s.runtime.ListContainers(ctx, false)
436+ if err != nil {
437+ log.Warn().Err(err).Msg("failed to list running containers for existing container resolution")
438+ return nil, false
439+ }
440+
441+ // Prefer canonical name; fall back to any running managed temp container.
442+ var best *domain.Container
443+ for _, c := range running {
444+ if !candidateNames[c.Name] || c.Labels[domain.LabelManaged] != "true" {
445+ continue
446+ }
447+ if best == nil || c.Name == canonicalName {
448+ best = c
449+ }
450+ }
451+
452+ if best != nil {
453+ log.Info().
454+ Str("container_id", best.ID).
455+ Str("container_name", best.Name).
456+ Msg("resolved existing container from runtime (in-memory state was stale)")
457+
458+ // Update in-memory state so subsequent lookups are fast
459+ s.mu.Lock()
460+ if _, alreadyTracked := s.containers[domainName]; !alreadyTracked {
461+ s.managedCount++
462+ }
463+ s.containers[domainName] = best
464+ s.mu.Unlock()
465+
466+ return best, true
467+ }
468+
469+ return nil, false
363 }470 }
364 471
365 func (s *Service) prepareDeployResources(ctx context.Context, route domain.Route, existing *domain.Container) (*deployResources, error) {472 func (s *Service) prepareDeployResources(ctx context.Context, route domain.Route, existing *domain.Container) (*deployResources, error) {
@@ -438,7 +545,7 @@ func (s *Service) createStartedContainer(ctx context.Context, route domain.Route
438 return nil, log.WrapErr(err, "failed to start container")545 return nil, log.WrapErr(err, "failed to start container")
439 }546 }
440 if !domain.IsSkipReadiness(ctx) {547 if !domain.IsSkipReadiness(ctx) {
441- if err := s.waitForReady(ctx, newContainer.ID); err != nil {548+ if err := s.waitForReady(ctx, newContainer.ID, containerConfig); err != nil {
442 s.cleanupFailedContainer(ctx, newContainer.ID)549 s.cleanupFailedContainer(ctx, newContainer.ID)
443 return nil, log.WrapErr(err, "container failed readiness check")550 return nil, log.WrapErr(err, "container failed readiness check")
444 }551 }
@@ -480,6 +587,85 @@ func (s *Service) activateDeployedContainer(ctx context.Context, domainName stri
480 return false587 return false
481 }588 }
482 589
590+// stabilizeNewContainer monitors the new container briefly after traffic switch.
591+// If it crashes during this window, rolls back to old container.
592+// Returns true if stabilization succeeded, false if rollback was performed.
593+// Returns an error only when both new and old containers are dead.
594+func (s *Service) stabilizeNewContainer(ctx context.Context, domainName string, newContainer, oldContainer *domain.Container) (bool, error) {
595+ log := zerowrap.FromCtx(ctx)
596+
597+ if oldContainer == nil {
598+ return true, nil
599+ }
600+
601+ s.mu.RLock()
602+ delay := s.config.StabilizationDelay
603+ s.mu.RUnlock()
604+ if delay == 0 {
605+ delay = 2 * time.Second
606+ }
607+
608+ // Brief stabilization: verify new container is still running after delay
609+ select {
610+ case <-time.After(delay):
611+ case <-ctx.Done():
612+ return true, nil
613+ }
614+
615+ running, err := s.runtime.IsContainerRunning(ctx, newContainer.ID)
616+ if err != nil || !running {
617+ log.Error().
618+ Str("new_container", newContainer.ID).
619+ Str("old_container", oldContainer.ID).
620+ Msg("new container crashed during stabilization, rolling back to old")
621+
622+ // Verify old container is still running before restoring it.
623+ // If both old and new are dead (e.g., OOM), returning a dead
624+ // container as "existing" would leave the domain in a broken state.
625+ oldRunning, oldErr := s.runtime.IsContainerRunning(ctx, oldContainer.ID)
626+ if oldErr != nil || !oldRunning {
627+ log.Error().
628+ Str("old_container", oldContainer.ID).
629+ Msg("old container is also not running, cannot rollback")
630+
631+ // Cleanup failed new container
632+ if stopErr := s.runtime.StopContainer(ctx, newContainer.ID); stopErr != nil {
633+ log.WrapErrWithFields(stopErr, "failed to stop failed new container during rollback", map[string]any{zerowrap.FieldEntityID: newContainer.ID})
634+ }
635+ if removeErr := s.runtime.RemoveContainer(ctx, newContainer.ID, true); removeErr != nil {
636+ log.WrapErrWithFields(removeErr, "failed to remove failed new container during rollback", map[string]any{zerowrap.FieldEntityID: newContainer.ID})
637+ }
638+
639+ return false, fmt.Errorf("stabilization failed: new container crashed and old container %s is also not running", oldContainer.ID)
640+ }
641+
642+ // Rollback: restore old container as tracked
643+ s.mu.Lock()
644+ s.containers[domainName] = oldContainer
645+ s.mu.Unlock()
646+
647+ // Re-invalidate proxy cache to point back to old
648+ s.mu.RLock()
649+ inv := s.cacheInvalidator
650+ s.mu.RUnlock()
651+ if inv != nil {
652+ inv.InvalidateTarget(ctx, domainName)
653+ }
654+
655+ // Cleanup failed new container
656+ if stopErr := s.runtime.StopContainer(ctx, newContainer.ID); stopErr != nil {
657+ log.WrapErrWithFields(stopErr, "failed to stop failed new container during rollback", map[string]any{zerowrap.FieldEntityID: newContainer.ID})
658+ }
659+ if removeErr := s.runtime.RemoveContainer(ctx, newContainer.ID, true); removeErr != nil {
660+ log.WrapErrWithFields(removeErr, "failed to remove failed new container during rollback", map[string]any{zerowrap.FieldEntityID: newContainer.ID})
661+ }
662+
663+ return false, nil
664+ }
665+
666+ return true, nil
667+}
668+
483 func (s *Service) finalizePreviousContainer(ctx context.Context, domainName string, existing *domain.Container, hasExisting, invalidated bool, newContainerID string) {669 func (s *Service) finalizePreviousContainer(ctx context.Context, domainName string, existing *domain.Container, hasExisting, invalidated bool, newContainerID string) {
484 if !hasExisting {670 if !hasExisting {
485 return671 return
@@ -1587,7 +1773,19 @@ func (s *Service) cleanupOrphanedContainers(ctx context.Context, domainName stri
1587 1773
1588 for _, c := range allContainers {1774 for _, c := range allContainers {
1589 if (c.Name == expectedName || c.Name == expectedNewName || c.Name == expectedNextName) && c.ID != skipContainerID {1775 if (c.Name == expectedName || c.Name == expectedNewName || c.Name == expectedNextName) && c.ID != skipContainerID {
1590- log.Info().Str(zerowrap.FieldEntityID, c.ID).Str(zerowrap.FieldStatus, c.Status).Msg("found orphaned container, removing")1776+ // Skip any running container regardless of name — a running
1777+ // temp container may be actively serving traffic while the
1778+ // new container stabilizes, or may be from a concurrent deploy.
1779+ if c.Status == "running" {
1780+ log.Debug().
1781+ Str(zerowrap.FieldEntityID, c.ID).
1782+ Str("container_name", c.Name).
1783+ Str(zerowrap.FieldStatus, c.Status).
1784+ Msg("skipping running container during orphan cleanup")
1785+ continue
1786+ }
1787+
1788+ log.Info().Str(zerowrap.FieldEntityID, c.ID).Str("container_name", c.Name).Str(zerowrap.FieldStatus, c.Status).Msg("found orphaned container, removing")
1591 1789
1592 if err := s.runtime.StopContainer(ctx, c.ID); err != nil {1790 if err := s.runtime.StopContainer(ctx, c.ID); err != nil {
1593 log.WrapErrWithFields(err, "failed to stop orphaned container", map[string]any{zerowrap.FieldEntityID: c.ID})1791 log.WrapErrWithFields(err, "failed to stop orphaned container", map[string]any{zerowrap.FieldEntityID: c.ID})
@@ -2065,7 +2263,7 @@ func (s *Service) findContainerByName(ctx context.Context, name string) *domain.
2065 return nil2263 return nil
2066 }2264 }
2067 2265
2068-func (s *Service) waitForReady(ctx context.Context, containerID string) error {2266+func (s *Service) waitForReady(ctx context.Context, containerID string, containerConfig *domain.ContainerConfig) error {
2069 if err := s.pollContainerRunning(ctx, containerID); err != nil {2267 if err := s.pollContainerRunning(ctx, containerID); err != nil {
2070 return err2268 return err
2071 }2269 }
@@ -2092,15 +2290,123 @@ func (s *Service) waitForReady(ctx context.Context, containerID string) error {
2092 }2290 }
2093 return s.waitForHealthy(ctx, containerID, cfg.HealthTimeout)2291 return s.waitForHealthy(ctx, containerID, cfg.HealthTimeout)
2094 default: // auto2292 default: // auto
2095- _, hasHealthcheck, err := s.runtime.GetContainerHealthStatus(ctx, containerID)2293+ return s.readinessCascade(ctx, containerID, containerConfig, cfg)
2096- if err != nil {2294+ }
2097- return err2295+}
2098- }2296+
2099- if hasHealthcheck {2297+// readinessCascade auto-detects the strongest available readiness signal:
2100- return s.waitForHealthy(ctx, containerID, cfg.HealthTimeout)2298+// 1. Docker healthcheck (if present) → wait for healthy status
2101- }2299+// 2. HTTP probe (if gordon.health label set) → GET until 2xx/3xx
2102- return s.waitForReadyByDelay(ctx, containerID)2300+// 3. TCP probe (if port info available) → connect until accepted
2301+// 4. Delay fallback (last resort) → waitForReadyByDelay
2302+func (s *Service) readinessCascade(ctx context.Context, containerID string, containerConfig *domain.ContainerConfig, cfg Config) error {
2303+ log := zerowrap.FromCtx(ctx)
2304+
2305+ // 1. Docker healthcheck
2306+ _, hasHealthcheck, err := s.runtime.GetContainerHealthStatus(ctx, containerID)
2307+ if err != nil {
2308+ return err
2309+ }
2310+ if hasHealthcheck {
2311+ log.Info().Msg("readiness cascade: using Docker healthcheck")
2312+ return s.waitForHealthy(ctx, containerID, cfg.HealthTimeout)
2313+ }
2314+
2315+ // 2. HTTP probe via gordon.health label
2316+ if probed, probeErr := s.tryHTTPProbe(ctx, containerID, containerConfig, cfg); probed {
2317+ return probeErr
2318+ }
2319+
2320+ // 3. TCP probe (if port info available)
2321+ if probed, probeErr := s.tryTCPProbe(ctx, containerID, containerConfig, cfg); probed {
2322+ return probeErr
2323+ }
2324+
2325+ // 4. Delay fallback
2326+ log.Info().Msg("readiness cascade: using delay fallback")
2327+ return s.waitForReadyByDelay(ctx, containerID)
2328+}
2329+
2330+// tryHTTPProbe attempts an HTTP probe if the gordon.health label is set.
2331+// Returns (true, err) if the probe was attempted, (false, nil) if skipped.
2332+func (s *Service) tryHTTPProbe(ctx context.Context, containerID string, containerConfig *domain.ContainerConfig, cfg Config) (bool, error) {
2333+ log := zerowrap.FromCtx(ctx)
2334+ if containerConfig == nil {
2335+ return false, nil
2336+ }
2337+ healthPath, ok := containerConfig.Labels[domain.LabelHealth]
2338+ if !ok || healthPath == "" {
2339+ return false, nil
2340+ }
2341+ ip, port, probeErr := s.resolveContainerEndpoint(ctx, containerID)
2342+ if probeErr != nil || ip == "" || port <= 0 {
2343+ log.Debug().Err(probeErr).Msg("readiness cascade: HTTP probe skipped, could not resolve container endpoint")
2344+ return false, nil
2345+ }
2346+ url := fmt.Sprintf("http://%s:%d%s", ip, port, healthPath)
2347+ timeout := cfg.HTTPProbeTimeout
2348+ if timeout == 0 {
2349+ timeout = 60 * time.Second
2350+ }
2351+ log.Info().Str("url", url).Dur("timeout", timeout).Msg("readiness cascade: using HTTP probe")
2352+ return true, httpProbe(ctx, url, timeout)
2353+}
2354+
2355+// tryTCPProbe attempts a TCP probe if port info is available.
2356+// Returns (true, err) if the probe was attempted, (false, nil) if skipped.
2357+func (s *Service) tryTCPProbe(ctx context.Context, containerID string, containerConfig *domain.ContainerConfig, cfg Config) (bool, error) {
2358+ log := zerowrap.FromCtx(ctx)
2359+ if containerConfig == nil || len(containerConfig.Ports) == 0 {
2360+ return false, nil
2361+ }
2362+ ip, port, probeErr := s.resolveContainerEndpoint(ctx, containerID)
2363+ if probeErr != nil || ip == "" || port <= 0 {
2364+ log.Debug().Err(probeErr).Msg("readiness cascade: TCP probe skipped, could not resolve container endpoint")
2365+ return false, nil
2366+ }
2367+ addr := fmt.Sprintf("%s:%d", ip, port)
2368+ timeout := cfg.TCPProbeTimeout
2369+ if timeout == 0 {
2370+ timeout = 30 * time.Second
2371+ }
2372+ log.Info().Str("addr", addr).Dur("timeout", timeout).Msg("readiness cascade: using TCP probe")
2373+ return true, tcpProbe(ctx, addr, timeout)
2374+}
2375+
2376+// resolveContainerEndpoint returns a host-reachable address for probing.
2377+// In rootless podman/Docker setups, container internal IPs are not routable
2378+// from the host. We use the host port binding (127.0.0.1:<mapped_port>)
2379+// which is always reachable. Falls back to the container's internal IP
2380+// only if no host port mapping exists (e.g. host-network mode).
2381+func (s *Service) resolveContainerEndpoint(ctx context.Context, containerID string) (string, int, error) {
2382+ log := zerowrap.FromCtx(ctx)
2383+
2384+ // First, get the container's internal port from network info
2385+ _, internalPort, err := s.runtime.GetContainerNetworkInfo(ctx, containerID)
2386+ if err != nil {
2387+ return "", 0, err
2388+ }
2389+
2390+ // Try to resolve via host port binding (works in rootless podman/Docker)
2391+ hostPort, hostErr := s.runtime.GetContainerPort(ctx, containerID, internalPort)
2392+ if hostErr == nil && hostPort > 0 {
2393+ log.Debug().
2394+ Int("internal_port", internalPort).
2395+ Int("host_port", hostPort).
2396+ Msg("resolved container endpoint via host port binding")
2397+ return "127.0.0.1", hostPort, nil
2398+ }
2399+
2400+ // Fallback: use internal IP (works in Docker rootful / host network)
2401+ ip, _, fallbackErr := s.runtime.GetContainerNetworkInfo(ctx, containerID)
2402+ if fallbackErr != nil {
2403+ return "", 0, fallbackErr
2103 }2404 }
2405+ log.Debug().
2406+ Str("ip", ip).
2407+ Int("port", internalPort).
2408+ Msg("resolved container endpoint via internal IP (no host port binding)")
2409+ return ip, internalPort, nil
2104 }2410 }
2105 2411
2106 // waitForReadyByDelay waits using the legacy running+delay strategy.2412 // waitForReadyByDelay waits using the legacy running+delay strategy.
M internal/usecase/container/service_readiness_test.go +175 -1
@@ -1,16 +1,31 @@
11 package container
22
33 import (
4+ "net"
45 "strings"
56 "testing"
67 "time"
78
89 "github.com/stretchr/testify/assert"
910 "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
1012
1113 "github.com/bnema/gordon/internal/boundaries/out/mocks"
14+ "github.com/bnema/gordon/internal/domain"
1215 )
1316
17+// reserveEphemeralAddr binds on 127.0.0.1:0, captures the assigned port, closes
18+// the listener, and returns the address string. The port is guaranteed to be
19+// unreachable (no process listening) when the probe runs.
20+func reserveEphemeralAddr(t *testing.T) (ip string, port int) {
21+ t.Helper()
22+ ln, err := net.Listen("tcp", "127.0.0.1:0")
23+ require.NoError(t, err)
24+ addr := ln.Addr().(*net.TCPAddr)
25+ ln.Close()
26+ return addr.IP.String(), addr.Port
27+}
28+
1429 func TestService_WaitForReady_AutoFallsBackToDelayWhenNoHealthcheck(t *testing.T) {
1530 runtime := mocks.NewMockContainerRuntime(t)
1631 svc := NewService(runtime, nil, nil, nil, Config{
@@ -28,7 +43,166 @@ func TestService_WaitForReady_AutoFallsBackToDelayWhenNoHealthcheck(t *testing.T
2843 // Delay-mode fallback verification
2944 runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()
3045
31- err := svc.waitForReady(ctx, containerID)
46+ err := svc.waitForReady(ctx, containerID, nil)
47+ assert.NoError(t, err)
48+}
49+
50+func TestService_WaitForReady_AutoCascadeUsesTCPWhenNoHealthcheckAndNoHealthLabel(t *testing.T) {
51+ runtime := mocks.NewMockContainerRuntime(t)
52+ svc := NewService(runtime, nil, nil, nil, Config{
53+ ReadinessMode: "auto",
54+ })
55+
56+ ctx := testContext()
57+ containerID := "container-1"
58+
59+ containerConfig := &domain.ContainerConfig{
60+ Ports: []int{8080},
61+ Labels: map[string]string{},
62+ }
63+
64+ // Reserve an ephemeral loopback port that is guaranteed to be unreachable.
65+ probeIP, probePort := reserveEphemeralAddr(t)
66+
67+ // Initial running poll
68+ runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()
69+ // No Docker healthcheck
70+ runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once()
71+ // Cascade resolves container endpoint for TCP probe
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()
75+
76+ // TCP probe will try to connect — which will fail since the port is closed.
77+ // Use a short health timeout so the test doesn't hang.
78+ svc.mu.Lock()
79+ svc.config.TCPProbeTimeout = 50 * time.Millisecond
80+ svc.mu.Unlock()
81+
82+ err := svc.waitForReady(ctx, containerID, containerConfig)
83+ // Expect TCP probe timeout (no server listening)
84+ assert.Error(t, err)
85+ assert.Contains(t, err.Error(), "TCP probe timeout")
86+}
87+
88+func TestService_WaitForReady_AutoCascadeUsesHTTPProbeWhenHealthLabelSet(t *testing.T) {
89+ runtime := mocks.NewMockContainerRuntime(t)
90+ svc := NewService(runtime, nil, nil, nil, Config{
91+ ReadinessMode: "auto",
92+ HTTPProbeTimeout: 50 * time.Millisecond,
93+ })
94+
95+ ctx := testContext()
96+ containerID := "container-1"
97+
98+ containerConfig := &domain.ContainerConfig{
99+ Ports: []int{8080},
100+ Labels: map[string]string{
101+ domain.LabelHealth: "/healthz",
102+ },
103+ }
104+
105+ // Reserve an ephemeral loopback port that is guaranteed to be unreachable.
106+ probeIP, probePort := reserveEphemeralAddr(t)
107+
108+ // Initial running poll
109+ runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()
110+ // No Docker healthcheck
111+ runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once()
112+ // Cascade resolves container endpoint for HTTP probe
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()
116+
117+ err := svc.waitForReady(ctx, containerID, containerConfig)
118+ // Expect HTTP probe timeout (no server listening)
119+ assert.Error(t, err)
120+ assert.Contains(t, err.Error(), "HTTP probe timeout")
121+}
122+
123+func TestService_WaitForReady_AutoCascadeUsesHealthcheckWhenPresent(t *testing.T) {
124+ runtime := mocks.NewMockContainerRuntime(t)
125+ svc := NewService(runtime, nil, nil, nil, Config{
126+ ReadinessMode: "auto",
127+ HealthTimeout: 50 * time.Millisecond,
128+ })
129+
130+ ctx := testContext()
131+ containerID := "container-1"
132+
133+ containerConfig := &domain.ContainerConfig{
134+ Ports: []int{8080},
135+ Labels: map[string]string{
136+ domain.LabelHealth: "/healthz",
137+ },
138+ }
139+
140+ // Initial running poll
141+ runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()
142+ // Docker healthcheck IS present — cascade detects it, then waitForHealthy polls it
143+ // First call: cascade detection (hasHealthcheck=true)
144+ // Second call: waitForHealthy loop (status=healthy)
145+ runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("starting", true, nil).Once()
146+ runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("healthy", true, nil).Once()
147+
148+ err := svc.waitForReady(ctx, containerID, containerConfig)
149+ assert.NoError(t, err)
150+}
151+
152+func TestService_WaitForReady_AutoCascadeFallsToDelayWhenNoEndpoint(t *testing.T) {
153+ runtime := mocks.NewMockContainerRuntime(t)
154+ svc := NewService(runtime, nil, nil, nil, Config{
155+ ReadinessMode: "auto",
156+ ReadinessDelay: time.Millisecond,
157+ })
158+
159+ ctx := testContext()
160+ containerID := "container-1"
161+
162+ // Container has ports but network info is unavailable
163+ containerConfig := &domain.ContainerConfig{
164+ Ports: []int{8080},
165+ Labels: map[string]string{},
166+ }
167+
168+ // Initial running poll
169+ runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()
170+ // No Docker healthcheck
171+ runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once()
172+ // Network info fails — can't do TCP probe, fall through to delay
173+ runtime.EXPECT().GetContainerNetworkInfo(mock.Anything, containerID).Return("", 0, assert.AnError).Once()
174+ // Delay-mode fallback verification
175+ runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()
176+
177+ err := svc.waitForReady(ctx, containerID, containerConfig)
178+ assert.NoError(t, err)
179+}
180+
181+func TestService_WaitForReady_ExplicitDelayModeIgnoresCascade(t *testing.T) {
182+ runtime := mocks.NewMockContainerRuntime(t)
183+ svc := NewService(runtime, nil, nil, nil, Config{
184+ ReadinessMode: "delay",
185+ ReadinessDelay: time.Millisecond,
186+ })
187+
188+ ctx := testContext()
189+ containerID := "container-1"
190+
191+ // Even with health label and ports, "delay" mode skips cascade entirely
192+ containerConfig := &domain.ContainerConfig{
193+ Ports: []int{8080},
194+ Labels: map[string]string{
195+ domain.LabelHealth: "/healthz",
196+ },
197+ }
198+
199+ // Initial running poll
200+ runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()
201+ // No GetContainerHealthStatus call — delay mode skips it
202+ // Delay-mode verification
203+ runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()
204+
205+ err := svc.waitForReady(ctx, containerID, containerConfig)
32206 assert.NoError(t, err)
33207 }
34208
@@ -1,16 +1,31 @@
1 package container1 package container
2 2
3 import (3 import (
4+ "net"
4 "strings"5 "strings"
5 "testing"6 "testing"
6 "time"7 "time"
7 8
8 "github.com/stretchr/testify/assert"9 "github.com/stretchr/testify/assert"
9 "github.com/stretchr/testify/mock"10 "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
10 12
11 "github.com/bnema/gordon/internal/boundaries/out/mocks"13 "github.com/bnema/gordon/internal/boundaries/out/mocks"
14+ "github.com/bnema/gordon/internal/domain"
12 )15 )
13 16
17+// reserveEphemeralAddr binds on 127.0.0.1:0, captures the assigned port, closes
18+// the listener, and returns the address string. The port is guaranteed to be
19+// unreachable (no process listening) when the probe runs.
20+func reserveEphemeralAddr(t *testing.T) (ip string, port int) {
21+ t.Helper()
22+ ln, err := net.Listen("tcp", "127.0.0.1:0")
23+ require.NoError(t, err)
24+ addr := ln.Addr().(*net.TCPAddr)
25+ ln.Close()
26+ return addr.IP.String(), addr.Port
27+}
28+
14 func TestService_WaitForReady_AutoFallsBackToDelayWhenNoHealthcheck(t *testing.T) {29 func TestService_WaitForReady_AutoFallsBackToDelayWhenNoHealthcheck(t *testing.T) {
15 runtime := mocks.NewMockContainerRuntime(t)30 runtime := mocks.NewMockContainerRuntime(t)
16 svc := NewService(runtime, nil, nil, nil, Config{31 svc := NewService(runtime, nil, nil, nil, Config{
@@ -28,7 +43,166 @@ func TestService_WaitForReady_AutoFallsBackToDelayWhenNoHealthcheck(t *testing.T
28 // Delay-mode fallback verification43 // Delay-mode fallback verification
29 runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()44 runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()
30 45
31- err := svc.waitForReady(ctx, containerID)46+ err := svc.waitForReady(ctx, containerID, nil)
47+ assert.NoError(t, err)
48+}
49+
50+func TestService_WaitForReady_AutoCascadeUsesTCPWhenNoHealthcheckAndNoHealthLabel(t *testing.T) {
51+ runtime := mocks.NewMockContainerRuntime(t)
52+ svc := NewService(runtime, nil, nil, nil, Config{
53+ ReadinessMode: "auto",
54+ })
55+
56+ ctx := testContext()
57+ containerID := "container-1"
58+
59+ containerConfig := &domain.ContainerConfig{
60+ Ports: []int{8080},
61+ Labels: map[string]string{},
62+ }
63+
64+ // Reserve an ephemeral loopback port that is guaranteed to be unreachable.
65+ probeIP, probePort := reserveEphemeralAddr(t)
66+
67+ // Initial running poll
68+ runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()
69+ // No Docker healthcheck
70+ runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once()
71+ // Cascade resolves container endpoint for TCP probe
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()
75+
76+ // TCP probe will try to connect — which will fail since the port is closed.
77+ // Use a short health timeout so the test doesn't hang.
78+ svc.mu.Lock()
79+ svc.config.TCPProbeTimeout = 50 * time.Millisecond
80+ svc.mu.Unlock()
81+
82+ err := svc.waitForReady(ctx, containerID, containerConfig)
83+ // Expect TCP probe timeout (no server listening)
84+ assert.Error(t, err)
85+ assert.Contains(t, err.Error(), "TCP probe timeout")
86+}
87+
88+func TestService_WaitForReady_AutoCascadeUsesHTTPProbeWhenHealthLabelSet(t *testing.T) {
89+ runtime := mocks.NewMockContainerRuntime(t)
90+ svc := NewService(runtime, nil, nil, nil, Config{
91+ ReadinessMode: "auto",
92+ HTTPProbeTimeout: 50 * time.Millisecond,
93+ })
94+
95+ ctx := testContext()
96+ containerID := "container-1"
97+
98+ containerConfig := &domain.ContainerConfig{
99+ Ports: []int{8080},
100+ Labels: map[string]string{
101+ domain.LabelHealth: "/healthz",
102+ },
103+ }
104+
105+ // Reserve an ephemeral loopback port that is guaranteed to be unreachable.
106+ probeIP, probePort := reserveEphemeralAddr(t)
107+
108+ // Initial running poll
109+ runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()
110+ // No Docker healthcheck
111+ runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once()
112+ // Cascade resolves container endpoint for HTTP probe
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()
116+
117+ err := svc.waitForReady(ctx, containerID, containerConfig)
118+ // Expect HTTP probe timeout (no server listening)
119+ assert.Error(t, err)
120+ assert.Contains(t, err.Error(), "HTTP probe timeout")
121+}
122+
123+func TestService_WaitForReady_AutoCascadeUsesHealthcheckWhenPresent(t *testing.T) {
124+ runtime := mocks.NewMockContainerRuntime(t)
125+ svc := NewService(runtime, nil, nil, nil, Config{
126+ ReadinessMode: "auto",
127+ HealthTimeout: 50 * time.Millisecond,
128+ })
129+
130+ ctx := testContext()
131+ containerID := "container-1"
132+
133+ containerConfig := &domain.ContainerConfig{
134+ Ports: []int{8080},
135+ Labels: map[string]string{
136+ domain.LabelHealth: "/healthz",
137+ },
138+ }
139+
140+ // Initial running poll
141+ runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()
142+ // Docker healthcheck IS present — cascade detects it, then waitForHealthy polls it
143+ // First call: cascade detection (hasHealthcheck=true)
144+ // Second call: waitForHealthy loop (status=healthy)
145+ runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("starting", true, nil).Once()
146+ runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("healthy", true, nil).Once()
147+
148+ err := svc.waitForReady(ctx, containerID, containerConfig)
149+ assert.NoError(t, err)
150+}
151+
152+func TestService_WaitForReady_AutoCascadeFallsToDelayWhenNoEndpoint(t *testing.T) {
153+ runtime := mocks.NewMockContainerRuntime(t)
154+ svc := NewService(runtime, nil, nil, nil, Config{
155+ ReadinessMode: "auto",
156+ ReadinessDelay: time.Millisecond,
157+ })
158+
159+ ctx := testContext()
160+ containerID := "container-1"
161+
162+ // Container has ports but network info is unavailable
163+ containerConfig := &domain.ContainerConfig{
164+ Ports: []int{8080},
165+ Labels: map[string]string{},
166+ }
167+
168+ // Initial running poll
169+ runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()
170+ // No Docker healthcheck
171+ runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once()
172+ // Network info fails — can't do TCP probe, fall through to delay
173+ runtime.EXPECT().GetContainerNetworkInfo(mock.Anything, containerID).Return("", 0, assert.AnError).Once()
174+ // Delay-mode fallback verification
175+ runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()
176+
177+ err := svc.waitForReady(ctx, containerID, containerConfig)
178+ assert.NoError(t, err)
179+}
180+
181+func TestService_WaitForReady_ExplicitDelayModeIgnoresCascade(t *testing.T) {
182+ runtime := mocks.NewMockContainerRuntime(t)
183+ svc := NewService(runtime, nil, nil, nil, Config{
184+ ReadinessMode: "delay",
185+ ReadinessDelay: time.Millisecond,
186+ })
187+
188+ ctx := testContext()
189+ containerID := "container-1"
190+
191+ // Even with health label and ports, "delay" mode skips cascade entirely
192+ containerConfig := &domain.ContainerConfig{
193+ Ports: []int{8080},
194+ Labels: map[string]string{
195+ domain.LabelHealth: "/healthz",
196+ },
197+ }
198+
199+ // Initial running poll
200+ runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()
201+ // No GetContainerHealthStatus call — delay mode skips it
202+ // Delay-mode verification
203+ runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()
204+
205+ err := svc.waitForReady(ctx, containerID, containerConfig)
32 assert.NoError(t, err)206 assert.NoError(t, err)
33 }207 }
34 208
M internal/usecase/container/service_test.go +811 -89
@@ -25,6 +25,17 @@ func testContext() context.Context {
2525 return zerowrap.WithCtx(context.Background(), zerowrap.Default())
2626 }
2727
28+// testMinDelayConfig returns a Config with all timing delays set to 1ms,
29+// suitable for unit tests that don't want to wait for real timeouts.
30+func testMinDelayConfig() Config {
31+ return Config{
32+ ReadinessDelay: time.Millisecond,
33+ DrainDelay: time.Millisecond,
34+ DrainDelayConfigured: true,
35+ StabilizationDelay: time.Millisecond,
36+ }
37+}
38+
2839 func setupMetricsTest(t *testing.T) (*telemetry.Metrics, *sdkmetric.ManualReader) {
2940 t.Helper()
3041
@@ -184,10 +195,11 @@ func TestService_Deploy_Success(t *testing.T) {
184195 eventBus := mocks.NewMockEventPublisher(t)
185196
186197 config := Config{
187- NetworkIsolation: false,
188- VolumeAutoCreate: false,
189- ReadinessDelay: time.Millisecond, // Minimal delay for tests
190- DrainDelay: time.Millisecond,
198+ NetworkIsolation: false,
199+ VolumeAutoCreate: false,
200+ ReadinessDelay: time.Millisecond, // Minimal delay for tests
201+ DrainDelay: time.Millisecond,
202+ DrainDelayConfigured: true,
191203 }
192204
193205 svc := NewService(runtime, envLoader, eventBus, nil, config)
@@ -198,6 +210,9 @@ func TestService_Deploy_Success(t *testing.T) {
198210 Image: "myapp:latest",
199211 }
200212
213+ // No container in memory — runtime resolution returns nothing
214+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
215+
201216 // Setup mocks - no orphaned containers
202217 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
203218
@@ -261,10 +276,11 @@ func TestService_Deploy_ReadinessRecoveryWindow_AllowsTransientFlap(t *testing.T
261276 eventBus := mocks.NewMockEventPublisher(t)
262277
263278 config := Config{
264- NetworkIsolation: false,
265- VolumeAutoCreate: false,
266- ReadinessDelay: time.Millisecond,
267- DrainDelay: time.Millisecond,
279+ NetworkIsolation: false,
280+ VolumeAutoCreate: false,
281+ ReadinessDelay: time.Millisecond,
282+ DrainDelay: time.Millisecond,
283+ DrainDelayConfigured: true,
268284 }
269285
270286 svc := NewService(runtime, envLoader, eventBus, nil, config)
@@ -275,6 +291,9 @@ func TestService_Deploy_ReadinessRecoveryWindow_AllowsTransientFlap(t *testing.T
275291 Image: "myapp:latest",
276292 }
277293
294+ // No container in memory — runtime resolution returns nothing
295+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
296+
278297 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
279298 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
280299 runtime.EXPECT().PullImage(mock.Anything, "myapp:latest").Return(nil)
@@ -329,6 +348,9 @@ func TestService_Deploy_ImagePullFailure(t *testing.T) {
329348 Image: "myapp:latest",
330349 }
331350
351+ // No container in memory — runtime resolution returns nothing
352+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
353+
332354 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
333355 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
334356 runtime.EXPECT().PullImage(mock.Anything, "myapp:latest").Return(errors.New("image not found"))
@@ -406,8 +428,10 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) {
406428 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
407429
408430 config := Config{
409- ReadinessDelay: time.Millisecond, // Minimal delay for tests
410- DrainDelay: time.Millisecond,
431+ ReadinessDelay: time.Millisecond, // Minimal delay for tests
432+ DrainDelay: time.Millisecond,
433+ DrainDelayConfigured: true,
434+ StabilizationDelay: time.Millisecond,
411435 }
412436 svc := NewService(runtime, envLoader, eventBus, nil, config)
413437 svc.SetProxyCacheInvalidator(cacheInvalidator)
@@ -445,8 +469,8 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) {
445469 })).Return(newContainer, nil)
446470 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
447471
448- // Wait for ready
449- runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
472+ // Wait for ready (2 calls) + stabilization check (1 call)
473+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
450474
451475 // Inspect after ready
452476 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
@@ -460,7 +484,7 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) {
460484 // Synchronous cache invalidation before old container cleanup
461485 cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
462486
463- // Now cleanup old container (after new one is ready + cache invalidated + drain delay)
487+ // Now cleanup old container (after new one is ready + cache invalidated + stabilization + drain delay)
464488 runtime.EXPECT().StopContainer(mock.Anything, "old-container").Return(nil)
465489 runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil)
466490
@@ -471,10 +495,106 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) {
471495
472496 assert.NoError(t, err)
473497 assert.Equal(t, "new-container", result.ID)
498+}
499+
500+// TestService_Deploy_ResolvesExistingFromRuntime_WhenMemoryStale verifies that
501+// when in-memory state has no tracked container for a domain, Deploy queries
502+// the runtime and discovers the running container. This prevents orphan cleanup
503+// from killing the real active container after a Gordon restart.
504+func TestService_Deploy_ResolvesExistingFromRuntime_WhenMemoryStale(t *testing.T) {
505+ runtime := mocks.NewMockContainerRuntime(t)
506+ envLoader := mocks.NewMockEnvLoader(t)
507+ eventBus := mocks.NewMockEventPublisher(t)
508+ cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
509+
510+ config := testMinDelayConfig()
511+ svc := NewService(runtime, envLoader, eventBus, nil, config)
512+ svc.SetProxyCacheInvalidator(cacheInvalidator)
513+ ctx := testContext()
514+
515+ // NO tracked container in memory — simulates Gordon restart
516+ // (svc.containers is empty for "test.example.com")
517+
518+ route := domain.Route{
519+ Domain: "test.example.com",
520+ Image: "myapp:v2",
521+ }
522+
523+ // resolveExistingContainer should query running containers from runtime
524+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{
525+ {
526+ ID: "runtime-active-container",
527+ Name: "gordon-test.example.com",
528+ Status: "running",
529+ Labels: map[string]string{
530+ domain.LabelDomain: "test.example.com",
531+ domain.LabelManaged: "true",
532+ },
533+ },
534+ }, nil)
535+
536+ // Orphan cleanup lists ALL containers (including stopped) — the runtime-active
537+ // container should be skipped because it is now recognized as the existing one
538+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
539+ {
540+ ID: "runtime-active-container",
541+ Name: "gordon-test.example.com",
542+ Status: "running",
543+ Labels: map[string]string{
544+ domain.LabelDomain: "test.example.com",
545+ domain.LabelManaged: "true",
546+ },
547+ },
548+ }, nil)
549+
550+ // Image operations
551+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
552+ runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
553+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
554+
555+ // Environment
556+ envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
557+ runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
558+
559+ // Create new container with -new suffix (zero-downtime: existing was resolved from runtime)
560+ newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
561+ runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
562+ return cfg.Name == "gordon-test.example.com-new"
563+ })).Return(newContainer, nil)
564+ runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
565+
566+ // Wait for ready (2 calls) + stabilization check (1 call)
567+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
568+
569+ // Inspect after ready
570+ runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
571+ ID: "new-container",
572+ Status: "running",
573+ }, nil)
574+
575+ // Publish event
576+ eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
577+
578+ // Synchronous cache invalidation
579+ cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
580+
581+ // AFTER new container is ready: stop and remove old runtime-discovered container
582+ // This is the key assertion — StopContainer on the runtime-active container
583+ // happens during finalizePreviousContainer, NOT during orphan cleanup
584+ runtime.EXPECT().StopContainer(mock.Anything, "runtime-active-container").Return(nil)
585+ runtime.EXPECT().RemoveContainer(mock.Anything, "runtime-active-container", true).Return(nil)
586+
587+ // Rename new container to canonical name
588+ runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
589+
590+ result, err := svc.Deploy(ctx, route)
591+
592+ assert.NoError(t, err)
593+ assert.Equal(t, "new-container", result.ID)
474594
475- // Ensure the service's tracked container entry now points to the new container.
476- tracked, ok := svc.Get(ctx, "test.example.com")
477- assert.True(t, ok)
595+ // Verify new container is now tracked
596+ tracked, exists := svc.Get(ctx, "test.example.com")
597+ assert.True(t, exists)
478598 assert.Equal(t, "new-container", tracked.ID)
479599 }
480600
@@ -483,10 +603,7 @@ func TestService_Deploy_SkipRedundantDeploy_GetImageIDError(t *testing.T) {
483603 envLoader := mocks.NewMockEnvLoader(t)
484604 eventBus := mocks.NewMockEventPublisher(t)
485605
486- config := Config{
487- ReadinessDelay: time.Millisecond,
488- DrainDelay: time.Millisecond,
489- }
606+ config := testMinDelayConfig()
490607 svc := NewService(runtime, envLoader, eventBus, nil, config)
491608 ctx := testContext()
492609
@@ -519,7 +636,8 @@ func TestService_Deploy_SkipRedundantDeploy_GetImageIDError(t *testing.T) {
519636 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
520637 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil)
521638 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
522- runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
639+ // Wait for ready (2 calls) + stabilization check (1 call)
640+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
523641 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
524642 ID: "new-container", Status: "running",
525643 }, nil)
@@ -542,10 +660,11 @@ func TestService_Deploy_WithNetworkIsolation(t *testing.T) {
542660 eventBus := mocks.NewMockEventPublisher(t)
543661
544662 config := Config{
545- NetworkIsolation: true,
546- NetworkPrefix: "gordon",
547- ReadinessDelay: time.Millisecond, // Minimal delay for tests
548- DrainDelay: time.Millisecond,
663+ NetworkIsolation: true,
664+ NetworkPrefix: "gordon",
665+ ReadinessDelay: time.Millisecond, // Minimal delay for tests
666+ DrainDelay: time.Millisecond,
667+ DrainDelayConfigured: true,
549668 }
550669 svc := NewService(runtime, envLoader, eventBus, nil, config)
551670 ctx := testContext()
@@ -555,6 +674,9 @@ func TestService_Deploy_WithNetworkIsolation(t *testing.T) {
555674 Image: "myapp:latest",
556675 }
557676
677+ // No container in memory — runtime resolution returns nothing
678+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
679+
558680 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
559681 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
560682 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
@@ -594,10 +716,11 @@ func TestService_Deploy_WithVolumeAutoCreate(t *testing.T) {
594716 eventBus := mocks.NewMockEventPublisher(t)
595717
596718 config := Config{
597- VolumeAutoCreate: true,
598- VolumePrefix: "gordon",
599- ReadinessDelay: time.Millisecond, // Minimal delay for tests
600- DrainDelay: time.Millisecond,
719+ VolumeAutoCreate: true,
720+ VolumePrefix: "gordon",
721+ ReadinessDelay: time.Millisecond, // Minimal delay for tests
722+ DrainDelay: time.Millisecond,
723+ DrainDelayConfigured: true,
601724 }
602725 svc := NewService(runtime, envLoader, eventBus, nil, config)
603726 ctx := testContext()
@@ -607,6 +730,9 @@ func TestService_Deploy_WithVolumeAutoCreate(t *testing.T) {
607730 Image: "myapp:latest",
608731 }
609732
733+ // No container in memory — runtime resolution returns nothing
734+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
735+
610736 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
611737 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
612738 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
@@ -1312,6 +1438,7 @@ func TestService_Deploy_InternalDeployForcesPull(t *testing.T) {
13121438 InternalRegistryPassword: "secret",
13131439 ReadinessDelay: time.Millisecond,
13141440 DrainDelay: time.Millisecond,
1441+ DrainDelayConfigured: true,
13151442 }
13161443
13171444 svc := NewService(runtime, envLoader, eventBus, nil, config)
@@ -1324,6 +1451,9 @@ func TestService_Deploy_InternalDeployForcesPull(t *testing.T) {
13241451 Image: "myapp:latest",
13251452 }
13261453
1454+ // No container in memory — runtime resolution returns nothing
1455+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
1456+
13271457 // Setup mocks - no orphaned containers
13281458 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
13291459
@@ -1379,8 +1509,9 @@ func TestService_AutoStart_StartsNewContainers(t *testing.T) {
13791509 eventBus := mocks.NewMockEventPublisher(t)
13801510
13811511 config := Config{
1382- ReadinessDelay: time.Millisecond,
1383- DrainDelay: time.Millisecond,
1512+ ReadinessDelay: time.Millisecond,
1513+ DrainDelay: time.Millisecond,
1514+ DrainDelayConfigured: true,
13841515 }
13851516 svc := NewService(runtime, envLoader, eventBus, nil, config)
13861517 ctx := testContext()
@@ -1390,6 +1521,9 @@ func TestService_AutoStart_StartsNewContainers(t *testing.T) {
13901521 {Domain: "app2.example.com", Image: "myapp2:latest"},
13911522 }
13921523
1524+ // No container in memory — runtime resolution returns nothing (one per route)
1525+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil).Times(2)
1526+
13931527 // Setup mocks for route deployments
13941528 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Times(2)
13951529 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp1:latest", "myapp2:latest"}, nil).Times(2)
@@ -1422,8 +1556,9 @@ func TestService_AutoStart_SkipsExistingContainers(t *testing.T) {
14221556 eventBus := mocks.NewMockEventPublisher(t)
14231557
14241558 config := Config{
1425- ReadinessDelay: time.Millisecond,
1426- DrainDelay: time.Millisecond,
1559+ ReadinessDelay: time.Millisecond,
1560+ DrainDelay: time.Millisecond,
1561+ DrainDelayConfigured: true,
14271562 }
14281563 svc := NewService(runtime, envLoader, eventBus, nil, config)
14291564 ctx := testContext()
@@ -1438,6 +1573,9 @@ func TestService_AutoStart_SkipsExistingContainers(t *testing.T) {
14381573 {Domain: "app2.example.com", Image: "myapp2:latest"}, // New route
14391574 }
14401575
1576+ // No container in memory for app2 — runtime resolution returns nothing
1577+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil).Once()
1578+
14411579 // Only deploy for app2 (app1 is skipped). Readiness is skipped — no IsContainerRunning.
14421580 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Once()
14431581 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp2:latest"}, nil).Once()
@@ -1472,6 +1610,9 @@ func TestService_AutoStart_HandlesDeployErrors(t *testing.T) {
14721610 {Domain: "app1.example.com", Image: "myapp1:latest"},
14731611 }
14741612
1613+ // No container in memory — runtime resolution returns nothing
1614+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
1615+
14751616 // Setup mocks for failure
14761617 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
14771618 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
@@ -1490,10 +1631,11 @@ func TestService_AutoStart_UsesInternalDeployContext(t *testing.T) {
14901631 eventBus := mocks.NewMockEventPublisher(t)
14911632
14921633 config := Config{
1493- RegistryDomain: "reg.example.com",
1494- RegistryPort: 5000,
1495- ReadinessDelay: time.Millisecond,
1496- DrainDelay: time.Millisecond,
1634+ RegistryDomain: "reg.example.com",
1635+ RegistryPort: 5000,
1636+ ReadinessDelay: time.Millisecond,
1637+ DrainDelay: time.Millisecond,
1638+ DrainDelayConfigured: true,
14971639 }
14981640 svc := NewService(runtime, envLoader, eventBus, nil, config)
14991641
@@ -1504,6 +1646,9 @@ func TestService_AutoStart_UsesInternalDeployContext(t *testing.T) {
15041646 {Domain: "app1.example.com", Image: "reg.example.com/myapp:latest"},
15051647 }
15061648
1649+ // No container in memory — runtime resolution returns nothing
1650+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
1651+
15071652 // Key assertion: PullImage should be called with localhost:5000 rewrite,
15081653 // NOT the original reg.example.com/myapp:latest.
15091654 // Readiness is skipped — no IsContainerRunning calls.
@@ -1538,8 +1683,10 @@ func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) {
15381683 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
15391684
15401685 config := Config{
1541- ReadinessDelay: time.Millisecond, // Minimal delay for tests
1542- DrainDelay: time.Millisecond,
1686+ ReadinessDelay: time.Millisecond, // Minimal delay for tests
1687+ DrainDelay: time.Millisecond,
1688+ DrainDelayConfigured: true,
1689+ StabilizationDelay: time.Millisecond,
15431690 }
15441691 svc := NewService(runtime, envLoader, eventBus, nil, config)
15451692 svc.SetProxyCacheInvalidator(cacheInvalidator)
@@ -1585,8 +1732,8 @@ func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) {
15851732 })).Return(newContainer, nil)
15861733 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
15871734
1588- // Wait for ready
1589- runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
1735+ // Wait for ready (2 calls) + stabilization check (1 call)
1736+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
15901737
15911738 // Inspect after ready
15921739 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
@@ -1619,16 +1766,18 @@ func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) {
16191766 assert.Equal(t, "new-container", tracked.ID)
16201767 }
16211768
1622-// TestService_Deploy_OrphanCleanupRemovesTrueOrphans verifies that containers
1623-// with the same name but NOT tracked are properly removed as orphans.
1769+// TestService_Deploy_OrphanCleanupRemovesTrueOrphans verifies that stopped canonical
1770+// containers with the same name but NOT tracked are properly removed as orphans.
1771+// Running canonical containers are preserved (see NeverKillsRunningCanonical test).
16241772 func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) {
16251773 runtime := mocks.NewMockContainerRuntime(t)
16261774 envLoader := mocks.NewMockEnvLoader(t)
16271775 eventBus := mocks.NewMockEventPublisher(t)
16281776
16291777 config := Config{
1630- ReadinessDelay: time.Millisecond, // Minimal delay for tests
1631- DrainDelay: time.Millisecond,
1778+ ReadinessDelay: time.Millisecond, // Minimal delay for tests
1779+ DrainDelay: time.Millisecond,
1780+ DrainDelayConfigured: true,
16321781 }
16331782 svc := NewService(runtime, envLoader, eventBus, nil, config)
16341783 ctx := testContext()
@@ -1640,17 +1789,21 @@ func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) {
16401789 Image: "myapp:v1",
16411790 }
16421791
1643- // ListContainers returns an orphaned container (same name, but not tracked)
1644- // This could happen if Gordon crashed and restarted, or container was created manually
1792+ // No container in memory — runtime resolution finds no managed container
1793+ // (the orphan is stopped so it won't appear in running-only query)
1794+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
1795+
1796+ // ListContainers(true) returns a STOPPED orphaned container (same name, but not tracked)
1797+ // Stopped canonical containers are safe to remove during orphan cleanup
16451798 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
16461799 {
16471800 ID: "orphan-container",
16481801 Name: "gordon-test.example.com",
1649- Status: "running",
1802+ Status: "exited",
16501803 },
16511804 }, nil)
16521805
1653- // Orphan should be stopped and removed BEFORE we proceed
1806+ // Stopped orphan should be stopped and removed BEFORE we proceed
16541807 runtime.EXPECT().StopContainer(mock.Anything, "orphan-container").Return(nil)
16551808 runtime.EXPECT().RemoveContainer(mock.Anything, "orphan-container", true).Return(nil)
16561809
@@ -1700,10 +1853,7 @@ func TestService_Deploy_OrphanCleanupRemovesStaleNewContainer(t *testing.T) {
17001853 eventBus := mocks.NewMockEventPublisher(t)
17011854 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
17021855
1703- config := Config{
1704- ReadinessDelay: time.Millisecond,
1705- DrainDelay: time.Millisecond,
1706- }
1856+ config := testMinDelayConfig()
17071857 svc := NewService(runtime, envLoader, eventBus, nil, config)
17081858 svc.SetProxyCacheInvalidator(cacheInvalidator)
17091859 ctx := testContext()
@@ -1746,7 +1896,8 @@ func TestService_Deploy_OrphanCleanupRemovesStaleNewContainer(t *testing.T) {
17461896 return cfg.Name == "gordon-test.example.com-new"
17471897 })).Return(newContainer, nil)
17481898 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
1749- runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
1899+ // Wait for ready (2 calls) + stabilization check (1 call)
1900+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
17501901 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
17511902 ID: "new-container",
17521903 Status: "running",
@@ -1772,10 +1923,7 @@ func TestService_Deploy_TrackedTempContainerUsesAlternateTempName(t *testing.T)
17721923 eventBus := mocks.NewMockEventPublisher(t)
17731924 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
17741925
1775- config := Config{
1776- ReadinessDelay: time.Millisecond,
1777- DrainDelay: time.Millisecond,
1778- }
1926+ config := testMinDelayConfig()
17791927 svc := NewService(runtime, envLoader, eventBus, nil, config)
17801928 svc.SetProxyCacheInvalidator(cacheInvalidator)
17811929 ctx := testContext()
@@ -1811,7 +1959,8 @@ func TestService_Deploy_TrackedTempContainerUsesAlternateTempName(t *testing.T)
18111959 return cfg.Name == "gordon-test.example.com-next"
18121960 })).Return(created, nil)
18131961 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
1814- runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
1962+ // Wait for ready (2 calls) + stabilization check (1 call)
1963+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
18151964 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
18161965 ID: "new-container",
18171966 Status: "running",
@@ -1961,10 +2110,7 @@ func TestService_Deploy_ConcurrentSameDomain(t *testing.T) {
19612110 eventBus := mocks.NewMockEventPublisher(t)
19622111 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
19632112
1964- config := Config{
1965- ReadinessDelay: time.Millisecond,
1966- DrainDelay: time.Millisecond,
1967- }
2113+ config := testMinDelayConfig()
19682114 svc := NewService(runtime, envLoader, eventBus, nil, config)
19692115 svc.SetProxyCacheInvalidator(cacheInvalidator)
19702116 ctx := testContext()
@@ -1982,6 +2128,9 @@ func TestService_Deploy_ConcurrentSameDomain(t *testing.T) {
19822128 // First deploy: no existing container → creates gordon-test.example.com
19832129 // Second deploy: sees first container → creates gordon-test.example.com-new
19842130
2131+ // First deploy resolves from runtime (no container in memory yet)
2132+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil).Once()
2133+
19852134 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Times(2)
19862135 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil).Times(2)
19872136 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil).Times(2)
@@ -2005,8 +2154,8 @@ func TestService_Deploy_ConcurrentSameDomain(t *testing.T) {
20052154 })
20062155
20072156 runtime.EXPECT().StartContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Times(2)
2008- // IsContainerRunning is called 2x per deploy in waitForReady (initial check + after delay)
2009- runtime.EXPECT().IsContainerRunning(mock.Anything, mock.AnythingOfType("string")).Return(true, nil).Times(4)
2157+ // IsContainerRunning: 2x per deploy in waitForReady + 1x stabilization for the second deploy (which has existing)
2158+ runtime.EXPECT().IsContainerRunning(mock.Anything, mock.AnythingOfType("string")).Return(true, nil).Times(5)
20102159
20112160 runtime.EXPECT().InspectContainer(mock.Anything, mock.AnythingOfType("string")).
20122161 RunAndReturn(func(_ context.Context, id string) (*domain.Container, error) {
@@ -2069,8 +2218,9 @@ func TestService_Deploy_ContextCancellation(t *testing.T) {
20692218 eventBus := mocks.NewMockEventPublisher(t)
20702219
20712220 config := Config{
2072- ReadinessDelay: time.Millisecond,
2073- DrainDelay: time.Millisecond,
2221+ ReadinessDelay: time.Millisecond,
2222+ DrainDelay: time.Millisecond,
2223+ DrainDelayConfigured: true,
20742224 }
20752225 svc := NewService(runtime, envLoader, eventBus, nil, config)
20762226
@@ -2097,10 +2247,7 @@ func TestService_Deploy_CacheInvalidationBeforeOldContainerStop(t *testing.T) {
20972247 eventBus := mocks.NewMockEventPublisher(t)
20982248 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
20992249
2100- config := Config{
2101- ReadinessDelay: time.Millisecond,
2102- DrainDelay: time.Millisecond,
2103- }
2250+ config := testMinDelayConfig()
21042251 svc := NewService(runtime, envLoader, eventBus, nil, config)
21052252 svc.SetProxyCacheInvalidator(cacheInvalidator)
21062253 ctx := testContext()
@@ -2132,7 +2279,8 @@ func TestService_Deploy_CacheInvalidationBeforeOldContainerStop(t *testing.T) {
21322279 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
21332280 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil)
21342281 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
2135- runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
2282+ // Wait for ready (2 calls) + stabilization check (1 call)
2283+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
21362284 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
21372285 ID: "new-container", Status: "running",
21382286 }, nil)
@@ -2176,10 +2324,7 @@ func TestService_Deploy_NilCacheInvalidator(t *testing.T) {
21762324 envLoader := mocks.NewMockEnvLoader(t)
21772325 eventBus := mocks.NewMockEventPublisher(t)
21782326
2179- config := Config{
2180- ReadinessDelay: time.Millisecond,
2181- DrainDelay: time.Millisecond,
2182- }
2327+ config := testMinDelayConfig()
21832328 // Intentionally NOT setting cache invalidator
21842329 svc := NewService(runtime, envLoader, eventBus, nil, config)
21852330 ctx := testContext()
@@ -2205,7 +2350,8 @@ func TestService_Deploy_NilCacheInvalidator(t *testing.T) {
22052350 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
22062351 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil)
22072352 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
2208- runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
2353+ // Wait for ready (2 calls) + stabilization check (1 call)
2354+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
22092355 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
22102356 ID: "new-container", Status: "running",
22112357 }, nil)
@@ -2226,8 +2372,9 @@ func TestService_Deploy_SkipsRedundantDeploy(t *testing.T) {
22262372 eventBus := mocks.NewMockEventPublisher(t)
22272373
22282374 config := Config{
2229- ReadinessDelay: time.Millisecond,
2230- DrainDelay: time.Millisecond,
2375+ ReadinessDelay: time.Millisecond,
2376+ DrainDelay: time.Millisecond,
2377+ DrainDelayConfigured: true,
22312378 }
22322379 svc := NewService(runtime, envLoader, eventBus, nil, config)
22332380 ctx := testContext()
@@ -2273,16 +2420,68 @@ func TestService_Deploy_SkipsRedundantDeploy(t *testing.T) {
22732420 assert.Equal(t, "existing-container", tracked.ID)
22742421 }
22752422
2276-func TestService_Deploy_DoesNotSkipWhenImageIDDiffers(t *testing.T) {
2423+func TestService_Deploy_SkipsRedundantDeploy_WhenTrackedImageIDMissing(t *testing.T) {
22772424 runtime := mocks.NewMockContainerRuntime(t)
22782425 envLoader := mocks.NewMockEnvLoader(t)
22792426 eventBus := mocks.NewMockEventPublisher(t)
2280- cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
22812427
22822428 config := Config{
2283- ReadinessDelay: time.Millisecond,
2284- DrainDelay: time.Millisecond,
2429+ ReadinessDelay: time.Millisecond,
2430+ DrainDelay: time.Millisecond,
2431+ DrainDelayConfigured: true,
2432+ }
2433+ svc := NewService(runtime, envLoader, eventBus, nil, config)
2434+ ctx := testContext()
2435+
2436+ // Pre-populate with existing container missing ImageID (common after stale in-memory state).
2437+ existingContainer := &domain.Container{
2438+ ID: "existing-container",
2439+ Name: "gordon-test.example.com",
2440+ Image: "myapp:latest",
2441+ Status: "running",
22852442 }
2443+ svc.containers["test.example.com"] = existingContainer
2444+
2445+ route := domain.Route{
2446+ Domain: "test.example.com",
2447+ Image: "myapp:latest",
2448+ }
2449+
2450+ // prepareDeployResources
2451+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
2452+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
2453+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
2454+ envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2455+ runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
2456+
2457+ // Existing container image ID is recovered from runtime inspection,
2458+ // then redundancy check can skip the deploy.
2459+ runtime.EXPECT().InspectContainer(mock.Anything, "existing-container").Return(&domain.Container{
2460+ ID: "existing-container",
2461+ ImageID: "sha256:abc123",
2462+ Status: "running",
2463+ }, nil)
2464+ runtime.EXPECT().GetImageID(mock.Anything, "myapp:latest").Return("sha256:abc123", nil)
2465+ runtime.EXPECT().IsContainerRunning(mock.Anything, "existing-container").Return(true, nil)
2466+
2467+ result, err := svc.Deploy(ctx, route)
2468+
2469+ assert.NoError(t, err)
2470+ assert.NotNil(t, result)
2471+ assert.Equal(t, "existing-container", result.ID)
2472+
2473+ tracked, exists := svc.Get(ctx, "test.example.com")
2474+ assert.True(t, exists)
2475+ assert.Equal(t, "existing-container", tracked.ID)
2476+}
2477+
2478+func TestService_Deploy_DoesNotSkipWhenImageIDDiffers(t *testing.T) {
2479+ runtime := mocks.NewMockContainerRuntime(t)
2480+ envLoader := mocks.NewMockEnvLoader(t)
2481+ eventBus := mocks.NewMockEventPublisher(t)
2482+ cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
2483+
2484+ config := testMinDelayConfig()
22862485 svc := NewService(runtime, envLoader, eventBus, nil, config)
22872486 svc.SetProxyCacheInvalidator(cacheInvalidator)
22882487 ctx := testContext()
@@ -2316,7 +2515,8 @@ func TestService_Deploy_DoesNotSkipWhenImageIDDiffers(t *testing.T) {
23162515 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
23172516 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil)
23182517 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
2319- runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
2518+ // Wait for ready (2 calls) + stabilization check (1 call)
2519+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
23202520 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
23212521 ID: "new-container", Status: "running",
23222522 }, nil)
@@ -2380,10 +2580,7 @@ func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) {
23802580 envLoader := mocks.NewMockEnvLoader(t)
23812581 eventBus := mocks.NewMockEventPublisher(t)
23822582
2383- config := Config{
2384- ReadinessDelay: time.Millisecond,
2385- DrainDelay: time.Millisecond,
2386- }
2583+ config := testMinDelayConfig()
23872584 svc := NewService(runtime, envLoader, eventBus, nil, config)
23882585 ctx := testContext()
23892586
@@ -2417,7 +2614,8 @@ func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) {
24172614 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
24182615 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil)
24192616 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
2420- runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
2617+ // Wait for ready (2 calls) + stabilization check (1 call)
2618+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
24212619 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
24222620 ID: "new-container", Status: "running",
24232621 }, nil)
@@ -2433,3 +2631,527 @@ func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) {
24332631 assert.NoError(t, err)
24342632 assert.Equal(t, "new-container", result.ID)
24352633 }
2634+
2635+// TestService_Deploy_OrphanCleanup_NeverKillsRunningCanonical verifies that orphan
2636+// cleanup never stops a running container with the canonical name. Only stopped
2637+// canonical containers and temp containers (-new/-next) should be cleaned up.
2638+// The active canonical container is resolved via resolveExistingContainer (it has the
2639+// managed label), so the new container is created with the "-new" suffix — Docker
2640+// disallows creating a container with the same name as an existing running container.
2641+func TestService_Deploy_OrphanCleanup_NeverKillsRunningCanonical(t *testing.T) {
2642+ runtime := mocks.NewMockContainerRuntime(t)
2643+ envLoader := mocks.NewMockEnvLoader(t)
2644+ eventBus := mocks.NewMockEventPublisher(t)
2645+
2646+ config := testMinDelayConfig()
2647+ svc := NewService(runtime, envLoader, eventBus, nil, config)
2648+ ctx := testContext()
2649+
2650+ // The canonical container is running and has the managed label — it will be
2651+ // discovered by resolveExistingContainer's slow path (no in-memory state).
2652+ existingContainer := &domain.Container{
2653+ ID: "active-canonical",
2654+ Name: "gordon-test.example.com",
2655+ Status: "running",
2656+ Labels: map[string]string{domain.LabelManaged: "true"},
2657+ }
2658+
2659+ route := domain.Route{
2660+ Domain: "test.example.com",
2661+ Image: "myapp:v2",
2662+ }
2663+
2664+ // resolveExistingContainer: slow path finds the running canonical container via label.
2665+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{existingContainer}, nil)
2666+
2667+ // cleanupOrphanedContainers: finds the running canonical AND a stale -new leftover.
2668+ // The canonical container must NOT be killed because it's running.
2669+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
2670+ existingContainer,
2671+ {
2672+ ID: "stale-leftover",
2673+ Name: "gordon-test.example.com-new",
2674+ Status: "exited",
2675+ },
2676+ }, nil)
2677+
2678+ // Only the stale -new container should be stopped and removed during orphan cleanup.
2679+ runtime.EXPECT().StopContainer(mock.Anything, "stale-leftover").Return(nil).Once()
2680+ runtime.EXPECT().RemoveContainer(mock.Anything, "stale-leftover", true).Return(nil).Once()
2681+
2682+ // Image operations: pull new image (existing.Image is empty so redundancy check is skipped)
2683+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
2684+ runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
2685+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
2686+
2687+ // Environment
2688+ envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2689+ runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
2690+
2691+ // Create container — existing is the canonical container, so the new one gets "-new" suffix.
2692+ // Docker enforces unique container names; using "-new" avoids a name collision.
2693+ newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
2694+ runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
2695+ return cfg.Name == "gordon-test.example.com-new"
2696+ })).Return(newContainer, nil)
2697+ runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
2698+
2699+ // Wait for ready (delay mode): pollContainerRunning (1×) + waitForReadyByDelay (1×)
2700+ // Post-switch stabilization: 1× — total 3 calls.
2701+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
2702+
2703+ // Inspect after ready
2704+ runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
2705+ ID: "new-container",
2706+ Status: "running",
2707+ Ports: []int{8080},
2708+ }, nil)
2709+
2710+ // Rename new container to canonical name after stabilization.
2711+ runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil).Once()
2712+
2713+ // Stop and remove old canonical container.
2714+ runtime.EXPECT().StopContainer(mock.Anything, "active-canonical").Return(nil).Once()
2715+ runtime.EXPECT().RemoveContainer(mock.Anything, "active-canonical", true).Return(nil).Once()
2716+
2717+ // Publish event
2718+ eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
2719+
2720+ result, err := svc.Deploy(ctx, route)
2721+
2722+ assert.NoError(t, err)
2723+ assert.Equal(t, "new-container", result.ID)
2724+
2725+ // Verify new container is tracked
2726+ tracked, exists := svc.Get(ctx, "test.example.com")
2727+ assert.True(t, exists)
2728+ assert.Equal(t, "new-container", tracked.ID)
2729+}
2730+
2731+// TestService_Deploy_RollbackOnPostSwitchCrash verifies that if the new container crashes
2732+// during the post-switch stabilization window, traffic is automatically rolled back to the
2733+// old container: the old container is restored as tracked, the proxy cache is re-invalidated,
2734+// and the failed new container is cleaned up.
2735+func TestService_Deploy_RollbackOnPostSwitchCrash(t *testing.T) {
2736+ runtime := mocks.NewMockContainerRuntime(t)
2737+ envLoader := mocks.NewMockEnvLoader(t)
2738+ eventBus := mocks.NewMockEventPublisher(t)
2739+ cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
2740+
2741+ config := Config{
2742+ ReadinessDelay: time.Millisecond,
2743+ DrainDelay: time.Millisecond,
2744+ DrainDelayConfigured: true,
2745+ StabilizationDelay: time.Millisecond,
2746+ }
2747+ svc := NewService(runtime, envLoader, eventBus, nil, config)
2748+ svc.SetProxyCacheInvalidator(cacheInvalidator)
2749+ ctx := testContext()
2750+
2751+ // Pre-populate with existing container
2752+ existing := &domain.Container{
2753+ ID: "old-container",
2754+ Name: "gordon-test.example.com",
2755+ Status: "running",
2756+ }
2757+ svc.containers["test.example.com"] = existing
2758+
2759+ route := domain.Route{
2760+ Domain: "test.example.com",
2761+ Image: "myapp:v2",
2762+ }
2763+
2764+ // Cleanup orphans
2765+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
2766+
2767+ // Image operations
2768+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
2769+ runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
2770+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
2771+
2772+ // Environment
2773+ envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2774+ runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
2775+
2776+ // Create new container
2777+ newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
2778+ runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
2779+ return cfg.Name == "gordon-test.example.com-new"
2780+ })).Return(newContainer, nil)
2781+ runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
2782+
2783+ // Readiness: new container is running during readiness checks (2 calls)
2784+ // Stabilization: new container has crashed (returns false)
2785+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
2786+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(false, nil).Once()
2787+
2788+ // Rollback: verify old container is still running before restoring
2789+ runtime.EXPECT().IsContainerRunning(mock.Anything, "old-container").Return(true, nil).Once()
2790+
2791+ // Inspect after ready
2792+ runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
2793+ ID: "new-container",
2794+ Name: "gordon-test.example.com-new",
2795+ Status: "running",
2796+ }, nil)
2797+
2798+ // Publish event (happens during activateDeployedContainer)
2799+ eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
2800+
2801+ // InvalidateTarget called TWICE: once during activate, once during rollback
2802+ cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return().Times(2)
2803+
2804+ // Cleanup failed new container during rollback
2805+ runtime.EXPECT().StopContainer(mock.Anything, "new-container").Return(nil)
2806+ runtime.EXPECT().RemoveContainer(mock.Anything, "new-container", true).Return(nil)
2807+
2808+ // StopContainer on old-container should NOT be called (old stays running)
2809+ // (no expectation for StopContainer("old-container"))
2810+
2811+ result, err := svc.Deploy(ctx, route)
2812+
2813+ // Deploy returns the old (existing) container after rollback
2814+ assert.NoError(t, err)
2815+ assert.NotNil(t, result)
2816+ assert.Equal(t, "old-container", result.ID, "should return old container after rollback")
2817+
2818+ // Verify old container is restored as tracked
2819+ trackedRB, existsRB := svc.Get(ctx, "test.example.com")
2820+ assert.True(t, existsRB)
2821+ assert.Equal(t, "old-container", trackedRB.ID, "old container should be restored after rollback")
2822+}
2823+
2824+// TestService_Deploy_StabilizationSuccess verifies that when the new container remains
2825+// healthy during the stabilization window, deployment proceeds normally and the old
2826+// container is finalized (stopped and removed).
2827+func TestService_Deploy_StabilizationSuccess(t *testing.T) {
2828+ runtime := mocks.NewMockContainerRuntime(t)
2829+ envLoader := mocks.NewMockEnvLoader(t)
2830+ eventBus := mocks.NewMockEventPublisher(t)
2831+ cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
2832+
2833+ config := Config{
2834+ ReadinessDelay: time.Millisecond,
2835+ DrainDelay: time.Millisecond,
2836+ DrainDelayConfigured: true,
2837+ StabilizationDelay: time.Millisecond,
2838+ }
2839+ svc := NewService(runtime, envLoader, eventBus, nil, config)
2840+ svc.SetProxyCacheInvalidator(cacheInvalidator)
2841+ ctx := testContext()
2842+
2843+ // Pre-populate with existing container
2844+ existing := &domain.Container{
2845+ ID: "old-container",
2846+ Name: "gordon-test.example.com",
2847+ Status: "running",
2848+ }
2849+ svc.containers["test.example.com"] = existing
2850+
2851+ route := domain.Route{
2852+ Domain: "test.example.com",
2853+ Image: "myapp:v2",
2854+ }
2855+
2856+ // Cleanup orphans
2857+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
2858+
2859+ // Image operations
2860+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
2861+ runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
2862+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
2863+
2864+ // Environment
2865+ envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2866+ runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
2867+
2868+ // Create new container
2869+ newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
2870+ runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
2871+ return cfg.Name == "gordon-test.example.com-new"
2872+ })).Return(newContainer, nil)
2873+ runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
2874+
2875+ // Readiness (2 calls) + stabilization check (1 call) — all return running
2876+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
2877+
2878+ // Inspect after ready
2879+ runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
2880+ ID: "new-container",
2881+ Name: "gordon-test.example.com-new",
2882+ Status: "running",
2883+ }, nil)
2884+
2885+ // Publish event
2886+ eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
2887+
2888+ // Cache invalidation (once, during activate)
2889+ cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
2890+
2891+ // Old container finalized normally
2892+ runtime.EXPECT().StopContainer(mock.Anything, "old-container").Return(nil)
2893+ runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil)
2894+ runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
2895+
2896+ result, err := svc.Deploy(ctx, route)
2897+
2898+ assert.NoError(t, err)
2899+ assert.NotNil(t, result)
2900+ assert.Equal(t, "new-container", result.ID, "should return new container on successful stabilization")
2901+
2902+ // Verify new container is tracked
2903+ trackedSS, existsSS := svc.Get(ctx, "test.example.com")
2904+ assert.True(t, existsSS)
2905+ assert.Equal(t, "new-container", trackedSS.ID)
2906+}
2907+
2908+// TestService_Deploy_ZeroDowntime_OldNeverStoppedBeforeNewReady verifies the strict
2909+// ordering invariant: StopContainer("old-container") must NEVER happen before
2910+// InspectContainer("new-container") completes (the last step of readiness).
2911+// Uses testify's NotBefore() to enforce mock call ordering.
2912+func TestService_Deploy_ZeroDowntime_OldNeverStoppedBeforeNewReady(t *testing.T) {
2913+ runtime := mocks.NewMockContainerRuntime(t)
2914+ envLoader := mocks.NewMockEnvLoader(t)
2915+ eventBus := mocks.NewMockEventPublisher(t)
2916+ cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
2917+
2918+ config := Config{
2919+ ReadinessDelay: time.Millisecond,
2920+ DrainDelay: time.Millisecond,
2921+ DrainDelayConfigured: true,
2922+ StabilizationDelay: time.Millisecond,
2923+ }
2924+ svc := NewService(runtime, envLoader, eventBus, nil, config)
2925+ svc.SetProxyCacheInvalidator(cacheInvalidator)
2926+ ctx := testContext()
2927+
2928+ // Pre-populate with existing tracked container
2929+ existingContainer := &domain.Container{
2930+ ID: "old-container",
2931+ Name: "gordon-test.example.com",
2932+ Status: "running",
2933+ }
2934+ svc.containers["test.example.com"] = existingContainer
2935+
2936+ route := domain.Route{
2937+ Domain: "test.example.com",
2938+ Image: "myapp:v2",
2939+ }
2940+
2941+ // Cleanup orphans
2942+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
2943+
2944+ // Image operations
2945+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
2946+ runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
2947+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
2948+
2949+ // Environment
2950+ envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2951+ runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
2952+
2953+ // Create new container with -new suffix for zero-downtime
2954+ newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
2955+ runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
2956+ return cfg.Name == "gordon-test.example.com-new"
2957+ })).Return(newContainer, nil)
2958+ runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
2959+
2960+ // Readiness: pollContainerRunning (1 call) + waitForReadyByDelay verification (1 call)
2961+ // Stabilization check (1 call)
2962+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
2963+
2964+ // KEY ORDERING CONSTRAINT: InspectContainer("new-container") is the last step
2965+ // of readiness inside createStartedContainer. StopContainer("old-container")
2966+ // must happen AFTER this call completes.
2967+ inspectCall := runtime.EXPECT().InspectContainer(mock.Anything, "new-container").
2968+ Return(&domain.Container{
2969+ ID: "new-container",
2970+ Name: "gordon-test.example.com-new",
2971+ Status: "running",
2972+ }, nil)
2973+
2974+ // Publish event
2975+ eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
2976+
2977+ // Synchronous cache invalidation
2978+ cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
2979+
2980+ // STRICT ORDERING: StopContainer on old container must NOT happen before
2981+ // InspectContainer on new container (readiness completion)
2982+ runtime.EXPECT().StopContainer(mock.Anything, "old-container").
2983+ Return(nil).
2984+ NotBefore(inspectCall.Call)
2985+
2986+ runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil)
2987+ runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
2988+
2989+ result, err := svc.Deploy(ctx, route)
2990+
2991+ assert.NoError(t, err)
2992+ assert.NotNil(t, result)
2993+ assert.Equal(t, "new-container", result.ID)
2994+
2995+ // Verify new container is tracked
2996+ tracked, exists := svc.Get(ctx, "test.example.com")
2997+ assert.True(t, exists)
2998+ assert.Equal(t, "new-container", tracked.ID)
2999+}
3000+
3001+// TestService_Deploy_ZeroDowntime_ReadinessFailure_OldUntouched verifies that when a
3002+// new container fails readiness (IsContainerRunning returns false), the old container
3003+// is completely untouched: never stopped, never removed. Deploy returns an error and
3004+// the old container remains as the tracked container.
3005+func TestService_Deploy_ZeroDowntime_ReadinessFailure_OldUntouched(t *testing.T) {
3006+ runtime := mocks.NewMockContainerRuntime(t)
3007+ envLoader := mocks.NewMockEnvLoader(t)
3008+ eventBus := mocks.NewMockEventPublisher(t)
3009+
3010+ config := Config{
3011+ ReadinessDelay: time.Millisecond,
3012+ DrainDelay: time.Millisecond,
3013+ DrainDelayConfigured: true,
3014+ StabilizationDelay: time.Millisecond,
3015+ }
3016+ svc := NewService(runtime, envLoader, eventBus, nil, config)
3017+ ctx := testContext()
3018+
3019+ // Pre-populate with existing tracked container
3020+ existingContainer := &domain.Container{
3021+ ID: "old-container",
3022+ Name: "gordon-test.example.com",
3023+ Status: "running",
3024+ }
3025+ svc.containers["test.example.com"] = existingContainer
3026+
3027+ route := domain.Route{
3028+ Domain: "test.example.com",
3029+ Image: "myapp:v2",
3030+ }
3031+
3032+ // Cleanup orphans
3033+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
3034+
3035+ // Image operations
3036+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
3037+ runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
3038+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
3039+
3040+ // Environment
3041+ envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
3042+ runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
3043+
3044+ // Create new container
3045+ newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
3046+ runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
3047+ return cfg.Name == "gordon-test.example.com-new"
3048+ })).Return(newContainer, nil)
3049+ runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
3050+
3051+ // Readiness FAILS: pollContainerRunning succeeds (container starts) but
3052+ // waitForReadyByDelay verification finds it not running.
3053+ // pollContainerRunning: returns true (container initially starts)
3054+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Once()
3055+ // waitForReadyByDelay post-delay verification: returns false (container crashed)
3056+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(false, nil).Once()
3057+ // Recovery window poll: return error to fail fast instead of waiting 30s
3058+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").
3059+ Return(false, errors.New("container exited")).Once()
3060+
3061+ // cleanupFailedContainer: stop and remove the failed new container
3062+ runtime.EXPECT().StopContainer(mock.Anything, "new-container").Return(nil)
3063+ runtime.EXPECT().RemoveContainer(mock.Anything, "new-container", true).Return(nil)
3064+
3065+ // NOTE: StopContainer("old-container") and RemoveContainer("old-container", true)
3066+ // are intentionally NOT mocked. If either is called, testify will panic with
3067+ // "unexpected method call" — verifying the old container is never touched.
3068+
3069+ result, err := svc.Deploy(ctx, route)
3070+
3071+ // Deploy should return an error (readiness failure)
3072+ assert.Error(t, err)
3073+ assert.Nil(t, result)
3074+ assert.Contains(t, err.Error(), "readiness check")
3075+
3076+ // Old container must still be tracked — completely untouched
3077+ tracked, exists := svc.Get(ctx, "test.example.com")
3078+ assert.True(t, exists, "old container should still be tracked after readiness failure")
3079+ assert.Equal(t, "old-container", tracked.ID, "old container ID should be unchanged")
3080+}
3081+
3082+// TestService_Deploy_ZeroDowntime_NoExisting_FirstDeploy is a sanity check:
3083+// first deploy with no existing container should work normally without any
3084+// finalize/drain logic being triggered.
3085+func TestService_Deploy_ZeroDowntime_NoExisting_FirstDeploy(t *testing.T) {
3086+ runtime := mocks.NewMockContainerRuntime(t)
3087+ envLoader := mocks.NewMockEnvLoader(t)
3088+ eventBus := mocks.NewMockEventPublisher(t)
3089+
3090+ config := Config{
3091+ ReadinessDelay: time.Millisecond,
3092+ DrainDelay: time.Millisecond,
3093+ DrainDelayConfigured: true,
3094+ StabilizationDelay: time.Millisecond,
3095+ }
3096+ svc := NewService(runtime, envLoader, eventBus, nil, config)
3097+ ctx := testContext()
3098+
3099+ // NO existing container — this is a first deploy
3100+
3101+ route := domain.Route{
3102+ Domain: "test.example.com",
3103+ Image: "myapp:v2",
3104+ }
3105+
3106+ // resolveExistingContainer: no container in memory, runtime returns nothing
3107+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
3108+
3109+ // Cleanup orphans — nothing found
3110+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
3111+
3112+ // Image operations
3113+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
3114+ runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
3115+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
3116+
3117+ // Environment
3118+ envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
3119+ runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
3120+
3121+ // Create container — canonical name (no -new suffix since no existing container)
3122+ newContainer := &domain.Container{ID: "first-container", Name: "gordon-test.example.com", Status: "created"}
3123+ runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
3124+ return cfg.Name == "gordon-test.example.com"
3125+ })).Return(newContainer, nil)
3126+ runtime.EXPECT().StartContainer(mock.Anything, "first-container").Return(nil)
3127+
3128+ // Readiness: pollContainerRunning (1 call) + waitForReadyByDelay verification (1 call)
3129+ // No stabilization check for first deploy (hasExisting is false)
3130+ runtime.EXPECT().IsContainerRunning(mock.Anything, "first-container").Return(true, nil).Times(2)
3131+
3132+ // Inspect after readiness
3133+ runtime.EXPECT().InspectContainer(mock.Anything, "first-container").Return(&domain.Container{
3134+ ID: "first-container",
3135+ Name: "gordon-test.example.com",
3136+ Status: "running",
3137+ Ports: []int{8080},
3138+ }, nil)
3139+
3140+ // Publish event
3141+ eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
3142+
3143+ // NOTE: No StopContainer, RemoveContainer, or RenameContainer expectations —
3144+ // first deploy has no previous container to finalize, and no stabilization check.
3145+
3146+ result, err := svc.Deploy(ctx, route)
3147+
3148+ assert.NoError(t, err)
3149+ assert.NotNil(t, result)
3150+ assert.Equal(t, "first-container", result.ID)
3151+ assert.Equal(t, "running", result.Status)
3152+
3153+ // Verify container is tracked
3154+ tracked, exists := svc.Get(ctx, "test.example.com")
3155+ assert.True(t, exists)
3156+ assert.Equal(t, "first-container", tracked.ID)
3157+}
@@ -25,6 +25,17 @@ func testContext() context.Context {
25 return zerowrap.WithCtx(context.Background(), zerowrap.Default())25 return zerowrap.WithCtx(context.Background(), zerowrap.Default())
26 }26 }
27 27
28+// testMinDelayConfig returns a Config with all timing delays set to 1ms,
29+// suitable for unit tests that don't want to wait for real timeouts.
30+func testMinDelayConfig() Config {
31+ return Config{
32+ ReadinessDelay: time.Millisecond,
33+ DrainDelay: time.Millisecond,
34+ DrainDelayConfigured: true,
35+ StabilizationDelay: time.Millisecond,
36+ }
37+}
38+
28 func setupMetricsTest(t *testing.T) (*telemetry.Metrics, *sdkmetric.ManualReader) {39 func setupMetricsTest(t *testing.T) (*telemetry.Metrics, *sdkmetric.ManualReader) {
29 t.Helper()40 t.Helper()
30 41
@@ -184,10 +195,11 @@ func TestService_Deploy_Success(t *testing.T) {
184 eventBus := mocks.NewMockEventPublisher(t)195 eventBus := mocks.NewMockEventPublisher(t)
185 196
186 config := Config{197 config := Config{
187- NetworkIsolation: false,198+ NetworkIsolation: false,
188- VolumeAutoCreate: false,199+ VolumeAutoCreate: false,
189- ReadinessDelay: time.Millisecond, // Minimal delay for tests200+ ReadinessDelay: time.Millisecond, // Minimal delay for tests
190- DrainDelay: time.Millisecond,201+ DrainDelay: time.Millisecond,
202+ DrainDelayConfigured: true,
191 }203 }
192 204
193 svc := NewService(runtime, envLoader, eventBus, nil, config)205 svc := NewService(runtime, envLoader, eventBus, nil, config)
@@ -198,6 +210,9 @@ func TestService_Deploy_Success(t *testing.T) {
198 Image: "myapp:latest",210 Image: "myapp:latest",
199 }211 }
200 212
213+ // No container in memory — runtime resolution returns nothing
214+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
215+
201 // Setup mocks - no orphaned containers216 // Setup mocks - no orphaned containers
202 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)217 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
203 218
@@ -261,10 +276,11 @@ func TestService_Deploy_ReadinessRecoveryWindow_AllowsTransientFlap(t *testing.T
261 eventBus := mocks.NewMockEventPublisher(t)276 eventBus := mocks.NewMockEventPublisher(t)
262 277
263 config := Config{278 config := Config{
264- NetworkIsolation: false,279+ NetworkIsolation: false,
265- VolumeAutoCreate: false,280+ VolumeAutoCreate: false,
266- ReadinessDelay: time.Millisecond,281+ ReadinessDelay: time.Millisecond,
267- DrainDelay: time.Millisecond,282+ DrainDelay: time.Millisecond,
283+ DrainDelayConfigured: true,
268 }284 }
269 285
270 svc := NewService(runtime, envLoader, eventBus, nil, config)286 svc := NewService(runtime, envLoader, eventBus, nil, config)
@@ -275,6 +291,9 @@ func TestService_Deploy_ReadinessRecoveryWindow_AllowsTransientFlap(t *testing.T
275 Image: "myapp:latest",291 Image: "myapp:latest",
276 }292 }
277 293
294+ // No container in memory — runtime resolution returns nothing
295+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
296+
278 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)297 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
279 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)298 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
280 runtime.EXPECT().PullImage(mock.Anything, "myapp:latest").Return(nil)299 runtime.EXPECT().PullImage(mock.Anything, "myapp:latest").Return(nil)
@@ -329,6 +348,9 @@ func TestService_Deploy_ImagePullFailure(t *testing.T) {
329 Image: "myapp:latest",348 Image: "myapp:latest",
330 }349 }
331 350
351+ // No container in memory — runtime resolution returns nothing
352+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
353+
332 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)354 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
333 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)355 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
334 runtime.EXPECT().PullImage(mock.Anything, "myapp:latest").Return(errors.New("image not found"))356 runtime.EXPECT().PullImage(mock.Anything, "myapp:latest").Return(errors.New("image not found"))
@@ -406,8 +428,10 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) {
406 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)428 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
407 429
408 config := Config{430 config := Config{
409- ReadinessDelay: time.Millisecond, // Minimal delay for tests431+ ReadinessDelay: time.Millisecond, // Minimal delay for tests
410- DrainDelay: time.Millisecond,432+ DrainDelay: time.Millisecond,
433+ DrainDelayConfigured: true,
434+ StabilizationDelay: time.Millisecond,
411 }435 }
412 svc := NewService(runtime, envLoader, eventBus, nil, config)436 svc := NewService(runtime, envLoader, eventBus, nil, config)
413 svc.SetProxyCacheInvalidator(cacheInvalidator)437 svc.SetProxyCacheInvalidator(cacheInvalidator)
@@ -445,8 +469,8 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) {
445 })).Return(newContainer, nil)469 })).Return(newContainer, nil)
446 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)470 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
447 471
448- // Wait for ready472+ // Wait for ready (2 calls) + stabilization check (1 call)
449- runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)473+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
450 474
451 // Inspect after ready475 // Inspect after ready
452 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{476 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
@@ -460,7 +484,7 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) {
460 // Synchronous cache invalidation before old container cleanup484 // Synchronous cache invalidation before old container cleanup
461 cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()485 cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
462 486
463- // Now cleanup old container (after new one is ready + cache invalidated + drain delay)487+ // Now cleanup old container (after new one is ready + cache invalidated + stabilization + drain delay)
464 runtime.EXPECT().StopContainer(mock.Anything, "old-container").Return(nil)488 runtime.EXPECT().StopContainer(mock.Anything, "old-container").Return(nil)
465 runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil)489 runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil)
466 490
@@ -471,10 +495,106 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) {
471 495
472 assert.NoError(t, err)496 assert.NoError(t, err)
473 assert.Equal(t, "new-container", result.ID)497 assert.Equal(t, "new-container", result.ID)
498+}
499+
500+// TestService_Deploy_ResolvesExistingFromRuntime_WhenMemoryStale verifies that
501+// when in-memory state has no tracked container for a domain, Deploy queries
502+// the runtime and discovers the running container. This prevents orphan cleanup
503+// from killing the real active container after a Gordon restart.
504+func TestService_Deploy_ResolvesExistingFromRuntime_WhenMemoryStale(t *testing.T) {
505+ runtime := mocks.NewMockContainerRuntime(t)
506+ envLoader := mocks.NewMockEnvLoader(t)
507+ eventBus := mocks.NewMockEventPublisher(t)
508+ cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
509+
510+ config := testMinDelayConfig()
511+ svc := NewService(runtime, envLoader, eventBus, nil, config)
512+ svc.SetProxyCacheInvalidator(cacheInvalidator)
513+ ctx := testContext()
514+
515+ // NO tracked container in memory — simulates Gordon restart
516+ // (svc.containers is empty for "test.example.com")
517+
518+ route := domain.Route{
519+ Domain: "test.example.com",
520+ Image: "myapp:v2",
521+ }
522+
523+ // resolveExistingContainer should query running containers from runtime
524+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{
525+ {
526+ ID: "runtime-active-container",
527+ Name: "gordon-test.example.com",
528+ Status: "running",
529+ Labels: map[string]string{
530+ domain.LabelDomain: "test.example.com",
531+ domain.LabelManaged: "true",
532+ },
533+ },
534+ }, nil)
535+
536+ // Orphan cleanup lists ALL containers (including stopped) — the runtime-active
537+ // container should be skipped because it is now recognized as the existing one
538+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
539+ {
540+ ID: "runtime-active-container",
541+ Name: "gordon-test.example.com",
542+ Status: "running",
543+ Labels: map[string]string{
544+ domain.LabelDomain: "test.example.com",
545+ domain.LabelManaged: "true",
546+ },
547+ },
548+ }, nil)
549+
550+ // Image operations
551+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
552+ runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
553+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
554+
555+ // Environment
556+ envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
557+ runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
558+
559+ // Create new container with -new suffix (zero-downtime: existing was resolved from runtime)
560+ newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
561+ runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
562+ return cfg.Name == "gordon-test.example.com-new"
563+ })).Return(newContainer, nil)
564+ runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
565+
566+ // Wait for ready (2 calls) + stabilization check (1 call)
567+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
568+
569+ // Inspect after ready
570+ runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
571+ ID: "new-container",
572+ Status: "running",
573+ }, nil)
574+
575+ // Publish event
576+ eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
577+
578+ // Synchronous cache invalidation
579+ cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
580+
581+ // AFTER new container is ready: stop and remove old runtime-discovered container
582+ // This is the key assertion — StopContainer on the runtime-active container
583+ // happens during finalizePreviousContainer, NOT during orphan cleanup
584+ runtime.EXPECT().StopContainer(mock.Anything, "runtime-active-container").Return(nil)
585+ runtime.EXPECT().RemoveContainer(mock.Anything, "runtime-active-container", true).Return(nil)
586+
587+ // Rename new container to canonical name
588+ runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
589+
590+ result, err := svc.Deploy(ctx, route)
591+
592+ assert.NoError(t, err)
593+ assert.Equal(t, "new-container", result.ID)
474 594
475- // Ensure the service's tracked container entry now points to the new container.595+ // Verify new container is now tracked
476- tracked, ok := svc.Get(ctx, "test.example.com")596+ tracked, exists := svc.Get(ctx, "test.example.com")
477- assert.True(t, ok)597+ assert.True(t, exists)
478 assert.Equal(t, "new-container", tracked.ID)598 assert.Equal(t, "new-container", tracked.ID)
479 }599 }
480 600
@@ -483,10 +603,7 @@ func TestService_Deploy_SkipRedundantDeploy_GetImageIDError(t *testing.T) {
483 envLoader := mocks.NewMockEnvLoader(t)603 envLoader := mocks.NewMockEnvLoader(t)
484 eventBus := mocks.NewMockEventPublisher(t)604 eventBus := mocks.NewMockEventPublisher(t)
485 605
486- config := Config{606+ config := testMinDelayConfig()
487- ReadinessDelay: time.Millisecond,
488- DrainDelay: time.Millisecond,
489- }
490 svc := NewService(runtime, envLoader, eventBus, nil, config)607 svc := NewService(runtime, envLoader, eventBus, nil, config)
491 ctx := testContext()608 ctx := testContext()
492 609
@@ -519,7 +636,8 @@ func TestService_Deploy_SkipRedundantDeploy_GetImageIDError(t *testing.T) {
519 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}636 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
520 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil)637 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil)
521 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)638 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
522- runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)639+ // Wait for ready (2 calls) + stabilization check (1 call)
640+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
523 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{641 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
524 ID: "new-container", Status: "running",642 ID: "new-container", Status: "running",
525 }, nil)643 }, nil)
@@ -542,10 +660,11 @@ func TestService_Deploy_WithNetworkIsolation(t *testing.T) {
542 eventBus := mocks.NewMockEventPublisher(t)660 eventBus := mocks.NewMockEventPublisher(t)
543 661
544 config := Config{662 config := Config{
545- NetworkIsolation: true,663+ NetworkIsolation: true,
546- NetworkPrefix: "gordon",664+ NetworkPrefix: "gordon",
547- ReadinessDelay: time.Millisecond, // Minimal delay for tests665+ ReadinessDelay: time.Millisecond, // Minimal delay for tests
548- DrainDelay: time.Millisecond,666+ DrainDelay: time.Millisecond,
667+ DrainDelayConfigured: true,
549 }668 }
550 svc := NewService(runtime, envLoader, eventBus, nil, config)669 svc := NewService(runtime, envLoader, eventBus, nil, config)
551 ctx := testContext()670 ctx := testContext()
@@ -555,6 +674,9 @@ func TestService_Deploy_WithNetworkIsolation(t *testing.T) {
555 Image: "myapp:latest",674 Image: "myapp:latest",
556 }675 }
557 676
677+ // No container in memory — runtime resolution returns nothing
678+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
679+
558 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)680 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
559 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)681 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
560 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)682 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
@@ -594,10 +716,11 @@ func TestService_Deploy_WithVolumeAutoCreate(t *testing.T) {
594 eventBus := mocks.NewMockEventPublisher(t)716 eventBus := mocks.NewMockEventPublisher(t)
595 717
596 config := Config{718 config := Config{
597- VolumeAutoCreate: true,719+ VolumeAutoCreate: true,
598- VolumePrefix: "gordon",720+ VolumePrefix: "gordon",
599- ReadinessDelay: time.Millisecond, // Minimal delay for tests721+ ReadinessDelay: time.Millisecond, // Minimal delay for tests
600- DrainDelay: time.Millisecond,722+ DrainDelay: time.Millisecond,
723+ DrainDelayConfigured: true,
601 }724 }
602 svc := NewService(runtime, envLoader, eventBus, nil, config)725 svc := NewService(runtime, envLoader, eventBus, nil, config)
603 ctx := testContext()726 ctx := testContext()
@@ -607,6 +730,9 @@ func TestService_Deploy_WithVolumeAutoCreate(t *testing.T) {
607 Image: "myapp:latest",730 Image: "myapp:latest",
608 }731 }
609 732
733+ // No container in memory — runtime resolution returns nothing
734+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
735+
610 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)736 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
611 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)737 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
612 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)738 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
@@ -1312,6 +1438,7 @@ func TestService_Deploy_InternalDeployForcesPull(t *testing.T) {
1312 InternalRegistryPassword: "secret",1438 InternalRegistryPassword: "secret",
1313 ReadinessDelay: time.Millisecond,1439 ReadinessDelay: time.Millisecond,
1314 DrainDelay: time.Millisecond,1440 DrainDelay: time.Millisecond,
1441+ DrainDelayConfigured: true,
1315 }1442 }
1316 1443
1317 svc := NewService(runtime, envLoader, eventBus, nil, config)1444 svc := NewService(runtime, envLoader, eventBus, nil, config)
@@ -1324,6 +1451,9 @@ func TestService_Deploy_InternalDeployForcesPull(t *testing.T) {
1324 Image: "myapp:latest",1451 Image: "myapp:latest",
1325 }1452 }
1326 1453
1454+ // No container in memory — runtime resolution returns nothing
1455+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
1456+
1327 // Setup mocks - no orphaned containers1457 // Setup mocks - no orphaned containers
1328 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)1458 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
1329 1459
@@ -1379,8 +1509,9 @@ func TestService_AutoStart_StartsNewContainers(t *testing.T) {
1379 eventBus := mocks.NewMockEventPublisher(t)1509 eventBus := mocks.NewMockEventPublisher(t)
1380 1510
1381 config := Config{1511 config := Config{
1382- ReadinessDelay: time.Millisecond,1512+ ReadinessDelay: time.Millisecond,
1383- DrainDelay: time.Millisecond,1513+ DrainDelay: time.Millisecond,
1514+ DrainDelayConfigured: true,
1384 }1515 }
1385 svc := NewService(runtime, envLoader, eventBus, nil, config)1516 svc := NewService(runtime, envLoader, eventBus, nil, config)
1386 ctx := testContext()1517 ctx := testContext()
@@ -1390,6 +1521,9 @@ func TestService_AutoStart_StartsNewContainers(t *testing.T) {
1390 {Domain: "app2.example.com", Image: "myapp2:latest"},1521 {Domain: "app2.example.com", Image: "myapp2:latest"},
1391 }1522 }
1392 1523
1524+ // No container in memory — runtime resolution returns nothing (one per route)
1525+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil).Times(2)
1526+
1393 // Setup mocks for route deployments1527 // Setup mocks for route deployments
1394 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Times(2)1528 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Times(2)
1395 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp1:latest", "myapp2:latest"}, nil).Times(2)1529 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp1:latest", "myapp2:latest"}, nil).Times(2)
@@ -1422,8 +1556,9 @@ func TestService_AutoStart_SkipsExistingContainers(t *testing.T) {
1422 eventBus := mocks.NewMockEventPublisher(t)1556 eventBus := mocks.NewMockEventPublisher(t)
1423 1557
1424 config := Config{1558 config := Config{
1425- ReadinessDelay: time.Millisecond,1559+ ReadinessDelay: time.Millisecond,
1426- DrainDelay: time.Millisecond,1560+ DrainDelay: time.Millisecond,
1561+ DrainDelayConfigured: true,
1427 }1562 }
1428 svc := NewService(runtime, envLoader, eventBus, nil, config)1563 svc := NewService(runtime, envLoader, eventBus, nil, config)
1429 ctx := testContext()1564 ctx := testContext()
@@ -1438,6 +1573,9 @@ func TestService_AutoStart_SkipsExistingContainers(t *testing.T) {
1438 {Domain: "app2.example.com", Image: "myapp2:latest"}, // New route1573 {Domain: "app2.example.com", Image: "myapp2:latest"}, // New route
1439 }1574 }
1440 1575
1576+ // No container in memory for app2 — runtime resolution returns nothing
1577+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil).Once()
1578+
1441 // Only deploy for app2 (app1 is skipped). Readiness is skipped — no IsContainerRunning.1579 // Only deploy for app2 (app1 is skipped). Readiness is skipped — no IsContainerRunning.
1442 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Once()1580 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Once()
1443 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp2:latest"}, nil).Once()1581 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp2:latest"}, nil).Once()
@@ -1472,6 +1610,9 @@ func TestService_AutoStart_HandlesDeployErrors(t *testing.T) {
1472 {Domain: "app1.example.com", Image: "myapp1:latest"},1610 {Domain: "app1.example.com", Image: "myapp1:latest"},
1473 }1611 }
1474 1612
1613+ // No container in memory — runtime resolution returns nothing
1614+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
1615+
1475 // Setup mocks for failure1616 // Setup mocks for failure
1476 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)1617 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
1477 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)1618 runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
@@ -1490,10 +1631,11 @@ func TestService_AutoStart_UsesInternalDeployContext(t *testing.T) {
1490 eventBus := mocks.NewMockEventPublisher(t)1631 eventBus := mocks.NewMockEventPublisher(t)
1491 1632
1492 config := Config{1633 config := Config{
1493- RegistryDomain: "reg.example.com",1634+ RegistryDomain: "reg.example.com",
1494- RegistryPort: 5000,1635+ RegistryPort: 5000,
1495- ReadinessDelay: time.Millisecond,1636+ ReadinessDelay: time.Millisecond,
1496- DrainDelay: time.Millisecond,1637+ DrainDelay: time.Millisecond,
1638+ DrainDelayConfigured: true,
1497 }1639 }
1498 svc := NewService(runtime, envLoader, eventBus, nil, config)1640 svc := NewService(runtime, envLoader, eventBus, nil, config)
1499 1641
@@ -1504,6 +1646,9 @@ func TestService_AutoStart_UsesInternalDeployContext(t *testing.T) {
1504 {Domain: "app1.example.com", Image: "reg.example.com/myapp:latest"},1646 {Domain: "app1.example.com", Image: "reg.example.com/myapp:latest"},
1505 }1647 }
1506 1648
1649+ // No container in memory — runtime resolution returns nothing
1650+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
1651+
1507 // Key assertion: PullImage should be called with localhost:5000 rewrite,1652 // Key assertion: PullImage should be called with localhost:5000 rewrite,
1508 // NOT the original reg.example.com/myapp:latest.1653 // NOT the original reg.example.com/myapp:latest.
1509 // Readiness is skipped — no IsContainerRunning calls.1654 // Readiness is skipped — no IsContainerRunning calls.
@@ -1538,8 +1683,10 @@ func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) {
1538 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)1683 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
1539 1684
1540 config := Config{1685 config := Config{
1541- ReadinessDelay: time.Millisecond, // Minimal delay for tests1686+ ReadinessDelay: time.Millisecond, // Minimal delay for tests
1542- DrainDelay: time.Millisecond,1687+ DrainDelay: time.Millisecond,
1688+ DrainDelayConfigured: true,
1689+ StabilizationDelay: time.Millisecond,
1543 }1690 }
1544 svc := NewService(runtime, envLoader, eventBus, nil, config)1691 svc := NewService(runtime, envLoader, eventBus, nil, config)
1545 svc.SetProxyCacheInvalidator(cacheInvalidator)1692 svc.SetProxyCacheInvalidator(cacheInvalidator)
@@ -1585,8 +1732,8 @@ func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) {
1585 })).Return(newContainer, nil)1732 })).Return(newContainer, nil)
1586 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)1733 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
1587 1734
1588- // Wait for ready1735+ // Wait for ready (2 calls) + stabilization check (1 call)
1589- runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)1736+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
1590 1737
1591 // Inspect after ready1738 // Inspect after ready
1592 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{1739 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
@@ -1619,16 +1766,18 @@ func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) {
1619 assert.Equal(t, "new-container", tracked.ID)1766 assert.Equal(t, "new-container", tracked.ID)
1620 }1767 }
1621 1768
1622-// TestService_Deploy_OrphanCleanupRemovesTrueOrphans verifies that containers1769+// TestService_Deploy_OrphanCleanupRemovesTrueOrphans verifies that stopped canonical
1623-// with the same name but NOT tracked are properly removed as orphans.1770+// containers with the same name but NOT tracked are properly removed as orphans.
1771+// Running canonical containers are preserved (see NeverKillsRunningCanonical test).
1624 func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) {1772 func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) {
1625 runtime := mocks.NewMockContainerRuntime(t)1773 runtime := mocks.NewMockContainerRuntime(t)
1626 envLoader := mocks.NewMockEnvLoader(t)1774 envLoader := mocks.NewMockEnvLoader(t)
1627 eventBus := mocks.NewMockEventPublisher(t)1775 eventBus := mocks.NewMockEventPublisher(t)
1628 1776
1629 config := Config{1777 config := Config{
1630- ReadinessDelay: time.Millisecond, // Minimal delay for tests1778+ ReadinessDelay: time.Millisecond, // Minimal delay for tests
1631- DrainDelay: time.Millisecond,1779+ DrainDelay: time.Millisecond,
1780+ DrainDelayConfigured: true,
1632 }1781 }
1633 svc := NewService(runtime, envLoader, eventBus, nil, config)1782 svc := NewService(runtime, envLoader, eventBus, nil, config)
1634 ctx := testContext()1783 ctx := testContext()
@@ -1640,17 +1789,21 @@ func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) {
1640 Image: "myapp:v1",1789 Image: "myapp:v1",
1641 }1790 }
1642 1791
1643- // ListContainers returns an orphaned container (same name, but not tracked)1792+ // No container in memory — runtime resolution finds no managed container
1644- // This could happen if Gordon crashed and restarted, or container was created manually1793+ // (the orphan is stopped so it won't appear in running-only query)
1794+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
1795+
1796+ // ListContainers(true) returns a STOPPED orphaned container (same name, but not tracked)
1797+ // Stopped canonical containers are safe to remove during orphan cleanup
1645 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{1798 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
1646 {1799 {
1647 ID: "orphan-container",1800 ID: "orphan-container",
1648 Name: "gordon-test.example.com",1801 Name: "gordon-test.example.com",
1649- Status: "running",1802+ Status: "exited",
1650 },1803 },
1651 }, nil)1804 }, nil)
1652 1805
1653- // Orphan should be stopped and removed BEFORE we proceed1806+ // Stopped orphan should be stopped and removed BEFORE we proceed
1654 runtime.EXPECT().StopContainer(mock.Anything, "orphan-container").Return(nil)1807 runtime.EXPECT().StopContainer(mock.Anything, "orphan-container").Return(nil)
1655 runtime.EXPECT().RemoveContainer(mock.Anything, "orphan-container", true).Return(nil)1808 runtime.EXPECT().RemoveContainer(mock.Anything, "orphan-container", true).Return(nil)
1656 1809
@@ -1700,10 +1853,7 @@ func TestService_Deploy_OrphanCleanupRemovesStaleNewContainer(t *testing.T) {
1700 eventBus := mocks.NewMockEventPublisher(t)1853 eventBus := mocks.NewMockEventPublisher(t)
1701 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)1854 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
1702 1855
1703- config := Config{1856+ config := testMinDelayConfig()
1704- ReadinessDelay: time.Millisecond,
1705- DrainDelay: time.Millisecond,
1706- }
1707 svc := NewService(runtime, envLoader, eventBus, nil, config)1857 svc := NewService(runtime, envLoader, eventBus, nil, config)
1708 svc.SetProxyCacheInvalidator(cacheInvalidator)1858 svc.SetProxyCacheInvalidator(cacheInvalidator)
1709 ctx := testContext()1859 ctx := testContext()
@@ -1746,7 +1896,8 @@ func TestService_Deploy_OrphanCleanupRemovesStaleNewContainer(t *testing.T) {
1746 return cfg.Name == "gordon-test.example.com-new"1896 return cfg.Name == "gordon-test.example.com-new"
1747 })).Return(newContainer, nil)1897 })).Return(newContainer, nil)
1748 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)1898 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
1749- runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)1899+ // Wait for ready (2 calls) + stabilization check (1 call)
1900+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
1750 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{1901 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
1751 ID: "new-container",1902 ID: "new-container",
1752 Status: "running",1903 Status: "running",
@@ -1772,10 +1923,7 @@ func TestService_Deploy_TrackedTempContainerUsesAlternateTempName(t *testing.T)
1772 eventBus := mocks.NewMockEventPublisher(t)1923 eventBus := mocks.NewMockEventPublisher(t)
1773 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)1924 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
1774 1925
1775- config := Config{1926+ config := testMinDelayConfig()
1776- ReadinessDelay: time.Millisecond,
1777- DrainDelay: time.Millisecond,
1778- }
1779 svc := NewService(runtime, envLoader, eventBus, nil, config)1927 svc := NewService(runtime, envLoader, eventBus, nil, config)
1780 svc.SetProxyCacheInvalidator(cacheInvalidator)1928 svc.SetProxyCacheInvalidator(cacheInvalidator)
1781 ctx := testContext()1929 ctx := testContext()
@@ -1811,7 +1959,8 @@ func TestService_Deploy_TrackedTempContainerUsesAlternateTempName(t *testing.T)
1811 return cfg.Name == "gordon-test.example.com-next"1959 return cfg.Name == "gordon-test.example.com-next"
1812 })).Return(created, nil)1960 })).Return(created, nil)
1813 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)1961 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
1814- runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)1962+ // Wait for ready (2 calls) + stabilization check (1 call)
1963+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
1815 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{1964 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
1816 ID: "new-container",1965 ID: "new-container",
1817 Status: "running",1966 Status: "running",
@@ -1961,10 +2110,7 @@ func TestService_Deploy_ConcurrentSameDomain(t *testing.T) {
1961 eventBus := mocks.NewMockEventPublisher(t)2110 eventBus := mocks.NewMockEventPublisher(t)
1962 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)2111 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
1963 2112
1964- config := Config{2113+ config := testMinDelayConfig()
1965- ReadinessDelay: time.Millisecond,
1966- DrainDelay: time.Millisecond,
1967- }
1968 svc := NewService(runtime, envLoader, eventBus, nil, config)2114 svc := NewService(runtime, envLoader, eventBus, nil, config)
1969 svc.SetProxyCacheInvalidator(cacheInvalidator)2115 svc.SetProxyCacheInvalidator(cacheInvalidator)
1970 ctx := testContext()2116 ctx := testContext()
@@ -1982,6 +2128,9 @@ func TestService_Deploy_ConcurrentSameDomain(t *testing.T) {
1982 // First deploy: no existing container → creates gordon-test.example.com2128 // First deploy: no existing container → creates gordon-test.example.com
1983 // Second deploy: sees first container → creates gordon-test.example.com-new2129 // Second deploy: sees first container → creates gordon-test.example.com-new
1984 2130
2131+ // First deploy resolves from runtime (no container in memory yet)
2132+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil).Once()
2133+
1985 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Times(2)2134 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Times(2)
1986 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil).Times(2)2135 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil).Times(2)
1987 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil).Times(2)2136 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil).Times(2)
@@ -2005,8 +2154,8 @@ func TestService_Deploy_ConcurrentSameDomain(t *testing.T) {
2005 })2154 })
2006 2155
2007 runtime.EXPECT().StartContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Times(2)2156 runtime.EXPECT().StartContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Times(2)
2008- // IsContainerRunning is called 2x per deploy in waitForReady (initial check + after delay)2157+ // IsContainerRunning: 2x per deploy in waitForReady + 1x stabilization for the second deploy (which has existing)
2009- runtime.EXPECT().IsContainerRunning(mock.Anything, mock.AnythingOfType("string")).Return(true, nil).Times(4)2158+ runtime.EXPECT().IsContainerRunning(mock.Anything, mock.AnythingOfType("string")).Return(true, nil).Times(5)
2010 2159
2011 runtime.EXPECT().InspectContainer(mock.Anything, mock.AnythingOfType("string")).2160 runtime.EXPECT().InspectContainer(mock.Anything, mock.AnythingOfType("string")).
2012 RunAndReturn(func(_ context.Context, id string) (*domain.Container, error) {2161 RunAndReturn(func(_ context.Context, id string) (*domain.Container, error) {
@@ -2069,8 +2218,9 @@ func TestService_Deploy_ContextCancellation(t *testing.T) {
2069 eventBus := mocks.NewMockEventPublisher(t)2218 eventBus := mocks.NewMockEventPublisher(t)
2070 2219
2071 config := Config{2220 config := Config{
2072- ReadinessDelay: time.Millisecond,2221+ ReadinessDelay: time.Millisecond,
2073- DrainDelay: time.Millisecond,2222+ DrainDelay: time.Millisecond,
2223+ DrainDelayConfigured: true,
2074 }2224 }
2075 svc := NewService(runtime, envLoader, eventBus, nil, config)2225 svc := NewService(runtime, envLoader, eventBus, nil, config)
2076 2226
@@ -2097,10 +2247,7 @@ func TestService_Deploy_CacheInvalidationBeforeOldContainerStop(t *testing.T) {
2097 eventBus := mocks.NewMockEventPublisher(t)2247 eventBus := mocks.NewMockEventPublisher(t)
2098 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)2248 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
2099 2249
2100- config := Config{2250+ config := testMinDelayConfig()
2101- ReadinessDelay: time.Millisecond,
2102- DrainDelay: time.Millisecond,
2103- }
2104 svc := NewService(runtime, envLoader, eventBus, nil, config)2251 svc := NewService(runtime, envLoader, eventBus, nil, config)
2105 svc.SetProxyCacheInvalidator(cacheInvalidator)2252 svc.SetProxyCacheInvalidator(cacheInvalidator)
2106 ctx := testContext()2253 ctx := testContext()
@@ -2132,7 +2279,8 @@ func TestService_Deploy_CacheInvalidationBeforeOldContainerStop(t *testing.T) {
2132 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}2279 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
2133 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil)2280 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil)
2134 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)2281 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
2135- runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)2282+ // Wait for ready (2 calls) + stabilization check (1 call)
2283+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
2136 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{2284 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
2137 ID: "new-container", Status: "running",2285 ID: "new-container", Status: "running",
2138 }, nil)2286 }, nil)
@@ -2176,10 +2324,7 @@ func TestService_Deploy_NilCacheInvalidator(t *testing.T) {
2176 envLoader := mocks.NewMockEnvLoader(t)2324 envLoader := mocks.NewMockEnvLoader(t)
2177 eventBus := mocks.NewMockEventPublisher(t)2325 eventBus := mocks.NewMockEventPublisher(t)
2178 2326
2179- config := Config{2327+ config := testMinDelayConfig()
2180- ReadinessDelay: time.Millisecond,
2181- DrainDelay: time.Millisecond,
2182- }
2183 // Intentionally NOT setting cache invalidator2328 // Intentionally NOT setting cache invalidator
2184 svc := NewService(runtime, envLoader, eventBus, nil, config)2329 svc := NewService(runtime, envLoader, eventBus, nil, config)
2185 ctx := testContext()2330 ctx := testContext()
@@ -2205,7 +2350,8 @@ func TestService_Deploy_NilCacheInvalidator(t *testing.T) {
2205 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}2350 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
2206 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil)2351 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil)
2207 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)2352 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
2208- runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)2353+ // Wait for ready (2 calls) + stabilization check (1 call)
2354+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
2209 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{2355 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
2210 ID: "new-container", Status: "running",2356 ID: "new-container", Status: "running",
2211 }, nil)2357 }, nil)
@@ -2226,8 +2372,9 @@ func TestService_Deploy_SkipsRedundantDeploy(t *testing.T) {
2226 eventBus := mocks.NewMockEventPublisher(t)2372 eventBus := mocks.NewMockEventPublisher(t)
2227 2373
2228 config := Config{2374 config := Config{
2229- ReadinessDelay: time.Millisecond,2375+ ReadinessDelay: time.Millisecond,
2230- DrainDelay: time.Millisecond,2376+ DrainDelay: time.Millisecond,
2377+ DrainDelayConfigured: true,
2231 }2378 }
2232 svc := NewService(runtime, envLoader, eventBus, nil, config)2379 svc := NewService(runtime, envLoader, eventBus, nil, config)
2233 ctx := testContext()2380 ctx := testContext()
@@ -2273,16 +2420,68 @@ func TestService_Deploy_SkipsRedundantDeploy(t *testing.T) {
2273 assert.Equal(t, "existing-container", tracked.ID)2420 assert.Equal(t, "existing-container", tracked.ID)
2274 }2421 }
2275 2422
2276-func TestService_Deploy_DoesNotSkipWhenImageIDDiffers(t *testing.T) {2423+func TestService_Deploy_SkipsRedundantDeploy_WhenTrackedImageIDMissing(t *testing.T) {
2277 runtime := mocks.NewMockContainerRuntime(t)2424 runtime := mocks.NewMockContainerRuntime(t)
2278 envLoader := mocks.NewMockEnvLoader(t)2425 envLoader := mocks.NewMockEnvLoader(t)
2279 eventBus := mocks.NewMockEventPublisher(t)2426 eventBus := mocks.NewMockEventPublisher(t)
2280- cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
2281 2427
2282 config := Config{2428 config := Config{
2283- ReadinessDelay: time.Millisecond,2429+ ReadinessDelay: time.Millisecond,
2284- DrainDelay: time.Millisecond,2430+ DrainDelay: time.Millisecond,
2431+ DrainDelayConfigured: true,
2432+ }
2433+ svc := NewService(runtime, envLoader, eventBus, nil, config)
2434+ ctx := testContext()
2435+
2436+ // Pre-populate with existing container missing ImageID (common after stale in-memory state).
2437+ existingContainer := &domain.Container{
2438+ ID: "existing-container",
2439+ Name: "gordon-test.example.com",
2440+ Image: "myapp:latest",
2441+ Status: "running",
2285 }2442 }
2443+ svc.containers["test.example.com"] = existingContainer
2444+
2445+ route := domain.Route{
2446+ Domain: "test.example.com",
2447+ Image: "myapp:latest",
2448+ }
2449+
2450+ // prepareDeployResources
2451+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
2452+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil)
2453+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil)
2454+ envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2455+ runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil)
2456+
2457+ // Existing container image ID is recovered from runtime inspection,
2458+ // then redundancy check can skip the deploy.
2459+ runtime.EXPECT().InspectContainer(mock.Anything, "existing-container").Return(&domain.Container{
2460+ ID: "existing-container",
2461+ ImageID: "sha256:abc123",
2462+ Status: "running",
2463+ }, nil)
2464+ runtime.EXPECT().GetImageID(mock.Anything, "myapp:latest").Return("sha256:abc123", nil)
2465+ runtime.EXPECT().IsContainerRunning(mock.Anything, "existing-container").Return(true, nil)
2466+
2467+ result, err := svc.Deploy(ctx, route)
2468+
2469+ assert.NoError(t, err)
2470+ assert.NotNil(t, result)
2471+ assert.Equal(t, "existing-container", result.ID)
2472+
2473+ tracked, exists := svc.Get(ctx, "test.example.com")
2474+ assert.True(t, exists)
2475+ assert.Equal(t, "existing-container", tracked.ID)
2476+}
2477+
2478+func TestService_Deploy_DoesNotSkipWhenImageIDDiffers(t *testing.T) {
2479+ runtime := mocks.NewMockContainerRuntime(t)
2480+ envLoader := mocks.NewMockEnvLoader(t)
2481+ eventBus := mocks.NewMockEventPublisher(t)
2482+ cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
2483+
2484+ config := testMinDelayConfig()
2286 svc := NewService(runtime, envLoader, eventBus, nil, config)2485 svc := NewService(runtime, envLoader, eventBus, nil, config)
2287 svc.SetProxyCacheInvalidator(cacheInvalidator)2486 svc.SetProxyCacheInvalidator(cacheInvalidator)
2288 ctx := testContext()2487 ctx := testContext()
@@ -2316,7 +2515,8 @@ func TestService_Deploy_DoesNotSkipWhenImageIDDiffers(t *testing.T) {
2316 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}2515 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
2317 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil)2516 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil)
2318 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)2517 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
2319- runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)2518+ // Wait for ready (2 calls) + stabilization check (1 call)
2519+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
2320 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{2520 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
2321 ID: "new-container", Status: "running",2521 ID: "new-container", Status: "running",
2322 }, nil)2522 }, nil)
@@ -2380,10 +2580,7 @@ func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) {
2380 envLoader := mocks.NewMockEnvLoader(t)2580 envLoader := mocks.NewMockEnvLoader(t)
2381 eventBus := mocks.NewMockEventPublisher(t)2581 eventBus := mocks.NewMockEventPublisher(t)
2382 2582
2383- config := Config{2583+ config := testMinDelayConfig()
2384- ReadinessDelay: time.Millisecond,
2385- DrainDelay: time.Millisecond,
2386- }
2387 svc := NewService(runtime, envLoader, eventBus, nil, config)2584 svc := NewService(runtime, envLoader, eventBus, nil, config)
2388 ctx := testContext()2585 ctx := testContext()
2389 2586
@@ -2417,7 +2614,8 @@ func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) {
2417 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}2614 newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
2418 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil)2615 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil)
2419 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)2616 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
2420- runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)2617+ // Wait for ready (2 calls) + stabilization check (1 call)
2618+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
2421 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{2619 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
2422 ID: "new-container", Status: "running",2620 ID: "new-container", Status: "running",
2423 }, nil)2621 }, nil)
@@ -2433,3 +2631,527 @@ func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) {
2433 assert.NoError(t, err)2631 assert.NoError(t, err)
2434 assert.Equal(t, "new-container", result.ID)2632 assert.Equal(t, "new-container", result.ID)
2435 }2633 }
2634+
2635+// TestService_Deploy_OrphanCleanup_NeverKillsRunningCanonical verifies that orphan
2636+// cleanup never stops a running container with the canonical name. Only stopped
2637+// canonical containers and temp containers (-new/-next) should be cleaned up.
2638+// The active canonical container is resolved via resolveExistingContainer (it has the
2639+// managed label), so the new container is created with the "-new" suffix — Docker
2640+// disallows creating a container with the same name as an existing running container.
2641+func TestService_Deploy_OrphanCleanup_NeverKillsRunningCanonical(t *testing.T) {
2642+ runtime := mocks.NewMockContainerRuntime(t)
2643+ envLoader := mocks.NewMockEnvLoader(t)
2644+ eventBus := mocks.NewMockEventPublisher(t)
2645+
2646+ config := testMinDelayConfig()
2647+ svc := NewService(runtime, envLoader, eventBus, nil, config)
2648+ ctx := testContext()
2649+
2650+ // The canonical container is running and has the managed label — it will be
2651+ // discovered by resolveExistingContainer's slow path (no in-memory state).
2652+ existingContainer := &domain.Container{
2653+ ID: "active-canonical",
2654+ Name: "gordon-test.example.com",
2655+ Status: "running",
2656+ Labels: map[string]string{domain.LabelManaged: "true"},
2657+ }
2658+
2659+ route := domain.Route{
2660+ Domain: "test.example.com",
2661+ Image: "myapp:v2",
2662+ }
2663+
2664+ // resolveExistingContainer: slow path finds the running canonical container via label.
2665+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{existingContainer}, nil)
2666+
2667+ // cleanupOrphanedContainers: finds the running canonical AND a stale -new leftover.
2668+ // The canonical container must NOT be killed because it's running.
2669+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
2670+ existingContainer,
2671+ {
2672+ ID: "stale-leftover",
2673+ Name: "gordon-test.example.com-new",
2674+ Status: "exited",
2675+ },
2676+ }, nil)
2677+
2678+ // Only the stale -new container should be stopped and removed during orphan cleanup.
2679+ runtime.EXPECT().StopContainer(mock.Anything, "stale-leftover").Return(nil).Once()
2680+ runtime.EXPECT().RemoveContainer(mock.Anything, "stale-leftover", true).Return(nil).Once()
2681+
2682+ // Image operations: pull new image (existing.Image is empty so redundancy check is skipped)
2683+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
2684+ runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
2685+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
2686+
2687+ // Environment
2688+ envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2689+ runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
2690+
2691+ // Create container — existing is the canonical container, so the new one gets "-new" suffix.
2692+ // Docker enforces unique container names; using "-new" avoids a name collision.
2693+ newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
2694+ runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
2695+ return cfg.Name == "gordon-test.example.com-new"
2696+ })).Return(newContainer, nil)
2697+ runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
2698+
2699+ // Wait for ready (delay mode): pollContainerRunning (1×) + waitForReadyByDelay (1×)
2700+ // Post-switch stabilization: 1× — total 3 calls.
2701+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
2702+
2703+ // Inspect after ready
2704+ runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
2705+ ID: "new-container",
2706+ Status: "running",
2707+ Ports: []int{8080},
2708+ }, nil)
2709+
2710+ // Rename new container to canonical name after stabilization.
2711+ runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil).Once()
2712+
2713+ // Stop and remove old canonical container.
2714+ runtime.EXPECT().StopContainer(mock.Anything, "active-canonical").Return(nil).Once()
2715+ runtime.EXPECT().RemoveContainer(mock.Anything, "active-canonical", true).Return(nil).Once()
2716+
2717+ // Publish event
2718+ eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
2719+
2720+ result, err := svc.Deploy(ctx, route)
2721+
2722+ assert.NoError(t, err)
2723+ assert.Equal(t, "new-container", result.ID)
2724+
2725+ // Verify new container is tracked
2726+ tracked, exists := svc.Get(ctx, "test.example.com")
2727+ assert.True(t, exists)
2728+ assert.Equal(t, "new-container", tracked.ID)
2729+}
2730+
2731+// TestService_Deploy_RollbackOnPostSwitchCrash verifies that if the new container crashes
2732+// during the post-switch stabilization window, traffic is automatically rolled back to the
2733+// old container: the old container is restored as tracked, the proxy cache is re-invalidated,
2734+// and the failed new container is cleaned up.
2735+func TestService_Deploy_RollbackOnPostSwitchCrash(t *testing.T) {
2736+ runtime := mocks.NewMockContainerRuntime(t)
2737+ envLoader := mocks.NewMockEnvLoader(t)
2738+ eventBus := mocks.NewMockEventPublisher(t)
2739+ cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
2740+
2741+ config := Config{
2742+ ReadinessDelay: time.Millisecond,
2743+ DrainDelay: time.Millisecond,
2744+ DrainDelayConfigured: true,
2745+ StabilizationDelay: time.Millisecond,
2746+ }
2747+ svc := NewService(runtime, envLoader, eventBus, nil, config)
2748+ svc.SetProxyCacheInvalidator(cacheInvalidator)
2749+ ctx := testContext()
2750+
2751+ // Pre-populate with existing container
2752+ existing := &domain.Container{
2753+ ID: "old-container",
2754+ Name: "gordon-test.example.com",
2755+ Status: "running",
2756+ }
2757+ svc.containers["test.example.com"] = existing
2758+
2759+ route := domain.Route{
2760+ Domain: "test.example.com",
2761+ Image: "myapp:v2",
2762+ }
2763+
2764+ // Cleanup orphans
2765+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
2766+
2767+ // Image operations
2768+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
2769+ runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
2770+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
2771+
2772+ // Environment
2773+ envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2774+ runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
2775+
2776+ // Create new container
2777+ newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
2778+ runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
2779+ return cfg.Name == "gordon-test.example.com-new"
2780+ })).Return(newContainer, nil)
2781+ runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
2782+
2783+ // Readiness: new container is running during readiness checks (2 calls)
2784+ // Stabilization: new container has crashed (returns false)
2785+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
2786+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(false, nil).Once()
2787+
2788+ // Rollback: verify old container is still running before restoring
2789+ runtime.EXPECT().IsContainerRunning(mock.Anything, "old-container").Return(true, nil).Once()
2790+
2791+ // Inspect after ready
2792+ runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
2793+ ID: "new-container",
2794+ Name: "gordon-test.example.com-new",
2795+ Status: "running",
2796+ }, nil)
2797+
2798+ // Publish event (happens during activateDeployedContainer)
2799+ eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
2800+
2801+ // InvalidateTarget called TWICE: once during activate, once during rollback
2802+ cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return().Times(2)
2803+
2804+ // Cleanup failed new container during rollback
2805+ runtime.EXPECT().StopContainer(mock.Anything, "new-container").Return(nil)
2806+ runtime.EXPECT().RemoveContainer(mock.Anything, "new-container", true).Return(nil)
2807+
2808+ // StopContainer on old-container should NOT be called (old stays running)
2809+ // (no expectation for StopContainer("old-container"))
2810+
2811+ result, err := svc.Deploy(ctx, route)
2812+
2813+ // Deploy returns the old (existing) container after rollback
2814+ assert.NoError(t, err)
2815+ assert.NotNil(t, result)
2816+ assert.Equal(t, "old-container", result.ID, "should return old container after rollback")
2817+
2818+ // Verify old container is restored as tracked
2819+ trackedRB, existsRB := svc.Get(ctx, "test.example.com")
2820+ assert.True(t, existsRB)
2821+ assert.Equal(t, "old-container", trackedRB.ID, "old container should be restored after rollback")
2822+}
2823+
2824+// TestService_Deploy_StabilizationSuccess verifies that when the new container remains
2825+// healthy during the stabilization window, deployment proceeds normally and the old
2826+// container is finalized (stopped and removed).
2827+func TestService_Deploy_StabilizationSuccess(t *testing.T) {
2828+ runtime := mocks.NewMockContainerRuntime(t)
2829+ envLoader := mocks.NewMockEnvLoader(t)
2830+ eventBus := mocks.NewMockEventPublisher(t)
2831+ cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
2832+
2833+ config := Config{
2834+ ReadinessDelay: time.Millisecond,
2835+ DrainDelay: time.Millisecond,
2836+ DrainDelayConfigured: true,
2837+ StabilizationDelay: time.Millisecond,
2838+ }
2839+ svc := NewService(runtime, envLoader, eventBus, nil, config)
2840+ svc.SetProxyCacheInvalidator(cacheInvalidator)
2841+ ctx := testContext()
2842+
2843+ // Pre-populate with existing container
2844+ existing := &domain.Container{
2845+ ID: "old-container",
2846+ Name: "gordon-test.example.com",
2847+ Status: "running",
2848+ }
2849+ svc.containers["test.example.com"] = existing
2850+
2851+ route := domain.Route{
2852+ Domain: "test.example.com",
2853+ Image: "myapp:v2",
2854+ }
2855+
2856+ // Cleanup orphans
2857+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
2858+
2859+ // Image operations
2860+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
2861+ runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
2862+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
2863+
2864+ // Environment
2865+ envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2866+ runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
2867+
2868+ // Create new container
2869+ newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
2870+ runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
2871+ return cfg.Name == "gordon-test.example.com-new"
2872+ })).Return(newContainer, nil)
2873+ runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
2874+
2875+ // Readiness (2 calls) + stabilization check (1 call) — all return running
2876+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
2877+
2878+ // Inspect after ready
2879+ runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
2880+ ID: "new-container",
2881+ Name: "gordon-test.example.com-new",
2882+ Status: "running",
2883+ }, nil)
2884+
2885+ // Publish event
2886+ eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
2887+
2888+ // Cache invalidation (once, during activate)
2889+ cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
2890+
2891+ // Old container finalized normally
2892+ runtime.EXPECT().StopContainer(mock.Anything, "old-container").Return(nil)
2893+ runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil)
2894+ runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
2895+
2896+ result, err := svc.Deploy(ctx, route)
2897+
2898+ assert.NoError(t, err)
2899+ assert.NotNil(t, result)
2900+ assert.Equal(t, "new-container", result.ID, "should return new container on successful stabilization")
2901+
2902+ // Verify new container is tracked
2903+ trackedSS, existsSS := svc.Get(ctx, "test.example.com")
2904+ assert.True(t, existsSS)
2905+ assert.Equal(t, "new-container", trackedSS.ID)
2906+}
2907+
2908+// TestService_Deploy_ZeroDowntime_OldNeverStoppedBeforeNewReady verifies the strict
2909+// ordering invariant: StopContainer("old-container") must NEVER happen before
2910+// InspectContainer("new-container") completes (the last step of readiness).
2911+// Uses testify's NotBefore() to enforce mock call ordering.
2912+func TestService_Deploy_ZeroDowntime_OldNeverStoppedBeforeNewReady(t *testing.T) {
2913+ runtime := mocks.NewMockContainerRuntime(t)
2914+ envLoader := mocks.NewMockEnvLoader(t)
2915+ eventBus := mocks.NewMockEventPublisher(t)
2916+ cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
2917+
2918+ config := Config{
2919+ ReadinessDelay: time.Millisecond,
2920+ DrainDelay: time.Millisecond,
2921+ DrainDelayConfigured: true,
2922+ StabilizationDelay: time.Millisecond,
2923+ }
2924+ svc := NewService(runtime, envLoader, eventBus, nil, config)
2925+ svc.SetProxyCacheInvalidator(cacheInvalidator)
2926+ ctx := testContext()
2927+
2928+ // Pre-populate with existing tracked container
2929+ existingContainer := &domain.Container{
2930+ ID: "old-container",
2931+ Name: "gordon-test.example.com",
2932+ Status: "running",
2933+ }
2934+ svc.containers["test.example.com"] = existingContainer
2935+
2936+ route := domain.Route{
2937+ Domain: "test.example.com",
2938+ Image: "myapp:v2",
2939+ }
2940+
2941+ // Cleanup orphans
2942+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
2943+
2944+ // Image operations
2945+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
2946+ runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
2947+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
2948+
2949+ // Environment
2950+ envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2951+ runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
2952+
2953+ // Create new container with -new suffix for zero-downtime
2954+ newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
2955+ runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
2956+ return cfg.Name == "gordon-test.example.com-new"
2957+ })).Return(newContainer, nil)
2958+ runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
2959+
2960+ // Readiness: pollContainerRunning (1 call) + waitForReadyByDelay verification (1 call)
2961+ // Stabilization check (1 call)
2962+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3)
2963+
2964+ // KEY ORDERING CONSTRAINT: InspectContainer("new-container") is the last step
2965+ // of readiness inside createStartedContainer. StopContainer("old-container")
2966+ // must happen AFTER this call completes.
2967+ inspectCall := runtime.EXPECT().InspectContainer(mock.Anything, "new-container").
2968+ Return(&domain.Container{
2969+ ID: "new-container",
2970+ Name: "gordon-test.example.com-new",
2971+ Status: "running",
2972+ }, nil)
2973+
2974+ // Publish event
2975+ eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
2976+
2977+ // Synchronous cache invalidation
2978+ cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
2979+
2980+ // STRICT ORDERING: StopContainer on old container must NOT happen before
2981+ // InspectContainer on new container (readiness completion)
2982+ runtime.EXPECT().StopContainer(mock.Anything, "old-container").
2983+ Return(nil).
2984+ NotBefore(inspectCall.Call)
2985+
2986+ runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil)
2987+ runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
2988+
2989+ result, err := svc.Deploy(ctx, route)
2990+
2991+ assert.NoError(t, err)
2992+ assert.NotNil(t, result)
2993+ assert.Equal(t, "new-container", result.ID)
2994+
2995+ // Verify new container is tracked
2996+ tracked, exists := svc.Get(ctx, "test.example.com")
2997+ assert.True(t, exists)
2998+ assert.Equal(t, "new-container", tracked.ID)
2999+}
3000+
3001+// TestService_Deploy_ZeroDowntime_ReadinessFailure_OldUntouched verifies that when a
3002+// new container fails readiness (IsContainerRunning returns false), the old container
3003+// is completely untouched: never stopped, never removed. Deploy returns an error and
3004+// the old container remains as the tracked container.
3005+func TestService_Deploy_ZeroDowntime_ReadinessFailure_OldUntouched(t *testing.T) {
3006+ runtime := mocks.NewMockContainerRuntime(t)
3007+ envLoader := mocks.NewMockEnvLoader(t)
3008+ eventBus := mocks.NewMockEventPublisher(t)
3009+
3010+ config := Config{
3011+ ReadinessDelay: time.Millisecond,
3012+ DrainDelay: time.Millisecond,
3013+ DrainDelayConfigured: true,
3014+ StabilizationDelay: time.Millisecond,
3015+ }
3016+ svc := NewService(runtime, envLoader, eventBus, nil, config)
3017+ ctx := testContext()
3018+
3019+ // Pre-populate with existing tracked container
3020+ existingContainer := &domain.Container{
3021+ ID: "old-container",
3022+ Name: "gordon-test.example.com",
3023+ Status: "running",
3024+ }
3025+ svc.containers["test.example.com"] = existingContainer
3026+
3027+ route := domain.Route{
3028+ Domain: "test.example.com",
3029+ Image: "myapp:v2",
3030+ }
3031+
3032+ // Cleanup orphans
3033+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
3034+
3035+ // Image operations
3036+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
3037+ runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
3038+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
3039+
3040+ // Environment
3041+ envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
3042+ runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
3043+
3044+ // Create new container
3045+ newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
3046+ runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
3047+ return cfg.Name == "gordon-test.example.com-new"
3048+ })).Return(newContainer, nil)
3049+ runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
3050+
3051+ // Readiness FAILS: pollContainerRunning succeeds (container starts) but
3052+ // waitForReadyByDelay verification finds it not running.
3053+ // pollContainerRunning: returns true (container initially starts)
3054+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Once()
3055+ // waitForReadyByDelay post-delay verification: returns false (container crashed)
3056+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(false, nil).Once()
3057+ // Recovery window poll: return error to fail fast instead of waiting 30s
3058+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").
3059+ Return(false, errors.New("container exited")).Once()
3060+
3061+ // cleanupFailedContainer: stop and remove the failed new container
3062+ runtime.EXPECT().StopContainer(mock.Anything, "new-container").Return(nil)
3063+ runtime.EXPECT().RemoveContainer(mock.Anything, "new-container", true).Return(nil)
3064+
3065+ // NOTE: StopContainer("old-container") and RemoveContainer("old-container", true)
3066+ // are intentionally NOT mocked. If either is called, testify will panic with
3067+ // "unexpected method call" — verifying the old container is never touched.
3068+
3069+ result, err := svc.Deploy(ctx, route)
3070+
3071+ // Deploy should return an error (readiness failure)
3072+ assert.Error(t, err)
3073+ assert.Nil(t, result)
3074+ assert.Contains(t, err.Error(), "readiness check")
3075+
3076+ // Old container must still be tracked — completely untouched
3077+ tracked, exists := svc.Get(ctx, "test.example.com")
3078+ assert.True(t, exists, "old container should still be tracked after readiness failure")
3079+ assert.Equal(t, "old-container", tracked.ID, "old container ID should be unchanged")
3080+}
3081+
3082+// TestService_Deploy_ZeroDowntime_NoExisting_FirstDeploy is a sanity check:
3083+// first deploy with no existing container should work normally without any
3084+// finalize/drain logic being triggered.
3085+func TestService_Deploy_ZeroDowntime_NoExisting_FirstDeploy(t *testing.T) {
3086+ runtime := mocks.NewMockContainerRuntime(t)
3087+ envLoader := mocks.NewMockEnvLoader(t)
3088+ eventBus := mocks.NewMockEventPublisher(t)
3089+
3090+ config := Config{
3091+ ReadinessDelay: time.Millisecond,
3092+ DrainDelay: time.Millisecond,
3093+ DrainDelayConfigured: true,
3094+ StabilizationDelay: time.Millisecond,
3095+ }
3096+ svc := NewService(runtime, envLoader, eventBus, nil, config)
3097+ ctx := testContext()
3098+
3099+ // NO existing container — this is a first deploy
3100+
3101+ route := domain.Route{
3102+ Domain: "test.example.com",
3103+ Image: "myapp:v2",
3104+ }
3105+
3106+ // resolveExistingContainer: no container in memory, runtime returns nothing
3107+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
3108+
3109+ // Cleanup orphans — nothing found
3110+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
3111+
3112+ // Image operations
3113+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil)
3114+ runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil)
3115+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
3116+
3117+ // Environment
3118+ envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
3119+ runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
3120+
3121+ // Create container — canonical name (no -new suffix since no existing container)
3122+ newContainer := &domain.Container{ID: "first-container", Name: "gordon-test.example.com", Status: "created"}
3123+ runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
3124+ return cfg.Name == "gordon-test.example.com"
3125+ })).Return(newContainer, nil)
3126+ runtime.EXPECT().StartContainer(mock.Anything, "first-container").Return(nil)
3127+
3128+ // Readiness: pollContainerRunning (1 call) + waitForReadyByDelay verification (1 call)
3129+ // No stabilization check for first deploy (hasExisting is false)
3130+ runtime.EXPECT().IsContainerRunning(mock.Anything, "first-container").Return(true, nil).Times(2)
3131+
3132+ // Inspect after readiness
3133+ runtime.EXPECT().InspectContainer(mock.Anything, "first-container").Return(&domain.Container{
3134+ ID: "first-container",
3135+ Name: "gordon-test.example.com",
3136+ Status: "running",
3137+ Ports: []int{8080},
3138+ }, nil)
3139+
3140+ // Publish event
3141+ eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
3142+
3143+ // NOTE: No StopContainer, RemoveContainer, or RenameContainer expectations —
3144+ // first deploy has no previous container to finalize, and no stabilization check.
3145+
3146+ result, err := svc.Deploy(ctx, route)
3147+
3148+ assert.NoError(t, err)
3149+ assert.NotNil(t, result)
3150+ assert.Equal(t, "first-container", result.ID)
3151+ assert.Equal(t, "running", result.Status)
3152+
3153+ // Verify container is tracked
3154+ tracked, exists := svc.Get(ctx, "test.example.com")
3155+ assert.True(t, exists)
3156+ assert.Equal(t, "first-container", tracked.ID)
3157+}
M internal/usecase/proxy/service_test.go +7 -10
@@ -539,14 +539,9 @@ func TestDrainRegistryInFlight(t *testing.T) {
539539
540540 svc.registryInFlight.Add(2)
541541
542- done := make(chan struct{})
542+ result := make(chan bool, 1)
543543 go func() {
544- drained := svc.DrainRegistryInFlight(50 * time.Millisecond)
545- if !drained {
546- // Signal failure via done channel by leaving it open — test will time out
547- return
548- }
549- close(done)
544+ result <- svc.DrainRegistryInFlight(50 * time.Millisecond)
550545 }()
551546
552547 time.Sleep(5 * time.Millisecond)
@@ -554,10 +549,12 @@ func TestDrainRegistryInFlight(t *testing.T) {
554549 svc.registryInFlight.Add(-1)
555550
556551 select {
557- case <-done:
558- // good — drained cleanly
552+ case drained := <-result:
553+ if !drained {
554+ t.Fatalf("DrainRegistryInFlight returned false; expected true after all requests completed")
555+ }
559556 case <-time.After(500 * time.Millisecond):
560- t.Fatal("DrainRegistryInFlight did not return true after requests completed")
557+ t.Fatal("DrainRegistryInFlight did not return within timeout")
561558 }
562559 }
563560
@@ -539,14 +539,9 @@ func TestDrainRegistryInFlight(t *testing.T) {
539 539
540 svc.registryInFlight.Add(2)540 svc.registryInFlight.Add(2)
541 541
542- done := make(chan struct{})542+ result := make(chan bool, 1)
543 go func() {543 go func() {
544- drained := svc.DrainRegistryInFlight(50 * time.Millisecond)544+ result <- svc.DrainRegistryInFlight(50 * time.Millisecond)
545- if !drained {
546- // Signal failure via done channel by leaving it open — test will time out
547- return
548- }
549- close(done)
550 }()545 }()
551 546
552 time.Sleep(5 * time.Millisecond)547 time.Sleep(5 * time.Millisecond)
@@ -554,10 +549,12 @@ func TestDrainRegistryInFlight(t *testing.T) {
554 svc.registryInFlight.Add(-1)549 svc.registryInFlight.Add(-1)
555 550
556 select {551 select {
557- case <-done:552+ case drained := <-result:
558- // good — drained cleanly553+ if !drained {
554+ t.Fatalf("DrainRegistryInFlight returned false; expected true after all requests completed")
555+ }
559 case <-time.After(500 * time.Millisecond):556 case <-time.After(500 * time.Millisecond):
560- t.Fatal("DrainRegistryInFlight did not return true after requests completed")557+ t.Fatal("DrainRegistryInFlight did not return within timeout")
561 }558 }
562 }559 }
563 560
M internal/usecase/registry/service.go +97 -11
@@ -7,6 +7,8 @@ import (
77 "fmt"
88 "io"
99 "strings"
10+ "sync"
11+ "time"
1012
1113 "github.com/bnema/zerowrap"
1214 "go.opentelemetry.io/otel"
@@ -24,10 +26,11 @@ var registryTracer = otel.Tracer("gordon.registry")
2426
2527 // Service implements the RegistryService interface.
2628 type Service struct {
27- blobStorage out.BlobStorage
28- manifestStorage out.ManifestStorage
29- eventBus out.EventPublisher
30- metrics *telemetry.Metrics
29+ blobStorage out.BlobStorage
30+ manifestStorage out.ManifestStorage
31+ eventBus out.EventPublisher
32+ metrics *telemetry.Metrics
33+ suppressedImages sync.Map // imageName -> *time.Timer
3134 }
3235
3336 // SetMetrics sets the telemetry metrics for the registry service.
@@ -48,6 +51,85 @@ func NewService(
4851 }
4952 }
5053
54+// SuppressDeployEvent marks an image name to skip image.pushed events.
55+// The suppression auto-expires after 2 minutes to prevent leaks.
56+func (s *Service) SuppressDeployEvent(imageName string) {
57+ imageName = ExtractImageName(strings.TrimSpace(imageName))
58+ if imageName == "" {
59+ return
60+ }
61+
62+ var timer *time.Timer
63+ timer = time.AfterFunc(2*time.Minute, func() {
64+ // Only delete if this timer is still the current one, preventing an
65+ // old timer's callback from removing a newer suppression entry.
66+ if v, ok := s.suppressedImages.Load(imageName); ok && v == timer {
67+ s.suppressedImages.Delete(imageName)
68+ }
69+ })
70+ if existing, loaded := s.suppressedImages.LoadOrStore(imageName, timer); loaded {
71+ existing.(*time.Timer).Stop()
72+ s.suppressedImages.Store(imageName, timer)
73+ }
74+}
75+
76+// ClearDeployEventSuppression removes event suppression for an image.
77+func (s *Service) ClearDeployEventSuppression(imageName string) {
78+ imageName = ExtractImageName(strings.TrimSpace(imageName))
79+ if imageName == "" {
80+ return
81+ }
82+
83+ if v, loaded := s.suppressedImages.LoadAndDelete(imageName); loaded {
84+ v.(*time.Timer).Stop()
85+ }
86+}
87+
88+// ExtractImageName returns just the repository path of a container image
89+// reference, stripping any registry host prefix, tag, and digest.
90+// Examples:
91+//
92+// "reg.example.com/team/my-app:latest" -> "team/my-app"
93+// "reg.example.com/my-app@sha256:abc" -> "my-app"
94+// "my-app:v1.2" -> "my-app"
95+func ExtractImageName(imageRef string) string {
96+ name := imageRef
97+ // Strip digest
98+ if idx := strings.Index(name, "@"); idx != -1 {
99+ name = name[:idx]
100+ }
101+ // Strip tag
102+ // Find the last colon, but only strip it if it comes after any slash
103+ // (to avoid treating a port number in the host as a tag).
104+ if idx := strings.LastIndex(name, ":"); idx != -1 {
105+ slashIdx := strings.LastIndex(name, "/")
106+ if idx > slashIdx {
107+ name = name[:idx]
108+ }
109+ }
110+ // Strip registry host: if the first segment contains a dot or colon it is
111+ // a registry hostname; remove it.
112+ parts := strings.SplitN(name, "/", 2)
113+ if len(parts) == 2 {
114+ host := parts[0]
115+ if strings.ContainsAny(host, ".:") || host == "localhost" {
116+ name = parts[1]
117+ }
118+ }
119+ return name
120+}
121+
122+// IsDeployEventSuppressed checks if deploy events are suppressed for an image.
123+func (s *Service) IsDeployEventSuppressed(imageName string) bool {
124+ imageName = ExtractImageName(strings.TrimSpace(imageName))
125+ if imageName == "" {
126+ return false
127+ }
128+
129+ _, exists := s.suppressedImages.Load(imageName)
130+ return exists
131+}
132+
51133 // GetManifest retrieves a manifest by name and reference.
52134 func (s *Service) GetManifest(ctx context.Context, name, reference string) (*domain.Manifest, error) {
53135 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -110,13 +192,17 @@ func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (s
110192 // A docker push sends manifests by both digest and tag; firing only on
111193 // tag prevents duplicate deploy triggers for the same push.
112194 if s.eventBus != nil && !strings.HasPrefix(manifest.Reference, "sha256:") {
113- if err := s.eventBus.Publish(domain.EventImagePushed, domain.ImagePushedPayload{
114- Name: manifest.Name,
115- Reference: manifest.Reference,
116- Manifest: manifest.Data,
117- Annotations: manifest.Annotations,
118- }); err != nil {
119- log.Warn().Err(err).Msg("failed to publish image pushed event")
195+ if s.IsDeployEventSuppressed(manifest.Name) {
196+ log.Info().Str("image", manifest.Name).Msg("skipping image.pushed event: CLI deploy intent active")
197+ } else {
198+ if err := s.eventBus.Publish(domain.EventImagePushed, domain.ImagePushedPayload{
199+ Name: manifest.Name,
200+ Reference: manifest.Reference,
201+ Manifest: manifest.Data,
202+ Annotations: manifest.Annotations,
203+ }); err != nil {
204+ log.Warn().Err(err).Msg("failed to publish image pushed event")
205+ }
120206 }
121207 }
122208
@@ -7,6 +7,8 @@ import (
7 "fmt"7 "fmt"
8 "io"8 "io"
9 "strings"9 "strings"
10+ "sync"
11+ "time"
10 12
11 "github.com/bnema/zerowrap"13 "github.com/bnema/zerowrap"
12 "go.opentelemetry.io/otel"14 "go.opentelemetry.io/otel"
@@ -24,10 +26,11 @@ var registryTracer = otel.Tracer("gordon.registry")
24 26
25 // Service implements the RegistryService interface.27 // Service implements the RegistryService interface.
26 type Service struct {28 type Service struct {
27- blobStorage out.BlobStorage29+ blobStorage out.BlobStorage
28- manifestStorage out.ManifestStorage30+ manifestStorage out.ManifestStorage
29- eventBus out.EventPublisher31+ eventBus out.EventPublisher
30- metrics *telemetry.Metrics32+ metrics *telemetry.Metrics
33+ suppressedImages sync.Map // imageName -> *time.Timer
31 }34 }
32 35
33 // SetMetrics sets the telemetry metrics for the registry service.36 // SetMetrics sets the telemetry metrics for the registry service.
@@ -48,6 +51,85 @@ func NewService(
48 }51 }
49 }52 }
50 53
54+// SuppressDeployEvent marks an image name to skip image.pushed events.
55+// The suppression auto-expires after 2 minutes to prevent leaks.
56+func (s *Service) SuppressDeployEvent(imageName string) {
57+ imageName = ExtractImageName(strings.TrimSpace(imageName))
58+ if imageName == "" {
59+ return
60+ }
61+
62+ var timer *time.Timer
63+ timer = time.AfterFunc(2*time.Minute, func() {
64+ // Only delete if this timer is still the current one, preventing an
65+ // old timer's callback from removing a newer suppression entry.
66+ if v, ok := s.suppressedImages.Load(imageName); ok && v == timer {
67+ s.suppressedImages.Delete(imageName)
68+ }
69+ })
70+ if existing, loaded := s.suppressedImages.LoadOrStore(imageName, timer); loaded {
71+ existing.(*time.Timer).Stop()
72+ s.suppressedImages.Store(imageName, timer)
73+ }
74+}
75+
76+// ClearDeployEventSuppression removes event suppression for an image.
77+func (s *Service) ClearDeployEventSuppression(imageName string) {
78+ imageName = ExtractImageName(strings.TrimSpace(imageName))
79+ if imageName == "" {
80+ return
81+ }
82+
83+ if v, loaded := s.suppressedImages.LoadAndDelete(imageName); loaded {
84+ v.(*time.Timer).Stop()
85+ }
86+}
87+
88+// ExtractImageName returns just the repository path of a container image
89+// reference, stripping any registry host prefix, tag, and digest.
90+// Examples:
91+//
92+// "reg.example.com/team/my-app:latest" -> "team/my-app"
93+// "reg.example.com/my-app@sha256:abc" -> "my-app"
94+// "my-app:v1.2" -> "my-app"
95+func ExtractImageName(imageRef string) string {
96+ name := imageRef
97+ // Strip digest
98+ if idx := strings.Index(name, "@"); idx != -1 {
99+ name = name[:idx]
100+ }
101+ // Strip tag
102+ // Find the last colon, but only strip it if it comes after any slash
103+ // (to avoid treating a port number in the host as a tag).
104+ if idx := strings.LastIndex(name, ":"); idx != -1 {
105+ slashIdx := strings.LastIndex(name, "/")
106+ if idx > slashIdx {
107+ name = name[:idx]
108+ }
109+ }
110+ // Strip registry host: if the first segment contains a dot or colon it is
111+ // a registry hostname; remove it.
112+ parts := strings.SplitN(name, "/", 2)
113+ if len(parts) == 2 {
114+ host := parts[0]
115+ if strings.ContainsAny(host, ".:") || host == "localhost" {
116+ name = parts[1]
117+ }
118+ }
119+ return name
120+}
121+
122+// IsDeployEventSuppressed checks if deploy events are suppressed for an image.
123+func (s *Service) IsDeployEventSuppressed(imageName string) bool {
124+ imageName = ExtractImageName(strings.TrimSpace(imageName))
125+ if imageName == "" {
126+ return false
127+ }
128+
129+ _, exists := s.suppressedImages.Load(imageName)
130+ return exists
131+}
132+
51 // GetManifest retrieves a manifest by name and reference.133 // GetManifest retrieves a manifest by name and reference.
52 func (s *Service) GetManifest(ctx context.Context, name, reference string) (*domain.Manifest, error) {134 func (s *Service) GetManifest(ctx context.Context, name, reference string) (*domain.Manifest, error) {
53 ctx = zerowrap.CtxWithFields(ctx, map[string]any{135 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -110,13 +192,17 @@ func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (s
110 // A docker push sends manifests by both digest and tag; firing only on192 // A docker push sends manifests by both digest and tag; firing only on
111 // tag prevents duplicate deploy triggers for the same push.193 // tag prevents duplicate deploy triggers for the same push.
112 if s.eventBus != nil && !strings.HasPrefix(manifest.Reference, "sha256:") {194 if s.eventBus != nil && !strings.HasPrefix(manifest.Reference, "sha256:") {
113- if err := s.eventBus.Publish(domain.EventImagePushed, domain.ImagePushedPayload{195+ if s.IsDeployEventSuppressed(manifest.Name) {
114- Name: manifest.Name,196+ log.Info().Str("image", manifest.Name).Msg("skipping image.pushed event: CLI deploy intent active")
115- Reference: manifest.Reference,197+ } else {
116- Manifest: manifest.Data,198+ if err := s.eventBus.Publish(domain.EventImagePushed, domain.ImagePushedPayload{
117- Annotations: manifest.Annotations,199+ Name: manifest.Name,
118- }); err != nil {200+ Reference: manifest.Reference,
119- log.Warn().Err(err).Msg("failed to publish image pushed event")201+ Manifest: manifest.Data,
202+ Annotations: manifest.Annotations,
203+ }); err != nil {
204+ log.Warn().Err(err).Msg("failed to publish image pushed event")
205+ }
120 }206 }
121 }207 }
122 208
M internal/usecase/registry/service_test.go +39 -0
@@ -11,6 +11,7 @@ import (
1111 "github.com/bnema/zerowrap"
1212 "github.com/stretchr/testify/assert"
1313 "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
1415
1516 "github.com/bnema/gordon/internal/boundaries/out/mocks"
1617 "github.com/bnema/gordon/internal/domain"
@@ -437,3 +438,41 @@ func TestService_ListRepositories_Error(t *testing.T) {
437438 assert.Nil(t, repos)
438439 assert.Contains(t, err.Error(), "failed to list repositories")
439440 }
441+
442+func TestPutManifest_SkipsEventWhenDeployIntentSuppressed(t *testing.T) {
443+ blobStorage := mocks.NewMockBlobStorage(t)
444+ manifestStorage := mocks.NewMockManifestStorage(t)
445+ eventBus := mocks.NewMockEventPublisher(t)
446+
447+ svc := NewService(blobStorage, manifestStorage, eventBus)
448+
449+ svc.SuppressDeployEvent("my-app")
450+
451+ manifestStorage.EXPECT().PutManifest("my-app", "latest", "application/vnd.oci.image.manifest.v1+json", mock.Anything).Return(nil)
452+
453+ manifest := &domain.Manifest{
454+ Name: "my-app",
455+ Reference: "latest",
456+ ContentType: "application/vnd.oci.image.manifest.v1+json",
457+ Data: []byte(`{"schemaVersion":2}`),
458+ }
459+
460+ _, err := svc.PutManifest(context.Background(), manifest)
461+ require.NoError(t, err)
462+
463+ eventBus.AssertNotCalled(t, "Publish", mock.Anything, mock.Anything)
464+}
465+
466+func TestSuppressDeployEvent_ClearsCorrectly(t *testing.T) {
467+ blobStorage := mocks.NewMockBlobStorage(t)
468+ manifestStorage := mocks.NewMockManifestStorage(t)
469+ eventBus := mocks.NewMockEventPublisher(t)
470+
471+ svc := NewService(blobStorage, manifestStorage, eventBus)
472+
473+ svc.SuppressDeployEvent("my-app")
474+ assert.True(t, svc.IsDeployEventSuppressed("my-app"))
475+
476+ svc.ClearDeployEventSuppression("my-app")
477+ assert.False(t, svc.IsDeployEventSuppressed("my-app"))
478+}
@@ -11,6 +11,7 @@ import (
11 "github.com/bnema/zerowrap"11 "github.com/bnema/zerowrap"
12 "github.com/stretchr/testify/assert"12 "github.com/stretchr/testify/assert"
13 "github.com/stretchr/testify/mock"13 "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
14 15
15 "github.com/bnema/gordon/internal/boundaries/out/mocks"16 "github.com/bnema/gordon/internal/boundaries/out/mocks"
16 "github.com/bnema/gordon/internal/domain"17 "github.com/bnema/gordon/internal/domain"
@@ -437,3 +438,41 @@ func TestService_ListRepositories_Error(t *testing.T) {
437 assert.Nil(t, repos)438 assert.Nil(t, repos)
438 assert.Contains(t, err.Error(), "failed to list repositories")439 assert.Contains(t, err.Error(), "failed to list repositories")
439 }440 }
441+
442+func TestPutManifest_SkipsEventWhenDeployIntentSuppressed(t *testing.T) {
443+ blobStorage := mocks.NewMockBlobStorage(t)
444+ manifestStorage := mocks.NewMockManifestStorage(t)
445+ eventBus := mocks.NewMockEventPublisher(t)
446+
447+ svc := NewService(blobStorage, manifestStorage, eventBus)
448+
449+ svc.SuppressDeployEvent("my-app")
450+
451+ manifestStorage.EXPECT().PutManifest("my-app", "latest", "application/vnd.oci.image.manifest.v1+json", mock.Anything).Return(nil)
452+
453+ manifest := &domain.Manifest{
454+ Name: "my-app",
455+ Reference: "latest",
456+ ContentType: "application/vnd.oci.image.manifest.v1+json",
457+ Data: []byte(`{"schemaVersion":2}`),
458+ }
459+
460+ _, err := svc.PutManifest(context.Background(), manifest)
461+ require.NoError(t, err)
462+
463+ eventBus.AssertNotCalled(t, "Publish", mock.Anything, mock.Anything)
464+}
465+
466+func TestSuppressDeployEvent_ClearsCorrectly(t *testing.T) {
467+ blobStorage := mocks.NewMockBlobStorage(t)
468+ manifestStorage := mocks.NewMockManifestStorage(t)
469+ eventBus := mocks.NewMockEventPublisher(t)
470+
471+ svc := NewService(blobStorage, manifestStorage, eventBus)
472+
473+ svc.SuppressDeployEvent("my-app")
474+ assert.True(t, svc.IsDeployEventSuppressed("my-app"))
475+
476+ svc.ClearDeployEventSuppression("my-app")
477+ assert.False(t, svc.IsDeployEventSuppressed("my-app"))
478+}
M wiki/examples/production.md +0 -1
@@ -58,7 +58,6 @@ preserve = true
5858 [network_isolation]
5959 enabled = true
6060 network_prefix = "prod"
61-dns_suffix = ".internal"
6261
6362 # Application routes with pinned versions
6463 [routes]
@@ -58,7 +58,6 @@ preserve = true
58 [network_isolation]58 [network_isolation]
59 enabled = true59 enabled = true
60 network_prefix = "prod"60 network_prefix = "prod"
61-dns_suffix = ".internal"
62 61
63 # Application routes with pinned versions62 # Application routes with pinned versions
64 [routes]63 [routes]

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