fix(dns): add readiness check for attachments before starting main container
Attachments (postgres, redis, etc.) were started with no readiness barrier. The main app container could boot and attempt DNS resolution before the attachment was registered in the network DNS (aardvark-dns on Podman). Add waitForAttachmentReady with a reduced cascade: Docker healthcheck → TCP probe → delay fallback. HTTP probes are skipped since attachments are typically databases/caches. Fixes #143
- commit
- d56ae1663c29fcbb517cf0dbe114f6e0ef262ba8
- author
- brice · · 2026-03-23 19:11 UTC
- parent
- a025239
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +197 -0
M internal/usecase/container/service.go +57 -0
| @@ -2252,6 +2252,7 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||
| 2252 | 2252 | Image: actualImageRef, | |
| 2253 | 2253 | Name: containerName, | |
| 2254 | 2254 | Hostname: serviceName, // Internal DNS: postgres, redis, etc. | |
| 2255 | + | Aliases: []string{serviceName}, | |
| 2255 | 2256 | Ports: exposedPorts, | |
| 2256 | 2257 | Env: envVars, | |
| 2257 | 2258 | Volumes: volumes, | |
| @@ -2279,6 +2280,14 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||
| 2279 | 2280 | return log.WrapErr(err, "failed to start attachment container") | |
| 2280 | 2281 | } | |
| 2281 | 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 | + | s.runtime.StopContainer(ctx, container.ID) | |
| 2287 | + | s.runtime.RemoveContainer(ctx, container.ID, true) | |
| 2288 | + | return fmt.Errorf("attachment %q not ready: %w", serviceName, err) | |
| 2289 | + | } | |
| 2290 | + | ||
| 2282 | 2291 | // Track attachment | |
| 2283 | 2292 | s.mu.Lock() | |
| 2284 | 2293 | s.attachments[ownerDomain] = append(s.attachments[ownerDomain], container.ID) | |
| @@ -2592,6 +2601,54 @@ func (s *Service) readinessCascade(ctx context.Context, containerID string, cont | |||
| 2592 | 2601 | return s.waitForReadyByDelay(ctx, containerID) | |
| 2593 | 2602 | } | |
| 2594 | 2603 | ||
| 2604 | + | // waitForAttachmentReady runs a reduced readiness cascade for attachments: | |
| 2605 | + | // Docker healthcheck → TCP probe → delay fallback. | |
| 2606 | + | // HTTP probes are skipped — attachments are typically databases/caches. | |
| 2607 | + | func (s *Service) waitForAttachmentReady(ctx context.Context, containerID string, containerConfig *domain.ContainerConfig) error { | |
| 2608 | + | if err := s.pollContainerRunning(ctx, containerID); err != nil { | |
| 2609 | + | return err | |
| 2610 | + | } | |
| 2611 | + | ||
| 2612 | + | log := zerowrap.FromCtx(ctx) | |
| 2613 | + | ||
| 2614 | + | s.mu.RLock() | |
| 2615 | + | cfg := s.config | |
| 2616 | + | s.mu.RUnlock() | |
| 2617 | + | ||
| 2618 | + | // 1. Docker healthcheck (if present) | |
| 2619 | + | _, hasHealthcheck, err := s.runtime.GetContainerHealthStatus(ctx, containerID) | |
| 2620 | + | if err != nil { | |
| 2621 | + | return err | |
| 2622 | + | } | |
| 2623 | + | if hasHealthcheck { | |
| 2624 | + | log.Info().Msg("attachment readiness: using Docker healthcheck") | |
| 2625 | + | timeout := cfg.HealthTimeout | |
| 2626 | + | if timeout == 0 { | |
| 2627 | + | timeout = 90 * time.Second | |
| 2628 | + | } | |
| 2629 | + | return s.waitForHealthy(ctx, containerID, timeout) | |
| 2630 | + | } | |
| 2631 | + | ||
| 2632 | + | // 2. TCP probe (if port info available) | |
| 2633 | + | if containerConfig != nil && len(containerConfig.Ports) > 0 { | |
| 2634 | + | ip, port, probeErr := s.resolveProbeEndpoint(ctx, containerID, containerConfig) | |
| 2635 | + | if probeErr == nil && ip != "" && port > 0 { | |
| 2636 | + | addr := fmt.Sprintf("%s:%d", ip, port) | |
| 2637 | + | timeout := cfg.AttachmentReadinessTimeout | |
| 2638 | + | if timeout == 0 { | |
| 2639 | + | timeout = 30 * time.Second | |
| 2640 | + | } | |
| 2641 | + | log.Info().Str("addr", addr).Dur("timeout", timeout).Msg("attachment readiness: using TCP probe") | |
| 2642 | + | return tcpProbe(ctx, addr, timeout) | |
| 2643 | + | } | |
| 2644 | + | log.Debug().Err(probeErr).Msg("attachment readiness: TCP probe skipped, could not resolve endpoint") | |
| 2645 | + | } | |
| 2646 | + | ||
| 2647 | + | // 3. Delay fallback | |
| 2648 | + | log.Info().Msg("attachment readiness: using delay fallback") | |
| 2649 | + | return s.waitForReadyByDelay(ctx, containerID) | |
| 2650 | + | } | |
| 2651 | + | ||
| 2595 | 2652 | // tryHTTPProbe attempts an HTTP probe if the gordon.health label is set. | |
| 2596 | 2653 | // Returns (true, err) if the probe was attempted, (false, nil) if skipped. | |
| 2597 | 2654 | func (s *Service) tryHTTPProbe(ctx context.Context, containerID string, containerConfig *domain.ContainerConfig, cfg Config) (bool, error) { | |
| @@ -2252,6 +2252,7 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||||
| 2252 | Image: actualImageRef, | 2252 | Image: actualImageRef, | ||
| 2253 | Name: containerName, | 2253 | Name: containerName, | ||
| 2254 | Hostname: serviceName, // Internal DNS: postgres, redis, etc. | 2254 | Hostname: serviceName, // Internal DNS: postgres, redis, etc. | ||
| 2255 | + | Aliases: []string{serviceName}, | |||
| 2255 | Ports: exposedPorts, | 2256 | Ports: exposedPorts, | ||
| 2256 | Env: envVars, | 2257 | Env: envVars, | ||
| 2257 | Volumes: volumes, | 2258 | Volumes: volumes, | ||
| @@ -2279,6 +2280,14 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||||
| 2279 | return log.WrapErr(err, "failed to start attachment container") | 2280 | return log.WrapErr(err, "failed to start attachment container") | ||
| 2280 | } | 2281 | } | ||
| 2281 | 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 | + | s.runtime.StopContainer(ctx, container.ID) | |||
| 2287 | + | s.runtime.RemoveContainer(ctx, container.ID, true) | |||
| 2288 | + | return fmt.Errorf("attachment %q not ready: %w", serviceName, err) | |||
| 2289 | + | } | |||
| 2290 | + | ||||
| 2282 | // Track attachment | 2291 | // Track attachment | ||
| 2283 | s.mu.Lock() | 2292 | s.mu.Lock() | ||
| 2284 | s.attachments[ownerDomain] = append(s.attachments[ownerDomain], container.ID) | 2293 | s.attachments[ownerDomain] = append(s.attachments[ownerDomain], container.ID) | ||
| @@ -2592,6 +2601,54 @@ func (s *Service) readinessCascade(ctx context.Context, containerID string, cont | |||||
| 2592 | return s.waitForReadyByDelay(ctx, containerID) | 2601 | return s.waitForReadyByDelay(ctx, containerID) | ||
| 2593 | } | 2602 | } | ||
| 2594 | 2603 | ||||
| 2604 | + | // waitForAttachmentReady runs a reduced readiness cascade for attachments: | |||
| 2605 | + | // Docker healthcheck → TCP probe → delay fallback. | |||
| 2606 | + | // HTTP probes are skipped — attachments are typically databases/caches. | |||
| 2607 | + | func (s *Service) waitForAttachmentReady(ctx context.Context, containerID string, containerConfig *domain.ContainerConfig) error { | |||
| 2608 | + | if err := s.pollContainerRunning(ctx, containerID); err != nil { | |||
| 2609 | + | return err | |||
| 2610 | + | } | |||
| 2611 | + | ||||
| 2612 | + | log := zerowrap.FromCtx(ctx) | |||
| 2613 | + | ||||
| 2614 | + | s.mu.RLock() | |||
| 2615 | + | cfg := s.config | |||
| 2616 | + | s.mu.RUnlock() | |||
| 2617 | + | ||||
| 2618 | + | // 1. Docker healthcheck (if present) | |||
| 2619 | + | _, hasHealthcheck, err := s.runtime.GetContainerHealthStatus(ctx, containerID) | |||
| 2620 | + | if err != nil { | |||
| 2621 | + | return err | |||
| 2622 | + | } | |||
| 2623 | + | if hasHealthcheck { | |||
| 2624 | + | log.Info().Msg("attachment readiness: using Docker healthcheck") | |||
| 2625 | + | timeout := cfg.HealthTimeout | |||
| 2626 | + | if timeout == 0 { | |||
| 2627 | + | timeout = 90 * time.Second | |||
| 2628 | + | } | |||
| 2629 | + | return s.waitForHealthy(ctx, containerID, timeout) | |||
| 2630 | + | } | |||
| 2631 | + | ||||
| 2632 | + | // 2. TCP probe (if port info available) | |||
| 2633 | + | if containerConfig != nil && len(containerConfig.Ports) > 0 { | |||
| 2634 | + | ip, port, probeErr := s.resolveProbeEndpoint(ctx, containerID, containerConfig) | |||
| 2635 | + | if probeErr == nil && ip != "" && port > 0 { | |||
| 2636 | + | addr := fmt.Sprintf("%s:%d", ip, port) | |||
| 2637 | + | timeout := cfg.AttachmentReadinessTimeout | |||
| 2638 | + | if timeout == 0 { | |||
| 2639 | + | timeout = 30 * time.Second | |||
| 2640 | + | } | |||
| 2641 | + | log.Info().Str("addr", addr).Dur("timeout", timeout).Msg("attachment readiness: using TCP probe") | |||
| 2642 | + | return tcpProbe(ctx, addr, timeout) | |||
| 2643 | + | } | |||
| 2644 | + | log.Debug().Err(probeErr).Msg("attachment readiness: TCP probe skipped, could not resolve endpoint") | |||
| 2645 | + | } | |||
| 2646 | + | ||||
| 2647 | + | // 3. Delay fallback | |||
| 2648 | + | log.Info().Msg("attachment readiness: using delay fallback") | |||
| 2649 | + | return s.waitForReadyByDelay(ctx, containerID) | |||
| 2650 | + | } | |||
| 2651 | + | ||||
| 2595 | // tryHTTPProbe attempts an HTTP probe if the gordon.health label is set. | 2652 | // tryHTTPProbe attempts an HTTP probe if the gordon.health label is set. | ||
| 2596 | // Returns (true, err) if the probe was attempted, (false, nil) if skipped. | 2653 | // Returns (true, err) if the probe was attempted, (false, nil) if skipped. | ||
| 2597 | func (s *Service) tryHTTPProbe(ctx context.Context, containerID string, containerConfig *domain.ContainerConfig, cfg Config) (bool, error) { | 2654 | func (s *Service) tryHTTPProbe(ctx context.Context, containerID string, containerConfig *domain.ContainerConfig, cfg Config) (bool, error) { | ||
M internal/usecase/container/service_test.go +140 -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,142 @@ 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 | + | err := svc.deployAttachedService(ctx, ownerDomain, serviceImage, networkName) | |
| 3969 | + | require.NoError(t, err) | |
| 3970 | + | ||
| 3971 | + | // Verify the attachment is tracked | |
| 3972 | + | svc.mu.RLock() | |
| 3973 | + | attachIDs := svc.attachments[ownerDomain] | |
| 3974 | + | svc.mu.RUnlock() | |
| 3975 | + | require.Len(t, attachIDs, 1) | |
| 3976 | + | assert.Equal(t, "pg-container-1", attachIDs[0]) | |
| 3977 | + | } | |
| @@ -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,142 @@ 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 | + | err := svc.deployAttachedService(ctx, ownerDomain, serviceImage, networkName) | |||
| 3969 | + | require.NoError(t, err) | |||
| 3970 | + | ||||
| 3971 | + | // Verify the attachment is tracked | |||
| 3972 | + | svc.mu.RLock() | |||
| 3973 | + | attachIDs := svc.attachments[ownerDomain] | |||
| 3974 | + | svc.mu.RUnlock() | |||
| 3975 | + | require.Len(t, attachIDs, 1) | |||
| 3976 | + | assert.Equal(t, "pg-container-1", attachIDs[0]) | |||
| 3977 | + | } | |||