detached 15d9fe3

Merge pull request #144 from bnema/fix/attachment-dns-race

fix(dns): add attachment readiness checks to prevent EAI_AGAIN
commit
15d9fe34c5567d7c234bf70229a01a2750b9f35e
author
bnema · · 2026-03-23 19:53 UTC
committer
GitHub · · 2026-03-23 19:53 UTC
merge of
63e7f3a + d3fec5b · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v2.30.0-pre4
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
12851285 }
12861286
12871287 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"),
13131314 }
13141315 if v.IsSet("deploy.drain_delay") {
13151316 containerConfig.DrainDelayConfigured = true
@@ -2511,6 +2512,7 @@ func loadConfig(v *viper.Viper, configPath string) error {
25112512 v.SetDefault("deploy.stabilization_delay", "2s")
25122513 v.SetDefault("deploy.tcp_probe_timeout", "30s")
25132514 v.SetDefault("deploy.http_probe_timeout", "60s")
2515+ v.SetDefault("deploy.attachment_readiness_timeout", "30s")
25142516 v.SetDefault("deploy.drain_mode", "auto")
25152517 v.SetDefault("deploy.drain_timeout", "30s")
25162518
@@ -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 = true1316 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 (
2626
2727 // Config holds configuration needed by the container service.
2828 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)
5758 }
5859
5960 var tracer = otel.Tracer("gordon.container")
@@ -2251,6 +2252,7 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
22512252 Image: actualImageRef,
22522253 Name: containerName,
22532254 Hostname: serviceName, // Internal DNS: postgres, redis, etc.
2255+ Aliases: []string{serviceName},
22542256 Ports: exposedPorts,
22552257 Env: envVars,
22562258 Volumes: volumes,
@@ -2278,6 +2280,16 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
22782280 return log.WrapErr(err, "failed to start attachment container")
22792281 }
22802282
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+
22812293 // Track attachment
22822294 s.mu.Lock()
22832295 s.attachments[ownerDomain] = append(s.attachments[ownerDomain], container.ID)
@@ -2591,6 +2603,54 @@ func (s *Service) readinessCascade(ctx context.Context, containerID string, cont
25912603 return s.waitForReadyByDelay(ctx, containerID)
25922604 }
25932605
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+
25942654 // tryHTTPProbe attempts an HTTP probe if the gordon.health label is set.
25952655 // Returns (true, err) if the probe was attempted, (false, nil) if skipped.
25962656 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 bool29+ RegistryAuthEnabled bool
30- RegistryDomain string30+ RegistryDomain string
31- RegistryPort int31+ RegistryPort int
32- ServiceTokenUsername string32+ ServiceTokenUsername string
33- ServiceToken string33+ ServiceToken string
34- InternalRegistryUsername string34+ InternalRegistryUsername string
35- InternalRegistryPassword string35+ InternalRegistryPassword string
36- PullPolicy string36+ PullPolicy string
37- VolumeAutoCreate bool37+ VolumeAutoCreate bool
38- VolumePrefix string38+ VolumePrefix string
39- VolumePreserve bool39+ VolumePreserve bool
40- NetworkIsolation bool40+ NetworkIsolation bool
41- NetworkPrefix string41+ NetworkPrefix string
42- NetworkGroups map[string][]string42+ NetworkGroups map[string][]string
43- Attachments map[string][]string43+ Attachments map[string][]string
44- ReadinessDelay time.Duration // Delay after container starts before considering it ready44+ ReadinessDelay time.Duration // Delay after container starts before considering it ready
45- ReadinessMode string // Readiness strategy: auto, docker-health, delay45+ ReadinessMode string // Readiness strategy: auto, docker-health, delay
46- HealthTimeout time.Duration // Max wait for health-based readiness46+ HealthTimeout time.Duration // Max wait for health-based readiness
47- DrainDelay time.Duration // Grace period after cache invalidation before stopping old container47+ DrainDelay time.Duration // Grace period after cache invalidation before stopping old container
48- DrainDelayConfigured bool // True when deploy.drain_delay was explicitly configured48+ DrainDelayConfigured bool // True when deploy.drain_delay was explicitly configured
49- DrainMode string // Drain strategy: auto, inflight, delay49+ DrainMode string // Drain strategy: auto, inflight, delay
50- DrainTimeout time.Duration // Max wait for in-flight requests to drain50+ 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 attachment2293 // 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 (
44 "context"
55 "errors"
66 "fmt"
7+ "net"
78 "sync"
89 "testing"
910 "time"
@@ -3835,3 +3836,149 @@ func TestLoadEnvironment_NoPreResolvedEnv(t *testing.T) {
38353836 assert.Contains(t, result, "DB_HOST=prod")
38363837 assert.Contains(t, result, "FROM_DOCKERFILE=base")
38373838 }
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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close