detached 5e11a00

Merge pull request #66 from bnema/fix/proxy-cache-race-condition-clean

fix(deploy): use signal-based readiness and drain for zero-downtime swaps
commit
5e11a003e40570b7493e68d6d9c3f4cec36a6efb
author
bnema · · 2026-02-07 21:27 UTC
committer
GitHub · · 2026-02-07 21:27 UTC
merge of
711b2ac + 99c9ecb · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v2.14.0
12 files · +780 -59
M docs/config/deploy.md +70 -0
@@ -7,7 +7,12 @@ Controls how Gordon pulls images during deployment.
77 ```toml
88 [deploy]
99 pull_policy = "if-tag-changed"
10+readiness_mode = "auto"
11+health_timeout = "90s"
1012 readiness_delay = "5s"
13+drain_mode = "auto"
14+drain_timeout = "30s"
15+drain_delay = "2s"
1116 ```
1217
1318 ## Settings
@@ -33,7 +38,72 @@ considered ready for traffic.
3338
3439 Default: `"5s"`
3540
41+### `deploy.readiness_mode`
42+
43+How Gordon determines that a newly started container is ready for traffic.
44+
45+- `auto`: use Docker health status if the container has a healthcheck; otherwise
46+ fall back to `deploy.readiness_delay`.
47+- `docker-health`: require a Docker healthcheck and wait for `healthy`.
48+ Deploy fails with `no healthcheck detected` if absent.
49+- `delay`: always use `deploy.readiness_delay`.
50+
51+Default: `"auto"`
52+
53+### `deploy.health_timeout`
54+
55+Maximum time Gordon waits for health-based readiness in `auto`/`docker-health`
56+mode before failing the deploy.
57+
58+- Uses Go duration format (examples: `"30s"`, `"90s"`, `"2m"`).
59+- Default `"90s"` balances typical app startup time with fast failure feedback.
60+- Increase for slow cold-starts, migrations, or heavy warmup workloads.
61+
62+Default: `"90s"`
63+
64+### `deploy.drain_delay`
65+
66+How long Gordon waits after synchronous proxy cache invalidation before stopping
67+the previous container during zero-downtime replacement.
68+
69+- Uses Go duration format (examples: `"2s"`, `"10s"`, `"1m"`).
70+- Applied only when a previous container exists and cache invalidation was
71+ triggered for the deployed domain.
72+- If explicitly set to `"0s"` (or a negative duration), delay is disabled and
73+ Gordon switches immediately after invalidation.
74+
75+Default: `"2s"`
76+
77+### `deploy.drain_mode`
78+
79+How Gordon decides when it is safe to stop the previous container after routing
80+traffic to the new one.
81+
82+- `auto`: wait for in-flight proxy requests to drain when available; otherwise
83+ fall back to `deploy.drain_delay`.
84+- `inflight`: wait for in-flight proxy requests on the previous container to
85+ reach zero (bounded by `deploy.drain_timeout`).
86+- `delay`: always use `deploy.drain_delay`.
87+
88+Default: `"auto"`
89+
90+### `deploy.drain_timeout`
91+
92+Maximum time Gordon waits for in-flight request drain before continuing with old
93+container shutdown.
94+
95+- Uses Go duration format (examples: `"10s"`, `"30s"`, `"1m"`).
96+- Default `"30s"` is a practical upper bound for most HTTP workloads.
97+- Increase for long-lived requests (large uploads, SSE, long polling).
98+
99+Default: `"30s"`
100+
36101 ## Notes
37102
38103 - Changes to `deploy.pull_policy` require a restart to take effect.
104+- Changes to `deploy.readiness_mode` require a restart to take effect.
105+- Changes to `deploy.health_timeout` require a restart to take effect.
39106 - Changes to `deploy.readiness_delay` require a restart to take effect.
107+- Changes to `deploy.drain_mode` require a restart to take effect.
108+- Changes to `deploy.drain_timeout` require a restart to take effect.
109+- Changes to `deploy.drain_delay` require a restart to take effect.
@@ -7,7 +7,12 @@ Controls how Gordon pulls images during deployment.
7 ```toml7 ```toml
8 [deploy]8 [deploy]
9 pull_policy = "if-tag-changed"9 pull_policy = "if-tag-changed"
10+readiness_mode = "auto"
11+health_timeout = "90s"
10 readiness_delay = "5s"12 readiness_delay = "5s"
13+drain_mode = "auto"
14+drain_timeout = "30s"
15+drain_delay = "2s"
11 ```16 ```
12 17
13 ## Settings18 ## Settings
@@ -33,7 +38,72 @@ considered ready for traffic.
33 38
34 Default: `"5s"`39 Default: `"5s"`
35 40
41+### `deploy.readiness_mode`
42+
43+How Gordon determines that a newly started container is ready for traffic.
44+
45+- `auto`: use Docker health status if the container has a healthcheck; otherwise
46+ fall back to `deploy.readiness_delay`.
47+- `docker-health`: require a Docker healthcheck and wait for `healthy`.
48+ Deploy fails with `no healthcheck detected` if absent.
49+- `delay`: always use `deploy.readiness_delay`.
50+
51+Default: `"auto"`
52+
53+### `deploy.health_timeout`
54+
55+Maximum time Gordon waits for health-based readiness in `auto`/`docker-health`
56+mode before failing the deploy.
57+
58+- Uses Go duration format (examples: `"30s"`, `"90s"`, `"2m"`).
59+- Default `"90s"` balances typical app startup time with fast failure feedback.
60+- Increase for slow cold-starts, migrations, or heavy warmup workloads.
61+
62+Default: `"90s"`
63+
64+### `deploy.drain_delay`
65+
66+How long Gordon waits after synchronous proxy cache invalidation before stopping
67+the previous container during zero-downtime replacement.
68+
69+- Uses Go duration format (examples: `"2s"`, `"10s"`, `"1m"`).
70+- Applied only when a previous container exists and cache invalidation was
71+ triggered for the deployed domain.
72+- If explicitly set to `"0s"` (or a negative duration), delay is disabled and
73+ Gordon switches immediately after invalidation.
74+
75+Default: `"2s"`
76+
77+### `deploy.drain_mode`
78+
79+How Gordon decides when it is safe to stop the previous container after routing
80+traffic to the new one.
81+
82+- `auto`: wait for in-flight proxy requests to drain when available; otherwise
83+ fall back to `deploy.drain_delay`.
84+- `inflight`: wait for in-flight proxy requests on the previous container to
85+ reach zero (bounded by `deploy.drain_timeout`).
86+- `delay`: always use `deploy.drain_delay`.
87+
88+Default: `"auto"`
89+
90+### `deploy.drain_timeout`
91+
92+Maximum time Gordon waits for in-flight request drain before continuing with old
93+container shutdown.
94+
95+- Uses Go duration format (examples: `"10s"`, `"30s"`, `"1m"`).
96+- Default `"30s"` is a practical upper bound for most HTTP workloads.
97+- Increase for long-lived requests (large uploads, SSE, long polling).
98+
99+Default: `"30s"`
100+
36 ## Notes101 ## Notes
37 102
38 - Changes to `deploy.pull_policy` require a restart to take effect.103 - Changes to `deploy.pull_policy` require a restart to take effect.
104+- Changes to `deploy.readiness_mode` require a restart to take effect.
105+- Changes to `deploy.health_timeout` require a restart to take effect.
39 - Changes to `deploy.readiness_delay` require a restart to take effect.106 - Changes to `deploy.readiness_delay` require a restart to take effect.
107+- Changes to `deploy.drain_mode` require a restart to take effect.
108+- Changes to `deploy.drain_timeout` require a restart to take effect.
109+- Changes to `deploy.drain_delay` require a restart to take effect.
M docs/config/index.md +16 -0
@@ -56,6 +56,12 @@ trusted_proxies = [] # IPs/CIDRs trusted for X-Forwarded-For
5656 # Deploy behavior
5757 [deploy]
5858 pull_policy = "if-tag-changed" # always, if-not-present, if-tag-changed
59+readiness_mode = "auto" # auto, docker-health, delay
60+health_timeout = "90s" # Max wait for health-based readiness
61+readiness_delay = "5s" # Wait after running before ready
62+drain_mode = "auto" # auto, inflight, delay
63+drain_timeout = "30s" # Max wait for in-flight drain
64+drain_delay = "2s" # Wait after proxy invalidation before stopping the old container
5965
6066 # Logging
6167 [logging]
@@ -157,7 +163,12 @@ monthly = 12
157163 | `api.rate_limit.burst` | `100` |
158164 | `api.rate_limit.trusted_proxies` | `[]` |
159165 | `deploy.pull_policy` | `"if-tag-changed"` |
166+| `deploy.readiness_mode` | `"auto"` |
167+| `deploy.health_timeout` | `"90s"` |
160168 | `deploy.readiness_delay` | `"5s"` |
169+| `deploy.drain_mode` | `"auto"` |
170+| `deploy.drain_timeout` | `"30s"` |
171+| `deploy.drain_delay` | `"2s"` |
161172 | `logging.level` | `"info"` |
162173 | `logging.format` | `"console"` |
163174 | `logging.file.enabled` | `false` |
@@ -187,7 +198,12 @@ The following settings require a restart to take effect:
187198 - `server.data_dir`
188199 - `auth` settings
189200 - `deploy.pull_policy`
201+- `deploy.readiness_mode`
202+- `deploy.health_timeout`
190203 - `deploy.readiness_delay`
204+- `deploy.drain_mode`
205+- `deploy.drain_timeout`
206+- `deploy.drain_delay`
191207
192208 ## Environment Variable Override
193209
@@ -56,6 +56,12 @@ trusted_proxies = [] # IPs/CIDRs trusted for X-Forwarded-For
56 # Deploy behavior56 # Deploy behavior
57 [deploy]57 [deploy]
58 pull_policy = "if-tag-changed" # always, if-not-present, if-tag-changed58 pull_policy = "if-tag-changed" # always, if-not-present, if-tag-changed
59+readiness_mode = "auto" # auto, docker-health, delay
60+health_timeout = "90s" # Max wait for health-based readiness
61+readiness_delay = "5s" # Wait after running before ready
62+drain_mode = "auto" # auto, inflight, delay
63+drain_timeout = "30s" # Max wait for in-flight drain
64+drain_delay = "2s" # Wait after proxy invalidation before stopping the old container
59 65
60 # Logging66 # Logging
61 [logging]67 [logging]
@@ -157,7 +163,12 @@ monthly = 12
157 | `api.rate_limit.burst` | `100` |163 | `api.rate_limit.burst` | `100` |
158 | `api.rate_limit.trusted_proxies` | `[]` |164 | `api.rate_limit.trusted_proxies` | `[]` |
159 | `deploy.pull_policy` | `"if-tag-changed"` |165 | `deploy.pull_policy` | `"if-tag-changed"` |
166+| `deploy.readiness_mode` | `"auto"` |
167+| `deploy.health_timeout` | `"90s"` |
160 | `deploy.readiness_delay` | `"5s"` |168 | `deploy.readiness_delay` | `"5s"` |
169+| `deploy.drain_mode` | `"auto"` |
170+| `deploy.drain_timeout` | `"30s"` |
171+| `deploy.drain_delay` | `"2s"` |
161 | `logging.level` | `"info"` |172 | `logging.level` | `"info"` |
162 | `logging.format` | `"console"` |173 | `logging.format` | `"console"` |
163 | `logging.file.enabled` | `false` |174 | `logging.file.enabled` | `false` |
@@ -187,7 +198,12 @@ The following settings require a restart to take effect:
187 - `server.data_dir`198 - `server.data_dir`
188 - `auth` settings199 - `auth` settings
189 - `deploy.pull_policy`200 - `deploy.pull_policy`
201+- `deploy.readiness_mode`
202+- `deploy.health_timeout`
190 - `deploy.readiness_delay`203 - `deploy.readiness_delay`
204+- `deploy.drain_mode`
205+- `deploy.drain_timeout`
206+- `deploy.drain_delay`
191 207
192 ## Environment Variable Override208 ## Environment Variable Override
193 209
M docs/config/reference.md +11 -0
@@ -74,6 +74,12 @@ dir = "" # Env files directory (default: {da
7474 # =============================================================================
7575 [deploy]
7676 pull_policy = "if-tag-changed" # "always", "if-tag-changed", "never"
77+readiness_mode = "auto" # "auto", "docker-health", "delay"
78+health_timeout = "90s" # Max wait for health-based readiness
79+readiness_delay = "5s" # Wait after running before considered ready
80+drain_mode = "auto" # "auto", "inflight", "delay"
81+drain_timeout = "30s" # Max wait for in-flight request drain
82+drain_delay = "2s" # Wait after cache invalidation before old stop
7783
7884 # =============================================================================
7985 # AUTO-ROUTE
@@ -172,7 +178,12 @@ monthly = 0 # Keep N monthly backups per DB
172178 | `logging.container_logs.max_backups` | `3` | Keep 3 old files |
173179 | `logging.container_logs.max_age` | `28` | 28 days |
174180 | `deploy.pull_policy` | `"if-tag-changed"` | Pull on tag change |
181+| `deploy.readiness_mode` | `"auto"` | Readiness strategy (`auto`, `docker-health`, `delay`) |
182+| `deploy.health_timeout` | `"90s"` | Max wait for health-based readiness before deploy fails |
175183 | `deploy.readiness_delay` | `"5s"` | Delay before container is considered ready |
184+| `deploy.drain_mode` | `"auto"` | Drain strategy (`auto`, `inflight`, `delay`) |
185+| `deploy.drain_timeout` | `"30s"` | Max wait for in-flight request drain before old stop |
186+| `deploy.drain_delay` | `"2s"` | Delay before stopping previous container after cache invalidation |
176187 | `auto_route.enabled` | `false` | Auto-route disabled |
177188 | `network_isolation.enabled` | `false` | Isolation disabled |
178189 | `network_isolation.network_prefix` | `"gordon"` | Network prefix |
@@ -74,6 +74,12 @@ dir = "" # Env files directory (default: {da
74 # =============================================================================74 # =============================================================================
75 [deploy]75 [deploy]
76 pull_policy = "if-tag-changed" # "always", "if-tag-changed", "never"76 pull_policy = "if-tag-changed" # "always", "if-tag-changed", "never"
77+readiness_mode = "auto" # "auto", "docker-health", "delay"
78+health_timeout = "90s" # Max wait for health-based readiness
79+readiness_delay = "5s" # Wait after running before considered ready
80+drain_mode = "auto" # "auto", "inflight", "delay"
81+drain_timeout = "30s" # Max wait for in-flight request drain
82+drain_delay = "2s" # Wait after cache invalidation before old stop
77 83
78 # =============================================================================84 # =============================================================================
79 # AUTO-ROUTE85 # AUTO-ROUTE
@@ -172,7 +178,12 @@ monthly = 0 # Keep N monthly backups per DB
172 | `logging.container_logs.max_backups` | `3` | Keep 3 old files |178 | `logging.container_logs.max_backups` | `3` | Keep 3 old files |
173 | `logging.container_logs.max_age` | `28` | 28 days |179 | `logging.container_logs.max_age` | `28` | 28 days |
174 | `deploy.pull_policy` | `"if-tag-changed"` | Pull on tag change |180 | `deploy.pull_policy` | `"if-tag-changed"` | Pull on tag change |
181+| `deploy.readiness_mode` | `"auto"` | Readiness strategy (`auto`, `docker-health`, `delay`) |
182+| `deploy.health_timeout` | `"90s"` | Max wait for health-based readiness before deploy fails |
175 | `deploy.readiness_delay` | `"5s"` | Delay before container is considered ready |183 | `deploy.readiness_delay` | `"5s"` | Delay before container is considered ready |
184+| `deploy.drain_mode` | `"auto"` | Drain strategy (`auto`, `inflight`, `delay`) |
185+| `deploy.drain_timeout` | `"30s"` | Max wait for in-flight request drain before old stop |
186+| `deploy.drain_delay` | `"2s"` | Delay before stopping previous container after cache invalidation |
176 | `auto_route.enabled` | `false` | Auto-route disabled |187 | `auto_route.enabled` | `false` | Auto-route disabled |
177 | `network_isolation.enabled` | `false` | Isolation disabled |188 | `network_isolation.enabled` | `false` | Isolation disabled |
178 | `network_isolation.network_prefix` | `"gordon"` | Network prefix |189 | `network_isolation.network_prefix` | `"gordon"` | Network prefix |
M internal/adapters/out/docker/runtime.go +21 -0
@@ -596,6 +596,27 @@ func (r *Runtime) IsContainerRunning(ctx context.Context, containerID string) (b
596596 return ctr.Status == string(domain.ContainerStatusRunning), nil
597597 }
598598
599+// GetContainerHealthStatus returns Docker healthcheck status when available.
600+func (r *Runtime) GetContainerHealthStatus(ctx context.Context, containerID string) (string, bool, error) {
601+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
602+ zerowrap.FieldLayer: "adapter",
603+ zerowrap.FieldAdapter: "docker",
604+ zerowrap.FieldAction: "GetContainerHealthStatus",
605+ zerowrap.FieldEntityID: containerID,
606+ })
607+ log := zerowrap.FromCtx(ctx)
608+
609+ resp, err := r.client.ContainerInspect(ctx, containerID)
610+ if err != nil {
611+ return "", false, log.WrapErr(err, "failed to inspect container")
612+ }
613+ if resp.State == nil || resp.State.Health == nil {
614+ return "", false, nil
615+ }
616+
617+ return resp.State.Health.Status, true, nil
618+}
619+
599620 // GetContainerPort gets the host port for a container's internal port.
600621 func (r *Runtime) GetContainerPort(ctx context.Context, containerID string, internalPort int) (int, error) {
601622 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -596,6 +596,27 @@ func (r *Runtime) IsContainerRunning(ctx context.Context, containerID string) (b
596 return ctr.Status == string(domain.ContainerStatusRunning), nil596 return ctr.Status == string(domain.ContainerStatusRunning), nil
597 }597 }
598 598
599+// GetContainerHealthStatus returns Docker healthcheck status when available.
600+func (r *Runtime) GetContainerHealthStatus(ctx context.Context, containerID string) (string, bool, error) {
601+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
602+ zerowrap.FieldLayer: "adapter",
603+ zerowrap.FieldAdapter: "docker",
604+ zerowrap.FieldAction: "GetContainerHealthStatus",
605+ zerowrap.FieldEntityID: containerID,
606+ })
607+ log := zerowrap.FromCtx(ctx)
608+
609+ resp, err := r.client.ContainerInspect(ctx, containerID)
610+ if err != nil {
611+ return "", false, log.WrapErr(err, "failed to inspect container")
612+ }
613+ if resp.State == nil || resp.State.Health == nil {
614+ return "", false, nil
615+ }
616+
617+ return resp.State.Health.Status, true, nil
618+}
619+
599 // GetContainerPort gets the host port for a container's internal port.620 // GetContainerPort gets the host port for a container's internal port.
600 func (r *Runtime) GetContainerPort(ctx context.Context, containerID string, internalPort int) (int, error) {621 func (r *Runtime) GetContainerPort(ctx context.Context, containerID string, internalPort int) (int, error) {
601 ctx = zerowrap.CtxWithFields(ctx, map[string]any{622 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
M internal/app/run.go +18 -6
@@ -460,6 +460,11 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
460460 svc.maxBlobChunkSize = proxyCfg.maxBlobChunkSize
461461 svc.proxySvc = proxy.NewService(svc.runtime, svc.containerSvc, svc.configSvc, proxyCfg.proxyConfig)
462462
463+ // Wire synchronous proxy cache invalidation for zero-downtime deployments.
464+ // The proxy service implements out.ProxyCacheInvalidator via InvalidateTarget().
465+ svc.containerSvc.SetProxyCacheInvalidator(svc.proxySvc)
466+ svc.containerSvc.SetProxyDrainWaiter(svc.proxySvc)
467+
463468 // Create token handler for registry token endpoint
464469 if svc.authSvc != nil {
465470 internalAuth := authhandler.InternalAuth{
@@ -1055,6 +1060,15 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc
10551060 NetworkGroups: svc.configSvc.GetNetworkGroups(),
10561061 Attachments: svc.configSvc.GetAttachments(),
10571062 ReadinessDelay: v.GetDuration("deploy.readiness_delay"),
1063+ ReadinessMode: v.GetString("deploy.readiness_mode"),
1064+ HealthTimeout: v.GetDuration("deploy.health_timeout"),
1065+ DrainDelay: v.GetDuration("deploy.drain_delay"),
1066+ DrainMode: v.GetString("deploy.drain_mode"),
1067+ DrainTimeout: v.GetDuration("deploy.drain_timeout"),
1068+ }
1069+ if v.IsSet("deploy.drain_delay") {
1070+ containerConfig.DrainDelayConfigured = true
1071+ containerConfig.DrainDelay = v.GetDuration("deploy.drain_delay")
10581072 }
10591073
10601074 if cfg.Auth.Enabled && svc.authSvc != nil {
@@ -1166,12 +1180,6 @@ func registerEventHandlers(ctx context.Context, svc *services, cfg Config) error
11661180 return fmt.Errorf("failed to subscribe manual deploy handler: %w", err)
11671181 }
11681182
1169- // Proxy cache invalidation on container deployment (for zero-downtime)
1170- containerDeployedHandler := proxy.NewContainerDeployedHandler(ctx, svc.proxySvc)
1171- if err := svc.eventBus.Subscribe(containerDeployedHandler); err != nil {
1172- return fmt.Errorf("failed to subscribe container deployed handler: %w", err)
1173- }
1174-
11751183 // Proxy cache invalidation on config reload (clears stale targets for removed routes)
11761184 configReloadProxyHandler := proxy.NewConfigReloadProxyHandler(ctx, svc.proxySvc)
11771185 if err := svc.eventBus.Subscribe(configReloadProxyHandler); err != nil {
@@ -1856,6 +1864,10 @@ func loadConfig(v *viper.Viper, configPath string) error {
18561864 v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit
18571865 v.SetDefault("server.registry_allowed_ips", []string{})
18581866 v.SetDefault("deploy.readiness_delay", "5s")
1867+ v.SetDefault("deploy.readiness_mode", "auto")
1868+ v.SetDefault("deploy.health_timeout", "90s")
1869+ v.SetDefault("deploy.drain_mode", "auto")
1870+ v.SetDefault("deploy.drain_timeout", "30s")
18591871
18601872 ConfigureViper(v, configPath)
18611873
@@ -460,6 +460,11 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
460 svc.maxBlobChunkSize = proxyCfg.maxBlobChunkSize460 svc.maxBlobChunkSize = proxyCfg.maxBlobChunkSize
461 svc.proxySvc = proxy.NewService(svc.runtime, svc.containerSvc, svc.configSvc, proxyCfg.proxyConfig)461 svc.proxySvc = proxy.NewService(svc.runtime, svc.containerSvc, svc.configSvc, proxyCfg.proxyConfig)
462 462
463+ // Wire synchronous proxy cache invalidation for zero-downtime deployments.
464+ // The proxy service implements out.ProxyCacheInvalidator via InvalidateTarget().
465+ svc.containerSvc.SetProxyCacheInvalidator(svc.proxySvc)
466+ svc.containerSvc.SetProxyDrainWaiter(svc.proxySvc)
467+
463 // Create token handler for registry token endpoint468 // Create token handler for registry token endpoint
464 if svc.authSvc != nil {469 if svc.authSvc != nil {
465 internalAuth := authhandler.InternalAuth{470 internalAuth := authhandler.InternalAuth{
@@ -1055,6 +1060,15 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc
1055 NetworkGroups: svc.configSvc.GetNetworkGroups(),1060 NetworkGroups: svc.configSvc.GetNetworkGroups(),
1056 Attachments: svc.configSvc.GetAttachments(),1061 Attachments: svc.configSvc.GetAttachments(),
1057 ReadinessDelay: v.GetDuration("deploy.readiness_delay"),1062 ReadinessDelay: v.GetDuration("deploy.readiness_delay"),
1063+ ReadinessMode: v.GetString("deploy.readiness_mode"),
1064+ HealthTimeout: v.GetDuration("deploy.health_timeout"),
1065+ DrainDelay: v.GetDuration("deploy.drain_delay"),
1066+ DrainMode: v.GetString("deploy.drain_mode"),
1067+ DrainTimeout: v.GetDuration("deploy.drain_timeout"),
1068+ }
1069+ if v.IsSet("deploy.drain_delay") {
1070+ containerConfig.DrainDelayConfigured = true
1071+ containerConfig.DrainDelay = v.GetDuration("deploy.drain_delay")
1058 }1072 }
1059 1073
1060 if cfg.Auth.Enabled && svc.authSvc != nil {1074 if cfg.Auth.Enabled && svc.authSvc != nil {
@@ -1166,12 +1180,6 @@ func registerEventHandlers(ctx context.Context, svc *services, cfg Config) error
1166 return fmt.Errorf("failed to subscribe manual deploy handler: %w", err)1180 return fmt.Errorf("failed to subscribe manual deploy handler: %w", err)
1167 }1181 }
1168 1182
1169- // Proxy cache invalidation on container deployment (for zero-downtime)
1170- containerDeployedHandler := proxy.NewContainerDeployedHandler(ctx, svc.proxySvc)
1171- if err := svc.eventBus.Subscribe(containerDeployedHandler); err != nil {
1172- return fmt.Errorf("failed to subscribe container deployed handler: %w", err)
1173- }
1174-
1175 // Proxy cache invalidation on config reload (clears stale targets for removed routes)1183 // Proxy cache invalidation on config reload (clears stale targets for removed routes)
1176 configReloadProxyHandler := proxy.NewConfigReloadProxyHandler(ctx, svc.proxySvc)1184 configReloadProxyHandler := proxy.NewConfigReloadProxyHandler(ctx, svc.proxySvc)
1177 if err := svc.eventBus.Subscribe(configReloadProxyHandler); err != nil {1185 if err := svc.eventBus.Subscribe(configReloadProxyHandler); err != nil {
@@ -1856,6 +1864,10 @@ func loadConfig(v *viper.Viper, configPath string) error {
1856 v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit1864 v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit
1857 v.SetDefault("server.registry_allowed_ips", []string{})1865 v.SetDefault("server.registry_allowed_ips", []string{})
1858 v.SetDefault("deploy.readiness_delay", "5s")1866 v.SetDefault("deploy.readiness_delay", "5s")
1867+ v.SetDefault("deploy.readiness_mode", "auto")
1868+ v.SetDefault("deploy.health_timeout", "90s")
1869+ v.SetDefault("deploy.drain_mode", "auto")
1870+ v.SetDefault("deploy.drain_timeout", "30s")
1859 1871
1860 ConfigureViper(v, configPath)1872 ConfigureViper(v, configPath)
1861 1873
M internal/boundaries/out/mocks/mock_container_runtime.go +72 -0
@@ -570,6 +570,78 @@ func (_c *MockContainerRuntime_GetContainerExposedPorts_Call) RunAndReturn(run f
570570 return _c
571571 }
572572
573+// GetContainerHealthStatus provides a mock function for the type MockContainerRuntime
574+func (_mock *MockContainerRuntime) GetContainerHealthStatus(ctx context.Context, containerID string) (string, bool, error) {
575+ ret := _mock.Called(ctx, containerID)
576+
577+ if len(ret) == 0 {
578+ panic("no return value specified for GetContainerHealthStatus")
579+ }
580+
581+ var r0 string
582+ var r1 bool
583+ var r2 error
584+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (string, bool, error)); ok {
585+ return returnFunc(ctx, containerID)
586+ }
587+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) string); ok {
588+ r0 = returnFunc(ctx, containerID)
589+ } else {
590+ r0 = ret.Get(0).(string)
591+ }
592+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) bool); ok {
593+ r1 = returnFunc(ctx, containerID)
594+ } else {
595+ r1 = ret.Get(1).(bool)
596+ }
597+ if returnFunc, ok := ret.Get(2).(func(context.Context, string) error); ok {
598+ r2 = returnFunc(ctx, containerID)
599+ } else {
600+ r2 = ret.Error(2)
601+ }
602+ return r0, r1, r2
603+}
604+
605+// MockContainerRuntime_GetContainerHealthStatus_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetContainerHealthStatus'
606+type MockContainerRuntime_GetContainerHealthStatus_Call struct {
607+ *mock.Call
608+}
609+
610+// GetContainerHealthStatus is a helper method to define mock.On call
611+// - ctx context.Context
612+// - containerID string
613+func (_e *MockContainerRuntime_Expecter) GetContainerHealthStatus(ctx interface{}, containerID interface{}) *MockContainerRuntime_GetContainerHealthStatus_Call {
614+ return &MockContainerRuntime_GetContainerHealthStatus_Call{Call: _e.mock.On("GetContainerHealthStatus", ctx, containerID)}
615+}
616+
617+func (_c *MockContainerRuntime_GetContainerHealthStatus_Call) Run(run func(ctx context.Context, containerID string)) *MockContainerRuntime_GetContainerHealthStatus_Call {
618+ _c.Call.Run(func(args mock.Arguments) {
619+ var arg0 context.Context
620+ if args[0] != nil {
621+ arg0 = args[0].(context.Context)
622+ }
623+ var arg1 string
624+ if args[1] != nil {
625+ arg1 = args[1].(string)
626+ }
627+ run(
628+ arg0,
629+ arg1,
630+ )
631+ })
632+ return _c
633+}
634+
635+func (_c *MockContainerRuntime_GetContainerHealthStatus_Call) Return(status string, hasHealthcheck bool, err error) *MockContainerRuntime_GetContainerHealthStatus_Call {
636+ _c.Call.Return(status, hasHealthcheck, err)
637+ return _c
638+}
639+
640+func (_c *MockContainerRuntime_GetContainerHealthStatus_Call) RunAndReturn(run func(ctx context.Context, containerID string) (string, bool, error)) *MockContainerRuntime_GetContainerHealthStatus_Call {
641+ _c.Call.Return(run)
642+ return _c
643+}
644+
573645 // GetContainerLogs provides a mock function for the type MockContainerRuntime
574646 func (_mock *MockContainerRuntime) GetContainerLogs(ctx context.Context, containerID string, follow bool) (io.ReadCloser, error) {
575647 ret := _mock.Called(ctx, containerID, follow)
@@ -570,6 +570,78 @@ func (_c *MockContainerRuntime_GetContainerExposedPorts_Call) RunAndReturn(run f
570 return _c570 return _c
571 }571 }
572 572
573+// GetContainerHealthStatus provides a mock function for the type MockContainerRuntime
574+func (_mock *MockContainerRuntime) GetContainerHealthStatus(ctx context.Context, containerID string) (string, bool, error) {
575+ ret := _mock.Called(ctx, containerID)
576+
577+ if len(ret) == 0 {
578+ panic("no return value specified for GetContainerHealthStatus")
579+ }
580+
581+ var r0 string
582+ var r1 bool
583+ var r2 error
584+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (string, bool, error)); ok {
585+ return returnFunc(ctx, containerID)
586+ }
587+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) string); ok {
588+ r0 = returnFunc(ctx, containerID)
589+ } else {
590+ r0 = ret.Get(0).(string)
591+ }
592+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) bool); ok {
593+ r1 = returnFunc(ctx, containerID)
594+ } else {
595+ r1 = ret.Get(1).(bool)
596+ }
597+ if returnFunc, ok := ret.Get(2).(func(context.Context, string) error); ok {
598+ r2 = returnFunc(ctx, containerID)
599+ } else {
600+ r2 = ret.Error(2)
601+ }
602+ return r0, r1, r2
603+}
604+
605+// MockContainerRuntime_GetContainerHealthStatus_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetContainerHealthStatus'
606+type MockContainerRuntime_GetContainerHealthStatus_Call struct {
607+ *mock.Call
608+}
609+
610+// GetContainerHealthStatus is a helper method to define mock.On call
611+// - ctx context.Context
612+// - containerID string
613+func (_e *MockContainerRuntime_Expecter) GetContainerHealthStatus(ctx interface{}, containerID interface{}) *MockContainerRuntime_GetContainerHealthStatus_Call {
614+ return &MockContainerRuntime_GetContainerHealthStatus_Call{Call: _e.mock.On("GetContainerHealthStatus", ctx, containerID)}
615+}
616+
617+func (_c *MockContainerRuntime_GetContainerHealthStatus_Call) Run(run func(ctx context.Context, containerID string)) *MockContainerRuntime_GetContainerHealthStatus_Call {
618+ _c.Call.Run(func(args mock.Arguments) {
619+ var arg0 context.Context
620+ if args[0] != nil {
621+ arg0 = args[0].(context.Context)
622+ }
623+ var arg1 string
624+ if args[1] != nil {
625+ arg1 = args[1].(string)
626+ }
627+ run(
628+ arg0,
629+ arg1,
630+ )
631+ })
632+ return _c
633+}
634+
635+func (_c *MockContainerRuntime_GetContainerHealthStatus_Call) Return(status string, hasHealthcheck bool, err error) *MockContainerRuntime_GetContainerHealthStatus_Call {
636+ _c.Call.Return(status, hasHealthcheck, err)
637+ return _c
638+}
639+
640+func (_c *MockContainerRuntime_GetContainerHealthStatus_Call) RunAndReturn(run func(ctx context.Context, containerID string) (string, bool, error)) *MockContainerRuntime_GetContainerHealthStatus_Call {
641+ _c.Call.Return(run)
642+ return _c
643+}
644+
573 // GetContainerLogs provides a mock function for the type MockContainerRuntime645 // GetContainerLogs provides a mock function for the type MockContainerRuntime
574 func (_mock *MockContainerRuntime) GetContainerLogs(ctx context.Context, containerID string, follow bool) (io.ReadCloser, error) {646 func (_mock *MockContainerRuntime) GetContainerLogs(ctx context.Context, containerID string, follow bool) (io.ReadCloser, error) {
575 ret := _mock.Called(ctx, containerID, follow)647 ret := _mock.Called(ctx, containerID, follow)
A internal/boundaries/out/mocks/mock_proxy_cache_invalidator.go +80 -0
@@ -0,0 +1,80 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ mock "github.com/stretchr/testify/mock"
11+)
12+
13+// NewMockProxyCacheInvalidator creates a new instance of MockProxyCacheInvalidator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14+// The first argument is typically a *testing.T value.
15+func NewMockProxyCacheInvalidator(t interface {
16+ mock.TestingT
17+ Cleanup(func())
18+}) *MockProxyCacheInvalidator {
19+ mock := &MockProxyCacheInvalidator{}
20+ mock.Mock.Test(t)
21+
22+ t.Cleanup(func() { mock.AssertExpectations(t) })
23+
24+ return mock
25+}
26+
27+// MockProxyCacheInvalidator is an autogenerated mock type for the ProxyCacheInvalidator type
28+type MockProxyCacheInvalidator struct {
29+ mock.Mock
30+}
31+
32+type MockProxyCacheInvalidator_Expecter struct {
33+ mock *mock.Mock
34+}
35+
36+func (_m *MockProxyCacheInvalidator) EXPECT() *MockProxyCacheInvalidator_Expecter {
37+ return &MockProxyCacheInvalidator_Expecter{mock: &_m.Mock}
38+}
39+
40+// InvalidateTarget provides a mock function for the type MockProxyCacheInvalidator
41+func (_mock *MockProxyCacheInvalidator) InvalidateTarget(ctx context.Context, domainName string) {
42+ _mock.Called(ctx, domainName)
43+}
44+
45+// MockProxyCacheInvalidator_InvalidateTarget_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'InvalidateTarget'
46+type MockProxyCacheInvalidator_InvalidateTarget_Call struct {
47+ *mock.Call
48+}
49+
50+// InvalidateTarget is a helper method to define mock.On call
51+// - ctx context.Context
52+// - domainName string
53+func (_e *MockProxyCacheInvalidator_Expecter) InvalidateTarget(ctx any, domainName any) *MockProxyCacheInvalidator_InvalidateTarget_Call {
54+ return &MockProxyCacheInvalidator_InvalidateTarget_Call{Call: _e.mock.On("InvalidateTarget", ctx, domainName)}
55+}
56+
57+func (_c *MockProxyCacheInvalidator_InvalidateTarget_Call) Run(run func(ctx context.Context, domainName string)) *MockProxyCacheInvalidator_InvalidateTarget_Call {
58+ _c.Call.Run(func(args mock.Arguments) {
59+ var arg0 context.Context
60+ if args[0] != nil {
61+ arg0 = args[0].(context.Context)
62+ }
63+ var arg1 string
64+ if args[1] != nil {
65+ arg1 = args[1].(string)
66+ }
67+ run(arg0, arg1)
68+ })
69+ return _c
70+}
71+
72+func (_c *MockProxyCacheInvalidator_InvalidateTarget_Call) Return() *MockProxyCacheInvalidator_InvalidateTarget_Call {
73+ _c.Call.Return()
74+ return _c
75+}
76+
77+func (_c *MockProxyCacheInvalidator_InvalidateTarget_Call) RunAndReturn(run func(ctx context.Context, domainName string)) *MockProxyCacheInvalidator_InvalidateTarget_Call {
78+ _c.Run(run)
79+ return _c
80+}
@@ -0,0 +1,80 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ mock "github.com/stretchr/testify/mock"
11+)
12+
13+// NewMockProxyCacheInvalidator creates a new instance of MockProxyCacheInvalidator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14+// The first argument is typically a *testing.T value.
15+func NewMockProxyCacheInvalidator(t interface {
16+ mock.TestingT
17+ Cleanup(func())
18+}) *MockProxyCacheInvalidator {
19+ mock := &MockProxyCacheInvalidator{}
20+ mock.Mock.Test(t)
21+
22+ t.Cleanup(func() { mock.AssertExpectations(t) })
23+
24+ return mock
25+}
26+
27+// MockProxyCacheInvalidator is an autogenerated mock type for the ProxyCacheInvalidator type
28+type MockProxyCacheInvalidator struct {
29+ mock.Mock
30+}
31+
32+type MockProxyCacheInvalidator_Expecter struct {
33+ mock *mock.Mock
34+}
35+
36+func (_m *MockProxyCacheInvalidator) EXPECT() *MockProxyCacheInvalidator_Expecter {
37+ return &MockProxyCacheInvalidator_Expecter{mock: &_m.Mock}
38+}
39+
40+// InvalidateTarget provides a mock function for the type MockProxyCacheInvalidator
41+func (_mock *MockProxyCacheInvalidator) InvalidateTarget(ctx context.Context, domainName string) {
42+ _mock.Called(ctx, domainName)
43+}
44+
45+// MockProxyCacheInvalidator_InvalidateTarget_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'InvalidateTarget'
46+type MockProxyCacheInvalidator_InvalidateTarget_Call struct {
47+ *mock.Call
48+}
49+
50+// InvalidateTarget is a helper method to define mock.On call
51+// - ctx context.Context
52+// - domainName string
53+func (_e *MockProxyCacheInvalidator_Expecter) InvalidateTarget(ctx any, domainName any) *MockProxyCacheInvalidator_InvalidateTarget_Call {
54+ return &MockProxyCacheInvalidator_InvalidateTarget_Call{Call: _e.mock.On("InvalidateTarget", ctx, domainName)}
55+}
56+
57+func (_c *MockProxyCacheInvalidator_InvalidateTarget_Call) Run(run func(ctx context.Context, domainName string)) *MockProxyCacheInvalidator_InvalidateTarget_Call {
58+ _c.Call.Run(func(args mock.Arguments) {
59+ var arg0 context.Context
60+ if args[0] != nil {
61+ arg0 = args[0].(context.Context)
62+ }
63+ var arg1 string
64+ if args[1] != nil {
65+ arg1 = args[1].(string)
66+ }
67+ run(arg0, arg1)
68+ })
69+ return _c
70+}
71+
72+func (_c *MockProxyCacheInvalidator_InvalidateTarget_Call) Return() *MockProxyCacheInvalidator_InvalidateTarget_Call {
73+ _c.Call.Return()
74+ return _c
75+}
76+
77+func (_c *MockProxyCacheInvalidator_InvalidateTarget_Call) RunAndReturn(run func(ctx context.Context, domainName string)) *MockProxyCacheInvalidator_InvalidateTarget_Call {
78+ _c.Run(run)
79+ return _c
80+}
A internal/boundaries/out/proxy_cache.go +19 -0
@@ -0,0 +1,19 @@
1+package out
2+
3+import (
4+ "context"
5+ "time"
6+)
7+
8+// ProxyCacheInvalidator defines the contract for synchronously invalidating
9+// proxy target cache entries. This is used during zero-downtime deployments
10+// to ensure the proxy stops routing to an old container before it is stopped.
11+type ProxyCacheInvalidator interface {
12+ InvalidateTarget(ctx context.Context, domainName string)
13+}
14+
15+// ProxyDrainWaiter defines the contract for waiting until no in-flight
16+// requests remain for a container.
17+type ProxyDrainWaiter interface {
18+ WaitForNoInFlight(ctx context.Context, containerID string, timeout time.Duration) bool
19+}
@@ -0,0 +1,19 @@
1+package out
2+
3+import (
4+ "context"
5+ "time"
6+)
7+
8+// ProxyCacheInvalidator defines the contract for synchronously invalidating
9+// proxy target cache entries. This is used during zero-downtime deployments
10+// to ensure the proxy stops routing to an old container before it is stopped.
11+type ProxyCacheInvalidator interface {
12+ InvalidateTarget(ctx context.Context, domainName string)
13+}
14+
15+// ProxyDrainWaiter defines the contract for waiting until no in-flight
16+// requests remain for a container.
17+type ProxyDrainWaiter interface {
18+ WaitForNoInFlight(ctx context.Context, containerID string, timeout time.Duration) bool
19+}
M internal/boundaries/out/runtime.go +1 -0
@@ -40,6 +40,7 @@ type ContainerRuntime interface {
4040
4141 // Health and status
4242 IsContainerRunning(ctx context.Context, containerID string) (bool, error)
43+ GetContainerHealthStatus(ctx context.Context, containerID string) (status string, hasHealthcheck bool, err error)
4344 GetContainerPort(ctx context.Context, containerID string, internalPort int) (int, error)
4445
4546 // Image and port inspection
@@ -40,6 +40,7 @@ type ContainerRuntime interface {
40 40
41 // Health and status41 // Health and status
42 IsContainerRunning(ctx context.Context, containerID string) (bool, error)42 IsContainerRunning(ctx context.Context, containerID string) (bool, error)
43+ GetContainerHealthStatus(ctx context.Context, containerID string) (status string, hasHealthcheck bool, err error)
43 GetContainerPort(ctx context.Context, containerID string, internalPort int) (int, error)44 GetContainerPort(ctx context.Context, containerID string, internalPort int) (int, error)
44 45
45 // Image and port inspection46 // Image and port inspection
M internal/usecase/container/service.go +243 -51
@@ -36,6 +36,12 @@ type Config struct {
3636 NetworkGroups map[string][]string
3737 Attachments map[string][]string
3838 ReadinessDelay time.Duration // Delay after container starts before considering it ready
39+ ReadinessMode string // Readiness strategy: auto, docker-health, delay
40+ HealthTimeout time.Duration // Max wait for health-based readiness
41+ DrainDelay time.Duration // Grace period after cache invalidation before stopping old container
42+ DrainDelayConfigured bool // True when deploy.drain_delay was explicitly configured
43+ DrainMode string // Drain strategy: auto, inflight, delay
44+ DrainTimeout time.Duration // Max wait for in-flight requests to drain
3945 }
4046
4147 const (
@@ -52,15 +58,17 @@ const (
5258
5359 // Service implements the ContainerService interface.
5460 type Service struct {
55- runtime out.ContainerRuntime
56- envLoader out.EnvLoader
57- eventBus out.EventPublisher
58- logWriter out.ContainerLogWriter
59- config Config
60- containers map[string]*domain.Container
61- attachments map[string][]string // ownerDomain → []containerIDs
62- mu sync.RWMutex
63- deployMu sync.Map // per-domain deploy locks (domain → *domainDeployLock)
61+ runtime out.ContainerRuntime
62+ envLoader out.EnvLoader
63+ eventBus out.EventPublisher
64+ logWriter out.ContainerLogWriter
65+ cacheInvalidator out.ProxyCacheInvalidator
66+ drainWaiter out.ProxyDrainWaiter
67+ config Config
68+ containers map[string]*domain.Container
69+ attachments map[string][]string // ownerDomain → []containerIDs
70+ mu sync.RWMutex
71+ deployMu sync.Map // per-domain deploy locks (domain → *domainDeployLock)
6472 }
6573
6674 // domainDeployLock is a context-aware mutex using a buffered channel.
@@ -126,6 +134,24 @@ func NewService(
126134 }
127135 }
128136
137+// SetProxyCacheInvalidator sets the proxy cache invalidator for synchronous
138+// cache invalidation during zero-downtime deployments. This must be called
139+// after construction because the proxy service is created after the container
140+// service during application initialization.
141+func (s *Service) SetProxyCacheInvalidator(inv out.ProxyCacheInvalidator) {
142+ s.mu.Lock()
143+ s.cacheInvalidator = inv
144+ s.mu.Unlock()
145+}
146+
147+// SetProxyDrainWaiter sets the proxy in-flight drain waiter used during
148+// zero-downtime replacement before stopping the old container.
149+func (s *Service) SetProxyDrainWaiter(waiter out.ProxyDrainWaiter) {
150+ s.mu.Lock()
151+ s.drainWaiter = waiter
152+ s.mu.Unlock()
153+}
154+
129155 // domainDeployMu returns a per-domain lock for serializing deploy operations.
130156 func (s *Service) domainDeployMu(domain string) *domainDeployLock {
131157 v, _ := s.deployMu.LoadOrStore(domain, newDomainDeployLock())
@@ -205,110 +231,212 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta
205231 })
206232 log := zerowrap.FromCtx(ctx)
207233
208- // Get existing container (if any) without holding lock
234+ existing, hasExisting := s.getTrackedContainer(route.Domain)
235+
236+ resources, err := s.prepareDeployResources(ctx, route, existing)
237+ if err != nil {
238+ return nil, err
239+ }
240+
241+ newContainer, err := s.createStartedContainer(ctx, route, existing, resources)
242+ if err != nil {
243+ return nil, err
244+ }
245+
246+ invalidated := s.activateDeployedContainer(ctx, route.Domain, newContainer)
247+ s.finalizePreviousContainer(ctx, route.Domain, existing, hasExisting, invalidated, newContainer.ID)
248+
249+ // Start container log collection (non-blocking, errors don't fail deployment)
250+ s.startLogCollection(ctx, newContainer.ID, route.Domain)
251+
252+ log.Info().
253+ Str("image", route.Image).
254+ Str(zerowrap.FieldEntityID, newContainer.ID).
255+ Ints("ports", newContainer.Ports).
256+ Str("network", resources.networkName).
257+ Bool("zero_downtime", hasExisting).
258+ Msg("container deployed successfully")
259+
260+ return newContainer, nil
261+}
262+
263+type deployResources struct {
264+ networkName string
265+ actualImageRef string
266+ exposedPorts []int
267+ envVars []string
268+ volumes map[string]string
269+}
270+
271+func (s *Service) getTrackedContainer(domainName string) (*domain.Container, bool) {
209272 s.mu.RLock()
210- existing, hasExisting := s.containers[route.Domain]
273+ container, ok := s.containers[domainName]
211274 s.mu.RUnlock()
275+ return container, ok
276+}
277+
278+func (s *Service) prepareDeployResources(ctx context.Context, route domain.Route, existing *domain.Container) (*deployResources, error) {
279+ log := zerowrap.FromCtx(ctx)
212280
213- // Clean up orphaned containers (containers with same name but not tracked)
214- // Skip the currently tracked container to preserve zero-downtime deployment
215281 existingID := ""
216- if hasExisting {
282+ if existing != nil {
217283 existingID = existing.ID
218284 }
219285 if err := s.cleanupOrphanedContainers(ctx, route.Domain, existingID); err != nil {
220286 log.WrapErr(err, "failed to cleanup orphaned containers")
221287 }
222288
223- // Build image reference and ensure it's available.
224- // ensureImage returns the canonical reference; internal pulls may use the local registry.
225289 imageRef := s.buildImageRef(route.Image)
226290 actualImageRef, err := s.ensureImage(ctx, imageRef)
227291 if err != nil {
228292 return nil, err
229293 }
230294
231- // Setup network FIRST (attachments need it)
232295 networkName := s.getNetworkForApp(route.Domain)
233296 if err := s.createNetworkIfNeeded(ctx, networkName); err != nil {
234297 return nil, log.WrapErr(err, "failed to create network")
235298 }
236-
237- // Deploy attachments BEFORE main container (dependencies first)
238299 if err := s.deployAttachments(ctx, route.Domain, networkName); err != nil {
239300 return nil, log.WrapErr(err, "failed to deploy attachments")
240301 }
241302
242- // Get exposed ports (use actual image ref from pull)
243303 exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, actualImageRef)
244304 if err != nil {
245305 log.WrapErr(err, "failed to get exposed ports, using defaults")
246306 exposedPorts = []int{80, 8080, 3000}
247307 }
248308
249- // Load environment
250309 envVars, err := s.loadEnvironment(ctx, route.Domain, actualImageRef)
251310 if err != nil {
252311 return nil, err
253312 }
254313
255- // Setup volumes (use actual image ref from pull)
256314 volumes, err := s.setupVolumes(ctx, route.Domain, actualImageRef)
257315 if err != nil {
258316 return nil, err
259317 }
260318
261- // Build container configuration
262- containerConfig := s.buildContainerConfig(route.Domain, route.Image, actualImageRef, exposedPorts, envVars, volumes, networkName, existing)
319+ return &deployResources{
320+ networkName: networkName,
321+ actualImageRef: actualImageRef,
322+ exposedPorts: exposedPorts,
323+ envVars: envVars,
324+ volumes: volumes,
325+ }, nil
326+}
327+
328+func (s *Service) createStartedContainer(ctx context.Context, route domain.Route, existing *domain.Container, resources *deployResources) (*domain.Container, error) {
329+ log := zerowrap.FromCtx(ctx)
330+
331+ containerConfig := s.buildContainerConfig(
332+ route.Domain,
333+ route.Image,
334+ resources.actualImageRef,
335+ resources.exposedPorts,
336+ resources.envVars,
337+ resources.volumes,
338+ resources.networkName,
339+ existing,
340+ )
263341
264342 newContainer, err := s.runtime.CreateContainer(ctx, containerConfig)
265343 if err != nil {
266344 return nil, log.WrapErr(err, "failed to create container")
267345 }
268-
269- // Start new container
270346 if err := s.runtime.StartContainer(ctx, newContainer.ID); err != nil {
271347 s.runtime.RemoveContainer(ctx, newContainer.ID, true)
272348 return nil, log.WrapErr(err, "failed to start container")
273349 }
274-
275- // Wait for new container to be ready
276350 if err := s.waitForReady(ctx, newContainer.ID); err != nil {
277351 s.cleanupFailedContainer(ctx, newContainer.ID)
278352 return nil, log.WrapErr(err, "container failed readiness check")
279353 }
280354
281- // Re-inspect for updated info (ports, etc.)
282- newContainer, err = s.runtime.InspectContainer(ctx, newContainer.ID)
355+ inspected, err := s.runtime.InspectContainer(ctx, newContainer.ID)
283356 if err != nil {
357+ s.cleanupFailedContainer(ctx, newContainer.ID)
284358 return nil, log.WrapErr(err, "failed to inspect started container")
285359 }
286360
287- // ATOMIC SWITCH: Update tracking first (proxy will now route to new container)
361+ return inspected, nil
362+}
363+
364+func (s *Service) activateDeployedContainer(ctx context.Context, domainName string, container *domain.Container) bool {
288365 s.mu.Lock()
289- s.containers[route.Domain] = newContainer
366+ s.containers[domainName] = container
290367 s.mu.Unlock()
291368
292- // Publish container deployed event (proxy will invalidate cache)
293- s.publishContainerDeployed(ctx, route.Domain, newContainer.ID)
369+ s.publishContainerDeployed(ctx, domainName, container.ID)
294370
295- // NOW stop and remove old container (traffic already going to new one)
296- if hasExisting {
297- s.cleanupOldContainer(ctx, existing, newContainer.ID, route.Domain)
371+ s.mu.RLock()
372+ inv := s.cacheInvalidator
373+ s.mu.RUnlock()
374+ if inv != nil {
375+ inv.InvalidateTarget(ctx, domainName)
376+ return true
298377 }
299378
300- // Start container log collection (non-blocking, errors don't fail deployment)
301- s.startLogCollection(ctx, newContainer.ID, route.Domain)
379+ return false
380+}
302381
303- log.Info().
304- Str("image", route.Image).
305- Str(zerowrap.FieldEntityID, newContainer.ID).
306- Ints("ports", newContainer.Ports).
307- Str("network", networkName).
308- Bool("zero_downtime", hasExisting).
309- Msg("container deployed successfully")
382+func (s *Service) finalizePreviousContainer(ctx context.Context, domainName string, existing *domain.Container, hasExisting, invalidated bool, newContainerID string) {
383+ if !hasExisting {
384+ return
385+ }
386+ log := zerowrap.FromCtx(ctx)
310387
311- return newContainer, nil
388+ if invalidated {
389+ s.waitForDrain(ctx, existing.ID)
390+ }
391+
392+ if err := ctx.Err(); err != nil {
393+ log.Debug().Err(err).Str("domain", domainName).Str("new_container_id", newContainerID).Msg("skipping old container cleanup due to canceled context")
394+ return
395+ }
396+
397+ s.cleanupOldContainer(ctx, existing, newContainerID, domainName)
398+}
399+
400+func (s *Service) waitForDrain(ctx context.Context, oldContainerID string) {
401+ log := zerowrap.FromCtx(ctx)
402+
403+ s.mu.RLock()
404+ cfg := s.config
405+ waiter := s.drainWaiter
406+ s.mu.RUnlock()
407+
408+ drainMode := cfg.DrainMode
409+ if drainMode == "" {
410+ drainMode = "delay"
411+ }
412+
413+ shouldUseInFlight := (drainMode == "inflight" || drainMode == "auto") && waiter != nil
414+ if shouldUseInFlight {
415+ timeout := cfg.DrainTimeout
416+ if timeout == 0 {
417+ timeout = 30 * time.Second
418+ }
419+ drained := waiter.WaitForNoInFlight(ctx, oldContainerID, timeout)
420+ if !drained {
421+ log.Warn().
422+ Str("old_container_id", oldContainerID).
423+ Dur("drain_timeout", timeout).
424+ Msg("drain wait timed out; old container may still have in-flight traffic")
425+ }
426+ return
427+ }
428+
429+ drainDelay := 2 * time.Second
430+ if cfg.DrainDelayConfigured {
431+ drainDelay = cfg.DrainDelay
432+ }
433+ if drainDelay <= 0 {
434+ return
435+ }
436+ select {
437+ case <-time.After(drainDelay):
438+ case <-ctx.Done():
439+ }
312440 }
313441
314442 // Restart restarts a running container for the given domain.
@@ -1764,11 +1892,7 @@ func (s *Service) findContainerByName(ctx context.Context, name string) *domain.
17641892 return nil
17651893 }
17661894
1767-// waitForReady waits for a container to be ready.
1768-// Uses simple "running + delay" approach for universal compatibility.
17691895 func (s *Service) waitForReady(ctx context.Context, containerID string) error {
1770- log := zerowrap.FromCtx(ctx)
1771-
17721896 if err := s.pollContainerRunning(ctx, containerID); err != nil {
17731897 return err
17741898 }
@@ -1777,6 +1901,43 @@ func (s *Service) waitForReady(ctx context.Context, containerID string) error {
17771901 cfg := s.config
17781902 s.mu.RUnlock()
17791903
1904+ readinessMode := cfg.ReadinessMode
1905+ if readinessMode == "" {
1906+ readinessMode = "delay"
1907+ }
1908+
1909+ switch readinessMode {
1910+ case "delay":
1911+ return s.waitForReadyByDelay(ctx, containerID)
1912+ case "docker-health":
1913+ _, hasHealthcheck, err := s.runtime.GetContainerHealthStatus(ctx, containerID)
1914+ if err != nil {
1915+ return err
1916+ }
1917+ if !hasHealthcheck {
1918+ return errors.New("no healthcheck detected")
1919+ }
1920+ return s.waitForHealthy(ctx, containerID, cfg.HealthTimeout)
1921+ default: // auto
1922+ _, hasHealthcheck, err := s.runtime.GetContainerHealthStatus(ctx, containerID)
1923+ if err != nil {
1924+ return err
1925+ }
1926+ if hasHealthcheck {
1927+ return s.waitForHealthy(ctx, containerID, cfg.HealthTimeout)
1928+ }
1929+ return s.waitForReadyByDelay(ctx, containerID)
1930+ }
1931+}
1932+
1933+// waitForReadyByDelay waits using the legacy running+delay strategy.
1934+func (s *Service) waitForReadyByDelay(ctx context.Context, containerID string) error {
1935+ log := zerowrap.FromCtx(ctx)
1936+
1937+ s.mu.RLock()
1938+ cfg := s.config
1939+ s.mu.RUnlock()
1940+
17801941 delay := cfg.ReadinessDelay
17811942 if delay == 0 {
17821943 delay = 5 * time.Second
@@ -1801,6 +1962,37 @@ func (s *Service) waitForReady(ctx context.Context, containerID string) error {
18011962 return s.waitForRecovery(ctx, containerID)
18021963 }
18031964
1965+func (s *Service) waitForHealthy(ctx context.Context, containerID string, timeout time.Duration) error {
1966+ if timeout == 0 {
1967+ timeout = 90 * time.Second
1968+ }
1969+ waitCtx, cancel := context.WithTimeout(ctx, timeout)
1970+ defer cancel()
1971+
1972+ lastStatus := "starting"
1973+ for {
1974+ status, hasHealthcheck, err := s.runtime.GetContainerHealthStatus(waitCtx, containerID)
1975+ if err != nil {
1976+ return err
1977+ }
1978+ if !hasHealthcheck {
1979+ return errors.New("no healthcheck detected")
1980+ }
1981+ if status == "healthy" {
1982+ return nil
1983+ }
1984+ if status != "" {
1985+ lastStatus = status
1986+ }
1987+
1988+ select {
1989+ case <-time.After(time.Second):
1990+ case <-waitCtx.Done():
1991+ return fmt.Errorf("container healthcheck timeout after %s (last status: %s)", timeout, lastStatus)
1992+ }
1993+ }
1994+}
1995+
18041996 // pollContainerRunning polls until the container is running (max 30 seconds).
18051997 func (s *Service) pollContainerRunning(ctx context.Context, containerID string) error {
18061998 for i := 0; i < 30; i++ {
@@ -36,6 +36,12 @@ type Config struct {
36 NetworkGroups map[string][]string36 NetworkGroups map[string][]string
37 Attachments map[string][]string37 Attachments map[string][]string
38 ReadinessDelay time.Duration // Delay after container starts before considering it ready38 ReadinessDelay time.Duration // Delay after container starts before considering it ready
39+ ReadinessMode string // Readiness strategy: auto, docker-health, delay
40+ HealthTimeout time.Duration // Max wait for health-based readiness
41+ DrainDelay time.Duration // Grace period after cache invalidation before stopping old container
42+ DrainDelayConfigured bool // True when deploy.drain_delay was explicitly configured
43+ DrainMode string // Drain strategy: auto, inflight, delay
44+ DrainTimeout time.Duration // Max wait for in-flight requests to drain
39 }45 }
40 46
41 const (47 const (
@@ -52,15 +58,17 @@ const (
52 58
53 // Service implements the ContainerService interface.59 // Service implements the ContainerService interface.
54 type Service struct {60 type Service struct {
55- runtime out.ContainerRuntime61+ runtime out.ContainerRuntime
56- envLoader out.EnvLoader62+ envLoader out.EnvLoader
57- eventBus out.EventPublisher63+ eventBus out.EventPublisher
58- logWriter out.ContainerLogWriter64+ logWriter out.ContainerLogWriter
59- config Config65+ cacheInvalidator out.ProxyCacheInvalidator
60- containers map[string]*domain.Container66+ drainWaiter out.ProxyDrainWaiter
61- attachments map[string][]string // ownerDomain → []containerIDs67+ config Config
62- mu sync.RWMutex68+ containers map[string]*domain.Container
63- deployMu sync.Map // per-domain deploy locks (domain → *domainDeployLock)69+ attachments map[string][]string // ownerDomain → []containerIDs
70+ mu sync.RWMutex
71+ deployMu sync.Map // per-domain deploy locks (domain → *domainDeployLock)
64 }72 }
65 73
66 // domainDeployLock is a context-aware mutex using a buffered channel.74 // domainDeployLock is a context-aware mutex using a buffered channel.
@@ -126,6 +134,24 @@ func NewService(
126 }134 }
127 }135 }
128 136
137+// SetProxyCacheInvalidator sets the proxy cache invalidator for synchronous
138+// cache invalidation during zero-downtime deployments. This must be called
139+// after construction because the proxy service is created after the container
140+// service during application initialization.
141+func (s *Service) SetProxyCacheInvalidator(inv out.ProxyCacheInvalidator) {
142+ s.mu.Lock()
143+ s.cacheInvalidator = inv
144+ s.mu.Unlock()
145+}
146+
147+// SetProxyDrainWaiter sets the proxy in-flight drain waiter used during
148+// zero-downtime replacement before stopping the old container.
149+func (s *Service) SetProxyDrainWaiter(waiter out.ProxyDrainWaiter) {
150+ s.mu.Lock()
151+ s.drainWaiter = waiter
152+ s.mu.Unlock()
153+}
154+
129 // domainDeployMu returns a per-domain lock for serializing deploy operations.155 // domainDeployMu returns a per-domain lock for serializing deploy operations.
130 func (s *Service) domainDeployMu(domain string) *domainDeployLock {156 func (s *Service) domainDeployMu(domain string) *domainDeployLock {
131 v, _ := s.deployMu.LoadOrStore(domain, newDomainDeployLock())157 v, _ := s.deployMu.LoadOrStore(domain, newDomainDeployLock())
@@ -205,110 +231,212 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta
205 })231 })
206 log := zerowrap.FromCtx(ctx)232 log := zerowrap.FromCtx(ctx)
207 233
208- // Get existing container (if any) without holding lock234+ existing, hasExisting := s.getTrackedContainer(route.Domain)
235+
236+ resources, err := s.prepareDeployResources(ctx, route, existing)
237+ if err != nil {
238+ return nil, err
239+ }
240+
241+ newContainer, err := s.createStartedContainer(ctx, route, existing, resources)
242+ if err != nil {
243+ return nil, err
244+ }
245+
246+ invalidated := s.activateDeployedContainer(ctx, route.Domain, newContainer)
247+ s.finalizePreviousContainer(ctx, route.Domain, existing, hasExisting, invalidated, newContainer.ID)
248+
249+ // Start container log collection (non-blocking, errors don't fail deployment)
250+ s.startLogCollection(ctx, newContainer.ID, route.Domain)
251+
252+ log.Info().
253+ Str("image", route.Image).
254+ Str(zerowrap.FieldEntityID, newContainer.ID).
255+ Ints("ports", newContainer.Ports).
256+ Str("network", resources.networkName).
257+ Bool("zero_downtime", hasExisting).
258+ Msg("container deployed successfully")
259+
260+ return newContainer, nil
261+}
262+
263+type deployResources struct {
264+ networkName string
265+ actualImageRef string
266+ exposedPorts []int
267+ envVars []string
268+ volumes map[string]string
269+}
270+
271+func (s *Service) getTrackedContainer(domainName string) (*domain.Container, bool) {
209 s.mu.RLock()272 s.mu.RLock()
210- existing, hasExisting := s.containers[route.Domain]273+ container, ok := s.containers[domainName]
211 s.mu.RUnlock()274 s.mu.RUnlock()
275+ return container, ok
276+}
277+
278+func (s *Service) prepareDeployResources(ctx context.Context, route domain.Route, existing *domain.Container) (*deployResources, error) {
279+ log := zerowrap.FromCtx(ctx)
212 280
213- // Clean up orphaned containers (containers with same name but not tracked)
214- // Skip the currently tracked container to preserve zero-downtime deployment
215 existingID := ""281 existingID := ""
216- if hasExisting {282+ if existing != nil {
217 existingID = existing.ID283 existingID = existing.ID
218 }284 }
219 if err := s.cleanupOrphanedContainers(ctx, route.Domain, existingID); err != nil {285 if err := s.cleanupOrphanedContainers(ctx, route.Domain, existingID); err != nil {
220 log.WrapErr(err, "failed to cleanup orphaned containers")286 log.WrapErr(err, "failed to cleanup orphaned containers")
221 }287 }
222 288
223- // Build image reference and ensure it's available.
224- // ensureImage returns the canonical reference; internal pulls may use the local registry.
225 imageRef := s.buildImageRef(route.Image)289 imageRef := s.buildImageRef(route.Image)
226 actualImageRef, err := s.ensureImage(ctx, imageRef)290 actualImageRef, err := s.ensureImage(ctx, imageRef)
227 if err != nil {291 if err != nil {
228 return nil, err292 return nil, err
229 }293 }
230 294
231- // Setup network FIRST (attachments need it)
232 networkName := s.getNetworkForApp(route.Domain)295 networkName := s.getNetworkForApp(route.Domain)
233 if err := s.createNetworkIfNeeded(ctx, networkName); err != nil {296 if err := s.createNetworkIfNeeded(ctx, networkName); err != nil {
234 return nil, log.WrapErr(err, "failed to create network")297 return nil, log.WrapErr(err, "failed to create network")
235 }298 }
236-
237- // Deploy attachments BEFORE main container (dependencies first)
238 if err := s.deployAttachments(ctx, route.Domain, networkName); err != nil {299 if err := s.deployAttachments(ctx, route.Domain, networkName); err != nil {
239 return nil, log.WrapErr(err, "failed to deploy attachments")300 return nil, log.WrapErr(err, "failed to deploy attachments")
240 }301 }
241 302
242- // Get exposed ports (use actual image ref from pull)
243 exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, actualImageRef)303 exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, actualImageRef)
244 if err != nil {304 if err != nil {
245 log.WrapErr(err, "failed to get exposed ports, using defaults")305 log.WrapErr(err, "failed to get exposed ports, using defaults")
246 exposedPorts = []int{80, 8080, 3000}306 exposedPorts = []int{80, 8080, 3000}
247 }307 }
248 308
249- // Load environment
250 envVars, err := s.loadEnvironment(ctx, route.Domain, actualImageRef)309 envVars, err := s.loadEnvironment(ctx, route.Domain, actualImageRef)
251 if err != nil {310 if err != nil {
252 return nil, err311 return nil, err
253 }312 }
254 313
255- // Setup volumes (use actual image ref from pull)
256 volumes, err := s.setupVolumes(ctx, route.Domain, actualImageRef)314 volumes, err := s.setupVolumes(ctx, route.Domain, actualImageRef)
257 if err != nil {315 if err != nil {
258 return nil, err316 return nil, err
259 }317 }
260 318
261- // Build container configuration319+ return &deployResources{
262- containerConfig := s.buildContainerConfig(route.Domain, route.Image, actualImageRef, exposedPorts, envVars, volumes, networkName, existing)320+ networkName: networkName,
321+ actualImageRef: actualImageRef,
322+ exposedPorts: exposedPorts,
323+ envVars: envVars,
324+ volumes: volumes,
325+ }, nil
326+}
327+
328+func (s *Service) createStartedContainer(ctx context.Context, route domain.Route, existing *domain.Container, resources *deployResources) (*domain.Container, error) {
329+ log := zerowrap.FromCtx(ctx)
330+
331+ containerConfig := s.buildContainerConfig(
332+ route.Domain,
333+ route.Image,
334+ resources.actualImageRef,
335+ resources.exposedPorts,
336+ resources.envVars,
337+ resources.volumes,
338+ resources.networkName,
339+ existing,
340+ )
263 341
264 newContainer, err := s.runtime.CreateContainer(ctx, containerConfig)342 newContainer, err := s.runtime.CreateContainer(ctx, containerConfig)
265 if err != nil {343 if err != nil {
266 return nil, log.WrapErr(err, "failed to create container")344 return nil, log.WrapErr(err, "failed to create container")
267 }345 }
268-
269- // Start new container
270 if err := s.runtime.StartContainer(ctx, newContainer.ID); err != nil {346 if err := s.runtime.StartContainer(ctx, newContainer.ID); err != nil {
271 s.runtime.RemoveContainer(ctx, newContainer.ID, true)347 s.runtime.RemoveContainer(ctx, newContainer.ID, true)
272 return nil, log.WrapErr(err, "failed to start container")348 return nil, log.WrapErr(err, "failed to start container")
273 }349 }
274-
275- // Wait for new container to be ready
276 if err := s.waitForReady(ctx, newContainer.ID); err != nil {350 if err := s.waitForReady(ctx, newContainer.ID); err != nil {
277 s.cleanupFailedContainer(ctx, newContainer.ID)351 s.cleanupFailedContainer(ctx, newContainer.ID)
278 return nil, log.WrapErr(err, "container failed readiness check")352 return nil, log.WrapErr(err, "container failed readiness check")
279 }353 }
280 354
281- // Re-inspect for updated info (ports, etc.)355+ inspected, err := s.runtime.InspectContainer(ctx, newContainer.ID)
282- newContainer, err = s.runtime.InspectContainer(ctx, newContainer.ID)
283 if err != nil {356 if err != nil {
357+ s.cleanupFailedContainer(ctx, newContainer.ID)
284 return nil, log.WrapErr(err, "failed to inspect started container")358 return nil, log.WrapErr(err, "failed to inspect started container")
285 }359 }
286 360
287- // ATOMIC SWITCH: Update tracking first (proxy will now route to new container)361+ return inspected, nil
362+}
363+
364+func (s *Service) activateDeployedContainer(ctx context.Context, domainName string, container *domain.Container) bool {
288 s.mu.Lock()365 s.mu.Lock()
289- s.containers[route.Domain] = newContainer366+ s.containers[domainName] = container
290 s.mu.Unlock()367 s.mu.Unlock()
291 368
292- // Publish container deployed event (proxy will invalidate cache)369+ s.publishContainerDeployed(ctx, domainName, container.ID)
293- s.publishContainerDeployed(ctx, route.Domain, newContainer.ID)
294 370
295- // NOW stop and remove old container (traffic already going to new one)371+ s.mu.RLock()
296- if hasExisting {372+ inv := s.cacheInvalidator
297- s.cleanupOldContainer(ctx, existing, newContainer.ID, route.Domain)373+ s.mu.RUnlock()
374+ if inv != nil {
375+ inv.InvalidateTarget(ctx, domainName)
376+ return true
298 }377 }
299 378
300- // Start container log collection (non-blocking, errors don't fail deployment)379+ return false
301- s.startLogCollection(ctx, newContainer.ID, route.Domain)380+}
302 381
303- log.Info().382+func (s *Service) finalizePreviousContainer(ctx context.Context, domainName string, existing *domain.Container, hasExisting, invalidated bool, newContainerID string) {
304- Str("image", route.Image).383+ if !hasExisting {
305- Str(zerowrap.FieldEntityID, newContainer.ID).384+ return
306- Ints("ports", newContainer.Ports).385+ }
307- Str("network", networkName).386+ log := zerowrap.FromCtx(ctx)
308- Bool("zero_downtime", hasExisting).
309- Msg("container deployed successfully")
310 387
311- return newContainer, nil388+ if invalidated {
389+ s.waitForDrain(ctx, existing.ID)
390+ }
391+
392+ if err := ctx.Err(); err != nil {
393+ log.Debug().Err(err).Str("domain", domainName).Str("new_container_id", newContainerID).Msg("skipping old container cleanup due to canceled context")
394+ return
395+ }
396+
397+ s.cleanupOldContainer(ctx, existing, newContainerID, domainName)
398+}
399+
400+func (s *Service) waitForDrain(ctx context.Context, oldContainerID string) {
401+ log := zerowrap.FromCtx(ctx)
402+
403+ s.mu.RLock()
404+ cfg := s.config
405+ waiter := s.drainWaiter
406+ s.mu.RUnlock()
407+
408+ drainMode := cfg.DrainMode
409+ if drainMode == "" {
410+ drainMode = "delay"
411+ }
412+
413+ shouldUseInFlight := (drainMode == "inflight" || drainMode == "auto") && waiter != nil
414+ if shouldUseInFlight {
415+ timeout := cfg.DrainTimeout
416+ if timeout == 0 {
417+ timeout = 30 * time.Second
418+ }
419+ drained := waiter.WaitForNoInFlight(ctx, oldContainerID, timeout)
420+ if !drained {
421+ log.Warn().
422+ Str("old_container_id", oldContainerID).
423+ Dur("drain_timeout", timeout).
424+ Msg("drain wait timed out; old container may still have in-flight traffic")
425+ }
426+ return
427+ }
428+
429+ drainDelay := 2 * time.Second
430+ if cfg.DrainDelayConfigured {
431+ drainDelay = cfg.DrainDelay
432+ }
433+ if drainDelay <= 0 {
434+ return
435+ }
436+ select {
437+ case <-time.After(drainDelay):
438+ case <-ctx.Done():
439+ }
312 }440 }
313 441
314 // Restart restarts a running container for the given domain.442 // Restart restarts a running container for the given domain.
@@ -1764,11 +1892,7 @@ func (s *Service) findContainerByName(ctx context.Context, name string) *domain.
1764 return nil1892 return nil
1765 }1893 }
1766 1894
1767-// waitForReady waits for a container to be ready.
1768-// Uses simple "running + delay" approach for universal compatibility.
1769 func (s *Service) waitForReady(ctx context.Context, containerID string) error {1895 func (s *Service) waitForReady(ctx context.Context, containerID string) error {
1770- log := zerowrap.FromCtx(ctx)
1771-
1772 if err := s.pollContainerRunning(ctx, containerID); err != nil {1896 if err := s.pollContainerRunning(ctx, containerID); err != nil {
1773 return err1897 return err
1774 }1898 }
@@ -1777,6 +1901,43 @@ func (s *Service) waitForReady(ctx context.Context, containerID string) error {
1777 cfg := s.config1901 cfg := s.config
1778 s.mu.RUnlock()1902 s.mu.RUnlock()
1779 1903
1904+ readinessMode := cfg.ReadinessMode
1905+ if readinessMode == "" {
1906+ readinessMode = "delay"
1907+ }
1908+
1909+ switch readinessMode {
1910+ case "delay":
1911+ return s.waitForReadyByDelay(ctx, containerID)
1912+ case "docker-health":
1913+ _, hasHealthcheck, err := s.runtime.GetContainerHealthStatus(ctx, containerID)
1914+ if err != nil {
1915+ return err
1916+ }
1917+ if !hasHealthcheck {
1918+ return errors.New("no healthcheck detected")
1919+ }
1920+ return s.waitForHealthy(ctx, containerID, cfg.HealthTimeout)
1921+ default: // auto
1922+ _, hasHealthcheck, err := s.runtime.GetContainerHealthStatus(ctx, containerID)
1923+ if err != nil {
1924+ return err
1925+ }
1926+ if hasHealthcheck {
1927+ return s.waitForHealthy(ctx, containerID, cfg.HealthTimeout)
1928+ }
1929+ return s.waitForReadyByDelay(ctx, containerID)
1930+ }
1931+}
1932+
1933+// waitForReadyByDelay waits using the legacy running+delay strategy.
1934+func (s *Service) waitForReadyByDelay(ctx context.Context, containerID string) error {
1935+ log := zerowrap.FromCtx(ctx)
1936+
1937+ s.mu.RLock()
1938+ cfg := s.config
1939+ s.mu.RUnlock()
1940+
1780 delay := cfg.ReadinessDelay1941 delay := cfg.ReadinessDelay
1781 if delay == 0 {1942 if delay == 0 {
1782 delay = 5 * time.Second1943 delay = 5 * time.Second
@@ -1801,6 +1962,37 @@ func (s *Service) waitForReady(ctx context.Context, containerID string) error {
1801 return s.waitForRecovery(ctx, containerID)1962 return s.waitForRecovery(ctx, containerID)
1802 }1963 }
1803 1964
1965+func (s *Service) waitForHealthy(ctx context.Context, containerID string, timeout time.Duration) error {
1966+ if timeout == 0 {
1967+ timeout = 90 * time.Second
1968+ }
1969+ waitCtx, cancel := context.WithTimeout(ctx, timeout)
1970+ defer cancel()
1971+
1972+ lastStatus := "starting"
1973+ for {
1974+ status, hasHealthcheck, err := s.runtime.GetContainerHealthStatus(waitCtx, containerID)
1975+ if err != nil {
1976+ return err
1977+ }
1978+ if !hasHealthcheck {
1979+ return errors.New("no healthcheck detected")
1980+ }
1981+ if status == "healthy" {
1982+ return nil
1983+ }
1984+ if status != "" {
1985+ lastStatus = status
1986+ }
1987+
1988+ select {
1989+ case <-time.After(time.Second):
1990+ case <-waitCtx.Done():
1991+ return fmt.Errorf("container healthcheck timeout after %s (last status: %s)", timeout, lastStatus)
1992+ }
1993+ }
1994+}
1995+
1804 // pollContainerRunning polls until the container is running (max 30 seconds).1996 // pollContainerRunning polls until the container is running (max 30 seconds).
1805 func (s *Service) pollContainerRunning(ctx context.Context, containerID string) error {1997 func (s *Service) pollContainerRunning(ctx context.Context, containerID string) error {
1806 for i := 0; i < 30; i++ {1998 for i := 0; i < 30; i++ {
M internal/usecase/container/service_test.go +173 -2
@@ -29,6 +29,7 @@ func TestService_Deploy_Success(t *testing.T) {
2929 NetworkIsolation: false,
3030 VolumeAutoCreate: false,
3131 ReadinessDelay: time.Millisecond, // Minimal delay for tests
32+ DrainDelay: time.Millisecond,
3233 }
3334
3435 svc := NewService(runtime, envLoader, eventBus, nil, config)
@@ -105,6 +106,7 @@ func TestService_Deploy_ReadinessRecoveryWindow_AllowsTransientFlap(t *testing.T
105106 NetworkIsolation: false,
106107 VolumeAutoCreate: false,
107108 ReadinessDelay: time.Millisecond,
109+ DrainDelay: time.Millisecond,
108110 }
109111
110112 svc := NewService(runtime, envLoader, eventBus, nil, config)
@@ -243,11 +245,14 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) {
243245 runtime := mocks.NewMockContainerRuntime(t)
244246 envLoader := mocks.NewMockEnvLoader(t)
245247 eventBus := mocks.NewMockEventPublisher(t)
248+ cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
246249
247250 config := Config{
248251 ReadinessDelay: time.Millisecond, // Minimal delay for tests
252+ DrainDelay: time.Millisecond,
249253 }
250254 svc := NewService(runtime, envLoader, eventBus, nil, config)
255+ svc.SetProxyCacheInvalidator(cacheInvalidator)
251256 ctx := testContext()
252257
253258 // Pre-populate with existing container
@@ -294,7 +299,10 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) {
294299 // Publish event
295300 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
296301
297- // Now cleanup old container (after new one is ready)
302+ // Synchronous cache invalidation before old container cleanup
303+ cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
304+
305+ // Now cleanup old container (after new one is ready + cache invalidated + drain delay)
298306 runtime.EXPECT().StopContainer(mock.Anything, "old-container").Return(nil)
299307 runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil)
300308
@@ -321,6 +329,7 @@ func TestService_Deploy_WithNetworkIsolation(t *testing.T) {
321329 NetworkIsolation: true,
322330 NetworkPrefix: "gordon",
323331 ReadinessDelay: time.Millisecond, // Minimal delay for tests
332+ DrainDelay: time.Millisecond,
324333 }
325334 svc := NewService(runtime, envLoader, eventBus, nil, config)
326335 ctx := testContext()
@@ -372,6 +381,7 @@ func TestService_Deploy_WithVolumeAutoCreate(t *testing.T) {
372381 VolumeAutoCreate: true,
373382 VolumePrefix: "gordon",
374383 ReadinessDelay: time.Millisecond, // Minimal delay for tests
384+ DrainDelay: time.Millisecond,
375385 }
376386 svc := NewService(runtime, envLoader, eventBus, nil, config)
377387 ctx := testContext()
@@ -1107,6 +1117,7 @@ func TestService_Deploy_InternalDeployForcesPull(t *testing.T) {
11071117 InternalRegistryUsername: "internal",
11081118 InternalRegistryPassword: "secret",
11091119 ReadinessDelay: time.Millisecond,
1120+ DrainDelay: time.Millisecond,
11101121 }
11111122
11121123 svc := NewService(runtime, envLoader, eventBus, nil, config)
@@ -1175,6 +1186,7 @@ func TestService_AutoStart_StartsNewContainers(t *testing.T) {
11751186
11761187 config := Config{
11771188 ReadinessDelay: time.Millisecond,
1189+ DrainDelay: time.Millisecond,
11781190 }
11791191 svc := NewService(runtime, envLoader, eventBus, nil, config)
11801192 ctx := testContext()
@@ -1218,6 +1230,7 @@ func TestService_AutoStart_SkipsExistingContainers(t *testing.T) {
12181230
12191231 config := Config{
12201232 ReadinessDelay: time.Millisecond,
1233+ DrainDelay: time.Millisecond,
12211234 }
12221235 svc := NewService(runtime, envLoader, eventBus, nil, config)
12231236 ctx := testContext()
@@ -1288,6 +1301,7 @@ func TestService_AutoStart_UsesInternalDeployContext(t *testing.T) {
12881301 RegistryDomain: "reg.example.com",
12891302 RegistryPort: 5000,
12901303 ReadinessDelay: time.Millisecond,
1304+ DrainDelay: time.Millisecond,
12911305 }
12921306 svc := NewService(runtime, envLoader, eventBus, nil, config)
12931307
@@ -1329,11 +1343,14 @@ func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) {
13291343 runtime := mocks.NewMockContainerRuntime(t)
13301344 envLoader := mocks.NewMockEnvLoader(t)
13311345 eventBus := mocks.NewMockEventPublisher(t)
1346+ cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
13321347
13331348 config := Config{
13341349 ReadinessDelay: time.Millisecond, // Minimal delay for tests
1350+ DrainDelay: time.Millisecond,
13351351 }
13361352 svc := NewService(runtime, envLoader, eventBus, nil, config)
1353+ svc.SetProxyCacheInvalidator(cacheInvalidator)
13371354 ctx := testContext()
13381355
13391356 // Pre-populate with existing tracked container
@@ -1388,6 +1405,9 @@ func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) {
13881405 // Publish event
13891406 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
13901407
1408+ // Synchronous cache invalidation
1409+ cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
1410+
13911411 // NOW (after new container is ready) the old container should be stopped and removed
13921412 // This is the correct zero-downtime sequence - not during orphan cleanup
13931413 runtime.EXPECT().StopContainer(mock.Anything, "tracked-container-123").Return(nil)
@@ -1416,6 +1436,7 @@ func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) {
14161436
14171437 config := Config{
14181438 ReadinessDelay: time.Millisecond, // Minimal delay for tests
1439+ DrainDelay: time.Millisecond,
14191440 }
14201441 svc := NewService(runtime, envLoader, eventBus, nil, config)
14211442 ctx := testContext()
@@ -1485,11 +1506,14 @@ func TestService_Deploy_OrphanCleanupRemovesStaleNewContainer(t *testing.T) {
14851506 runtime := mocks.NewMockContainerRuntime(t)
14861507 envLoader := mocks.NewMockEnvLoader(t)
14871508 eventBus := mocks.NewMockEventPublisher(t)
1509+ cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
14881510
14891511 config := Config{
14901512 ReadinessDelay: time.Millisecond,
1513+ DrainDelay: time.Millisecond,
14911514 }
14921515 svc := NewService(runtime, envLoader, eventBus, nil, config)
1516+ svc.SetProxyCacheInvalidator(cacheInvalidator)
14931517 ctx := testContext()
14941518
14951519 // Tracked canonical container exists, but stale -new container should be removed.
@@ -1537,6 +1561,9 @@ func TestService_Deploy_OrphanCleanupRemovesStaleNewContainer(t *testing.T) {
15371561 }, nil)
15381562 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
15391563
1564+ // Synchronous cache invalidation
1565+ cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
1566+
15401567 runtime.EXPECT().StopContainer(mock.Anything, "tracked-container-123").Return(nil)
15411568 runtime.EXPECT().RemoveContainer(mock.Anything, "tracked-container-123", true).Return(nil)
15421569 runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
@@ -1551,11 +1578,14 @@ func TestService_Deploy_TrackedTempContainerUsesAlternateTempName(t *testing.T)
15511578 runtime := mocks.NewMockContainerRuntime(t)
15521579 envLoader := mocks.NewMockEnvLoader(t)
15531580 eventBus := mocks.NewMockEventPublisher(t)
1581+ cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
15541582
15551583 config := Config{
15561584 ReadinessDelay: time.Millisecond,
1585+ DrainDelay: time.Millisecond,
15571586 }
15581587 svc := NewService(runtime, envLoader, eventBus, nil, config)
1588+ svc.SetProxyCacheInvalidator(cacheInvalidator)
15591589 ctx := testContext()
15601590
15611591 // Simulate an interrupted zero-downtime deploy that left "-new" as the tracked container name.
@@ -1596,6 +1626,9 @@ func TestService_Deploy_TrackedTempContainerUsesAlternateTempName(t *testing.T)
15961626 }, nil)
15971627 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
15981628
1629+ // Synchronous cache invalidation
1630+ cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
1631+
15991632 runtime.EXPECT().StopContainer(mock.Anything, "tracked-temp-container").Return(nil)
16001633 runtime.EXPECT().RemoveContainer(mock.Anything, "tracked-temp-container", true).Return(nil)
16011634 runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
@@ -1734,11 +1767,14 @@ func TestService_Deploy_ConcurrentSameDomain(t *testing.T) {
17341767 runtime := mocks.NewMockContainerRuntime(t)
17351768 envLoader := mocks.NewMockEnvLoader(t)
17361769 eventBus := mocks.NewMockEventPublisher(t)
1770+ cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
17371771
17381772 config := Config{
17391773 ReadinessDelay: time.Millisecond,
1774+ DrainDelay: time.Millisecond,
17401775 }
17411776 svc := NewService(runtime, envLoader, eventBus, nil, config)
1777+ svc.SetProxyCacheInvalidator(cacheInvalidator)
17421778 ctx := testContext()
17431779
17441780 route := domain.Route{
@@ -1800,7 +1836,9 @@ func TestService_Deploy_ConcurrentSameDomain(t *testing.T) {
18001836
18011837 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.Anything).Return(nil).Times(2)
18021838
1803- // The second deploy will see the first container and do zero-downtime swap,
1839+ // Both deploys call InvalidateTarget (to ensure proxy picks up the new container).
1840+ cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return().Times(2)
1841+
18041842 // which means it will also stop+remove+rename the old container.
18051843 runtime.EXPECT().StopContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Once()
18061844 runtime.EXPECT().RemoveContainer(mock.Anything, mock.AnythingOfType("string"), true).Return(nil).Once()
@@ -1840,6 +1878,7 @@ func TestService_Deploy_ContextCancellation(t *testing.T) {
18401878
18411879 config := Config{
18421880 ReadinessDelay: time.Millisecond,
1881+ DrainDelay: time.Millisecond,
18431882 }
18441883 svc := NewService(runtime, envLoader, eventBus, nil, config)
18451884
@@ -1856,3 +1895,135 @@ func TestService_Deploy_ContextCancellation(t *testing.T) {
18561895 assert.Error(t, err, "deploy should fail with cancelled context")
18571896 assert.ErrorIs(t, err, context.Canceled, "error should be context.Canceled")
18581897 }
1898+
1899+// TestService_Deploy_CacheInvalidationBeforeOldContainerStop verifies the fix for
1900+// the proxy cache race condition: InvalidateTarget must be called synchronously
1901+// BEFORE the old container is stopped, preventing 503 errors during deployment.
1902+func TestService_Deploy_CacheInvalidationBeforeOldContainerStop(t *testing.T) {
1903+ runtime := mocks.NewMockContainerRuntime(t)
1904+ envLoader := mocks.NewMockEnvLoader(t)
1905+ eventBus := mocks.NewMockEventPublisher(t)
1906+ cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
1907+
1908+ config := Config{
1909+ ReadinessDelay: time.Millisecond,
1910+ DrainDelay: time.Millisecond,
1911+ }
1912+ svc := NewService(runtime, envLoader, eventBus, nil, config)
1913+ svc.SetProxyCacheInvalidator(cacheInvalidator)
1914+ ctx := testContext()
1915+
1916+ // Pre-populate with existing container
1917+ existingContainer := &domain.Container{
1918+ ID: "old-container",
1919+ Name: "gordon-test.example.com",
1920+ Status: "running",
1921+ }
1922+ svc.containers["test.example.com"] = existingContainer
1923+
1924+ route := domain.Route{
1925+ Domain: "test.example.com",
1926+ Image: "myapp:v2",
1927+ }
1928+
1929+ // Track ordering of cache invalidation and container stop
1930+ var callOrder []string
1931+ var orderMu sync.Mutex
1932+
1933+ // Standard deploy mocks
1934+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
1935+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:v2"}, nil)
1936+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
1937+ envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
1938+ runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
1939+
1940+ newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
1941+ runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil)
1942+ runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
1943+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
1944+ runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
1945+ ID: "new-container", Status: "running",
1946+ }, nil)
1947+ eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
1948+
1949+ // Track: cache invalidation should happen first
1950+ cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").
1951+ Run(func(_ context.Context, _ string) {
1952+ orderMu.Lock()
1953+ callOrder = append(callOrder, "invalidate_cache")
1954+ orderMu.Unlock()
1955+ }).Return()
1956+
1957+ // Track: stop should happen after invalidation
1958+ runtime.EXPECT().StopContainer(mock.Anything, "old-container").
1959+ RunAndReturn(func(_ context.Context, _ string) error {
1960+ orderMu.Lock()
1961+ callOrder = append(callOrder, "stop_old_container")
1962+ orderMu.Unlock()
1963+ return nil
1964+ })
1965+ runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil)
1966+ runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
1967+
1968+ result, err := svc.Deploy(ctx, route)
1969+
1970+ assert.NoError(t, err)
1971+ assert.Equal(t, "new-container", result.ID)
1972+
1973+ // Verify ordering: cache invalidation MUST happen before old container stop
1974+ orderMu.Lock()
1975+ defer orderMu.Unlock()
1976+ assert.Equal(t, []string{"invalidate_cache", "stop_old_container"}, callOrder,
1977+ "InvalidateTarget must be called before StopContainer to prevent 503 errors")
1978+}
1979+
1980+// TestService_Deploy_NilCacheInvalidator verifies that deploy works gracefully
1981+// when no cache invalidator is set (e.g., in tests or minimal configurations).
1982+func TestService_Deploy_NilCacheInvalidator(t *testing.T) {
1983+ runtime := mocks.NewMockContainerRuntime(t)
1984+ envLoader := mocks.NewMockEnvLoader(t)
1985+ eventBus := mocks.NewMockEventPublisher(t)
1986+
1987+ config := Config{
1988+ ReadinessDelay: time.Millisecond,
1989+ DrainDelay: time.Millisecond,
1990+ }
1991+ // Intentionally NOT setting cache invalidator
1992+ svc := NewService(runtime, envLoader, eventBus, nil, config)
1993+ ctx := testContext()
1994+
1995+ // Pre-populate with existing container
1996+ svc.containers["test.example.com"] = &domain.Container{
1997+ ID: "old-container",
1998+ Name: "gordon-test.example.com",
1999+ Status: "running",
2000+ }
2001+
2002+ route := domain.Route{
2003+ Domain: "test.example.com",
2004+ Image: "myapp:v2",
2005+ }
2006+
2007+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
2008+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:v2"}, nil)
2009+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
2010+ envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2011+ runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
2012+
2013+ newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
2014+ runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil)
2015+ runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
2016+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
2017+ runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
2018+ ID: "new-container", Status: "running",
2019+ }, nil)
2020+ eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
2021+ runtime.EXPECT().StopContainer(mock.Anything, "old-container").Return(nil)
2022+ runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil)
2023+ runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
2024+
2025+ result, err := svc.Deploy(ctx, route)
2026+
2027+ assert.NoError(t, err)
2028+ assert.Equal(t, "new-container", result.ID)
2029+}
@@ -29,6 +29,7 @@ func TestService_Deploy_Success(t *testing.T) {
29 NetworkIsolation: false,29 NetworkIsolation: false,
30 VolumeAutoCreate: false,30 VolumeAutoCreate: false,
31 ReadinessDelay: time.Millisecond, // Minimal delay for tests31 ReadinessDelay: time.Millisecond, // Minimal delay for tests
32+ DrainDelay: time.Millisecond,
32 }33 }
33 34
34 svc := NewService(runtime, envLoader, eventBus, nil, config)35 svc := NewService(runtime, envLoader, eventBus, nil, config)
@@ -105,6 +106,7 @@ func TestService_Deploy_ReadinessRecoveryWindow_AllowsTransientFlap(t *testing.T
105 NetworkIsolation: false,106 NetworkIsolation: false,
106 VolumeAutoCreate: false,107 VolumeAutoCreate: false,
107 ReadinessDelay: time.Millisecond,108 ReadinessDelay: time.Millisecond,
109+ DrainDelay: time.Millisecond,
108 }110 }
109 111
110 svc := NewService(runtime, envLoader, eventBus, nil, config)112 svc := NewService(runtime, envLoader, eventBus, nil, config)
@@ -243,11 +245,14 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) {
243 runtime := mocks.NewMockContainerRuntime(t)245 runtime := mocks.NewMockContainerRuntime(t)
244 envLoader := mocks.NewMockEnvLoader(t)246 envLoader := mocks.NewMockEnvLoader(t)
245 eventBus := mocks.NewMockEventPublisher(t)247 eventBus := mocks.NewMockEventPublisher(t)
248+ cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
246 249
247 config := Config{250 config := Config{
248 ReadinessDelay: time.Millisecond, // Minimal delay for tests251 ReadinessDelay: time.Millisecond, // Minimal delay for tests
252+ DrainDelay: time.Millisecond,
249 }253 }
250 svc := NewService(runtime, envLoader, eventBus, nil, config)254 svc := NewService(runtime, envLoader, eventBus, nil, config)
255+ svc.SetProxyCacheInvalidator(cacheInvalidator)
251 ctx := testContext()256 ctx := testContext()
252 257
253 // Pre-populate with existing container258 // Pre-populate with existing container
@@ -294,7 +299,10 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) {
294 // Publish event299 // Publish event
295 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)300 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
296 301
297- // Now cleanup old container (after new one is ready)302+ // Synchronous cache invalidation before old container cleanup
303+ cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
304+
305+ // Now cleanup old container (after new one is ready + cache invalidated + drain delay)
298 runtime.EXPECT().StopContainer(mock.Anything, "old-container").Return(nil)306 runtime.EXPECT().StopContainer(mock.Anything, "old-container").Return(nil)
299 runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil)307 runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil)
300 308
@@ -321,6 +329,7 @@ func TestService_Deploy_WithNetworkIsolation(t *testing.T) {
321 NetworkIsolation: true,329 NetworkIsolation: true,
322 NetworkPrefix: "gordon",330 NetworkPrefix: "gordon",
323 ReadinessDelay: time.Millisecond, // Minimal delay for tests331 ReadinessDelay: time.Millisecond, // Minimal delay for tests
332+ DrainDelay: time.Millisecond,
324 }333 }
325 svc := NewService(runtime, envLoader, eventBus, nil, config)334 svc := NewService(runtime, envLoader, eventBus, nil, config)
326 ctx := testContext()335 ctx := testContext()
@@ -372,6 +381,7 @@ func TestService_Deploy_WithVolumeAutoCreate(t *testing.T) {
372 VolumeAutoCreate: true,381 VolumeAutoCreate: true,
373 VolumePrefix: "gordon",382 VolumePrefix: "gordon",
374 ReadinessDelay: time.Millisecond, // Minimal delay for tests383 ReadinessDelay: time.Millisecond, // Minimal delay for tests
384+ DrainDelay: time.Millisecond,
375 }385 }
376 svc := NewService(runtime, envLoader, eventBus, nil, config)386 svc := NewService(runtime, envLoader, eventBus, nil, config)
377 ctx := testContext()387 ctx := testContext()
@@ -1107,6 +1117,7 @@ func TestService_Deploy_InternalDeployForcesPull(t *testing.T) {
1107 InternalRegistryUsername: "internal",1117 InternalRegistryUsername: "internal",
1108 InternalRegistryPassword: "secret",1118 InternalRegistryPassword: "secret",
1109 ReadinessDelay: time.Millisecond,1119 ReadinessDelay: time.Millisecond,
1120+ DrainDelay: time.Millisecond,
1110 }1121 }
1111 1122
1112 svc := NewService(runtime, envLoader, eventBus, nil, config)1123 svc := NewService(runtime, envLoader, eventBus, nil, config)
@@ -1175,6 +1186,7 @@ func TestService_AutoStart_StartsNewContainers(t *testing.T) {
1175 1186
1176 config := Config{1187 config := Config{
1177 ReadinessDelay: time.Millisecond,1188 ReadinessDelay: time.Millisecond,
1189+ DrainDelay: time.Millisecond,
1178 }1190 }
1179 svc := NewService(runtime, envLoader, eventBus, nil, config)1191 svc := NewService(runtime, envLoader, eventBus, nil, config)
1180 ctx := testContext()1192 ctx := testContext()
@@ -1218,6 +1230,7 @@ func TestService_AutoStart_SkipsExistingContainers(t *testing.T) {
1218 1230
1219 config := Config{1231 config := Config{
1220 ReadinessDelay: time.Millisecond,1232 ReadinessDelay: time.Millisecond,
1233+ DrainDelay: time.Millisecond,
1221 }1234 }
1222 svc := NewService(runtime, envLoader, eventBus, nil, config)1235 svc := NewService(runtime, envLoader, eventBus, nil, config)
1223 ctx := testContext()1236 ctx := testContext()
@@ -1288,6 +1301,7 @@ func TestService_AutoStart_UsesInternalDeployContext(t *testing.T) {
1288 RegistryDomain: "reg.example.com",1301 RegistryDomain: "reg.example.com",
1289 RegistryPort: 5000,1302 RegistryPort: 5000,
1290 ReadinessDelay: time.Millisecond,1303 ReadinessDelay: time.Millisecond,
1304+ DrainDelay: time.Millisecond,
1291 }1305 }
1292 svc := NewService(runtime, envLoader, eventBus, nil, config)1306 svc := NewService(runtime, envLoader, eventBus, nil, config)
1293 1307
@@ -1329,11 +1343,14 @@ func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) {
1329 runtime := mocks.NewMockContainerRuntime(t)1343 runtime := mocks.NewMockContainerRuntime(t)
1330 envLoader := mocks.NewMockEnvLoader(t)1344 envLoader := mocks.NewMockEnvLoader(t)
1331 eventBus := mocks.NewMockEventPublisher(t)1345 eventBus := mocks.NewMockEventPublisher(t)
1346+ cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
1332 1347
1333 config := Config{1348 config := Config{
1334 ReadinessDelay: time.Millisecond, // Minimal delay for tests1349 ReadinessDelay: time.Millisecond, // Minimal delay for tests
1350+ DrainDelay: time.Millisecond,
1335 }1351 }
1336 svc := NewService(runtime, envLoader, eventBus, nil, config)1352 svc := NewService(runtime, envLoader, eventBus, nil, config)
1353+ svc.SetProxyCacheInvalidator(cacheInvalidator)
1337 ctx := testContext()1354 ctx := testContext()
1338 1355
1339 // Pre-populate with existing tracked container1356 // Pre-populate with existing tracked container
@@ -1388,6 +1405,9 @@ func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) {
1388 // Publish event1405 // Publish event
1389 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)1406 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
1390 1407
1408+ // Synchronous cache invalidation
1409+ cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
1410+
1391 // NOW (after new container is ready) the old container should be stopped and removed1411 // NOW (after new container is ready) the old container should be stopped and removed
1392 // This is the correct zero-downtime sequence - not during orphan cleanup1412 // This is the correct zero-downtime sequence - not during orphan cleanup
1393 runtime.EXPECT().StopContainer(mock.Anything, "tracked-container-123").Return(nil)1413 runtime.EXPECT().StopContainer(mock.Anything, "tracked-container-123").Return(nil)
@@ -1416,6 +1436,7 @@ func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) {
1416 1436
1417 config := Config{1437 config := Config{
1418 ReadinessDelay: time.Millisecond, // Minimal delay for tests1438 ReadinessDelay: time.Millisecond, // Minimal delay for tests
1439+ DrainDelay: time.Millisecond,
1419 }1440 }
1420 svc := NewService(runtime, envLoader, eventBus, nil, config)1441 svc := NewService(runtime, envLoader, eventBus, nil, config)
1421 ctx := testContext()1442 ctx := testContext()
@@ -1485,11 +1506,14 @@ func TestService_Deploy_OrphanCleanupRemovesStaleNewContainer(t *testing.T) {
1485 runtime := mocks.NewMockContainerRuntime(t)1506 runtime := mocks.NewMockContainerRuntime(t)
1486 envLoader := mocks.NewMockEnvLoader(t)1507 envLoader := mocks.NewMockEnvLoader(t)
1487 eventBus := mocks.NewMockEventPublisher(t)1508 eventBus := mocks.NewMockEventPublisher(t)
1509+ cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
1488 1510
1489 config := Config{1511 config := Config{
1490 ReadinessDelay: time.Millisecond,1512 ReadinessDelay: time.Millisecond,
1513+ DrainDelay: time.Millisecond,
1491 }1514 }
1492 svc := NewService(runtime, envLoader, eventBus, nil, config)1515 svc := NewService(runtime, envLoader, eventBus, nil, config)
1516+ svc.SetProxyCacheInvalidator(cacheInvalidator)
1493 ctx := testContext()1517 ctx := testContext()
1494 1518
1495 // Tracked canonical container exists, but stale -new container should be removed.1519 // Tracked canonical container exists, but stale -new container should be removed.
@@ -1537,6 +1561,9 @@ func TestService_Deploy_OrphanCleanupRemovesStaleNewContainer(t *testing.T) {
1537 }, nil)1561 }, nil)
1538 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)1562 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
1539 1563
1564+ // Synchronous cache invalidation
1565+ cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
1566+
1540 runtime.EXPECT().StopContainer(mock.Anything, "tracked-container-123").Return(nil)1567 runtime.EXPECT().StopContainer(mock.Anything, "tracked-container-123").Return(nil)
1541 runtime.EXPECT().RemoveContainer(mock.Anything, "tracked-container-123", true).Return(nil)1568 runtime.EXPECT().RemoveContainer(mock.Anything, "tracked-container-123", true).Return(nil)
1542 runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)1569 runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
@@ -1551,11 +1578,14 @@ func TestService_Deploy_TrackedTempContainerUsesAlternateTempName(t *testing.T)
1551 runtime := mocks.NewMockContainerRuntime(t)1578 runtime := mocks.NewMockContainerRuntime(t)
1552 envLoader := mocks.NewMockEnvLoader(t)1579 envLoader := mocks.NewMockEnvLoader(t)
1553 eventBus := mocks.NewMockEventPublisher(t)1580 eventBus := mocks.NewMockEventPublisher(t)
1581+ cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
1554 1582
1555 config := Config{1583 config := Config{
1556 ReadinessDelay: time.Millisecond,1584 ReadinessDelay: time.Millisecond,
1585+ DrainDelay: time.Millisecond,
1557 }1586 }
1558 svc := NewService(runtime, envLoader, eventBus, nil, config)1587 svc := NewService(runtime, envLoader, eventBus, nil, config)
1588+ svc.SetProxyCacheInvalidator(cacheInvalidator)
1559 ctx := testContext()1589 ctx := testContext()
1560 1590
1561 // Simulate an interrupted zero-downtime deploy that left "-new" as the tracked container name.1591 // Simulate an interrupted zero-downtime deploy that left "-new" as the tracked container name.
@@ -1596,6 +1626,9 @@ func TestService_Deploy_TrackedTempContainerUsesAlternateTempName(t *testing.T)
1596 }, nil)1626 }, nil)
1597 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)1627 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
1598 1628
1629+ // Synchronous cache invalidation
1630+ cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return()
1631+
1599 runtime.EXPECT().StopContainer(mock.Anything, "tracked-temp-container").Return(nil)1632 runtime.EXPECT().StopContainer(mock.Anything, "tracked-temp-container").Return(nil)
1600 runtime.EXPECT().RemoveContainer(mock.Anything, "tracked-temp-container", true).Return(nil)1633 runtime.EXPECT().RemoveContainer(mock.Anything, "tracked-temp-container", true).Return(nil)
1601 runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)1634 runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
@@ -1734,11 +1767,14 @@ func TestService_Deploy_ConcurrentSameDomain(t *testing.T) {
1734 runtime := mocks.NewMockContainerRuntime(t)1767 runtime := mocks.NewMockContainerRuntime(t)
1735 envLoader := mocks.NewMockEnvLoader(t)1768 envLoader := mocks.NewMockEnvLoader(t)
1736 eventBus := mocks.NewMockEventPublisher(t)1769 eventBus := mocks.NewMockEventPublisher(t)
1770+ cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
1737 1771
1738 config := Config{1772 config := Config{
1739 ReadinessDelay: time.Millisecond,1773 ReadinessDelay: time.Millisecond,
1774+ DrainDelay: time.Millisecond,
1740 }1775 }
1741 svc := NewService(runtime, envLoader, eventBus, nil, config)1776 svc := NewService(runtime, envLoader, eventBus, nil, config)
1777+ svc.SetProxyCacheInvalidator(cacheInvalidator)
1742 ctx := testContext()1778 ctx := testContext()
1743 1779
1744 route := domain.Route{1780 route := domain.Route{
@@ -1800,7 +1836,9 @@ func TestService_Deploy_ConcurrentSameDomain(t *testing.T) {
1800 1836
1801 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.Anything).Return(nil).Times(2)1837 eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.Anything).Return(nil).Times(2)
1802 1838
1803- // The second deploy will see the first container and do zero-downtime swap,1839+ // Both deploys call InvalidateTarget (to ensure proxy picks up the new container).
1840+ cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return().Times(2)
1841+
1804 // which means it will also stop+remove+rename the old container.1842 // which means it will also stop+remove+rename the old container.
1805 runtime.EXPECT().StopContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Once()1843 runtime.EXPECT().StopContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Once()
1806 runtime.EXPECT().RemoveContainer(mock.Anything, mock.AnythingOfType("string"), true).Return(nil).Once()1844 runtime.EXPECT().RemoveContainer(mock.Anything, mock.AnythingOfType("string"), true).Return(nil).Once()
@@ -1840,6 +1878,7 @@ func TestService_Deploy_ContextCancellation(t *testing.T) {
1840 1878
1841 config := Config{1879 config := Config{
1842 ReadinessDelay: time.Millisecond,1880 ReadinessDelay: time.Millisecond,
1881+ DrainDelay: time.Millisecond,
1843 }1882 }
1844 svc := NewService(runtime, envLoader, eventBus, nil, config)1883 svc := NewService(runtime, envLoader, eventBus, nil, config)
1845 1884
@@ -1856,3 +1895,135 @@ func TestService_Deploy_ContextCancellation(t *testing.T) {
1856 assert.Error(t, err, "deploy should fail with cancelled context")1895 assert.Error(t, err, "deploy should fail with cancelled context")
1857 assert.ErrorIs(t, err, context.Canceled, "error should be context.Canceled")1896 assert.ErrorIs(t, err, context.Canceled, "error should be context.Canceled")
1858 }1897 }
1898+
1899+// TestService_Deploy_CacheInvalidationBeforeOldContainerStop verifies the fix for
1900+// the proxy cache race condition: InvalidateTarget must be called synchronously
1901+// BEFORE the old container is stopped, preventing 503 errors during deployment.
1902+func TestService_Deploy_CacheInvalidationBeforeOldContainerStop(t *testing.T) {
1903+ runtime := mocks.NewMockContainerRuntime(t)
1904+ envLoader := mocks.NewMockEnvLoader(t)
1905+ eventBus := mocks.NewMockEventPublisher(t)
1906+ cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
1907+
1908+ config := Config{
1909+ ReadinessDelay: time.Millisecond,
1910+ DrainDelay: time.Millisecond,
1911+ }
1912+ svc := NewService(runtime, envLoader, eventBus, nil, config)
1913+ svc.SetProxyCacheInvalidator(cacheInvalidator)
1914+ ctx := testContext()
1915+
1916+ // Pre-populate with existing container
1917+ existingContainer := &domain.Container{
1918+ ID: "old-container",
1919+ Name: "gordon-test.example.com",
1920+ Status: "running",
1921+ }
1922+ svc.containers["test.example.com"] = existingContainer
1923+
1924+ route := domain.Route{
1925+ Domain: "test.example.com",
1926+ Image: "myapp:v2",
1927+ }
1928+
1929+ // Track ordering of cache invalidation and container stop
1930+ var callOrder []string
1931+ var orderMu sync.Mutex
1932+
1933+ // Standard deploy mocks
1934+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
1935+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:v2"}, nil)
1936+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
1937+ envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
1938+ runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
1939+
1940+ newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
1941+ runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil)
1942+ runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
1943+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
1944+ runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
1945+ ID: "new-container", Status: "running",
1946+ }, nil)
1947+ eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
1948+
1949+ // Track: cache invalidation should happen first
1950+ cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").
1951+ Run(func(_ context.Context, _ string) {
1952+ orderMu.Lock()
1953+ callOrder = append(callOrder, "invalidate_cache")
1954+ orderMu.Unlock()
1955+ }).Return()
1956+
1957+ // Track: stop should happen after invalidation
1958+ runtime.EXPECT().StopContainer(mock.Anything, "old-container").
1959+ RunAndReturn(func(_ context.Context, _ string) error {
1960+ orderMu.Lock()
1961+ callOrder = append(callOrder, "stop_old_container")
1962+ orderMu.Unlock()
1963+ return nil
1964+ })
1965+ runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil)
1966+ runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
1967+
1968+ result, err := svc.Deploy(ctx, route)
1969+
1970+ assert.NoError(t, err)
1971+ assert.Equal(t, "new-container", result.ID)
1972+
1973+ // Verify ordering: cache invalidation MUST happen before old container stop
1974+ orderMu.Lock()
1975+ defer orderMu.Unlock()
1976+ assert.Equal(t, []string{"invalidate_cache", "stop_old_container"}, callOrder,
1977+ "InvalidateTarget must be called before StopContainer to prevent 503 errors")
1978+}
1979+
1980+// TestService_Deploy_NilCacheInvalidator verifies that deploy works gracefully
1981+// when no cache invalidator is set (e.g., in tests or minimal configurations).
1982+func TestService_Deploy_NilCacheInvalidator(t *testing.T) {
1983+ runtime := mocks.NewMockContainerRuntime(t)
1984+ envLoader := mocks.NewMockEnvLoader(t)
1985+ eventBus := mocks.NewMockEventPublisher(t)
1986+
1987+ config := Config{
1988+ ReadinessDelay: time.Millisecond,
1989+ DrainDelay: time.Millisecond,
1990+ }
1991+ // Intentionally NOT setting cache invalidator
1992+ svc := NewService(runtime, envLoader, eventBus, nil, config)
1993+ ctx := testContext()
1994+
1995+ // Pre-populate with existing container
1996+ svc.containers["test.example.com"] = &domain.Container{
1997+ ID: "old-container",
1998+ Name: "gordon-test.example.com",
1999+ Status: "running",
2000+ }
2001+
2002+ route := domain.Route{
2003+ Domain: "test.example.com",
2004+ Image: "myapp:v2",
2005+ }
2006+
2007+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
2008+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:v2"}, nil)
2009+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil)
2010+ envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2011+ runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
2012+
2013+ newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
2014+ runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil)
2015+ runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
2016+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
2017+ runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
2018+ ID: "new-container", Status: "running",
2019+ }, nil)
2020+ eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
2021+ runtime.EXPECT().StopContainer(mock.Anything, "old-container").Return(nil)
2022+ runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil)
2023+ runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
2024+
2025+ result, err := svc.Deploy(ctx, route)
2026+
2027+ assert.NoError(t, err)
2028+ assert.Equal(t, "new-container", result.ID)
2029+}
M internal/usecase/proxy/service.go +56 -0
@@ -56,6 +56,8 @@ type Service struct {
5656 targets map[string]*domain.ProxyTarget
5757 mu sync.RWMutex
5858 activeConns atomic.Int64 // Atomic counter for concurrent connection limiting
59+ inFlight map[string]int
60+ inFlightMu sync.Mutex
5961 }
6062
6163 // NewService creates a new proxy service.
@@ -71,6 +73,7 @@ func NewService(
7173 configSvc: configSvc,
7274 config: config,
7375 targets: make(map[string]*domain.ProxyTarget),
76+ inFlight: make(map[string]int),
7477 }
7578 }
7679
@@ -304,6 +307,37 @@ func (s *Service) InvalidateTarget(_ context.Context, domainName string) {
304307 delete(s.targets, domainName)
305308 }
306309
310+// WaitForNoInFlight waits until no requests are currently proxied to the
311+// given container, or until timeout/context cancellation.
312+func (s *Service) WaitForNoInFlight(ctx context.Context, containerID string, timeout time.Duration) bool {
313+ if containerID == "" {
314+ return true
315+ }
316+ if timeout <= 0 {
317+ timeout = 30 * time.Second
318+ }
319+
320+ deadline := time.Now().Add(timeout)
321+ for {
322+ s.inFlightMu.Lock()
323+ count := s.inFlight[containerID]
324+ s.inFlightMu.Unlock()
325+ if count <= 0 {
326+ return true
327+ }
328+
329+ if time.Now().After(deadline) {
330+ return false
331+ }
332+
333+ select {
334+ case <-time.After(100 * time.Millisecond):
335+ case <-ctx.Done():
336+ return false
337+ }
338+ }
339+}
340+
307341 // RefreshTargets refreshes all proxy targets from container state.
308342 func (s *Service) RefreshTargets(ctx context.Context) error {
309343 s.mu.Lock()
@@ -459,6 +493,8 @@ func (s *Service) proxyToTarget(w http.ResponseWriter, r *http.Request, target *
459493 // If DNS pinning resolved hostname to IP, preserve the original hostname
460494 // for the Host header so virtual-hosted upstreams work correctly.
461495 originalHost := target.OriginalHost
496+ releaseInFlight := s.trackInFlight(target.ContainerID)
497+ defer releaseInFlight()
462498
463499 proxy := newReverseProxy(targetURL, originalHost,
464500 func(w http.ResponseWriter, _ *http.Request, err error) {
@@ -482,6 +518,26 @@ func (s *Service) proxyToTarget(w http.ResponseWriter, r *http.Request, target *
482518 proxy.ServeHTTP(w, r)
483519 }
484520
521+func (s *Service) trackInFlight(containerID string) func() {
522+ if containerID == "" {
523+ return func() {}
524+ }
525+
526+ s.inFlightMu.Lock()
527+ s.inFlight[containerID]++
528+ s.inFlightMu.Unlock()
529+
530+ return func() {
531+ s.inFlightMu.Lock()
532+ if s.inFlight[containerID] > 1 {
533+ s.inFlight[containerID]--
534+ } else {
535+ delete(s.inFlight, containerID)
536+ }
537+ s.inFlightMu.Unlock()
538+ }
539+}
540+
485541 // proxyToRegistry forwards requests to the local registry HTTP server.
486542 // SECURITY: This uses http://localhost:{port} which is safe because the registry
487543 // runs on the same host and traffic never leaves the loopback interface. If Gordon
@@ -56,6 +56,8 @@ type Service struct {
56 targets map[string]*domain.ProxyTarget56 targets map[string]*domain.ProxyTarget
57 mu sync.RWMutex57 mu sync.RWMutex
58 activeConns atomic.Int64 // Atomic counter for concurrent connection limiting58 activeConns atomic.Int64 // Atomic counter for concurrent connection limiting
59+ inFlight map[string]int
60+ inFlightMu sync.Mutex
59 }61 }
60 62
61 // NewService creates a new proxy service.63 // NewService creates a new proxy service.
@@ -71,6 +73,7 @@ func NewService(
71 configSvc: configSvc,73 configSvc: configSvc,
72 config: config,74 config: config,
73 targets: make(map[string]*domain.ProxyTarget),75 targets: make(map[string]*domain.ProxyTarget),
76+ inFlight: make(map[string]int),
74 }77 }
75 }78 }
76 79
@@ -304,6 +307,37 @@ func (s *Service) InvalidateTarget(_ context.Context, domainName string) {
304 delete(s.targets, domainName)307 delete(s.targets, domainName)
305 }308 }
306 309
310+// WaitForNoInFlight waits until no requests are currently proxied to the
311+// given container, or until timeout/context cancellation.
312+func (s *Service) WaitForNoInFlight(ctx context.Context, containerID string, timeout time.Duration) bool {
313+ if containerID == "" {
314+ return true
315+ }
316+ if timeout <= 0 {
317+ timeout = 30 * time.Second
318+ }
319+
320+ deadline := time.Now().Add(timeout)
321+ for {
322+ s.inFlightMu.Lock()
323+ count := s.inFlight[containerID]
324+ s.inFlightMu.Unlock()
325+ if count <= 0 {
326+ return true
327+ }
328+
329+ if time.Now().After(deadline) {
330+ return false
331+ }
332+
333+ select {
334+ case <-time.After(100 * time.Millisecond):
335+ case <-ctx.Done():
336+ return false
337+ }
338+ }
339+}
340+
307 // RefreshTargets refreshes all proxy targets from container state.341 // RefreshTargets refreshes all proxy targets from container state.
308 func (s *Service) RefreshTargets(ctx context.Context) error {342 func (s *Service) RefreshTargets(ctx context.Context) error {
309 s.mu.Lock()343 s.mu.Lock()
@@ -459,6 +493,8 @@ func (s *Service) proxyToTarget(w http.ResponseWriter, r *http.Request, target *
459 // If DNS pinning resolved hostname to IP, preserve the original hostname493 // If DNS pinning resolved hostname to IP, preserve the original hostname
460 // for the Host header so virtual-hosted upstreams work correctly.494 // for the Host header so virtual-hosted upstreams work correctly.
461 originalHost := target.OriginalHost495 originalHost := target.OriginalHost
496+ releaseInFlight := s.trackInFlight(target.ContainerID)
497+ defer releaseInFlight()
462 498
463 proxy := newReverseProxy(targetURL, originalHost,499 proxy := newReverseProxy(targetURL, originalHost,
464 func(w http.ResponseWriter, _ *http.Request, err error) {500 func(w http.ResponseWriter, _ *http.Request, err error) {
@@ -482,6 +518,26 @@ func (s *Service) proxyToTarget(w http.ResponseWriter, r *http.Request, target *
482 proxy.ServeHTTP(w, r)518 proxy.ServeHTTP(w, r)
483 }519 }
484 520
521+func (s *Service) trackInFlight(containerID string) func() {
522+ if containerID == "" {
523+ return func() {}
524+ }
525+
526+ s.inFlightMu.Lock()
527+ s.inFlight[containerID]++
528+ s.inFlightMu.Unlock()
529+
530+ return func() {
531+ s.inFlightMu.Lock()
532+ if s.inFlight[containerID] > 1 {
533+ s.inFlight[containerID]--
534+ } else {
535+ delete(s.inFlight, containerID)
536+ }
537+ s.inFlightMu.Unlock()
538+ }
539+}
540+
485 // proxyToRegistry forwards requests to the local registry HTTP server.541 // proxyToRegistry forwards requests to the local registry HTTP server.
486 // SECURITY: This uses http://localhost:{port} which is safe because the registry542 // SECURITY: This uses http://localhost:{port} which is safe because the registry
487 // runs on the same host and traffic never leaves the loopback interface. If Gordon543 // runs on the same host and traffic never leaves the loopback interface. If Gordon

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