Merge pull request #91 from bnema/feat/strict-zero-downtime-deploy
feat: strict zero-downtime deploy with readiness probes and rollback
23 files · +2108 -173
M docs/config/index.md +0 -1
| @@ -106,7 +106,6 @@ preserve = true # Keep volumes on container removal | |||
| 106 | 106 | [network_isolation] | |
| 107 | 107 | enabled = true # Per-app isolated networks | |
| 108 | 108 | network_prefix = "gordon" # Network name prefix | |
| 109 | - | dns_suffix = ".internal" # DNS suffix for services | |
| 110 | 109 | ||
| 111 | 110 | # Auto-route | |
| 112 | 111 | [auto_route] | |
| @@ -106,7 +106,6 @@ preserve = true # Keep volumes on container removal | |||||
| 106 | [network_isolation] | 106 | [network_isolation] | ||
| 107 | enabled = true # Per-app isolated networks | 107 | enabled = true # Per-app isolated networks | ||
| 108 | network_prefix = "gordon" # Network name prefix | 108 | network_prefix = "gordon" # Network name prefix | ||
| 109 | - | dns_suffix = ".internal" # DNS suffix for services | |||
| 110 | 109 | ||||
| 111 | # Auto-route | 110 | # Auto-route | ||
| 112 | [auto_route] | 111 | [auto_route] | ||
M docs/config/network-isolation.md +0 -16
| @@ -8,7 +8,6 @@ Isolate applications in separate Docker networks for enhanced security. | |||
| 8 | 8 | [network_isolation] | |
| 9 | 9 | enabled = true | |
| 10 | 10 | network_prefix = "gordon" | |
| 11 | - | dns_suffix = ".internal" | |
| 12 | 11 | ``` | |
| 13 | 12 | ||
| 14 | 13 | ## Options | |
| @@ -17,7 +16,6 @@ dns_suffix = ".internal" | |||
| 17 | 16 | |--------|------|---------|-------------| | |
| 18 | 17 | | `enabled` | bool | `false` | Enable per-app network isolation | | |
| 19 | 18 | | `network_prefix` | string | `"gordon"` | Prefix for created networks | | |
| 20 | - | | `dns_suffix` | string | `".internal"` | DNS suffix for service discovery | | |
| 21 | 19 | ||
| 22 | 20 | ## How It Works | |
| 23 | 21 | ||
| @@ -99,19 +97,6 @@ db = connect("postgresql://postgres:5432/mydb") | |||
| 99 | 97 | cache = connect("redis://redis:6379") | |
| 100 | 98 | ``` | |
| 101 | 99 | ||
| 102 | - | ## DNS Resolution | |
| 103 | - | ||
| 104 | - | The `dns_suffix` option adds a suffix for internal DNS resolution: | |
| 105 | - | ||
| 106 | - | ```toml | |
| 107 | - | [network_isolation] | |
| 108 | - | dns_suffix = ".internal" | |
| 109 | - | ``` | |
| 110 | - | ||
| 111 | - | Services can be accessed as: | |
| 112 | - | - `postgres` (short form) | |
| 113 | - | - `postgres.internal` (with suffix) | |
| 114 | - | ||
| 115 | 100 | ## Examples | |
| 116 | 101 | ||
| 117 | 102 | ### Basic Isolation | |
| @@ -137,7 +122,6 @@ Each app gets its own network with its own database. | |||
| 137 | 122 | [network_isolation] | |
| 138 | 123 | enabled = true | |
| 139 | 124 | network_prefix = "prod" | |
| 140 | - | dns_suffix = ".internal" | |
| 141 | 125 | ||
| 142 | 126 | [routes] | |
| 143 | 127 | "app.company.com" = "company-app:v2.1.0" | |
| @@ -8,7 +8,6 @@ Isolate applications in separate Docker networks for enhanced security. | |||||
| 8 | [network_isolation] | 8 | [network_isolation] | ||
| 9 | enabled = true | 9 | enabled = true | ||
| 10 | network_prefix = "gordon" | 10 | network_prefix = "gordon" | ||
| 11 | - | dns_suffix = ".internal" | |||
| 12 | ``` | 11 | ``` | ||
| 13 | 12 | ||||
| 14 | ## Options | 13 | ## Options | ||
| @@ -17,7 +16,6 @@ dns_suffix = ".internal" | |||||
| 17 | |--------|------|---------|-------------| | 16 | |--------|------|---------|-------------| | ||
| 18 | | `enabled` | bool | `false` | Enable per-app network isolation | | 17 | | `enabled` | bool | `false` | Enable per-app network isolation | | ||
| 19 | | `network_prefix` | string | `"gordon"` | Prefix for created networks | | 18 | | `network_prefix` | string | `"gordon"` | Prefix for created networks | | ||
| 20 | - | | `dns_suffix` | string | `".internal"` | DNS suffix for service discovery | | |||
| 21 | 19 | ||||
| 22 | ## How It Works | 20 | ## How It Works | ||
| 23 | 21 | ||||
| @@ -99,19 +97,6 @@ db = connect("postgresql://postgres:5432/mydb") | |||||
| 99 | cache = connect("redis://redis:6379") | 97 | cache = connect("redis://redis:6379") | ||
| 100 | ``` | 98 | ``` | ||
| 101 | 99 | ||||
| 102 | - | ## DNS Resolution | |||
| 103 | - | ||||
| 104 | - | The `dns_suffix` option adds a suffix for internal DNS resolution: | |||
| 105 | - | ||||
| 106 | - | ```toml | |||
| 107 | - | [network_isolation] | |||
| 108 | - | dns_suffix = ".internal" | |||
| 109 | - | ``` | |||
| 110 | - | ||||
| 111 | - | Services can be accessed as: | |||
| 112 | - | - `postgres` (short form) | |||
| 113 | - | - `postgres.internal` (with suffix) | |||
| 114 | - | ||||
| 115 | ## Examples | 100 | ## Examples | ||
| 116 | 101 | ||||
| 117 | ### Basic Isolation | 102 | ### Basic Isolation | ||
| @@ -137,7 +122,6 @@ Each app gets its own network with its own database. | |||||
| 137 | [network_isolation] | 122 | [network_isolation] | ||
| 138 | enabled = true | 123 | enabled = true | ||
| 139 | network_prefix = "prod" | 124 | network_prefix = "prod" | ||
| 140 | - | dns_suffix = ".internal" | |||
| 141 | 125 | ||||
| 142 | [routes] | 126 | [routes] | ||
| 143 | "app.company.com" = "company-app:v2.1.0" | 127 | "app.company.com" = "company-app:v2.1.0" | ||
M docs/config/reference.md +0 -2
| @@ -105,7 +105,6 @@ enabled = false # Create routes from image labels a | |||
| 105 | 105 | [network_isolation] | |
| 106 | 106 | enabled = false # Enable per-app Docker networks | |
| 107 | 107 | network_prefix = "gordon" # Prefix for created networks | |
| 108 | - | dns_suffix = ".internal" # DNS suffix for internal resolution | |
| 109 | 108 | ||
| 110 | 109 | # ============================================================================= | |
| 111 | 110 | # VOLUMES | |
| @@ -215,7 +214,6 @@ keep_last = 3 # Keep N newest tags per repository | |||
| 215 | 214 | | `auto_route.enabled` | `false` | Auto-route disabled | | |
| 216 | 215 | | `network_isolation.enabled` | `false` | Isolation disabled | | |
| 217 | 216 | | `network_isolation.network_prefix` | `"gordon"` | Network prefix | | |
| 218 | - | | `network_isolation.dns_suffix` | `".internal"` | DNS suffix | | |
| 219 | 217 | | `volumes.auto_create` | `true` | Auto-create volumes | | |
| 220 | 218 | | `volumes.prefix` | `"gordon"` | Volume prefix | | |
| 221 | 219 | | `volumes.preserve` | `true` | Keep volumes | | |
| @@ -105,7 +105,6 @@ enabled = false # Create routes from image labels a | |||||
| 105 | [network_isolation] | 105 | [network_isolation] | ||
| 106 | enabled = false # Enable per-app Docker networks | 106 | enabled = false # Enable per-app Docker networks | ||
| 107 | network_prefix = "gordon" # Prefix for created networks | 107 | network_prefix = "gordon" # Prefix for created networks | ||
| 108 | - | dns_suffix = ".internal" # DNS suffix for internal resolution | |||
| 109 | 108 | ||||
| 110 | # ============================================================================= | 109 | # ============================================================================= | ||
| 111 | # VOLUMES | 110 | # VOLUMES | ||
| @@ -215,7 +214,6 @@ keep_last = 3 # Keep N newest tags per repository | |||||
| 215 | | `auto_route.enabled` | `false` | Auto-route disabled | | 214 | | `auto_route.enabled` | `false` | Auto-route disabled | | ||
| 216 | | `network_isolation.enabled` | `false` | Isolation disabled | | 215 | | `network_isolation.enabled` | `false` | Isolation disabled | | ||
| 217 | | `network_isolation.network_prefix` | `"gordon"` | Network prefix | | 216 | | `network_isolation.network_prefix` | `"gordon"` | Network prefix | | ||
| 218 | - | | `network_isolation.dns_suffix` | `".internal"` | DNS suffix | | |||
| 219 | | `volumes.auto_create` | `true` | Auto-create volumes | | 217 | | `volumes.auto_create` | `true` | Auto-create volumes | | ||
| 220 | | `volumes.prefix` | `"gordon"` | Volume prefix | | 218 | | `volumes.prefix` | `"gordon"` | Volume prefix | | ||
| 221 | | `volumes.preserve` | `true` | Keep volumes | | 219 | | `volumes.preserve` | `true` | Keep volumes | | ||
M docs/reference/docker-labels.md +21 -1
| @@ -41,7 +41,27 @@ Labels you can set in your Dockerfile: | |||
| 41 | 41 | ||
| 42 | 42 | | Label | Example | Description | | |
| 43 | 43 | |-------|---------|-------------| | |
| 44 | - | | `gordon.proxy.port` | `"3000"` | Port to proxy HTTP traffic to | | |
| 44 | + | | `gordon.domains` | `"app.example.com,www.app.example.com"` | Comma-separated domains for auto-route | | |
| 45 | + | | `gordon.port` | `"3000"` | Port to proxy HTTP traffic to | | |
| 46 | + | | `gordon.proxy.port` | `"3000"` | Port to proxy HTTP traffic to (legacy alias for `gordon.port`) | | |
| 47 | + | | `gordon.health` | `"/healthz"` | HTTP health check endpoint path for readiness probing | | |
| 48 | + | | `gordon.env-file` | `"/app/.env.example"` | Path to env template file inside the image | | |
| 49 | + | ||
| 50 | + | ### Health Check Label | |
| 51 | + | ||
| 52 | + | When `gordon.health` is set, Gordon performs HTTP GET requests to the specified | |
| 53 | + | path during deployment and waits for a 2xx or 3xx response before routing traffic | |
| 54 | + | to the new container: | |
| 55 | + | ||
| 56 | + | ```dockerfile | |
| 57 | + | FROM node:20-alpine | |
| 58 | + | LABEL gordon.health="/api/health" | |
| 59 | + | EXPOSE 3000 | |
| 60 | + | CMD ["node", "server.js"] | |
| 61 | + | ``` | |
| 62 | + | ||
| 63 | + | Gordon probes `http://<container-ip>:3000/api/health` until it gets a successful | |
| 64 | + | response or the `deploy.http_probe_timeout` is reached. | |
| 45 | 65 | ||
| 46 | 66 | ### Proxy Port Label | |
| 47 | 67 | ||
| @@ -41,7 +41,27 @@ Labels you can set in your Dockerfile: | |||||
| 41 | 41 | ||||
| 42 | | Label | Example | Description | | 42 | | Label | Example | Description | | ||
| 43 | |-------|---------|-------------| | 43 | |-------|---------|-------------| | ||
| 44 | - | | `gordon.proxy.port` | `"3000"` | Port to proxy HTTP traffic to | | 44 | + | | `gordon.domains` | `"app.example.com,www.app.example.com"` | Comma-separated domains for auto-route | |
| 45 | + | | `gordon.port` | `"3000"` | Port to proxy HTTP traffic to | | |||
| 46 | + | | `gordon.proxy.port` | `"3000"` | Port to proxy HTTP traffic to (legacy alias for `gordon.port`) | | |||
| 47 | + | | `gordon.health` | `"/healthz"` | HTTP health check endpoint path for readiness probing | | |||
| 48 | + | | `gordon.env-file` | `"/app/.env.example"` | Path to env template file inside the image | | |||
| 49 | + | ||||
| 50 | + | ### Health Check Label | |||
| 51 | + | ||||
| 52 | + | When `gordon.health` is set, Gordon performs HTTP GET requests to the specified | |||
| 53 | + | path during deployment and waits for a 2xx or 3xx response before routing traffic | |||
| 54 | + | to the new container: | |||
| 55 | + | ||||
| 56 | + | ```dockerfile | |||
| 57 | + | FROM node:20-alpine | |||
| 58 | + | LABEL gordon.health="/api/health" | |||
| 59 | + | EXPOSE 3000 | |||
| 60 | + | CMD ["node", "server.js"] | |||
| 61 | + | ``` | |||
| 62 | + | ||||
| 63 | + | Gordon probes `http://<container-ip>:3000/api/health` until it gets a successful | |||
| 64 | + | response or the `deploy.http_probe_timeout` is reached. | |||
| 45 | 65 | ||||
| 46 | ### Proxy Port Label | 66 | ### Proxy Port Label | ||
| 47 | 67 | ||||
M internal/adapters/in/cli/controlplane.go +1 -0
| @@ -34,6 +34,7 @@ type ControlPlane interface { | |||
| 34 | 34 | ||
| 35 | 35 | GetStatus(ctx context.Context) (*remote.Status, error) | |
| 36 | 36 | Reload(ctx context.Context) error | |
| 37 | + | DeployIntent(ctx context.Context, imageName string) error | |
| 37 | 38 | Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error) | |
| 38 | 39 | Restart(ctx context.Context, restartDomain string, withAttachments bool) (*remote.RestartResult, error) | |
| 39 | 40 | ListTags(ctx context.Context, repository string) ([]string, error) | |
| @@ -34,6 +34,7 @@ type ControlPlane interface { | |||||
| 34 | 34 | ||||
| 35 | GetStatus(ctx context.Context) (*remote.Status, error) | 35 | GetStatus(ctx context.Context) (*remote.Status, error) | ||
| 36 | Reload(ctx context.Context) error | 36 | Reload(ctx context.Context) error | ||
| 37 | + | DeployIntent(ctx context.Context, imageName string) error | |||
| 37 | Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error) | 38 | Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error) | ||
| 38 | Restart(ctx context.Context, restartDomain string, withAttachments bool) (*remote.RestartResult, error) | 39 | Restart(ctx context.Context, restartDomain string, withAttachments bool) (*remote.RestartResult, error) | ||
| 39 | ListTags(ctx context.Context, repository string) ([]string, error) | 40 | ListTags(ctx context.Context, repository string) ([]string, error) | ||
M internal/adapters/in/cli/controlplane_local.go +18 -1
| @@ -18,6 +18,7 @@ type localControlPlane struct { | |||
| 18 | 18 | containerSvc in.ContainerService | |
| 19 | 19 | backupSvc in.BackupService | |
| 20 | 20 | registrySvc in.RegistryService | |
| 21 | + | deployCoord in.DeployCoordinator | |
| 21 | 22 | healthSvc in.HealthService | |
| 22 | 23 | logSvc in.LogService | |
| 23 | 24 | } | |
| @@ -27,12 +28,21 @@ func NewLocalControlPlane(kernel *app.Kernel) ControlPlane { | |||
| 27 | 28 | return &localControlPlane{} | |
| 28 | 29 | } | |
| 29 | 30 | ||
| 31 | + | registrySvc := kernel.Registry() | |
| 32 | + | var deployCoord in.DeployCoordinator | |
| 33 | + | if registrySvc != nil { | |
| 34 | + | if coordinator, ok := any(registrySvc).(in.DeployCoordinator); ok { | |
| 35 | + | deployCoord = coordinator | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | ||
| 30 | 39 | return &localControlPlane{ | |
| 31 | 40 | configSvc: kernel.Config(), | |
| 32 | 41 | secretSvc: kernel.Secrets(), | |
| 33 | 42 | containerSvc: kernel.Container(), | |
| 34 | 43 | backupSvc: kernel.Backup(), | |
| 35 | - | registrySvc: kernel.Registry(), | |
| 44 | + | registrySvc: registrySvc, | |
| 45 | + | deployCoord: deployCoord, | |
| 36 | 46 | healthSvc: kernel.Health(), | |
| 37 | 47 | logSvc: kernel.Logs(), | |
| 38 | 48 | } | |
| @@ -223,6 +233,13 @@ func (l *localControlPlane) Reload(_ context.Context) error { | |||
| 223 | 233 | return app.SendReloadSignal() | |
| 224 | 234 | } | |
| 225 | 235 | ||
| 236 | + | func (l *localControlPlane) DeployIntent(_ context.Context, imageName string) error { | |
| 237 | + | if l.deployCoord != nil { | |
| 238 | + | l.deployCoord.SuppressDeployEvent(imageName) | |
| 239 | + | } | |
| 240 | + | return nil | |
| 241 | + | } | |
| 242 | + | ||
| 226 | 243 | func (l *localControlPlane) Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error) { | |
| 227 | 244 | if l.containerSvc != nil && l.configSvc != nil { | |
| 228 | 245 | route, err := l.configSvc.GetRoute(ctx, deployDomain) | |
| @@ -18,6 +18,7 @@ type localControlPlane struct { | |||||
| 18 | containerSvc in.ContainerService | 18 | containerSvc in.ContainerService | ||
| 19 | backupSvc in.BackupService | 19 | backupSvc in.BackupService | ||
| 20 | registrySvc in.RegistryService | 20 | registrySvc in.RegistryService | ||
| 21 | + | deployCoord in.DeployCoordinator | |||
| 21 | healthSvc in.HealthService | 22 | healthSvc in.HealthService | ||
| 22 | logSvc in.LogService | 23 | logSvc in.LogService | ||
| 23 | } | 24 | } | ||
| @@ -27,12 +28,21 @@ func NewLocalControlPlane(kernel *app.Kernel) ControlPlane { | |||||
| 27 | return &localControlPlane{} | 28 | return &localControlPlane{} | ||
| 28 | } | 29 | } | ||
| 29 | 30 | ||||
| 31 | + | registrySvc := kernel.Registry() | |||
| 32 | + | var deployCoord in.DeployCoordinator | |||
| 33 | + | if registrySvc != nil { | |||
| 34 | + | if coordinator, ok := any(registrySvc).(in.DeployCoordinator); ok { | |||
| 35 | + | deployCoord = coordinator | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | ||||
| 30 | return &localControlPlane{ | 39 | return &localControlPlane{ | ||
| 31 | configSvc: kernel.Config(), | 40 | configSvc: kernel.Config(), | ||
| 32 | secretSvc: kernel.Secrets(), | 41 | secretSvc: kernel.Secrets(), | ||
| 33 | containerSvc: kernel.Container(), | 42 | containerSvc: kernel.Container(), | ||
| 34 | backupSvc: kernel.Backup(), | 43 | backupSvc: kernel.Backup(), | ||
| 35 | - | registrySvc: kernel.Registry(), | 44 | + | registrySvc: registrySvc, |
| 45 | + | deployCoord: deployCoord, | |||
| 36 | healthSvc: kernel.Health(), | 46 | healthSvc: kernel.Health(), | ||
| 37 | logSvc: kernel.Logs(), | 47 | logSvc: kernel.Logs(), | ||
| 38 | } | 48 | } | ||
| @@ -223,6 +233,13 @@ func (l *localControlPlane) Reload(_ context.Context) error { | |||||
| 223 | return app.SendReloadSignal() | 233 | return app.SendReloadSignal() | ||
| 224 | } | 234 | } | ||
| 225 | 235 | ||||
| 236 | + | func (l *localControlPlane) DeployIntent(_ context.Context, imageName string) error { | |||
| 237 | + | if l.deployCoord != nil { | |||
| 238 | + | l.deployCoord.SuppressDeployEvent(imageName) | |||
| 239 | + | } | |||
| 240 | + | return nil | |||
| 241 | + | } | |||
| 242 | + | ||||
| 226 | func (l *localControlPlane) Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error) { | 243 | func (l *localControlPlane) Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error) { | ||
| 227 | if l.containerSvc != nil && l.configSvc != nil { | 244 | if l.containerSvc != nil && l.configSvc != nil { | ||
| 228 | route, err := l.configSvc.GetRoute(ctx, deployDomain) | 245 | route, err := l.configSvc.GetRoute(ctx, deployDomain) | ||
M internal/adapters/in/cli/controlplane_remote.go +4 -0
| @@ -88,6 +88,10 @@ func (r *remoteControlPlane) Reload(ctx context.Context) error { | |||
| 88 | 88 | return r.client.Reload(ctx) | |
| 89 | 89 | } | |
| 90 | 90 | ||
| 91 | + | func (r *remoteControlPlane) DeployIntent(ctx context.Context, imageName string) error { | |
| 92 | + | return r.client.DeployIntent(ctx, imageName) | |
| 93 | + | } | |
| 94 | + | ||
| 91 | 95 | func (r *remoteControlPlane) Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error) { | |
| 92 | 96 | return r.client.Deploy(ctx, deployDomain) | |
| 93 | 97 | } | |
| @@ -88,6 +88,10 @@ func (r *remoteControlPlane) Reload(ctx context.Context) error { | |||||
| 88 | return r.client.Reload(ctx) | 88 | return r.client.Reload(ctx) | ||
| 89 | } | 89 | } | ||
| 90 | 90 | ||||
| 91 | + | func (r *remoteControlPlane) DeployIntent(ctx context.Context, imageName string) error { | |||
| 92 | + | return r.client.DeployIntent(ctx, imageName) | |||
| 93 | + | } | |||
| 94 | + | ||||
| 91 | func (r *remoteControlPlane) Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error) { | 95 | func (r *remoteControlPlane) Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error) { | ||
| 92 | return r.client.Deploy(ctx, deployDomain) | 96 | return r.client.Deploy(ctx, deployDomain) | ||
| 93 | } | 97 | } | ||
M internal/adapters/in/cli/push.go +9 -0
| @@ -154,6 +154,15 @@ func runPush(ctx context.Context, imageArg, domainFlag, tag string, build bool, | |||
| 154 | 154 | } | |
| 155 | 155 | fmt.Printf("Domain: %s\n", styles.Theme.Bold.Render(pushDomain)) | |
| 156 | 156 | ||
| 157 | + | // Signal the server to suppress event-based deploys for this image. | |
| 158 | + | // The CLI will trigger an explicit deploy after push completes. | |
| 159 | + | if !noDeploy { | |
| 160 | + | if err := handle.plane.DeployIntent(ctx, imageName); err != nil { | |
| 161 | + | // Non-fatal: worst case we get a redundant deploy via event | |
| 162 | + | fmt.Fprintf(os.Stderr, "warning: failed to register deploy intent: %v\n", err) | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | ||
| 157 | 166 | if build { | |
| 158 | 167 | if err := buildAndPush(ctx, version, platform, dockerfile, buildArgs, versionRef, latestRef); err != nil { | |
| 159 | 168 | return err | |
| @@ -154,6 +154,15 @@ func runPush(ctx context.Context, imageArg, domainFlag, tag string, build bool, | |||||
| 154 | } | 154 | } | ||
| 155 | fmt.Printf("Domain: %s\n", styles.Theme.Bold.Render(pushDomain)) | 155 | fmt.Printf("Domain: %s\n", styles.Theme.Bold.Render(pushDomain)) | ||
| 156 | 156 | ||||
| 157 | + | // Signal the server to suppress event-based deploys for this image. | |||
| 158 | + | // The CLI will trigger an explicit deploy after push completes. | |||
| 159 | + | if !noDeploy { | |||
| 160 | + | if err := handle.plane.DeployIntent(ctx, imageName); err != nil { | |||
| 161 | + | // Non-fatal: worst case we get a redundant deploy via event | |||
| 162 | + | fmt.Fprintf(os.Stderr, "warning: failed to register deploy intent: %v\n", err) | |||
| 163 | + | } | |||
| 164 | + | } | |||
| 165 | + | ||||
| 157 | if build { | 166 | if build { | ||
| 158 | if err := buildAndPush(ctx, version, platform, dockerfile, buildArgs, versionRef, latestRef); err != nil { | 167 | if err := buildAndPush(ctx, version, platform, dockerfile, buildArgs, versionRef, latestRef); err != nil { | ||
| 159 | return err | 168 | return err | ||
M internal/adapters/in/cli/remote/client.go +14 -0
| @@ -724,6 +724,20 @@ func (c *Client) Deploy(ctx context.Context, deployDomain string) (*DeployResult | |||
| 724 | 724 | return &result, nil | |
| 725 | 725 | } | |
| 726 | 726 | ||
| 727 | + | // DeployIntent tells the server that a CLI-managed push is about to happen, | |
| 728 | + | // suppressing event-based deploys for this image. | |
| 729 | + | func (c *Client) DeployIntent(ctx context.Context, imageName string) error { | |
| 730 | + | imageName = strings.TrimSpace(imageName) | |
| 731 | + | if imageName == "" { | |
| 732 | + | return fmt.Errorf("image name cannot be empty") | |
| 733 | + | } | |
| 734 | + | resp, err := c.requestWithRetry(ctx, http.MethodPost, "/deploy-intent/"+url.PathEscape(imageName), nil) | |
| 735 | + | if err != nil { | |
| 736 | + | return err | |
| 737 | + | } | |
| 738 | + | return parseResponse(resp, nil) | |
| 739 | + | } | |
| 740 | + | ||
| 727 | 741 | // Restart API | |
| 728 | 742 | ||
| 729 | 743 | // RestartResult contains the result of a restart. | |
| @@ -724,6 +724,20 @@ func (c *Client) Deploy(ctx context.Context, deployDomain string) (*DeployResult | |||||
| 724 | return &result, nil | 724 | return &result, nil | ||
| 725 | } | 725 | } | ||
| 726 | 726 | ||||
| 727 | + | // DeployIntent tells the server that a CLI-managed push is about to happen, | |||
| 728 | + | // suppressing event-based deploys for this image. | |||
| 729 | + | func (c *Client) DeployIntent(ctx context.Context, imageName string) error { | |||
| 730 | + | imageName = strings.TrimSpace(imageName) | |||
| 731 | + | if imageName == "" { | |||
| 732 | + | return fmt.Errorf("image name cannot be empty") | |||
| 733 | + | } | |||
| 734 | + | resp, err := c.requestWithRetry(ctx, http.MethodPost, "/deploy-intent/"+url.PathEscape(imageName), nil) | |||
| 735 | + | if err != nil { | |||
| 736 | + | return err | |||
| 737 | + | } | |||
| 738 | + | return parseResponse(resp, nil) | |||
| 739 | + | } | |||
| 740 | + | ||||
| 727 | // Restart API | 741 | // Restart API | ||
| 728 | 742 | ||||
| 729 | // RestartResult contains the result of a restart. | 743 | // RestartResult contains the result of a restart. | ||
M internal/adapters/in/http/admin/handler.go +60 -2
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "fmt" | |
| 8 | 8 | "io" | |
| 9 | 9 | "net/http" | |
| 10 | + | "net/url" | |
| 10 | 11 | "strconv" | |
| 11 | 12 | "strings" | |
| 12 | 13 | "time" | |
| @@ -27,6 +28,11 @@ const maxAdminRequestSize = 1 << 20 // 1MB | |||
| 27 | 28 | // maxLogLines is the maximum allowed number of log lines that can be requested. | |
| 28 | 29 | const maxLogLines = 10000 | |
| 29 | 30 | ||
| 31 | + | type registryDeployService interface { | |
| 32 | + | in.RegistryService | |
| 33 | + | in.DeployCoordinator | |
| 34 | + | } | |
| 35 | + | ||
| 30 | 36 | // Handler implements the HTTP handler for the admin API. | |
| 31 | 37 | type Handler struct { | |
| 32 | 38 | configSvc in.ConfigService | |
| @@ -37,7 +43,7 @@ type Handler struct { | |||
| 37 | 43 | healthSvc in.HealthService | |
| 38 | 44 | secretSvc in.SecretService | |
| 39 | 45 | logSvc in.LogService | |
| 40 | - | registrySvc in.RegistryService | |
| 46 | + | registrySvc registryDeployService | |
| 41 | 47 | eventBus out.EventPublisher | |
| 42 | 48 | log zerowrap.Logger | |
| 43 | 49 | } | |
| @@ -129,7 +135,7 @@ func NewHandler( | |||
| 129 | 135 | healthSvc in.HealthService, | |
| 130 | 136 | secretSvc in.SecretService, | |
| 131 | 137 | logSvc in.LogService, | |
| 132 | - | registrySvc in.RegistryService, | |
| 138 | + | registrySvc registryDeployService, | |
| 133 | 139 | eventBus out.EventPublisher, | |
| 134 | 140 | log zerowrap.Logger, | |
| 135 | 141 | backupSvc in.BackupService, | |
| @@ -213,6 +219,7 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) { | |||
| 213 | 219 | {"/routes/by-image", h.handleRoutesByImage}, | |
| 214 | 220 | {"/routes", h.handleRoutes}, | |
| 215 | 221 | {"/secrets", h.handleSecrets}, | |
| 222 | + | {"/deploy-intent", h.handleDeployIntent}, | |
| 216 | 223 | {"/deploy", h.handleDeploy}, | |
| 217 | 224 | {"/restart", h.handleRestart}, | |
| 218 | 225 | {"/tags", h.handleTags}, | |
| @@ -228,6 +235,48 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) { | |||
| 228 | 235 | return nil, false | |
| 229 | 236 | } | |
| 230 | 237 | ||
| 238 | + | // handleDeployIntent handles /admin/deploy-intent/:image endpoint. | |
| 239 | + | // It registers a deploy intent, suppressing event-based deploys for the image. | |
| 240 | + | func (h *Handler) handleDeployIntent(w http.ResponseWriter, r *http.Request, path string) { | |
| 241 | + | if r.Method != http.MethodPost { | |
| 242 | + | h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") | |
| 243 | + | return | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | ctx := r.Context() | |
| 247 | + | if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) { | |
| 248 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write") | |
| 249 | + | return | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | if h.registrySvc == nil { | |
| 253 | + | h.sendError(w, http.StatusServiceUnavailable, "registry service unavailable") | |
| 254 | + | return | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | rawName := strings.TrimPrefix(path, "/deploy-intent/") | |
| 258 | + | if rawName == "" || rawName == "/deploy-intent" { | |
| 259 | + | h.sendError(w, http.StatusBadRequest, "image name required") | |
| 260 | + | return | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | imageName, err := url.PathUnescape(rawName) | |
| 264 | + | if err != nil { | |
| 265 | + | h.sendError(w, http.StatusBadRequest, "invalid image name encoding") | |
| 266 | + | return | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | log := zerowrap.FromCtx(ctx) | |
| 270 | + | log.Info().Str("image", imageName).Msg("deploy intent registered, suppressing image.pushed events") | |
| 271 | + | ||
| 272 | + | h.registrySvc.SuppressDeployEvent(imageName) | |
| 273 | + | ||
| 274 | + | h.sendJSON(w, http.StatusOK, map[string]string{ | |
| 275 | + | "status": "ok", | |
| 276 | + | "image": imageName, | |
| 277 | + | }) | |
| 278 | + | } | |
| 279 | + | ||
| 231 | 280 | // sendJSON sends a JSON response. | |
| 232 | 281 | func (h *Handler) sendJSON(w http.ResponseWriter, status int, data any) { | |
| 233 | 282 | w.Header().Set("Content-Type", "application/json") | |
| @@ -1078,6 +1127,15 @@ func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request, path stri | |||
| 1078 | 1127 | return | |
| 1079 | 1128 | } | |
| 1080 | 1129 | ||
| 1130 | + | // Clear deploy event suppression now that the explicit deploy has completed. | |
| 1131 | + | // This re-enables event-based deploys for future direct docker pushes. | |
| 1132 | + | if route.Image != "" && h.registrySvc != nil { | |
| 1133 | + | // Use the registry package's image name normaliser so digest-form refs | |
| 1134 | + | // and multi-segment paths are handled correctly. | |
| 1135 | + | imageName := registry.ExtractImageName(route.Image) | |
| 1136 | + | h.registrySvc.ClearDeployEventSuppression(imageName) | |
| 1137 | + | } | |
| 1138 | + | ||
| 1081 | 1139 | log.Info().Str("domain", deployDomain).Str("container_id", container.ID).Msg("container deployed via admin API") | |
| 1082 | 1140 | h.sendJSON(w, http.StatusOK, dto.DeployResponse{ | |
| 1083 | 1141 | Status: "deployed", | |
| @@ -7,6 +7,7 @@ import ( | |||||
| 7 | "fmt" | 7 | "fmt" | ||
| 8 | "io" | 8 | "io" | ||
| 9 | "net/http" | 9 | "net/http" | ||
| 10 | + | "net/url" | |||
| 10 | "strconv" | 11 | "strconv" | ||
| 11 | "strings" | 12 | "strings" | ||
| 12 | "time" | 13 | "time" | ||
| @@ -27,6 +28,11 @@ const maxAdminRequestSize = 1 << 20 // 1MB | |||||
| 27 | // maxLogLines is the maximum allowed number of log lines that can be requested. | 28 | // maxLogLines is the maximum allowed number of log lines that can be requested. | ||
| 28 | const maxLogLines = 10000 | 29 | const maxLogLines = 10000 | ||
| 29 | 30 | ||||
| 31 | + | type registryDeployService interface { | |||
| 32 | + | in.RegistryService | |||
| 33 | + | in.DeployCoordinator | |||
| 34 | + | } | |||
| 35 | + | ||||
| 30 | // Handler implements the HTTP handler for the admin API. | 36 | // Handler implements the HTTP handler for the admin API. | ||
| 31 | type Handler struct { | 37 | type Handler struct { | ||
| 32 | configSvc in.ConfigService | 38 | configSvc in.ConfigService | ||
| @@ -37,7 +43,7 @@ type Handler struct { | |||||
| 37 | healthSvc in.HealthService | 43 | healthSvc in.HealthService | ||
| 38 | secretSvc in.SecretService | 44 | secretSvc in.SecretService | ||
| 39 | logSvc in.LogService | 45 | logSvc in.LogService | ||
| 40 | - | registrySvc in.RegistryService | 46 | + | registrySvc registryDeployService |
| 41 | eventBus out.EventPublisher | 47 | eventBus out.EventPublisher | ||
| 42 | log zerowrap.Logger | 48 | log zerowrap.Logger | ||
| 43 | } | 49 | } | ||
| @@ -129,7 +135,7 @@ func NewHandler( | |||||
| 129 | healthSvc in.HealthService, | 135 | healthSvc in.HealthService, | ||
| 130 | secretSvc in.SecretService, | 136 | secretSvc in.SecretService, | ||
| 131 | logSvc in.LogService, | 137 | logSvc in.LogService, | ||
| 132 | - | registrySvc in.RegistryService, | 138 | + | registrySvc registryDeployService, |
| 133 | eventBus out.EventPublisher, | 139 | eventBus out.EventPublisher, | ||
| 134 | log zerowrap.Logger, | 140 | log zerowrap.Logger, | ||
| 135 | backupSvc in.BackupService, | 141 | backupSvc in.BackupService, | ||
| @@ -213,6 +219,7 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) { | |||||
| 213 | {"/routes/by-image", h.handleRoutesByImage}, | 219 | {"/routes/by-image", h.handleRoutesByImage}, | ||
| 214 | {"/routes", h.handleRoutes}, | 220 | {"/routes", h.handleRoutes}, | ||
| 215 | {"/secrets", h.handleSecrets}, | 221 | {"/secrets", h.handleSecrets}, | ||
| 222 | + | {"/deploy-intent", h.handleDeployIntent}, | |||
| 216 | {"/deploy", h.handleDeploy}, | 223 | {"/deploy", h.handleDeploy}, | ||
| 217 | {"/restart", h.handleRestart}, | 224 | {"/restart", h.handleRestart}, | ||
| 218 | {"/tags", h.handleTags}, | 225 | {"/tags", h.handleTags}, | ||
| @@ -228,6 +235,48 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) { | |||||
| 228 | return nil, false | 235 | return nil, false | ||
| 229 | } | 236 | } | ||
| 230 | 237 | ||||
| 238 | + | // handleDeployIntent handles /admin/deploy-intent/:image endpoint. | |||
| 239 | + | // It registers a deploy intent, suppressing event-based deploys for the image. | |||
| 240 | + | func (h *Handler) handleDeployIntent(w http.ResponseWriter, r *http.Request, path string) { | |||
| 241 | + | if r.Method != http.MethodPost { | |||
| 242 | + | h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") | |||
| 243 | + | return | |||
| 244 | + | } | |||
| 245 | + | ||||
| 246 | + | ctx := r.Context() | |||
| 247 | + | if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) { | |||
| 248 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write") | |||
| 249 | + | return | |||
| 250 | + | } | |||
| 251 | + | ||||
| 252 | + | if h.registrySvc == nil { | |||
| 253 | + | h.sendError(w, http.StatusServiceUnavailable, "registry service unavailable") | |||
| 254 | + | return | |||
| 255 | + | } | |||
| 256 | + | ||||
| 257 | + | rawName := strings.TrimPrefix(path, "/deploy-intent/") | |||
| 258 | + | if rawName == "" || rawName == "/deploy-intent" { | |||
| 259 | + | h.sendError(w, http.StatusBadRequest, "image name required") | |||
| 260 | + | return | |||
| 261 | + | } | |||
| 262 | + | ||||
| 263 | + | imageName, err := url.PathUnescape(rawName) | |||
| 264 | + | if err != nil { | |||
| 265 | + | h.sendError(w, http.StatusBadRequest, "invalid image name encoding") | |||
| 266 | + | return | |||
| 267 | + | } | |||
| 268 | + | ||||
| 269 | + | log := zerowrap.FromCtx(ctx) | |||
| 270 | + | log.Info().Str("image", imageName).Msg("deploy intent registered, suppressing image.pushed events") | |||
| 271 | + | ||||
| 272 | + | h.registrySvc.SuppressDeployEvent(imageName) | |||
| 273 | + | ||||
| 274 | + | h.sendJSON(w, http.StatusOK, map[string]string{ | |||
| 275 | + | "status": "ok", | |||
| 276 | + | "image": imageName, | |||
| 277 | + | }) | |||
| 278 | + | } | |||
| 279 | + | ||||
| 231 | // sendJSON sends a JSON response. | 280 | // sendJSON sends a JSON response. | ||
| 232 | func (h *Handler) sendJSON(w http.ResponseWriter, status int, data any) { | 281 | func (h *Handler) sendJSON(w http.ResponseWriter, status int, data any) { | ||
| 233 | w.Header().Set("Content-Type", "application/json") | 282 | w.Header().Set("Content-Type", "application/json") | ||
| @@ -1078,6 +1127,15 @@ func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request, path stri | |||||
| 1078 | return | 1127 | return | ||
| 1079 | } | 1128 | } | ||
| 1080 | 1129 | ||||
| 1130 | + | // Clear deploy event suppression now that the explicit deploy has completed. | |||
| 1131 | + | // This re-enables event-based deploys for future direct docker pushes. | |||
| 1132 | + | if route.Image != "" && h.registrySvc != nil { | |||
| 1133 | + | // Use the registry package's image name normaliser so digest-form refs | |||
| 1134 | + | // and multi-segment paths are handled correctly. | |||
| 1135 | + | imageName := registry.ExtractImageName(route.Image) | |||
| 1136 | + | h.registrySvc.ClearDeployEventSuppression(imageName) | |||
| 1137 | + | } | |||
| 1138 | + | ||||
| 1081 | log.Info().Str("domain", deployDomain).Str("container_id", container.ID).Msg("container deployed via admin API") | 1139 | log.Info().Str("domain", deployDomain).Str("container_id", container.ID).Msg("container deployed via admin API") | ||
| 1082 | h.sendJSON(w, http.StatusOK, dto.DeployResponse{ | 1140 | h.sendJSON(w, http.StatusOK, dto.DeployResponse{ | ||
| 1083 | Status: "deployed", | 1141 | Status: "deployed", | ||
M internal/app/run.go +6 -2
| @@ -1187,12 +1187,14 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc | |||
| 1187 | 1187 | VolumePreserve: v.GetBool("volumes.preserve"), | |
| 1188 | 1188 | NetworkIsolation: v.GetBool("network_isolation.enabled"), | |
| 1189 | 1189 | NetworkPrefix: v.GetString("network_isolation.network_prefix"), | |
| 1190 | - | DNSSuffix: v.GetString("network_isolation.dns_suffix"), | |
| 1191 | 1190 | NetworkGroups: svc.configSvc.GetNetworkGroups(), | |
| 1192 | 1191 | Attachments: svc.configSvc.GetAttachments(), | |
| 1193 | 1192 | ReadinessDelay: v.GetDuration("deploy.readiness_delay"), | |
| 1194 | 1193 | ReadinessMode: v.GetString("deploy.readiness_mode"), | |
| 1195 | 1194 | HealthTimeout: v.GetDuration("deploy.health_timeout"), | |
| 1195 | + | StabilizationDelay: v.GetDuration("deploy.stabilization_delay"), | |
| 1196 | + | TCPProbeTimeout: v.GetDuration("deploy.tcp_probe_timeout"), | |
| 1197 | + | HTTPProbeTimeout: v.GetDuration("deploy.http_probe_timeout"), | |
| 1196 | 1198 | DrainDelay: v.GetDuration("deploy.drain_delay"), | |
| 1197 | 1199 | DrainMode: v.GetString("deploy.drain_mode"), | |
| 1198 | 1200 | DrainTimeout: v.GetDuration("deploy.drain_timeout"), | |
| @@ -2346,7 +2348,6 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||
| 2346 | 2348 | v.SetDefault("auto_route.enabled", false) | |
| 2347 | 2349 | v.SetDefault("network_isolation.enabled", false) | |
| 2348 | 2350 | v.SetDefault("network_isolation.network_prefix", "gordon") | |
| 2349 | - | v.SetDefault("network_isolation.dns_suffix", ".internal") | |
| 2350 | 2351 | v.SetDefault("volumes.auto_create", true) | |
| 2351 | 2352 | v.SetDefault("volumes.prefix", "gordon") | |
| 2352 | 2353 | v.SetDefault("volumes.preserve", true) | |
| @@ -2374,6 +2375,9 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||
| 2374 | 2375 | v.SetDefault("deploy.readiness_delay", "5s") | |
| 2375 | 2376 | v.SetDefault("deploy.readiness_mode", "auto") | |
| 2376 | 2377 | v.SetDefault("deploy.health_timeout", "90s") | |
| 2378 | + | v.SetDefault("deploy.stabilization_delay", "2s") | |
| 2379 | + | v.SetDefault("deploy.tcp_probe_timeout", "30s") | |
| 2380 | + | v.SetDefault("deploy.http_probe_timeout", "60s") | |
| 2377 | 2381 | v.SetDefault("deploy.drain_mode", "auto") | |
| 2378 | 2382 | v.SetDefault("deploy.drain_timeout", "30s") | |
| 2379 | 2383 | ||
| @@ -1187,12 +1187,14 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc | |||||
| 1187 | VolumePreserve: v.GetBool("volumes.preserve"), | 1187 | VolumePreserve: v.GetBool("volumes.preserve"), | ||
| 1188 | NetworkIsolation: v.GetBool("network_isolation.enabled"), | 1188 | NetworkIsolation: v.GetBool("network_isolation.enabled"), | ||
| 1189 | NetworkPrefix: v.GetString("network_isolation.network_prefix"), | 1189 | NetworkPrefix: v.GetString("network_isolation.network_prefix"), | ||
| 1190 | - | DNSSuffix: v.GetString("network_isolation.dns_suffix"), | |||
| 1191 | NetworkGroups: svc.configSvc.GetNetworkGroups(), | 1190 | NetworkGroups: svc.configSvc.GetNetworkGroups(), | ||
| 1192 | Attachments: svc.configSvc.GetAttachments(), | 1191 | Attachments: svc.configSvc.GetAttachments(), | ||
| 1193 | ReadinessDelay: v.GetDuration("deploy.readiness_delay"), | 1192 | ReadinessDelay: v.GetDuration("deploy.readiness_delay"), | ||
| 1194 | ReadinessMode: v.GetString("deploy.readiness_mode"), | 1193 | ReadinessMode: v.GetString("deploy.readiness_mode"), | ||
| 1195 | HealthTimeout: v.GetDuration("deploy.health_timeout"), | 1194 | HealthTimeout: v.GetDuration("deploy.health_timeout"), | ||
| 1195 | + | StabilizationDelay: v.GetDuration("deploy.stabilization_delay"), | |||
| 1196 | + | TCPProbeTimeout: v.GetDuration("deploy.tcp_probe_timeout"), | |||
| 1197 | + | HTTPProbeTimeout: v.GetDuration("deploy.http_probe_timeout"), | |||
| 1196 | DrainDelay: v.GetDuration("deploy.drain_delay"), | 1198 | DrainDelay: v.GetDuration("deploy.drain_delay"), | ||
| 1197 | DrainMode: v.GetString("deploy.drain_mode"), | 1199 | DrainMode: v.GetString("deploy.drain_mode"), | ||
| 1198 | DrainTimeout: v.GetDuration("deploy.drain_timeout"), | 1200 | DrainTimeout: v.GetDuration("deploy.drain_timeout"), | ||
| @@ -2346,7 +2348,6 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||||
| 2346 | v.SetDefault("auto_route.enabled", false) | 2348 | v.SetDefault("auto_route.enabled", false) | ||
| 2347 | v.SetDefault("network_isolation.enabled", false) | 2349 | v.SetDefault("network_isolation.enabled", false) | ||
| 2348 | v.SetDefault("network_isolation.network_prefix", "gordon") | 2350 | v.SetDefault("network_isolation.network_prefix", "gordon") | ||
| 2349 | - | v.SetDefault("network_isolation.dns_suffix", ".internal") | |||
| 2350 | v.SetDefault("volumes.auto_create", true) | 2351 | v.SetDefault("volumes.auto_create", true) | ||
| 2351 | v.SetDefault("volumes.prefix", "gordon") | 2352 | v.SetDefault("volumes.prefix", "gordon") | ||
| 2352 | v.SetDefault("volumes.preserve", true) | 2353 | v.SetDefault("volumes.preserve", true) | ||
| @@ -2374,6 +2375,9 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||||
| 2374 | v.SetDefault("deploy.readiness_delay", "5s") | 2375 | v.SetDefault("deploy.readiness_delay", "5s") | ||
| 2375 | v.SetDefault("deploy.readiness_mode", "auto") | 2376 | v.SetDefault("deploy.readiness_mode", "auto") | ||
| 2376 | v.SetDefault("deploy.health_timeout", "90s") | 2377 | v.SetDefault("deploy.health_timeout", "90s") | ||
| 2378 | + | v.SetDefault("deploy.stabilization_delay", "2s") | |||
| 2379 | + | v.SetDefault("deploy.tcp_probe_timeout", "30s") | |||
| 2380 | + | v.SetDefault("deploy.http_probe_timeout", "60s") | |||
| 2377 | v.SetDefault("deploy.drain_mode", "auto") | 2381 | v.SetDefault("deploy.drain_mode", "auto") | ||
| 2378 | v.SetDefault("deploy.drain_timeout", "30s") | 2382 | v.SetDefault("deploy.drain_timeout", "30s") | ||
| 2379 | 2383 | ||||
A internal/boundaries/in/deploy.go +10 -0
| @@ -0,0 +1,10 @@ | |||
| 1 | + | package in | |
| 2 | + | ||
| 3 | + | // DeployCoordinator defines deploy coordination operations. | |
| 4 | + | // | |
| 5 | + | // These methods let the CLI/admin API suppress image-pushed deploy events | |
| 6 | + | // while an explicit deploy flow is in progress. | |
| 7 | + | type DeployCoordinator interface { | |
| 8 | + | SuppressDeployEvent(imageName string) | |
| 9 | + | ClearDeployEventSuppression(imageName string) | |
| 10 | + | } | |
| @@ -0,0 +1,10 @@ | |||||
| 1 | + | package in | |||
| 2 | + | ||||
| 3 | + | // DeployCoordinator defines deploy coordination operations. | |||
| 4 | + | // | |||
| 5 | + | // These methods let the CLI/admin API suppress image-pushed deploy events | |||
| 6 | + | // while an explicit deploy flow is in progress. | |||
| 7 | + | type DeployCoordinator interface { | |||
| 8 | + | SuppressDeployEvent(imageName string) | |||
| 9 | + | ClearDeployEventSuppression(imageName string) | |||
| 10 | + | } | |||
A internal/boundaries/in/mocks/mock_deploy_coordinator.go +110 -0
| @@ -0,0 +1,110 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | mock "github.com/stretchr/testify/mock" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // NewMockDeployCoordinator creates a new instance of MockDeployCoordinator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 12 | + | // The first argument is typically a *testing.T value. | |
| 13 | + | func NewMockDeployCoordinator(t interface { | |
| 14 | + | mock.TestingT | |
| 15 | + | Cleanup(func()) | |
| 16 | + | }) *MockDeployCoordinator { | |
| 17 | + | mock := &MockDeployCoordinator{} | |
| 18 | + | mock.Mock.Test(t) | |
| 19 | + | ||
| 20 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 21 | + | ||
| 22 | + | return mock | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // MockDeployCoordinator is an autogenerated mock type for the DeployCoordinator type. | |
| 26 | + | type MockDeployCoordinator struct { | |
| 27 | + | mock.Mock | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | type MockDeployCoordinator_Expecter struct { | |
| 31 | + | mock *mock.Mock | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func (_m *MockDeployCoordinator) EXPECT() *MockDeployCoordinator_Expecter { | |
| 35 | + | return &MockDeployCoordinator_Expecter{mock: &_m.Mock} | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // ClearDeployEventSuppression provides a mock function for the type MockDeployCoordinator. | |
| 39 | + | func (_mock *MockDeployCoordinator) ClearDeployEventSuppression(imageName string) { | |
| 40 | + | _mock.Called(imageName) | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // MockDeployCoordinator_ClearDeployEventSuppression_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClearDeployEventSuppression'. | |
| 44 | + | type MockDeployCoordinator_ClearDeployEventSuppression_Call struct { | |
| 45 | + | *mock.Call | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // ClearDeployEventSuppression is a helper method to define mock.On call. | |
| 49 | + | // - imageName string | |
| 50 | + | func (_e *MockDeployCoordinator_Expecter) ClearDeployEventSuppression(imageName interface{}) *MockDeployCoordinator_ClearDeployEventSuppression_Call { | |
| 51 | + | return &MockDeployCoordinator_ClearDeployEventSuppression_Call{Call: _e.mock.On("ClearDeployEventSuppression", imageName)} | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func (_c *MockDeployCoordinator_ClearDeployEventSuppression_Call) Run(run func(imageName string)) *MockDeployCoordinator_ClearDeployEventSuppression_Call { | |
| 55 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 56 | + | var arg0 string | |
| 57 | + | if args[0] != nil { | |
| 58 | + | arg0 = args[0].(string) | |
| 59 | + | } | |
| 60 | + | run(arg0) | |
| 61 | + | }) | |
| 62 | + | return _c | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func (_c *MockDeployCoordinator_ClearDeployEventSuppression_Call) Return() *MockDeployCoordinator_ClearDeployEventSuppression_Call { | |
| 66 | + | _c.Call.Return() | |
| 67 | + | return _c | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func (_c *MockDeployCoordinator_ClearDeployEventSuppression_Call) RunAndReturn(run func(imageName string)) *MockDeployCoordinator_ClearDeployEventSuppression_Call { | |
| 71 | + | _c.Run(run) | |
| 72 | + | return _c | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // SuppressDeployEvent provides a mock function for the type MockDeployCoordinator. | |
| 76 | + | func (_mock *MockDeployCoordinator) SuppressDeployEvent(imageName string) { | |
| 77 | + | _mock.Called(imageName) | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // MockDeployCoordinator_SuppressDeployEvent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SuppressDeployEvent'. | |
| 81 | + | type MockDeployCoordinator_SuppressDeployEvent_Call struct { | |
| 82 | + | *mock.Call | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // SuppressDeployEvent is a helper method to define mock.On call. | |
| 86 | + | // - imageName string | |
| 87 | + | func (_e *MockDeployCoordinator_Expecter) SuppressDeployEvent(imageName interface{}) *MockDeployCoordinator_SuppressDeployEvent_Call { | |
| 88 | + | return &MockDeployCoordinator_SuppressDeployEvent_Call{Call: _e.mock.On("SuppressDeployEvent", imageName)} | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func (_c *MockDeployCoordinator_SuppressDeployEvent_Call) Run(run func(imageName string)) *MockDeployCoordinator_SuppressDeployEvent_Call { | |
| 92 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 93 | + | var arg0 string | |
| 94 | + | if args[0] != nil { | |
| 95 | + | arg0 = args[0].(string) | |
| 96 | + | } | |
| 97 | + | run(arg0) | |
| 98 | + | }) | |
| 99 | + | return _c | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func (_c *MockDeployCoordinator_SuppressDeployEvent_Call) Return() *MockDeployCoordinator_SuppressDeployEvent_Call { | |
| 103 | + | _c.Call.Return() | |
| 104 | + | return _c | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func (_c *MockDeployCoordinator_SuppressDeployEvent_Call) RunAndReturn(run func(imageName string)) *MockDeployCoordinator_SuppressDeployEvent_Call { | |
| 108 | + | _c.Run(run) | |
| 109 | + | return _c | |
| 110 | + | } | |
| @@ -0,0 +1,110 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package mocks | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | mock "github.com/stretchr/testify/mock" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // NewMockDeployCoordinator creates a new instance of MockDeployCoordinator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 12 | + | // The first argument is typically a *testing.T value. | |||
| 13 | + | func NewMockDeployCoordinator(t interface { | |||
| 14 | + | mock.TestingT | |||
| 15 | + | Cleanup(func()) | |||
| 16 | + | }) *MockDeployCoordinator { | |||
| 17 | + | mock := &MockDeployCoordinator{} | |||
| 18 | + | mock.Mock.Test(t) | |||
| 19 | + | ||||
| 20 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |||
| 21 | + | ||||
| 22 | + | return mock | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // MockDeployCoordinator is an autogenerated mock type for the DeployCoordinator type. | |||
| 26 | + | type MockDeployCoordinator struct { | |||
| 27 | + | mock.Mock | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | type MockDeployCoordinator_Expecter struct { | |||
| 31 | + | mock *mock.Mock | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func (_m *MockDeployCoordinator) EXPECT() *MockDeployCoordinator_Expecter { | |||
| 35 | + | return &MockDeployCoordinator_Expecter{mock: &_m.Mock} | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // ClearDeployEventSuppression provides a mock function for the type MockDeployCoordinator. | |||
| 39 | + | func (_mock *MockDeployCoordinator) ClearDeployEventSuppression(imageName string) { | |||
| 40 | + | _mock.Called(imageName) | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | // MockDeployCoordinator_ClearDeployEventSuppression_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClearDeployEventSuppression'. | |||
| 44 | + | type MockDeployCoordinator_ClearDeployEventSuppression_Call struct { | |||
| 45 | + | *mock.Call | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // ClearDeployEventSuppression is a helper method to define mock.On call. | |||
| 49 | + | // - imageName string | |||
| 50 | + | func (_e *MockDeployCoordinator_Expecter) ClearDeployEventSuppression(imageName interface{}) *MockDeployCoordinator_ClearDeployEventSuppression_Call { | |||
| 51 | + | return &MockDeployCoordinator_ClearDeployEventSuppression_Call{Call: _e.mock.On("ClearDeployEventSuppression", imageName)} | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func (_c *MockDeployCoordinator_ClearDeployEventSuppression_Call) Run(run func(imageName string)) *MockDeployCoordinator_ClearDeployEventSuppression_Call { | |||
| 55 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 56 | + | var arg0 string | |||
| 57 | + | if args[0] != nil { | |||
| 58 | + | arg0 = args[0].(string) | |||
| 59 | + | } | |||
| 60 | + | run(arg0) | |||
| 61 | + | }) | |||
| 62 | + | return _c | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func (_c *MockDeployCoordinator_ClearDeployEventSuppression_Call) Return() *MockDeployCoordinator_ClearDeployEventSuppression_Call { | |||
| 66 | + | _c.Call.Return() | |||
| 67 | + | return _c | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func (_c *MockDeployCoordinator_ClearDeployEventSuppression_Call) RunAndReturn(run func(imageName string)) *MockDeployCoordinator_ClearDeployEventSuppression_Call { | |||
| 71 | + | _c.Run(run) | |||
| 72 | + | return _c | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // SuppressDeployEvent provides a mock function for the type MockDeployCoordinator. | |||
| 76 | + | func (_mock *MockDeployCoordinator) SuppressDeployEvent(imageName string) { | |||
| 77 | + | _mock.Called(imageName) | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | // MockDeployCoordinator_SuppressDeployEvent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SuppressDeployEvent'. | |||
| 81 | + | type MockDeployCoordinator_SuppressDeployEvent_Call struct { | |||
| 82 | + | *mock.Call | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | // SuppressDeployEvent is a helper method to define mock.On call. | |||
| 86 | + | // - imageName string | |||
| 87 | + | func (_e *MockDeployCoordinator_Expecter) SuppressDeployEvent(imageName interface{}) *MockDeployCoordinator_SuppressDeployEvent_Call { | |||
| 88 | + | return &MockDeployCoordinator_SuppressDeployEvent_Call{Call: _e.mock.On("SuppressDeployEvent", imageName)} | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func (_c *MockDeployCoordinator_SuppressDeployEvent_Call) Run(run func(imageName string)) *MockDeployCoordinator_SuppressDeployEvent_Call { | |||
| 92 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 93 | + | var arg0 string | |||
| 94 | + | if args[0] != nil { | |||
| 95 | + | arg0 = args[0].(string) | |||
| 96 | + | } | |||
| 97 | + | run(arg0) | |||
| 98 | + | }) | |||
| 99 | + | return _c | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | func (_c *MockDeployCoordinator_SuppressDeployEvent_Call) Return() *MockDeployCoordinator_SuppressDeployEvent_Call { | |||
| 103 | + | _c.Call.Return() | |||
| 104 | + | return _c | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func (_c *MockDeployCoordinator_SuppressDeployEvent_Call) RunAndReturn(run func(imageName string)) *MockDeployCoordinator_SuppressDeployEvent_Call { | |||
| 108 | + | _c.Run(run) | |||
| 109 | + | return _c | |||
| 110 | + | } | |||
M internal/boundaries/in/mocks/mock_registry_service.go +80 -0
| @@ -231,6 +231,46 @@ func (_c *MockRegistryService_CancelUpload_Call) RunAndReturn(run func(ctx conte | |||
| 231 | 231 | return _c | |
| 232 | 232 | } | |
| 233 | 233 | ||
| 234 | + | // ClearDeployEventSuppression provides a mock function for the type MockRegistryService | |
| 235 | + | func (_mock *MockRegistryService) ClearDeployEventSuppression(imageName string) { | |
| 236 | + | _mock.Called(imageName) | |
| 237 | + | return | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | // MockRegistryService_ClearDeployEventSuppression_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClearDeployEventSuppression' | |
| 241 | + | type MockRegistryService_ClearDeployEventSuppression_Call struct { | |
| 242 | + | *mock.Call | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | // ClearDeployEventSuppression is a helper method to define mock.On call | |
| 246 | + | // - imageName string | |
| 247 | + | func (_e *MockRegistryService_Expecter) ClearDeployEventSuppression(imageName interface{}) *MockRegistryService_ClearDeployEventSuppression_Call { | |
| 248 | + | return &MockRegistryService_ClearDeployEventSuppression_Call{Call: _e.mock.On("ClearDeployEventSuppression", imageName)} | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | func (_c *MockRegistryService_ClearDeployEventSuppression_Call) Run(run func(imageName string)) *MockRegistryService_ClearDeployEventSuppression_Call { | |
| 252 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 253 | + | var arg0 string | |
| 254 | + | if args[0] != nil { | |
| 255 | + | arg0 = args[0].(string) | |
| 256 | + | } | |
| 257 | + | run( | |
| 258 | + | arg0, | |
| 259 | + | ) | |
| 260 | + | }) | |
| 261 | + | return _c | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | func (_c *MockRegistryService_ClearDeployEventSuppression_Call) Return() *MockRegistryService_ClearDeployEventSuppression_Call { | |
| 265 | + | _c.Call.Return() | |
| 266 | + | return _c | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | func (_c *MockRegistryService_ClearDeployEventSuppression_Call) RunAndReturn(run func(imageName string)) *MockRegistryService_ClearDeployEventSuppression_Call { | |
| 270 | + | _c.Run(run) | |
| 271 | + | return _c | |
| 272 | + | } | |
| 273 | + | ||
| 234 | 274 | // DeleteManifest provides a mock function for the type MockRegistryService | |
| 235 | 275 | func (_mock *MockRegistryService) DeleteManifest(ctx context.Context, name string, reference string) error { | |
| 236 | 276 | ret := _mock.Called(ctx, name, reference) | |
| @@ -895,3 +935,43 @@ func (_c *MockRegistryService_StartUpload_Call) RunAndReturn(run func(ctx contex | |||
| 895 | 935 | _c.Call.Return(run) | |
| 896 | 936 | return _c | |
| 897 | 937 | } | |
| 938 | + | ||
| 939 | + | // SuppressDeployEvent provides a mock function for the type MockRegistryService | |
| 940 | + | func (_mock *MockRegistryService) SuppressDeployEvent(imageName string) { | |
| 941 | + | _mock.Called(imageName) | |
| 942 | + | return | |
| 943 | + | } | |
| 944 | + | ||
| 945 | + | // MockRegistryService_SuppressDeployEvent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SuppressDeployEvent' | |
| 946 | + | type MockRegistryService_SuppressDeployEvent_Call struct { | |
| 947 | + | *mock.Call | |
| 948 | + | } | |
| 949 | + | ||
| 950 | + | // SuppressDeployEvent is a helper method to define mock.On call | |
| 951 | + | // - imageName string | |
| 952 | + | func (_e *MockRegistryService_Expecter) SuppressDeployEvent(imageName interface{}) *MockRegistryService_SuppressDeployEvent_Call { | |
| 953 | + | return &MockRegistryService_SuppressDeployEvent_Call{Call: _e.mock.On("SuppressDeployEvent", imageName)} | |
| 954 | + | } | |
| 955 | + | ||
| 956 | + | func (_c *MockRegistryService_SuppressDeployEvent_Call) Run(run func(imageName string)) *MockRegistryService_SuppressDeployEvent_Call { | |
| 957 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 958 | + | var arg0 string | |
| 959 | + | if args[0] != nil { | |
| 960 | + | arg0 = args[0].(string) | |
| 961 | + | } | |
| 962 | + | run( | |
| 963 | + | arg0, | |
| 964 | + | ) | |
| 965 | + | }) | |
| 966 | + | return _c | |
| 967 | + | } | |
| 968 | + | ||
| 969 | + | func (_c *MockRegistryService_SuppressDeployEvent_Call) Return() *MockRegistryService_SuppressDeployEvent_Call { | |
| 970 | + | _c.Call.Return() | |
| 971 | + | return _c | |
| 972 | + | } | |
| 973 | + | ||
| 974 | + | func (_c *MockRegistryService_SuppressDeployEvent_Call) RunAndReturn(run func(imageName string)) *MockRegistryService_SuppressDeployEvent_Call { | |
| 975 | + | _c.Run(run) | |
| 976 | + | return _c | |
| 977 | + | } | |
| @@ -231,6 +231,46 @@ func (_c *MockRegistryService_CancelUpload_Call) RunAndReturn(run func(ctx conte | |||||
| 231 | return _c | 231 | return _c | ||
| 232 | } | 232 | } | ||
| 233 | 233 | ||||
| 234 | + | // ClearDeployEventSuppression provides a mock function for the type MockRegistryService | |||
| 235 | + | func (_mock *MockRegistryService) ClearDeployEventSuppression(imageName string) { | |||
| 236 | + | _mock.Called(imageName) | |||
| 237 | + | return | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | // MockRegistryService_ClearDeployEventSuppression_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClearDeployEventSuppression' | |||
| 241 | + | type MockRegistryService_ClearDeployEventSuppression_Call struct { | |||
| 242 | + | *mock.Call | |||
| 243 | + | } | |||
| 244 | + | ||||
| 245 | + | // ClearDeployEventSuppression is a helper method to define mock.On call | |||
| 246 | + | // - imageName string | |||
| 247 | + | func (_e *MockRegistryService_Expecter) ClearDeployEventSuppression(imageName interface{}) *MockRegistryService_ClearDeployEventSuppression_Call { | |||
| 248 | + | return &MockRegistryService_ClearDeployEventSuppression_Call{Call: _e.mock.On("ClearDeployEventSuppression", imageName)} | |||
| 249 | + | } | |||
| 250 | + | ||||
| 251 | + | func (_c *MockRegistryService_ClearDeployEventSuppression_Call) Run(run func(imageName string)) *MockRegistryService_ClearDeployEventSuppression_Call { | |||
| 252 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 253 | + | var arg0 string | |||
| 254 | + | if args[0] != nil { | |||
| 255 | + | arg0 = args[0].(string) | |||
| 256 | + | } | |||
| 257 | + | run( | |||
| 258 | + | arg0, | |||
| 259 | + | ) | |||
| 260 | + | }) | |||
| 261 | + | return _c | |||
| 262 | + | } | |||
| 263 | + | ||||
| 264 | + | func (_c *MockRegistryService_ClearDeployEventSuppression_Call) Return() *MockRegistryService_ClearDeployEventSuppression_Call { | |||
| 265 | + | _c.Call.Return() | |||
| 266 | + | return _c | |||
| 267 | + | } | |||
| 268 | + | ||||
| 269 | + | func (_c *MockRegistryService_ClearDeployEventSuppression_Call) RunAndReturn(run func(imageName string)) *MockRegistryService_ClearDeployEventSuppression_Call { | |||
| 270 | + | _c.Run(run) | |||
| 271 | + | return _c | |||
| 272 | + | } | |||
| 273 | + | ||||
| 234 | // DeleteManifest provides a mock function for the type MockRegistryService | 274 | // DeleteManifest provides a mock function for the type MockRegistryService | ||
| 235 | func (_mock *MockRegistryService) DeleteManifest(ctx context.Context, name string, reference string) error { | 275 | func (_mock *MockRegistryService) DeleteManifest(ctx context.Context, name string, reference string) error { | ||
| 236 | ret := _mock.Called(ctx, name, reference) | 276 | ret := _mock.Called(ctx, name, reference) | ||
| @@ -895,3 +935,43 @@ func (_c *MockRegistryService_StartUpload_Call) RunAndReturn(run func(ctx contex | |||||
| 895 | _c.Call.Return(run) | 935 | _c.Call.Return(run) | ||
| 896 | return _c | 936 | return _c | ||
| 897 | } | 937 | } | ||
| 938 | + | ||||
| 939 | + | // SuppressDeployEvent provides a mock function for the type MockRegistryService | |||
| 940 | + | func (_mock *MockRegistryService) SuppressDeployEvent(imageName string) { | |||
| 941 | + | _mock.Called(imageName) | |||
| 942 | + | return | |||
| 943 | + | } | |||
| 944 | + | ||||
| 945 | + | // MockRegistryService_SuppressDeployEvent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SuppressDeployEvent' | |||
| 946 | + | type MockRegistryService_SuppressDeployEvent_Call struct { | |||
| 947 | + | *mock.Call | |||
| 948 | + | } | |||
| 949 | + | ||||
| 950 | + | // SuppressDeployEvent is a helper method to define mock.On call | |||
| 951 | + | // - imageName string | |||
| 952 | + | func (_e *MockRegistryService_Expecter) SuppressDeployEvent(imageName interface{}) *MockRegistryService_SuppressDeployEvent_Call { | |||
| 953 | + | return &MockRegistryService_SuppressDeployEvent_Call{Call: _e.mock.On("SuppressDeployEvent", imageName)} | |||
| 954 | + | } | |||
| 955 | + | ||||
| 956 | + | func (_c *MockRegistryService_SuppressDeployEvent_Call) Run(run func(imageName string)) *MockRegistryService_SuppressDeployEvent_Call { | |||
| 957 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 958 | + | var arg0 string | |||
| 959 | + | if args[0] != nil { | |||
| 960 | + | arg0 = args[0].(string) | |||
| 961 | + | } | |||
| 962 | + | run( | |||
| 963 | + | arg0, | |||
| 964 | + | ) | |||
| 965 | + | }) | |||
| 966 | + | return _c | |||
| 967 | + | } | |||
| 968 | + | ||||
| 969 | + | func (_c *MockRegistryService_SuppressDeployEvent_Call) Return() *MockRegistryService_SuppressDeployEvent_Call { | |||
| 970 | + | _c.Call.Return() | |||
| 971 | + | return _c | |||
| 972 | + | } | |||
| 973 | + | ||||
| 974 | + | func (_c *MockRegistryService_SuppressDeployEvent_Call) RunAndReturn(run func(imageName string)) *MockRegistryService_SuppressDeployEvent_Call { | |||
| 975 | + | _c.Run(run) | |||
| 976 | + | return _c | |||
| 977 | + | } | |||
A internal/usecase/container/readiness.go +137 -0
| @@ -0,0 +1,137 @@ | |||
| 1 | + | package container | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "net" | |
| 8 | + | "net/http" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/zerowrap" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | var errProbeTimeout = errors.New("probe timeout") | |
| 15 | + | ||
| 16 | + | func probeLoop( | |
| 17 | + | ctx context.Context, | |
| 18 | + | deadline time.Time, | |
| 19 | + | baseSleep time.Duration, | |
| 20 | + | defaultAttemptTimeout time.Duration, | |
| 21 | + | work func(attemptCtx context.Context) (success bool, err error), | |
| 22 | + | ) error { | |
| 23 | + | for { | |
| 24 | + | if err := ctx.Err(); err != nil { | |
| 25 | + | return err | |
| 26 | + | } | |
| 27 | + | if time.Now().After(deadline) { | |
| 28 | + | return errProbeTimeout | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | remaining := time.Until(deadline) | |
| 32 | + | attemptTimeout := defaultAttemptTimeout | |
| 33 | + | if remaining < attemptTimeout { | |
| 34 | + | attemptTimeout = remaining | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | attemptCtx, cancel := context.WithTimeout(ctx, attemptTimeout) | |
| 38 | + | success, err := work(attemptCtx) | |
| 39 | + | cancel() | |
| 40 | + | if err != nil { | |
| 41 | + | return err | |
| 42 | + | } | |
| 43 | + | if success { | |
| 44 | + | return nil | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | sleepInterval := baseSleep | |
| 48 | + | if remProbe := time.Until(deadline); remProbe < sleepInterval { | |
| 49 | + | sleepInterval = remProbe | |
| 50 | + | } | |
| 51 | + | if ctxDeadline, ok := ctx.Deadline(); ok { | |
| 52 | + | if remCtx := time.Until(ctxDeadline); remCtx < sleepInterval { | |
| 53 | + | sleepInterval = remCtx | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | if sleepInterval <= 0 { | |
| 57 | + | if ctx.Err() != nil { | |
| 58 | + | return ctx.Err() | |
| 59 | + | } | |
| 60 | + | return errProbeTimeout | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | t := time.NewTimer(sleepInterval) | |
| 64 | + | select { | |
| 65 | + | case <-t.C: | |
| 66 | + | case <-ctx.Done(): | |
| 67 | + | t.Stop() | |
| 68 | + | return ctx.Err() | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // tcpProbe attempts a TCP connection to addr, retrying every 500ms until | |
| 74 | + | // success or timeout. This is the universal fallback readiness check — | |
| 75 | + | // it verifies the process is at least accepting connections. | |
| 76 | + | func tcpProbe(ctx context.Context, addr string, timeout time.Duration) error { | |
| 77 | + | log := zerowrap.FromCtx(ctx) | |
| 78 | + | deadline := time.Now().Add(timeout) | |
| 79 | + | dialer := &net.Dialer{} | |
| 80 | + | attempts := 0 | |
| 81 | + | var lastErr error | |
| 82 | + | err := probeLoop(ctx, deadline, 500*time.Millisecond, time.Second, func(attemptCtx context.Context) (bool, error) { | |
| 83 | + | conn, err := dialer.DialContext(attemptCtx, "tcp", addr) | |
| 84 | + | attempts++ | |
| 85 | + | if err == nil { | |
| 86 | + | conn.Close() | |
| 87 | + | log.Debug().Str("addr", addr).Int("attempts", attempts).Msg("TCP probe connected") | |
| 88 | + | return true, nil | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | lastErr = err | |
| 92 | + | if attempts <= 3 || attempts%10 == 0 { | |
| 93 | + | log.Debug().Err(err).Str("addr", addr).Int("attempt", attempts).Msg("TCP probe attempt failed") | |
| 94 | + | } | |
| 95 | + | return false, nil | |
| 96 | + | }) | |
| 97 | + | if errors.Is(err, errProbeTimeout) { | |
| 98 | + | return fmt.Errorf("TCP probe timeout after %s: %s not reachable (attempts=%d, last_error=%v)", timeout, addr, attempts, lastErr) | |
| 99 | + | } | |
| 100 | + | return err | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // httpProbe performs HTTP GET requests to url, retrying every 1s until a | |
| 104 | + | // 2xx/3xx response or timeout. Used when gordon.health label is set. | |
| 105 | + | func httpProbe(ctx context.Context, url string, timeout time.Duration) error { | |
| 106 | + | client := &http.Client{ | |
| 107 | + | CheckRedirect: func(req *http.Request, via []*http.Request) error { | |
| 108 | + | // Don't follow redirects — treat 3xx as a successful response. | |
| 109 | + | return http.ErrUseLastResponse | |
| 110 | + | }, | |
| 111 | + | } | |
| 112 | + | deadline := time.Now().Add(timeout) | |
| 113 | + | var lastStatus int | |
| 114 | + | err := probeLoop(ctx, deadline, time.Second, 2*time.Second, func(attemptCtx context.Context) (bool, error) { | |
| 115 | + | req, reqErr := http.NewRequestWithContext(attemptCtx, http.MethodGet, url, nil) | |
| 116 | + | if reqErr != nil { | |
| 117 | + | return false, reqErr | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | resp, err := client.Do(req) | |
| 121 | + | if err != nil { | |
| 122 | + | return false, nil | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | lastStatus = resp.StatusCode | |
| 126 | + | resp.Body.Close() | |
| 127 | + | if lastStatus >= 200 && lastStatus < 400 { | |
| 128 | + | return true, nil | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | return false, nil | |
| 132 | + | }) | |
| 133 | + | if errors.Is(err, errProbeTimeout) { | |
| 134 | + | return fmt.Errorf("HTTP probe timeout after %s: last status %d from %s", timeout, lastStatus, url) | |
| 135 | + | } | |
| 136 | + | return err | |
| 137 | + | } | |
| @@ -0,0 +1,137 @@ | |||||
| 1 | + | package container | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "fmt" | |||
| 7 | + | "net" | |||
| 8 | + | "net/http" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/zerowrap" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | var errProbeTimeout = errors.New("probe timeout") | |||
| 15 | + | ||||
| 16 | + | func probeLoop( | |||
| 17 | + | ctx context.Context, | |||
| 18 | + | deadline time.Time, | |||
| 19 | + | baseSleep time.Duration, | |||
| 20 | + | defaultAttemptTimeout time.Duration, | |||
| 21 | + | work func(attemptCtx context.Context) (success bool, err error), | |||
| 22 | + | ) error { | |||
| 23 | + | for { | |||
| 24 | + | if err := ctx.Err(); err != nil { | |||
| 25 | + | return err | |||
| 26 | + | } | |||
| 27 | + | if time.Now().After(deadline) { | |||
| 28 | + | return errProbeTimeout | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | remaining := time.Until(deadline) | |||
| 32 | + | attemptTimeout := defaultAttemptTimeout | |||
| 33 | + | if remaining < attemptTimeout { | |||
| 34 | + | attemptTimeout = remaining | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | attemptCtx, cancel := context.WithTimeout(ctx, attemptTimeout) | |||
| 38 | + | success, err := work(attemptCtx) | |||
| 39 | + | cancel() | |||
| 40 | + | if err != nil { | |||
| 41 | + | return err | |||
| 42 | + | } | |||
| 43 | + | if success { | |||
| 44 | + | return nil | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | sleepInterval := baseSleep | |||
| 48 | + | if remProbe := time.Until(deadline); remProbe < sleepInterval { | |||
| 49 | + | sleepInterval = remProbe | |||
| 50 | + | } | |||
| 51 | + | if ctxDeadline, ok := ctx.Deadline(); ok { | |||
| 52 | + | if remCtx := time.Until(ctxDeadline); remCtx < sleepInterval { | |||
| 53 | + | sleepInterval = remCtx | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 56 | + | if sleepInterval <= 0 { | |||
| 57 | + | if ctx.Err() != nil { | |||
| 58 | + | return ctx.Err() | |||
| 59 | + | } | |||
| 60 | + | return errProbeTimeout | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | t := time.NewTimer(sleepInterval) | |||
| 64 | + | select { | |||
| 65 | + | case <-t.C: | |||
| 66 | + | case <-ctx.Done(): | |||
| 67 | + | t.Stop() | |||
| 68 | + | return ctx.Err() | |||
| 69 | + | } | |||
| 70 | + | } | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // tcpProbe attempts a TCP connection to addr, retrying every 500ms until | |||
| 74 | + | // success or timeout. This is the universal fallback readiness check — | |||
| 75 | + | // it verifies the process is at least accepting connections. | |||
| 76 | + | func tcpProbe(ctx context.Context, addr string, timeout time.Duration) error { | |||
| 77 | + | log := zerowrap.FromCtx(ctx) | |||
| 78 | + | deadline := time.Now().Add(timeout) | |||
| 79 | + | dialer := &net.Dialer{} | |||
| 80 | + | attempts := 0 | |||
| 81 | + | var lastErr error | |||
| 82 | + | err := probeLoop(ctx, deadline, 500*time.Millisecond, time.Second, func(attemptCtx context.Context) (bool, error) { | |||
| 83 | + | conn, err := dialer.DialContext(attemptCtx, "tcp", addr) | |||
| 84 | + | attempts++ | |||
| 85 | + | if err == nil { | |||
| 86 | + | conn.Close() | |||
| 87 | + | log.Debug().Str("addr", addr).Int("attempts", attempts).Msg("TCP probe connected") | |||
| 88 | + | return true, nil | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | lastErr = err | |||
| 92 | + | if attempts <= 3 || attempts%10 == 0 { | |||
| 93 | + | log.Debug().Err(err).Str("addr", addr).Int("attempt", attempts).Msg("TCP probe attempt failed") | |||
| 94 | + | } | |||
| 95 | + | return false, nil | |||
| 96 | + | }) | |||
| 97 | + | if errors.Is(err, errProbeTimeout) { | |||
| 98 | + | return fmt.Errorf("TCP probe timeout after %s: %s not reachable (attempts=%d, last_error=%v)", timeout, addr, attempts, lastErr) | |||
| 99 | + | } | |||
| 100 | + | return err | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | // httpProbe performs HTTP GET requests to url, retrying every 1s until a | |||
| 104 | + | // 2xx/3xx response or timeout. Used when gordon.health label is set. | |||
| 105 | + | func httpProbe(ctx context.Context, url string, timeout time.Duration) error { | |||
| 106 | + | client := &http.Client{ | |||
| 107 | + | CheckRedirect: func(req *http.Request, via []*http.Request) error { | |||
| 108 | + | // Don't follow redirects — treat 3xx as a successful response. | |||
| 109 | + | return http.ErrUseLastResponse | |||
| 110 | + | }, | |||
| 111 | + | } | |||
| 112 | + | deadline := time.Now().Add(timeout) | |||
| 113 | + | var lastStatus int | |||
| 114 | + | err := probeLoop(ctx, deadline, time.Second, 2*time.Second, func(attemptCtx context.Context) (bool, error) { | |||
| 115 | + | req, reqErr := http.NewRequestWithContext(attemptCtx, http.MethodGet, url, nil) | |||
| 116 | + | if reqErr != nil { | |||
| 117 | + | return false, reqErr | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | resp, err := client.Do(req) | |||
| 121 | + | if err != nil { | |||
| 122 | + | return false, nil | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | lastStatus = resp.StatusCode | |||
| 126 | + | resp.Body.Close() | |||
| 127 | + | if lastStatus >= 200 && lastStatus < 400 { | |||
| 128 | + | return true, nil | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | return false, nil | |||
| 132 | + | }) | |||
| 133 | + | if errors.Is(err, errProbeTimeout) { | |||
| 134 | + | return fmt.Errorf("HTTP probe timeout after %s: last status %d from %s", timeout, lastStatus, url) | |||
| 135 | + | } | |||
| 136 | + | return err | |||
| 137 | + | } | |||
A internal/usecase/container/readiness_test.go +167 -0
| @@ -0,0 +1,167 @@ | |||
| 1 | + | package container | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "net" | |
| 6 | + | "net/http" | |
| 7 | + | "net/http/httptest" | |
| 8 | + | "sync/atomic" | |
| 9 | + | "syscall" | |
| 10 | + | "testing" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "github.com/stretchr/testify/assert" | |
| 14 | + | "github.com/stretchr/testify/require" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // --- TCP Probe Tests --- | |
| 18 | + | ||
| 19 | + | func TestTCPProbe_Success(t *testing.T) { | |
| 20 | + | ln, err := net.Listen("tcp", "127.0.0.1:0") | |
| 21 | + | require.NoError(t, err) | |
| 22 | + | defer ln.Close() | |
| 23 | + | ||
| 24 | + | // Accept connections in background so the probe succeeds | |
| 25 | + | go func() { | |
| 26 | + | for { | |
| 27 | + | conn, err := ln.Accept() | |
| 28 | + | if err != nil { | |
| 29 | + | return | |
| 30 | + | } | |
| 31 | + | conn.Close() | |
| 32 | + | } | |
| 33 | + | }() | |
| 34 | + | ||
| 35 | + | ctx := testContext() | |
| 36 | + | err = tcpProbe(ctx, ln.Addr().String(), 5*time.Second) | |
| 37 | + | assert.NoError(t, err) | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func TestTCPProbe_Timeout(t *testing.T) { | |
| 41 | + | // Reserve an ephemeral port and close it immediately to get a deterministic | |
| 42 | + | // unreachable target, avoiding assumptions about privileged ports. | |
| 43 | + | ln, err := net.Listen("tcp", "127.0.0.1:0") | |
| 44 | + | require.NoError(t, err) | |
| 45 | + | addr := ln.Addr().String() | |
| 46 | + | ln.Close() | |
| 47 | + | ||
| 48 | + | ctx := testContext() | |
| 49 | + | err = tcpProbe(ctx, addr, 200*time.Millisecond) | |
| 50 | + | assert.Error(t, err) | |
| 51 | + | assert.Contains(t, err.Error(), "TCP probe timeout") | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func TestTCPProbe_ContextCancelled(t *testing.T) { | |
| 55 | + | ctx, cancel := context.WithCancel(testContext()) | |
| 56 | + | cancel() | |
| 57 | + | err := tcpProbe(ctx, "127.0.0.1:1", 5*time.Second) | |
| 58 | + | assert.Error(t, err) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func TestTCPProbe_DelayedListener(t *testing.T) { | |
| 62 | + | ln, err := net.Listen("tcp", "127.0.0.1:0") | |
| 63 | + | require.NoError(t, err) | |
| 64 | + | // Close initially so probe fails at first | |
| 65 | + | addr := ln.Addr().String() | |
| 66 | + | ln.Close() | |
| 67 | + | ||
| 68 | + | // Re-open after 1 second | |
| 69 | + | go func() { | |
| 70 | + | time.Sleep(time.Second) | |
| 71 | + | lc := net.ListenConfig{ | |
| 72 | + | Control: func(network, address string, c syscall.RawConn) error { | |
| 73 | + | return c.Control(func(fd uintptr) { | |
| 74 | + | _ = syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_REUSEADDR, 1) | |
| 75 | + | }) | |
| 76 | + | }, | |
| 77 | + | } | |
| 78 | + | newLn, err := lc.Listen(context.Background(), "tcp", addr) | |
| 79 | + | if err != nil { | |
| 80 | + | t.Logf("delayed listener failed to bind: %v", err) | |
| 81 | + | return | |
| 82 | + | } | |
| 83 | + | defer newLn.Close() | |
| 84 | + | for { | |
| 85 | + | conn, err := newLn.Accept() | |
| 86 | + | if err != nil { | |
| 87 | + | return | |
| 88 | + | } | |
| 89 | + | conn.Close() | |
| 90 | + | } | |
| 91 | + | }() | |
| 92 | + | ||
| 93 | + | ctx := testContext() | |
| 94 | + | err = tcpProbe(ctx, addr, 5*time.Second) | |
| 95 | + | assert.NoError(t, err) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | // --- HTTP Probe Tests --- | |
| 99 | + | ||
| 100 | + | func TestHTTPProbe_Success(t *testing.T) { | |
| 101 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 102 | + | if r.URL.Path == "/healthz" { | |
| 103 | + | w.WriteHeader(200) | |
| 104 | + | return | |
| 105 | + | } | |
| 106 | + | w.WriteHeader(404) | |
| 107 | + | })) | |
| 108 | + | defer srv.Close() | |
| 109 | + | ||
| 110 | + | ctx := testContext() | |
| 111 | + | err := httpProbe(ctx, srv.URL+"/healthz", 5*time.Second) | |
| 112 | + | assert.NoError(t, err) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func TestHTTPProbe_ServerError_ThenSuccess(t *testing.T) { | |
| 116 | + | var calls int32 | |
| 117 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 118 | + | n := atomic.AddInt32(&calls, 1) | |
| 119 | + | if n < 3 { | |
| 120 | + | w.WriteHeader(503) | |
| 121 | + | return | |
| 122 | + | } | |
| 123 | + | w.WriteHeader(200) | |
| 124 | + | })) | |
| 125 | + | defer srv.Close() | |
| 126 | + | ||
| 127 | + | ctx := testContext() | |
| 128 | + | err := httpProbe(ctx, srv.URL+"/healthz", 10*time.Second) | |
| 129 | + | assert.NoError(t, err) | |
| 130 | + | assert.GreaterOrEqual(t, atomic.LoadInt32(&calls), int32(3)) | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func TestHTTPProbe_Timeout(t *testing.T) { | |
| 134 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 135 | + | w.WriteHeader(503) | |
| 136 | + | })) | |
| 137 | + | defer srv.Close() | |
| 138 | + | ||
| 139 | + | ctx := testContext() | |
| 140 | + | err := httpProbe(ctx, srv.URL+"/healthz", 500*time.Millisecond) | |
| 141 | + | assert.Error(t, err) | |
| 142 | + | assert.Contains(t, err.Error(), "HTTP probe timeout") | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | func TestHTTPProbe_ContextCancelled(t *testing.T) { | |
| 146 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 147 | + | w.WriteHeader(503) | |
| 148 | + | })) | |
| 149 | + | defer srv.Close() | |
| 150 | + | ||
| 151 | + | ctx, cancel := context.WithCancel(testContext()) | |
| 152 | + | cancel() | |
| 153 | + | err := httpProbe(ctx, srv.URL+"/healthz", 5*time.Second) | |
| 154 | + | assert.Error(t, err) | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | func TestHTTPProbe_RedirectIsSuccess(t *testing.T) { | |
| 158 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 159 | + | http.Redirect(w, r, "/other", http.StatusFound) | |
| 160 | + | })) | |
| 161 | + | defer srv.Close() | |
| 162 | + | ||
| 163 | + | ctx := testContext() | |
| 164 | + | // The probe should treat 3xx as success without following the redirect | |
| 165 | + | err := httpProbe(ctx, srv.URL+"/healthz", 5*time.Second) | |
| 166 | + | assert.NoError(t, err) | |
| 167 | + | } | |
| @@ -0,0 +1,167 @@ | |||||
| 1 | + | package container | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "net" | |||
| 6 | + | "net/http" | |||
| 7 | + | "net/http/httptest" | |||
| 8 | + | "sync/atomic" | |||
| 9 | + | "syscall" | |||
| 10 | + | "testing" | |||
| 11 | + | "time" | |||
| 12 | + | ||||
| 13 | + | "github.com/stretchr/testify/assert" | |||
| 14 | + | "github.com/stretchr/testify/require" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // --- TCP Probe Tests --- | |||
| 18 | + | ||||
| 19 | + | func TestTCPProbe_Success(t *testing.T) { | |||
| 20 | + | ln, err := net.Listen("tcp", "127.0.0.1:0") | |||
| 21 | + | require.NoError(t, err) | |||
| 22 | + | defer ln.Close() | |||
| 23 | + | ||||
| 24 | + | // Accept connections in background so the probe succeeds | |||
| 25 | + | go func() { | |||
| 26 | + | for { | |||
| 27 | + | conn, err := ln.Accept() | |||
| 28 | + | if err != nil { | |||
| 29 | + | return | |||
| 30 | + | } | |||
| 31 | + | conn.Close() | |||
| 32 | + | } | |||
| 33 | + | }() | |||
| 34 | + | ||||
| 35 | + | ctx := testContext() | |||
| 36 | + | err = tcpProbe(ctx, ln.Addr().String(), 5*time.Second) | |||
| 37 | + | assert.NoError(t, err) | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func TestTCPProbe_Timeout(t *testing.T) { | |||
| 41 | + | // Reserve an ephemeral port and close it immediately to get a deterministic | |||
| 42 | + | // unreachable target, avoiding assumptions about privileged ports. | |||
| 43 | + | ln, err := net.Listen("tcp", "127.0.0.1:0") | |||
| 44 | + | require.NoError(t, err) | |||
| 45 | + | addr := ln.Addr().String() | |||
| 46 | + | ln.Close() | |||
| 47 | + | ||||
| 48 | + | ctx := testContext() | |||
| 49 | + | err = tcpProbe(ctx, addr, 200*time.Millisecond) | |||
| 50 | + | assert.Error(t, err) | |||
| 51 | + | assert.Contains(t, err.Error(), "TCP probe timeout") | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func TestTCPProbe_ContextCancelled(t *testing.T) { | |||
| 55 | + | ctx, cancel := context.WithCancel(testContext()) | |||
| 56 | + | cancel() | |||
| 57 | + | err := tcpProbe(ctx, "127.0.0.1:1", 5*time.Second) | |||
| 58 | + | assert.Error(t, err) | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func TestTCPProbe_DelayedListener(t *testing.T) { | |||
| 62 | + | ln, err := net.Listen("tcp", "127.0.0.1:0") | |||
| 63 | + | require.NoError(t, err) | |||
| 64 | + | // Close initially so probe fails at first | |||
| 65 | + | addr := ln.Addr().String() | |||
| 66 | + | ln.Close() | |||
| 67 | + | ||||
| 68 | + | // Re-open after 1 second | |||
| 69 | + | go func() { | |||
| 70 | + | time.Sleep(time.Second) | |||
| 71 | + | lc := net.ListenConfig{ | |||
| 72 | + | Control: func(network, address string, c syscall.RawConn) error { | |||
| 73 | + | return c.Control(func(fd uintptr) { | |||
| 74 | + | _ = syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_REUSEADDR, 1) | |||
| 75 | + | }) | |||
| 76 | + | }, | |||
| 77 | + | } | |||
| 78 | + | newLn, err := lc.Listen(context.Background(), "tcp", addr) | |||
| 79 | + | if err != nil { | |||
| 80 | + | t.Logf("delayed listener failed to bind: %v", err) | |||
| 81 | + | return | |||
| 82 | + | } | |||
| 83 | + | defer newLn.Close() | |||
| 84 | + | for { | |||
| 85 | + | conn, err := newLn.Accept() | |||
| 86 | + | if err != nil { | |||
| 87 | + | return | |||
| 88 | + | } | |||
| 89 | + | conn.Close() | |||
| 90 | + | } | |||
| 91 | + | }() | |||
| 92 | + | ||||
| 93 | + | ctx := testContext() | |||
| 94 | + | err = tcpProbe(ctx, addr, 5*time.Second) | |||
| 95 | + | assert.NoError(t, err) | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | // --- HTTP Probe Tests --- | |||
| 99 | + | ||||
| 100 | + | func TestHTTPProbe_Success(t *testing.T) { | |||
| 101 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 102 | + | if r.URL.Path == "/healthz" { | |||
| 103 | + | w.WriteHeader(200) | |||
| 104 | + | return | |||
| 105 | + | } | |||
| 106 | + | w.WriteHeader(404) | |||
| 107 | + | })) | |||
| 108 | + | defer srv.Close() | |||
| 109 | + | ||||
| 110 | + | ctx := testContext() | |||
| 111 | + | err := httpProbe(ctx, srv.URL+"/healthz", 5*time.Second) | |||
| 112 | + | assert.NoError(t, err) | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | func TestHTTPProbe_ServerError_ThenSuccess(t *testing.T) { | |||
| 116 | + | var calls int32 | |||
| 117 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 118 | + | n := atomic.AddInt32(&calls, 1) | |||
| 119 | + | if n < 3 { | |||
| 120 | + | w.WriteHeader(503) | |||
| 121 | + | return | |||
| 122 | + | } | |||
| 123 | + | w.WriteHeader(200) | |||
| 124 | + | })) | |||
| 125 | + | defer srv.Close() | |||
| 126 | + | ||||
| 127 | + | ctx := testContext() | |||
| 128 | + | err := httpProbe(ctx, srv.URL+"/healthz", 10*time.Second) | |||
| 129 | + | assert.NoError(t, err) | |||
| 130 | + | assert.GreaterOrEqual(t, atomic.LoadInt32(&calls), int32(3)) | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | func TestHTTPProbe_Timeout(t *testing.T) { | |||
| 134 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 135 | + | w.WriteHeader(503) | |||
| 136 | + | })) | |||
| 137 | + | defer srv.Close() | |||
| 138 | + | ||||
| 139 | + | ctx := testContext() | |||
| 140 | + | err := httpProbe(ctx, srv.URL+"/healthz", 500*time.Millisecond) | |||
| 141 | + | assert.Error(t, err) | |||
| 142 | + | assert.Contains(t, err.Error(), "HTTP probe timeout") | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | func TestHTTPProbe_ContextCancelled(t *testing.T) { | |||
| 146 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 147 | + | w.WriteHeader(503) | |||
| 148 | + | })) | |||
| 149 | + | defer srv.Close() | |||
| 150 | + | ||||
| 151 | + | ctx, cancel := context.WithCancel(testContext()) | |||
| 152 | + | cancel() | |||
| 153 | + | err := httpProbe(ctx, srv.URL+"/healthz", 5*time.Second) | |||
| 154 | + | assert.Error(t, err) | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | func TestHTTPProbe_RedirectIsSuccess(t *testing.T) { | |||
| 158 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 159 | + | http.Redirect(w, r, "/other", http.StatusFound) | |||
| 160 | + | })) | |||
| 161 | + | defer srv.Close() | |||
| 162 | + | ||||
| 163 | + | ctx := testContext() | |||
| 164 | + | // The probe should treat 3xx as success without following the redirect | |||
| 165 | + | err := httpProbe(ctx, srv.URL+"/healthz", 5*time.Second) | |||
| 166 | + | assert.NoError(t, err) | |||
| 167 | + | } | |||
M internal/usecase/container/service.go +342 -36
| @@ -38,7 +38,6 @@ type Config struct { | |||
| 38 | 38 | VolumePreserve bool | |
| 39 | 39 | NetworkIsolation bool | |
| 40 | 40 | NetworkPrefix string | |
| 41 | - | DNSSuffix string | |
| 42 | 41 | NetworkGroups map[string][]string | |
| 43 | 42 | Attachments map[string][]string | |
| 44 | 43 | ReadinessDelay time.Duration // Delay after container starts before considering it ready | |
| @@ -48,6 +47,9 @@ type Config struct { | |||
| 48 | 47 | DrainDelayConfigured bool // True when deploy.drain_delay was explicitly configured | |
| 49 | 48 | DrainMode string // Drain strategy: auto, inflight, delay | |
| 50 | 49 | DrainTimeout time.Duration // Max wait for in-flight requests to drain | |
| 50 | + | StabilizationDelay time.Duration // Post-switch monitoring window (default 2s) | |
| 51 | + | TCPProbeTimeout time.Duration // TCP probe timeout (default 30s) | |
| 52 | + | HTTPProbeTimeout time.Duration // HTTP probe timeout (default 60s) | |
| 51 | 53 | } | |
| 52 | 54 | ||
| 53 | 55 | var tracer = otel.Tracer("gordon.container") | |
| @@ -257,26 +259,9 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta | |||
| 257 | 259 | log := zerowrap.FromCtx(ctx) | |
| 258 | 260 | ||
| 259 | 261 | // Record deploy metrics and trace status | |
| 260 | - | defer func() { | |
| 261 | - | if err != nil { | |
| 262 | - | span.RecordError(err) | |
| 263 | - | span.SetStatus(codes.Error, err.Error()) | |
| 264 | - | } | |
| 265 | - | if s.metrics == nil { | |
| 266 | - | return | |
| 267 | - | } | |
| 268 | - | attrs := metric.WithAttributes( | |
| 269 | - | attribute.String("domain", route.Domain), | |
| 270 | - | attribute.String("image", route.Image), | |
| 271 | - | ) | |
| 272 | - | s.metrics.DeployTotal.Add(ctx, 1, attrs) | |
| 273 | - | s.metrics.DeployDuration.Record(ctx, time.Since(deployStart).Seconds(), attrs) | |
| 274 | - | if err != nil { | |
| 275 | - | s.metrics.DeployErrors.Add(ctx, 1, attrs) | |
| 276 | - | } | |
| 277 | - | }() | |
| 262 | + | defer s.recordDeployMetrics(ctx, span, route, deployStart, &err) | |
| 278 | 263 | ||
| 279 | - | existing, hasExisting := s.getTrackedContainer(route.Domain) | |
| 264 | + | existing, hasExisting := s.resolveExistingContainer(ctx, route.Domain) | |
| 280 | 265 | ||
| 281 | 266 | resources, err := s.prepareDeployResources(ctx, route, existing) | |
| 282 | 267 | if err != nil { | |
| @@ -287,9 +272,15 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta | |||
| 287 | 272 | // the exact same image (by Docker image ID), return it immediately. | |
| 288 | 273 | // This prevents the double-deploy caused by the event-based deploy | |
| 289 | 274 | // (triggered by image.pushed) racing with the explicit CLI deploy call. | |
| 290 | - | if hasExisting && existing.ImageID != "" { | |
| 291 | - | if skip, container := s.skipRedundantDeploy(ctx, existing, resources.actualImageRef); skip { | |
| 292 | - | return container, nil | |
| 275 | + | if hasExisting { | |
| 276 | + | existingForSkip := existing | |
| 277 | + | if existingForSkip.ImageID == "" && existingForSkip.Image != "" && normalizeImageRef(existingForSkip.Image) == normalizeImageRef(route.Image) { | |
| 278 | + | existingForSkip = s.containerForRedundantCheck(ctx, existingForSkip) | |
| 279 | + | } | |
| 280 | + | if existingForSkip.ImageID != "" { | |
| 281 | + | if skip, container := s.skipRedundantDeploy(ctx, existingForSkip, resources.actualImageRef); skip { | |
| 282 | + | return container, nil | |
| 283 | + | } | |
| 293 | 284 | } | |
| 294 | 285 | } | |
| 295 | 286 | ||
| @@ -299,6 +290,22 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta | |||
| 299 | 290 | } | |
| 300 | 291 | ||
| 301 | 292 | invalidated := s.activateDeployedContainer(ctx, route.Domain, newContainer) | |
| 293 | + | ||
| 294 | + | // Post-switch stabilization: verify new container stays running | |
| 295 | + | if hasExisting { | |
| 296 | + | stable, stabilizeErr := s.stabilizeNewContainer(ctx, route.Domain, newContainer, existing) | |
| 297 | + | if stabilizeErr != nil { | |
| 298 | + | // Both old and new containers are dead; assign to named return so | |
| 299 | + | // the deferred recordDeployMetrics and span see the failure. | |
| 300 | + | err = stabilizeErr | |
| 301 | + | return nil, err | |
| 302 | + | } | |
| 303 | + | if !stable { | |
| 304 | + | // Rollback performed — old container is restored | |
| 305 | + | return existing, nil | |
| 306 | + | } | |
| 307 | + | } | |
| 308 | + | ||
| 302 | 309 | s.finalizePreviousContainer(ctx, route.Domain, existing, hasExisting, invalidated, newContainer.ID) | |
| 303 | 310 | ||
| 304 | 311 | // Start container log collection (non-blocking, errors don't fail deployment) | |
| @@ -315,6 +322,29 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta | |||
| 315 | 322 | return newContainer, nil | |
| 316 | 323 | } | |
| 317 | 324 | ||
| 325 | + | // recordDeployMetrics records span error status and deploy metrics at the end of a Deploy call. | |
| 326 | + | // It is called via defer and receives a pointer to the named return error so it can observe | |
| 327 | + | // the final error value after all deferred functions have run. | |
| 328 | + | func (s *Service) recordDeployMetrics(ctx context.Context, span trace.Span, route domain.Route, start time.Time, errPtr *error) { | |
| 329 | + | err := *errPtr | |
| 330 | + | if err != nil { | |
| 331 | + | span.RecordError(err) | |
| 332 | + | span.SetStatus(codes.Error, err.Error()) | |
| 333 | + | } | |
| 334 | + | if s.metrics == nil { | |
| 335 | + | return | |
| 336 | + | } | |
| 337 | + | attrs := metric.WithAttributes( | |
| 338 | + | attribute.String("domain", route.Domain), | |
| 339 | + | attribute.String("image", route.Image), | |
| 340 | + | ) | |
| 341 | + | s.metrics.DeployTotal.Add(ctx, 1, attrs) | |
| 342 | + | s.metrics.DeployDuration.Record(ctx, time.Since(start).Seconds(), attrs) | |
| 343 | + | if err != nil { | |
| 344 | + | s.metrics.DeployErrors.Add(ctx, 1, attrs) | |
| 345 | + | } | |
| 346 | + | } | |
| 347 | + | ||
| 318 | 348 | // skipRedundantDeploy checks whether the existing container is already running | |
| 319 | 349 | // the same image (by Docker image ID) as the one we are about to deploy. | |
| 320 | 350 | // When a push triggers both an event-based deploy and an explicit CLI deploy, | |
| @@ -347,6 +377,26 @@ func (s *Service) skipRedundantDeploy(ctx context.Context, existing *domain.Cont | |||
| 347 | 377 | return true, existing | |
| 348 | 378 | } | |
| 349 | 379 | ||
| 380 | + | func (s *Service) containerForRedundantCheck(ctx context.Context, existing *domain.Container) *domain.Container { | |
| 381 | + | if existing == nil || existing.ImageID != "" || existing.ID == "" { | |
| 382 | + | return existing | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | log := zerowrap.FromCtx(ctx) | |
| 386 | + | inspected, err := s.runtime.InspectContainer(ctx, existing.ID) | |
| 387 | + | if err != nil { | |
| 388 | + | log.Debug().Err(err).Str("container_id", existing.ID).Msg("cannot inspect existing container for redundancy check") | |
| 389 | + | return existing | |
| 390 | + | } | |
| 391 | + | if inspected == nil || inspected.ImageID == "" { | |
| 392 | + | return existing | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | existingCopy := *existing | |
| 396 | + | existingCopy.ImageID = inspected.ImageID | |
| 397 | + | return &existingCopy | |
| 398 | + | } | |
| 399 | + | ||
| 350 | 400 | type deployResources struct { | |
| 351 | 401 | networkName string | |
| 352 | 402 | actualImageRef string | |
| @@ -355,11 +405,68 @@ type deployResources struct { | |||
| 355 | 405 | volumes map[string]string | |
| 356 | 406 | } | |
| 357 | 407 | ||
| 358 | - | func (s *Service) getTrackedContainer(domainName string) (*domain.Container, bool) { | |
| 408 | + | // resolveExistingContainer returns the currently running container for a domain. | |
| 409 | + | // It first checks the in-memory map (fast path). If not found, it queries the | |
| 410 | + | // runtime for a running container with matching name and managed label. This | |
| 411 | + | // handles cases where Gordon restarted and in-memory state is stale. | |
| 412 | + | // | |
| 413 | + | // The slow path checks canonical, -new, and -next names because a previous | |
| 414 | + | // deploy may have been interrupted (e.g. eventbus timeout, Gordon restart) | |
| 415 | + | // leaving the active container under a temp name. Canonical is preferred when | |
| 416 | + | // multiple matches exist. | |
| 417 | + | func (s *Service) resolveExistingContainer(ctx context.Context, domainName string) (*domain.Container, bool) { | |
| 418 | + | // Fast path: check in-memory state | |
| 359 | 419 | s.mu.RLock() | |
| 360 | 420 | container, ok := s.containers[domainName] | |
| 361 | 421 | s.mu.RUnlock() | |
| 362 | - | return container, ok | |
| 422 | + | if ok { | |
| 423 | + | return container, true | |
| 424 | + | } | |
| 425 | + | ||
| 426 | + | // Slow path: query runtime for running containers | |
| 427 | + | log := zerowrap.FromCtx(ctx) | |
| 428 | + | canonicalName := fmt.Sprintf("gordon-%s", domainName) | |
| 429 | + | candidateNames := map[string]bool{ | |
| 430 | + | canonicalName: true, | |
| 431 | + | canonicalName + "-new": true, | |
| 432 | + | canonicalName + "-next": true, | |
| 433 | + | } | |
| 434 | + | ||
| 435 | + | running, err := s.runtime.ListContainers(ctx, false) | |
| 436 | + | if err != nil { | |
| 437 | + | log.Warn().Err(err).Msg("failed to list running containers for existing container resolution") | |
| 438 | + | return nil, false | |
| 439 | + | } | |
| 440 | + | ||
| 441 | + | // Prefer canonical name; fall back to any running managed temp container. | |
| 442 | + | var best *domain.Container | |
| 443 | + | for _, c := range running { | |
| 444 | + | if !candidateNames[c.Name] || c.Labels[domain.LabelManaged] != "true" { | |
| 445 | + | continue | |
| 446 | + | } | |
| 447 | + | if best == nil || c.Name == canonicalName { | |
| 448 | + | best = c | |
| 449 | + | } | |
| 450 | + | } | |
| 451 | + | ||
| 452 | + | if best != nil { | |
| 453 | + | log.Info(). | |
| 454 | + | Str("container_id", best.ID). | |
| 455 | + | Str("container_name", best.Name). | |
| 456 | + | Msg("resolved existing container from runtime (in-memory state was stale)") | |
| 457 | + | ||
| 458 | + | // Update in-memory state so subsequent lookups are fast | |
| 459 | + | s.mu.Lock() | |
| 460 | + | if _, alreadyTracked := s.containers[domainName]; !alreadyTracked { | |
| 461 | + | s.managedCount++ | |
| 462 | + | } | |
| 463 | + | s.containers[domainName] = best | |
| 464 | + | s.mu.Unlock() | |
| 465 | + | ||
| 466 | + | return best, true | |
| 467 | + | } | |
| 468 | + | ||
| 469 | + | return nil, false | |
| 363 | 470 | } | |
| 364 | 471 | ||
| 365 | 472 | func (s *Service) prepareDeployResources(ctx context.Context, route domain.Route, existing *domain.Container) (*deployResources, error) { | |
| @@ -438,7 +545,7 @@ func (s *Service) createStartedContainer(ctx context.Context, route domain.Route | |||
| 438 | 545 | return nil, log.WrapErr(err, "failed to start container") | |
| 439 | 546 | } | |
| 440 | 547 | if !domain.IsSkipReadiness(ctx) { | |
| 441 | - | if err := s.waitForReady(ctx, newContainer.ID); err != nil { | |
| 548 | + | if err := s.waitForReady(ctx, newContainer.ID, containerConfig); err != nil { | |
| 442 | 549 | s.cleanupFailedContainer(ctx, newContainer.ID) | |
| 443 | 550 | return nil, log.WrapErr(err, "container failed readiness check") | |
| 444 | 551 | } | |
| @@ -480,6 +587,85 @@ func (s *Service) activateDeployedContainer(ctx context.Context, domainName stri | |||
| 480 | 587 | return false | |
| 481 | 588 | } | |
| 482 | 589 | ||
| 590 | + | // stabilizeNewContainer monitors the new container briefly after traffic switch. | |
| 591 | + | // If it crashes during this window, rolls back to old container. | |
| 592 | + | // Returns true if stabilization succeeded, false if rollback was performed. | |
| 593 | + | // Returns an error only when both new and old containers are dead. | |
| 594 | + | func (s *Service) stabilizeNewContainer(ctx context.Context, domainName string, newContainer, oldContainer *domain.Container) (bool, error) { | |
| 595 | + | log := zerowrap.FromCtx(ctx) | |
| 596 | + | ||
| 597 | + | if oldContainer == nil { | |
| 598 | + | return true, nil | |
| 599 | + | } | |
| 600 | + | ||
| 601 | + | s.mu.RLock() | |
| 602 | + | delay := s.config.StabilizationDelay | |
| 603 | + | s.mu.RUnlock() | |
| 604 | + | if delay == 0 { | |
| 605 | + | delay = 2 * time.Second | |
| 606 | + | } | |
| 607 | + | ||
| 608 | + | // Brief stabilization: verify new container is still running after delay | |
| 609 | + | select { | |
| 610 | + | case <-time.After(delay): | |
| 611 | + | case <-ctx.Done(): | |
| 612 | + | return true, nil | |
| 613 | + | } | |
| 614 | + | ||
| 615 | + | running, err := s.runtime.IsContainerRunning(ctx, newContainer.ID) | |
| 616 | + | if err != nil || !running { | |
| 617 | + | log.Error(). | |
| 618 | + | Str("new_container", newContainer.ID). | |
| 619 | + | Str("old_container", oldContainer.ID). | |
| 620 | + | Msg("new container crashed during stabilization, rolling back to old") | |
| 621 | + | ||
| 622 | + | // Verify old container is still running before restoring it. | |
| 623 | + | // If both old and new are dead (e.g., OOM), returning a dead | |
| 624 | + | // container as "existing" would leave the domain in a broken state. | |
| 625 | + | oldRunning, oldErr := s.runtime.IsContainerRunning(ctx, oldContainer.ID) | |
| 626 | + | if oldErr != nil || !oldRunning { | |
| 627 | + | log.Error(). | |
| 628 | + | Str("old_container", oldContainer.ID). | |
| 629 | + | Msg("old container is also not running, cannot rollback") | |
| 630 | + | ||
| 631 | + | // Cleanup failed new container | |
| 632 | + | if stopErr := s.runtime.StopContainer(ctx, newContainer.ID); stopErr != nil { | |
| 633 | + | log.WrapErrWithFields(stopErr, "failed to stop failed new container during rollback", map[string]any{zerowrap.FieldEntityID: newContainer.ID}) | |
| 634 | + | } | |
| 635 | + | if removeErr := s.runtime.RemoveContainer(ctx, newContainer.ID, true); removeErr != nil { | |
| 636 | + | log.WrapErrWithFields(removeErr, "failed to remove failed new container during rollback", map[string]any{zerowrap.FieldEntityID: newContainer.ID}) | |
| 637 | + | } | |
| 638 | + | ||
| 639 | + | return false, fmt.Errorf("stabilization failed: new container crashed and old container %s is also not running", oldContainer.ID) | |
| 640 | + | } | |
| 641 | + | ||
| 642 | + | // Rollback: restore old container as tracked | |
| 643 | + | s.mu.Lock() | |
| 644 | + | s.containers[domainName] = oldContainer | |
| 645 | + | s.mu.Unlock() | |
| 646 | + | ||
| 647 | + | // Re-invalidate proxy cache to point back to old | |
| 648 | + | s.mu.RLock() | |
| 649 | + | inv := s.cacheInvalidator | |
| 650 | + | s.mu.RUnlock() | |
| 651 | + | if inv != nil { | |
| 652 | + | inv.InvalidateTarget(ctx, domainName) | |
| 653 | + | } | |
| 654 | + | ||
| 655 | + | // Cleanup failed new container | |
| 656 | + | if stopErr := s.runtime.StopContainer(ctx, newContainer.ID); stopErr != nil { | |
| 657 | + | log.WrapErrWithFields(stopErr, "failed to stop failed new container during rollback", map[string]any{zerowrap.FieldEntityID: newContainer.ID}) | |
| 658 | + | } | |
| 659 | + | if removeErr := s.runtime.RemoveContainer(ctx, newContainer.ID, true); removeErr != nil { | |
| 660 | + | log.WrapErrWithFields(removeErr, "failed to remove failed new container during rollback", map[string]any{zerowrap.FieldEntityID: newContainer.ID}) | |
| 661 | + | } | |
| 662 | + | ||
| 663 | + | return false, nil | |
| 664 | + | } | |
| 665 | + | ||
| 666 | + | return true, nil | |
| 667 | + | } | |
| 668 | + | ||
| 483 | 669 | func (s *Service) finalizePreviousContainer(ctx context.Context, domainName string, existing *domain.Container, hasExisting, invalidated bool, newContainerID string) { | |
| 484 | 670 | if !hasExisting { | |
| 485 | 671 | return | |
| @@ -1587,7 +1773,19 @@ func (s *Service) cleanupOrphanedContainers(ctx context.Context, domainName stri | |||
| 1587 | 1773 | ||
| 1588 | 1774 | for _, c := range allContainers { | |
| 1589 | 1775 | if (c.Name == expectedName || c.Name == expectedNewName || c.Name == expectedNextName) && c.ID != skipContainerID { | |
| 1590 | - | log.Info().Str(zerowrap.FieldEntityID, c.ID).Str(zerowrap.FieldStatus, c.Status).Msg("found orphaned container, removing") | |
| 1776 | + | // Skip any running container regardless of name — a running | |
| 1777 | + | // temp container may be actively serving traffic while the | |
| 1778 | + | // new container stabilizes, or may be from a concurrent deploy. | |
| 1779 | + | if c.Status == "running" { | |
| 1780 | + | log.Debug(). | |
| 1781 | + | Str(zerowrap.FieldEntityID, c.ID). | |
| 1782 | + | Str("container_name", c.Name). | |
| 1783 | + | Str(zerowrap.FieldStatus, c.Status). | |
| 1784 | + | Msg("skipping running container during orphan cleanup") | |
| 1785 | + | continue | |
| 1786 | + | } | |
| 1787 | + | ||
| 1788 | + | log.Info().Str(zerowrap.FieldEntityID, c.ID).Str("container_name", c.Name).Str(zerowrap.FieldStatus, c.Status).Msg("found orphaned container, removing") | |
| 1591 | 1789 | ||
| 1592 | 1790 | if err := s.runtime.StopContainer(ctx, c.ID); err != nil { | |
| 1593 | 1791 | log.WrapErrWithFields(err, "failed to stop orphaned container", map[string]any{zerowrap.FieldEntityID: c.ID}) | |
| @@ -2065,7 +2263,7 @@ func (s *Service) findContainerByName(ctx context.Context, name string) *domain. | |||
| 2065 | 2263 | return nil | |
| 2066 | 2264 | } | |
| 2067 | 2265 | ||
| 2068 | - | func (s *Service) waitForReady(ctx context.Context, containerID string) error { | |
| 2266 | + | func (s *Service) waitForReady(ctx context.Context, containerID string, containerConfig *domain.ContainerConfig) error { | |
| 2069 | 2267 | if err := s.pollContainerRunning(ctx, containerID); err != nil { | |
| 2070 | 2268 | return err | |
| 2071 | 2269 | } | |
| @@ -2092,15 +2290,123 @@ func (s *Service) waitForReady(ctx context.Context, containerID string) error { | |||
| 2092 | 2290 | } | |
| 2093 | 2291 | return s.waitForHealthy(ctx, containerID, cfg.HealthTimeout) | |
| 2094 | 2292 | default: // auto | |
| 2095 | - | _, hasHealthcheck, err := s.runtime.GetContainerHealthStatus(ctx, containerID) | |
| 2096 | - | if err != nil { | |
| 2097 | - | return err | |
| 2098 | - | } | |
| 2099 | - | if hasHealthcheck { | |
| 2100 | - | return s.waitForHealthy(ctx, containerID, cfg.HealthTimeout) | |
| 2101 | - | } | |
| 2102 | - | return s.waitForReadyByDelay(ctx, containerID) | |
| 2293 | + | return s.readinessCascade(ctx, containerID, containerConfig, cfg) | |
| 2294 | + | } | |
| 2295 | + | } | |
| 2296 | + | ||
| 2297 | + | // readinessCascade auto-detects the strongest available readiness signal: | |
| 2298 | + | // 1. Docker healthcheck (if present) → wait for healthy status | |
| 2299 | + | // 2. HTTP probe (if gordon.health label set) → GET until 2xx/3xx | |
| 2300 | + | // 3. TCP probe (if port info available) → connect until accepted | |
| 2301 | + | // 4. Delay fallback (last resort) → waitForReadyByDelay | |
| 2302 | + | func (s *Service) readinessCascade(ctx context.Context, containerID string, containerConfig *domain.ContainerConfig, cfg Config) error { | |
| 2303 | + | log := zerowrap.FromCtx(ctx) | |
| 2304 | + | ||
| 2305 | + | // 1. Docker healthcheck | |
| 2306 | + | _, hasHealthcheck, err := s.runtime.GetContainerHealthStatus(ctx, containerID) | |
| 2307 | + | if err != nil { | |
| 2308 | + | return err | |
| 2309 | + | } | |
| 2310 | + | if hasHealthcheck { | |
| 2311 | + | log.Info().Msg("readiness cascade: using Docker healthcheck") | |
| 2312 | + | return s.waitForHealthy(ctx, containerID, cfg.HealthTimeout) | |
| 2313 | + | } | |
| 2314 | + | ||
| 2315 | + | // 2. HTTP probe via gordon.health label | |
| 2316 | + | if probed, probeErr := s.tryHTTPProbe(ctx, containerID, containerConfig, cfg); probed { | |
| 2317 | + | return probeErr | |
| 2318 | + | } | |
| 2319 | + | ||
| 2320 | + | // 3. TCP probe (if port info available) | |
| 2321 | + | if probed, probeErr := s.tryTCPProbe(ctx, containerID, containerConfig, cfg); probed { | |
| 2322 | + | return probeErr | |
| 2323 | + | } | |
| 2324 | + | ||
| 2325 | + | // 4. Delay fallback | |
| 2326 | + | log.Info().Msg("readiness cascade: using delay fallback") | |
| 2327 | + | return s.waitForReadyByDelay(ctx, containerID) | |
| 2328 | + | } | |
| 2329 | + | ||
| 2330 | + | // tryHTTPProbe attempts an HTTP probe if the gordon.health label is set. | |
| 2331 | + | // Returns (true, err) if the probe was attempted, (false, nil) if skipped. | |
| 2332 | + | func (s *Service) tryHTTPProbe(ctx context.Context, containerID string, containerConfig *domain.ContainerConfig, cfg Config) (bool, error) { | |
| 2333 | + | log := zerowrap.FromCtx(ctx) | |
| 2334 | + | if containerConfig == nil { | |
| 2335 | + | return false, nil | |
| 2336 | + | } | |
| 2337 | + | healthPath, ok := containerConfig.Labels[domain.LabelHealth] | |
| 2338 | + | if !ok || healthPath == "" { | |
| 2339 | + | return false, nil | |
| 2340 | + | } | |
| 2341 | + | ip, port, probeErr := s.resolveContainerEndpoint(ctx, containerID) | |
| 2342 | + | if probeErr != nil || ip == "" || port <= 0 { | |
| 2343 | + | log.Debug().Err(probeErr).Msg("readiness cascade: HTTP probe skipped, could not resolve container endpoint") | |
| 2344 | + | return false, nil | |
| 2345 | + | } | |
| 2346 | + | url := fmt.Sprintf("http://%s:%d%s", ip, port, healthPath) | |
| 2347 | + | timeout := cfg.HTTPProbeTimeout | |
| 2348 | + | if timeout == 0 { | |
| 2349 | + | timeout = 60 * time.Second | |
| 2350 | + | } | |
| 2351 | + | log.Info().Str("url", url).Dur("timeout", timeout).Msg("readiness cascade: using HTTP probe") | |
| 2352 | + | return true, httpProbe(ctx, url, timeout) | |
| 2353 | + | } | |
| 2354 | + | ||
| 2355 | + | // tryTCPProbe attempts a TCP probe if port info is available. | |
| 2356 | + | // Returns (true, err) if the probe was attempted, (false, nil) if skipped. | |
| 2357 | + | func (s *Service) tryTCPProbe(ctx context.Context, containerID string, containerConfig *domain.ContainerConfig, cfg Config) (bool, error) { | |
| 2358 | + | log := zerowrap.FromCtx(ctx) | |
| 2359 | + | if containerConfig == nil || len(containerConfig.Ports) == 0 { | |
| 2360 | + | return false, nil | |
| 2361 | + | } | |
| 2362 | + | ip, port, probeErr := s.resolveContainerEndpoint(ctx, containerID) | |
| 2363 | + | if probeErr != nil || ip == "" || port <= 0 { | |
| 2364 | + | log.Debug().Err(probeErr).Msg("readiness cascade: TCP probe skipped, could not resolve container endpoint") | |
| 2365 | + | return false, nil | |
| 2366 | + | } | |
| 2367 | + | addr := fmt.Sprintf("%s:%d", ip, port) | |
| 2368 | + | timeout := cfg.TCPProbeTimeout | |
| 2369 | + | if timeout == 0 { | |
| 2370 | + | timeout = 30 * time.Second | |
| 2371 | + | } | |
| 2372 | + | log.Info().Str("addr", addr).Dur("timeout", timeout).Msg("readiness cascade: using TCP probe") | |
| 2373 | + | return true, tcpProbe(ctx, addr, timeout) | |
| 2374 | + | } | |
| 2375 | + | ||
| 2376 | + | // resolveContainerEndpoint returns a host-reachable address for probing. | |
| 2377 | + | // In rootless podman/Docker setups, container internal IPs are not routable | |
| 2378 | + | // from the host. We use the host port binding (127.0.0.1:<mapped_port>) | |
| 2379 | + | // which is always reachable. Falls back to the container's internal IP | |
| 2380 | + | // only if no host port mapping exists (e.g. host-network mode). | |
| 2381 | + | func (s *Service) resolveContainerEndpoint(ctx context.Context, containerID string) (string, int, error) { | |
| 2382 | + | log := zerowrap.FromCtx(ctx) | |
| 2383 | + | ||
| 2384 | + | // First, get the container's internal port from network info | |
| 2385 | + | _, internalPort, err := s.runtime.GetContainerNetworkInfo(ctx, containerID) | |
| 2386 | + | if err != nil { | |
| 2387 | + | return "", 0, err | |
| 2388 | + | } | |
| 2389 | + | ||
| 2390 | + | // Try to resolve via host port binding (works in rootless podman/Docker) | |
| 2391 | + | hostPort, hostErr := s.runtime.GetContainerPort(ctx, containerID, internalPort) | |
| 2392 | + | if hostErr == nil && hostPort > 0 { | |
| 2393 | + | log.Debug(). | |
| 2394 | + | Int("internal_port", internalPort). | |
| 2395 | + | Int("host_port", hostPort). | |
| 2396 | + | Msg("resolved container endpoint via host port binding") | |
| 2397 | + | return "127.0.0.1", hostPort, nil | |
| 2398 | + | } | |
| 2399 | + | ||
| 2400 | + | // Fallback: use internal IP (works in Docker rootful / host network) | |
| 2401 | + | ip, _, fallbackErr := s.runtime.GetContainerNetworkInfo(ctx, containerID) | |
| 2402 | + | if fallbackErr != nil { | |
| 2403 | + | return "", 0, fallbackErr | |
| 2103 | 2404 | } | |
| 2405 | + | log.Debug(). | |
| 2406 | + | Str("ip", ip). | |
| 2407 | + | Int("port", internalPort). | |
| 2408 | + | Msg("resolved container endpoint via internal IP (no host port binding)") | |
| 2409 | + | return ip, internalPort, nil | |
| 2104 | 2410 | } | |
| 2105 | 2411 | ||
| 2106 | 2412 | // waitForReadyByDelay waits using the legacy running+delay strategy. | |
| @@ -38,7 +38,6 @@ type Config struct { | |||||
| 38 | VolumePreserve bool | 38 | VolumePreserve bool | ||
| 39 | NetworkIsolation bool | 39 | NetworkIsolation bool | ||
| 40 | NetworkPrefix string | 40 | NetworkPrefix string | ||
| 41 | - | DNSSuffix string | |||
| 42 | NetworkGroups map[string][]string | 41 | NetworkGroups map[string][]string | ||
| 43 | Attachments map[string][]string | 42 | Attachments map[string][]string | ||
| 44 | ReadinessDelay time.Duration // Delay after container starts before considering it ready | 43 | ReadinessDelay time.Duration // Delay after container starts before considering it ready | ||
| @@ -48,6 +47,9 @@ type Config struct { | |||||
| 48 | DrainDelayConfigured bool // True when deploy.drain_delay was explicitly configured | 47 | DrainDelayConfigured bool // True when deploy.drain_delay was explicitly configured | ||
| 49 | DrainMode string // Drain strategy: auto, inflight, delay | 48 | DrainMode string // Drain strategy: auto, inflight, delay | ||
| 50 | DrainTimeout time.Duration // Max wait for in-flight requests to drain | 49 | DrainTimeout time.Duration // Max wait for in-flight requests to drain | ||
| 50 | + | StabilizationDelay time.Duration // Post-switch monitoring window (default 2s) | |||
| 51 | + | TCPProbeTimeout time.Duration // TCP probe timeout (default 30s) | |||
| 52 | + | HTTPProbeTimeout time.Duration // HTTP probe timeout (default 60s) | |||
| 51 | } | 53 | } | ||
| 52 | 54 | ||||
| 53 | var tracer = otel.Tracer("gordon.container") | 55 | var tracer = otel.Tracer("gordon.container") | ||
| @@ -257,26 +259,9 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta | |||||
| 257 | log := zerowrap.FromCtx(ctx) | 259 | log := zerowrap.FromCtx(ctx) | ||
| 258 | 260 | ||||
| 259 | // Record deploy metrics and trace status | 261 | // Record deploy metrics and trace status | ||
| 260 | - | defer func() { | 262 | + | defer s.recordDeployMetrics(ctx, span, route, deployStart, &err) |
| 261 | - | if err != nil { | |||
| 262 | - | span.RecordError(err) | |||
| 263 | - | span.SetStatus(codes.Error, err.Error()) | |||
| 264 | - | } | |||
| 265 | - | if s.metrics == nil { | |||
| 266 | - | return | |||
| 267 | - | } | |||
| 268 | - | attrs := metric.WithAttributes( | |||
| 269 | - | attribute.String("domain", route.Domain), | |||
| 270 | - | attribute.String("image", route.Image), | |||
| 271 | - | ) | |||
| 272 | - | s.metrics.DeployTotal.Add(ctx, 1, attrs) | |||
| 273 | - | s.metrics.DeployDuration.Record(ctx, time.Since(deployStart).Seconds(), attrs) | |||
| 274 | - | if err != nil { | |||
| 275 | - | s.metrics.DeployErrors.Add(ctx, 1, attrs) | |||
| 276 | - | } | |||
| 277 | - | }() | |||
| 278 | 263 | ||||
| 279 | - | existing, hasExisting := s.getTrackedContainer(route.Domain) | 264 | + | existing, hasExisting := s.resolveExistingContainer(ctx, route.Domain) |
| 280 | 265 | ||||
| 281 | resources, err := s.prepareDeployResources(ctx, route, existing) | 266 | resources, err := s.prepareDeployResources(ctx, route, existing) | ||
| 282 | if err != nil { | 267 | if err != nil { | ||
| @@ -287,9 +272,15 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta | |||||
| 287 | // the exact same image (by Docker image ID), return it immediately. | 272 | // the exact same image (by Docker image ID), return it immediately. | ||
| 288 | // This prevents the double-deploy caused by the event-based deploy | 273 | // This prevents the double-deploy caused by the event-based deploy | ||
| 289 | // (triggered by image.pushed) racing with the explicit CLI deploy call. | 274 | // (triggered by image.pushed) racing with the explicit CLI deploy call. | ||
| 290 | - | if hasExisting && existing.ImageID != "" { | 275 | + | if hasExisting { |
| 291 | - | if skip, container := s.skipRedundantDeploy(ctx, existing, resources.actualImageRef); skip { | 276 | + | existingForSkip := existing |
| 292 | - | return container, nil | 277 | + | if existingForSkip.ImageID == "" && existingForSkip.Image != "" && normalizeImageRef(existingForSkip.Image) == normalizeImageRef(route.Image) { |
| 278 | + | existingForSkip = s.containerForRedundantCheck(ctx, existingForSkip) | |||
| 279 | + | } | |||
| 280 | + | if existingForSkip.ImageID != "" { | |||
| 281 | + | if skip, container := s.skipRedundantDeploy(ctx, existingForSkip, resources.actualImageRef); skip { | |||
| 282 | + | return container, nil | |||
| 283 | + | } | |||
| 293 | } | 284 | } | ||
| 294 | } | 285 | } | ||
| 295 | 286 | ||||
| @@ -299,6 +290,22 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta | |||||
| 299 | } | 290 | } | ||
| 300 | 291 | ||||
| 301 | invalidated := s.activateDeployedContainer(ctx, route.Domain, newContainer) | 292 | invalidated := s.activateDeployedContainer(ctx, route.Domain, newContainer) | ||
| 293 | + | ||||
| 294 | + | // Post-switch stabilization: verify new container stays running | |||
| 295 | + | if hasExisting { | |||
| 296 | + | stable, stabilizeErr := s.stabilizeNewContainer(ctx, route.Domain, newContainer, existing) | |||
| 297 | + | if stabilizeErr != nil { | |||
| 298 | + | // Both old and new containers are dead; assign to named return so | |||
| 299 | + | // the deferred recordDeployMetrics and span see the failure. | |||
| 300 | + | err = stabilizeErr | |||
| 301 | + | return nil, err | |||
| 302 | + | } | |||
| 303 | + | if !stable { | |||
| 304 | + | // Rollback performed — old container is restored | |||
| 305 | + | return existing, nil | |||
| 306 | + | } | |||
| 307 | + | } | |||
| 308 | + | ||||
| 302 | s.finalizePreviousContainer(ctx, route.Domain, existing, hasExisting, invalidated, newContainer.ID) | 309 | s.finalizePreviousContainer(ctx, route.Domain, existing, hasExisting, invalidated, newContainer.ID) | ||
| 303 | 310 | ||||
| 304 | // Start container log collection (non-blocking, errors don't fail deployment) | 311 | // Start container log collection (non-blocking, errors don't fail deployment) | ||
| @@ -315,6 +322,29 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta | |||||
| 315 | return newContainer, nil | 322 | return newContainer, nil | ||
| 316 | } | 323 | } | ||
| 317 | 324 | ||||
| 325 | + | // recordDeployMetrics records span error status and deploy metrics at the end of a Deploy call. | |||
| 326 | + | // It is called via defer and receives a pointer to the named return error so it can observe | |||
| 327 | + | // the final error value after all deferred functions have run. | |||
| 328 | + | func (s *Service) recordDeployMetrics(ctx context.Context, span trace.Span, route domain.Route, start time.Time, errPtr *error) { | |||
| 329 | + | err := *errPtr | |||
| 330 | + | if err != nil { | |||
| 331 | + | span.RecordError(err) | |||
| 332 | + | span.SetStatus(codes.Error, err.Error()) | |||
| 333 | + | } | |||
| 334 | + | if s.metrics == nil { | |||
| 335 | + | return | |||
| 336 | + | } | |||
| 337 | + | attrs := metric.WithAttributes( | |||
| 338 | + | attribute.String("domain", route.Domain), | |||
| 339 | + | attribute.String("image", route.Image), | |||
| 340 | + | ) | |||
| 341 | + | s.metrics.DeployTotal.Add(ctx, 1, attrs) | |||
| 342 | + | s.metrics.DeployDuration.Record(ctx, time.Since(start).Seconds(), attrs) | |||
| 343 | + | if err != nil { | |||
| 344 | + | s.metrics.DeployErrors.Add(ctx, 1, attrs) | |||
| 345 | + | } | |||
| 346 | + | } | |||
| 347 | + | ||||
| 318 | // skipRedundantDeploy checks whether the existing container is already running | 348 | // skipRedundantDeploy checks whether the existing container is already running | ||
| 319 | // the same image (by Docker image ID) as the one we are about to deploy. | 349 | // the same image (by Docker image ID) as the one we are about to deploy. | ||
| 320 | // When a push triggers both an event-based deploy and an explicit CLI deploy, | 350 | // When a push triggers both an event-based deploy and an explicit CLI deploy, | ||
| @@ -347,6 +377,26 @@ func (s *Service) skipRedundantDeploy(ctx context.Context, existing *domain.Cont | |||||
| 347 | return true, existing | 377 | return true, existing | ||
| 348 | } | 378 | } | ||
| 349 | 379 | ||||
| 380 | + | func (s *Service) containerForRedundantCheck(ctx context.Context, existing *domain.Container) *domain.Container { | |||
| 381 | + | if existing == nil || existing.ImageID != "" || existing.ID == "" { | |||
| 382 | + | return existing | |||
| 383 | + | } | |||
| 384 | + | ||||
| 385 | + | log := zerowrap.FromCtx(ctx) | |||
| 386 | + | inspected, err := s.runtime.InspectContainer(ctx, existing.ID) | |||
| 387 | + | if err != nil { | |||
| 388 | + | log.Debug().Err(err).Str("container_id", existing.ID).Msg("cannot inspect existing container for redundancy check") | |||
| 389 | + | return existing | |||
| 390 | + | } | |||
| 391 | + | if inspected == nil || inspected.ImageID == "" { | |||
| 392 | + | return existing | |||
| 393 | + | } | |||
| 394 | + | ||||
| 395 | + | existingCopy := *existing | |||
| 396 | + | existingCopy.ImageID = inspected.ImageID | |||
| 397 | + | return &existingCopy | |||
| 398 | + | } | |||
| 399 | + | ||||
| 350 | type deployResources struct { | 400 | type deployResources struct { | ||
| 351 | networkName string | 401 | networkName string | ||
| 352 | actualImageRef string | 402 | actualImageRef string | ||
| @@ -355,11 +405,68 @@ type deployResources struct { | |||||
| 355 | volumes map[string]string | 405 | volumes map[string]string | ||
| 356 | } | 406 | } | ||
| 357 | 407 | ||||
| 358 | - | func (s *Service) getTrackedContainer(domainName string) (*domain.Container, bool) { | 408 | + | // resolveExistingContainer returns the currently running container for a domain. |
| 409 | + | // It first checks the in-memory map (fast path). If not found, it queries the | |||
| 410 | + | // runtime for a running container with matching name and managed label. This | |||
| 411 | + | // handles cases where Gordon restarted and in-memory state is stale. | |||
| 412 | + | // | |||
| 413 | + | // The slow path checks canonical, -new, and -next names because a previous | |||
| 414 | + | // deploy may have been interrupted (e.g. eventbus timeout, Gordon restart) | |||
| 415 | + | // leaving the active container under a temp name. Canonical is preferred when | |||
| 416 | + | // multiple matches exist. | |||
| 417 | + | func (s *Service) resolveExistingContainer(ctx context.Context, domainName string) (*domain.Container, bool) { | |||
| 418 | + | // Fast path: check in-memory state | |||
| 359 | s.mu.RLock() | 419 | s.mu.RLock() | ||
| 360 | container, ok := s.containers[domainName] | 420 | container, ok := s.containers[domainName] | ||
| 361 | s.mu.RUnlock() | 421 | s.mu.RUnlock() | ||
| 362 | - | return container, ok | 422 | + | if ok { |
| 423 | + | return container, true | |||
| 424 | + | } | |||
| 425 | + | ||||
| 426 | + | // Slow path: query runtime for running containers | |||
| 427 | + | log := zerowrap.FromCtx(ctx) | |||
| 428 | + | canonicalName := fmt.Sprintf("gordon-%s", domainName) | |||
| 429 | + | candidateNames := map[string]bool{ | |||
| 430 | + | canonicalName: true, | |||
| 431 | + | canonicalName + "-new": true, | |||
| 432 | + | canonicalName + "-next": true, | |||
| 433 | + | } | |||
| 434 | + | ||||
| 435 | + | running, err := s.runtime.ListContainers(ctx, false) | |||
| 436 | + | if err != nil { | |||
| 437 | + | log.Warn().Err(err).Msg("failed to list running containers for existing container resolution") | |||
| 438 | + | return nil, false | |||
| 439 | + | } | |||
| 440 | + | ||||
| 441 | + | // Prefer canonical name; fall back to any running managed temp container. | |||
| 442 | + | var best *domain.Container | |||
| 443 | + | for _, c := range running { | |||
| 444 | + | if !candidateNames[c.Name] || c.Labels[domain.LabelManaged] != "true" { | |||
| 445 | + | continue | |||
| 446 | + | } | |||
| 447 | + | if best == nil || c.Name == canonicalName { | |||
| 448 | + | best = c | |||
| 449 | + | } | |||
| 450 | + | } | |||
| 451 | + | ||||
| 452 | + | if best != nil { | |||
| 453 | + | log.Info(). | |||
| 454 | + | Str("container_id", best.ID). | |||
| 455 | + | Str("container_name", best.Name). | |||
| 456 | + | Msg("resolved existing container from runtime (in-memory state was stale)") | |||
| 457 | + | ||||
| 458 | + | // Update in-memory state so subsequent lookups are fast | |||
| 459 | + | s.mu.Lock() | |||
| 460 | + | if _, alreadyTracked := s.containers[domainName]; !alreadyTracked { | |||
| 461 | + | s.managedCount++ | |||
| 462 | + | } | |||
| 463 | + | s.containers[domainName] = best | |||
| 464 | + | s.mu.Unlock() | |||
| 465 | + | ||||
| 466 | + | return best, true | |||
| 467 | + | } | |||
| 468 | + | ||||
| 469 | + | return nil, false | |||
| 363 | } | 470 | } | ||
| 364 | 471 | ||||
| 365 | func (s *Service) prepareDeployResources(ctx context.Context, route domain.Route, existing *domain.Container) (*deployResources, error) { | 472 | func (s *Service) prepareDeployResources(ctx context.Context, route domain.Route, existing *domain.Container) (*deployResources, error) { | ||
| @@ -438,7 +545,7 @@ func (s *Service) createStartedContainer(ctx context.Context, route domain.Route | |||||
| 438 | return nil, log.WrapErr(err, "failed to start container") | 545 | return nil, log.WrapErr(err, "failed to start container") | ||
| 439 | } | 546 | } | ||
| 440 | if !domain.IsSkipReadiness(ctx) { | 547 | if !domain.IsSkipReadiness(ctx) { | ||
| 441 | - | if err := s.waitForReady(ctx, newContainer.ID); err != nil { | 548 | + | if err := s.waitForReady(ctx, newContainer.ID, containerConfig); err != nil { |
| 442 | s.cleanupFailedContainer(ctx, newContainer.ID) | 549 | s.cleanupFailedContainer(ctx, newContainer.ID) | ||
| 443 | return nil, log.WrapErr(err, "container failed readiness check") | 550 | return nil, log.WrapErr(err, "container failed readiness check") | ||
| 444 | } | 551 | } | ||
| @@ -480,6 +587,85 @@ func (s *Service) activateDeployedContainer(ctx context.Context, domainName stri | |||||
| 480 | return false | 587 | return false | ||
| 481 | } | 588 | } | ||
| 482 | 589 | ||||
| 590 | + | // stabilizeNewContainer monitors the new container briefly after traffic switch. | |||
| 591 | + | // If it crashes during this window, rolls back to old container. | |||
| 592 | + | // Returns true if stabilization succeeded, false if rollback was performed. | |||
| 593 | + | // Returns an error only when both new and old containers are dead. | |||
| 594 | + | func (s *Service) stabilizeNewContainer(ctx context.Context, domainName string, newContainer, oldContainer *domain.Container) (bool, error) { | |||
| 595 | + | log := zerowrap.FromCtx(ctx) | |||
| 596 | + | ||||
| 597 | + | if oldContainer == nil { | |||
| 598 | + | return true, nil | |||
| 599 | + | } | |||
| 600 | + | ||||
| 601 | + | s.mu.RLock() | |||
| 602 | + | delay := s.config.StabilizationDelay | |||
| 603 | + | s.mu.RUnlock() | |||
| 604 | + | if delay == 0 { | |||
| 605 | + | delay = 2 * time.Second | |||
| 606 | + | } | |||
| 607 | + | ||||
| 608 | + | // Brief stabilization: verify new container is still running after delay | |||
| 609 | + | select { | |||
| 610 | + | case <-time.After(delay): | |||
| 611 | + | case <-ctx.Done(): | |||
| 612 | + | return true, nil | |||
| 613 | + | } | |||
| 614 | + | ||||
| 615 | + | running, err := s.runtime.IsContainerRunning(ctx, newContainer.ID) | |||
| 616 | + | if err != nil || !running { | |||
| 617 | + | log.Error(). | |||
| 618 | + | Str("new_container", newContainer.ID). | |||
| 619 | + | Str("old_container", oldContainer.ID). | |||
| 620 | + | Msg("new container crashed during stabilization, rolling back to old") | |||
| 621 | + | ||||
| 622 | + | // Verify old container is still running before restoring it. | |||
| 623 | + | // If both old and new are dead (e.g., OOM), returning a dead | |||
| 624 | + | // container as "existing" would leave the domain in a broken state. | |||
| 625 | + | oldRunning, oldErr := s.runtime.IsContainerRunning(ctx, oldContainer.ID) | |||
| 626 | + | if oldErr != nil || !oldRunning { | |||
| 627 | + | log.Error(). | |||
| 628 | + | Str("old_container", oldContainer.ID). | |||
| 629 | + | Msg("old container is also not running, cannot rollback") | |||
| 630 | + | ||||
| 631 | + | // Cleanup failed new container | |||
| 632 | + | if stopErr := s.runtime.StopContainer(ctx, newContainer.ID); stopErr != nil { | |||
| 633 | + | log.WrapErrWithFields(stopErr, "failed to stop failed new container during rollback", map[string]any{zerowrap.FieldEntityID: newContainer.ID}) | |||
| 634 | + | } | |||
| 635 | + | if removeErr := s.runtime.RemoveContainer(ctx, newContainer.ID, true); removeErr != nil { | |||
| 636 | + | log.WrapErrWithFields(removeErr, "failed to remove failed new container during rollback", map[string]any{zerowrap.FieldEntityID: newContainer.ID}) | |||
| 637 | + | } | |||
| 638 | + | ||||
| 639 | + | return false, fmt.Errorf("stabilization failed: new container crashed and old container %s is also not running", oldContainer.ID) | |||
| 640 | + | } | |||
| 641 | + | ||||
| 642 | + | // Rollback: restore old container as tracked | |||
| 643 | + | s.mu.Lock() | |||
| 644 | + | s.containers[domainName] = oldContainer | |||
| 645 | + | s.mu.Unlock() | |||
| 646 | + | ||||
| 647 | + | // Re-invalidate proxy cache to point back to old | |||
| 648 | + | s.mu.RLock() | |||
| 649 | + | inv := s.cacheInvalidator | |||
| 650 | + | s.mu.RUnlock() | |||
| 651 | + | if inv != nil { | |||
| 652 | + | inv.InvalidateTarget(ctx, domainName) | |||
| 653 | + | } | |||
| 654 | + | ||||
| 655 | + | // Cleanup failed new container | |||
| 656 | + | if stopErr := s.runtime.StopContainer(ctx, newContainer.ID); stopErr != nil { | |||
| 657 | + | log.WrapErrWithFields(stopErr, "failed to stop failed new container during rollback", map[string]any{zerowrap.FieldEntityID: newContainer.ID}) | |||
| 658 | + | } | |||
| 659 | + | if removeErr := s.runtime.RemoveContainer(ctx, newContainer.ID, true); removeErr != nil { | |||
| 660 | + | log.WrapErrWithFields(removeErr, "failed to remove failed new container during rollback", map[string]any{zerowrap.FieldEntityID: newContainer.ID}) | |||
| 661 | + | } | |||
| 662 | + | ||||
| 663 | + | return false, nil | |||
| 664 | + | } | |||
| 665 | + | ||||
| 666 | + | return true, nil | |||
| 667 | + | } | |||
| 668 | + | ||||
| 483 | func (s *Service) finalizePreviousContainer(ctx context.Context, domainName string, existing *domain.Container, hasExisting, invalidated bool, newContainerID string) { | 669 | func (s *Service) finalizePreviousContainer(ctx context.Context, domainName string, existing *domain.Container, hasExisting, invalidated bool, newContainerID string) { | ||
| 484 | if !hasExisting { | 670 | if !hasExisting { | ||
| 485 | return | 671 | return | ||
| @@ -1587,7 +1773,19 @@ func (s *Service) cleanupOrphanedContainers(ctx context.Context, domainName stri | |||||
| 1587 | 1773 | ||||
| 1588 | for _, c := range allContainers { | 1774 | for _, c := range allContainers { | ||
| 1589 | if (c.Name == expectedName || c.Name == expectedNewName || c.Name == expectedNextName) && c.ID != skipContainerID { | 1775 | if (c.Name == expectedName || c.Name == expectedNewName || c.Name == expectedNextName) && c.ID != skipContainerID { | ||
| 1590 | - | log.Info().Str(zerowrap.FieldEntityID, c.ID).Str(zerowrap.FieldStatus, c.Status).Msg("found orphaned container, removing") | 1776 | + | // Skip any running container regardless of name — a running |
| 1777 | + | // temp container may be actively serving traffic while the | |||
| 1778 | + | // new container stabilizes, or may be from a concurrent deploy. | |||
| 1779 | + | if c.Status == "running" { | |||
| 1780 | + | log.Debug(). | |||
| 1781 | + | Str(zerowrap.FieldEntityID, c.ID). | |||
| 1782 | + | Str("container_name", c.Name). | |||
| 1783 | + | Str(zerowrap.FieldStatus, c.Status). | |||
| 1784 | + | Msg("skipping running container during orphan cleanup") | |||
| 1785 | + | continue | |||
| 1786 | + | } | |||
| 1787 | + | ||||
| 1788 | + | log.Info().Str(zerowrap.FieldEntityID, c.ID).Str("container_name", c.Name).Str(zerowrap.FieldStatus, c.Status).Msg("found orphaned container, removing") | |||
| 1591 | 1789 | ||||
| 1592 | if err := s.runtime.StopContainer(ctx, c.ID); err != nil { | 1790 | if err := s.runtime.StopContainer(ctx, c.ID); err != nil { | ||
| 1593 | log.WrapErrWithFields(err, "failed to stop orphaned container", map[string]any{zerowrap.FieldEntityID: c.ID}) | 1791 | log.WrapErrWithFields(err, "failed to stop orphaned container", map[string]any{zerowrap.FieldEntityID: c.ID}) | ||
| @@ -2065,7 +2263,7 @@ func (s *Service) findContainerByName(ctx context.Context, name string) *domain. | |||||
| 2065 | return nil | 2263 | return nil | ||
| 2066 | } | 2264 | } | ||
| 2067 | 2265 | ||||
| 2068 | - | func (s *Service) waitForReady(ctx context.Context, containerID string) error { | 2266 | + | func (s *Service) waitForReady(ctx context.Context, containerID string, containerConfig *domain.ContainerConfig) error { |
| 2069 | if err := s.pollContainerRunning(ctx, containerID); err != nil { | 2267 | if err := s.pollContainerRunning(ctx, containerID); err != nil { | ||
| 2070 | return err | 2268 | return err | ||
| 2071 | } | 2269 | } | ||
| @@ -2092,15 +2290,123 @@ func (s *Service) waitForReady(ctx context.Context, containerID string) error { | |||||
| 2092 | } | 2290 | } | ||
| 2093 | return s.waitForHealthy(ctx, containerID, cfg.HealthTimeout) | 2291 | return s.waitForHealthy(ctx, containerID, cfg.HealthTimeout) | ||
| 2094 | default: // auto | 2292 | default: // auto | ||
| 2095 | - | _, hasHealthcheck, err := s.runtime.GetContainerHealthStatus(ctx, containerID) | 2293 | + | return s.readinessCascade(ctx, containerID, containerConfig, cfg) |
| 2096 | - | if err != nil { | 2294 | + | } |
| 2097 | - | return err | 2295 | + | } |
| 2098 | - | } | 2296 | + | |
| 2099 | - | if hasHealthcheck { | 2297 | + | // readinessCascade auto-detects the strongest available readiness signal: |
| 2100 | - | return s.waitForHealthy(ctx, containerID, cfg.HealthTimeout) | 2298 | + | // 1. Docker healthcheck (if present) → wait for healthy status |
| 2101 | - | } | 2299 | + | // 2. HTTP probe (if gordon.health label set) → GET until 2xx/3xx |
| 2102 | - | return s.waitForReadyByDelay(ctx, containerID) | 2300 | + | // 3. TCP probe (if port info available) → connect until accepted |
| 2301 | + | // 4. Delay fallback (last resort) → waitForReadyByDelay | |||
| 2302 | + | func (s *Service) readinessCascade(ctx context.Context, containerID string, containerConfig *domain.ContainerConfig, cfg Config) error { | |||
| 2303 | + | log := zerowrap.FromCtx(ctx) | |||
| 2304 | + | ||||
| 2305 | + | // 1. Docker healthcheck | |||
| 2306 | + | _, hasHealthcheck, err := s.runtime.GetContainerHealthStatus(ctx, containerID) | |||
| 2307 | + | if err != nil { | |||
| 2308 | + | return err | |||
| 2309 | + | } | |||
| 2310 | + | if hasHealthcheck { | |||
| 2311 | + | log.Info().Msg("readiness cascade: using Docker healthcheck") | |||
| 2312 | + | return s.waitForHealthy(ctx, containerID, cfg.HealthTimeout) | |||
| 2313 | + | } | |||
| 2314 | + | ||||
| 2315 | + | // 2. HTTP probe via gordon.health label | |||
| 2316 | + | if probed, probeErr := s.tryHTTPProbe(ctx, containerID, containerConfig, cfg); probed { | |||
| 2317 | + | return probeErr | |||
| 2318 | + | } | |||
| 2319 | + | ||||
| 2320 | + | // 3. TCP probe (if port info available) | |||
| 2321 | + | if probed, probeErr := s.tryTCPProbe(ctx, containerID, containerConfig, cfg); probed { | |||
| 2322 | + | return probeErr | |||
| 2323 | + | } | |||
| 2324 | + | ||||
| 2325 | + | // 4. Delay fallback | |||
| 2326 | + | log.Info().Msg("readiness cascade: using delay fallback") | |||
| 2327 | + | return s.waitForReadyByDelay(ctx, containerID) | |||
| 2328 | + | } | |||
| 2329 | + | ||||
| 2330 | + | // tryHTTPProbe attempts an HTTP probe if the gordon.health label is set. | |||
| 2331 | + | // Returns (true, err) if the probe was attempted, (false, nil) if skipped. | |||
| 2332 | + | func (s *Service) tryHTTPProbe(ctx context.Context, containerID string, containerConfig *domain.ContainerConfig, cfg Config) (bool, error) { | |||
| 2333 | + | log := zerowrap.FromCtx(ctx) | |||
| 2334 | + | if containerConfig == nil { | |||
| 2335 | + | return false, nil | |||
| 2336 | + | } | |||
| 2337 | + | healthPath, ok := containerConfig.Labels[domain.LabelHealth] | |||
| 2338 | + | if !ok || healthPath == "" { | |||
| 2339 | + | return false, nil | |||
| 2340 | + | } | |||
| 2341 | + | ip, port, probeErr := s.resolveContainerEndpoint(ctx, containerID) | |||
| 2342 | + | if probeErr != nil || ip == "" || port <= 0 { | |||
| 2343 | + | log.Debug().Err(probeErr).Msg("readiness cascade: HTTP probe skipped, could not resolve container endpoint") | |||
| 2344 | + | return false, nil | |||
| 2345 | + | } | |||
| 2346 | + | url := fmt.Sprintf("http://%s:%d%s", ip, port, healthPath) | |||
| 2347 | + | timeout := cfg.HTTPProbeTimeout | |||
| 2348 | + | if timeout == 0 { | |||
| 2349 | + | timeout = 60 * time.Second | |||
| 2350 | + | } | |||
| 2351 | + | log.Info().Str("url", url).Dur("timeout", timeout).Msg("readiness cascade: using HTTP probe") | |||
| 2352 | + | return true, httpProbe(ctx, url, timeout) | |||
| 2353 | + | } | |||
| 2354 | + | ||||
| 2355 | + | // tryTCPProbe attempts a TCP probe if port info is available. | |||
| 2356 | + | // Returns (true, err) if the probe was attempted, (false, nil) if skipped. | |||
| 2357 | + | func (s *Service) tryTCPProbe(ctx context.Context, containerID string, containerConfig *domain.ContainerConfig, cfg Config) (bool, error) { | |||
| 2358 | + | log := zerowrap.FromCtx(ctx) | |||
| 2359 | + | if containerConfig == nil || len(containerConfig.Ports) == 0 { | |||
| 2360 | + | return false, nil | |||
| 2361 | + | } | |||
| 2362 | + | ip, port, probeErr := s.resolveContainerEndpoint(ctx, containerID) | |||
| 2363 | + | if probeErr != nil || ip == "" || port <= 0 { | |||
| 2364 | + | log.Debug().Err(probeErr).Msg("readiness cascade: TCP probe skipped, could not resolve container endpoint") | |||
| 2365 | + | return false, nil | |||
| 2366 | + | } | |||
| 2367 | + | addr := fmt.Sprintf("%s:%d", ip, port) | |||
| 2368 | + | timeout := cfg.TCPProbeTimeout | |||
| 2369 | + | if timeout == 0 { | |||
| 2370 | + | timeout = 30 * time.Second | |||
| 2371 | + | } | |||
| 2372 | + | log.Info().Str("addr", addr).Dur("timeout", timeout).Msg("readiness cascade: using TCP probe") | |||
| 2373 | + | return true, tcpProbe(ctx, addr, timeout) | |||
| 2374 | + | } | |||
| 2375 | + | ||||
| 2376 | + | // resolveContainerEndpoint returns a host-reachable address for probing. | |||
| 2377 | + | // In rootless podman/Docker setups, container internal IPs are not routable | |||
| 2378 | + | // from the host. We use the host port binding (127.0.0.1:<mapped_port>) | |||
| 2379 | + | // which is always reachable. Falls back to the container's internal IP | |||
| 2380 | + | // only if no host port mapping exists (e.g. host-network mode). | |||
| 2381 | + | func (s *Service) resolveContainerEndpoint(ctx context.Context, containerID string) (string, int, error) { | |||
| 2382 | + | log := zerowrap.FromCtx(ctx) | |||
| 2383 | + | ||||
| 2384 | + | // First, get the container's internal port from network info | |||
| 2385 | + | _, internalPort, err := s.runtime.GetContainerNetworkInfo(ctx, containerID) | |||
| 2386 | + | if err != nil { | |||
| 2387 | + | return "", 0, err | |||
| 2388 | + | } | |||
| 2389 | + | ||||
| 2390 | + | // Try to resolve via host port binding (works in rootless podman/Docker) | |||
| 2391 | + | hostPort, hostErr := s.runtime.GetContainerPort(ctx, containerID, internalPort) | |||
| 2392 | + | if hostErr == nil && hostPort > 0 { | |||
| 2393 | + | log.Debug(). | |||
| 2394 | + | Int("internal_port", internalPort). | |||
| 2395 | + | Int("host_port", hostPort). | |||
| 2396 | + | Msg("resolved container endpoint via host port binding") | |||
| 2397 | + | return "127.0.0.1", hostPort, nil | |||
| 2398 | + | } | |||
| 2399 | + | ||||
| 2400 | + | // Fallback: use internal IP (works in Docker rootful / host network) | |||
| 2401 | + | ip, _, fallbackErr := s.runtime.GetContainerNetworkInfo(ctx, containerID) | |||
| 2402 | + | if fallbackErr != nil { | |||
| 2403 | + | return "", 0, fallbackErr | |||
| 2103 | } | 2404 | } | ||
| 2405 | + | log.Debug(). | |||
| 2406 | + | Str("ip", ip). | |||
| 2407 | + | Int("port", internalPort). | |||
| 2408 | + | Msg("resolved container endpoint via internal IP (no host port binding)") | |||
| 2409 | + | return ip, internalPort, nil | |||
| 2104 | } | 2410 | } | ||
| 2105 | 2411 | ||||
| 2106 | // waitForReadyByDelay waits using the legacy running+delay strategy. | 2412 | // waitForReadyByDelay waits using the legacy running+delay strategy. | ||
M internal/usecase/container/service_readiness_test.go +175 -1
| @@ -1,16 +1,31 @@ | |||
| 1 | 1 | package container | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "net" | |
| 4 | 5 | "strings" | |
| 5 | 6 | "testing" | |
| 6 | 7 | "time" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/stretchr/testify/assert" | |
| 9 | 10 | "github.com/stretchr/testify/mock" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 10 | 12 | ||
| 11 | 13 | "github.com/bnema/gordon/internal/boundaries/out/mocks" | |
| 14 | + | "github.com/bnema/gordon/internal/domain" | |
| 12 | 15 | ) | |
| 13 | 16 | ||
| 17 | + | // reserveEphemeralAddr binds on 127.0.0.1:0, captures the assigned port, closes | |
| 18 | + | // the listener, and returns the address string. The port is guaranteed to be | |
| 19 | + | // unreachable (no process listening) when the probe runs. | |
| 20 | + | func reserveEphemeralAddr(t *testing.T) (ip string, port int) { | |
| 21 | + | t.Helper() | |
| 22 | + | ln, err := net.Listen("tcp", "127.0.0.1:0") | |
| 23 | + | require.NoError(t, err) | |
| 24 | + | addr := ln.Addr().(*net.TCPAddr) | |
| 25 | + | ln.Close() | |
| 26 | + | return addr.IP.String(), addr.Port | |
| 27 | + | } | |
| 28 | + | ||
| 14 | 29 | func TestService_WaitForReady_AutoFallsBackToDelayWhenNoHealthcheck(t *testing.T) { | |
| 15 | 30 | runtime := mocks.NewMockContainerRuntime(t) | |
| 16 | 31 | svc := NewService(runtime, nil, nil, nil, Config{ | |
| @@ -28,7 +43,166 @@ func TestService_WaitForReady_AutoFallsBackToDelayWhenNoHealthcheck(t *testing.T | |||
| 28 | 43 | // Delay-mode fallback verification | |
| 29 | 44 | runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once() | |
| 30 | 45 | ||
| 31 | - | err := svc.waitForReady(ctx, containerID) | |
| 46 | + | err := svc.waitForReady(ctx, containerID, nil) | |
| 47 | + | assert.NoError(t, err) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func TestService_WaitForReady_AutoCascadeUsesTCPWhenNoHealthcheckAndNoHealthLabel(t *testing.T) { | |
| 51 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 52 | + | svc := NewService(runtime, nil, nil, nil, Config{ | |
| 53 | + | ReadinessMode: "auto", | |
| 54 | + | }) | |
| 55 | + | ||
| 56 | + | ctx := testContext() | |
| 57 | + | containerID := "container-1" | |
| 58 | + | ||
| 59 | + | containerConfig := &domain.ContainerConfig{ | |
| 60 | + | Ports: []int{8080}, | |
| 61 | + | Labels: map[string]string{}, | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // Reserve an ephemeral loopback port that is guaranteed to be unreachable. | |
| 65 | + | probeIP, probePort := reserveEphemeralAddr(t) | |
| 66 | + | ||
| 67 | + | // Initial running poll | |
| 68 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once() | |
| 69 | + | // No Docker healthcheck | |
| 70 | + | runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once() | |
| 71 | + | // Cascade resolves container endpoint for TCP probe | |
| 72 | + | runtime.EXPECT().GetContainerNetworkInfo(mock.Anything, containerID).Return(probeIP, probePort, nil).Once() | |
| 73 | + | // Host port binding resolution — return the same loopback addr so probe hits it | |
| 74 | + | runtime.EXPECT().GetContainerPort(mock.Anything, containerID, probePort).Return(probePort, nil).Once() | |
| 75 | + | ||
| 76 | + | // TCP probe will try to connect — which will fail since the port is closed. | |
| 77 | + | // Use a short health timeout so the test doesn't hang. | |
| 78 | + | svc.mu.Lock() | |
| 79 | + | svc.config.TCPProbeTimeout = 50 * time.Millisecond | |
| 80 | + | svc.mu.Unlock() | |
| 81 | + | ||
| 82 | + | err := svc.waitForReady(ctx, containerID, containerConfig) | |
| 83 | + | // Expect TCP probe timeout (no server listening) | |
| 84 | + | assert.Error(t, err) | |
| 85 | + | assert.Contains(t, err.Error(), "TCP probe timeout") | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func TestService_WaitForReady_AutoCascadeUsesHTTPProbeWhenHealthLabelSet(t *testing.T) { | |
| 89 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 90 | + | svc := NewService(runtime, nil, nil, nil, Config{ | |
| 91 | + | ReadinessMode: "auto", | |
| 92 | + | HTTPProbeTimeout: 50 * time.Millisecond, | |
| 93 | + | }) | |
| 94 | + | ||
| 95 | + | ctx := testContext() | |
| 96 | + | containerID := "container-1" | |
| 97 | + | ||
| 98 | + | containerConfig := &domain.ContainerConfig{ | |
| 99 | + | Ports: []int{8080}, | |
| 100 | + | Labels: map[string]string{ | |
| 101 | + | domain.LabelHealth: "/healthz", | |
| 102 | + | }, | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | // Reserve an ephemeral loopback port that is guaranteed to be unreachable. | |
| 106 | + | probeIP, probePort := reserveEphemeralAddr(t) | |
| 107 | + | ||
| 108 | + | // Initial running poll | |
| 109 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once() | |
| 110 | + | // No Docker healthcheck | |
| 111 | + | runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once() | |
| 112 | + | // Cascade resolves container endpoint for HTTP probe | |
| 113 | + | runtime.EXPECT().GetContainerNetworkInfo(mock.Anything, containerID).Return(probeIP, probePort, nil).Once() | |
| 114 | + | // Host port binding resolution — return the same loopback addr so probe hits it | |
| 115 | + | runtime.EXPECT().GetContainerPort(mock.Anything, containerID, probePort).Return(probePort, nil).Once() | |
| 116 | + | ||
| 117 | + | err := svc.waitForReady(ctx, containerID, containerConfig) | |
| 118 | + | // Expect HTTP probe timeout (no server listening) | |
| 119 | + | assert.Error(t, err) | |
| 120 | + | assert.Contains(t, err.Error(), "HTTP probe timeout") | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func TestService_WaitForReady_AutoCascadeUsesHealthcheckWhenPresent(t *testing.T) { | |
| 124 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 125 | + | svc := NewService(runtime, nil, nil, nil, Config{ | |
| 126 | + | ReadinessMode: "auto", | |
| 127 | + | HealthTimeout: 50 * time.Millisecond, | |
| 128 | + | }) | |
| 129 | + | ||
| 130 | + | ctx := testContext() | |
| 131 | + | containerID := "container-1" | |
| 132 | + | ||
| 133 | + | containerConfig := &domain.ContainerConfig{ | |
| 134 | + | Ports: []int{8080}, | |
| 135 | + | Labels: map[string]string{ | |
| 136 | + | domain.LabelHealth: "/healthz", | |
| 137 | + | }, | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | // Initial running poll | |
| 141 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once() | |
| 142 | + | // Docker healthcheck IS present — cascade detects it, then waitForHealthy polls it | |
| 143 | + | // First call: cascade detection (hasHealthcheck=true) | |
| 144 | + | // Second call: waitForHealthy loop (status=healthy) | |
| 145 | + | runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("starting", true, nil).Once() | |
| 146 | + | runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("healthy", true, nil).Once() | |
| 147 | + | ||
| 148 | + | err := svc.waitForReady(ctx, containerID, containerConfig) | |
| 149 | + | assert.NoError(t, err) | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | func TestService_WaitForReady_AutoCascadeFallsToDelayWhenNoEndpoint(t *testing.T) { | |
| 153 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 154 | + | svc := NewService(runtime, nil, nil, nil, Config{ | |
| 155 | + | ReadinessMode: "auto", | |
| 156 | + | ReadinessDelay: time.Millisecond, | |
| 157 | + | }) | |
| 158 | + | ||
| 159 | + | ctx := testContext() | |
| 160 | + | containerID := "container-1" | |
| 161 | + | ||
| 162 | + | // Container has ports but network info is unavailable | |
| 163 | + | containerConfig := &domain.ContainerConfig{ | |
| 164 | + | Ports: []int{8080}, | |
| 165 | + | Labels: map[string]string{}, | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | // Initial running poll | |
| 169 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once() | |
| 170 | + | // No Docker healthcheck | |
| 171 | + | runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once() | |
| 172 | + | // Network info fails — can't do TCP probe, fall through to delay | |
| 173 | + | runtime.EXPECT().GetContainerNetworkInfo(mock.Anything, containerID).Return("", 0, assert.AnError).Once() | |
| 174 | + | // Delay-mode fallback verification | |
| 175 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once() | |
| 176 | + | ||
| 177 | + | err := svc.waitForReady(ctx, containerID, containerConfig) | |
| 178 | + | assert.NoError(t, err) | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | func TestService_WaitForReady_ExplicitDelayModeIgnoresCascade(t *testing.T) { | |
| 182 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 183 | + | svc := NewService(runtime, nil, nil, nil, Config{ | |
| 184 | + | ReadinessMode: "delay", | |
| 185 | + | ReadinessDelay: time.Millisecond, | |
| 186 | + | }) | |
| 187 | + | ||
| 188 | + | ctx := testContext() | |
| 189 | + | containerID := "container-1" | |
| 190 | + | ||
| 191 | + | // Even with health label and ports, "delay" mode skips cascade entirely | |
| 192 | + | containerConfig := &domain.ContainerConfig{ | |
| 193 | + | Ports: []int{8080}, | |
| 194 | + | Labels: map[string]string{ | |
| 195 | + | domain.LabelHealth: "/healthz", | |
| 196 | + | }, | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | // Initial running poll | |
| 200 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once() | |
| 201 | + | // No GetContainerHealthStatus call — delay mode skips it | |
| 202 | + | // Delay-mode verification | |
| 203 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once() | |
| 204 | + | ||
| 205 | + | err := svc.waitForReady(ctx, containerID, containerConfig) | |
| 32 | 206 | assert.NoError(t, err) | |
| 33 | 207 | } | |
| 34 | 208 | ||
| @@ -1,16 +1,31 @@ | |||||
| 1 | package container | 1 | package container | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "net" | |||
| 4 | "strings" | 5 | "strings" | ||
| 5 | "testing" | 6 | "testing" | ||
| 6 | "time" | 7 | "time" | ||
| 7 | 8 | ||||
| 8 | "github.com/stretchr/testify/assert" | 9 | "github.com/stretchr/testify/assert" | ||
| 9 | "github.com/stretchr/testify/mock" | 10 | "github.com/stretchr/testify/mock" | ||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 10 | 12 | ||||
| 11 | "github.com/bnema/gordon/internal/boundaries/out/mocks" | 13 | "github.com/bnema/gordon/internal/boundaries/out/mocks" | ||
| 14 | + | "github.com/bnema/gordon/internal/domain" | |||
| 12 | ) | 15 | ) | ||
| 13 | 16 | ||||
| 17 | + | // reserveEphemeralAddr binds on 127.0.0.1:0, captures the assigned port, closes | |||
| 18 | + | // the listener, and returns the address string. The port is guaranteed to be | |||
| 19 | + | // unreachable (no process listening) when the probe runs. | |||
| 20 | + | func reserveEphemeralAddr(t *testing.T) (ip string, port int) { | |||
| 21 | + | t.Helper() | |||
| 22 | + | ln, err := net.Listen("tcp", "127.0.0.1:0") | |||
| 23 | + | require.NoError(t, err) | |||
| 24 | + | addr := ln.Addr().(*net.TCPAddr) | |||
| 25 | + | ln.Close() | |||
| 26 | + | return addr.IP.String(), addr.Port | |||
| 27 | + | } | |||
| 28 | + | ||||
| 14 | func TestService_WaitForReady_AutoFallsBackToDelayWhenNoHealthcheck(t *testing.T) { | 29 | func TestService_WaitForReady_AutoFallsBackToDelayWhenNoHealthcheck(t *testing.T) { | ||
| 15 | runtime := mocks.NewMockContainerRuntime(t) | 30 | runtime := mocks.NewMockContainerRuntime(t) | ||
| 16 | svc := NewService(runtime, nil, nil, nil, Config{ | 31 | svc := NewService(runtime, nil, nil, nil, Config{ | ||
| @@ -28,7 +43,166 @@ func TestService_WaitForReady_AutoFallsBackToDelayWhenNoHealthcheck(t *testing.T | |||||
| 28 | // Delay-mode fallback verification | 43 | // Delay-mode fallback verification | ||
| 29 | runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once() | 44 | runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once() | ||
| 30 | 45 | ||||
| 31 | - | err := svc.waitForReady(ctx, containerID) | 46 | + | err := svc.waitForReady(ctx, containerID, nil) |
| 47 | + | assert.NoError(t, err) | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func TestService_WaitForReady_AutoCascadeUsesTCPWhenNoHealthcheckAndNoHealthLabel(t *testing.T) { | |||
| 51 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 52 | + | svc := NewService(runtime, nil, nil, nil, Config{ | |||
| 53 | + | ReadinessMode: "auto", | |||
| 54 | + | }) | |||
| 55 | + | ||||
| 56 | + | ctx := testContext() | |||
| 57 | + | containerID := "container-1" | |||
| 58 | + | ||||
| 59 | + | containerConfig := &domain.ContainerConfig{ | |||
| 60 | + | Ports: []int{8080}, | |||
| 61 | + | Labels: map[string]string{}, | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | // Reserve an ephemeral loopback port that is guaranteed to be unreachable. | |||
| 65 | + | probeIP, probePort := reserveEphemeralAddr(t) | |||
| 66 | + | ||||
| 67 | + | // Initial running poll | |||
| 68 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once() | |||
| 69 | + | // No Docker healthcheck | |||
| 70 | + | runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once() | |||
| 71 | + | // Cascade resolves container endpoint for TCP probe | |||
| 72 | + | runtime.EXPECT().GetContainerNetworkInfo(mock.Anything, containerID).Return(probeIP, probePort, nil).Once() | |||
| 73 | + | // Host port binding resolution — return the same loopback addr so probe hits it | |||
| 74 | + | runtime.EXPECT().GetContainerPort(mock.Anything, containerID, probePort).Return(probePort, nil).Once() | |||
| 75 | + | ||||
| 76 | + | // TCP probe will try to connect — which will fail since the port is closed. | |||
| 77 | + | // Use a short health timeout so the test doesn't hang. | |||
| 78 | + | svc.mu.Lock() | |||
| 79 | + | svc.config.TCPProbeTimeout = 50 * time.Millisecond | |||
| 80 | + | svc.mu.Unlock() | |||
| 81 | + | ||||
| 82 | + | err := svc.waitForReady(ctx, containerID, containerConfig) | |||
| 83 | + | // Expect TCP probe timeout (no server listening) | |||
| 84 | + | assert.Error(t, err) | |||
| 85 | + | assert.Contains(t, err.Error(), "TCP probe timeout") | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | func TestService_WaitForReady_AutoCascadeUsesHTTPProbeWhenHealthLabelSet(t *testing.T) { | |||
| 89 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 90 | + | svc := NewService(runtime, nil, nil, nil, Config{ | |||
| 91 | + | ReadinessMode: "auto", | |||
| 92 | + | HTTPProbeTimeout: 50 * time.Millisecond, | |||
| 93 | + | }) | |||
| 94 | + | ||||
| 95 | + | ctx := testContext() | |||
| 96 | + | containerID := "container-1" | |||
| 97 | + | ||||
| 98 | + | containerConfig := &domain.ContainerConfig{ | |||
| 99 | + | Ports: []int{8080}, | |||
| 100 | + | Labels: map[string]string{ | |||
| 101 | + | domain.LabelHealth: "/healthz", | |||
| 102 | + | }, | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | // Reserve an ephemeral loopback port that is guaranteed to be unreachable. | |||
| 106 | + | probeIP, probePort := reserveEphemeralAddr(t) | |||
| 107 | + | ||||
| 108 | + | // Initial running poll | |||
| 109 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once() | |||
| 110 | + | // No Docker healthcheck | |||
| 111 | + | runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once() | |||
| 112 | + | // Cascade resolves container endpoint for HTTP probe | |||
| 113 | + | runtime.EXPECT().GetContainerNetworkInfo(mock.Anything, containerID).Return(probeIP, probePort, nil).Once() | |||
| 114 | + | // Host port binding resolution — return the same loopback addr so probe hits it | |||
| 115 | + | runtime.EXPECT().GetContainerPort(mock.Anything, containerID, probePort).Return(probePort, nil).Once() | |||
| 116 | + | ||||
| 117 | + | err := svc.waitForReady(ctx, containerID, containerConfig) | |||
| 118 | + | // Expect HTTP probe timeout (no server listening) | |||
| 119 | + | assert.Error(t, err) | |||
| 120 | + | assert.Contains(t, err.Error(), "HTTP probe timeout") | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func TestService_WaitForReady_AutoCascadeUsesHealthcheckWhenPresent(t *testing.T) { | |||
| 124 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 125 | + | svc := NewService(runtime, nil, nil, nil, Config{ | |||
| 126 | + | ReadinessMode: "auto", | |||
| 127 | + | HealthTimeout: 50 * time.Millisecond, | |||
| 128 | + | }) | |||
| 129 | + | ||||
| 130 | + | ctx := testContext() | |||
| 131 | + | containerID := "container-1" | |||
| 132 | + | ||||
| 133 | + | containerConfig := &domain.ContainerConfig{ | |||
| 134 | + | Ports: []int{8080}, | |||
| 135 | + | Labels: map[string]string{ | |||
| 136 | + | domain.LabelHealth: "/healthz", | |||
| 137 | + | }, | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | // Initial running poll | |||
| 141 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once() | |||
| 142 | + | // Docker healthcheck IS present — cascade detects it, then waitForHealthy polls it | |||
| 143 | + | // First call: cascade detection (hasHealthcheck=true) | |||
| 144 | + | // Second call: waitForHealthy loop (status=healthy) | |||
| 145 | + | runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("starting", true, nil).Once() | |||
| 146 | + | runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("healthy", true, nil).Once() | |||
| 147 | + | ||||
| 148 | + | err := svc.waitForReady(ctx, containerID, containerConfig) | |||
| 149 | + | assert.NoError(t, err) | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | func TestService_WaitForReady_AutoCascadeFallsToDelayWhenNoEndpoint(t *testing.T) { | |||
| 153 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 154 | + | svc := NewService(runtime, nil, nil, nil, Config{ | |||
| 155 | + | ReadinessMode: "auto", | |||
| 156 | + | ReadinessDelay: time.Millisecond, | |||
| 157 | + | }) | |||
| 158 | + | ||||
| 159 | + | ctx := testContext() | |||
| 160 | + | containerID := "container-1" | |||
| 161 | + | ||||
| 162 | + | // Container has ports but network info is unavailable | |||
| 163 | + | containerConfig := &domain.ContainerConfig{ | |||
| 164 | + | Ports: []int{8080}, | |||
| 165 | + | Labels: map[string]string{}, | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | // Initial running poll | |||
| 169 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once() | |||
| 170 | + | // No Docker healthcheck | |||
| 171 | + | runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once() | |||
| 172 | + | // Network info fails — can't do TCP probe, fall through to delay | |||
| 173 | + | runtime.EXPECT().GetContainerNetworkInfo(mock.Anything, containerID).Return("", 0, assert.AnError).Once() | |||
| 174 | + | // Delay-mode fallback verification | |||
| 175 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once() | |||
| 176 | + | ||||
| 177 | + | err := svc.waitForReady(ctx, containerID, containerConfig) | |||
| 178 | + | assert.NoError(t, err) | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | func TestService_WaitForReady_ExplicitDelayModeIgnoresCascade(t *testing.T) { | |||
| 182 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 183 | + | svc := NewService(runtime, nil, nil, nil, Config{ | |||
| 184 | + | ReadinessMode: "delay", | |||
| 185 | + | ReadinessDelay: time.Millisecond, | |||
| 186 | + | }) | |||
| 187 | + | ||||
| 188 | + | ctx := testContext() | |||
| 189 | + | containerID := "container-1" | |||
| 190 | + | ||||
| 191 | + | // Even with health label and ports, "delay" mode skips cascade entirely | |||
| 192 | + | containerConfig := &domain.ContainerConfig{ | |||
| 193 | + | Ports: []int{8080}, | |||
| 194 | + | Labels: map[string]string{ | |||
| 195 | + | domain.LabelHealth: "/healthz", | |||
| 196 | + | }, | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | // Initial running poll | |||
| 200 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once() | |||
| 201 | + | // No GetContainerHealthStatus call — delay mode skips it | |||
| 202 | + | // Delay-mode verification | |||
| 203 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once() | |||
| 204 | + | ||||
| 205 | + | err := svc.waitForReady(ctx, containerID, containerConfig) | |||
| 32 | assert.NoError(t, err) | 206 | assert.NoError(t, err) | ||
| 33 | } | 207 | } | ||
| 34 | 208 | ||||
M internal/usecase/container/service_test.go +811 -89
| @@ -25,6 +25,17 @@ func testContext() context.Context { | |||
| 25 | 25 | return zerowrap.WithCtx(context.Background(), zerowrap.Default()) | |
| 26 | 26 | } | |
| 27 | 27 | ||
| 28 | + | // testMinDelayConfig returns a Config with all timing delays set to 1ms, | |
| 29 | + | // suitable for unit tests that don't want to wait for real timeouts. | |
| 30 | + | func testMinDelayConfig() Config { | |
| 31 | + | return Config{ | |
| 32 | + | ReadinessDelay: time.Millisecond, | |
| 33 | + | DrainDelay: time.Millisecond, | |
| 34 | + | DrainDelayConfigured: true, | |
| 35 | + | StabilizationDelay: time.Millisecond, | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | ||
| 28 | 39 | func setupMetricsTest(t *testing.T) (*telemetry.Metrics, *sdkmetric.ManualReader) { | |
| 29 | 40 | t.Helper() | |
| 30 | 41 | ||
| @@ -184,10 +195,11 @@ func TestService_Deploy_Success(t *testing.T) { | |||
| 184 | 195 | eventBus := mocks.NewMockEventPublisher(t) | |
| 185 | 196 | ||
| 186 | 197 | config := Config{ | |
| 187 | - | NetworkIsolation: false, | |
| 188 | - | VolumeAutoCreate: false, | |
| 189 | - | ReadinessDelay: time.Millisecond, // Minimal delay for tests | |
| 190 | - | DrainDelay: time.Millisecond, | |
| 198 | + | NetworkIsolation: false, | |
| 199 | + | VolumeAutoCreate: false, | |
| 200 | + | ReadinessDelay: time.Millisecond, // Minimal delay for tests | |
| 201 | + | DrainDelay: time.Millisecond, | |
| 202 | + | DrainDelayConfigured: true, | |
| 191 | 203 | } | |
| 192 | 204 | ||
| 193 | 205 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| @@ -198,6 +210,9 @@ func TestService_Deploy_Success(t *testing.T) { | |||
| 198 | 210 | Image: "myapp:latest", | |
| 199 | 211 | } | |
| 200 | 212 | ||
| 213 | + | // No container in memory — runtime resolution returns nothing | |
| 214 | + | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil) | |
| 215 | + | ||
| 201 | 216 | // Setup mocks - no orphaned containers | |
| 202 | 217 | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | |
| 203 | 218 | ||
| @@ -261,10 +276,11 @@ func TestService_Deploy_ReadinessRecoveryWindow_AllowsTransientFlap(t *testing.T | |||
| 261 | 276 | eventBus := mocks.NewMockEventPublisher(t) | |
| 262 | 277 | ||
| 263 | 278 | config := Config{ | |
| 264 | - | NetworkIsolation: false, | |
| 265 | - | VolumeAutoCreate: false, | |
| 266 | - | ReadinessDelay: time.Millisecond, | |
| 267 | - | DrainDelay: time.Millisecond, | |
| 279 | + | NetworkIsolation: false, | |
| 280 | + | VolumeAutoCreate: false, | |
| 281 | + | ReadinessDelay: time.Millisecond, | |
| 282 | + | DrainDelay: time.Millisecond, | |
| 283 | + | DrainDelayConfigured: true, | |
| 268 | 284 | } | |
| 269 | 285 | ||
| 270 | 286 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| @@ -275,6 +291,9 @@ func TestService_Deploy_ReadinessRecoveryWindow_AllowsTransientFlap(t *testing.T | |||
| 275 | 291 | Image: "myapp:latest", | |
| 276 | 292 | } | |
| 277 | 293 | ||
| 294 | + | // No container in memory — runtime resolution returns nothing | |
| 295 | + | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil) | |
| 296 | + | ||
| 278 | 297 | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | |
| 279 | 298 | runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil) | |
| 280 | 299 | runtime.EXPECT().PullImage(mock.Anything, "myapp:latest").Return(nil) | |
| @@ -329,6 +348,9 @@ func TestService_Deploy_ImagePullFailure(t *testing.T) { | |||
| 329 | 348 | Image: "myapp:latest", | |
| 330 | 349 | } | |
| 331 | 350 | ||
| 351 | + | // No container in memory — runtime resolution returns nothing | |
| 352 | + | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil) | |
| 353 | + | ||
| 332 | 354 | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | |
| 333 | 355 | runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil) | |
| 334 | 356 | runtime.EXPECT().PullImage(mock.Anything, "myapp:latest").Return(errors.New("image not found")) | |
| @@ -406,8 +428,10 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) { | |||
| 406 | 428 | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | |
| 407 | 429 | ||
| 408 | 430 | config := Config{ | |
| 409 | - | ReadinessDelay: time.Millisecond, // Minimal delay for tests | |
| 410 | - | DrainDelay: time.Millisecond, | |
| 431 | + | ReadinessDelay: time.Millisecond, // Minimal delay for tests | |
| 432 | + | DrainDelay: time.Millisecond, | |
| 433 | + | DrainDelayConfigured: true, | |
| 434 | + | StabilizationDelay: time.Millisecond, | |
| 411 | 435 | } | |
| 412 | 436 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 413 | 437 | svc.SetProxyCacheInvalidator(cacheInvalidator) | |
| @@ -445,8 +469,8 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) { | |||
| 445 | 469 | })).Return(newContainer, nil) | |
| 446 | 470 | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | |
| 447 | 471 | ||
| 448 | - | // Wait for ready | |
| 449 | - | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2) | |
| 472 | + | // Wait for ready (2 calls) + stabilization check (1 call) | |
| 473 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3) | |
| 450 | 474 | ||
| 451 | 475 | // Inspect after ready | |
| 452 | 476 | runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{ | |
| @@ -460,7 +484,7 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) { | |||
| 460 | 484 | // Synchronous cache invalidation before old container cleanup | |
| 461 | 485 | cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return() | |
| 462 | 486 | ||
| 463 | - | // Now cleanup old container (after new one is ready + cache invalidated + drain delay) | |
| 487 | + | // Now cleanup old container (after new one is ready + cache invalidated + stabilization + drain delay) | |
| 464 | 488 | runtime.EXPECT().StopContainer(mock.Anything, "old-container").Return(nil) | |
| 465 | 489 | runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil) | |
| 466 | 490 | ||
| @@ -471,10 +495,106 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) { | |||
| 471 | 495 | ||
| 472 | 496 | assert.NoError(t, err) | |
| 473 | 497 | assert.Equal(t, "new-container", result.ID) | |
| 498 | + | } | |
| 499 | + | ||
| 500 | + | // TestService_Deploy_ResolvesExistingFromRuntime_WhenMemoryStale verifies that | |
| 501 | + | // when in-memory state has no tracked container for a domain, Deploy queries | |
| 502 | + | // the runtime and discovers the running container. This prevents orphan cleanup | |
| 503 | + | // from killing the real active container after a Gordon restart. | |
| 504 | + | func TestService_Deploy_ResolvesExistingFromRuntime_WhenMemoryStale(t *testing.T) { | |
| 505 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 506 | + | envLoader := mocks.NewMockEnvLoader(t) | |
| 507 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 508 | + | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | |
| 509 | + | ||
| 510 | + | config := testMinDelayConfig() | |
| 511 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 512 | + | svc.SetProxyCacheInvalidator(cacheInvalidator) | |
| 513 | + | ctx := testContext() | |
| 514 | + | ||
| 515 | + | // NO tracked container in memory — simulates Gordon restart | |
| 516 | + | // (svc.containers is empty for "test.example.com") | |
| 517 | + | ||
| 518 | + | route := domain.Route{ | |
| 519 | + | Domain: "test.example.com", | |
| 520 | + | Image: "myapp:v2", | |
| 521 | + | } | |
| 522 | + | ||
| 523 | + | // resolveExistingContainer should query running containers from runtime | |
| 524 | + | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{ | |
| 525 | + | { | |
| 526 | + | ID: "runtime-active-container", | |
| 527 | + | Name: "gordon-test.example.com", | |
| 528 | + | Status: "running", | |
| 529 | + | Labels: map[string]string{ | |
| 530 | + | domain.LabelDomain: "test.example.com", | |
| 531 | + | domain.LabelManaged: "true", | |
| 532 | + | }, | |
| 533 | + | }, | |
| 534 | + | }, nil) | |
| 535 | + | ||
| 536 | + | // Orphan cleanup lists ALL containers (including stopped) — the runtime-active | |
| 537 | + | // container should be skipped because it is now recognized as the existing one | |
| 538 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{ | |
| 539 | + | { | |
| 540 | + | ID: "runtime-active-container", | |
| 541 | + | Name: "gordon-test.example.com", | |
| 542 | + | Status: "running", | |
| 543 | + | Labels: map[string]string{ | |
| 544 | + | domain.LabelDomain: "test.example.com", | |
| 545 | + | domain.LabelManaged: "true", | |
| 546 | + | }, | |
| 547 | + | }, | |
| 548 | + | }, nil) | |
| 549 | + | ||
| 550 | + | // Image operations | |
| 551 | + | runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil) | |
| 552 | + | runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil) | |
| 553 | + | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil) | |
| 554 | + | ||
| 555 | + | // Environment | |
| 556 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil) | |
| 557 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil) | |
| 558 | + | ||
| 559 | + | // Create new container with -new suffix (zero-downtime: existing was resolved from runtime) | |
| 560 | + | newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"} | |
| 561 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool { | |
| 562 | + | return cfg.Name == "gordon-test.example.com-new" | |
| 563 | + | })).Return(newContainer, nil) | |
| 564 | + | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | |
| 565 | + | ||
| 566 | + | // Wait for ready (2 calls) + stabilization check (1 call) | |
| 567 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3) | |
| 568 | + | ||
| 569 | + | // Inspect after ready | |
| 570 | + | runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{ | |
| 571 | + | ID: "new-container", | |
| 572 | + | Status: "running", | |
| 573 | + | }, nil) | |
| 574 | + | ||
| 575 | + | // Publish event | |
| 576 | + | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil) | |
| 577 | + | ||
| 578 | + | // Synchronous cache invalidation | |
| 579 | + | cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return() | |
| 580 | + | ||
| 581 | + | // AFTER new container is ready: stop and remove old runtime-discovered container | |
| 582 | + | // This is the key assertion — StopContainer on the runtime-active container | |
| 583 | + | // happens during finalizePreviousContainer, NOT during orphan cleanup | |
| 584 | + | runtime.EXPECT().StopContainer(mock.Anything, "runtime-active-container").Return(nil) | |
| 585 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "runtime-active-container", true).Return(nil) | |
| 586 | + | ||
| 587 | + | // Rename new container to canonical name | |
| 588 | + | runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil) | |
| 589 | + | ||
| 590 | + | result, err := svc.Deploy(ctx, route) | |
| 591 | + | ||
| 592 | + | assert.NoError(t, err) | |
| 593 | + | assert.Equal(t, "new-container", result.ID) | |
| 474 | 594 | ||
| 475 | - | // Ensure the service's tracked container entry now points to the new container. | |
| 476 | - | tracked, ok := svc.Get(ctx, "test.example.com") | |
| 477 | - | assert.True(t, ok) | |
| 595 | + | // Verify new container is now tracked | |
| 596 | + | tracked, exists := svc.Get(ctx, "test.example.com") | |
| 597 | + | assert.True(t, exists) | |
| 478 | 598 | assert.Equal(t, "new-container", tracked.ID) | |
| 479 | 599 | } | |
| 480 | 600 | ||
| @@ -483,10 +603,7 @@ func TestService_Deploy_SkipRedundantDeploy_GetImageIDError(t *testing.T) { | |||
| 483 | 603 | envLoader := mocks.NewMockEnvLoader(t) | |
| 484 | 604 | eventBus := mocks.NewMockEventPublisher(t) | |
| 485 | 605 | ||
| 486 | - | config := Config{ | |
| 487 | - | ReadinessDelay: time.Millisecond, | |
| 488 | - | DrainDelay: time.Millisecond, | |
| 489 | - | } | |
| 606 | + | config := testMinDelayConfig() | |
| 490 | 607 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 491 | 608 | ctx := testContext() | |
| 492 | 609 | ||
| @@ -519,7 +636,8 @@ func TestService_Deploy_SkipRedundantDeploy_GetImageIDError(t *testing.T) { | |||
| 519 | 636 | newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"} | |
| 520 | 637 | runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil) | |
| 521 | 638 | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | |
| 522 | - | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2) | |
| 639 | + | // Wait for ready (2 calls) + stabilization check (1 call) | |
| 640 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3) | |
| 523 | 641 | runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{ | |
| 524 | 642 | ID: "new-container", Status: "running", | |
| 525 | 643 | }, nil) | |
| @@ -542,10 +660,11 @@ func TestService_Deploy_WithNetworkIsolation(t *testing.T) { | |||
| 542 | 660 | eventBus := mocks.NewMockEventPublisher(t) | |
| 543 | 661 | ||
| 544 | 662 | config := Config{ | |
| 545 | - | NetworkIsolation: true, | |
| 546 | - | NetworkPrefix: "gordon", | |
| 547 | - | ReadinessDelay: time.Millisecond, // Minimal delay for tests | |
| 548 | - | DrainDelay: time.Millisecond, | |
| 663 | + | NetworkIsolation: true, | |
| 664 | + | NetworkPrefix: "gordon", | |
| 665 | + | ReadinessDelay: time.Millisecond, // Minimal delay for tests | |
| 666 | + | DrainDelay: time.Millisecond, | |
| 667 | + | DrainDelayConfigured: true, | |
| 549 | 668 | } | |
| 550 | 669 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 551 | 670 | ctx := testContext() | |
| @@ -555,6 +674,9 @@ func TestService_Deploy_WithNetworkIsolation(t *testing.T) { | |||
| 555 | 674 | Image: "myapp:latest", | |
| 556 | 675 | } | |
| 557 | 676 | ||
| 677 | + | // No container in memory — runtime resolution returns nothing | |
| 678 | + | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil) | |
| 679 | + | ||
| 558 | 680 | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | |
| 559 | 681 | runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil) | |
| 560 | 682 | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil) | |
| @@ -594,10 +716,11 @@ func TestService_Deploy_WithVolumeAutoCreate(t *testing.T) { | |||
| 594 | 716 | eventBus := mocks.NewMockEventPublisher(t) | |
| 595 | 717 | ||
| 596 | 718 | config := Config{ | |
| 597 | - | VolumeAutoCreate: true, | |
| 598 | - | VolumePrefix: "gordon", | |
| 599 | - | ReadinessDelay: time.Millisecond, // Minimal delay for tests | |
| 600 | - | DrainDelay: time.Millisecond, | |
| 719 | + | VolumeAutoCreate: true, | |
| 720 | + | VolumePrefix: "gordon", | |
| 721 | + | ReadinessDelay: time.Millisecond, // Minimal delay for tests | |
| 722 | + | DrainDelay: time.Millisecond, | |
| 723 | + | DrainDelayConfigured: true, | |
| 601 | 724 | } | |
| 602 | 725 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 603 | 726 | ctx := testContext() | |
| @@ -607,6 +730,9 @@ func TestService_Deploy_WithVolumeAutoCreate(t *testing.T) { | |||
| 607 | 730 | Image: "myapp:latest", | |
| 608 | 731 | } | |
| 609 | 732 | ||
| 733 | + | // No container in memory — runtime resolution returns nothing | |
| 734 | + | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil) | |
| 735 | + | ||
| 610 | 736 | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | |
| 611 | 737 | runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil) | |
| 612 | 738 | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil) | |
| @@ -1312,6 +1438,7 @@ func TestService_Deploy_InternalDeployForcesPull(t *testing.T) { | |||
| 1312 | 1438 | InternalRegistryPassword: "secret", | |
| 1313 | 1439 | ReadinessDelay: time.Millisecond, | |
| 1314 | 1440 | DrainDelay: time.Millisecond, | |
| 1441 | + | DrainDelayConfigured: true, | |
| 1315 | 1442 | } | |
| 1316 | 1443 | ||
| 1317 | 1444 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| @@ -1324,6 +1451,9 @@ func TestService_Deploy_InternalDeployForcesPull(t *testing.T) { | |||
| 1324 | 1451 | Image: "myapp:latest", | |
| 1325 | 1452 | } | |
| 1326 | 1453 | ||
| 1454 | + | // No container in memory — runtime resolution returns nothing | |
| 1455 | + | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil) | |
| 1456 | + | ||
| 1327 | 1457 | // Setup mocks - no orphaned containers | |
| 1328 | 1458 | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | |
| 1329 | 1459 | ||
| @@ -1379,8 +1509,9 @@ func TestService_AutoStart_StartsNewContainers(t *testing.T) { | |||
| 1379 | 1509 | eventBus := mocks.NewMockEventPublisher(t) | |
| 1380 | 1510 | ||
| 1381 | 1511 | config := Config{ | |
| 1382 | - | ReadinessDelay: time.Millisecond, | |
| 1383 | - | DrainDelay: time.Millisecond, | |
| 1512 | + | ReadinessDelay: time.Millisecond, | |
| 1513 | + | DrainDelay: time.Millisecond, | |
| 1514 | + | DrainDelayConfigured: true, | |
| 1384 | 1515 | } | |
| 1385 | 1516 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 1386 | 1517 | ctx := testContext() | |
| @@ -1390,6 +1521,9 @@ func TestService_AutoStart_StartsNewContainers(t *testing.T) { | |||
| 1390 | 1521 | {Domain: "app2.example.com", Image: "myapp2:latest"}, | |
| 1391 | 1522 | } | |
| 1392 | 1523 | ||
| 1524 | + | // No container in memory — runtime resolution returns nothing (one per route) | |
| 1525 | + | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil).Times(2) | |
| 1526 | + | ||
| 1393 | 1527 | // Setup mocks for route deployments | |
| 1394 | 1528 | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Times(2) | |
| 1395 | 1529 | runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp1:latest", "myapp2:latest"}, nil).Times(2) | |
| @@ -1422,8 +1556,9 @@ func TestService_AutoStart_SkipsExistingContainers(t *testing.T) { | |||
| 1422 | 1556 | eventBus := mocks.NewMockEventPublisher(t) | |
| 1423 | 1557 | ||
| 1424 | 1558 | config := Config{ | |
| 1425 | - | ReadinessDelay: time.Millisecond, | |
| 1426 | - | DrainDelay: time.Millisecond, | |
| 1559 | + | ReadinessDelay: time.Millisecond, | |
| 1560 | + | DrainDelay: time.Millisecond, | |
| 1561 | + | DrainDelayConfigured: true, | |
| 1427 | 1562 | } | |
| 1428 | 1563 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 1429 | 1564 | ctx := testContext() | |
| @@ -1438,6 +1573,9 @@ func TestService_AutoStart_SkipsExistingContainers(t *testing.T) { | |||
| 1438 | 1573 | {Domain: "app2.example.com", Image: "myapp2:latest"}, // New route | |
| 1439 | 1574 | } | |
| 1440 | 1575 | ||
| 1576 | + | // No container in memory for app2 — runtime resolution returns nothing | |
| 1577 | + | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil).Once() | |
| 1578 | + | ||
| 1441 | 1579 | // Only deploy for app2 (app1 is skipped). Readiness is skipped — no IsContainerRunning. | |
| 1442 | 1580 | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Once() | |
| 1443 | 1581 | runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp2:latest"}, nil).Once() | |
| @@ -1472,6 +1610,9 @@ func TestService_AutoStart_HandlesDeployErrors(t *testing.T) { | |||
| 1472 | 1610 | {Domain: "app1.example.com", Image: "myapp1:latest"}, | |
| 1473 | 1611 | } | |
| 1474 | 1612 | ||
| 1613 | + | // No container in memory — runtime resolution returns nothing | |
| 1614 | + | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil) | |
| 1615 | + | ||
| 1475 | 1616 | // Setup mocks for failure | |
| 1476 | 1617 | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | |
| 1477 | 1618 | runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil) | |
| @@ -1490,10 +1631,11 @@ func TestService_AutoStart_UsesInternalDeployContext(t *testing.T) { | |||
| 1490 | 1631 | eventBus := mocks.NewMockEventPublisher(t) | |
| 1491 | 1632 | ||
| 1492 | 1633 | config := Config{ | |
| 1493 | - | RegistryDomain: "reg.example.com", | |
| 1494 | - | RegistryPort: 5000, | |
| 1495 | - | ReadinessDelay: time.Millisecond, | |
| 1496 | - | DrainDelay: time.Millisecond, | |
| 1634 | + | RegistryDomain: "reg.example.com", | |
| 1635 | + | RegistryPort: 5000, | |
| 1636 | + | ReadinessDelay: time.Millisecond, | |
| 1637 | + | DrainDelay: time.Millisecond, | |
| 1638 | + | DrainDelayConfigured: true, | |
| 1497 | 1639 | } | |
| 1498 | 1640 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 1499 | 1641 | ||
| @@ -1504,6 +1646,9 @@ func TestService_AutoStart_UsesInternalDeployContext(t *testing.T) { | |||
| 1504 | 1646 | {Domain: "app1.example.com", Image: "reg.example.com/myapp:latest"}, | |
| 1505 | 1647 | } | |
| 1506 | 1648 | ||
| 1649 | + | // No container in memory — runtime resolution returns nothing | |
| 1650 | + | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil) | |
| 1651 | + | ||
| 1507 | 1652 | // Key assertion: PullImage should be called with localhost:5000 rewrite, | |
| 1508 | 1653 | // NOT the original reg.example.com/myapp:latest. | |
| 1509 | 1654 | // Readiness is skipped — no IsContainerRunning calls. | |
| @@ -1538,8 +1683,10 @@ func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) { | |||
| 1538 | 1683 | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | |
| 1539 | 1684 | ||
| 1540 | 1685 | config := Config{ | |
| 1541 | - | ReadinessDelay: time.Millisecond, // Minimal delay for tests | |
| 1542 | - | DrainDelay: time.Millisecond, | |
| 1686 | + | ReadinessDelay: time.Millisecond, // Minimal delay for tests | |
| 1687 | + | DrainDelay: time.Millisecond, | |
| 1688 | + | DrainDelayConfigured: true, | |
| 1689 | + | StabilizationDelay: time.Millisecond, | |
| 1543 | 1690 | } | |
| 1544 | 1691 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 1545 | 1692 | svc.SetProxyCacheInvalidator(cacheInvalidator) | |
| @@ -1585,8 +1732,8 @@ func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) { | |||
| 1585 | 1732 | })).Return(newContainer, nil) | |
| 1586 | 1733 | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | |
| 1587 | 1734 | ||
| 1588 | - | // Wait for ready | |
| 1589 | - | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2) | |
| 1735 | + | // Wait for ready (2 calls) + stabilization check (1 call) | |
| 1736 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3) | |
| 1590 | 1737 | ||
| 1591 | 1738 | // Inspect after ready | |
| 1592 | 1739 | runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{ | |
| @@ -1619,16 +1766,18 @@ func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) { | |||
| 1619 | 1766 | assert.Equal(t, "new-container", tracked.ID) | |
| 1620 | 1767 | } | |
| 1621 | 1768 | ||
| 1622 | - | // TestService_Deploy_OrphanCleanupRemovesTrueOrphans verifies that containers | |
| 1623 | - | // with the same name but NOT tracked are properly removed as orphans. | |
| 1769 | + | // TestService_Deploy_OrphanCleanupRemovesTrueOrphans verifies that stopped canonical | |
| 1770 | + | // containers with the same name but NOT tracked are properly removed as orphans. | |
| 1771 | + | // Running canonical containers are preserved (see NeverKillsRunningCanonical test). | |
| 1624 | 1772 | func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) { | |
| 1625 | 1773 | runtime := mocks.NewMockContainerRuntime(t) | |
| 1626 | 1774 | envLoader := mocks.NewMockEnvLoader(t) | |
| 1627 | 1775 | eventBus := mocks.NewMockEventPublisher(t) | |
| 1628 | 1776 | ||
| 1629 | 1777 | config := Config{ | |
| 1630 | - | ReadinessDelay: time.Millisecond, // Minimal delay for tests | |
| 1631 | - | DrainDelay: time.Millisecond, | |
| 1778 | + | ReadinessDelay: time.Millisecond, // Minimal delay for tests | |
| 1779 | + | DrainDelay: time.Millisecond, | |
| 1780 | + | DrainDelayConfigured: true, | |
| 1632 | 1781 | } | |
| 1633 | 1782 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 1634 | 1783 | ctx := testContext() | |
| @@ -1640,17 +1789,21 @@ func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) { | |||
| 1640 | 1789 | Image: "myapp:v1", | |
| 1641 | 1790 | } | |
| 1642 | 1791 | ||
| 1643 | - | // ListContainers returns an orphaned container (same name, but not tracked) | |
| 1644 | - | // This could happen if Gordon crashed and restarted, or container was created manually | |
| 1792 | + | // No container in memory — runtime resolution finds no managed container | |
| 1793 | + | // (the orphan is stopped so it won't appear in running-only query) | |
| 1794 | + | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil) | |
| 1795 | + | ||
| 1796 | + | // ListContainers(true) returns a STOPPED orphaned container (same name, but not tracked) | |
| 1797 | + | // Stopped canonical containers are safe to remove during orphan cleanup | |
| 1645 | 1798 | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{ | |
| 1646 | 1799 | { | |
| 1647 | 1800 | ID: "orphan-container", | |
| 1648 | 1801 | Name: "gordon-test.example.com", | |
| 1649 | - | Status: "running", | |
| 1802 | + | Status: "exited", | |
| 1650 | 1803 | }, | |
| 1651 | 1804 | }, nil) | |
| 1652 | 1805 | ||
| 1653 | - | // Orphan should be stopped and removed BEFORE we proceed | |
| 1806 | + | // Stopped orphan should be stopped and removed BEFORE we proceed | |
| 1654 | 1807 | runtime.EXPECT().StopContainer(mock.Anything, "orphan-container").Return(nil) | |
| 1655 | 1808 | runtime.EXPECT().RemoveContainer(mock.Anything, "orphan-container", true).Return(nil) | |
| 1656 | 1809 | ||
| @@ -1700,10 +1853,7 @@ func TestService_Deploy_OrphanCleanupRemovesStaleNewContainer(t *testing.T) { | |||
| 1700 | 1853 | eventBus := mocks.NewMockEventPublisher(t) | |
| 1701 | 1854 | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | |
| 1702 | 1855 | ||
| 1703 | - | config := Config{ | |
| 1704 | - | ReadinessDelay: time.Millisecond, | |
| 1705 | - | DrainDelay: time.Millisecond, | |
| 1706 | - | } | |
| 1856 | + | config := testMinDelayConfig() | |
| 1707 | 1857 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 1708 | 1858 | svc.SetProxyCacheInvalidator(cacheInvalidator) | |
| 1709 | 1859 | ctx := testContext() | |
| @@ -1746,7 +1896,8 @@ func TestService_Deploy_OrphanCleanupRemovesStaleNewContainer(t *testing.T) { | |||
| 1746 | 1896 | return cfg.Name == "gordon-test.example.com-new" | |
| 1747 | 1897 | })).Return(newContainer, nil) | |
| 1748 | 1898 | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | |
| 1749 | - | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2) | |
| 1899 | + | // Wait for ready (2 calls) + stabilization check (1 call) | |
| 1900 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3) | |
| 1750 | 1901 | runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{ | |
| 1751 | 1902 | ID: "new-container", | |
| 1752 | 1903 | Status: "running", | |
| @@ -1772,10 +1923,7 @@ func TestService_Deploy_TrackedTempContainerUsesAlternateTempName(t *testing.T) | |||
| 1772 | 1923 | eventBus := mocks.NewMockEventPublisher(t) | |
| 1773 | 1924 | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | |
| 1774 | 1925 | ||
| 1775 | - | config := Config{ | |
| 1776 | - | ReadinessDelay: time.Millisecond, | |
| 1777 | - | DrainDelay: time.Millisecond, | |
| 1778 | - | } | |
| 1926 | + | config := testMinDelayConfig() | |
| 1779 | 1927 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 1780 | 1928 | svc.SetProxyCacheInvalidator(cacheInvalidator) | |
| 1781 | 1929 | ctx := testContext() | |
| @@ -1811,7 +1959,8 @@ func TestService_Deploy_TrackedTempContainerUsesAlternateTempName(t *testing.T) | |||
| 1811 | 1959 | return cfg.Name == "gordon-test.example.com-next" | |
| 1812 | 1960 | })).Return(created, nil) | |
| 1813 | 1961 | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | |
| 1814 | - | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2) | |
| 1962 | + | // Wait for ready (2 calls) + stabilization check (1 call) | |
| 1963 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3) | |
| 1815 | 1964 | runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{ | |
| 1816 | 1965 | ID: "new-container", | |
| 1817 | 1966 | Status: "running", | |
| @@ -1961,10 +2110,7 @@ func TestService_Deploy_ConcurrentSameDomain(t *testing.T) { | |||
| 1961 | 2110 | eventBus := mocks.NewMockEventPublisher(t) | |
| 1962 | 2111 | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | |
| 1963 | 2112 | ||
| 1964 | - | config := Config{ | |
| 1965 | - | ReadinessDelay: time.Millisecond, | |
| 1966 | - | DrainDelay: time.Millisecond, | |
| 1967 | - | } | |
| 2113 | + | config := testMinDelayConfig() | |
| 1968 | 2114 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 1969 | 2115 | svc.SetProxyCacheInvalidator(cacheInvalidator) | |
| 1970 | 2116 | ctx := testContext() | |
| @@ -1982,6 +2128,9 @@ func TestService_Deploy_ConcurrentSameDomain(t *testing.T) { | |||
| 1982 | 2128 | // First deploy: no existing container → creates gordon-test.example.com | |
| 1983 | 2129 | // Second deploy: sees first container → creates gordon-test.example.com-new | |
| 1984 | 2130 | ||
| 2131 | + | // First deploy resolves from runtime (no container in memory yet) | |
| 2132 | + | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil).Once() | |
| 2133 | + | ||
| 1985 | 2134 | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Times(2) | |
| 1986 | 2135 | runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil).Times(2) | |
| 1987 | 2136 | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil).Times(2) | |
| @@ -2005,8 +2154,8 @@ func TestService_Deploy_ConcurrentSameDomain(t *testing.T) { | |||
| 2005 | 2154 | }) | |
| 2006 | 2155 | ||
| 2007 | 2156 | runtime.EXPECT().StartContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Times(2) | |
| 2008 | - | // IsContainerRunning is called 2x per deploy in waitForReady (initial check + after delay) | |
| 2009 | - | runtime.EXPECT().IsContainerRunning(mock.Anything, mock.AnythingOfType("string")).Return(true, nil).Times(4) | |
| 2157 | + | // IsContainerRunning: 2x per deploy in waitForReady + 1x stabilization for the second deploy (which has existing) | |
| 2158 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, mock.AnythingOfType("string")).Return(true, nil).Times(5) | |
| 2010 | 2159 | ||
| 2011 | 2160 | runtime.EXPECT().InspectContainer(mock.Anything, mock.AnythingOfType("string")). | |
| 2012 | 2161 | RunAndReturn(func(_ context.Context, id string) (*domain.Container, error) { | |
| @@ -2069,8 +2218,9 @@ func TestService_Deploy_ContextCancellation(t *testing.T) { | |||
| 2069 | 2218 | eventBus := mocks.NewMockEventPublisher(t) | |
| 2070 | 2219 | ||
| 2071 | 2220 | config := Config{ | |
| 2072 | - | ReadinessDelay: time.Millisecond, | |
| 2073 | - | DrainDelay: time.Millisecond, | |
| 2221 | + | ReadinessDelay: time.Millisecond, | |
| 2222 | + | DrainDelay: time.Millisecond, | |
| 2223 | + | DrainDelayConfigured: true, | |
| 2074 | 2224 | } | |
| 2075 | 2225 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 2076 | 2226 | ||
| @@ -2097,10 +2247,7 @@ func TestService_Deploy_CacheInvalidationBeforeOldContainerStop(t *testing.T) { | |||
| 2097 | 2247 | eventBus := mocks.NewMockEventPublisher(t) | |
| 2098 | 2248 | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | |
| 2099 | 2249 | ||
| 2100 | - | config := Config{ | |
| 2101 | - | ReadinessDelay: time.Millisecond, | |
| 2102 | - | DrainDelay: time.Millisecond, | |
| 2103 | - | } | |
| 2250 | + | config := testMinDelayConfig() | |
| 2104 | 2251 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 2105 | 2252 | svc.SetProxyCacheInvalidator(cacheInvalidator) | |
| 2106 | 2253 | ctx := testContext() | |
| @@ -2132,7 +2279,8 @@ func TestService_Deploy_CacheInvalidationBeforeOldContainerStop(t *testing.T) { | |||
| 2132 | 2279 | newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"} | |
| 2133 | 2280 | runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil) | |
| 2134 | 2281 | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | |
| 2135 | - | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2) | |
| 2282 | + | // Wait for ready (2 calls) + stabilization check (1 call) | |
| 2283 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3) | |
| 2136 | 2284 | runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{ | |
| 2137 | 2285 | ID: "new-container", Status: "running", | |
| 2138 | 2286 | }, nil) | |
| @@ -2176,10 +2324,7 @@ func TestService_Deploy_NilCacheInvalidator(t *testing.T) { | |||
| 2176 | 2324 | envLoader := mocks.NewMockEnvLoader(t) | |
| 2177 | 2325 | eventBus := mocks.NewMockEventPublisher(t) | |
| 2178 | 2326 | ||
| 2179 | - | config := Config{ | |
| 2180 | - | ReadinessDelay: time.Millisecond, | |
| 2181 | - | DrainDelay: time.Millisecond, | |
| 2182 | - | } | |
| 2327 | + | config := testMinDelayConfig() | |
| 2183 | 2328 | // Intentionally NOT setting cache invalidator | |
| 2184 | 2329 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 2185 | 2330 | ctx := testContext() | |
| @@ -2205,7 +2350,8 @@ func TestService_Deploy_NilCacheInvalidator(t *testing.T) { | |||
| 2205 | 2350 | newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"} | |
| 2206 | 2351 | runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil) | |
| 2207 | 2352 | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | |
| 2208 | - | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2) | |
| 2353 | + | // Wait for ready (2 calls) + stabilization check (1 call) | |
| 2354 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3) | |
| 2209 | 2355 | runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{ | |
| 2210 | 2356 | ID: "new-container", Status: "running", | |
| 2211 | 2357 | }, nil) | |
| @@ -2226,8 +2372,9 @@ func TestService_Deploy_SkipsRedundantDeploy(t *testing.T) { | |||
| 2226 | 2372 | eventBus := mocks.NewMockEventPublisher(t) | |
| 2227 | 2373 | ||
| 2228 | 2374 | config := Config{ | |
| 2229 | - | ReadinessDelay: time.Millisecond, | |
| 2230 | - | DrainDelay: time.Millisecond, | |
| 2375 | + | ReadinessDelay: time.Millisecond, | |
| 2376 | + | DrainDelay: time.Millisecond, | |
| 2377 | + | DrainDelayConfigured: true, | |
| 2231 | 2378 | } | |
| 2232 | 2379 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 2233 | 2380 | ctx := testContext() | |
| @@ -2273,16 +2420,68 @@ func TestService_Deploy_SkipsRedundantDeploy(t *testing.T) { | |||
| 2273 | 2420 | assert.Equal(t, "existing-container", tracked.ID) | |
| 2274 | 2421 | } | |
| 2275 | 2422 | ||
| 2276 | - | func TestService_Deploy_DoesNotSkipWhenImageIDDiffers(t *testing.T) { | |
| 2423 | + | func TestService_Deploy_SkipsRedundantDeploy_WhenTrackedImageIDMissing(t *testing.T) { | |
| 2277 | 2424 | runtime := mocks.NewMockContainerRuntime(t) | |
| 2278 | 2425 | envLoader := mocks.NewMockEnvLoader(t) | |
| 2279 | 2426 | eventBus := mocks.NewMockEventPublisher(t) | |
| 2280 | - | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | |
| 2281 | 2427 | ||
| 2282 | 2428 | config := Config{ | |
| 2283 | - | ReadinessDelay: time.Millisecond, | |
| 2284 | - | DrainDelay: time.Millisecond, | |
| 2429 | + | ReadinessDelay: time.Millisecond, | |
| 2430 | + | DrainDelay: time.Millisecond, | |
| 2431 | + | DrainDelayConfigured: true, | |
| 2432 | + | } | |
| 2433 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 2434 | + | ctx := testContext() | |
| 2435 | + | ||
| 2436 | + | // Pre-populate with existing container missing ImageID (common after stale in-memory state). | |
| 2437 | + | existingContainer := &domain.Container{ | |
| 2438 | + | ID: "existing-container", | |
| 2439 | + | Name: "gordon-test.example.com", | |
| 2440 | + | Image: "myapp:latest", | |
| 2441 | + | Status: "running", | |
| 2285 | 2442 | } | |
| 2443 | + | svc.containers["test.example.com"] = existingContainer | |
| 2444 | + | ||
| 2445 | + | route := domain.Route{ | |
| 2446 | + | Domain: "test.example.com", | |
| 2447 | + | Image: "myapp:latest", | |
| 2448 | + | } | |
| 2449 | + | ||
| 2450 | + | // prepareDeployResources | |
| 2451 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | |
| 2452 | + | runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil) | |
| 2453 | + | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil) | |
| 2454 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil) | |
| 2455 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil) | |
| 2456 | + | ||
| 2457 | + | // Existing container image ID is recovered from runtime inspection, | |
| 2458 | + | // then redundancy check can skip the deploy. | |
| 2459 | + | runtime.EXPECT().InspectContainer(mock.Anything, "existing-container").Return(&domain.Container{ | |
| 2460 | + | ID: "existing-container", | |
| 2461 | + | ImageID: "sha256:abc123", | |
| 2462 | + | Status: "running", | |
| 2463 | + | }, nil) | |
| 2464 | + | runtime.EXPECT().GetImageID(mock.Anything, "myapp:latest").Return("sha256:abc123", nil) | |
| 2465 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "existing-container").Return(true, nil) | |
| 2466 | + | ||
| 2467 | + | result, err := svc.Deploy(ctx, route) | |
| 2468 | + | ||
| 2469 | + | assert.NoError(t, err) | |
| 2470 | + | assert.NotNil(t, result) | |
| 2471 | + | assert.Equal(t, "existing-container", result.ID) | |
| 2472 | + | ||
| 2473 | + | tracked, exists := svc.Get(ctx, "test.example.com") | |
| 2474 | + | assert.True(t, exists) | |
| 2475 | + | assert.Equal(t, "existing-container", tracked.ID) | |
| 2476 | + | } | |
| 2477 | + | ||
| 2478 | + | func TestService_Deploy_DoesNotSkipWhenImageIDDiffers(t *testing.T) { | |
| 2479 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 2480 | + | envLoader := mocks.NewMockEnvLoader(t) | |
| 2481 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 2482 | + | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | |
| 2483 | + | ||
| 2484 | + | config := testMinDelayConfig() | |
| 2286 | 2485 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 2287 | 2486 | svc.SetProxyCacheInvalidator(cacheInvalidator) | |
| 2288 | 2487 | ctx := testContext() | |
| @@ -2316,7 +2515,8 @@ func TestService_Deploy_DoesNotSkipWhenImageIDDiffers(t *testing.T) { | |||
| 2316 | 2515 | newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"} | |
| 2317 | 2516 | runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil) | |
| 2318 | 2517 | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | |
| 2319 | - | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2) | |
| 2518 | + | // Wait for ready (2 calls) + stabilization check (1 call) | |
| 2519 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3) | |
| 2320 | 2520 | runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{ | |
| 2321 | 2521 | ID: "new-container", Status: "running", | |
| 2322 | 2522 | }, nil) | |
| @@ -2380,10 +2580,7 @@ func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) { | |||
| 2380 | 2580 | envLoader := mocks.NewMockEnvLoader(t) | |
| 2381 | 2581 | eventBus := mocks.NewMockEventPublisher(t) | |
| 2382 | 2582 | ||
| 2383 | - | config := Config{ | |
| 2384 | - | ReadinessDelay: time.Millisecond, | |
| 2385 | - | DrainDelay: time.Millisecond, | |
| 2386 | - | } | |
| 2583 | + | config := testMinDelayConfig() | |
| 2387 | 2584 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 2388 | 2585 | ctx := testContext() | |
| 2389 | 2586 | ||
| @@ -2417,7 +2614,8 @@ func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) { | |||
| 2417 | 2614 | newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"} | |
| 2418 | 2615 | runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil) | |
| 2419 | 2616 | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | |
| 2420 | - | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2) | |
| 2617 | + | // Wait for ready (2 calls) + stabilization check (1 call) | |
| 2618 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3) | |
| 2421 | 2619 | runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{ | |
| 2422 | 2620 | ID: "new-container", Status: "running", | |
| 2423 | 2621 | }, nil) | |
| @@ -2433,3 +2631,527 @@ func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) { | |||
| 2433 | 2631 | assert.NoError(t, err) | |
| 2434 | 2632 | assert.Equal(t, "new-container", result.ID) | |
| 2435 | 2633 | } | |
| 2634 | + | ||
| 2635 | + | // TestService_Deploy_OrphanCleanup_NeverKillsRunningCanonical verifies that orphan | |
| 2636 | + | // cleanup never stops a running container with the canonical name. Only stopped | |
| 2637 | + | // canonical containers and temp containers (-new/-next) should be cleaned up. | |
| 2638 | + | // The active canonical container is resolved via resolveExistingContainer (it has the | |
| 2639 | + | // managed label), so the new container is created with the "-new" suffix — Docker | |
| 2640 | + | // disallows creating a container with the same name as an existing running container. | |
| 2641 | + | func TestService_Deploy_OrphanCleanup_NeverKillsRunningCanonical(t *testing.T) { | |
| 2642 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 2643 | + | envLoader := mocks.NewMockEnvLoader(t) | |
| 2644 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 2645 | + | ||
| 2646 | + | config := testMinDelayConfig() | |
| 2647 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 2648 | + | ctx := testContext() | |
| 2649 | + | ||
| 2650 | + | // The canonical container is running and has the managed label — it will be | |
| 2651 | + | // discovered by resolveExistingContainer's slow path (no in-memory state). | |
| 2652 | + | existingContainer := &domain.Container{ | |
| 2653 | + | ID: "active-canonical", | |
| 2654 | + | Name: "gordon-test.example.com", | |
| 2655 | + | Status: "running", | |
| 2656 | + | Labels: map[string]string{domain.LabelManaged: "true"}, | |
| 2657 | + | } | |
| 2658 | + | ||
| 2659 | + | route := domain.Route{ | |
| 2660 | + | Domain: "test.example.com", | |
| 2661 | + | Image: "myapp:v2", | |
| 2662 | + | } | |
| 2663 | + | ||
| 2664 | + | // resolveExistingContainer: slow path finds the running canonical container via label. | |
| 2665 | + | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{existingContainer}, nil) | |
| 2666 | + | ||
| 2667 | + | // cleanupOrphanedContainers: finds the running canonical AND a stale -new leftover. | |
| 2668 | + | // The canonical container must NOT be killed because it's running. | |
| 2669 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{ | |
| 2670 | + | existingContainer, | |
| 2671 | + | { | |
| 2672 | + | ID: "stale-leftover", | |
| 2673 | + | Name: "gordon-test.example.com-new", | |
| 2674 | + | Status: "exited", | |
| 2675 | + | }, | |
| 2676 | + | }, nil) | |
| 2677 | + | ||
| 2678 | + | // Only the stale -new container should be stopped and removed during orphan cleanup. | |
| 2679 | + | runtime.EXPECT().StopContainer(mock.Anything, "stale-leftover").Return(nil).Once() | |
| 2680 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "stale-leftover", true).Return(nil).Once() | |
| 2681 | + | ||
| 2682 | + | // Image operations: pull new image (existing.Image is empty so redundancy check is skipped) | |
| 2683 | + | runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil) | |
| 2684 | + | runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil) | |
| 2685 | + | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil) | |
| 2686 | + | ||
| 2687 | + | // Environment | |
| 2688 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil) | |
| 2689 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil) | |
| 2690 | + | ||
| 2691 | + | // Create container — existing is the canonical container, so the new one gets "-new" suffix. | |
| 2692 | + | // Docker enforces unique container names; using "-new" avoids a name collision. | |
| 2693 | + | newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"} | |
| 2694 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool { | |
| 2695 | + | return cfg.Name == "gordon-test.example.com-new" | |
| 2696 | + | })).Return(newContainer, nil) | |
| 2697 | + | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | |
| 2698 | + | ||
| 2699 | + | // Wait for ready (delay mode): pollContainerRunning (1×) + waitForReadyByDelay (1×) | |
| 2700 | + | // Post-switch stabilization: 1× — total 3 calls. | |
| 2701 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3) | |
| 2702 | + | ||
| 2703 | + | // Inspect after ready | |
| 2704 | + | runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{ | |
| 2705 | + | ID: "new-container", | |
| 2706 | + | Status: "running", | |
| 2707 | + | Ports: []int{8080}, | |
| 2708 | + | }, nil) | |
| 2709 | + | ||
| 2710 | + | // Rename new container to canonical name after stabilization. | |
| 2711 | + | runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil).Once() | |
| 2712 | + | ||
| 2713 | + | // Stop and remove old canonical container. | |
| 2714 | + | runtime.EXPECT().StopContainer(mock.Anything, "active-canonical").Return(nil).Once() | |
| 2715 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "active-canonical", true).Return(nil).Once() | |
| 2716 | + | ||
| 2717 | + | // Publish event | |
| 2718 | + | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil) | |
| 2719 | + | ||
| 2720 | + | result, err := svc.Deploy(ctx, route) | |
| 2721 | + | ||
| 2722 | + | assert.NoError(t, err) | |
| 2723 | + | assert.Equal(t, "new-container", result.ID) | |
| 2724 | + | ||
| 2725 | + | // Verify new container is tracked | |
| 2726 | + | tracked, exists := svc.Get(ctx, "test.example.com") | |
| 2727 | + | assert.True(t, exists) | |
| 2728 | + | assert.Equal(t, "new-container", tracked.ID) | |
| 2729 | + | } | |
| 2730 | + | ||
| 2731 | + | // TestService_Deploy_RollbackOnPostSwitchCrash verifies that if the new container crashes | |
| 2732 | + | // during the post-switch stabilization window, traffic is automatically rolled back to the | |
| 2733 | + | // old container: the old container is restored as tracked, the proxy cache is re-invalidated, | |
| 2734 | + | // and the failed new container is cleaned up. | |
| 2735 | + | func TestService_Deploy_RollbackOnPostSwitchCrash(t *testing.T) { | |
| 2736 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 2737 | + | envLoader := mocks.NewMockEnvLoader(t) | |
| 2738 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 2739 | + | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | |
| 2740 | + | ||
| 2741 | + | config := Config{ | |
| 2742 | + | ReadinessDelay: time.Millisecond, | |
| 2743 | + | DrainDelay: time.Millisecond, | |
| 2744 | + | DrainDelayConfigured: true, | |
| 2745 | + | StabilizationDelay: time.Millisecond, | |
| 2746 | + | } | |
| 2747 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 2748 | + | svc.SetProxyCacheInvalidator(cacheInvalidator) | |
| 2749 | + | ctx := testContext() | |
| 2750 | + | ||
| 2751 | + | // Pre-populate with existing container | |
| 2752 | + | existing := &domain.Container{ | |
| 2753 | + | ID: "old-container", | |
| 2754 | + | Name: "gordon-test.example.com", | |
| 2755 | + | Status: "running", | |
| 2756 | + | } | |
| 2757 | + | svc.containers["test.example.com"] = existing | |
| 2758 | + | ||
| 2759 | + | route := domain.Route{ | |
| 2760 | + | Domain: "test.example.com", | |
| 2761 | + | Image: "myapp:v2", | |
| 2762 | + | } | |
| 2763 | + | ||
| 2764 | + | // Cleanup orphans | |
| 2765 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | |
| 2766 | + | ||
| 2767 | + | // Image operations | |
| 2768 | + | runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil) | |
| 2769 | + | runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil) | |
| 2770 | + | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil) | |
| 2771 | + | ||
| 2772 | + | // Environment | |
| 2773 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil) | |
| 2774 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil) | |
| 2775 | + | ||
| 2776 | + | // Create new container | |
| 2777 | + | newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"} | |
| 2778 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool { | |
| 2779 | + | return cfg.Name == "gordon-test.example.com-new" | |
| 2780 | + | })).Return(newContainer, nil) | |
| 2781 | + | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | |
| 2782 | + | ||
| 2783 | + | // Readiness: new container is running during readiness checks (2 calls) | |
| 2784 | + | // Stabilization: new container has crashed (returns false) | |
| 2785 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2) | |
| 2786 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(false, nil).Once() | |
| 2787 | + | ||
| 2788 | + | // Rollback: verify old container is still running before restoring | |
| 2789 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "old-container").Return(true, nil).Once() | |
| 2790 | + | ||
| 2791 | + | // Inspect after ready | |
| 2792 | + | runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{ | |
| 2793 | + | ID: "new-container", | |
| 2794 | + | Name: "gordon-test.example.com-new", | |
| 2795 | + | Status: "running", | |
| 2796 | + | }, nil) | |
| 2797 | + | ||
| 2798 | + | // Publish event (happens during activateDeployedContainer) | |
| 2799 | + | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil) | |
| 2800 | + | ||
| 2801 | + | // InvalidateTarget called TWICE: once during activate, once during rollback | |
| 2802 | + | cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return().Times(2) | |
| 2803 | + | ||
| 2804 | + | // Cleanup failed new container during rollback | |
| 2805 | + | runtime.EXPECT().StopContainer(mock.Anything, "new-container").Return(nil) | |
| 2806 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "new-container", true).Return(nil) | |
| 2807 | + | ||
| 2808 | + | // StopContainer on old-container should NOT be called (old stays running) | |
| 2809 | + | // (no expectation for StopContainer("old-container")) | |
| 2810 | + | ||
| 2811 | + | result, err := svc.Deploy(ctx, route) | |
| 2812 | + | ||
| 2813 | + | // Deploy returns the old (existing) container after rollback | |
| 2814 | + | assert.NoError(t, err) | |
| 2815 | + | assert.NotNil(t, result) | |
| 2816 | + | assert.Equal(t, "old-container", result.ID, "should return old container after rollback") | |
| 2817 | + | ||
| 2818 | + | // Verify old container is restored as tracked | |
| 2819 | + | trackedRB, existsRB := svc.Get(ctx, "test.example.com") | |
| 2820 | + | assert.True(t, existsRB) | |
| 2821 | + | assert.Equal(t, "old-container", trackedRB.ID, "old container should be restored after rollback") | |
| 2822 | + | } | |
| 2823 | + | ||
| 2824 | + | // TestService_Deploy_StabilizationSuccess verifies that when the new container remains | |
| 2825 | + | // healthy during the stabilization window, deployment proceeds normally and the old | |
| 2826 | + | // container is finalized (stopped and removed). | |
| 2827 | + | func TestService_Deploy_StabilizationSuccess(t *testing.T) { | |
| 2828 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 2829 | + | envLoader := mocks.NewMockEnvLoader(t) | |
| 2830 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 2831 | + | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | |
| 2832 | + | ||
| 2833 | + | config := Config{ | |
| 2834 | + | ReadinessDelay: time.Millisecond, | |
| 2835 | + | DrainDelay: time.Millisecond, | |
| 2836 | + | DrainDelayConfigured: true, | |
| 2837 | + | StabilizationDelay: time.Millisecond, | |
| 2838 | + | } | |
| 2839 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 2840 | + | svc.SetProxyCacheInvalidator(cacheInvalidator) | |
| 2841 | + | ctx := testContext() | |
| 2842 | + | ||
| 2843 | + | // Pre-populate with existing container | |
| 2844 | + | existing := &domain.Container{ | |
| 2845 | + | ID: "old-container", | |
| 2846 | + | Name: "gordon-test.example.com", | |
| 2847 | + | Status: "running", | |
| 2848 | + | } | |
| 2849 | + | svc.containers["test.example.com"] = existing | |
| 2850 | + | ||
| 2851 | + | route := domain.Route{ | |
| 2852 | + | Domain: "test.example.com", | |
| 2853 | + | Image: "myapp:v2", | |
| 2854 | + | } | |
| 2855 | + | ||
| 2856 | + | // Cleanup orphans | |
| 2857 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | |
| 2858 | + | ||
| 2859 | + | // Image operations | |
| 2860 | + | runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil) | |
| 2861 | + | runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil) | |
| 2862 | + | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil) | |
| 2863 | + | ||
| 2864 | + | // Environment | |
| 2865 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil) | |
| 2866 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil) | |
| 2867 | + | ||
| 2868 | + | // Create new container | |
| 2869 | + | newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"} | |
| 2870 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool { | |
| 2871 | + | return cfg.Name == "gordon-test.example.com-new" | |
| 2872 | + | })).Return(newContainer, nil) | |
| 2873 | + | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | |
| 2874 | + | ||
| 2875 | + | // Readiness (2 calls) + stabilization check (1 call) — all return running | |
| 2876 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3) | |
| 2877 | + | ||
| 2878 | + | // Inspect after ready | |
| 2879 | + | runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{ | |
| 2880 | + | ID: "new-container", | |
| 2881 | + | Name: "gordon-test.example.com-new", | |
| 2882 | + | Status: "running", | |
| 2883 | + | }, nil) | |
| 2884 | + | ||
| 2885 | + | // Publish event | |
| 2886 | + | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil) | |
| 2887 | + | ||
| 2888 | + | // Cache invalidation (once, during activate) | |
| 2889 | + | cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return() | |
| 2890 | + | ||
| 2891 | + | // Old container finalized normally | |
| 2892 | + | runtime.EXPECT().StopContainer(mock.Anything, "old-container").Return(nil) | |
| 2893 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil) | |
| 2894 | + | runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil) | |
| 2895 | + | ||
| 2896 | + | result, err := svc.Deploy(ctx, route) | |
| 2897 | + | ||
| 2898 | + | assert.NoError(t, err) | |
| 2899 | + | assert.NotNil(t, result) | |
| 2900 | + | assert.Equal(t, "new-container", result.ID, "should return new container on successful stabilization") | |
| 2901 | + | ||
| 2902 | + | // Verify new container is tracked | |
| 2903 | + | trackedSS, existsSS := svc.Get(ctx, "test.example.com") | |
| 2904 | + | assert.True(t, existsSS) | |
| 2905 | + | assert.Equal(t, "new-container", trackedSS.ID) | |
| 2906 | + | } | |
| 2907 | + | ||
| 2908 | + | // TestService_Deploy_ZeroDowntime_OldNeverStoppedBeforeNewReady verifies the strict | |
| 2909 | + | // ordering invariant: StopContainer("old-container") must NEVER happen before | |
| 2910 | + | // InspectContainer("new-container") completes (the last step of readiness). | |
| 2911 | + | // Uses testify's NotBefore() to enforce mock call ordering. | |
| 2912 | + | func TestService_Deploy_ZeroDowntime_OldNeverStoppedBeforeNewReady(t *testing.T) { | |
| 2913 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 2914 | + | envLoader := mocks.NewMockEnvLoader(t) | |
| 2915 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 2916 | + | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | |
| 2917 | + | ||
| 2918 | + | config := Config{ | |
| 2919 | + | ReadinessDelay: time.Millisecond, | |
| 2920 | + | DrainDelay: time.Millisecond, | |
| 2921 | + | DrainDelayConfigured: true, | |
| 2922 | + | StabilizationDelay: time.Millisecond, | |
| 2923 | + | } | |
| 2924 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 2925 | + | svc.SetProxyCacheInvalidator(cacheInvalidator) | |
| 2926 | + | ctx := testContext() | |
| 2927 | + | ||
| 2928 | + | // Pre-populate with existing tracked container | |
| 2929 | + | existingContainer := &domain.Container{ | |
| 2930 | + | ID: "old-container", | |
| 2931 | + | Name: "gordon-test.example.com", | |
| 2932 | + | Status: "running", | |
| 2933 | + | } | |
| 2934 | + | svc.containers["test.example.com"] = existingContainer | |
| 2935 | + | ||
| 2936 | + | route := domain.Route{ | |
| 2937 | + | Domain: "test.example.com", | |
| 2938 | + | Image: "myapp:v2", | |
| 2939 | + | } | |
| 2940 | + | ||
| 2941 | + | // Cleanup orphans | |
| 2942 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | |
| 2943 | + | ||
| 2944 | + | // Image operations | |
| 2945 | + | runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil) | |
| 2946 | + | runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil) | |
| 2947 | + | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil) | |
| 2948 | + | ||
| 2949 | + | // Environment | |
| 2950 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil) | |
| 2951 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil) | |
| 2952 | + | ||
| 2953 | + | // Create new container with -new suffix for zero-downtime | |
| 2954 | + | newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"} | |
| 2955 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool { | |
| 2956 | + | return cfg.Name == "gordon-test.example.com-new" | |
| 2957 | + | })).Return(newContainer, nil) | |
| 2958 | + | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | |
| 2959 | + | ||
| 2960 | + | // Readiness: pollContainerRunning (1 call) + waitForReadyByDelay verification (1 call) | |
| 2961 | + | // Stabilization check (1 call) | |
| 2962 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3) | |
| 2963 | + | ||
| 2964 | + | // KEY ORDERING CONSTRAINT: InspectContainer("new-container") is the last step | |
| 2965 | + | // of readiness inside createStartedContainer. StopContainer("old-container") | |
| 2966 | + | // must happen AFTER this call completes. | |
| 2967 | + | inspectCall := runtime.EXPECT().InspectContainer(mock.Anything, "new-container"). | |
| 2968 | + | Return(&domain.Container{ | |
| 2969 | + | ID: "new-container", | |
| 2970 | + | Name: "gordon-test.example.com-new", | |
| 2971 | + | Status: "running", | |
| 2972 | + | }, nil) | |
| 2973 | + | ||
| 2974 | + | // Publish event | |
| 2975 | + | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil) | |
| 2976 | + | ||
| 2977 | + | // Synchronous cache invalidation | |
| 2978 | + | cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return() | |
| 2979 | + | ||
| 2980 | + | // STRICT ORDERING: StopContainer on old container must NOT happen before | |
| 2981 | + | // InspectContainer on new container (readiness completion) | |
| 2982 | + | runtime.EXPECT().StopContainer(mock.Anything, "old-container"). | |
| 2983 | + | Return(nil). | |
| 2984 | + | NotBefore(inspectCall.Call) | |
| 2985 | + | ||
| 2986 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil) | |
| 2987 | + | runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil) | |
| 2988 | + | ||
| 2989 | + | result, err := svc.Deploy(ctx, route) | |
| 2990 | + | ||
| 2991 | + | assert.NoError(t, err) | |
| 2992 | + | assert.NotNil(t, result) | |
| 2993 | + | assert.Equal(t, "new-container", result.ID) | |
| 2994 | + | ||
| 2995 | + | // Verify new container is tracked | |
| 2996 | + | tracked, exists := svc.Get(ctx, "test.example.com") | |
| 2997 | + | assert.True(t, exists) | |
| 2998 | + | assert.Equal(t, "new-container", tracked.ID) | |
| 2999 | + | } | |
| 3000 | + | ||
| 3001 | + | // TestService_Deploy_ZeroDowntime_ReadinessFailure_OldUntouched verifies that when a | |
| 3002 | + | // new container fails readiness (IsContainerRunning returns false), the old container | |
| 3003 | + | // is completely untouched: never stopped, never removed. Deploy returns an error and | |
| 3004 | + | // the old container remains as the tracked container. | |
| 3005 | + | func TestService_Deploy_ZeroDowntime_ReadinessFailure_OldUntouched(t *testing.T) { | |
| 3006 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 3007 | + | envLoader := mocks.NewMockEnvLoader(t) | |
| 3008 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 3009 | + | ||
| 3010 | + | config := Config{ | |
| 3011 | + | ReadinessDelay: time.Millisecond, | |
| 3012 | + | DrainDelay: time.Millisecond, | |
| 3013 | + | DrainDelayConfigured: true, | |
| 3014 | + | StabilizationDelay: time.Millisecond, | |
| 3015 | + | } | |
| 3016 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 3017 | + | ctx := testContext() | |
| 3018 | + | ||
| 3019 | + | // Pre-populate with existing tracked container | |
| 3020 | + | existingContainer := &domain.Container{ | |
| 3021 | + | ID: "old-container", | |
| 3022 | + | Name: "gordon-test.example.com", | |
| 3023 | + | Status: "running", | |
| 3024 | + | } | |
| 3025 | + | svc.containers["test.example.com"] = existingContainer | |
| 3026 | + | ||
| 3027 | + | route := domain.Route{ | |
| 3028 | + | Domain: "test.example.com", | |
| 3029 | + | Image: "myapp:v2", | |
| 3030 | + | } | |
| 3031 | + | ||
| 3032 | + | // Cleanup orphans | |
| 3033 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | |
| 3034 | + | ||
| 3035 | + | // Image operations | |
| 3036 | + | runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil) | |
| 3037 | + | runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil) | |
| 3038 | + | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil) | |
| 3039 | + | ||
| 3040 | + | // Environment | |
| 3041 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil) | |
| 3042 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil) | |
| 3043 | + | ||
| 3044 | + | // Create new container | |
| 3045 | + | newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"} | |
| 3046 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool { | |
| 3047 | + | return cfg.Name == "gordon-test.example.com-new" | |
| 3048 | + | })).Return(newContainer, nil) | |
| 3049 | + | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | |
| 3050 | + | ||
| 3051 | + | // Readiness FAILS: pollContainerRunning succeeds (container starts) but | |
| 3052 | + | // waitForReadyByDelay verification finds it not running. | |
| 3053 | + | // pollContainerRunning: returns true (container initially starts) | |
| 3054 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Once() | |
| 3055 | + | // waitForReadyByDelay post-delay verification: returns false (container crashed) | |
| 3056 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(false, nil).Once() | |
| 3057 | + | // Recovery window poll: return error to fail fast instead of waiting 30s | |
| 3058 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container"). | |
| 3059 | + | Return(false, errors.New("container exited")).Once() | |
| 3060 | + | ||
| 3061 | + | // cleanupFailedContainer: stop and remove the failed new container | |
| 3062 | + | runtime.EXPECT().StopContainer(mock.Anything, "new-container").Return(nil) | |
| 3063 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "new-container", true).Return(nil) | |
| 3064 | + | ||
| 3065 | + | // NOTE: StopContainer("old-container") and RemoveContainer("old-container", true) | |
| 3066 | + | // are intentionally NOT mocked. If either is called, testify will panic with | |
| 3067 | + | // "unexpected method call" — verifying the old container is never touched. | |
| 3068 | + | ||
| 3069 | + | result, err := svc.Deploy(ctx, route) | |
| 3070 | + | ||
| 3071 | + | // Deploy should return an error (readiness failure) | |
| 3072 | + | assert.Error(t, err) | |
| 3073 | + | assert.Nil(t, result) | |
| 3074 | + | assert.Contains(t, err.Error(), "readiness check") | |
| 3075 | + | ||
| 3076 | + | // Old container must still be tracked — completely untouched | |
| 3077 | + | tracked, exists := svc.Get(ctx, "test.example.com") | |
| 3078 | + | assert.True(t, exists, "old container should still be tracked after readiness failure") | |
| 3079 | + | assert.Equal(t, "old-container", tracked.ID, "old container ID should be unchanged") | |
| 3080 | + | } | |
| 3081 | + | ||
| 3082 | + | // TestService_Deploy_ZeroDowntime_NoExisting_FirstDeploy is a sanity check: | |
| 3083 | + | // first deploy with no existing container should work normally without any | |
| 3084 | + | // finalize/drain logic being triggered. | |
| 3085 | + | func TestService_Deploy_ZeroDowntime_NoExisting_FirstDeploy(t *testing.T) { | |
| 3086 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 3087 | + | envLoader := mocks.NewMockEnvLoader(t) | |
| 3088 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 3089 | + | ||
| 3090 | + | config := Config{ | |
| 3091 | + | ReadinessDelay: time.Millisecond, | |
| 3092 | + | DrainDelay: time.Millisecond, | |
| 3093 | + | DrainDelayConfigured: true, | |
| 3094 | + | StabilizationDelay: time.Millisecond, | |
| 3095 | + | } | |
| 3096 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 3097 | + | ctx := testContext() | |
| 3098 | + | ||
| 3099 | + | // NO existing container — this is a first deploy | |
| 3100 | + | ||
| 3101 | + | route := domain.Route{ | |
| 3102 | + | Domain: "test.example.com", | |
| 3103 | + | Image: "myapp:v2", | |
| 3104 | + | } | |
| 3105 | + | ||
| 3106 | + | // resolveExistingContainer: no container in memory, runtime returns nothing | |
| 3107 | + | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil) | |
| 3108 | + | ||
| 3109 | + | // Cleanup orphans — nothing found | |
| 3110 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | |
| 3111 | + | ||
| 3112 | + | // Image operations | |
| 3113 | + | runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil) | |
| 3114 | + | runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil) | |
| 3115 | + | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil) | |
| 3116 | + | ||
| 3117 | + | // Environment | |
| 3118 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil) | |
| 3119 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil) | |
| 3120 | + | ||
| 3121 | + | // Create container — canonical name (no -new suffix since no existing container) | |
| 3122 | + | newContainer := &domain.Container{ID: "first-container", Name: "gordon-test.example.com", Status: "created"} | |
| 3123 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool { | |
| 3124 | + | return cfg.Name == "gordon-test.example.com" | |
| 3125 | + | })).Return(newContainer, nil) | |
| 3126 | + | runtime.EXPECT().StartContainer(mock.Anything, "first-container").Return(nil) | |
| 3127 | + | ||
| 3128 | + | // Readiness: pollContainerRunning (1 call) + waitForReadyByDelay verification (1 call) | |
| 3129 | + | // No stabilization check for first deploy (hasExisting is false) | |
| 3130 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "first-container").Return(true, nil).Times(2) | |
| 3131 | + | ||
| 3132 | + | // Inspect after readiness | |
| 3133 | + | runtime.EXPECT().InspectContainer(mock.Anything, "first-container").Return(&domain.Container{ | |
| 3134 | + | ID: "first-container", | |
| 3135 | + | Name: "gordon-test.example.com", | |
| 3136 | + | Status: "running", | |
| 3137 | + | Ports: []int{8080}, | |
| 3138 | + | }, nil) | |
| 3139 | + | ||
| 3140 | + | // Publish event | |
| 3141 | + | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil) | |
| 3142 | + | ||
| 3143 | + | // NOTE: No StopContainer, RemoveContainer, or RenameContainer expectations — | |
| 3144 | + | // first deploy has no previous container to finalize, and no stabilization check. | |
| 3145 | + | ||
| 3146 | + | result, err := svc.Deploy(ctx, route) | |
| 3147 | + | ||
| 3148 | + | assert.NoError(t, err) | |
| 3149 | + | assert.NotNil(t, result) | |
| 3150 | + | assert.Equal(t, "first-container", result.ID) | |
| 3151 | + | assert.Equal(t, "running", result.Status) | |
| 3152 | + | ||
| 3153 | + | // Verify container is tracked | |
| 3154 | + | tracked, exists := svc.Get(ctx, "test.example.com") | |
| 3155 | + | assert.True(t, exists) | |
| 3156 | + | assert.Equal(t, "first-container", tracked.ID) | |
| 3157 | + | } | |
| @@ -25,6 +25,17 @@ func testContext() context.Context { | |||||
| 25 | return zerowrap.WithCtx(context.Background(), zerowrap.Default()) | 25 | return zerowrap.WithCtx(context.Background(), zerowrap.Default()) | ||
| 26 | } | 26 | } | ||
| 27 | 27 | ||||
| 28 | + | // testMinDelayConfig returns a Config with all timing delays set to 1ms, | |||
| 29 | + | // suitable for unit tests that don't want to wait for real timeouts. | |||
| 30 | + | func testMinDelayConfig() Config { | |||
| 31 | + | return Config{ | |||
| 32 | + | ReadinessDelay: time.Millisecond, | |||
| 33 | + | DrainDelay: time.Millisecond, | |||
| 34 | + | DrainDelayConfigured: true, | |||
| 35 | + | StabilizationDelay: time.Millisecond, | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | ||||
| 28 | func setupMetricsTest(t *testing.T) (*telemetry.Metrics, *sdkmetric.ManualReader) { | 39 | func setupMetricsTest(t *testing.T) (*telemetry.Metrics, *sdkmetric.ManualReader) { | ||
| 29 | t.Helper() | 40 | t.Helper() | ||
| 30 | 41 | ||||
| @@ -184,10 +195,11 @@ func TestService_Deploy_Success(t *testing.T) { | |||||
| 184 | eventBus := mocks.NewMockEventPublisher(t) | 195 | eventBus := mocks.NewMockEventPublisher(t) | ||
| 185 | 196 | ||||
| 186 | config := Config{ | 197 | config := Config{ | ||
| 187 | - | NetworkIsolation: false, | 198 | + | NetworkIsolation: false, |
| 188 | - | VolumeAutoCreate: false, | 199 | + | VolumeAutoCreate: false, |
| 189 | - | ReadinessDelay: time.Millisecond, // Minimal delay for tests | 200 | + | ReadinessDelay: time.Millisecond, // Minimal delay for tests |
| 190 | - | DrainDelay: time.Millisecond, | 201 | + | DrainDelay: time.Millisecond, |
| 202 | + | DrainDelayConfigured: true, | |||
| 191 | } | 203 | } | ||
| 192 | 204 | ||||
| 193 | svc := NewService(runtime, envLoader, eventBus, nil, config) | 205 | svc := NewService(runtime, envLoader, eventBus, nil, config) | ||
| @@ -198,6 +210,9 @@ func TestService_Deploy_Success(t *testing.T) { | |||||
| 198 | Image: "myapp:latest", | 210 | Image: "myapp:latest", | ||
| 199 | } | 211 | } | ||
| 200 | 212 | ||||
| 213 | + | // No container in memory — runtime resolution returns nothing | |||
| 214 | + | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil) | |||
| 215 | + | ||||
| 201 | // Setup mocks - no orphaned containers | 216 | // Setup mocks - no orphaned containers | ||
| 202 | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | 217 | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | ||
| 203 | 218 | ||||
| @@ -261,10 +276,11 @@ func TestService_Deploy_ReadinessRecoveryWindow_AllowsTransientFlap(t *testing.T | |||||
| 261 | eventBus := mocks.NewMockEventPublisher(t) | 276 | eventBus := mocks.NewMockEventPublisher(t) | ||
| 262 | 277 | ||||
| 263 | config := Config{ | 278 | config := Config{ | ||
| 264 | - | NetworkIsolation: false, | 279 | + | NetworkIsolation: false, |
| 265 | - | VolumeAutoCreate: false, | 280 | + | VolumeAutoCreate: false, |
| 266 | - | ReadinessDelay: time.Millisecond, | 281 | + | ReadinessDelay: time.Millisecond, |
| 267 | - | DrainDelay: time.Millisecond, | 282 | + | DrainDelay: time.Millisecond, |
| 283 | + | DrainDelayConfigured: true, | |||
| 268 | } | 284 | } | ||
| 269 | 285 | ||||
| 270 | svc := NewService(runtime, envLoader, eventBus, nil, config) | 286 | svc := NewService(runtime, envLoader, eventBus, nil, config) | ||
| @@ -275,6 +291,9 @@ func TestService_Deploy_ReadinessRecoveryWindow_AllowsTransientFlap(t *testing.T | |||||
| 275 | Image: "myapp:latest", | 291 | Image: "myapp:latest", | ||
| 276 | } | 292 | } | ||
| 277 | 293 | ||||
| 294 | + | // No container in memory — runtime resolution returns nothing | |||
| 295 | + | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil) | |||
| 296 | + | ||||
| 278 | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | 297 | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | ||
| 279 | runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil) | 298 | runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil) | ||
| 280 | runtime.EXPECT().PullImage(mock.Anything, "myapp:latest").Return(nil) | 299 | runtime.EXPECT().PullImage(mock.Anything, "myapp:latest").Return(nil) | ||
| @@ -329,6 +348,9 @@ func TestService_Deploy_ImagePullFailure(t *testing.T) { | |||||
| 329 | Image: "myapp:latest", | 348 | Image: "myapp:latest", | ||
| 330 | } | 349 | } | ||
| 331 | 350 | ||||
| 351 | + | // No container in memory — runtime resolution returns nothing | |||
| 352 | + | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil) | |||
| 353 | + | ||||
| 332 | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | 354 | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | ||
| 333 | runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil) | 355 | runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil) | ||
| 334 | runtime.EXPECT().PullImage(mock.Anything, "myapp:latest").Return(errors.New("image not found")) | 356 | runtime.EXPECT().PullImage(mock.Anything, "myapp:latest").Return(errors.New("image not found")) | ||
| @@ -406,8 +428,10 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) { | |||||
| 406 | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | 428 | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | ||
| 407 | 429 | ||||
| 408 | config := Config{ | 430 | config := Config{ | ||
| 409 | - | ReadinessDelay: time.Millisecond, // Minimal delay for tests | 431 | + | ReadinessDelay: time.Millisecond, // Minimal delay for tests |
| 410 | - | DrainDelay: time.Millisecond, | 432 | + | DrainDelay: time.Millisecond, |
| 433 | + | DrainDelayConfigured: true, | |||
| 434 | + | StabilizationDelay: time.Millisecond, | |||
| 411 | } | 435 | } | ||
| 412 | svc := NewService(runtime, envLoader, eventBus, nil, config) | 436 | svc := NewService(runtime, envLoader, eventBus, nil, config) | ||
| 413 | svc.SetProxyCacheInvalidator(cacheInvalidator) | 437 | svc.SetProxyCacheInvalidator(cacheInvalidator) | ||
| @@ -445,8 +469,8 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) { | |||||
| 445 | })).Return(newContainer, nil) | 469 | })).Return(newContainer, nil) | ||
| 446 | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | 470 | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | ||
| 447 | 471 | ||||
| 448 | - | // Wait for ready | 472 | + | // Wait for ready (2 calls) + stabilization check (1 call) |
| 449 | - | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2) | 473 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3) |
| 450 | 474 | ||||
| 451 | // Inspect after ready | 475 | // Inspect after ready | ||
| 452 | runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{ | 476 | runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{ | ||
| @@ -460,7 +484,7 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) { | |||||
| 460 | // Synchronous cache invalidation before old container cleanup | 484 | // Synchronous cache invalidation before old container cleanup | ||
| 461 | cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return() | 485 | cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return() | ||
| 462 | 486 | ||||
| 463 | - | // Now cleanup old container (after new one is ready + cache invalidated + drain delay) | 487 | + | // Now cleanup old container (after new one is ready + cache invalidated + stabilization + drain delay) |
| 464 | runtime.EXPECT().StopContainer(mock.Anything, "old-container").Return(nil) | 488 | runtime.EXPECT().StopContainer(mock.Anything, "old-container").Return(nil) | ||
| 465 | runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil) | 489 | runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil) | ||
| 466 | 490 | ||||
| @@ -471,10 +495,106 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) { | |||||
| 471 | 495 | ||||
| 472 | assert.NoError(t, err) | 496 | assert.NoError(t, err) | ||
| 473 | assert.Equal(t, "new-container", result.ID) | 497 | assert.Equal(t, "new-container", result.ID) | ||
| 498 | + | } | |||
| 499 | + | ||||
| 500 | + | // TestService_Deploy_ResolvesExistingFromRuntime_WhenMemoryStale verifies that | |||
| 501 | + | // when in-memory state has no tracked container for a domain, Deploy queries | |||
| 502 | + | // the runtime and discovers the running container. This prevents orphan cleanup | |||
| 503 | + | // from killing the real active container after a Gordon restart. | |||
| 504 | + | func TestService_Deploy_ResolvesExistingFromRuntime_WhenMemoryStale(t *testing.T) { | |||
| 505 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 506 | + | envLoader := mocks.NewMockEnvLoader(t) | |||
| 507 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 508 | + | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | |||
| 509 | + | ||||
| 510 | + | config := testMinDelayConfig() | |||
| 511 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |||
| 512 | + | svc.SetProxyCacheInvalidator(cacheInvalidator) | |||
| 513 | + | ctx := testContext() | |||
| 514 | + | ||||
| 515 | + | // NO tracked container in memory — simulates Gordon restart | |||
| 516 | + | // (svc.containers is empty for "test.example.com") | |||
| 517 | + | ||||
| 518 | + | route := domain.Route{ | |||
| 519 | + | Domain: "test.example.com", | |||
| 520 | + | Image: "myapp:v2", | |||
| 521 | + | } | |||
| 522 | + | ||||
| 523 | + | // resolveExistingContainer should query running containers from runtime | |||
| 524 | + | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{ | |||
| 525 | + | { | |||
| 526 | + | ID: "runtime-active-container", | |||
| 527 | + | Name: "gordon-test.example.com", | |||
| 528 | + | Status: "running", | |||
| 529 | + | Labels: map[string]string{ | |||
| 530 | + | domain.LabelDomain: "test.example.com", | |||
| 531 | + | domain.LabelManaged: "true", | |||
| 532 | + | }, | |||
| 533 | + | }, | |||
| 534 | + | }, nil) | |||
| 535 | + | ||||
| 536 | + | // Orphan cleanup lists ALL containers (including stopped) — the runtime-active | |||
| 537 | + | // container should be skipped because it is now recognized as the existing one | |||
| 538 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{ | |||
| 539 | + | { | |||
| 540 | + | ID: "runtime-active-container", | |||
| 541 | + | Name: "gordon-test.example.com", | |||
| 542 | + | Status: "running", | |||
| 543 | + | Labels: map[string]string{ | |||
| 544 | + | domain.LabelDomain: "test.example.com", | |||
| 545 | + | domain.LabelManaged: "true", | |||
| 546 | + | }, | |||
| 547 | + | }, | |||
| 548 | + | }, nil) | |||
| 549 | + | ||||
| 550 | + | // Image operations | |||
| 551 | + | runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil) | |||
| 552 | + | runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil) | |||
| 553 | + | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil) | |||
| 554 | + | ||||
| 555 | + | // Environment | |||
| 556 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil) | |||
| 557 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil) | |||
| 558 | + | ||||
| 559 | + | // Create new container with -new suffix (zero-downtime: existing was resolved from runtime) | |||
| 560 | + | newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"} | |||
| 561 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool { | |||
| 562 | + | return cfg.Name == "gordon-test.example.com-new" | |||
| 563 | + | })).Return(newContainer, nil) | |||
| 564 | + | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | |||
| 565 | + | ||||
| 566 | + | // Wait for ready (2 calls) + stabilization check (1 call) | |||
| 567 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3) | |||
| 568 | + | ||||
| 569 | + | // Inspect after ready | |||
| 570 | + | runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{ | |||
| 571 | + | ID: "new-container", | |||
| 572 | + | Status: "running", | |||
| 573 | + | }, nil) | |||
| 574 | + | ||||
| 575 | + | // Publish event | |||
| 576 | + | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil) | |||
| 577 | + | ||||
| 578 | + | // Synchronous cache invalidation | |||
| 579 | + | cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return() | |||
| 580 | + | ||||
| 581 | + | // AFTER new container is ready: stop and remove old runtime-discovered container | |||
| 582 | + | // This is the key assertion — StopContainer on the runtime-active container | |||
| 583 | + | // happens during finalizePreviousContainer, NOT during orphan cleanup | |||
| 584 | + | runtime.EXPECT().StopContainer(mock.Anything, "runtime-active-container").Return(nil) | |||
| 585 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "runtime-active-container", true).Return(nil) | |||
| 586 | + | ||||
| 587 | + | // Rename new container to canonical name | |||
| 588 | + | runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil) | |||
| 589 | + | ||||
| 590 | + | result, err := svc.Deploy(ctx, route) | |||
| 591 | + | ||||
| 592 | + | assert.NoError(t, err) | |||
| 593 | + | assert.Equal(t, "new-container", result.ID) | |||
| 474 | 594 | ||||
| 475 | - | // Ensure the service's tracked container entry now points to the new container. | 595 | + | // Verify new container is now tracked |
| 476 | - | tracked, ok := svc.Get(ctx, "test.example.com") | 596 | + | tracked, exists := svc.Get(ctx, "test.example.com") |
| 477 | - | assert.True(t, ok) | 597 | + | assert.True(t, exists) |
| 478 | assert.Equal(t, "new-container", tracked.ID) | 598 | assert.Equal(t, "new-container", tracked.ID) | ||
| 479 | } | 599 | } | ||
| 480 | 600 | ||||
| @@ -483,10 +603,7 @@ func TestService_Deploy_SkipRedundantDeploy_GetImageIDError(t *testing.T) { | |||||
| 483 | envLoader := mocks.NewMockEnvLoader(t) | 603 | envLoader := mocks.NewMockEnvLoader(t) | ||
| 484 | eventBus := mocks.NewMockEventPublisher(t) | 604 | eventBus := mocks.NewMockEventPublisher(t) | ||
| 485 | 605 | ||||
| 486 | - | config := Config{ | 606 | + | config := testMinDelayConfig() |
| 487 | - | ReadinessDelay: time.Millisecond, | |||
| 488 | - | DrainDelay: time.Millisecond, | |||
| 489 | - | } | |||
| 490 | svc := NewService(runtime, envLoader, eventBus, nil, config) | 607 | svc := NewService(runtime, envLoader, eventBus, nil, config) | ||
| 491 | ctx := testContext() | 608 | ctx := testContext() | ||
| 492 | 609 | ||||
| @@ -519,7 +636,8 @@ func TestService_Deploy_SkipRedundantDeploy_GetImageIDError(t *testing.T) { | |||||
| 519 | newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"} | 636 | newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"} | ||
| 520 | runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil) | 637 | runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil) | ||
| 521 | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | 638 | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | ||
| 522 | - | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2) | 639 | + | // Wait for ready (2 calls) + stabilization check (1 call) |
| 640 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3) | |||
| 523 | runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{ | 641 | runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{ | ||
| 524 | ID: "new-container", Status: "running", | 642 | ID: "new-container", Status: "running", | ||
| 525 | }, nil) | 643 | }, nil) | ||
| @@ -542,10 +660,11 @@ func TestService_Deploy_WithNetworkIsolation(t *testing.T) { | |||||
| 542 | eventBus := mocks.NewMockEventPublisher(t) | 660 | eventBus := mocks.NewMockEventPublisher(t) | ||
| 543 | 661 | ||||
| 544 | config := Config{ | 662 | config := Config{ | ||
| 545 | - | NetworkIsolation: true, | 663 | + | NetworkIsolation: true, |
| 546 | - | NetworkPrefix: "gordon", | 664 | + | NetworkPrefix: "gordon", |
| 547 | - | ReadinessDelay: time.Millisecond, // Minimal delay for tests | 665 | + | ReadinessDelay: time.Millisecond, // Minimal delay for tests |
| 548 | - | DrainDelay: time.Millisecond, | 666 | + | DrainDelay: time.Millisecond, |
| 667 | + | DrainDelayConfigured: true, | |||
| 549 | } | 668 | } | ||
| 550 | svc := NewService(runtime, envLoader, eventBus, nil, config) | 669 | svc := NewService(runtime, envLoader, eventBus, nil, config) | ||
| 551 | ctx := testContext() | 670 | ctx := testContext() | ||
| @@ -555,6 +674,9 @@ func TestService_Deploy_WithNetworkIsolation(t *testing.T) { | |||||
| 555 | Image: "myapp:latest", | 674 | Image: "myapp:latest", | ||
| 556 | } | 675 | } | ||
| 557 | 676 | ||||
| 677 | + | // No container in memory — runtime resolution returns nothing | |||
| 678 | + | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil) | |||
| 679 | + | ||||
| 558 | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | 680 | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | ||
| 559 | runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil) | 681 | runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil) | ||
| 560 | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil) | 682 | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil) | ||
| @@ -594,10 +716,11 @@ func TestService_Deploy_WithVolumeAutoCreate(t *testing.T) { | |||||
| 594 | eventBus := mocks.NewMockEventPublisher(t) | 716 | eventBus := mocks.NewMockEventPublisher(t) | ||
| 595 | 717 | ||||
| 596 | config := Config{ | 718 | config := Config{ | ||
| 597 | - | VolumeAutoCreate: true, | 719 | + | VolumeAutoCreate: true, |
| 598 | - | VolumePrefix: "gordon", | 720 | + | VolumePrefix: "gordon", |
| 599 | - | ReadinessDelay: time.Millisecond, // Minimal delay for tests | 721 | + | ReadinessDelay: time.Millisecond, // Minimal delay for tests |
| 600 | - | DrainDelay: time.Millisecond, | 722 | + | DrainDelay: time.Millisecond, |
| 723 | + | DrainDelayConfigured: true, | |||
| 601 | } | 724 | } | ||
| 602 | svc := NewService(runtime, envLoader, eventBus, nil, config) | 725 | svc := NewService(runtime, envLoader, eventBus, nil, config) | ||
| 603 | ctx := testContext() | 726 | ctx := testContext() | ||
| @@ -607,6 +730,9 @@ func TestService_Deploy_WithVolumeAutoCreate(t *testing.T) { | |||||
| 607 | Image: "myapp:latest", | 730 | Image: "myapp:latest", | ||
| 608 | } | 731 | } | ||
| 609 | 732 | ||||
| 733 | + | // No container in memory — runtime resolution returns nothing | |||
| 734 | + | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil) | |||
| 735 | + | ||||
| 610 | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | 736 | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | ||
| 611 | runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil) | 737 | runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil) | ||
| 612 | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil) | 738 | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil) | ||
| @@ -1312,6 +1438,7 @@ func TestService_Deploy_InternalDeployForcesPull(t *testing.T) { | |||||
| 1312 | InternalRegistryPassword: "secret", | 1438 | InternalRegistryPassword: "secret", | ||
| 1313 | ReadinessDelay: time.Millisecond, | 1439 | ReadinessDelay: time.Millisecond, | ||
| 1314 | DrainDelay: time.Millisecond, | 1440 | DrainDelay: time.Millisecond, | ||
| 1441 | + | DrainDelayConfigured: true, | |||
| 1315 | } | 1442 | } | ||
| 1316 | 1443 | ||||
| 1317 | svc := NewService(runtime, envLoader, eventBus, nil, config) | 1444 | svc := NewService(runtime, envLoader, eventBus, nil, config) | ||
| @@ -1324,6 +1451,9 @@ func TestService_Deploy_InternalDeployForcesPull(t *testing.T) { | |||||
| 1324 | Image: "myapp:latest", | 1451 | Image: "myapp:latest", | ||
| 1325 | } | 1452 | } | ||
| 1326 | 1453 | ||||
| 1454 | + | // No container in memory — runtime resolution returns nothing | |||
| 1455 | + | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil) | |||
| 1456 | + | ||||
| 1327 | // Setup mocks - no orphaned containers | 1457 | // Setup mocks - no orphaned containers | ||
| 1328 | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | 1458 | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | ||
| 1329 | 1459 | ||||
| @@ -1379,8 +1509,9 @@ func TestService_AutoStart_StartsNewContainers(t *testing.T) { | |||||
| 1379 | eventBus := mocks.NewMockEventPublisher(t) | 1509 | eventBus := mocks.NewMockEventPublisher(t) | ||
| 1380 | 1510 | ||||
| 1381 | config := Config{ | 1511 | config := Config{ | ||
| 1382 | - | ReadinessDelay: time.Millisecond, | 1512 | + | ReadinessDelay: time.Millisecond, |
| 1383 | - | DrainDelay: time.Millisecond, | 1513 | + | DrainDelay: time.Millisecond, |
| 1514 | + | DrainDelayConfigured: true, | |||
| 1384 | } | 1515 | } | ||
| 1385 | svc := NewService(runtime, envLoader, eventBus, nil, config) | 1516 | svc := NewService(runtime, envLoader, eventBus, nil, config) | ||
| 1386 | ctx := testContext() | 1517 | ctx := testContext() | ||
| @@ -1390,6 +1521,9 @@ func TestService_AutoStart_StartsNewContainers(t *testing.T) { | |||||
| 1390 | {Domain: "app2.example.com", Image: "myapp2:latest"}, | 1521 | {Domain: "app2.example.com", Image: "myapp2:latest"}, | ||
| 1391 | } | 1522 | } | ||
| 1392 | 1523 | ||||
| 1524 | + | // No container in memory — runtime resolution returns nothing (one per route) | |||
| 1525 | + | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil).Times(2) | |||
| 1526 | + | ||||
| 1393 | // Setup mocks for route deployments | 1527 | // Setup mocks for route deployments | ||
| 1394 | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Times(2) | 1528 | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Times(2) | ||
| 1395 | runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp1:latest", "myapp2:latest"}, nil).Times(2) | 1529 | runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp1:latest", "myapp2:latest"}, nil).Times(2) | ||
| @@ -1422,8 +1556,9 @@ func TestService_AutoStart_SkipsExistingContainers(t *testing.T) { | |||||
| 1422 | eventBus := mocks.NewMockEventPublisher(t) | 1556 | eventBus := mocks.NewMockEventPublisher(t) | ||
| 1423 | 1557 | ||||
| 1424 | config := Config{ | 1558 | config := Config{ | ||
| 1425 | - | ReadinessDelay: time.Millisecond, | 1559 | + | ReadinessDelay: time.Millisecond, |
| 1426 | - | DrainDelay: time.Millisecond, | 1560 | + | DrainDelay: time.Millisecond, |
| 1561 | + | DrainDelayConfigured: true, | |||
| 1427 | } | 1562 | } | ||
| 1428 | svc := NewService(runtime, envLoader, eventBus, nil, config) | 1563 | svc := NewService(runtime, envLoader, eventBus, nil, config) | ||
| 1429 | ctx := testContext() | 1564 | ctx := testContext() | ||
| @@ -1438,6 +1573,9 @@ func TestService_AutoStart_SkipsExistingContainers(t *testing.T) { | |||||
| 1438 | {Domain: "app2.example.com", Image: "myapp2:latest"}, // New route | 1573 | {Domain: "app2.example.com", Image: "myapp2:latest"}, // New route | ||
| 1439 | } | 1574 | } | ||
| 1440 | 1575 | ||||
| 1576 | + | // No container in memory for app2 — runtime resolution returns nothing | |||
| 1577 | + | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil).Once() | |||
| 1578 | + | ||||
| 1441 | // Only deploy for app2 (app1 is skipped). Readiness is skipped — no IsContainerRunning. | 1579 | // Only deploy for app2 (app1 is skipped). Readiness is skipped — no IsContainerRunning. | ||
| 1442 | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Once() | 1580 | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Once() | ||
| 1443 | runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp2:latest"}, nil).Once() | 1581 | runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp2:latest"}, nil).Once() | ||
| @@ -1472,6 +1610,9 @@ func TestService_AutoStart_HandlesDeployErrors(t *testing.T) { | |||||
| 1472 | {Domain: "app1.example.com", Image: "myapp1:latest"}, | 1610 | {Domain: "app1.example.com", Image: "myapp1:latest"}, | ||
| 1473 | } | 1611 | } | ||
| 1474 | 1612 | ||||
| 1613 | + | // No container in memory — runtime resolution returns nothing | |||
| 1614 | + | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil) | |||
| 1615 | + | ||||
| 1475 | // Setup mocks for failure | 1616 | // Setup mocks for failure | ||
| 1476 | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | 1617 | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | ||
| 1477 | runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil) | 1618 | runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil) | ||
| @@ -1490,10 +1631,11 @@ func TestService_AutoStart_UsesInternalDeployContext(t *testing.T) { | |||||
| 1490 | eventBus := mocks.NewMockEventPublisher(t) | 1631 | eventBus := mocks.NewMockEventPublisher(t) | ||
| 1491 | 1632 | ||||
| 1492 | config := Config{ | 1633 | config := Config{ | ||
| 1493 | - | RegistryDomain: "reg.example.com", | 1634 | + | RegistryDomain: "reg.example.com", |
| 1494 | - | RegistryPort: 5000, | 1635 | + | RegistryPort: 5000, |
| 1495 | - | ReadinessDelay: time.Millisecond, | 1636 | + | ReadinessDelay: time.Millisecond, |
| 1496 | - | DrainDelay: time.Millisecond, | 1637 | + | DrainDelay: time.Millisecond, |
| 1638 | + | DrainDelayConfigured: true, | |||
| 1497 | } | 1639 | } | ||
| 1498 | svc := NewService(runtime, envLoader, eventBus, nil, config) | 1640 | svc := NewService(runtime, envLoader, eventBus, nil, config) | ||
| 1499 | 1641 | ||||
| @@ -1504,6 +1646,9 @@ func TestService_AutoStart_UsesInternalDeployContext(t *testing.T) { | |||||
| 1504 | {Domain: "app1.example.com", Image: "reg.example.com/myapp:latest"}, | 1646 | {Domain: "app1.example.com", Image: "reg.example.com/myapp:latest"}, | ||
| 1505 | } | 1647 | } | ||
| 1506 | 1648 | ||||
| 1649 | + | // No container in memory — runtime resolution returns nothing | |||
| 1650 | + | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil) | |||
| 1651 | + | ||||
| 1507 | // Key assertion: PullImage should be called with localhost:5000 rewrite, | 1652 | // Key assertion: PullImage should be called with localhost:5000 rewrite, | ||
| 1508 | // NOT the original reg.example.com/myapp:latest. | 1653 | // NOT the original reg.example.com/myapp:latest. | ||
| 1509 | // Readiness is skipped — no IsContainerRunning calls. | 1654 | // Readiness is skipped — no IsContainerRunning calls. | ||
| @@ -1538,8 +1683,10 @@ func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) { | |||||
| 1538 | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | 1683 | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | ||
| 1539 | 1684 | ||||
| 1540 | config := Config{ | 1685 | config := Config{ | ||
| 1541 | - | ReadinessDelay: time.Millisecond, // Minimal delay for tests | 1686 | + | ReadinessDelay: time.Millisecond, // Minimal delay for tests |
| 1542 | - | DrainDelay: time.Millisecond, | 1687 | + | DrainDelay: time.Millisecond, |
| 1688 | + | DrainDelayConfigured: true, | |||
| 1689 | + | StabilizationDelay: time.Millisecond, | |||
| 1543 | } | 1690 | } | ||
| 1544 | svc := NewService(runtime, envLoader, eventBus, nil, config) | 1691 | svc := NewService(runtime, envLoader, eventBus, nil, config) | ||
| 1545 | svc.SetProxyCacheInvalidator(cacheInvalidator) | 1692 | svc.SetProxyCacheInvalidator(cacheInvalidator) | ||
| @@ -1585,8 +1732,8 @@ func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) { | |||||
| 1585 | })).Return(newContainer, nil) | 1732 | })).Return(newContainer, nil) | ||
| 1586 | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | 1733 | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | ||
| 1587 | 1734 | ||||
| 1588 | - | // Wait for ready | 1735 | + | // Wait for ready (2 calls) + stabilization check (1 call) |
| 1589 | - | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2) | 1736 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3) |
| 1590 | 1737 | ||||
| 1591 | // Inspect after ready | 1738 | // Inspect after ready | ||
| 1592 | runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{ | 1739 | runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{ | ||
| @@ -1619,16 +1766,18 @@ func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) { | |||||
| 1619 | assert.Equal(t, "new-container", tracked.ID) | 1766 | assert.Equal(t, "new-container", tracked.ID) | ||
| 1620 | } | 1767 | } | ||
| 1621 | 1768 | ||||
| 1622 | - | // TestService_Deploy_OrphanCleanupRemovesTrueOrphans verifies that containers | 1769 | + | // TestService_Deploy_OrphanCleanupRemovesTrueOrphans verifies that stopped canonical |
| 1623 | - | // with the same name but NOT tracked are properly removed as orphans. | 1770 | + | // containers with the same name but NOT tracked are properly removed as orphans. |
| 1771 | + | // Running canonical containers are preserved (see NeverKillsRunningCanonical test). | |||
| 1624 | func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) { | 1772 | func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) { | ||
| 1625 | runtime := mocks.NewMockContainerRuntime(t) | 1773 | runtime := mocks.NewMockContainerRuntime(t) | ||
| 1626 | envLoader := mocks.NewMockEnvLoader(t) | 1774 | envLoader := mocks.NewMockEnvLoader(t) | ||
| 1627 | eventBus := mocks.NewMockEventPublisher(t) | 1775 | eventBus := mocks.NewMockEventPublisher(t) | ||
| 1628 | 1776 | ||||
| 1629 | config := Config{ | 1777 | config := Config{ | ||
| 1630 | - | ReadinessDelay: time.Millisecond, // Minimal delay for tests | 1778 | + | ReadinessDelay: time.Millisecond, // Minimal delay for tests |
| 1631 | - | DrainDelay: time.Millisecond, | 1779 | + | DrainDelay: time.Millisecond, |
| 1780 | + | DrainDelayConfigured: true, | |||
| 1632 | } | 1781 | } | ||
| 1633 | svc := NewService(runtime, envLoader, eventBus, nil, config) | 1782 | svc := NewService(runtime, envLoader, eventBus, nil, config) | ||
| 1634 | ctx := testContext() | 1783 | ctx := testContext() | ||
| @@ -1640,17 +1789,21 @@ func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) { | |||||
| 1640 | Image: "myapp:v1", | 1789 | Image: "myapp:v1", | ||
| 1641 | } | 1790 | } | ||
| 1642 | 1791 | ||||
| 1643 | - | // ListContainers returns an orphaned container (same name, but not tracked) | 1792 | + | // No container in memory — runtime resolution finds no managed container |
| 1644 | - | // This could happen if Gordon crashed and restarted, or container was created manually | 1793 | + | // (the orphan is stopped so it won't appear in running-only query) |
| 1794 | + | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil) | |||
| 1795 | + | ||||
| 1796 | + | // ListContainers(true) returns a STOPPED orphaned container (same name, but not tracked) | |||
| 1797 | + | // Stopped canonical containers are safe to remove during orphan cleanup | |||
| 1645 | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{ | 1798 | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{ | ||
| 1646 | { | 1799 | { | ||
| 1647 | ID: "orphan-container", | 1800 | ID: "orphan-container", | ||
| 1648 | Name: "gordon-test.example.com", | 1801 | Name: "gordon-test.example.com", | ||
| 1649 | - | Status: "running", | 1802 | + | Status: "exited", |
| 1650 | }, | 1803 | }, | ||
| 1651 | }, nil) | 1804 | }, nil) | ||
| 1652 | 1805 | ||||
| 1653 | - | // Orphan should be stopped and removed BEFORE we proceed | 1806 | + | // Stopped orphan should be stopped and removed BEFORE we proceed |
| 1654 | runtime.EXPECT().StopContainer(mock.Anything, "orphan-container").Return(nil) | 1807 | runtime.EXPECT().StopContainer(mock.Anything, "orphan-container").Return(nil) | ||
| 1655 | runtime.EXPECT().RemoveContainer(mock.Anything, "orphan-container", true).Return(nil) | 1808 | runtime.EXPECT().RemoveContainer(mock.Anything, "orphan-container", true).Return(nil) | ||
| 1656 | 1809 | ||||
| @@ -1700,10 +1853,7 @@ func TestService_Deploy_OrphanCleanupRemovesStaleNewContainer(t *testing.T) { | |||||
| 1700 | eventBus := mocks.NewMockEventPublisher(t) | 1853 | eventBus := mocks.NewMockEventPublisher(t) | ||
| 1701 | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | 1854 | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | ||
| 1702 | 1855 | ||||
| 1703 | - | config := Config{ | 1856 | + | config := testMinDelayConfig() |
| 1704 | - | ReadinessDelay: time.Millisecond, | |||
| 1705 | - | DrainDelay: time.Millisecond, | |||
| 1706 | - | } | |||
| 1707 | svc := NewService(runtime, envLoader, eventBus, nil, config) | 1857 | svc := NewService(runtime, envLoader, eventBus, nil, config) | ||
| 1708 | svc.SetProxyCacheInvalidator(cacheInvalidator) | 1858 | svc.SetProxyCacheInvalidator(cacheInvalidator) | ||
| 1709 | ctx := testContext() | 1859 | ctx := testContext() | ||
| @@ -1746,7 +1896,8 @@ func TestService_Deploy_OrphanCleanupRemovesStaleNewContainer(t *testing.T) { | |||||
| 1746 | return cfg.Name == "gordon-test.example.com-new" | 1896 | return cfg.Name == "gordon-test.example.com-new" | ||
| 1747 | })).Return(newContainer, nil) | 1897 | })).Return(newContainer, nil) | ||
| 1748 | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | 1898 | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | ||
| 1749 | - | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2) | 1899 | + | // Wait for ready (2 calls) + stabilization check (1 call) |
| 1900 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3) | |||
| 1750 | runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{ | 1901 | runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{ | ||
| 1751 | ID: "new-container", | 1902 | ID: "new-container", | ||
| 1752 | Status: "running", | 1903 | Status: "running", | ||
| @@ -1772,10 +1923,7 @@ func TestService_Deploy_TrackedTempContainerUsesAlternateTempName(t *testing.T) | |||||
| 1772 | eventBus := mocks.NewMockEventPublisher(t) | 1923 | eventBus := mocks.NewMockEventPublisher(t) | ||
| 1773 | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | 1924 | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | ||
| 1774 | 1925 | ||||
| 1775 | - | config := Config{ | 1926 | + | config := testMinDelayConfig() |
| 1776 | - | ReadinessDelay: time.Millisecond, | |||
| 1777 | - | DrainDelay: time.Millisecond, | |||
| 1778 | - | } | |||
| 1779 | svc := NewService(runtime, envLoader, eventBus, nil, config) | 1927 | svc := NewService(runtime, envLoader, eventBus, nil, config) | ||
| 1780 | svc.SetProxyCacheInvalidator(cacheInvalidator) | 1928 | svc.SetProxyCacheInvalidator(cacheInvalidator) | ||
| 1781 | ctx := testContext() | 1929 | ctx := testContext() | ||
| @@ -1811,7 +1959,8 @@ func TestService_Deploy_TrackedTempContainerUsesAlternateTempName(t *testing.T) | |||||
| 1811 | return cfg.Name == "gordon-test.example.com-next" | 1959 | return cfg.Name == "gordon-test.example.com-next" | ||
| 1812 | })).Return(created, nil) | 1960 | })).Return(created, nil) | ||
| 1813 | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | 1961 | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | ||
| 1814 | - | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2) | 1962 | + | // Wait for ready (2 calls) + stabilization check (1 call) |
| 1963 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3) | |||
| 1815 | runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{ | 1964 | runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{ | ||
| 1816 | ID: "new-container", | 1965 | ID: "new-container", | ||
| 1817 | Status: "running", | 1966 | Status: "running", | ||
| @@ -1961,10 +2110,7 @@ func TestService_Deploy_ConcurrentSameDomain(t *testing.T) { | |||||
| 1961 | eventBus := mocks.NewMockEventPublisher(t) | 2110 | eventBus := mocks.NewMockEventPublisher(t) | ||
| 1962 | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | 2111 | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | ||
| 1963 | 2112 | ||||
| 1964 | - | config := Config{ | 2113 | + | config := testMinDelayConfig() |
| 1965 | - | ReadinessDelay: time.Millisecond, | |||
| 1966 | - | DrainDelay: time.Millisecond, | |||
| 1967 | - | } | |||
| 1968 | svc := NewService(runtime, envLoader, eventBus, nil, config) | 2114 | svc := NewService(runtime, envLoader, eventBus, nil, config) | ||
| 1969 | svc.SetProxyCacheInvalidator(cacheInvalidator) | 2115 | svc.SetProxyCacheInvalidator(cacheInvalidator) | ||
| 1970 | ctx := testContext() | 2116 | ctx := testContext() | ||
| @@ -1982,6 +2128,9 @@ func TestService_Deploy_ConcurrentSameDomain(t *testing.T) { | |||||
| 1982 | // First deploy: no existing container → creates gordon-test.example.com | 2128 | // First deploy: no existing container → creates gordon-test.example.com | ||
| 1983 | // Second deploy: sees first container → creates gordon-test.example.com-new | 2129 | // Second deploy: sees first container → creates gordon-test.example.com-new | ||
| 1984 | 2130 | ||||
| 2131 | + | // First deploy resolves from runtime (no container in memory yet) | |||
| 2132 | + | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil).Once() | |||
| 2133 | + | ||||
| 1985 | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Times(2) | 2134 | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Times(2) | ||
| 1986 | runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil).Times(2) | 2135 | runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil).Times(2) | ||
| 1987 | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil).Times(2) | 2136 | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil).Times(2) | ||
| @@ -2005,8 +2154,8 @@ func TestService_Deploy_ConcurrentSameDomain(t *testing.T) { | |||||
| 2005 | }) | 2154 | }) | ||
| 2006 | 2155 | ||||
| 2007 | runtime.EXPECT().StartContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Times(2) | 2156 | runtime.EXPECT().StartContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Times(2) | ||
| 2008 | - | // IsContainerRunning is called 2x per deploy in waitForReady (initial check + after delay) | 2157 | + | // IsContainerRunning: 2x per deploy in waitForReady + 1x stabilization for the second deploy (which has existing) |
| 2009 | - | runtime.EXPECT().IsContainerRunning(mock.Anything, mock.AnythingOfType("string")).Return(true, nil).Times(4) | 2158 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, mock.AnythingOfType("string")).Return(true, nil).Times(5) |
| 2010 | 2159 | ||||
| 2011 | runtime.EXPECT().InspectContainer(mock.Anything, mock.AnythingOfType("string")). | 2160 | runtime.EXPECT().InspectContainer(mock.Anything, mock.AnythingOfType("string")). | ||
| 2012 | RunAndReturn(func(_ context.Context, id string) (*domain.Container, error) { | 2161 | RunAndReturn(func(_ context.Context, id string) (*domain.Container, error) { | ||
| @@ -2069,8 +2218,9 @@ func TestService_Deploy_ContextCancellation(t *testing.T) { | |||||
| 2069 | eventBus := mocks.NewMockEventPublisher(t) | 2218 | eventBus := mocks.NewMockEventPublisher(t) | ||
| 2070 | 2219 | ||||
| 2071 | config := Config{ | 2220 | config := Config{ | ||
| 2072 | - | ReadinessDelay: time.Millisecond, | 2221 | + | ReadinessDelay: time.Millisecond, |
| 2073 | - | DrainDelay: time.Millisecond, | 2222 | + | DrainDelay: time.Millisecond, |
| 2223 | + | DrainDelayConfigured: true, | |||
| 2074 | } | 2224 | } | ||
| 2075 | svc := NewService(runtime, envLoader, eventBus, nil, config) | 2225 | svc := NewService(runtime, envLoader, eventBus, nil, config) | ||
| 2076 | 2226 | ||||
| @@ -2097,10 +2247,7 @@ func TestService_Deploy_CacheInvalidationBeforeOldContainerStop(t *testing.T) { | |||||
| 2097 | eventBus := mocks.NewMockEventPublisher(t) | 2247 | eventBus := mocks.NewMockEventPublisher(t) | ||
| 2098 | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | 2248 | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | ||
| 2099 | 2249 | ||||
| 2100 | - | config := Config{ | 2250 | + | config := testMinDelayConfig() |
| 2101 | - | ReadinessDelay: time.Millisecond, | |||
| 2102 | - | DrainDelay: time.Millisecond, | |||
| 2103 | - | } | |||
| 2104 | svc := NewService(runtime, envLoader, eventBus, nil, config) | 2251 | svc := NewService(runtime, envLoader, eventBus, nil, config) | ||
| 2105 | svc.SetProxyCacheInvalidator(cacheInvalidator) | 2252 | svc.SetProxyCacheInvalidator(cacheInvalidator) | ||
| 2106 | ctx := testContext() | 2253 | ctx := testContext() | ||
| @@ -2132,7 +2279,8 @@ func TestService_Deploy_CacheInvalidationBeforeOldContainerStop(t *testing.T) { | |||||
| 2132 | newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"} | 2279 | newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"} | ||
| 2133 | runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil) | 2280 | runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil) | ||
| 2134 | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | 2281 | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | ||
| 2135 | - | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2) | 2282 | + | // Wait for ready (2 calls) + stabilization check (1 call) |
| 2283 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3) | |||
| 2136 | runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{ | 2284 | runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{ | ||
| 2137 | ID: "new-container", Status: "running", | 2285 | ID: "new-container", Status: "running", | ||
| 2138 | }, nil) | 2286 | }, nil) | ||
| @@ -2176,10 +2324,7 @@ func TestService_Deploy_NilCacheInvalidator(t *testing.T) { | |||||
| 2176 | envLoader := mocks.NewMockEnvLoader(t) | 2324 | envLoader := mocks.NewMockEnvLoader(t) | ||
| 2177 | eventBus := mocks.NewMockEventPublisher(t) | 2325 | eventBus := mocks.NewMockEventPublisher(t) | ||
| 2178 | 2326 | ||||
| 2179 | - | config := Config{ | 2327 | + | config := testMinDelayConfig() |
| 2180 | - | ReadinessDelay: time.Millisecond, | |||
| 2181 | - | DrainDelay: time.Millisecond, | |||
| 2182 | - | } | |||
| 2183 | // Intentionally NOT setting cache invalidator | 2328 | // Intentionally NOT setting cache invalidator | ||
| 2184 | svc := NewService(runtime, envLoader, eventBus, nil, config) | 2329 | svc := NewService(runtime, envLoader, eventBus, nil, config) | ||
| 2185 | ctx := testContext() | 2330 | ctx := testContext() | ||
| @@ -2205,7 +2350,8 @@ func TestService_Deploy_NilCacheInvalidator(t *testing.T) { | |||||
| 2205 | newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"} | 2350 | newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"} | ||
| 2206 | runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil) | 2351 | runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil) | ||
| 2207 | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | 2352 | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | ||
| 2208 | - | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2) | 2353 | + | // Wait for ready (2 calls) + stabilization check (1 call) |
| 2354 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3) | |||
| 2209 | runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{ | 2355 | runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{ | ||
| 2210 | ID: "new-container", Status: "running", | 2356 | ID: "new-container", Status: "running", | ||
| 2211 | }, nil) | 2357 | }, nil) | ||
| @@ -2226,8 +2372,9 @@ func TestService_Deploy_SkipsRedundantDeploy(t *testing.T) { | |||||
| 2226 | eventBus := mocks.NewMockEventPublisher(t) | 2372 | eventBus := mocks.NewMockEventPublisher(t) | ||
| 2227 | 2373 | ||||
| 2228 | config := Config{ | 2374 | config := Config{ | ||
| 2229 | - | ReadinessDelay: time.Millisecond, | 2375 | + | ReadinessDelay: time.Millisecond, |
| 2230 | - | DrainDelay: time.Millisecond, | 2376 | + | DrainDelay: time.Millisecond, |
| 2377 | + | DrainDelayConfigured: true, | |||
| 2231 | } | 2378 | } | ||
| 2232 | svc := NewService(runtime, envLoader, eventBus, nil, config) | 2379 | svc := NewService(runtime, envLoader, eventBus, nil, config) | ||
| 2233 | ctx := testContext() | 2380 | ctx := testContext() | ||
| @@ -2273,16 +2420,68 @@ func TestService_Deploy_SkipsRedundantDeploy(t *testing.T) { | |||||
| 2273 | assert.Equal(t, "existing-container", tracked.ID) | 2420 | assert.Equal(t, "existing-container", tracked.ID) | ||
| 2274 | } | 2421 | } | ||
| 2275 | 2422 | ||||
| 2276 | - | func TestService_Deploy_DoesNotSkipWhenImageIDDiffers(t *testing.T) { | 2423 | + | func TestService_Deploy_SkipsRedundantDeploy_WhenTrackedImageIDMissing(t *testing.T) { |
| 2277 | runtime := mocks.NewMockContainerRuntime(t) | 2424 | runtime := mocks.NewMockContainerRuntime(t) | ||
| 2278 | envLoader := mocks.NewMockEnvLoader(t) | 2425 | envLoader := mocks.NewMockEnvLoader(t) | ||
| 2279 | eventBus := mocks.NewMockEventPublisher(t) | 2426 | eventBus := mocks.NewMockEventPublisher(t) | ||
| 2280 | - | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | |||
| 2281 | 2427 | ||||
| 2282 | config := Config{ | 2428 | config := Config{ | ||
| 2283 | - | ReadinessDelay: time.Millisecond, | 2429 | + | ReadinessDelay: time.Millisecond, |
| 2284 | - | DrainDelay: time.Millisecond, | 2430 | + | DrainDelay: time.Millisecond, |
| 2431 | + | DrainDelayConfigured: true, | |||
| 2432 | + | } | |||
| 2433 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |||
| 2434 | + | ctx := testContext() | |||
| 2435 | + | ||||
| 2436 | + | // Pre-populate with existing container missing ImageID (common after stale in-memory state). | |||
| 2437 | + | existingContainer := &domain.Container{ | |||
| 2438 | + | ID: "existing-container", | |||
| 2439 | + | Name: "gordon-test.example.com", | |||
| 2440 | + | Image: "myapp:latest", | |||
| 2441 | + | Status: "running", | |||
| 2285 | } | 2442 | } | ||
| 2443 | + | svc.containers["test.example.com"] = existingContainer | |||
| 2444 | + | ||||
| 2445 | + | route := domain.Route{ | |||
| 2446 | + | Domain: "test.example.com", | |||
| 2447 | + | Image: "myapp:latest", | |||
| 2448 | + | } | |||
| 2449 | + | ||||
| 2450 | + | // prepareDeployResources | |||
| 2451 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | |||
| 2452 | + | runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil) | |||
| 2453 | + | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil) | |||
| 2454 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil) | |||
| 2455 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil) | |||
| 2456 | + | ||||
| 2457 | + | // Existing container image ID is recovered from runtime inspection, | |||
| 2458 | + | // then redundancy check can skip the deploy. | |||
| 2459 | + | runtime.EXPECT().InspectContainer(mock.Anything, "existing-container").Return(&domain.Container{ | |||
| 2460 | + | ID: "existing-container", | |||
| 2461 | + | ImageID: "sha256:abc123", | |||
| 2462 | + | Status: "running", | |||
| 2463 | + | }, nil) | |||
| 2464 | + | runtime.EXPECT().GetImageID(mock.Anything, "myapp:latest").Return("sha256:abc123", nil) | |||
| 2465 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "existing-container").Return(true, nil) | |||
| 2466 | + | ||||
| 2467 | + | result, err := svc.Deploy(ctx, route) | |||
| 2468 | + | ||||
| 2469 | + | assert.NoError(t, err) | |||
| 2470 | + | assert.NotNil(t, result) | |||
| 2471 | + | assert.Equal(t, "existing-container", result.ID) | |||
| 2472 | + | ||||
| 2473 | + | tracked, exists := svc.Get(ctx, "test.example.com") | |||
| 2474 | + | assert.True(t, exists) | |||
| 2475 | + | assert.Equal(t, "existing-container", tracked.ID) | |||
| 2476 | + | } | |||
| 2477 | + | ||||
| 2478 | + | func TestService_Deploy_DoesNotSkipWhenImageIDDiffers(t *testing.T) { | |||
| 2479 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 2480 | + | envLoader := mocks.NewMockEnvLoader(t) | |||
| 2481 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 2482 | + | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | |||
| 2483 | + | ||||
| 2484 | + | config := testMinDelayConfig() | |||
| 2286 | svc := NewService(runtime, envLoader, eventBus, nil, config) | 2485 | svc := NewService(runtime, envLoader, eventBus, nil, config) | ||
| 2287 | svc.SetProxyCacheInvalidator(cacheInvalidator) | 2486 | svc.SetProxyCacheInvalidator(cacheInvalidator) | ||
| 2288 | ctx := testContext() | 2487 | ctx := testContext() | ||
| @@ -2316,7 +2515,8 @@ func TestService_Deploy_DoesNotSkipWhenImageIDDiffers(t *testing.T) { | |||||
| 2316 | newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"} | 2515 | newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"} | ||
| 2317 | runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil) | 2516 | runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil) | ||
| 2318 | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | 2517 | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | ||
| 2319 | - | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2) | 2518 | + | // Wait for ready (2 calls) + stabilization check (1 call) |
| 2519 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3) | |||
| 2320 | runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{ | 2520 | runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{ | ||
| 2321 | ID: "new-container", Status: "running", | 2521 | ID: "new-container", Status: "running", | ||
| 2322 | }, nil) | 2522 | }, nil) | ||
| @@ -2380,10 +2580,7 @@ func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) { | |||||
| 2380 | envLoader := mocks.NewMockEnvLoader(t) | 2580 | envLoader := mocks.NewMockEnvLoader(t) | ||
| 2381 | eventBus := mocks.NewMockEventPublisher(t) | 2581 | eventBus := mocks.NewMockEventPublisher(t) | ||
| 2382 | 2582 | ||||
| 2383 | - | config := Config{ | 2583 | + | config := testMinDelayConfig() |
| 2384 | - | ReadinessDelay: time.Millisecond, | |||
| 2385 | - | DrainDelay: time.Millisecond, | |||
| 2386 | - | } | |||
| 2387 | svc := NewService(runtime, envLoader, eventBus, nil, config) | 2584 | svc := NewService(runtime, envLoader, eventBus, nil, config) | ||
| 2388 | ctx := testContext() | 2585 | ctx := testContext() | ||
| 2389 | 2586 | ||||
| @@ -2417,7 +2614,8 @@ func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) { | |||||
| 2417 | newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"} | 2614 | newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"} | ||
| 2418 | runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil) | 2615 | runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(newContainer, nil) | ||
| 2419 | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | 2616 | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | ||
| 2420 | - | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2) | 2617 | + | // Wait for ready (2 calls) + stabilization check (1 call) |
| 2618 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3) | |||
| 2421 | runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{ | 2619 | runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{ | ||
| 2422 | ID: "new-container", Status: "running", | 2620 | ID: "new-container", Status: "running", | ||
| 2423 | }, nil) | 2621 | }, nil) | ||
| @@ -2433,3 +2631,527 @@ func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) { | |||||
| 2433 | assert.NoError(t, err) | 2631 | assert.NoError(t, err) | ||
| 2434 | assert.Equal(t, "new-container", result.ID) | 2632 | assert.Equal(t, "new-container", result.ID) | ||
| 2435 | } | 2633 | } | ||
| 2634 | + | ||||
| 2635 | + | // TestService_Deploy_OrphanCleanup_NeverKillsRunningCanonical verifies that orphan | |||
| 2636 | + | // cleanup never stops a running container with the canonical name. Only stopped | |||
| 2637 | + | // canonical containers and temp containers (-new/-next) should be cleaned up. | |||
| 2638 | + | // The active canonical container is resolved via resolveExistingContainer (it has the | |||
| 2639 | + | // managed label), so the new container is created with the "-new" suffix — Docker | |||
| 2640 | + | // disallows creating a container with the same name as an existing running container. | |||
| 2641 | + | func TestService_Deploy_OrphanCleanup_NeverKillsRunningCanonical(t *testing.T) { | |||
| 2642 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 2643 | + | envLoader := mocks.NewMockEnvLoader(t) | |||
| 2644 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 2645 | + | ||||
| 2646 | + | config := testMinDelayConfig() | |||
| 2647 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |||
| 2648 | + | ctx := testContext() | |||
| 2649 | + | ||||
| 2650 | + | // The canonical container is running and has the managed label — it will be | |||
| 2651 | + | // discovered by resolveExistingContainer's slow path (no in-memory state). | |||
| 2652 | + | existingContainer := &domain.Container{ | |||
| 2653 | + | ID: "active-canonical", | |||
| 2654 | + | Name: "gordon-test.example.com", | |||
| 2655 | + | Status: "running", | |||
| 2656 | + | Labels: map[string]string{domain.LabelManaged: "true"}, | |||
| 2657 | + | } | |||
| 2658 | + | ||||
| 2659 | + | route := domain.Route{ | |||
| 2660 | + | Domain: "test.example.com", | |||
| 2661 | + | Image: "myapp:v2", | |||
| 2662 | + | } | |||
| 2663 | + | ||||
| 2664 | + | // resolveExistingContainer: slow path finds the running canonical container via label. | |||
| 2665 | + | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{existingContainer}, nil) | |||
| 2666 | + | ||||
| 2667 | + | // cleanupOrphanedContainers: finds the running canonical AND a stale -new leftover. | |||
| 2668 | + | // The canonical container must NOT be killed because it's running. | |||
| 2669 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{ | |||
| 2670 | + | existingContainer, | |||
| 2671 | + | { | |||
| 2672 | + | ID: "stale-leftover", | |||
| 2673 | + | Name: "gordon-test.example.com-new", | |||
| 2674 | + | Status: "exited", | |||
| 2675 | + | }, | |||
| 2676 | + | }, nil) | |||
| 2677 | + | ||||
| 2678 | + | // Only the stale -new container should be stopped and removed during orphan cleanup. | |||
| 2679 | + | runtime.EXPECT().StopContainer(mock.Anything, "stale-leftover").Return(nil).Once() | |||
| 2680 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "stale-leftover", true).Return(nil).Once() | |||
| 2681 | + | ||||
| 2682 | + | // Image operations: pull new image (existing.Image is empty so redundancy check is skipped) | |||
| 2683 | + | runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil) | |||
| 2684 | + | runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil) | |||
| 2685 | + | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil) | |||
| 2686 | + | ||||
| 2687 | + | // Environment | |||
| 2688 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil) | |||
| 2689 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil) | |||
| 2690 | + | ||||
| 2691 | + | // Create container — existing is the canonical container, so the new one gets "-new" suffix. | |||
| 2692 | + | // Docker enforces unique container names; using "-new" avoids a name collision. | |||
| 2693 | + | newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"} | |||
| 2694 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool { | |||
| 2695 | + | return cfg.Name == "gordon-test.example.com-new" | |||
| 2696 | + | })).Return(newContainer, nil) | |||
| 2697 | + | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | |||
| 2698 | + | ||||
| 2699 | + | // Wait for ready (delay mode): pollContainerRunning (1×) + waitForReadyByDelay (1×) | |||
| 2700 | + | // Post-switch stabilization: 1× — total 3 calls. | |||
| 2701 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3) | |||
| 2702 | + | ||||
| 2703 | + | // Inspect after ready | |||
| 2704 | + | runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{ | |||
| 2705 | + | ID: "new-container", | |||
| 2706 | + | Status: "running", | |||
| 2707 | + | Ports: []int{8080}, | |||
| 2708 | + | }, nil) | |||
| 2709 | + | ||||
| 2710 | + | // Rename new container to canonical name after stabilization. | |||
| 2711 | + | runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil).Once() | |||
| 2712 | + | ||||
| 2713 | + | // Stop and remove old canonical container. | |||
| 2714 | + | runtime.EXPECT().StopContainer(mock.Anything, "active-canonical").Return(nil).Once() | |||
| 2715 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "active-canonical", true).Return(nil).Once() | |||
| 2716 | + | ||||
| 2717 | + | // Publish event | |||
| 2718 | + | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil) | |||
| 2719 | + | ||||
| 2720 | + | result, err := svc.Deploy(ctx, route) | |||
| 2721 | + | ||||
| 2722 | + | assert.NoError(t, err) | |||
| 2723 | + | assert.Equal(t, "new-container", result.ID) | |||
| 2724 | + | ||||
| 2725 | + | // Verify new container is tracked | |||
| 2726 | + | tracked, exists := svc.Get(ctx, "test.example.com") | |||
| 2727 | + | assert.True(t, exists) | |||
| 2728 | + | assert.Equal(t, "new-container", tracked.ID) | |||
| 2729 | + | } | |||
| 2730 | + | ||||
| 2731 | + | // TestService_Deploy_RollbackOnPostSwitchCrash verifies that if the new container crashes | |||
| 2732 | + | // during the post-switch stabilization window, traffic is automatically rolled back to the | |||
| 2733 | + | // old container: the old container is restored as tracked, the proxy cache is re-invalidated, | |||
| 2734 | + | // and the failed new container is cleaned up. | |||
| 2735 | + | func TestService_Deploy_RollbackOnPostSwitchCrash(t *testing.T) { | |||
| 2736 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 2737 | + | envLoader := mocks.NewMockEnvLoader(t) | |||
| 2738 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 2739 | + | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | |||
| 2740 | + | ||||
| 2741 | + | config := Config{ | |||
| 2742 | + | ReadinessDelay: time.Millisecond, | |||
| 2743 | + | DrainDelay: time.Millisecond, | |||
| 2744 | + | DrainDelayConfigured: true, | |||
| 2745 | + | StabilizationDelay: time.Millisecond, | |||
| 2746 | + | } | |||
| 2747 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |||
| 2748 | + | svc.SetProxyCacheInvalidator(cacheInvalidator) | |||
| 2749 | + | ctx := testContext() | |||
| 2750 | + | ||||
| 2751 | + | // Pre-populate with existing container | |||
| 2752 | + | existing := &domain.Container{ | |||
| 2753 | + | ID: "old-container", | |||
| 2754 | + | Name: "gordon-test.example.com", | |||
| 2755 | + | Status: "running", | |||
| 2756 | + | } | |||
| 2757 | + | svc.containers["test.example.com"] = existing | |||
| 2758 | + | ||||
| 2759 | + | route := domain.Route{ | |||
| 2760 | + | Domain: "test.example.com", | |||
| 2761 | + | Image: "myapp:v2", | |||
| 2762 | + | } | |||
| 2763 | + | ||||
| 2764 | + | // Cleanup orphans | |||
| 2765 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | |||
| 2766 | + | ||||
| 2767 | + | // Image operations | |||
| 2768 | + | runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil) | |||
| 2769 | + | runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil) | |||
| 2770 | + | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil) | |||
| 2771 | + | ||||
| 2772 | + | // Environment | |||
| 2773 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil) | |||
| 2774 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil) | |||
| 2775 | + | ||||
| 2776 | + | // Create new container | |||
| 2777 | + | newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"} | |||
| 2778 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool { | |||
| 2779 | + | return cfg.Name == "gordon-test.example.com-new" | |||
| 2780 | + | })).Return(newContainer, nil) | |||
| 2781 | + | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | |||
| 2782 | + | ||||
| 2783 | + | // Readiness: new container is running during readiness checks (2 calls) | |||
| 2784 | + | // Stabilization: new container has crashed (returns false) | |||
| 2785 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2) | |||
| 2786 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(false, nil).Once() | |||
| 2787 | + | ||||
| 2788 | + | // Rollback: verify old container is still running before restoring | |||
| 2789 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "old-container").Return(true, nil).Once() | |||
| 2790 | + | ||||
| 2791 | + | // Inspect after ready | |||
| 2792 | + | runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{ | |||
| 2793 | + | ID: "new-container", | |||
| 2794 | + | Name: "gordon-test.example.com-new", | |||
| 2795 | + | Status: "running", | |||
| 2796 | + | }, nil) | |||
| 2797 | + | ||||
| 2798 | + | // Publish event (happens during activateDeployedContainer) | |||
| 2799 | + | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil) | |||
| 2800 | + | ||||
| 2801 | + | // InvalidateTarget called TWICE: once during activate, once during rollback | |||
| 2802 | + | cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return().Times(2) | |||
| 2803 | + | ||||
| 2804 | + | // Cleanup failed new container during rollback | |||
| 2805 | + | runtime.EXPECT().StopContainer(mock.Anything, "new-container").Return(nil) | |||
| 2806 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "new-container", true).Return(nil) | |||
| 2807 | + | ||||
| 2808 | + | // StopContainer on old-container should NOT be called (old stays running) | |||
| 2809 | + | // (no expectation for StopContainer("old-container")) | |||
| 2810 | + | ||||
| 2811 | + | result, err := svc.Deploy(ctx, route) | |||
| 2812 | + | ||||
| 2813 | + | // Deploy returns the old (existing) container after rollback | |||
| 2814 | + | assert.NoError(t, err) | |||
| 2815 | + | assert.NotNil(t, result) | |||
| 2816 | + | assert.Equal(t, "old-container", result.ID, "should return old container after rollback") | |||
| 2817 | + | ||||
| 2818 | + | // Verify old container is restored as tracked | |||
| 2819 | + | trackedRB, existsRB := svc.Get(ctx, "test.example.com") | |||
| 2820 | + | assert.True(t, existsRB) | |||
| 2821 | + | assert.Equal(t, "old-container", trackedRB.ID, "old container should be restored after rollback") | |||
| 2822 | + | } | |||
| 2823 | + | ||||
| 2824 | + | // TestService_Deploy_StabilizationSuccess verifies that when the new container remains | |||
| 2825 | + | // healthy during the stabilization window, deployment proceeds normally and the old | |||
| 2826 | + | // container is finalized (stopped and removed). | |||
| 2827 | + | func TestService_Deploy_StabilizationSuccess(t *testing.T) { | |||
| 2828 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 2829 | + | envLoader := mocks.NewMockEnvLoader(t) | |||
| 2830 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 2831 | + | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | |||
| 2832 | + | ||||
| 2833 | + | config := Config{ | |||
| 2834 | + | ReadinessDelay: time.Millisecond, | |||
| 2835 | + | DrainDelay: time.Millisecond, | |||
| 2836 | + | DrainDelayConfigured: true, | |||
| 2837 | + | StabilizationDelay: time.Millisecond, | |||
| 2838 | + | } | |||
| 2839 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |||
| 2840 | + | svc.SetProxyCacheInvalidator(cacheInvalidator) | |||
| 2841 | + | ctx := testContext() | |||
| 2842 | + | ||||
| 2843 | + | // Pre-populate with existing container | |||
| 2844 | + | existing := &domain.Container{ | |||
| 2845 | + | ID: "old-container", | |||
| 2846 | + | Name: "gordon-test.example.com", | |||
| 2847 | + | Status: "running", | |||
| 2848 | + | } | |||
| 2849 | + | svc.containers["test.example.com"] = existing | |||
| 2850 | + | ||||
| 2851 | + | route := domain.Route{ | |||
| 2852 | + | Domain: "test.example.com", | |||
| 2853 | + | Image: "myapp:v2", | |||
| 2854 | + | } | |||
| 2855 | + | ||||
| 2856 | + | // Cleanup orphans | |||
| 2857 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | |||
| 2858 | + | ||||
| 2859 | + | // Image operations | |||
| 2860 | + | runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil) | |||
| 2861 | + | runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil) | |||
| 2862 | + | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil) | |||
| 2863 | + | ||||
| 2864 | + | // Environment | |||
| 2865 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil) | |||
| 2866 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil) | |||
| 2867 | + | ||||
| 2868 | + | // Create new container | |||
| 2869 | + | newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"} | |||
| 2870 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool { | |||
| 2871 | + | return cfg.Name == "gordon-test.example.com-new" | |||
| 2872 | + | })).Return(newContainer, nil) | |||
| 2873 | + | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | |||
| 2874 | + | ||||
| 2875 | + | // Readiness (2 calls) + stabilization check (1 call) — all return running | |||
| 2876 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3) | |||
| 2877 | + | ||||
| 2878 | + | // Inspect after ready | |||
| 2879 | + | runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{ | |||
| 2880 | + | ID: "new-container", | |||
| 2881 | + | Name: "gordon-test.example.com-new", | |||
| 2882 | + | Status: "running", | |||
| 2883 | + | }, nil) | |||
| 2884 | + | ||||
| 2885 | + | // Publish event | |||
| 2886 | + | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil) | |||
| 2887 | + | ||||
| 2888 | + | // Cache invalidation (once, during activate) | |||
| 2889 | + | cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return() | |||
| 2890 | + | ||||
| 2891 | + | // Old container finalized normally | |||
| 2892 | + | runtime.EXPECT().StopContainer(mock.Anything, "old-container").Return(nil) | |||
| 2893 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil) | |||
| 2894 | + | runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil) | |||
| 2895 | + | ||||
| 2896 | + | result, err := svc.Deploy(ctx, route) | |||
| 2897 | + | ||||
| 2898 | + | assert.NoError(t, err) | |||
| 2899 | + | assert.NotNil(t, result) | |||
| 2900 | + | assert.Equal(t, "new-container", result.ID, "should return new container on successful stabilization") | |||
| 2901 | + | ||||
| 2902 | + | // Verify new container is tracked | |||
| 2903 | + | trackedSS, existsSS := svc.Get(ctx, "test.example.com") | |||
| 2904 | + | assert.True(t, existsSS) | |||
| 2905 | + | assert.Equal(t, "new-container", trackedSS.ID) | |||
| 2906 | + | } | |||
| 2907 | + | ||||
| 2908 | + | // TestService_Deploy_ZeroDowntime_OldNeverStoppedBeforeNewReady verifies the strict | |||
| 2909 | + | // ordering invariant: StopContainer("old-container") must NEVER happen before | |||
| 2910 | + | // InspectContainer("new-container") completes (the last step of readiness). | |||
| 2911 | + | // Uses testify's NotBefore() to enforce mock call ordering. | |||
| 2912 | + | func TestService_Deploy_ZeroDowntime_OldNeverStoppedBeforeNewReady(t *testing.T) { | |||
| 2913 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 2914 | + | envLoader := mocks.NewMockEnvLoader(t) | |||
| 2915 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 2916 | + | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | |||
| 2917 | + | ||||
| 2918 | + | config := Config{ | |||
| 2919 | + | ReadinessDelay: time.Millisecond, | |||
| 2920 | + | DrainDelay: time.Millisecond, | |||
| 2921 | + | DrainDelayConfigured: true, | |||
| 2922 | + | StabilizationDelay: time.Millisecond, | |||
| 2923 | + | } | |||
| 2924 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |||
| 2925 | + | svc.SetProxyCacheInvalidator(cacheInvalidator) | |||
| 2926 | + | ctx := testContext() | |||
| 2927 | + | ||||
| 2928 | + | // Pre-populate with existing tracked container | |||
| 2929 | + | existingContainer := &domain.Container{ | |||
| 2930 | + | ID: "old-container", | |||
| 2931 | + | Name: "gordon-test.example.com", | |||
| 2932 | + | Status: "running", | |||
| 2933 | + | } | |||
| 2934 | + | svc.containers["test.example.com"] = existingContainer | |||
| 2935 | + | ||||
| 2936 | + | route := domain.Route{ | |||
| 2937 | + | Domain: "test.example.com", | |||
| 2938 | + | Image: "myapp:v2", | |||
| 2939 | + | } | |||
| 2940 | + | ||||
| 2941 | + | // Cleanup orphans | |||
| 2942 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | |||
| 2943 | + | ||||
| 2944 | + | // Image operations | |||
| 2945 | + | runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil) | |||
| 2946 | + | runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil) | |||
| 2947 | + | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil) | |||
| 2948 | + | ||||
| 2949 | + | // Environment | |||
| 2950 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil) | |||
| 2951 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil) | |||
| 2952 | + | ||||
| 2953 | + | // Create new container with -new suffix for zero-downtime | |||
| 2954 | + | newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"} | |||
| 2955 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool { | |||
| 2956 | + | return cfg.Name == "gordon-test.example.com-new" | |||
| 2957 | + | })).Return(newContainer, nil) | |||
| 2958 | + | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | |||
| 2959 | + | ||||
| 2960 | + | // Readiness: pollContainerRunning (1 call) + waitForReadyByDelay verification (1 call) | |||
| 2961 | + | // Stabilization check (1 call) | |||
| 2962 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3) | |||
| 2963 | + | ||||
| 2964 | + | // KEY ORDERING CONSTRAINT: InspectContainer("new-container") is the last step | |||
| 2965 | + | // of readiness inside createStartedContainer. StopContainer("old-container") | |||
| 2966 | + | // must happen AFTER this call completes. | |||
| 2967 | + | inspectCall := runtime.EXPECT().InspectContainer(mock.Anything, "new-container"). | |||
| 2968 | + | Return(&domain.Container{ | |||
| 2969 | + | ID: "new-container", | |||
| 2970 | + | Name: "gordon-test.example.com-new", | |||
| 2971 | + | Status: "running", | |||
| 2972 | + | }, nil) | |||
| 2973 | + | ||||
| 2974 | + | // Publish event | |||
| 2975 | + | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil) | |||
| 2976 | + | ||||
| 2977 | + | // Synchronous cache invalidation | |||
| 2978 | + | cacheInvalidator.EXPECT().InvalidateTarget(mock.Anything, "test.example.com").Return() | |||
| 2979 | + | ||||
| 2980 | + | // STRICT ORDERING: StopContainer on old container must NOT happen before | |||
| 2981 | + | // InspectContainer on new container (readiness completion) | |||
| 2982 | + | runtime.EXPECT().StopContainer(mock.Anything, "old-container"). | |||
| 2983 | + | Return(nil). | |||
| 2984 | + | NotBefore(inspectCall.Call) | |||
| 2985 | + | ||||
| 2986 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil) | |||
| 2987 | + | runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil) | |||
| 2988 | + | ||||
| 2989 | + | result, err := svc.Deploy(ctx, route) | |||
| 2990 | + | ||||
| 2991 | + | assert.NoError(t, err) | |||
| 2992 | + | assert.NotNil(t, result) | |||
| 2993 | + | assert.Equal(t, "new-container", result.ID) | |||
| 2994 | + | ||||
| 2995 | + | // Verify new container is tracked | |||
| 2996 | + | tracked, exists := svc.Get(ctx, "test.example.com") | |||
| 2997 | + | assert.True(t, exists) | |||
| 2998 | + | assert.Equal(t, "new-container", tracked.ID) | |||
| 2999 | + | } | |||
| 3000 | + | ||||
| 3001 | + | // TestService_Deploy_ZeroDowntime_ReadinessFailure_OldUntouched verifies that when a | |||
| 3002 | + | // new container fails readiness (IsContainerRunning returns false), the old container | |||
| 3003 | + | // is completely untouched: never stopped, never removed. Deploy returns an error and | |||
| 3004 | + | // the old container remains as the tracked container. | |||
| 3005 | + | func TestService_Deploy_ZeroDowntime_ReadinessFailure_OldUntouched(t *testing.T) { | |||
| 3006 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 3007 | + | envLoader := mocks.NewMockEnvLoader(t) | |||
| 3008 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 3009 | + | ||||
| 3010 | + | config := Config{ | |||
| 3011 | + | ReadinessDelay: time.Millisecond, | |||
| 3012 | + | DrainDelay: time.Millisecond, | |||
| 3013 | + | DrainDelayConfigured: true, | |||
| 3014 | + | StabilizationDelay: time.Millisecond, | |||
| 3015 | + | } | |||
| 3016 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |||
| 3017 | + | ctx := testContext() | |||
| 3018 | + | ||||
| 3019 | + | // Pre-populate with existing tracked container | |||
| 3020 | + | existingContainer := &domain.Container{ | |||
| 3021 | + | ID: "old-container", | |||
| 3022 | + | Name: "gordon-test.example.com", | |||
| 3023 | + | Status: "running", | |||
| 3024 | + | } | |||
| 3025 | + | svc.containers["test.example.com"] = existingContainer | |||
| 3026 | + | ||||
| 3027 | + | route := domain.Route{ | |||
| 3028 | + | Domain: "test.example.com", | |||
| 3029 | + | Image: "myapp:v2", | |||
| 3030 | + | } | |||
| 3031 | + | ||||
| 3032 | + | // Cleanup orphans | |||
| 3033 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | |||
| 3034 | + | ||||
| 3035 | + | // Image operations | |||
| 3036 | + | runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil) | |||
| 3037 | + | runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil) | |||
| 3038 | + | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil) | |||
| 3039 | + | ||||
| 3040 | + | // Environment | |||
| 3041 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil) | |||
| 3042 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil) | |||
| 3043 | + | ||||
| 3044 | + | // Create new container | |||
| 3045 | + | newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"} | |||
| 3046 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool { | |||
| 3047 | + | return cfg.Name == "gordon-test.example.com-new" | |||
| 3048 | + | })).Return(newContainer, nil) | |||
| 3049 | + | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | |||
| 3050 | + | ||||
| 3051 | + | // Readiness FAILS: pollContainerRunning succeeds (container starts) but | |||
| 3052 | + | // waitForReadyByDelay verification finds it not running. | |||
| 3053 | + | // pollContainerRunning: returns true (container initially starts) | |||
| 3054 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Once() | |||
| 3055 | + | // waitForReadyByDelay post-delay verification: returns false (container crashed) | |||
| 3056 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(false, nil).Once() | |||
| 3057 | + | // Recovery window poll: return error to fail fast instead of waiting 30s | |||
| 3058 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container"). | |||
| 3059 | + | Return(false, errors.New("container exited")).Once() | |||
| 3060 | + | ||||
| 3061 | + | // cleanupFailedContainer: stop and remove the failed new container | |||
| 3062 | + | runtime.EXPECT().StopContainer(mock.Anything, "new-container").Return(nil) | |||
| 3063 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "new-container", true).Return(nil) | |||
| 3064 | + | ||||
| 3065 | + | // NOTE: StopContainer("old-container") and RemoveContainer("old-container", true) | |||
| 3066 | + | // are intentionally NOT mocked. If either is called, testify will panic with | |||
| 3067 | + | // "unexpected method call" — verifying the old container is never touched. | |||
| 3068 | + | ||||
| 3069 | + | result, err := svc.Deploy(ctx, route) | |||
| 3070 | + | ||||
| 3071 | + | // Deploy should return an error (readiness failure) | |||
| 3072 | + | assert.Error(t, err) | |||
| 3073 | + | assert.Nil(t, result) | |||
| 3074 | + | assert.Contains(t, err.Error(), "readiness check") | |||
| 3075 | + | ||||
| 3076 | + | // Old container must still be tracked — completely untouched | |||
| 3077 | + | tracked, exists := svc.Get(ctx, "test.example.com") | |||
| 3078 | + | assert.True(t, exists, "old container should still be tracked after readiness failure") | |||
| 3079 | + | assert.Equal(t, "old-container", tracked.ID, "old container ID should be unchanged") | |||
| 3080 | + | } | |||
| 3081 | + | ||||
| 3082 | + | // TestService_Deploy_ZeroDowntime_NoExisting_FirstDeploy is a sanity check: | |||
| 3083 | + | // first deploy with no existing container should work normally without any | |||
| 3084 | + | // finalize/drain logic being triggered. | |||
| 3085 | + | func TestService_Deploy_ZeroDowntime_NoExisting_FirstDeploy(t *testing.T) { | |||
| 3086 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 3087 | + | envLoader := mocks.NewMockEnvLoader(t) | |||
| 3088 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 3089 | + | ||||
| 3090 | + | config := Config{ | |||
| 3091 | + | ReadinessDelay: time.Millisecond, | |||
| 3092 | + | DrainDelay: time.Millisecond, | |||
| 3093 | + | DrainDelayConfigured: true, | |||
| 3094 | + | StabilizationDelay: time.Millisecond, | |||
| 3095 | + | } | |||
| 3096 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |||
| 3097 | + | ctx := testContext() | |||
| 3098 | + | ||||
| 3099 | + | // NO existing container — this is a first deploy | |||
| 3100 | + | ||||
| 3101 | + | route := domain.Route{ | |||
| 3102 | + | Domain: "test.example.com", | |||
| 3103 | + | Image: "myapp:v2", | |||
| 3104 | + | } | |||
| 3105 | + | ||||
| 3106 | + | // resolveExistingContainer: no container in memory, runtime returns nothing | |||
| 3107 | + | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil) | |||
| 3108 | + | ||||
| 3109 | + | // Cleanup orphans — nothing found | |||
| 3110 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | |||
| 3111 | + | ||||
| 3112 | + | // Image operations | |||
| 3113 | + | runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil) | |||
| 3114 | + | runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil) | |||
| 3115 | + | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil) | |||
| 3116 | + | ||||
| 3117 | + | // Environment | |||
| 3118 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil) | |||
| 3119 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil) | |||
| 3120 | + | ||||
| 3121 | + | // Create container — canonical name (no -new suffix since no existing container) | |||
| 3122 | + | newContainer := &domain.Container{ID: "first-container", Name: "gordon-test.example.com", Status: "created"} | |||
| 3123 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool { | |||
| 3124 | + | return cfg.Name == "gordon-test.example.com" | |||
| 3125 | + | })).Return(newContainer, nil) | |||
| 3126 | + | runtime.EXPECT().StartContainer(mock.Anything, "first-container").Return(nil) | |||
| 3127 | + | ||||
| 3128 | + | // Readiness: pollContainerRunning (1 call) + waitForReadyByDelay verification (1 call) | |||
| 3129 | + | // No stabilization check for first deploy (hasExisting is false) | |||
| 3130 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "first-container").Return(true, nil).Times(2) | |||
| 3131 | + | ||||
| 3132 | + | // Inspect after readiness | |||
| 3133 | + | runtime.EXPECT().InspectContainer(mock.Anything, "first-container").Return(&domain.Container{ | |||
| 3134 | + | ID: "first-container", | |||
| 3135 | + | Name: "gordon-test.example.com", | |||
| 3136 | + | Status: "running", | |||
| 3137 | + | Ports: []int{8080}, | |||
| 3138 | + | }, nil) | |||
| 3139 | + | ||||
| 3140 | + | // Publish event | |||
| 3141 | + | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil) | |||
| 3142 | + | ||||
| 3143 | + | // NOTE: No StopContainer, RemoveContainer, or RenameContainer expectations — | |||
| 3144 | + | // first deploy has no previous container to finalize, and no stabilization check. | |||
| 3145 | + | ||||
| 3146 | + | result, err := svc.Deploy(ctx, route) | |||
| 3147 | + | ||||
| 3148 | + | assert.NoError(t, err) | |||
| 3149 | + | assert.NotNil(t, result) | |||
| 3150 | + | assert.Equal(t, "first-container", result.ID) | |||
| 3151 | + | assert.Equal(t, "running", result.Status) | |||
| 3152 | + | ||||
| 3153 | + | // Verify container is tracked | |||
| 3154 | + | tracked, exists := svc.Get(ctx, "test.example.com") | |||
| 3155 | + | assert.True(t, exists) | |||
| 3156 | + | assert.Equal(t, "first-container", tracked.ID) | |||
| 3157 | + | } | |||
M internal/usecase/proxy/service_test.go +7 -10
| @@ -539,14 +539,9 @@ func TestDrainRegistryInFlight(t *testing.T) { | |||
| 539 | 539 | ||
| 540 | 540 | svc.registryInFlight.Add(2) | |
| 541 | 541 | ||
| 542 | - | done := make(chan struct{}) | |
| 542 | + | result := make(chan bool, 1) | |
| 543 | 543 | go func() { | |
| 544 | - | drained := svc.DrainRegistryInFlight(50 * time.Millisecond) | |
| 545 | - | if !drained { | |
| 546 | - | // Signal failure via done channel by leaving it open — test will time out | |
| 547 | - | return | |
| 548 | - | } | |
| 549 | - | close(done) | |
| 544 | + | result <- svc.DrainRegistryInFlight(50 * time.Millisecond) | |
| 550 | 545 | }() | |
| 551 | 546 | ||
| 552 | 547 | time.Sleep(5 * time.Millisecond) | |
| @@ -554,10 +549,12 @@ func TestDrainRegistryInFlight(t *testing.T) { | |||
| 554 | 549 | svc.registryInFlight.Add(-1) | |
| 555 | 550 | ||
| 556 | 551 | select { | |
| 557 | - | case <-done: | |
| 558 | - | // good — drained cleanly | |
| 552 | + | case drained := <-result: | |
| 553 | + | if !drained { | |
| 554 | + | t.Fatalf("DrainRegistryInFlight returned false; expected true after all requests completed") | |
| 555 | + | } | |
| 559 | 556 | case <-time.After(500 * time.Millisecond): | |
| 560 | - | t.Fatal("DrainRegistryInFlight did not return true after requests completed") | |
| 557 | + | t.Fatal("DrainRegistryInFlight did not return within timeout") | |
| 561 | 558 | } | |
| 562 | 559 | } | |
| 563 | 560 | ||
| @@ -539,14 +539,9 @@ func TestDrainRegistryInFlight(t *testing.T) { | |||||
| 539 | 539 | ||||
| 540 | svc.registryInFlight.Add(2) | 540 | svc.registryInFlight.Add(2) | ||
| 541 | 541 | ||||
| 542 | - | done := make(chan struct{}) | 542 | + | result := make(chan bool, 1) |
| 543 | go func() { | 543 | go func() { | ||
| 544 | - | drained := svc.DrainRegistryInFlight(50 * time.Millisecond) | 544 | + | result <- svc.DrainRegistryInFlight(50 * time.Millisecond) |
| 545 | - | if !drained { | |||
| 546 | - | // Signal failure via done channel by leaving it open — test will time out | |||
| 547 | - | return | |||
| 548 | - | } | |||
| 549 | - | close(done) | |||
| 550 | }() | 545 | }() | ||
| 551 | 546 | ||||
| 552 | time.Sleep(5 * time.Millisecond) | 547 | time.Sleep(5 * time.Millisecond) | ||
| @@ -554,10 +549,12 @@ func TestDrainRegistryInFlight(t *testing.T) { | |||||
| 554 | svc.registryInFlight.Add(-1) | 549 | svc.registryInFlight.Add(-1) | ||
| 555 | 550 | ||||
| 556 | select { | 551 | select { | ||
| 557 | - | case <-done: | 552 | + | case drained := <-result: |
| 558 | - | // good — drained cleanly | 553 | + | if !drained { |
| 554 | + | t.Fatalf("DrainRegistryInFlight returned false; expected true after all requests completed") | |||
| 555 | + | } | |||
| 559 | case <-time.After(500 * time.Millisecond): | 556 | case <-time.After(500 * time.Millisecond): | ||
| 560 | - | t.Fatal("DrainRegistryInFlight did not return true after requests completed") | 557 | + | t.Fatal("DrainRegistryInFlight did not return within timeout") |
| 561 | } | 558 | } | ||
| 562 | } | 559 | } | ||
| 563 | 560 | ||||
M internal/usecase/registry/service.go +97 -11
| @@ -7,6 +7,8 @@ import ( | |||
| 7 | 7 | "fmt" | |
| 8 | 8 | "io" | |
| 9 | 9 | "strings" | |
| 10 | + | "sync" | |
| 11 | + | "time" | |
| 10 | 12 | ||
| 11 | 13 | "github.com/bnema/zerowrap" | |
| 12 | 14 | "go.opentelemetry.io/otel" | |
| @@ -24,10 +26,11 @@ var registryTracer = otel.Tracer("gordon.registry") | |||
| 24 | 26 | ||
| 25 | 27 | // Service implements the RegistryService interface. | |
| 26 | 28 | type Service struct { | |
| 27 | - | blobStorage out.BlobStorage | |
| 28 | - | manifestStorage out.ManifestStorage | |
| 29 | - | eventBus out.EventPublisher | |
| 30 | - | metrics *telemetry.Metrics | |
| 29 | + | blobStorage out.BlobStorage | |
| 30 | + | manifestStorage out.ManifestStorage | |
| 31 | + | eventBus out.EventPublisher | |
| 32 | + | metrics *telemetry.Metrics | |
| 33 | + | suppressedImages sync.Map // imageName -> *time.Timer | |
| 31 | 34 | } | |
| 32 | 35 | ||
| 33 | 36 | // SetMetrics sets the telemetry metrics for the registry service. | |
| @@ -48,6 +51,85 @@ func NewService( | |||
| 48 | 51 | } | |
| 49 | 52 | } | |
| 50 | 53 | ||
| 54 | + | // SuppressDeployEvent marks an image name to skip image.pushed events. | |
| 55 | + | // The suppression auto-expires after 2 minutes to prevent leaks. | |
| 56 | + | func (s *Service) SuppressDeployEvent(imageName string) { | |
| 57 | + | imageName = ExtractImageName(strings.TrimSpace(imageName)) | |
| 58 | + | if imageName == "" { | |
| 59 | + | return | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | var timer *time.Timer | |
| 63 | + | timer = time.AfterFunc(2*time.Minute, func() { | |
| 64 | + | // Only delete if this timer is still the current one, preventing an | |
| 65 | + | // old timer's callback from removing a newer suppression entry. | |
| 66 | + | if v, ok := s.suppressedImages.Load(imageName); ok && v == timer { | |
| 67 | + | s.suppressedImages.Delete(imageName) | |
| 68 | + | } | |
| 69 | + | }) | |
| 70 | + | if existing, loaded := s.suppressedImages.LoadOrStore(imageName, timer); loaded { | |
| 71 | + | existing.(*time.Timer).Stop() | |
| 72 | + | s.suppressedImages.Store(imageName, timer) | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // ClearDeployEventSuppression removes event suppression for an image. | |
| 77 | + | func (s *Service) ClearDeployEventSuppression(imageName string) { | |
| 78 | + | imageName = ExtractImageName(strings.TrimSpace(imageName)) | |
| 79 | + | if imageName == "" { | |
| 80 | + | return | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | if v, loaded := s.suppressedImages.LoadAndDelete(imageName); loaded { | |
| 84 | + | v.(*time.Timer).Stop() | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // ExtractImageName returns just the repository path of a container image | |
| 89 | + | // reference, stripping any registry host prefix, tag, and digest. | |
| 90 | + | // Examples: | |
| 91 | + | // | |
| 92 | + | // "reg.example.com/team/my-app:latest" -> "team/my-app" | |
| 93 | + | // "reg.example.com/my-app@sha256:abc" -> "my-app" | |
| 94 | + | // "my-app:v1.2" -> "my-app" | |
| 95 | + | func ExtractImageName(imageRef string) string { | |
| 96 | + | name := imageRef | |
| 97 | + | // Strip digest | |
| 98 | + | if idx := strings.Index(name, "@"); idx != -1 { | |
| 99 | + | name = name[:idx] | |
| 100 | + | } | |
| 101 | + | // Strip tag | |
| 102 | + | // Find the last colon, but only strip it if it comes after any slash | |
| 103 | + | // (to avoid treating a port number in the host as a tag). | |
| 104 | + | if idx := strings.LastIndex(name, ":"); idx != -1 { | |
| 105 | + | slashIdx := strings.LastIndex(name, "/") | |
| 106 | + | if idx > slashIdx { | |
| 107 | + | name = name[:idx] | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | // Strip registry host: if the first segment contains a dot or colon it is | |
| 111 | + | // a registry hostname; remove it. | |
| 112 | + | parts := strings.SplitN(name, "/", 2) | |
| 113 | + | if len(parts) == 2 { | |
| 114 | + | host := parts[0] | |
| 115 | + | if strings.ContainsAny(host, ".:") || host == "localhost" { | |
| 116 | + | name = parts[1] | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | return name | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | // IsDeployEventSuppressed checks if deploy events are suppressed for an image. | |
| 123 | + | func (s *Service) IsDeployEventSuppressed(imageName string) bool { | |
| 124 | + | imageName = ExtractImageName(strings.TrimSpace(imageName)) | |
| 125 | + | if imageName == "" { | |
| 126 | + | return false | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | _, exists := s.suppressedImages.Load(imageName) | |
| 130 | + | return exists | |
| 131 | + | } | |
| 132 | + | ||
| 51 | 133 | // GetManifest retrieves a manifest by name and reference. | |
| 52 | 134 | func (s *Service) GetManifest(ctx context.Context, name, reference string) (*domain.Manifest, error) { | |
| 53 | 135 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| @@ -110,13 +192,17 @@ func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (s | |||
| 110 | 192 | // A docker push sends manifests by both digest and tag; firing only on | |
| 111 | 193 | // tag prevents duplicate deploy triggers for the same push. | |
| 112 | 194 | if s.eventBus != nil && !strings.HasPrefix(manifest.Reference, "sha256:") { | |
| 113 | - | if err := s.eventBus.Publish(domain.EventImagePushed, domain.ImagePushedPayload{ | |
| 114 | - | Name: manifest.Name, | |
| 115 | - | Reference: manifest.Reference, | |
| 116 | - | Manifest: manifest.Data, | |
| 117 | - | Annotations: manifest.Annotations, | |
| 118 | - | }); err != nil { | |
| 119 | - | log.Warn().Err(err).Msg("failed to publish image pushed event") | |
| 195 | + | if s.IsDeployEventSuppressed(manifest.Name) { | |
| 196 | + | log.Info().Str("image", manifest.Name).Msg("skipping image.pushed event: CLI deploy intent active") | |
| 197 | + | } else { | |
| 198 | + | if err := s.eventBus.Publish(domain.EventImagePushed, domain.ImagePushedPayload{ | |
| 199 | + | Name: manifest.Name, | |
| 200 | + | Reference: manifest.Reference, | |
| 201 | + | Manifest: manifest.Data, | |
| 202 | + | Annotations: manifest.Annotations, | |
| 203 | + | }); err != nil { | |
| 204 | + | log.Warn().Err(err).Msg("failed to publish image pushed event") | |
| 205 | + | } | |
| 120 | 206 | } | |
| 121 | 207 | } | |
| 122 | 208 | ||
| @@ -7,6 +7,8 @@ import ( | |||||
| 7 | "fmt" | 7 | "fmt" | ||
| 8 | "io" | 8 | "io" | ||
| 9 | "strings" | 9 | "strings" | ||
| 10 | + | "sync" | |||
| 11 | + | "time" | |||
| 10 | 12 | ||||
| 11 | "github.com/bnema/zerowrap" | 13 | "github.com/bnema/zerowrap" | ||
| 12 | "go.opentelemetry.io/otel" | 14 | "go.opentelemetry.io/otel" | ||
| @@ -24,10 +26,11 @@ var registryTracer = otel.Tracer("gordon.registry") | |||||
| 24 | 26 | ||||
| 25 | // Service implements the RegistryService interface. | 27 | // Service implements the RegistryService interface. | ||
| 26 | type Service struct { | 28 | type Service struct { | ||
| 27 | - | blobStorage out.BlobStorage | 29 | + | blobStorage out.BlobStorage |
| 28 | - | manifestStorage out.ManifestStorage | 30 | + | manifestStorage out.ManifestStorage |
| 29 | - | eventBus out.EventPublisher | 31 | + | eventBus out.EventPublisher |
| 30 | - | metrics *telemetry.Metrics | 32 | + | metrics *telemetry.Metrics |
| 33 | + | suppressedImages sync.Map // imageName -> *time.Timer | |||
| 31 | } | 34 | } | ||
| 32 | 35 | ||||
| 33 | // SetMetrics sets the telemetry metrics for the registry service. | 36 | // SetMetrics sets the telemetry metrics for the registry service. | ||
| @@ -48,6 +51,85 @@ func NewService( | |||||
| 48 | } | 51 | } | ||
| 49 | } | 52 | } | ||
| 50 | 53 | ||||
| 54 | + | // SuppressDeployEvent marks an image name to skip image.pushed events. | |||
| 55 | + | // The suppression auto-expires after 2 minutes to prevent leaks. | |||
| 56 | + | func (s *Service) SuppressDeployEvent(imageName string) { | |||
| 57 | + | imageName = ExtractImageName(strings.TrimSpace(imageName)) | |||
| 58 | + | if imageName == "" { | |||
| 59 | + | return | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | var timer *time.Timer | |||
| 63 | + | timer = time.AfterFunc(2*time.Minute, func() { | |||
| 64 | + | // Only delete if this timer is still the current one, preventing an | |||
| 65 | + | // old timer's callback from removing a newer suppression entry. | |||
| 66 | + | if v, ok := s.suppressedImages.Load(imageName); ok && v == timer { | |||
| 67 | + | s.suppressedImages.Delete(imageName) | |||
| 68 | + | } | |||
| 69 | + | }) | |||
| 70 | + | if existing, loaded := s.suppressedImages.LoadOrStore(imageName, timer); loaded { | |||
| 71 | + | existing.(*time.Timer).Stop() | |||
| 72 | + | s.suppressedImages.Store(imageName, timer) | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | // ClearDeployEventSuppression removes event suppression for an image. | |||
| 77 | + | func (s *Service) ClearDeployEventSuppression(imageName string) { | |||
| 78 | + | imageName = ExtractImageName(strings.TrimSpace(imageName)) | |||
| 79 | + | if imageName == "" { | |||
| 80 | + | return | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | if v, loaded := s.suppressedImages.LoadAndDelete(imageName); loaded { | |||
| 84 | + | v.(*time.Timer).Stop() | |||
| 85 | + | } | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | // ExtractImageName returns just the repository path of a container image | |||
| 89 | + | // reference, stripping any registry host prefix, tag, and digest. | |||
| 90 | + | // Examples: | |||
| 91 | + | // | |||
| 92 | + | // "reg.example.com/team/my-app:latest" -> "team/my-app" | |||
| 93 | + | // "reg.example.com/my-app@sha256:abc" -> "my-app" | |||
| 94 | + | // "my-app:v1.2" -> "my-app" | |||
| 95 | + | func ExtractImageName(imageRef string) string { | |||
| 96 | + | name := imageRef | |||
| 97 | + | // Strip digest | |||
| 98 | + | if idx := strings.Index(name, "@"); idx != -1 { | |||
| 99 | + | name = name[:idx] | |||
| 100 | + | } | |||
| 101 | + | // Strip tag | |||
| 102 | + | // Find the last colon, but only strip it if it comes after any slash | |||
| 103 | + | // (to avoid treating a port number in the host as a tag). | |||
| 104 | + | if idx := strings.LastIndex(name, ":"); idx != -1 { | |||
| 105 | + | slashIdx := strings.LastIndex(name, "/") | |||
| 106 | + | if idx > slashIdx { | |||
| 107 | + | name = name[:idx] | |||
| 108 | + | } | |||
| 109 | + | } | |||
| 110 | + | // Strip registry host: if the first segment contains a dot or colon it is | |||
| 111 | + | // a registry hostname; remove it. | |||
| 112 | + | parts := strings.SplitN(name, "/", 2) | |||
| 113 | + | if len(parts) == 2 { | |||
| 114 | + | host := parts[0] | |||
| 115 | + | if strings.ContainsAny(host, ".:") || host == "localhost" { | |||
| 116 | + | name = parts[1] | |||
| 117 | + | } | |||
| 118 | + | } | |||
| 119 | + | return name | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | // IsDeployEventSuppressed checks if deploy events are suppressed for an image. | |||
| 123 | + | func (s *Service) IsDeployEventSuppressed(imageName string) bool { | |||
| 124 | + | imageName = ExtractImageName(strings.TrimSpace(imageName)) | |||
| 125 | + | if imageName == "" { | |||
| 126 | + | return false | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | _, exists := s.suppressedImages.Load(imageName) | |||
| 130 | + | return exists | |||
| 131 | + | } | |||
| 132 | + | ||||
| 51 | // GetManifest retrieves a manifest by name and reference. | 133 | // GetManifest retrieves a manifest by name and reference. | ||
| 52 | func (s *Service) GetManifest(ctx context.Context, name, reference string) (*domain.Manifest, error) { | 134 | func (s *Service) GetManifest(ctx context.Context, name, reference string) (*domain.Manifest, error) { | ||
| 53 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | 135 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | ||
| @@ -110,13 +192,17 @@ func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (s | |||||
| 110 | // A docker push sends manifests by both digest and tag; firing only on | 192 | // A docker push sends manifests by both digest and tag; firing only on | ||
| 111 | // tag prevents duplicate deploy triggers for the same push. | 193 | // tag prevents duplicate deploy triggers for the same push. | ||
| 112 | if s.eventBus != nil && !strings.HasPrefix(manifest.Reference, "sha256:") { | 194 | if s.eventBus != nil && !strings.HasPrefix(manifest.Reference, "sha256:") { | ||
| 113 | - | if err := s.eventBus.Publish(domain.EventImagePushed, domain.ImagePushedPayload{ | 195 | + | if s.IsDeployEventSuppressed(manifest.Name) { |
| 114 | - | Name: manifest.Name, | 196 | + | log.Info().Str("image", manifest.Name).Msg("skipping image.pushed event: CLI deploy intent active") |
| 115 | - | Reference: manifest.Reference, | 197 | + | } else { |
| 116 | - | Manifest: manifest.Data, | 198 | + | if err := s.eventBus.Publish(domain.EventImagePushed, domain.ImagePushedPayload{ |
| 117 | - | Annotations: manifest.Annotations, | 199 | + | Name: manifest.Name, |
| 118 | - | }); err != nil { | 200 | + | Reference: manifest.Reference, |
| 119 | - | log.Warn().Err(err).Msg("failed to publish image pushed event") | 201 | + | Manifest: manifest.Data, |
| 202 | + | Annotations: manifest.Annotations, | |||
| 203 | + | }); err != nil { | |||
| 204 | + | log.Warn().Err(err).Msg("failed to publish image pushed event") | |||
| 205 | + | } | |||
| 120 | } | 206 | } | ||
| 121 | } | 207 | } | ||
| 122 | 208 | ||||
M internal/usecase/registry/service_test.go +39 -0
| @@ -11,6 +11,7 @@ import ( | |||
| 11 | 11 | "github.com/bnema/zerowrap" | |
| 12 | 12 | "github.com/stretchr/testify/assert" | |
| 13 | 13 | "github.com/stretchr/testify/mock" | |
| 14 | + | "github.com/stretchr/testify/require" | |
| 14 | 15 | ||
| 15 | 16 | "github.com/bnema/gordon/internal/boundaries/out/mocks" | |
| 16 | 17 | "github.com/bnema/gordon/internal/domain" | |
| @@ -437,3 +438,41 @@ func TestService_ListRepositories_Error(t *testing.T) { | |||
| 437 | 438 | assert.Nil(t, repos) | |
| 438 | 439 | assert.Contains(t, err.Error(), "failed to list repositories") | |
| 439 | 440 | } | |
| 441 | + | ||
| 442 | + | func TestPutManifest_SkipsEventWhenDeployIntentSuppressed(t *testing.T) { | |
| 443 | + | blobStorage := mocks.NewMockBlobStorage(t) | |
| 444 | + | manifestStorage := mocks.NewMockManifestStorage(t) | |
| 445 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 446 | + | ||
| 447 | + | svc := NewService(blobStorage, manifestStorage, eventBus) | |
| 448 | + | ||
| 449 | + | svc.SuppressDeployEvent("my-app") | |
| 450 | + | ||
| 451 | + | manifestStorage.EXPECT().PutManifest("my-app", "latest", "application/vnd.oci.image.manifest.v1+json", mock.Anything).Return(nil) | |
| 452 | + | ||
| 453 | + | manifest := &domain.Manifest{ | |
| 454 | + | Name: "my-app", | |
| 455 | + | Reference: "latest", | |
| 456 | + | ContentType: "application/vnd.oci.image.manifest.v1+json", | |
| 457 | + | Data: []byte(`{"schemaVersion":2}`), | |
| 458 | + | } | |
| 459 | + | ||
| 460 | + | _, err := svc.PutManifest(context.Background(), manifest) | |
| 461 | + | require.NoError(t, err) | |
| 462 | + | ||
| 463 | + | eventBus.AssertNotCalled(t, "Publish", mock.Anything, mock.Anything) | |
| 464 | + | } | |
| 465 | + | ||
| 466 | + | func TestSuppressDeployEvent_ClearsCorrectly(t *testing.T) { | |
| 467 | + | blobStorage := mocks.NewMockBlobStorage(t) | |
| 468 | + | manifestStorage := mocks.NewMockManifestStorage(t) | |
| 469 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 470 | + | ||
| 471 | + | svc := NewService(blobStorage, manifestStorage, eventBus) | |
| 472 | + | ||
| 473 | + | svc.SuppressDeployEvent("my-app") | |
| 474 | + | assert.True(t, svc.IsDeployEventSuppressed("my-app")) | |
| 475 | + | ||
| 476 | + | svc.ClearDeployEventSuppression("my-app") | |
| 477 | + | assert.False(t, svc.IsDeployEventSuppressed("my-app")) | |
| 478 | + | } | |
| @@ -11,6 +11,7 @@ import ( | |||||
| 11 | "github.com/bnema/zerowrap" | 11 | "github.com/bnema/zerowrap" | ||
| 12 | "github.com/stretchr/testify/assert" | 12 | "github.com/stretchr/testify/assert" | ||
| 13 | "github.com/stretchr/testify/mock" | 13 | "github.com/stretchr/testify/mock" | ||
| 14 | + | "github.com/stretchr/testify/require" | |||
| 14 | 15 | ||||
| 15 | "github.com/bnema/gordon/internal/boundaries/out/mocks" | 16 | "github.com/bnema/gordon/internal/boundaries/out/mocks" | ||
| 16 | "github.com/bnema/gordon/internal/domain" | 17 | "github.com/bnema/gordon/internal/domain" | ||
| @@ -437,3 +438,41 @@ func TestService_ListRepositories_Error(t *testing.T) { | |||||
| 437 | assert.Nil(t, repos) | 438 | assert.Nil(t, repos) | ||
| 438 | assert.Contains(t, err.Error(), "failed to list repositories") | 439 | assert.Contains(t, err.Error(), "failed to list repositories") | ||
| 439 | } | 440 | } | ||
| 441 | + | ||||
| 442 | + | func TestPutManifest_SkipsEventWhenDeployIntentSuppressed(t *testing.T) { | |||
| 443 | + | blobStorage := mocks.NewMockBlobStorage(t) | |||
| 444 | + | manifestStorage := mocks.NewMockManifestStorage(t) | |||
| 445 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 446 | + | ||||
| 447 | + | svc := NewService(blobStorage, manifestStorage, eventBus) | |||
| 448 | + | ||||
| 449 | + | svc.SuppressDeployEvent("my-app") | |||
| 450 | + | ||||
| 451 | + | manifestStorage.EXPECT().PutManifest("my-app", "latest", "application/vnd.oci.image.manifest.v1+json", mock.Anything).Return(nil) | |||
| 452 | + | ||||
| 453 | + | manifest := &domain.Manifest{ | |||
| 454 | + | Name: "my-app", | |||
| 455 | + | Reference: "latest", | |||
| 456 | + | ContentType: "application/vnd.oci.image.manifest.v1+json", | |||
| 457 | + | Data: []byte(`{"schemaVersion":2}`), | |||
| 458 | + | } | |||
| 459 | + | ||||
| 460 | + | _, err := svc.PutManifest(context.Background(), manifest) | |||
| 461 | + | require.NoError(t, err) | |||
| 462 | + | ||||
| 463 | + | eventBus.AssertNotCalled(t, "Publish", mock.Anything, mock.Anything) | |||
| 464 | + | } | |||
| 465 | + | ||||
| 466 | + | func TestSuppressDeployEvent_ClearsCorrectly(t *testing.T) { | |||
| 467 | + | blobStorage := mocks.NewMockBlobStorage(t) | |||
| 468 | + | manifestStorage := mocks.NewMockManifestStorage(t) | |||
| 469 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 470 | + | ||||
| 471 | + | svc := NewService(blobStorage, manifestStorage, eventBus) | |||
| 472 | + | ||||
| 473 | + | svc.SuppressDeployEvent("my-app") | |||
| 474 | + | assert.True(t, svc.IsDeployEventSuppressed("my-app")) | |||
| 475 | + | ||||
| 476 | + | svc.ClearDeployEventSuppression("my-app") | |||
| 477 | + | assert.False(t, svc.IsDeployEventSuppressed("my-app")) | |||
| 478 | + | } | |||
M wiki/examples/production.md +0 -1
| @@ -58,7 +58,6 @@ preserve = true | |||
| 58 | 58 | [network_isolation] | |
| 59 | 59 | enabled = true | |
| 60 | 60 | network_prefix = "prod" | |
| 61 | - | dns_suffix = ".internal" | |
| 62 | 61 | ||
| 63 | 62 | # Application routes with pinned versions | |
| 64 | 63 | [routes] | |
| @@ -58,7 +58,6 @@ preserve = true | |||||
| 58 | [network_isolation] | 58 | [network_isolation] | ||
| 59 | enabled = true | 59 | enabled = true | ||
| 60 | network_prefix = "prod" | 60 | network_prefix = "prod" | ||
| 61 | - | dns_suffix = ".internal" | |||
| 62 | 61 | ||||
| 63 | # Application routes with pinned versions | 62 | # Application routes with pinned versions | ||
| 64 | [routes] | 63 | [routes] | ||