detached d56ae16

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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
22522252 Image: actualImageRef,
22532253 Name: containerName,
22542254 Hostname: serviceName, // Internal DNS: postgres, redis, etc.
2255+ Aliases: []string{serviceName},
22552256 Ports: exposedPorts,
22562257 Env: envVars,
22572258 Volumes: volumes,
@@ -2279,6 +2280,14 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
22792280 return log.WrapErr(err, "failed to start attachment container")
22802281 }
22812282
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+
22822291 // Track attachment
22832292 s.mu.Lock()
22842293 s.attachments[ownerDomain] = append(s.attachments[ownerDomain], container.ID)
@@ -2592,6 +2601,54 @@ func (s *Service) readinessCascade(ctx context.Context, containerID string, cont
25922601 return s.waitForReadyByDelay(ctx, containerID)
25932602 }
25942603
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+
25952652 // tryHTTPProbe attempts an HTTP probe if the gordon.health label is set.
25962653 // Returns (true, err) if the probe was attempted, (false, nil) if skipped.
25972654 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 attachment2291 // 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 (
44 "context"
55 "errors"
66 "fmt"
7+ "net"
78 "sync"
89 "testing"
910 "time"
@@ -3835,3 +3836,142 @@ 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+ 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+}

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