Merge pull request #144 from bnema/fix/attachment-dns-race
fix(dns): add attachment readiness checks to prevent EAI_AGAIN
3 files · +262 -53
M internal/app/run.go +27 -25
| @@ -1285,31 +1285,32 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc | |||
| 1285 | 1285 | } | |
| 1286 | 1286 | ||
| 1287 | 1287 | containerConfig := container.Config{ | |
| 1288 | - | RegistryAuthEnabled: cfg.Auth.Enabled, | |
| 1289 | - | RegistryDomain: cfg.Server.RegistryDomain, | |
| 1290 | - | RegistryPort: cfg.Server.RegistryPort, | |
| 1291 | - | InternalRegistryUsername: svc.internalRegUser, | |
| 1292 | - | InternalRegistryPassword: svc.internalRegPass, | |
| 1293 | - | PullPolicy: v.GetString("deploy.pull_policy"), | |
| 1294 | - | VolumeAutoCreate: v.GetBool("volumes.auto_create"), | |
| 1295 | - | VolumePrefix: v.GetString("volumes.prefix"), | |
| 1296 | - | VolumePreserve: v.GetBool("volumes.preserve"), | |
| 1297 | - | NetworkIsolation: v.GetBool("network_isolation.enabled"), | |
| 1298 | - | NetworkPrefix: v.GetString("network_isolation.network_prefix"), | |
| 1299 | - | NetworkGroups: svc.configSvc.GetNetworkGroups(), | |
| 1300 | - | Attachments: svc.configSvc.GetAttachments(), | |
| 1301 | - | ReadinessDelay: v.GetDuration("deploy.readiness_delay"), | |
| 1302 | - | ReadinessMode: v.GetString("deploy.readiness_mode"), | |
| 1303 | - | HealthTimeout: v.GetDuration("deploy.health_timeout"), | |
| 1304 | - | StabilizationDelay: v.GetDuration("deploy.stabilization_delay"), | |
| 1305 | - | TCPProbeTimeout: v.GetDuration("deploy.tcp_probe_timeout"), | |
| 1306 | - | HTTPProbeTimeout: v.GetDuration("deploy.http_probe_timeout"), | |
| 1307 | - | DrainDelay: v.GetDuration("deploy.drain_delay"), | |
| 1308 | - | DrainMode: v.GetString("deploy.drain_mode"), | |
| 1309 | - | DrainTimeout: v.GetDuration("deploy.drain_timeout"), | |
| 1310 | - | DefaultMemoryLimit: defaultMemoryLimit, | |
| 1311 | - | DefaultNanoCPUs: defaultNanoCPUs, | |
| 1312 | - | DefaultPidsLimit: cfg.Containers.PidsLimit, | |
| 1288 | + | RegistryAuthEnabled: cfg.Auth.Enabled, | |
| 1289 | + | RegistryDomain: cfg.Server.RegistryDomain, | |
| 1290 | + | RegistryPort: cfg.Server.RegistryPort, | |
| 1291 | + | InternalRegistryUsername: svc.internalRegUser, | |
| 1292 | + | InternalRegistryPassword: svc.internalRegPass, | |
| 1293 | + | PullPolicy: v.GetString("deploy.pull_policy"), | |
| 1294 | + | VolumeAutoCreate: v.GetBool("volumes.auto_create"), | |
| 1295 | + | VolumePrefix: v.GetString("volumes.prefix"), | |
| 1296 | + | VolumePreserve: v.GetBool("volumes.preserve"), | |
| 1297 | + | NetworkIsolation: v.GetBool("network_isolation.enabled"), | |
| 1298 | + | NetworkPrefix: v.GetString("network_isolation.network_prefix"), | |
| 1299 | + | NetworkGroups: svc.configSvc.GetNetworkGroups(), | |
| 1300 | + | Attachments: svc.configSvc.GetAttachments(), | |
| 1301 | + | ReadinessDelay: v.GetDuration("deploy.readiness_delay"), | |
| 1302 | + | ReadinessMode: v.GetString("deploy.readiness_mode"), | |
| 1303 | + | HealthTimeout: v.GetDuration("deploy.health_timeout"), | |
| 1304 | + | StabilizationDelay: v.GetDuration("deploy.stabilization_delay"), | |
| 1305 | + | TCPProbeTimeout: v.GetDuration("deploy.tcp_probe_timeout"), | |
| 1306 | + | HTTPProbeTimeout: v.GetDuration("deploy.http_probe_timeout"), | |
| 1307 | + | DrainDelay: v.GetDuration("deploy.drain_delay"), | |
| 1308 | + | DrainMode: v.GetString("deploy.drain_mode"), | |
| 1309 | + | DrainTimeout: v.GetDuration("deploy.drain_timeout"), | |
| 1310 | + | DefaultMemoryLimit: defaultMemoryLimit, | |
| 1311 | + | DefaultNanoCPUs: defaultNanoCPUs, | |
| 1312 | + | DefaultPidsLimit: cfg.Containers.PidsLimit, | |
| 1313 | + | AttachmentReadinessTimeout: v.GetDuration("deploy.attachment_readiness_timeout"), | |
| 1313 | 1314 | } | |
| 1314 | 1315 | if v.IsSet("deploy.drain_delay") { | |
| 1315 | 1316 | containerConfig.DrainDelayConfigured = true | |
| @@ -2511,6 +2512,7 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||
| 2511 | 2512 | v.SetDefault("deploy.stabilization_delay", "2s") | |
| 2512 | 2513 | v.SetDefault("deploy.tcp_probe_timeout", "30s") | |
| 2513 | 2514 | v.SetDefault("deploy.http_probe_timeout", "60s") | |
| 2515 | + | v.SetDefault("deploy.attachment_readiness_timeout", "30s") | |
| 2514 | 2516 | v.SetDefault("deploy.drain_mode", "auto") | |
| 2515 | 2517 | v.SetDefault("deploy.drain_timeout", "30s") | |
| 2516 | 2518 | ||
| @@ -1285,31 +1285,32 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc | |||||
| 1285 | } | 1285 | } | ||
| 1286 | 1286 | ||||
| 1287 | containerConfig := container.Config{ | 1287 | containerConfig := container.Config{ | ||
| 1288 | - | RegistryAuthEnabled: cfg.Auth.Enabled, | 1288 | + | RegistryAuthEnabled: cfg.Auth.Enabled, |
| 1289 | - | RegistryDomain: cfg.Server.RegistryDomain, | 1289 | + | RegistryDomain: cfg.Server.RegistryDomain, |
| 1290 | - | RegistryPort: cfg.Server.RegistryPort, | 1290 | + | RegistryPort: cfg.Server.RegistryPort, |
| 1291 | - | InternalRegistryUsername: svc.internalRegUser, | 1291 | + | InternalRegistryUsername: svc.internalRegUser, |
| 1292 | - | InternalRegistryPassword: svc.internalRegPass, | 1292 | + | InternalRegistryPassword: svc.internalRegPass, |
| 1293 | - | PullPolicy: v.GetString("deploy.pull_policy"), | 1293 | + | PullPolicy: v.GetString("deploy.pull_policy"), |
| 1294 | - | VolumeAutoCreate: v.GetBool("volumes.auto_create"), | 1294 | + | VolumeAutoCreate: v.GetBool("volumes.auto_create"), |
| 1295 | - | VolumePrefix: v.GetString("volumes.prefix"), | 1295 | + | VolumePrefix: v.GetString("volumes.prefix"), |
| 1296 | - | VolumePreserve: v.GetBool("volumes.preserve"), | 1296 | + | VolumePreserve: v.GetBool("volumes.preserve"), |
| 1297 | - | NetworkIsolation: v.GetBool("network_isolation.enabled"), | 1297 | + | NetworkIsolation: v.GetBool("network_isolation.enabled"), |
| 1298 | - | NetworkPrefix: v.GetString("network_isolation.network_prefix"), | 1298 | + | NetworkPrefix: v.GetString("network_isolation.network_prefix"), |
| 1299 | - | NetworkGroups: svc.configSvc.GetNetworkGroups(), | 1299 | + | NetworkGroups: svc.configSvc.GetNetworkGroups(), |
| 1300 | - | Attachments: svc.configSvc.GetAttachments(), | 1300 | + | Attachments: svc.configSvc.GetAttachments(), |
| 1301 | - | ReadinessDelay: v.GetDuration("deploy.readiness_delay"), | 1301 | + | ReadinessDelay: v.GetDuration("deploy.readiness_delay"), |
| 1302 | - | ReadinessMode: v.GetString("deploy.readiness_mode"), | 1302 | + | ReadinessMode: v.GetString("deploy.readiness_mode"), |
| 1303 | - | HealthTimeout: v.GetDuration("deploy.health_timeout"), | 1303 | + | HealthTimeout: v.GetDuration("deploy.health_timeout"), |
| 1304 | - | StabilizationDelay: v.GetDuration("deploy.stabilization_delay"), | 1304 | + | StabilizationDelay: v.GetDuration("deploy.stabilization_delay"), |
| 1305 | - | TCPProbeTimeout: v.GetDuration("deploy.tcp_probe_timeout"), | 1305 | + | TCPProbeTimeout: v.GetDuration("deploy.tcp_probe_timeout"), |
| 1306 | - | HTTPProbeTimeout: v.GetDuration("deploy.http_probe_timeout"), | 1306 | + | HTTPProbeTimeout: v.GetDuration("deploy.http_probe_timeout"), |
| 1307 | - | DrainDelay: v.GetDuration("deploy.drain_delay"), | 1307 | + | DrainDelay: v.GetDuration("deploy.drain_delay"), |
| 1308 | - | DrainMode: v.GetString("deploy.drain_mode"), | 1308 | + | DrainMode: v.GetString("deploy.drain_mode"), |
| 1309 | - | DrainTimeout: v.GetDuration("deploy.drain_timeout"), | 1309 | + | DrainTimeout: v.GetDuration("deploy.drain_timeout"), |
| 1310 | - | DefaultMemoryLimit: defaultMemoryLimit, | 1310 | + | DefaultMemoryLimit: defaultMemoryLimit, |
| 1311 | - | DefaultNanoCPUs: defaultNanoCPUs, | 1311 | + | DefaultNanoCPUs: defaultNanoCPUs, |
| 1312 | - | DefaultPidsLimit: cfg.Containers.PidsLimit, | 1312 | + | DefaultPidsLimit: cfg.Containers.PidsLimit, |
| 1313 | + | AttachmentReadinessTimeout: v.GetDuration("deploy.attachment_readiness_timeout"), | |||
| 1313 | } | 1314 | } | ||
| 1314 | if v.IsSet("deploy.drain_delay") { | 1315 | if v.IsSet("deploy.drain_delay") { | ||
| 1315 | containerConfig.DrainDelayConfigured = true | 1316 | containerConfig.DrainDelayConfigured = true | ||
| @@ -2511,6 +2512,7 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||||
| 2511 | v.SetDefault("deploy.stabilization_delay", "2s") | 2512 | v.SetDefault("deploy.stabilization_delay", "2s") | ||
| 2512 | v.SetDefault("deploy.tcp_probe_timeout", "30s") | 2513 | v.SetDefault("deploy.tcp_probe_timeout", "30s") | ||
| 2513 | v.SetDefault("deploy.http_probe_timeout", "60s") | 2514 | v.SetDefault("deploy.http_probe_timeout", "60s") | ||
| 2515 | + | v.SetDefault("deploy.attachment_readiness_timeout", "30s") | |||
| 2514 | v.SetDefault("deploy.drain_mode", "auto") | 2516 | v.SetDefault("deploy.drain_mode", "auto") | ||
| 2515 | v.SetDefault("deploy.drain_timeout", "30s") | 2517 | v.SetDefault("deploy.drain_timeout", "30s") | ||
| 2516 | 2518 | ||||
M internal/usecase/container/service.go +88 -28
| @@ -26,34 +26,35 @@ import ( | |||
| 26 | 26 | ||
| 27 | 27 | // Config holds configuration needed by the container service. | |
| 28 | 28 | type Config struct { | |
| 29 | - | RegistryAuthEnabled bool | |
| 30 | - | RegistryDomain string | |
| 31 | - | RegistryPort int | |
| 32 | - | ServiceTokenUsername string | |
| 33 | - | ServiceToken string | |
| 34 | - | InternalRegistryUsername string | |
| 35 | - | InternalRegistryPassword string | |
| 36 | - | PullPolicy string | |
| 37 | - | VolumeAutoCreate bool | |
| 38 | - | VolumePrefix string | |
| 39 | - | VolumePreserve bool | |
| 40 | - | NetworkIsolation bool | |
| 41 | - | NetworkPrefix string | |
| 42 | - | NetworkGroups map[string][]string | |
| 43 | - | Attachments map[string][]string | |
| 44 | - | ReadinessDelay time.Duration // Delay after container starts before considering it ready | |
| 45 | - | ReadinessMode string // Readiness strategy: auto, docker-health, delay | |
| 46 | - | HealthTimeout time.Duration // Max wait for health-based readiness | |
| 47 | - | DrainDelay time.Duration // Grace period after cache invalidation before stopping old container | |
| 48 | - | DrainDelayConfigured bool // True when deploy.drain_delay was explicitly configured | |
| 49 | - | DrainMode string // Drain strategy: auto, inflight, delay | |
| 50 | - | DrainTimeout time.Duration // Max wait for in-flight requests to drain | |
| 51 | - | StabilizationDelay time.Duration // Post-switch monitoring window (default 2s) | |
| 52 | - | TCPProbeTimeout time.Duration // TCP probe timeout (default 30s) | |
| 53 | - | HTTPProbeTimeout time.Duration // HTTP probe timeout (default 60s) | |
| 54 | - | DefaultMemoryLimit int64 // Default memory limit in bytes for containers (0 = no limit) | |
| 55 | - | DefaultNanoCPUs int64 // Default CPU quota in nanoseconds for containers (0 = no limit) | |
| 56 | - | DefaultPidsLimit int64 // Default max PIDs for containers (0 = no limit) | |
| 29 | + | RegistryAuthEnabled bool | |
| 30 | + | RegistryDomain string | |
| 31 | + | RegistryPort int | |
| 32 | + | ServiceTokenUsername string | |
| 33 | + | ServiceToken string | |
| 34 | + | InternalRegistryUsername string | |
| 35 | + | InternalRegistryPassword string | |
| 36 | + | PullPolicy string | |
| 37 | + | VolumeAutoCreate bool | |
| 38 | + | VolumePrefix string | |
| 39 | + | VolumePreserve bool | |
| 40 | + | NetworkIsolation bool | |
| 41 | + | NetworkPrefix string | |
| 42 | + | NetworkGroups map[string][]string | |
| 43 | + | Attachments map[string][]string | |
| 44 | + | ReadinessDelay time.Duration // Delay after container starts before considering it ready | |
| 45 | + | ReadinessMode string // Readiness strategy: auto, docker-health, delay | |
| 46 | + | HealthTimeout time.Duration // Max wait for health-based readiness | |
| 47 | + | DrainDelay time.Duration // Grace period after cache invalidation before stopping old container | |
| 48 | + | DrainDelayConfigured bool // True when deploy.drain_delay was explicitly configured | |
| 49 | + | DrainMode string // Drain strategy: auto, inflight, delay | |
| 50 | + | DrainTimeout time.Duration // Max wait for in-flight requests to drain | |
| 51 | + | StabilizationDelay time.Duration // Post-switch monitoring window (default 2s) | |
| 52 | + | TCPProbeTimeout time.Duration // TCP probe timeout (default 30s) | |
| 53 | + | HTTPProbeTimeout time.Duration // HTTP probe timeout (default 60s) | |
| 54 | + | AttachmentReadinessTimeout time.Duration // Max wait for attachment readiness (default 30s) | |
| 55 | + | DefaultMemoryLimit int64 // Default memory limit in bytes for containers (0 = no limit) | |
| 56 | + | DefaultNanoCPUs int64 // Default CPU quota in nanoseconds for containers (0 = no limit) | |
| 57 | + | DefaultPidsLimit int64 // Default max PIDs for containers (0 = no limit) | |
| 57 | 58 | } | |
| 58 | 59 | ||
| 59 | 60 | var tracer = otel.Tracer("gordon.container") | |
| @@ -2251,6 +2252,7 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||
| 2251 | 2252 | Image: actualImageRef, | |
| 2252 | 2253 | Name: containerName, | |
| 2253 | 2254 | Hostname: serviceName, // Internal DNS: postgres, redis, etc. | |
| 2255 | + | Aliases: []string{serviceName}, | |
| 2254 | 2256 | Ports: exposedPorts, | |
| 2255 | 2257 | Env: envVars, | |
| 2256 | 2258 | Volumes: volumes, | |
| @@ -2278,6 +2280,16 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||
| 2278 | 2280 | return log.WrapErr(err, "failed to start attachment container") | |
| 2279 | 2281 | } | |
| 2280 | 2282 | ||
| 2283 | + | // Wait for attachment to be ready before proceeding | |
| 2284 | + | if err := s.waitForAttachmentReady(ctx, container.ID, config); err != nil { | |
| 2285 | + | log.WrapErr(err, "attachment readiness check failed, cleaning up") | |
| 2286 | + | if stopErr := s.runtime.StopContainer(ctx, container.ID); stopErr != nil { | |
| 2287 | + | log.Warn().Err(stopErr).Str(zerowrap.FieldEntityID, container.ID).Msg("failed to stop unready attachment") | |
| 2288 | + | } | |
| 2289 | + | s.runtime.RemoveContainer(ctx, container.ID, true) | |
| 2290 | + | return fmt.Errorf("attachment %q not ready: %w", serviceName, err) | |
| 2291 | + | } | |
| 2292 | + | ||
| 2281 | 2293 | // Track attachment | |
| 2282 | 2294 | s.mu.Lock() | |
| 2283 | 2295 | s.attachments[ownerDomain] = append(s.attachments[ownerDomain], container.ID) | |
| @@ -2591,6 +2603,54 @@ func (s *Service) readinessCascade(ctx context.Context, containerID string, cont | |||
| 2591 | 2603 | return s.waitForReadyByDelay(ctx, containerID) | |
| 2592 | 2604 | } | |
| 2593 | 2605 | ||
| 2606 | + | // waitForAttachmentReady runs a reduced readiness cascade for attachments: | |
| 2607 | + | // Docker healthcheck → TCP probe → delay fallback. | |
| 2608 | + | // HTTP probes are skipped — attachments are typically databases/caches. | |
| 2609 | + | func (s *Service) waitForAttachmentReady(ctx context.Context, containerID string, containerConfig *domain.ContainerConfig) error { | |
| 2610 | + | if err := s.pollContainerRunning(ctx, containerID); err != nil { | |
| 2611 | + | return err | |
| 2612 | + | } | |
| 2613 | + | ||
| 2614 | + | log := zerowrap.FromCtx(ctx) | |
| 2615 | + | ||
| 2616 | + | s.mu.RLock() | |
| 2617 | + | cfg := s.config | |
| 2618 | + | s.mu.RUnlock() | |
| 2619 | + | ||
| 2620 | + | // 1. Docker healthcheck (if present) | |
| 2621 | + | _, hasHealthcheck, err := s.runtime.GetContainerHealthStatus(ctx, containerID) | |
| 2622 | + | if err != nil { | |
| 2623 | + | return err | |
| 2624 | + | } | |
| 2625 | + | if hasHealthcheck { | |
| 2626 | + | log.Info().Msg("attachment readiness: using Docker healthcheck") | |
| 2627 | + | timeout := cfg.HealthTimeout | |
| 2628 | + | if timeout == 0 { | |
| 2629 | + | timeout = 90 * time.Second | |
| 2630 | + | } | |
| 2631 | + | return s.waitForHealthy(ctx, containerID, timeout) | |
| 2632 | + | } | |
| 2633 | + | ||
| 2634 | + | // 2. TCP probe (if port info available) | |
| 2635 | + | if containerConfig != nil && len(containerConfig.Ports) > 0 { | |
| 2636 | + | ip, port, probeErr := s.resolveProbeEndpoint(ctx, containerID, containerConfig) | |
| 2637 | + | if probeErr == nil && ip != "" && port > 0 { | |
| 2638 | + | addr := fmt.Sprintf("%s:%d", ip, port) | |
| 2639 | + | timeout := cfg.AttachmentReadinessTimeout | |
| 2640 | + | if timeout == 0 { | |
| 2641 | + | timeout = 30 * time.Second | |
| 2642 | + | } | |
| 2643 | + | log.Info().Str("addr", addr).Dur("timeout", timeout).Msg("attachment readiness: using TCP probe") | |
| 2644 | + | return tcpProbe(ctx, addr, timeout) | |
| 2645 | + | } | |
| 2646 | + | log.Debug().Err(probeErr).Msg("attachment readiness: TCP probe skipped, could not resolve endpoint") | |
| 2647 | + | } | |
| 2648 | + | ||
| 2649 | + | // 3. Delay fallback | |
| 2650 | + | log.Info().Msg("attachment readiness: using delay fallback") | |
| 2651 | + | return s.waitForReadyByDelay(ctx, containerID) | |
| 2652 | + | } | |
| 2653 | + | ||
| 2594 | 2654 | // tryHTTPProbe attempts an HTTP probe if the gordon.health label is set. | |
| 2595 | 2655 | // Returns (true, err) if the probe was attempted, (false, nil) if skipped. | |
| 2596 | 2656 | func (s *Service) tryHTTPProbe(ctx context.Context, containerID string, containerConfig *domain.ContainerConfig, cfg Config) (bool, error) { | |
| @@ -26,34 +26,35 @@ import ( | |||||
| 26 | 26 | ||||
| 27 | // Config holds configuration needed by the container service. | 27 | // Config holds configuration needed by the container service. | ||
| 28 | type Config struct { | 28 | type Config struct { | ||
| 29 | - | RegistryAuthEnabled bool | 29 | + | RegistryAuthEnabled bool |
| 30 | - | RegistryDomain string | 30 | + | RegistryDomain string |
| 31 | - | RegistryPort int | 31 | + | RegistryPort int |
| 32 | - | ServiceTokenUsername string | 32 | + | ServiceTokenUsername string |
| 33 | - | ServiceToken string | 33 | + | ServiceToken string |
| 34 | - | InternalRegistryUsername string | 34 | + | InternalRegistryUsername string |
| 35 | - | InternalRegistryPassword string | 35 | + | InternalRegistryPassword string |
| 36 | - | PullPolicy string | 36 | + | PullPolicy string |
| 37 | - | VolumeAutoCreate bool | 37 | + | VolumeAutoCreate bool |
| 38 | - | VolumePrefix string | 38 | + | VolumePrefix string |
| 39 | - | VolumePreserve bool | 39 | + | VolumePreserve bool |
| 40 | - | NetworkIsolation bool | 40 | + | NetworkIsolation bool |
| 41 | - | NetworkPrefix string | 41 | + | NetworkPrefix string |
| 42 | - | NetworkGroups map[string][]string | 42 | + | NetworkGroups map[string][]string |
| 43 | - | Attachments map[string][]string | 43 | + | Attachments map[string][]string |
| 44 | - | ReadinessDelay time.Duration // Delay after container starts before considering it ready | 44 | + | ReadinessDelay time.Duration // Delay after container starts before considering it ready |
| 45 | - | ReadinessMode string // Readiness strategy: auto, docker-health, delay | 45 | + | ReadinessMode string // Readiness strategy: auto, docker-health, delay |
| 46 | - | HealthTimeout time.Duration // Max wait for health-based readiness | 46 | + | HealthTimeout time.Duration // Max wait for health-based readiness |
| 47 | - | DrainDelay time.Duration // Grace period after cache invalidation before stopping old container | 47 | + | DrainDelay time.Duration // Grace period after cache invalidation before stopping old container |
| 48 | - | DrainDelayConfigured bool // True when deploy.drain_delay was explicitly configured | 48 | + | DrainDelayConfigured bool // True when deploy.drain_delay was explicitly configured |
| 49 | - | DrainMode string // Drain strategy: auto, inflight, delay | 49 | + | DrainMode string // Drain strategy: auto, inflight, delay |
| 50 | - | DrainTimeout time.Duration // Max wait for in-flight requests to drain | 50 | + | DrainTimeout time.Duration // Max wait for in-flight requests to drain |
| 51 | - | StabilizationDelay time.Duration // Post-switch monitoring window (default 2s) | 51 | + | StabilizationDelay time.Duration // Post-switch monitoring window (default 2s) |
| 52 | - | TCPProbeTimeout time.Duration // TCP probe timeout (default 30s) | 52 | + | TCPProbeTimeout time.Duration // TCP probe timeout (default 30s) |
| 53 | - | HTTPProbeTimeout time.Duration // HTTP probe timeout (default 60s) | 53 | + | HTTPProbeTimeout time.Duration // HTTP probe timeout (default 60s) |
| 54 | - | DefaultMemoryLimit int64 // Default memory limit in bytes for containers (0 = no limit) | 54 | + | AttachmentReadinessTimeout time.Duration // Max wait for attachment readiness (default 30s) |
| 55 | - | DefaultNanoCPUs int64 // Default CPU quota in nanoseconds for containers (0 = no limit) | 55 | + | DefaultMemoryLimit int64 // Default memory limit in bytes for containers (0 = no limit) |
| 56 | - | DefaultPidsLimit int64 // Default max PIDs for containers (0 = no limit) | 56 | + | DefaultNanoCPUs int64 // Default CPU quota in nanoseconds for containers (0 = no limit) |
| 57 | + | DefaultPidsLimit int64 // Default max PIDs for containers (0 = no limit) | |||
| 57 | } | 58 | } | ||
| 58 | 59 | ||||
| 59 | var tracer = otel.Tracer("gordon.container") | 60 | var tracer = otel.Tracer("gordon.container") | ||
| @@ -2251,6 +2252,7 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||||
| 2251 | Image: actualImageRef, | 2252 | Image: actualImageRef, | ||
| 2252 | Name: containerName, | 2253 | Name: containerName, | ||
| 2253 | Hostname: serviceName, // Internal DNS: postgres, redis, etc. | 2254 | Hostname: serviceName, // Internal DNS: postgres, redis, etc. | ||
| 2255 | + | Aliases: []string{serviceName}, | |||
| 2254 | Ports: exposedPorts, | 2256 | Ports: exposedPorts, | ||
| 2255 | Env: envVars, | 2257 | Env: envVars, | ||
| 2256 | Volumes: volumes, | 2258 | Volumes: volumes, | ||
| @@ -2278,6 +2280,16 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||||
| 2278 | return log.WrapErr(err, "failed to start attachment container") | 2280 | return log.WrapErr(err, "failed to start attachment container") | ||
| 2279 | } | 2281 | } | ||
| 2280 | 2282 | ||||
| 2283 | + | // Wait for attachment to be ready before proceeding | |||
| 2284 | + | if err := s.waitForAttachmentReady(ctx, container.ID, config); err != nil { | |||
| 2285 | + | log.WrapErr(err, "attachment readiness check failed, cleaning up") | |||
| 2286 | + | if stopErr := s.runtime.StopContainer(ctx, container.ID); stopErr != nil { | |||
| 2287 | + | log.Warn().Err(stopErr).Str(zerowrap.FieldEntityID, container.ID).Msg("failed to stop unready attachment") | |||
| 2288 | + | } | |||
| 2289 | + | s.runtime.RemoveContainer(ctx, container.ID, true) | |||
| 2290 | + | return fmt.Errorf("attachment %q not ready: %w", serviceName, err) | |||
| 2291 | + | } | |||
| 2292 | + | ||||
| 2281 | // Track attachment | 2293 | // Track attachment | ||
| 2282 | s.mu.Lock() | 2294 | s.mu.Lock() | ||
| 2283 | s.attachments[ownerDomain] = append(s.attachments[ownerDomain], container.ID) | 2295 | s.attachments[ownerDomain] = append(s.attachments[ownerDomain], container.ID) | ||
| @@ -2591,6 +2603,54 @@ func (s *Service) readinessCascade(ctx context.Context, containerID string, cont | |||||
| 2591 | return s.waitForReadyByDelay(ctx, containerID) | 2603 | return s.waitForReadyByDelay(ctx, containerID) | ||
| 2592 | } | 2604 | } | ||
| 2593 | 2605 | ||||
| 2606 | + | // waitForAttachmentReady runs a reduced readiness cascade for attachments: | |||
| 2607 | + | // Docker healthcheck → TCP probe → delay fallback. | |||
| 2608 | + | // HTTP probes are skipped — attachments are typically databases/caches. | |||
| 2609 | + | func (s *Service) waitForAttachmentReady(ctx context.Context, containerID string, containerConfig *domain.ContainerConfig) error { | |||
| 2610 | + | if err := s.pollContainerRunning(ctx, containerID); err != nil { | |||
| 2611 | + | return err | |||
| 2612 | + | } | |||
| 2613 | + | ||||
| 2614 | + | log := zerowrap.FromCtx(ctx) | |||
| 2615 | + | ||||
| 2616 | + | s.mu.RLock() | |||
| 2617 | + | cfg := s.config | |||
| 2618 | + | s.mu.RUnlock() | |||
| 2619 | + | ||||
| 2620 | + | // 1. Docker healthcheck (if present) | |||
| 2621 | + | _, hasHealthcheck, err := s.runtime.GetContainerHealthStatus(ctx, containerID) | |||
| 2622 | + | if err != nil { | |||
| 2623 | + | return err | |||
| 2624 | + | } | |||
| 2625 | + | if hasHealthcheck { | |||
| 2626 | + | log.Info().Msg("attachment readiness: using Docker healthcheck") | |||
| 2627 | + | timeout := cfg.HealthTimeout | |||
| 2628 | + | if timeout == 0 { | |||
| 2629 | + | timeout = 90 * time.Second | |||
| 2630 | + | } | |||
| 2631 | + | return s.waitForHealthy(ctx, containerID, timeout) | |||
| 2632 | + | } | |||
| 2633 | + | ||||
| 2634 | + | // 2. TCP probe (if port info available) | |||
| 2635 | + | if containerConfig != nil && len(containerConfig.Ports) > 0 { | |||
| 2636 | + | ip, port, probeErr := s.resolveProbeEndpoint(ctx, containerID, containerConfig) | |||
| 2637 | + | if probeErr == nil && ip != "" && port > 0 { | |||
| 2638 | + | addr := fmt.Sprintf("%s:%d", ip, port) | |||
| 2639 | + | timeout := cfg.AttachmentReadinessTimeout | |||
| 2640 | + | if timeout == 0 { | |||
| 2641 | + | timeout = 30 * time.Second | |||
| 2642 | + | } | |||
| 2643 | + | log.Info().Str("addr", addr).Dur("timeout", timeout).Msg("attachment readiness: using TCP probe") | |||
| 2644 | + | return tcpProbe(ctx, addr, timeout) | |||
| 2645 | + | } | |||
| 2646 | + | log.Debug().Err(probeErr).Msg("attachment readiness: TCP probe skipped, could not resolve endpoint") | |||
| 2647 | + | } | |||
| 2648 | + | ||||
| 2649 | + | // 3. Delay fallback | |||
| 2650 | + | log.Info().Msg("attachment readiness: using delay fallback") | |||
| 2651 | + | return s.waitForReadyByDelay(ctx, containerID) | |||
| 2652 | + | } | |||
| 2653 | + | ||||
| 2594 | // tryHTTPProbe attempts an HTTP probe if the gordon.health label is set. | 2654 | // tryHTTPProbe attempts an HTTP probe if the gordon.health label is set. | ||
| 2595 | // Returns (true, err) if the probe was attempted, (false, nil) if skipped. | 2655 | // Returns (true, err) if the probe was attempted, (false, nil) if skipped. | ||
| 2596 | func (s *Service) tryHTTPProbe(ctx context.Context, containerID string, containerConfig *domain.ContainerConfig, cfg Config) (bool, error) { | 2656 | func (s *Service) tryHTTPProbe(ctx context.Context, containerID string, containerConfig *domain.ContainerConfig, cfg Config) (bool, error) { | ||
M internal/usecase/container/service_test.go +147 -0
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | 6 | "fmt" | |
| 7 | + | "net" | |
| 7 | 8 | "sync" | |
| 8 | 9 | "testing" | |
| 9 | 10 | "time" | |
| @@ -3835,3 +3836,149 @@ func TestLoadEnvironment_NoPreResolvedEnv(t *testing.T) { | |||
| 3835 | 3836 | assert.Contains(t, result, "DB_HOST=prod") | |
| 3836 | 3837 | assert.Contains(t, result, "FROM_DOCKERFILE=base") | |
| 3837 | 3838 | } | |
| 3839 | + | ||
| 3840 | + | func TestService_WaitForAttachmentReady_TCPProbe(t *testing.T) { | |
| 3841 | + | // Start a real TCP listener | |
| 3842 | + | ln, err := net.Listen("tcp", "127.0.0.1:0") | |
| 3843 | + | require.NoError(t, err) | |
| 3844 | + | defer ln.Close() | |
| 3845 | + | go func() { | |
| 3846 | + | for { | |
| 3847 | + | conn, err := ln.Accept() | |
| 3848 | + | if err != nil { | |
| 3849 | + | return | |
| 3850 | + | } | |
| 3851 | + | conn.Close() | |
| 3852 | + | } | |
| 3853 | + | }() | |
| 3854 | + | addr := ln.Addr().(*net.TCPAddr) | |
| 3855 | + | ||
| 3856 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 3857 | + | svc := NewService(runtime, nil, nil, nil, Config{ | |
| 3858 | + | AttachmentReadinessTimeout: 5 * time.Second, | |
| 3859 | + | }, nil) | |
| 3860 | + | ||
| 3861 | + | ctx := testContext() | |
| 3862 | + | containerID := "attachment-1" | |
| 3863 | + | containerConfig := &domain.ContainerConfig{ | |
| 3864 | + | Ports: []int{5432}, | |
| 3865 | + | } | |
| 3866 | + | ||
| 3867 | + | // pollContainerRunning | |
| 3868 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once() | |
| 3869 | + | // No healthcheck | |
| 3870 | + | runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once() | |
| 3871 | + | // Resolve endpoint to our real listener | |
| 3872 | + | runtime.EXPECT().GetContainerNetworkInfo(mock.Anything, containerID).Return(addr.IP.String(), addr.Port, nil).Once() | |
| 3873 | + | runtime.EXPECT().GetContainerPort(mock.Anything, containerID, addr.Port).Return(addr.Port, nil).Once() | |
| 3874 | + | ||
| 3875 | + | err = svc.waitForAttachmentReady(ctx, containerID, containerConfig) | |
| 3876 | + | assert.NoError(t, err) | |
| 3877 | + | } | |
| 3878 | + | ||
| 3879 | + | func TestService_WaitForAttachmentReady_Timeout(t *testing.T) { | |
| 3880 | + | // Reserve a port with no listener | |
| 3881 | + | ln, err := net.Listen("tcp", "127.0.0.1:0") | |
| 3882 | + | require.NoError(t, err) | |
| 3883 | + | addr := ln.Addr().(*net.TCPAddr) | |
| 3884 | + | ln.Close() // Close so nothing listens | |
| 3885 | + | ||
| 3886 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 3887 | + | svc := NewService(runtime, nil, nil, nil, Config{ | |
| 3888 | + | AttachmentReadinessTimeout: 300 * time.Millisecond, | |
| 3889 | + | }, nil) | |
| 3890 | + | ||
| 3891 | + | ctx := testContext() | |
| 3892 | + | containerID := "attachment-1" | |
| 3893 | + | containerConfig := &domain.ContainerConfig{ | |
| 3894 | + | Ports: []int{5432}, | |
| 3895 | + | } | |
| 3896 | + | ||
| 3897 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once() | |
| 3898 | + | runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once() | |
| 3899 | + | runtime.EXPECT().GetContainerNetworkInfo(mock.Anything, containerID).Return(addr.IP.String(), addr.Port, nil).Once() | |
| 3900 | + | runtime.EXPECT().GetContainerPort(mock.Anything, containerID, addr.Port).Return(addr.Port, nil).Once() | |
| 3901 | + | ||
| 3902 | + | err = svc.waitForAttachmentReady(ctx, containerID, containerConfig) | |
| 3903 | + | assert.Error(t, err) | |
| 3904 | + | assert.Contains(t, err.Error(), "TCP probe timeout") | |
| 3905 | + | } | |
| 3906 | + | ||
| 3907 | + | func TestService_WaitForAttachmentReady_FallsBackToDelay(t *testing.T) { | |
| 3908 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 3909 | + | svc := NewService(runtime, nil, nil, nil, Config{ | |
| 3910 | + | ReadinessDelay: time.Millisecond, | |
| 3911 | + | }, nil) | |
| 3912 | + | ||
| 3913 | + | ctx := testContext() | |
| 3914 | + | containerID := "attachment-1" | |
| 3915 | + | // No ports → skip TCP probe → fall back to delay | |
| 3916 | + | containerConfig := &domain.ContainerConfig{} | |
| 3917 | + | ||
| 3918 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once() | |
| 3919 | + | runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once() | |
| 3920 | + | // Delay fallback checks IsContainerRunning again | |
| 3921 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once() | |
| 3922 | + | ||
| 3923 | + | err := svc.waitForAttachmentReady(ctx, containerID, containerConfig) | |
| 3924 | + | assert.NoError(t, err) | |
| 3925 | + | } | |
| 3926 | + | ||
| 3927 | + | func TestDeployAttachedService_SetsAliasOnContainerConfig(t *testing.T) { | |
| 3928 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 3929 | + | envLoader := mocks.NewMockEnvLoader(t) | |
| 3930 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 3931 | + | ||
| 3932 | + | svc := NewService(runtime, envLoader, eventBus, nil, testMinDelayConfig(), nil) | |
| 3933 | + | ctx := testContext() | |
| 3934 | + | ||
| 3935 | + | ownerDomain := "app.example.com" | |
| 3936 | + | serviceImage := "postgres:16" | |
| 3937 | + | networkName := "gordon-net" | |
| 3938 | + | containerName := fmt.Sprintf("gordon-%s-postgres", domain.SanitizeDomainForContainer(ownerDomain)) | |
| 3939 | + | ||
| 3940 | + | // resolveExistingAttachment: findContainerByName for new name, then legacy name — both miss | |
| 3941 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Times(2) | |
| 3942 | + | ||
| 3943 | + | // ensureImage: pullRefForDeploy returns imageRef unchanged (no registry configured); | |
| 3944 | + | // ensureLocalImage checks ListImages and finds it locally. | |
| 3945 | + | runtime.EXPECT().ListImages(mock.Anything).Return([]string{"postgres:16"}, nil) | |
| 3946 | + | ||
| 3947 | + | // GetImageExposedPorts | |
| 3948 | + | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "postgres:16").Return([]int{5432}, nil) | |
| 3949 | + | ||
| 3950 | + | // loadEnvironment | |
| 3951 | + | envLoader.EXPECT().LoadEnv(mock.Anything, containerName).Return([]string{}, nil) | |
| 3952 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, "postgres:16").Return([]string{}, nil) | |
| 3953 | + | ||
| 3954 | + | // setupVolumes: VolumeAutoCreate is false (default), so no volume calls needed | |
| 3955 | + | ||
| 3956 | + | // CreateContainer — capture the config and verify Aliases | |
| 3957 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")). | |
| 3958 | + | Run(func(_ context.Context, cfg *domain.ContainerConfig) { | |
| 3959 | + | assert.Equal(t, []string{"postgres"}, cfg.Aliases, "attachment container must have network aliases for DNS resolution") | |
| 3960 | + | assert.Equal(t, "postgres", cfg.Hostname) | |
| 3961 | + | assert.Equal(t, networkName, cfg.NetworkMode) | |
| 3962 | + | }). | |
| 3963 | + | Return(&domain.Container{ID: "pg-container-1", Name: containerName}, nil) | |
| 3964 | + | ||
| 3965 | + | // StartContainer | |
| 3966 | + | runtime.EXPECT().StartContainer(mock.Anything, "pg-container-1").Return(nil) | |
| 3967 | + | ||
| 3968 | + | // waitForAttachmentReady: pollContainerRunning | |
| 3969 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "pg-container-1").Return(true, nil).Times(2) | |
| 3970 | + | // No Docker healthcheck | |
| 3971 | + | runtime.EXPECT().GetContainerHealthStatus(mock.Anything, "pg-container-1").Return("", false, nil) | |
| 3972 | + | // TCP probe: resolve endpoint fails -> falls back to delay | |
| 3973 | + | runtime.EXPECT().GetContainerNetworkInfo(mock.Anything, "pg-container-1").Return("", 0, errors.New("no network")) | |
| 3974 | + | ||
| 3975 | + | err := svc.deployAttachedService(ctx, ownerDomain, serviceImage, networkName) | |
| 3976 | + | require.NoError(t, err) | |
| 3977 | + | ||
| 3978 | + | // Verify the attachment is tracked | |
| 3979 | + | svc.mu.RLock() | |
| 3980 | + | attachIDs := svc.attachments[ownerDomain] | |
| 3981 | + | svc.mu.RUnlock() | |
| 3982 | + | require.Len(t, attachIDs, 1) | |
| 3983 | + | assert.Equal(t, "pg-container-1", attachIDs[0]) | |
| 3984 | + | } | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "errors" | 5 | "errors" | ||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | + | "net" | |||
| 7 | "sync" | 8 | "sync" | ||
| 8 | "testing" | 9 | "testing" | ||
| 9 | "time" | 10 | "time" | ||
| @@ -3835,3 +3836,149 @@ func TestLoadEnvironment_NoPreResolvedEnv(t *testing.T) { | |||||
| 3835 | assert.Contains(t, result, "DB_HOST=prod") | 3836 | assert.Contains(t, result, "DB_HOST=prod") | ||
| 3836 | assert.Contains(t, result, "FROM_DOCKERFILE=base") | 3837 | assert.Contains(t, result, "FROM_DOCKERFILE=base") | ||
| 3837 | } | 3838 | } | ||
| 3839 | + | ||||
| 3840 | + | func TestService_WaitForAttachmentReady_TCPProbe(t *testing.T) { | |||
| 3841 | + | // Start a real TCP listener | |||
| 3842 | + | ln, err := net.Listen("tcp", "127.0.0.1:0") | |||
| 3843 | + | require.NoError(t, err) | |||
| 3844 | + | defer ln.Close() | |||
| 3845 | + | go func() { | |||
| 3846 | + | for { | |||
| 3847 | + | conn, err := ln.Accept() | |||
| 3848 | + | if err != nil { | |||
| 3849 | + | return | |||
| 3850 | + | } | |||
| 3851 | + | conn.Close() | |||
| 3852 | + | } | |||
| 3853 | + | }() | |||
| 3854 | + | addr := ln.Addr().(*net.TCPAddr) | |||
| 3855 | + | ||||
| 3856 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 3857 | + | svc := NewService(runtime, nil, nil, nil, Config{ | |||
| 3858 | + | AttachmentReadinessTimeout: 5 * time.Second, | |||
| 3859 | + | }, nil) | |||
| 3860 | + | ||||
| 3861 | + | ctx := testContext() | |||
| 3862 | + | containerID := "attachment-1" | |||
| 3863 | + | containerConfig := &domain.ContainerConfig{ | |||
| 3864 | + | Ports: []int{5432}, | |||
| 3865 | + | } | |||
| 3866 | + | ||||
| 3867 | + | // pollContainerRunning | |||
| 3868 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once() | |||
| 3869 | + | // No healthcheck | |||
| 3870 | + | runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once() | |||
| 3871 | + | // Resolve endpoint to our real listener | |||
| 3872 | + | runtime.EXPECT().GetContainerNetworkInfo(mock.Anything, containerID).Return(addr.IP.String(), addr.Port, nil).Once() | |||
| 3873 | + | runtime.EXPECT().GetContainerPort(mock.Anything, containerID, addr.Port).Return(addr.Port, nil).Once() | |||
| 3874 | + | ||||
| 3875 | + | err = svc.waitForAttachmentReady(ctx, containerID, containerConfig) | |||
| 3876 | + | assert.NoError(t, err) | |||
| 3877 | + | } | |||
| 3878 | + | ||||
| 3879 | + | func TestService_WaitForAttachmentReady_Timeout(t *testing.T) { | |||
| 3880 | + | // Reserve a port with no listener | |||
| 3881 | + | ln, err := net.Listen("tcp", "127.0.0.1:0") | |||
| 3882 | + | require.NoError(t, err) | |||
| 3883 | + | addr := ln.Addr().(*net.TCPAddr) | |||
| 3884 | + | ln.Close() // Close so nothing listens | |||
| 3885 | + | ||||
| 3886 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 3887 | + | svc := NewService(runtime, nil, nil, nil, Config{ | |||
| 3888 | + | AttachmentReadinessTimeout: 300 * time.Millisecond, | |||
| 3889 | + | }, nil) | |||
| 3890 | + | ||||
| 3891 | + | ctx := testContext() | |||
| 3892 | + | containerID := "attachment-1" | |||
| 3893 | + | containerConfig := &domain.ContainerConfig{ | |||
| 3894 | + | Ports: []int{5432}, | |||
| 3895 | + | } | |||
| 3896 | + | ||||
| 3897 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once() | |||
| 3898 | + | runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once() | |||
| 3899 | + | runtime.EXPECT().GetContainerNetworkInfo(mock.Anything, containerID).Return(addr.IP.String(), addr.Port, nil).Once() | |||
| 3900 | + | runtime.EXPECT().GetContainerPort(mock.Anything, containerID, addr.Port).Return(addr.Port, nil).Once() | |||
| 3901 | + | ||||
| 3902 | + | err = svc.waitForAttachmentReady(ctx, containerID, containerConfig) | |||
| 3903 | + | assert.Error(t, err) | |||
| 3904 | + | assert.Contains(t, err.Error(), "TCP probe timeout") | |||
| 3905 | + | } | |||
| 3906 | + | ||||
| 3907 | + | func TestService_WaitForAttachmentReady_FallsBackToDelay(t *testing.T) { | |||
| 3908 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 3909 | + | svc := NewService(runtime, nil, nil, nil, Config{ | |||
| 3910 | + | ReadinessDelay: time.Millisecond, | |||
| 3911 | + | }, nil) | |||
| 3912 | + | ||||
| 3913 | + | ctx := testContext() | |||
| 3914 | + | containerID := "attachment-1" | |||
| 3915 | + | // No ports → skip TCP probe → fall back to delay | |||
| 3916 | + | containerConfig := &domain.ContainerConfig{} | |||
| 3917 | + | ||||
| 3918 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once() | |||
| 3919 | + | runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once() | |||
| 3920 | + | // Delay fallback checks IsContainerRunning again | |||
| 3921 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once() | |||
| 3922 | + | ||||
| 3923 | + | err := svc.waitForAttachmentReady(ctx, containerID, containerConfig) | |||
| 3924 | + | assert.NoError(t, err) | |||
| 3925 | + | } | |||
| 3926 | + | ||||
| 3927 | + | func TestDeployAttachedService_SetsAliasOnContainerConfig(t *testing.T) { | |||
| 3928 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 3929 | + | envLoader := mocks.NewMockEnvLoader(t) | |||
| 3930 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 3931 | + | ||||
| 3932 | + | svc := NewService(runtime, envLoader, eventBus, nil, testMinDelayConfig(), nil) | |||
| 3933 | + | ctx := testContext() | |||
| 3934 | + | ||||
| 3935 | + | ownerDomain := "app.example.com" | |||
| 3936 | + | serviceImage := "postgres:16" | |||
| 3937 | + | networkName := "gordon-net" | |||
| 3938 | + | containerName := fmt.Sprintf("gordon-%s-postgres", domain.SanitizeDomainForContainer(ownerDomain)) | |||
| 3939 | + | ||||
| 3940 | + | // resolveExistingAttachment: findContainerByName for new name, then legacy name — both miss | |||
| 3941 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Times(2) | |||
| 3942 | + | ||||
| 3943 | + | // ensureImage: pullRefForDeploy returns imageRef unchanged (no registry configured); | |||
| 3944 | + | // ensureLocalImage checks ListImages and finds it locally. | |||
| 3945 | + | runtime.EXPECT().ListImages(mock.Anything).Return([]string{"postgres:16"}, nil) | |||
| 3946 | + | ||||
| 3947 | + | // GetImageExposedPorts | |||
| 3948 | + | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "postgres:16").Return([]int{5432}, nil) | |||
| 3949 | + | ||||
| 3950 | + | // loadEnvironment | |||
| 3951 | + | envLoader.EXPECT().LoadEnv(mock.Anything, containerName).Return([]string{}, nil) | |||
| 3952 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, "postgres:16").Return([]string{}, nil) | |||
| 3953 | + | ||||
| 3954 | + | // setupVolumes: VolumeAutoCreate is false (default), so no volume calls needed | |||
| 3955 | + | ||||
| 3956 | + | // CreateContainer — capture the config and verify Aliases | |||
| 3957 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")). | |||
| 3958 | + | Run(func(_ context.Context, cfg *domain.ContainerConfig) { | |||
| 3959 | + | assert.Equal(t, []string{"postgres"}, cfg.Aliases, "attachment container must have network aliases for DNS resolution") | |||
| 3960 | + | assert.Equal(t, "postgres", cfg.Hostname) | |||
| 3961 | + | assert.Equal(t, networkName, cfg.NetworkMode) | |||
| 3962 | + | }). | |||
| 3963 | + | Return(&domain.Container{ID: "pg-container-1", Name: containerName}, nil) | |||
| 3964 | + | ||||
| 3965 | + | // StartContainer | |||
| 3966 | + | runtime.EXPECT().StartContainer(mock.Anything, "pg-container-1").Return(nil) | |||
| 3967 | + | ||||
| 3968 | + | // waitForAttachmentReady: pollContainerRunning | |||
| 3969 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "pg-container-1").Return(true, nil).Times(2) | |||
| 3970 | + | // No Docker healthcheck | |||
| 3971 | + | runtime.EXPECT().GetContainerHealthStatus(mock.Anything, "pg-container-1").Return("", false, nil) | |||
| 3972 | + | // TCP probe: resolve endpoint fails -> falls back to delay | |||
| 3973 | + | runtime.EXPECT().GetContainerNetworkInfo(mock.Anything, "pg-container-1").Return("", 0, errors.New("no network")) | |||
| 3974 | + | ||||
| 3975 | + | err := svc.deployAttachedService(ctx, ownerDomain, serviceImage, networkName) | |||
| 3976 | + | require.NoError(t, err) | |||
| 3977 | + | ||||
| 3978 | + | // Verify the attachment is tracked | |||
| 3979 | + | svc.mu.RLock() | |||
| 3980 | + | attachIDs := svc.attachments[ownerDomain] | |||
| 3981 | + | svc.mu.RUnlock() | |||
| 3982 | + | require.Len(t, attachIDs, 1) | |||
| 3983 | + | assert.Equal(t, "pg-container-1", attachIDs[0]) | |||
| 3984 | + | } | |||