Merge pull request #66 from bnema/fix/proxy-cache-race-condition-clean
fix(deploy): use signal-based readiness and drain for zero-downtime swaps
12 files · +780 -59
M docs/config/deploy.md +70 -0
| @@ -7,7 +7,12 @@ Controls how Gordon pulls images during deployment. | |||
| 7 | 7 | ```toml | |
| 8 | 8 | [deploy] | |
| 9 | 9 | pull_policy = "if-tag-changed" | |
| 10 | + | readiness_mode = "auto" | |
| 11 | + | health_timeout = "90s" | |
| 10 | 12 | readiness_delay = "5s" | |
| 13 | + | drain_mode = "auto" | |
| 14 | + | drain_timeout = "30s" | |
| 15 | + | drain_delay = "2s" | |
| 11 | 16 | ``` | |
| 12 | 17 | ||
| 13 | 18 | ## Settings | |
| @@ -33,7 +38,72 @@ considered ready for traffic. | |||
| 33 | 38 | ||
| 34 | 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 | 101 | ## Notes | |
| 37 | 102 | ||
| 38 | 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 | 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. | |
| @@ -7,7 +7,12 @@ Controls how Gordon pulls images during deployment. | |||||
| 7 | ```toml | 7 | ```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 | ## Settings | 18 | ## 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 | ## Notes | 101 | ## 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 | |||
| 56 | 56 | # Deploy behavior | |
| 57 | 57 | [deploy] | |
| 58 | 58 | 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 | 66 | # Logging | |
| 61 | 67 | [logging] | |
| @@ -157,7 +163,12 @@ monthly = 12 | |||
| 157 | 163 | | `api.rate_limit.burst` | `100` | | |
| 158 | 164 | | `api.rate_limit.trusted_proxies` | `[]` | | |
| 159 | 165 | | `deploy.pull_policy` | `"if-tag-changed"` | | |
| 166 | + | | `deploy.readiness_mode` | `"auto"` | | |
| 167 | + | | `deploy.health_timeout` | `"90s"` | | |
| 160 | 168 | | `deploy.readiness_delay` | `"5s"` | | |
| 169 | + | | `deploy.drain_mode` | `"auto"` | | |
| 170 | + | | `deploy.drain_timeout` | `"30s"` | | |
| 171 | + | | `deploy.drain_delay` | `"2s"` | | |
| 161 | 172 | | `logging.level` | `"info"` | | |
| 162 | 173 | | `logging.format` | `"console"` | | |
| 163 | 174 | | `logging.file.enabled` | `false` | | |
| @@ -187,7 +198,12 @@ The following settings require a restart to take effect: | |||
| 187 | 198 | - `server.data_dir` | |
| 188 | 199 | - `auth` settings | |
| 189 | 200 | - `deploy.pull_policy` | |
| 201 | + | - `deploy.readiness_mode` | |
| 202 | + | - `deploy.health_timeout` | |
| 190 | 203 | - `deploy.readiness_delay` | |
| 204 | + | - `deploy.drain_mode` | |
| 205 | + | - `deploy.drain_timeout` | |
| 206 | + | - `deploy.drain_delay` | |
| 191 | 207 | ||
| 192 | 208 | ## Environment Variable Override | |
| 193 | 209 | ||
| @@ -56,6 +56,12 @@ trusted_proxies = [] # IPs/CIDRs trusted for X-Forwarded-For | |||||
| 56 | # Deploy behavior | 56 | # Deploy behavior | ||
| 57 | [deploy] | 57 | [deploy] | ||
| 58 | pull_policy = "if-tag-changed" # always, if-not-present, if-tag-changed | 58 | 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 | # Logging | 66 | # 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` settings | 199 | - `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 Override | 208 | ## Environment Variable Override | ||
| 193 | 209 | ||||
M docs/config/reference.md +11 -0
| @@ -74,6 +74,12 @@ dir = "" # Env files directory (default: {da | |||
| 74 | 74 | # ============================================================================= | |
| 75 | 75 | [deploy] | |
| 76 | 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 | 85 | # AUTO-ROUTE | |
| @@ -172,7 +178,12 @@ monthly = 0 # Keep N monthly backups per DB | |||
| 172 | 178 | | `logging.container_logs.max_backups` | `3` | Keep 3 old files | | |
| 173 | 179 | | `logging.container_logs.max_age` | `28` | 28 days | | |
| 174 | 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 | 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 | 187 | | `auto_route.enabled` | `false` | Auto-route disabled | | |
| 177 | 188 | | `network_isolation.enabled` | `false` | Isolation disabled | | |
| 178 | 189 | | `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-ROUTE | 85 | # 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 | |||
| 596 | 596 | 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 | 620 | // GetContainerPort gets the host port for a container's internal port. | |
| 600 | 621 | func (r *Runtime) GetContainerPort(ctx context.Context, containerID string, internalPort int) (int, error) { | |
| 601 | 622 | 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), nil | 596 | 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 | |||
| 460 | 460 | svc.maxBlobChunkSize = proxyCfg.maxBlobChunkSize | |
| 461 | 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 | 468 | // Create token handler for registry token endpoint | |
| 464 | 469 | if svc.authSvc != nil { | |
| 465 | 470 | internalAuth := authhandler.InternalAuth{ | |
| @@ -1055,6 +1060,15 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc | |||
| 1055 | 1060 | NetworkGroups: svc.configSvc.GetNetworkGroups(), | |
| 1056 | 1061 | Attachments: svc.configSvc.GetAttachments(), | |
| 1057 | 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 | 1074 | if cfg.Auth.Enabled && svc.authSvc != nil { | |
| @@ -1166,12 +1180,6 @@ func registerEventHandlers(ctx context.Context, svc *services, cfg Config) error | |||
| 1166 | 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 | 1183 | // Proxy cache invalidation on config reload (clears stale targets for removed routes) | |
| 1176 | 1184 | configReloadProxyHandler := proxy.NewConfigReloadProxyHandler(ctx, svc.proxySvc) | |
| 1177 | 1185 | if err := svc.eventBus.Subscribe(configReloadProxyHandler); err != nil { | |
| @@ -1856,6 +1864,10 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||
| 1856 | 1864 | v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit | |
| 1857 | 1865 | v.SetDefault("server.registry_allowed_ips", []string{}) | |
| 1858 | 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 | 1872 | ConfigureViper(v, configPath) | |
| 1861 | 1873 | ||
| @@ -460,6 +460,11 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||||
| 460 | svc.maxBlobChunkSize = proxyCfg.maxBlobChunkSize | 460 | 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 endpoint | 468 | // 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 limit | 1864 | 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 | |||
| 570 | 570 | 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 | 645 | // GetContainerLogs provides a mock function for the type MockContainerRuntime | |
| 574 | 646 | func (_mock *MockContainerRuntime) GetContainerLogs(ctx context.Context, containerID string, follow bool) (io.ReadCloser, error) { | |
| 575 | 647 | ret := _mock.Called(ctx, containerID, follow) | |
| @@ -570,6 +570,78 @@ func (_c *MockContainerRuntime_GetContainerExposedPorts_Call) RunAndReturn(run f | |||||
| 570 | return _c | 570 | 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 MockContainerRuntime | 645 | // 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 { | |||
| 40 | 40 | ||
| 41 | 41 | // Health and status | |
| 42 | 42 | IsContainerRunning(ctx context.Context, containerID string) (bool, error) | |
| 43 | + | GetContainerHealthStatus(ctx context.Context, containerID string) (status string, hasHealthcheck bool, err error) | |
| 43 | 44 | GetContainerPort(ctx context.Context, containerID string, internalPort int) (int, error) | |
| 44 | 45 | ||
| 45 | 46 | // Image and port inspection | |
| @@ -40,6 +40,7 @@ type ContainerRuntime interface { | |||||
| 40 | 40 | ||||
| 41 | // Health and status | 41 | // 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 inspection | 46 | // Image and port inspection | ||
M internal/usecase/container/service.go +243 -51
| @@ -36,6 +36,12 @@ type Config struct { | |||
| 36 | 36 | NetworkGroups map[string][]string | |
| 37 | 37 | Attachments map[string][]string | |
| 38 | 38 | 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 | 47 | const ( | |
| @@ -52,15 +58,17 @@ const ( | |||
| 52 | 58 | ||
| 53 | 59 | // Service implements the ContainerService interface. | |
| 54 | 60 | 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) | |
| 64 | 72 | } | |
| 65 | 73 | ||
| 66 | 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 | 155 | // domainDeployMu returns a per-domain lock for serializing deploy operations. | |
| 130 | 156 | func (s *Service) domainDeployMu(domain string) *domainDeployLock { | |
| 131 | 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 | 232 | log := zerowrap.FromCtx(ctx) | |
| 207 | 233 | ||
| 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) { | |
| 209 | 272 | s.mu.RLock() | |
| 210 | - | existing, hasExisting := s.containers[route.Domain] | |
| 273 | + | container, ok := s.containers[domainName] | |
| 211 | 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 | 281 | existingID := "" | |
| 216 | - | if hasExisting { | |
| 282 | + | if existing != nil { | |
| 217 | 283 | existingID = existing.ID | |
| 218 | 284 | } | |
| 219 | 285 | if err := s.cleanupOrphanedContainers(ctx, route.Domain, existingID); err != nil { | |
| 220 | 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 | 289 | imageRef := s.buildImageRef(route.Image) | |
| 226 | 290 | actualImageRef, err := s.ensureImage(ctx, imageRef) | |
| 227 | 291 | if err != nil { | |
| 228 | 292 | return nil, err | |
| 229 | 293 | } | |
| 230 | 294 | ||
| 231 | - | // Setup network FIRST (attachments need it) | |
| 232 | 295 | networkName := s.getNetworkForApp(route.Domain) | |
| 233 | 296 | if err := s.createNetworkIfNeeded(ctx, networkName); err != nil { | |
| 234 | 297 | return nil, log.WrapErr(err, "failed to create network") | |
| 235 | 298 | } | |
| 236 | - | ||
| 237 | - | // Deploy attachments BEFORE main container (dependencies first) | |
| 238 | 299 | if err := s.deployAttachments(ctx, route.Domain, networkName); err != nil { | |
| 239 | 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 | 303 | exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, actualImageRef) | |
| 244 | 304 | if err != nil { | |
| 245 | 305 | log.WrapErr(err, "failed to get exposed ports, using defaults") | |
| 246 | 306 | exposedPorts = []int{80, 8080, 3000} | |
| 247 | 307 | } | |
| 248 | 308 | ||
| 249 | - | // Load environment | |
| 250 | 309 | envVars, err := s.loadEnvironment(ctx, route.Domain, actualImageRef) | |
| 251 | 310 | if err != nil { | |
| 252 | 311 | return nil, err | |
| 253 | 312 | } | |
| 254 | 313 | ||
| 255 | - | // Setup volumes (use actual image ref from pull) | |
| 256 | 314 | volumes, err := s.setupVolumes(ctx, route.Domain, actualImageRef) | |
| 257 | 315 | if err != nil { | |
| 258 | 316 | return nil, err | |
| 259 | 317 | } | |
| 260 | 318 | ||
| 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 | + | ) | |
| 263 | 341 | ||
| 264 | 342 | newContainer, err := s.runtime.CreateContainer(ctx, containerConfig) | |
| 265 | 343 | if err != nil { | |
| 266 | 344 | return nil, log.WrapErr(err, "failed to create container") | |
| 267 | 345 | } | |
| 268 | - | ||
| 269 | - | // Start new container | |
| 270 | 346 | if err := s.runtime.StartContainer(ctx, newContainer.ID); err != nil { | |
| 271 | 347 | s.runtime.RemoveContainer(ctx, newContainer.ID, true) | |
| 272 | 348 | return nil, log.WrapErr(err, "failed to start container") | |
| 273 | 349 | } | |
| 274 | - | ||
| 275 | - | // Wait for new container to be ready | |
| 276 | 350 | if err := s.waitForReady(ctx, newContainer.ID); err != nil { | |
| 277 | 351 | s.cleanupFailedContainer(ctx, newContainer.ID) | |
| 278 | 352 | return nil, log.WrapErr(err, "container failed readiness check") | |
| 279 | 353 | } | |
| 280 | 354 | ||
| 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) | |
| 283 | 356 | if err != nil { | |
| 357 | + | s.cleanupFailedContainer(ctx, newContainer.ID) | |
| 284 | 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 | 365 | s.mu.Lock() | |
| 289 | - | s.containers[route.Domain] = newContainer | |
| 366 | + | s.containers[domainName] = container | |
| 290 | 367 | s.mu.Unlock() | |
| 291 | 368 | ||
| 292 | - | // Publish container deployed event (proxy will invalidate cache) | |
| 293 | - | s.publishContainerDeployed(ctx, route.Domain, newContainer.ID) | |
| 369 | + | s.publishContainerDeployed(ctx, domainName, container.ID) | |
| 294 | 370 | ||
| 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 | |
| 298 | 377 | } | |
| 299 | 378 | ||
| 300 | - | // Start container log collection (non-blocking, errors don't fail deployment) | |
| 301 | - | s.startLogCollection(ctx, newContainer.ID, route.Domain) | |
| 379 | + | return false | |
| 380 | + | } | |
| 302 | 381 | ||
| 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) | |
| 310 | 387 | ||
| 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 | + | } | |
| 312 | 440 | } | |
| 313 | 441 | ||
| 314 | 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 | 1892 | 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 | 1895 | func (s *Service) waitForReady(ctx context.Context, containerID string) error { | |
| 1770 | - | log := zerowrap.FromCtx(ctx) | |
| 1771 | - | ||
| 1772 | 1896 | if err := s.pollContainerRunning(ctx, containerID); err != nil { | |
| 1773 | 1897 | return err | |
| 1774 | 1898 | } | |
| @@ -1777,6 +1901,43 @@ func (s *Service) waitForReady(ctx context.Context, containerID string) error { | |||
| 1777 | 1901 | cfg := s.config | |
| 1778 | 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 | 1941 | delay := cfg.ReadinessDelay | |
| 1781 | 1942 | if delay == 0 { | |
| 1782 | 1943 | delay = 5 * time.Second | |
| @@ -1801,6 +1962,37 @@ func (s *Service) waitForReady(ctx context.Context, containerID string) error { | |||
| 1801 | 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 | 1996 | // pollContainerRunning polls until the container is running (max 30 seconds). | |
| 1805 | 1997 | func (s *Service) pollContainerRunning(ctx context.Context, containerID string) error { | |
| 1806 | 1998 | for i := 0; i < 30; i++ { | |
| @@ -36,6 +36,12 @@ type Config struct { | |||||
| 36 | NetworkGroups map[string][]string | 36 | NetworkGroups map[string][]string | ||
| 37 | Attachments map[string][]string | 37 | Attachments map[string][]string | ||
| 38 | ReadinessDelay time.Duration // Delay after container starts before considering it ready | 38 | 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.ContainerRuntime | 61 | + | runtime out.ContainerRuntime |
| 56 | - | envLoader out.EnvLoader | 62 | + | envLoader out.EnvLoader |
| 57 | - | eventBus out.EventPublisher | 63 | + | eventBus out.EventPublisher |
| 58 | - | logWriter out.ContainerLogWriter | 64 | + | logWriter out.ContainerLogWriter |
| 59 | - | config Config | 65 | + | cacheInvalidator out.ProxyCacheInvalidator |
| 60 | - | containers map[string]*domain.Container | 66 | + | drainWaiter out.ProxyDrainWaiter |
| 61 | - | attachments map[string][]string // ownerDomain → []containerIDs | 67 | + | config Config |
| 62 | - | mu sync.RWMutex | 68 | + | 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 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) { | |||
| 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.ID | 283 | 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, err | 292 | 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, err | 311 | 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, err | 316 | return nil, err | ||
| 259 | } | 317 | } | ||
| 260 | 318 | ||||
| 261 | - | // Build container configuration | 319 | + | 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] = newContainer | 366 | + | 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, 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 | + | } | |||
| 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 nil | 1892 | 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 err | 1897 | return err | ||
| 1774 | } | 1898 | } | ||
| @@ -1777,6 +1901,43 @@ func (s *Service) waitForReady(ctx context.Context, containerID string) error { | |||||
| 1777 | cfg := s.config | 1901 | 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.ReadinessDelay | 1941 | delay := cfg.ReadinessDelay | ||
| 1781 | if delay == 0 { | 1942 | if delay == 0 { | ||
| 1782 | delay = 5 * time.Second | 1943 | 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) { | |||
| 29 | 29 | NetworkIsolation: false, | |
| 30 | 30 | VolumeAutoCreate: false, | |
| 31 | 31 | ReadinessDelay: time.Millisecond, // Minimal delay for tests | |
| 32 | + | DrainDelay: time.Millisecond, | |
| 32 | 33 | } | |
| 33 | 34 | ||
| 34 | 35 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| @@ -105,6 +106,7 @@ func TestService_Deploy_ReadinessRecoveryWindow_AllowsTransientFlap(t *testing.T | |||
| 105 | 106 | NetworkIsolation: false, | |
| 106 | 107 | VolumeAutoCreate: false, | |
| 107 | 108 | ReadinessDelay: time.Millisecond, | |
| 109 | + | DrainDelay: time.Millisecond, | |
| 108 | 110 | } | |
| 109 | 111 | ||
| 110 | 112 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| @@ -243,11 +245,14 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) { | |||
| 243 | 245 | runtime := mocks.NewMockContainerRuntime(t) | |
| 244 | 246 | envLoader := mocks.NewMockEnvLoader(t) | |
| 245 | 247 | eventBus := mocks.NewMockEventPublisher(t) | |
| 248 | + | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | |
| 246 | 249 | ||
| 247 | 250 | config := Config{ | |
| 248 | 251 | ReadinessDelay: time.Millisecond, // Minimal delay for tests | |
| 252 | + | DrainDelay: time.Millisecond, | |
| 249 | 253 | } | |
| 250 | 254 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 255 | + | svc.SetProxyCacheInvalidator(cacheInvalidator) | |
| 251 | 256 | ctx := testContext() | |
| 252 | 257 | ||
| 253 | 258 | // Pre-populate with existing container | |
| @@ -294,7 +299,10 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) { | |||
| 294 | 299 | // Publish event | |
| 295 | 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 | 306 | runtime.EXPECT().StopContainer(mock.Anything, "old-container").Return(nil) | |
| 299 | 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 | 329 | NetworkIsolation: true, | |
| 322 | 330 | NetworkPrefix: "gordon", | |
| 323 | 331 | ReadinessDelay: time.Millisecond, // Minimal delay for tests | |
| 332 | + | DrainDelay: time.Millisecond, | |
| 324 | 333 | } | |
| 325 | 334 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 326 | 335 | ctx := testContext() | |
| @@ -372,6 +381,7 @@ func TestService_Deploy_WithVolumeAutoCreate(t *testing.T) { | |||
| 372 | 381 | VolumeAutoCreate: true, | |
| 373 | 382 | VolumePrefix: "gordon", | |
| 374 | 383 | ReadinessDelay: time.Millisecond, // Minimal delay for tests | |
| 384 | + | DrainDelay: time.Millisecond, | |
| 375 | 385 | } | |
| 376 | 386 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 377 | 387 | ctx := testContext() | |
| @@ -1107,6 +1117,7 @@ func TestService_Deploy_InternalDeployForcesPull(t *testing.T) { | |||
| 1107 | 1117 | InternalRegistryUsername: "internal", | |
| 1108 | 1118 | InternalRegistryPassword: "secret", | |
| 1109 | 1119 | ReadinessDelay: time.Millisecond, | |
| 1120 | + | DrainDelay: time.Millisecond, | |
| 1110 | 1121 | } | |
| 1111 | 1122 | ||
| 1112 | 1123 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| @@ -1175,6 +1186,7 @@ func TestService_AutoStart_StartsNewContainers(t *testing.T) { | |||
| 1175 | 1186 | ||
| 1176 | 1187 | config := Config{ | |
| 1177 | 1188 | ReadinessDelay: time.Millisecond, | |
| 1189 | + | DrainDelay: time.Millisecond, | |
| 1178 | 1190 | } | |
| 1179 | 1191 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 1180 | 1192 | ctx := testContext() | |
| @@ -1218,6 +1230,7 @@ func TestService_AutoStart_SkipsExistingContainers(t *testing.T) { | |||
| 1218 | 1230 | ||
| 1219 | 1231 | config := Config{ | |
| 1220 | 1232 | ReadinessDelay: time.Millisecond, | |
| 1233 | + | DrainDelay: time.Millisecond, | |
| 1221 | 1234 | } | |
| 1222 | 1235 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 1223 | 1236 | ctx := testContext() | |
| @@ -1288,6 +1301,7 @@ func TestService_AutoStart_UsesInternalDeployContext(t *testing.T) { | |||
| 1288 | 1301 | RegistryDomain: "reg.example.com", | |
| 1289 | 1302 | RegistryPort: 5000, | |
| 1290 | 1303 | ReadinessDelay: time.Millisecond, | |
| 1304 | + | DrainDelay: time.Millisecond, | |
| 1291 | 1305 | } | |
| 1292 | 1306 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 1293 | 1307 | ||
| @@ -1329,11 +1343,14 @@ func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) { | |||
| 1329 | 1343 | runtime := mocks.NewMockContainerRuntime(t) | |
| 1330 | 1344 | envLoader := mocks.NewMockEnvLoader(t) | |
| 1331 | 1345 | eventBus := mocks.NewMockEventPublisher(t) | |
| 1346 | + | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | |
| 1332 | 1347 | ||
| 1333 | 1348 | config := Config{ | |
| 1334 | 1349 | ReadinessDelay: time.Millisecond, // Minimal delay for tests | |
| 1350 | + | DrainDelay: time.Millisecond, | |
| 1335 | 1351 | } | |
| 1336 | 1352 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 1353 | + | svc.SetProxyCacheInvalidator(cacheInvalidator) | |
| 1337 | 1354 | ctx := testContext() | |
| 1338 | 1355 | ||
| 1339 | 1356 | // Pre-populate with existing tracked container | |
| @@ -1388,6 +1405,9 @@ func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) { | |||
| 1388 | 1405 | // Publish event | |
| 1389 | 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 | 1411 | // NOW (after new container is ready) the old container should be stopped and removed | |
| 1392 | 1412 | // This is the correct zero-downtime sequence - not during orphan cleanup | |
| 1393 | 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 | 1437 | config := Config{ | |
| 1418 | 1438 | ReadinessDelay: time.Millisecond, // Minimal delay for tests | |
| 1439 | + | DrainDelay: time.Millisecond, | |
| 1419 | 1440 | } | |
| 1420 | 1441 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 1421 | 1442 | ctx := testContext() | |
| @@ -1485,11 +1506,14 @@ func TestService_Deploy_OrphanCleanupRemovesStaleNewContainer(t *testing.T) { | |||
| 1485 | 1506 | runtime := mocks.NewMockContainerRuntime(t) | |
| 1486 | 1507 | envLoader := mocks.NewMockEnvLoader(t) | |
| 1487 | 1508 | eventBus := mocks.NewMockEventPublisher(t) | |
| 1509 | + | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | |
| 1488 | 1510 | ||
| 1489 | 1511 | config := Config{ | |
| 1490 | 1512 | ReadinessDelay: time.Millisecond, | |
| 1513 | + | DrainDelay: time.Millisecond, | |
| 1491 | 1514 | } | |
| 1492 | 1515 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 1516 | + | svc.SetProxyCacheInvalidator(cacheInvalidator) | |
| 1493 | 1517 | ctx := testContext() | |
| 1494 | 1518 | ||
| 1495 | 1519 | // Tracked canonical container exists, but stale -new container should be removed. | |
| @@ -1537,6 +1561,9 @@ func TestService_Deploy_OrphanCleanupRemovesStaleNewContainer(t *testing.T) { | |||
| 1537 | 1561 | }, nil) | |
| 1538 | 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 | 1567 | runtime.EXPECT().StopContainer(mock.Anything, "tracked-container-123").Return(nil) | |
| 1541 | 1568 | runtime.EXPECT().RemoveContainer(mock.Anything, "tracked-container-123", true).Return(nil) | |
| 1542 | 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 | 1578 | runtime := mocks.NewMockContainerRuntime(t) | |
| 1552 | 1579 | envLoader := mocks.NewMockEnvLoader(t) | |
| 1553 | 1580 | eventBus := mocks.NewMockEventPublisher(t) | |
| 1581 | + | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | |
| 1554 | 1582 | ||
| 1555 | 1583 | config := Config{ | |
| 1556 | 1584 | ReadinessDelay: time.Millisecond, | |
| 1585 | + | DrainDelay: time.Millisecond, | |
| 1557 | 1586 | } | |
| 1558 | 1587 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 1588 | + | svc.SetProxyCacheInvalidator(cacheInvalidator) | |
| 1559 | 1589 | ctx := testContext() | |
| 1560 | 1590 | ||
| 1561 | 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 | 1626 | }, nil) | |
| 1597 | 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 | 1632 | runtime.EXPECT().StopContainer(mock.Anything, "tracked-temp-container").Return(nil) | |
| 1600 | 1633 | runtime.EXPECT().RemoveContainer(mock.Anything, "tracked-temp-container", true).Return(nil) | |
| 1601 | 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 | 1767 | runtime := mocks.NewMockContainerRuntime(t) | |
| 1735 | 1768 | envLoader := mocks.NewMockEnvLoader(t) | |
| 1736 | 1769 | eventBus := mocks.NewMockEventPublisher(t) | |
| 1770 | + | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | |
| 1737 | 1771 | ||
| 1738 | 1772 | config := Config{ | |
| 1739 | 1773 | ReadinessDelay: time.Millisecond, | |
| 1774 | + | DrainDelay: time.Millisecond, | |
| 1740 | 1775 | } | |
| 1741 | 1776 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 1777 | + | svc.SetProxyCacheInvalidator(cacheInvalidator) | |
| 1742 | 1778 | ctx := testContext() | |
| 1743 | 1779 | ||
| 1744 | 1780 | route := domain.Route{ | |
| @@ -1800,7 +1836,9 @@ func TestService_Deploy_ConcurrentSameDomain(t *testing.T) { | |||
| 1800 | 1836 | ||
| 1801 | 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 | 1842 | // which means it will also stop+remove+rename the old container. | |
| 1805 | 1843 | runtime.EXPECT().StopContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Once() | |
| 1806 | 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 | 1879 | config := Config{ | |
| 1842 | 1880 | ReadinessDelay: time.Millisecond, | |
| 1881 | + | DrainDelay: time.Millisecond, | |
| 1843 | 1882 | } | |
| 1844 | 1883 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 1845 | 1884 | ||
| @@ -1856,3 +1895,135 @@ func TestService_Deploy_ContextCancellation(t *testing.T) { | |||
| 1856 | 1895 | assert.Error(t, err, "deploy should fail with cancelled context") | |
| 1857 | 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 | + | } | |
| @@ -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 tests | 31 | 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 tests | 251 | 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 container | 258 | // Pre-populate with existing container | ||
| @@ -294,7 +299,10 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) { | |||||
| 294 | // Publish event | 299 | // 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 tests | 331 | 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 tests | 383 | 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 tests | 1349 | 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 container | 1356 | // Pre-populate with existing tracked container | ||
| @@ -1388,6 +1405,9 @@ func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) { | |||||
| 1388 | // Publish event | 1405 | // 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 removed | 1411 | // 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 cleanup | 1412 | // 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 tests | 1438 | 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 { | |||
| 56 | 56 | targets map[string]*domain.ProxyTarget | |
| 57 | 57 | mu sync.RWMutex | |
| 58 | 58 | activeConns atomic.Int64 // Atomic counter for concurrent connection limiting | |
| 59 | + | inFlight map[string]int | |
| 60 | + | inFlightMu sync.Mutex | |
| 59 | 61 | } | |
| 60 | 62 | ||
| 61 | 63 | // NewService creates a new proxy service. | |
| @@ -71,6 +73,7 @@ func NewService( | |||
| 71 | 73 | configSvc: configSvc, | |
| 72 | 74 | config: config, | |
| 73 | 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 | 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 | 341 | // RefreshTargets refreshes all proxy targets from container state. | |
| 308 | 342 | func (s *Service) RefreshTargets(ctx context.Context) error { | |
| 309 | 343 | s.mu.Lock() | |
| @@ -459,6 +493,8 @@ func (s *Service) proxyToTarget(w http.ResponseWriter, r *http.Request, target * | |||
| 459 | 493 | // If DNS pinning resolved hostname to IP, preserve the original hostname | |
| 460 | 494 | // for the Host header so virtual-hosted upstreams work correctly. | |
| 461 | 495 | originalHost := target.OriginalHost | |
| 496 | + | releaseInFlight := s.trackInFlight(target.ContainerID) | |
| 497 | + | defer releaseInFlight() | |
| 462 | 498 | ||
| 463 | 499 | proxy := newReverseProxy(targetURL, originalHost, | |
| 464 | 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 | 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 | 541 | // proxyToRegistry forwards requests to the local registry HTTP server. | |
| 486 | 542 | // SECURITY: This uses http://localhost:{port} which is safe because the registry | |
| 487 | 543 | // 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.ProxyTarget | 56 | targets map[string]*domain.ProxyTarget | ||
| 57 | mu sync.RWMutex | 57 | mu sync.RWMutex | ||
| 58 | activeConns atomic.Int64 // Atomic counter for concurrent connection limiting | 58 | 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 hostname | 493 | // 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.OriginalHost | 495 | 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 registry | 542 | // 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 Gordon | 543 | // runs on the same host and traffic never leaves the loopback interface. If Gordon | ||