detached 54cce4b

refactor(container): implement zero-downtime deployment

Refactor Deploy method for zero-downtime container replacement:
- Start new container first (with -new suffix when replacing)
- Wait for readiness (running state + configurable delay)
- Update tracking atomically (traffic switches to new container)
- Stop and remove old container after switch
- Rename new container to canonical name

Also implement attachment deployment before main container,
add helper methods for container cleanup, and update all tests.
commit
54cce4b2121ea58ed036d47d9546d7761be2ed6e
author
brice · · 2026-01-12 20:47 UTC
committer
brice · · 2026-01-12 22:33 UTC
parent
92cef49
signature
unsigned
2 files · +346 -65
M internal/usecase/container/service.go +300 -57
@@ -8,6 +8,7 @@ import (
88 "slices"
99 "strings"
1010 "sync"
11+ "time"
1112
1213 "github.com/bnema/zerowrap"
1314
@@ -29,17 +30,19 @@ type Config struct {
2930 DNSSuffix string
3031 NetworkGroups map[string][]string
3132 Attachments map[string][]string
33+ ReadinessDelay time.Duration // Delay after container starts before considering it ready
3234 }
3335
3436 // Service implements the ContainerService interface.
3537 type Service struct {
36- runtime out.ContainerRuntime
37- envLoader out.EnvLoader
38- eventBus out.EventPublisher
39- logWriter out.ContainerLogWriter
40- config Config
41- containers map[string]*domain.Container
42- mu sync.RWMutex
38+ runtime out.ContainerRuntime
39+ envLoader out.EnvLoader
40+ eventBus out.EventPublisher
41+ logWriter out.ContainerLogWriter
42+ config Config
43+ containers map[string]*domain.Container
44+ attachments map[string][]string // ownerDomain → []containerIDs
45+ mu sync.RWMutex
4346 }
4447
4548 // NewService creates a new container service.
@@ -51,16 +54,18 @@ func NewService(
5154 config Config,
5255 ) *Service {
5356 return &Service{
54- runtime: runtime,
55- envLoader: envLoader,
56- eventBus: eventBus,
57- logWriter: logWriter,
58- config: config,
59- containers: make(map[string]*domain.Container),
57+ runtime: runtime,
58+ envLoader: envLoader,
59+ eventBus: eventBus,
60+ logWriter: logWriter,
61+ config: config,
62+ containers: make(map[string]*domain.Container),
63+ attachments: make(map[string][]string),
6064 }
6165 }
6266
6367 // Deploy creates and starts a container for the given route.
68+// Implements zero-downtime deployment: new container starts before old one stops.
6469 func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Container, error) {
6570 // Enrich context with use case fields for all downstream logs
6671 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -70,25 +75,12 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta
7075 })
7176 log := zerowrap.FromCtx(ctx)
7277
73- s.mu.Lock()
74- defer s.mu.Unlock()
75-
76- // Check if container already exists for this domain
77- if existing, exists := s.containers[route.Domain]; exists {
78- log.Info().Str(zerowrap.FieldEntityID, existing.ID).Msg("container already exists, restarting")
79-
80- if err := s.runtime.StopContainer(ctx, existing.ID); err != nil {
81- log.WrapErrWithFields(err, "failed to stop existing container", map[string]any{zerowrap.FieldEntityID: existing.ID})
82- }
83-
84- if err := s.runtime.RemoveContainer(ctx, existing.ID, true); err != nil {
85- log.WrapErrWithFields(err, "failed to remove existing container", map[string]any{zerowrap.FieldEntityID: existing.ID})
86- }
87-
88- delete(s.containers, route.Domain)
89- }
78+ // Get existing container (if any) without holding lock
79+ s.mu.RLock()
80+ existing, hasExisting := s.containers[route.Domain]
81+ s.mu.RUnlock()
9082
91- // Clean up orphaned containers
83+ // Clean up orphaned containers (containers with same name but not tracked)
9284 if err := s.cleanupOrphanedContainers(ctx, route.Domain); err != nil {
9385 log.WrapErr(err, "failed to cleanup orphaned containers")
9486 }
@@ -99,6 +91,17 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta
9991 return nil, err
10092 }
10193
94+ // Setup network FIRST (attachments need it)
95+ networkName := s.getNetworkForApp(route.Domain)
96+ if err := s.createNetworkIfNeeded(ctx, networkName); err != nil {
97+ return nil, log.WrapErr(err, "failed to create network")
98+ }
99+
100+ // Deploy attachments BEFORE main container (dependencies first)
101+ if err := s.deployAttachments(ctx, route.Domain, networkName); err != nil {
102+ log.WrapErr(err, "failed to deploy some attachments")
103+ }
104+
102105 // Get exposed ports
103106 exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, imageRef)
104107 if err != nil {
@@ -118,16 +121,16 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta
118121 return nil, err
119122 }
120123
121- // Setup network
122- networkName := s.getNetworkForApp(route.Domain)
123- if err := s.createNetworkIfNeeded(ctx, networkName); err != nil {
124- return nil, log.WrapErr(err, "failed to create network")
124+ // Determine container name (use temp suffix for zero-downtime if existing)
125+ containerName := fmt.Sprintf("gordon-%s", route.Domain)
126+ if hasExisting {
127+ containerName = fmt.Sprintf("gordon-%s-new", route.Domain)
125128 }
126129
127130 // Create container
128131 containerConfig := &domain.ContainerConfig{
129132 Image: imageRef,
130- Name: fmt.Sprintf("gordon-%s", route.Domain),
133+ Name: containerName,
131134 Ports: exposedPorts,
132135 Env: envVars,
133136 Volumes: volumes,
@@ -142,41 +145,54 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta
142145 AutoRemove: false,
143146 }
144147
145- container, err := s.runtime.CreateContainer(ctx, containerConfig)
148+ newContainer, err := s.runtime.CreateContainer(ctx, containerConfig)
146149 if err != nil {
147150 return nil, log.WrapErr(err, "failed to create container")
148151 }
149152
150- // Start container
151- if err := s.runtime.StartContainer(ctx, container.ID); err != nil {
152- s.runtime.RemoveContainer(ctx, container.ID, true)
153+ // Start new container
154+ if err := s.runtime.StartContainer(ctx, newContainer.ID); err != nil {
155+ s.runtime.RemoveContainer(ctx, newContainer.ID, true)
153156 return nil, log.WrapErr(err, "failed to start container")
154157 }
155158
156- // Re-inspect for updated info
157- container, err = s.runtime.InspectContainer(ctx, container.ID)
159+ // Wait for new container to be ready
160+ if err := s.waitForReady(ctx, newContainer.ID); err != nil {
161+ s.cleanupFailedContainer(ctx, newContainer.ID)
162+ return nil, log.WrapErr(err, "container failed readiness check")
163+ }
164+
165+ // Re-inspect for updated info (ports, etc.)
166+ newContainer, err = s.runtime.InspectContainer(ctx, newContainer.ID)
158167 if err != nil {
159168 return nil, log.WrapErr(err, "failed to inspect started container")
160169 }
161170
162- // Start container log collection (non-blocking, errors don't fail deployment)
163- s.startLogCollection(ctx, container.ID, route.Domain)
171+ // ATOMIC SWITCH: Update tracking first (proxy will now route to new container)
172+ s.mu.Lock()
173+ s.containers[route.Domain] = newContainer
174+ s.mu.Unlock()
164175
165- s.containers[route.Domain] = container
176+ // Publish container deployed event (proxy will invalidate cache)
177+ s.publishContainerDeployed(ctx, route.Domain, newContainer.ID)
178+
179+ // NOW stop and remove old container (traffic already going to new one)
180+ if hasExisting {
181+ s.cleanupOldContainer(ctx, existing, newContainer.ID, route.Domain)
182+ }
183+
184+ // Start container log collection (non-blocking, errors don't fail deployment)
185+ s.startLogCollection(ctx, newContainer.ID, route.Domain)
166186
167187 log.Info().
168188 Str("image", route.Image).
169- Str(zerowrap.FieldEntityID, container.ID).
170- Ints("ports", container.Ports).
189+ Str(zerowrap.FieldEntityID, newContainer.ID).
190+ Ints("ports", newContainer.Ports).
171191 Str("network", networkName).
192+ Bool("zero_downtime", hasExisting).
172193 Msg("container deployed successfully")
173194
174- // Deploy attachments
175- if err := s.deployAttachments(ctx, route.Domain); err != nil {
176- log.WrapErr(err, "failed to deploy some attachments")
177- }
178-
179- return container, nil
195+ return newContainer, nil
180196 }
181197
182198 // Stop stops a running container.
@@ -373,6 +389,44 @@ func (s *Service) UpdateConfig(config Config) {
373389
374390 // Helper methods
375391
392+// cleanupFailedContainer stops and removes a container that failed to start properly.
393+func (s *Service) cleanupFailedContainer(ctx context.Context, containerID string) {
394+ log := zerowrap.FromCtx(ctx)
395+ if err := s.runtime.StopContainer(ctx, containerID); err != nil {
396+ log.Warn().Err(err).Str(zerowrap.FieldEntityID, containerID).Msg("failed to stop container after failure")
397+ }
398+ if err := s.runtime.RemoveContainer(ctx, containerID, true); err != nil {
399+ log.Warn().Err(err).Str(zerowrap.FieldEntityID, containerID).Msg("failed to remove container after failure")
400+ }
401+}
402+
403+// cleanupOldContainer stops and removes an old container after zero-downtime switch.
404+// It also renames the new container to the canonical name.
405+func (s *Service) cleanupOldContainer(ctx context.Context, old *domain.Container, newContainerID, domainName string) {
406+ log := zerowrap.FromCtx(ctx)
407+ log.Info().Str(zerowrap.FieldEntityID, old.ID).Msg("stopping old container after zero-downtime switch")
408+
409+ if s.logWriter != nil {
410+ if err := s.logWriter.StopLogging(old.ID); err != nil {
411+ log.Warn().Err(err).Str(zerowrap.FieldEntityID, old.ID).Msg("failed to stop logging for old container")
412+ }
413+ }
414+
415+ if err := s.runtime.StopContainer(ctx, old.ID); err != nil {
416+ log.Warn().Err(err).Str(zerowrap.FieldEntityID, old.ID).Msg("failed to stop old container")
417+ }
418+
419+ if err := s.runtime.RemoveContainer(ctx, old.ID, true); err != nil {
420+ log.Warn().Err(err).Str(zerowrap.FieldEntityID, old.ID).Msg("failed to remove old container")
421+ }
422+
423+ // Rename new container to canonical name
424+ canonicalName := fmt.Sprintf("gordon-%s", domainName)
425+ if err := s.runtime.RenameContainer(ctx, newContainerID, canonicalName); err != nil {
426+ log.Warn().Err(err).Str("canonical_name", canonicalName).Msg("failed to rename container to canonical name")
427+ }
428+}
429+
376430 // startLogCollection starts log collection for a container in the background.
377431 // Errors are logged but don't fail the calling operation.
378432 func (s *Service) startLogCollection(ctx context.Context, containerID, domainName string) {
@@ -595,7 +649,7 @@ func (s *Service) cleanupNetworkIfEmpty(ctx context.Context, networkName string)
595649 return nil
596650 }
597651
598-func (s *Service) deployAttachments(ctx context.Context, domainName string) error {
652+func (s *Service) deployAttachments(ctx context.Context, domainName, networkName string) error {
599653 attachments, ok := s.config.Attachments[domainName]
600654 if !ok {
601655 return nil
@@ -603,7 +657,7 @@ func (s *Service) deployAttachments(ctx context.Context, domainName string) erro
603657
604658 log := zerowrap.FromCtx(ctx)
605659 for _, svc := range attachments {
606- if err := s.deployAttachedService(ctx, domainName, svc); err != nil {
660+ if err := s.deployAttachedService(ctx, domainName, svc, networkName); err != nil {
607661 log.WrapErrWithFields(err, "failed to deploy attachment", map[string]any{zerowrap.FieldService: svc, "domain": domainName})
608662 }
609663 }
@@ -611,9 +665,100 @@ func (s *Service) deployAttachments(ctx context.Context, domainName string) erro
611665 return nil
612666 }
613667
614-func (s *Service) deployAttachedService(ctx context.Context, identifier, serviceImage string) error {
668+func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, serviceImage, networkName string) error {
615669 log := zerowrap.FromCtx(ctx)
616- log.Info().Str("identifier", identifier).Str(zerowrap.FieldService, serviceImage).Msg("deploying attached service")
670+
671+ // Parse service name from image (e.g., "my-postgres:latest" → "postgres")
672+ serviceName := extractServiceName(serviceImage)
673+ containerName := fmt.Sprintf("gordon-%s-%s", sanitizeName(ownerDomain), serviceName)
674+
675+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
676+ "attachment": serviceName,
677+ "container_name": containerName,
678+ "owner_domain": ownerDomain,
679+ })
680+
681+ // Check if already running (idempotent)
682+ existingContainer := s.findContainerByName(ctx, containerName)
683+ if existingContainer != nil && existingContainer.Status == string(domain.ContainerStatusRunning) {
684+ log.Debug().Str("container_name", containerName).Msg("attachment already running, skipping")
685+ return nil
686+ }
687+
688+ // Remove existing stopped container if present
689+ if existingContainer != nil {
690+ log.Info().Str("container_name", containerName).Msg("removing stopped attachment container")
691+ if err := s.runtime.RemoveContainer(ctx, existingContainer.ID, true); err != nil {
692+ log.WrapErr(err, "failed to remove existing attachment container")
693+ }
694+ }
695+
696+ log.Info().Str(zerowrap.FieldService, serviceImage).Msg("deploying attached service")
697+
698+ // Ensure image
699+ imageRef := s.buildImageRef(serviceImage)
700+ if err := s.ensureImage(ctx, imageRef); err != nil {
701+ return err
702+ }
703+
704+ // Get image metadata
705+ exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, imageRef)
706+ if err != nil {
707+ log.WrapErr(err, "failed to get exposed ports for attachment, using defaults")
708+ exposedPorts = []int{}
709+ }
710+
711+ // Setup volumes (attachments need persistent data)
712+ volumes, err := s.setupVolumes(ctx, containerName, imageRef)
713+ if err != nil {
714+ log.WrapErr(err, "failed to setup volumes for attachment")
715+ volumes = make(map[string]string)
716+ }
717+
718+ // Load environment (attachment-specific env file)
719+ envVars, err := s.loadEnvironment(ctx, containerName, imageRef)
720+ if err != nil {
721+ log.WrapErr(err, "failed to load environment for attachment")
722+ envVars = []string{}
723+ }
724+
725+ // Create container with attachment labels
726+ config := &domain.ContainerConfig{
727+ Image: imageRef,
728+ Name: containerName,
729+ Hostname: serviceName, // Internal DNS: postgres, redis, etc.
730+ Ports: exposedPorts,
731+ Env: envVars,
732+ Volumes: volumes,
733+ NetworkMode: networkName, // Same network as main app
734+ Labels: map[string]string{
735+ "gordon.managed": "true",
736+ "gordon.attachment": "true",
737+ "gordon.attached-to": ownerDomain,
738+ "gordon.image": serviceImage,
739+ },
740+ }
741+
742+ container, err := s.runtime.CreateContainer(ctx, config)
743+ if err != nil {
744+ return log.WrapErr(err, "failed to create attachment container")
745+ }
746+
747+ // Start container
748+ if err := s.runtime.StartContainer(ctx, container.ID); err != nil {
749+ s.runtime.RemoveContainer(ctx, container.ID, true)
750+ return log.WrapErr(err, "failed to start attachment container")
751+ }
752+
753+ // Track attachment
754+ s.mu.Lock()
755+ s.attachments[ownerDomain] = append(s.attachments[ownerDomain], container.ID)
756+ s.mu.Unlock()
757+
758+ // Start log collection for attachment
759+ s.startLogCollection(ctx, container.ID, containerName)
760+
761+ log.Info().Str(zerowrap.FieldEntityID, container.ID).Msg("attachment deployed successfully")
617762 return nil
618763 }
619764
@@ -703,3 +848,101 @@ func mergeEnvironmentVariables(dockerfileEnv, userEnv []string) []string {
703848
704849 return result
705850 }
851+
852+// extractServiceName gets service name from image reference.
853+// "my-postgres:latest" → "postgres", "redis:7" → "redis"
854+func extractServiceName(image string) string {
855+ // Remove tag
856+ parts := strings.Split(image, ":")
857+ name := parts[0]
858+
859+ // Remove registry prefix if present
860+ if strings.Contains(name, "/") {
861+ nameParts := strings.Split(name, "/")
862+ name = nameParts[len(nameParts)-1]
863+ }
864+
865+ // Remove common prefixes like "my-"
866+ name = strings.TrimPrefix(name, "my-")
867+
868+ return name
869+}
870+
871+// sanitizeName makes a domain safe for container naming.
872+func sanitizeName(domain string) string {
873+ // Replace dots and other special chars with dashes
874+ result := strings.ReplaceAll(domain, ".", "-")
875+ result = strings.ReplaceAll(result, ":", "-")
876+ result = strings.ReplaceAll(result, "/", "-")
877+ return result
878+}
879+
880+// findContainerByName finds a container by its name.
881+func (s *Service) findContainerByName(ctx context.Context, name string) *domain.Container {
882+ containers, err := s.runtime.ListContainers(ctx, true)
883+ if err != nil {
884+ return nil
885+ }
886+
887+ for _, c := range containers {
888+ if c.Name == name {
889+ return c
890+ }
891+ }
892+ return nil
893+}
894+
895+// waitForReady waits for a container to be ready.
896+// Uses simple "running + delay" approach for universal compatibility.
897+func (s *Service) waitForReady(ctx context.Context, containerID string) error {
898+ log := zerowrap.FromCtx(ctx)
899+
900+ // Poll for container to be running (max 30 seconds)
901+ for i := 0; i < 30; i++ {
902+ running, err := s.runtime.IsContainerRunning(ctx, containerID)
903+ if err != nil {
904+ return err
905+ }
906+ if running {
907+ break
908+ }
909+ if i == 29 {
910+ return fmt.Errorf("container did not start within 30 seconds")
911+ }
912+ time.Sleep(time.Second)
913+ }
914+
915+ // Additional readiness delay (configurable, default 5 seconds)
916+ delay := s.config.ReadinessDelay
917+ if delay == 0 {
918+ delay = 5 * time.Second
919+ }
920+
921+ log.Debug().Dur("delay", delay).Msg("waiting for container readiness")
922+ time.Sleep(delay)
923+
924+ // Verify still running after delay
925+ running, err := s.runtime.IsContainerRunning(ctx, containerID)
926+ if err != nil {
927+ return err
928+ }
929+ if !running {
930+ return fmt.Errorf("container stopped during readiness delay")
931+ }
932+
933+ return nil
934+}
935+
936+// publishContainerDeployed publishes a container.deployed event.
937+func (s *Service) publishContainerDeployed(ctx context.Context, domainName, containerID string) {
938+ payload := &domain.ContainerEventPayload{
939+ ContainerID: containerID,
940+ Domain: domainName,
941+ Action: "deployed",
942+ }
943+
944+ if err := s.eventBus.Publish(domain.EventContainerDeployed, payload); err != nil {
945+ log := zerowrap.FromCtx(ctx)
946+ log.Warn().Err(err).Msg("failed to publish container deployed event")
947+ }
948+}
@@ -8,6 +8,7 @@ import (
8 "slices"8 "slices"
9 "strings"9 "strings"
10 "sync"10 "sync"
11+ "time"
11 12
12 "github.com/bnema/zerowrap"13 "github.com/bnema/zerowrap"
13 14
@@ -29,17 +30,19 @@ type Config struct {
29 DNSSuffix string30 DNSSuffix string
30 NetworkGroups map[string][]string31 NetworkGroups map[string][]string
31 Attachments map[string][]string32 Attachments map[string][]string
33+ ReadinessDelay time.Duration // Delay after container starts before considering it ready
32 }34 }
33 35
34 // Service implements the ContainerService interface.36 // Service implements the ContainerService interface.
35 type Service struct {37 type Service struct {
36- runtime out.ContainerRuntime38+ runtime out.ContainerRuntime
37- envLoader out.EnvLoader39+ envLoader out.EnvLoader
38- eventBus out.EventPublisher40+ eventBus out.EventPublisher
39- logWriter out.ContainerLogWriter41+ logWriter out.ContainerLogWriter
40- config Config42+ config Config
41- containers map[string]*domain.Container43+ containers map[string]*domain.Container
42- mu sync.RWMutex44+ attachments map[string][]string // ownerDomain → []containerIDs
45+ mu sync.RWMutex
43 }46 }
44 47
45 // NewService creates a new container service.48 // NewService creates a new container service.
@@ -51,16 +54,18 @@ func NewService(
51 config Config,54 config Config,
52 ) *Service {55 ) *Service {
53 return &Service{56 return &Service{
54- runtime: runtime,57+ runtime: runtime,
55- envLoader: envLoader,58+ envLoader: envLoader,
56- eventBus: eventBus,59+ eventBus: eventBus,
57- logWriter: logWriter,60+ logWriter: logWriter,
58- config: config,61+ config: config,
59- containers: make(map[string]*domain.Container),62+ containers: make(map[string]*domain.Container),
63+ attachments: make(map[string][]string),
60 }64 }
61 }65 }
62 66
63 // Deploy creates and starts a container for the given route.67 // Deploy creates and starts a container for the given route.
68+// Implements zero-downtime deployment: new container starts before old one stops.
64 func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Container, error) {69 func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Container, error) {
65 // Enrich context with use case fields for all downstream logs70 // Enrich context with use case fields for all downstream logs
66 ctx = zerowrap.CtxWithFields(ctx, map[string]any{71 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -70,25 +75,12 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta
70 })75 })
71 log := zerowrap.FromCtx(ctx)76 log := zerowrap.FromCtx(ctx)
72 77
73- s.mu.Lock()78+ // Get existing container (if any) without holding lock
74- defer s.mu.Unlock()79+ s.mu.RLock()
75-80+ existing, hasExisting := s.containers[route.Domain]
76- // Check if container already exists for this domain81+ s.mu.RUnlock()
77- if existing, exists := s.containers[route.Domain]; exists {
78- log.Info().Str(zerowrap.FieldEntityID, existing.ID).Msg("container already exists, restarting")
79-
80- if err := s.runtime.StopContainer(ctx, existing.ID); err != nil {
81- log.WrapErrWithFields(err, "failed to stop existing container", map[string]any{zerowrap.FieldEntityID: existing.ID})
82- }
83-
84- if err := s.runtime.RemoveContainer(ctx, existing.ID, true); err != nil {
85- log.WrapErrWithFields(err, "failed to remove existing container", map[string]any{zerowrap.FieldEntityID: existing.ID})
86- }
87-
88- delete(s.containers, route.Domain)
89- }
90 82
91- // Clean up orphaned containers83+ // Clean up orphaned containers (containers with same name but not tracked)
92 if err := s.cleanupOrphanedContainers(ctx, route.Domain); err != nil {84 if err := s.cleanupOrphanedContainers(ctx, route.Domain); err != nil {
93 log.WrapErr(err, "failed to cleanup orphaned containers")85 log.WrapErr(err, "failed to cleanup orphaned containers")
94 }86 }
@@ -99,6 +91,17 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta
99 return nil, err91 return nil, err
100 }92 }
101 93
94+ // Setup network FIRST (attachments need it)
95+ networkName := s.getNetworkForApp(route.Domain)
96+ if err := s.createNetworkIfNeeded(ctx, networkName); err != nil {
97+ return nil, log.WrapErr(err, "failed to create network")
98+ }
99+
100+ // Deploy attachments BEFORE main container (dependencies first)
101+ if err := s.deployAttachments(ctx, route.Domain, networkName); err != nil {
102+ log.WrapErr(err, "failed to deploy some attachments")
103+ }
104+
102 // Get exposed ports105 // Get exposed ports
103 exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, imageRef)106 exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, imageRef)
104 if err != nil {107 if err != nil {
@@ -118,16 +121,16 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta
118 return nil, err121 return nil, err
119 }122 }
120 123
121- // Setup network124+ // Determine container name (use temp suffix for zero-downtime if existing)
122- networkName := s.getNetworkForApp(route.Domain)125+ containerName := fmt.Sprintf("gordon-%s", route.Domain)
123- if err := s.createNetworkIfNeeded(ctx, networkName); err != nil {126+ if hasExisting {
124- return nil, log.WrapErr(err, "failed to create network")127+ containerName = fmt.Sprintf("gordon-%s-new", route.Domain)
125 }128 }
126 129
127 // Create container130 // Create container
128 containerConfig := &domain.ContainerConfig{131 containerConfig := &domain.ContainerConfig{
129 Image: imageRef,132 Image: imageRef,
130- Name: fmt.Sprintf("gordon-%s", route.Domain),133+ Name: containerName,
131 Ports: exposedPorts,134 Ports: exposedPorts,
132 Env: envVars,135 Env: envVars,
133 Volumes: volumes,136 Volumes: volumes,
@@ -142,41 +145,54 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta
142 AutoRemove: false,145 AutoRemove: false,
143 }146 }
144 147
145- container, err := s.runtime.CreateContainer(ctx, containerConfig)148+ newContainer, err := s.runtime.CreateContainer(ctx, containerConfig)
146 if err != nil {149 if err != nil {
147 return nil, log.WrapErr(err, "failed to create container")150 return nil, log.WrapErr(err, "failed to create container")
148 }151 }
149 152
150- // Start container153+ // Start new container
151- if err := s.runtime.StartContainer(ctx, container.ID); err != nil {154+ if err := s.runtime.StartContainer(ctx, newContainer.ID); err != nil {
152- s.runtime.RemoveContainer(ctx, container.ID, true)155+ s.runtime.RemoveContainer(ctx, newContainer.ID, true)
153 return nil, log.WrapErr(err, "failed to start container")156 return nil, log.WrapErr(err, "failed to start container")
154 }157 }
155 158
156- // Re-inspect for updated info159+ // Wait for new container to be ready
157- container, err = s.runtime.InspectContainer(ctx, container.ID)160+ if err := s.waitForReady(ctx, newContainer.ID); err != nil {
161+ s.cleanupFailedContainer(ctx, newContainer.ID)
162+ return nil, log.WrapErr(err, "container failed readiness check")
163+ }
164+
165+ // Re-inspect for updated info (ports, etc.)
166+ newContainer, err = s.runtime.InspectContainer(ctx, newContainer.ID)
158 if err != nil {167 if err != nil {
159 return nil, log.WrapErr(err, "failed to inspect started container")168 return nil, log.WrapErr(err, "failed to inspect started container")
160 }169 }
161 170
162- // Start container log collection (non-blocking, errors don't fail deployment)171+ // ATOMIC SWITCH: Update tracking first (proxy will now route to new container)
163- s.startLogCollection(ctx, container.ID, route.Domain)172+ s.mu.Lock()
173+ s.containers[route.Domain] = newContainer
174+ s.mu.Unlock()
164 175
165- s.containers[route.Domain] = container176+ // Publish container deployed event (proxy will invalidate cache)
177+ s.publishContainerDeployed(ctx, route.Domain, newContainer.ID)
178+
179+ // NOW stop and remove old container (traffic already going to new one)
180+ if hasExisting {
181+ s.cleanupOldContainer(ctx, existing, newContainer.ID, route.Domain)
182+ }
183+
184+ // Start container log collection (non-blocking, errors don't fail deployment)
185+ s.startLogCollection(ctx, newContainer.ID, route.Domain)
166 186
167 log.Info().187 log.Info().
168 Str("image", route.Image).188 Str("image", route.Image).
169- Str(zerowrap.FieldEntityID, container.ID).189+ Str(zerowrap.FieldEntityID, newContainer.ID).
170- Ints("ports", container.Ports).190+ Ints("ports", newContainer.Ports).
171 Str("network", networkName).191 Str("network", networkName).
192+ Bool("zero_downtime", hasExisting).
172 Msg("container deployed successfully")193 Msg("container deployed successfully")
173 194
174- // Deploy attachments195+ return newContainer, nil
175- if err := s.deployAttachments(ctx, route.Domain); err != nil {
176- log.WrapErr(err, "failed to deploy some attachments")
177- }
178-
179- return container, nil
180 }196 }
181 197
182 // Stop stops a running container.198 // Stop stops a running container.
@@ -373,6 +389,44 @@ func (s *Service) UpdateConfig(config Config) {
373 389
374 // Helper methods390 // Helper methods
375 391
392+// cleanupFailedContainer stops and removes a container that failed to start properly.
393+func (s *Service) cleanupFailedContainer(ctx context.Context, containerID string) {
394+ log := zerowrap.FromCtx(ctx)
395+ if err := s.runtime.StopContainer(ctx, containerID); err != nil {
396+ log.Warn().Err(err).Str(zerowrap.FieldEntityID, containerID).Msg("failed to stop container after failure")
397+ }
398+ if err := s.runtime.RemoveContainer(ctx, containerID, true); err != nil {
399+ log.Warn().Err(err).Str(zerowrap.FieldEntityID, containerID).Msg("failed to remove container after failure")
400+ }
401+}
402+
403+// cleanupOldContainer stops and removes an old container after zero-downtime switch.
404+// It also renames the new container to the canonical name.
405+func (s *Service) cleanupOldContainer(ctx context.Context, old *domain.Container, newContainerID, domainName string) {
406+ log := zerowrap.FromCtx(ctx)
407+ log.Info().Str(zerowrap.FieldEntityID, old.ID).Msg("stopping old container after zero-downtime switch")
408+
409+ if s.logWriter != nil {
410+ if err := s.logWriter.StopLogging(old.ID); err != nil {
411+ log.Warn().Err(err).Str(zerowrap.FieldEntityID, old.ID).Msg("failed to stop logging for old container")
412+ }
413+ }
414+
415+ if err := s.runtime.StopContainer(ctx, old.ID); err != nil {
416+ log.Warn().Err(err).Str(zerowrap.FieldEntityID, old.ID).Msg("failed to stop old container")
417+ }
418+
419+ if err := s.runtime.RemoveContainer(ctx, old.ID, true); err != nil {
420+ log.Warn().Err(err).Str(zerowrap.FieldEntityID, old.ID).Msg("failed to remove old container")
421+ }
422+
423+ // Rename new container to canonical name
424+ canonicalName := fmt.Sprintf("gordon-%s", domainName)
425+ if err := s.runtime.RenameContainer(ctx, newContainerID, canonicalName); err != nil {
426+ log.Warn().Err(err).Str("canonical_name", canonicalName).Msg("failed to rename container to canonical name")
427+ }
428+}
429+
376 // startLogCollection starts log collection for a container in the background.430 // startLogCollection starts log collection for a container in the background.
377 // Errors are logged but don't fail the calling operation.431 // Errors are logged but don't fail the calling operation.
378 func (s *Service) startLogCollection(ctx context.Context, containerID, domainName string) {432 func (s *Service) startLogCollection(ctx context.Context, containerID, domainName string) {
@@ -595,7 +649,7 @@ func (s *Service) cleanupNetworkIfEmpty(ctx context.Context, networkName string)
595 return nil649 return nil
596 }650 }
597 651
598-func (s *Service) deployAttachments(ctx context.Context, domainName string) error {652+func (s *Service) deployAttachments(ctx context.Context, domainName, networkName string) error {
599 attachments, ok := s.config.Attachments[domainName]653 attachments, ok := s.config.Attachments[domainName]
600 if !ok {654 if !ok {
601 return nil655 return nil
@@ -603,7 +657,7 @@ func (s *Service) deployAttachments(ctx context.Context, domainName string) erro
603 657
604 log := zerowrap.FromCtx(ctx)658 log := zerowrap.FromCtx(ctx)
605 for _, svc := range attachments {659 for _, svc := range attachments {
606- if err := s.deployAttachedService(ctx, domainName, svc); err != nil {660+ if err := s.deployAttachedService(ctx, domainName, svc, networkName); err != nil {
607 log.WrapErrWithFields(err, "failed to deploy attachment", map[string]any{zerowrap.FieldService: svc, "domain": domainName})661 log.WrapErrWithFields(err, "failed to deploy attachment", map[string]any{zerowrap.FieldService: svc, "domain": domainName})
608 }662 }
609 }663 }
@@ -611,9 +665,100 @@ func (s *Service) deployAttachments(ctx context.Context, domainName string) erro
611 return nil665 return nil
612 }666 }
613 667
614-func (s *Service) deployAttachedService(ctx context.Context, identifier, serviceImage string) error {668+func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, serviceImage, networkName string) error {
615 log := zerowrap.FromCtx(ctx)669 log := zerowrap.FromCtx(ctx)
616- log.Info().Str("identifier", identifier).Str(zerowrap.FieldService, serviceImage).Msg("deploying attached service")670+
671+ // Parse service name from image (e.g., "my-postgres:latest" → "postgres")
672+ serviceName := extractServiceName(serviceImage)
673+ containerName := fmt.Sprintf("gordon-%s-%s", sanitizeName(ownerDomain), serviceName)
674+
675+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
676+ "attachment": serviceName,
677+ "container_name": containerName,
678+ "owner_domain": ownerDomain,
679+ })
680+
681+ // Check if already running (idempotent)
682+ existingContainer := s.findContainerByName(ctx, containerName)
683+ if existingContainer != nil && existingContainer.Status == string(domain.ContainerStatusRunning) {
684+ log.Debug().Str("container_name", containerName).Msg("attachment already running, skipping")
685+ return nil
686+ }
687+
688+ // Remove existing stopped container if present
689+ if existingContainer != nil {
690+ log.Info().Str("container_name", containerName).Msg("removing stopped attachment container")
691+ if err := s.runtime.RemoveContainer(ctx, existingContainer.ID, true); err != nil {
692+ log.WrapErr(err, "failed to remove existing attachment container")
693+ }
694+ }
695+
696+ log.Info().Str(zerowrap.FieldService, serviceImage).Msg("deploying attached service")
697+
698+ // Ensure image
699+ imageRef := s.buildImageRef(serviceImage)
700+ if err := s.ensureImage(ctx, imageRef); err != nil {
701+ return err
702+ }
703+
704+ // Get image metadata
705+ exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, imageRef)
706+ if err != nil {
707+ log.WrapErr(err, "failed to get exposed ports for attachment, using defaults")
708+ exposedPorts = []int{}
709+ }
710+
711+ // Setup volumes (attachments need persistent data)
712+ volumes, err := s.setupVolumes(ctx, containerName, imageRef)
713+ if err != nil {
714+ log.WrapErr(err, "failed to setup volumes for attachment")
715+ volumes = make(map[string]string)
716+ }
717+
718+ // Load environment (attachment-specific env file)
719+ envVars, err := s.loadEnvironment(ctx, containerName, imageRef)
720+ if err != nil {
721+ log.WrapErr(err, "failed to load environment for attachment")
722+ envVars = []string{}
723+ }
724+
725+ // Create container with attachment labels
726+ config := &domain.ContainerConfig{
727+ Image: imageRef,
728+ Name: containerName,
729+ Hostname: serviceName, // Internal DNS: postgres, redis, etc.
730+ Ports: exposedPorts,
731+ Env: envVars,
732+ Volumes: volumes,
733+ NetworkMode: networkName, // Same network as main app
734+ Labels: map[string]string{
735+ "gordon.managed": "true",
736+ "gordon.attachment": "true",
737+ "gordon.attached-to": ownerDomain,
738+ "gordon.image": serviceImage,
739+ },
740+ }
741+
742+ container, err := s.runtime.CreateContainer(ctx, config)
743+ if err != nil {
744+ return log.WrapErr(err, "failed to create attachment container")
745+ }
746+
747+ // Start container
748+ if err := s.runtime.StartContainer(ctx, container.ID); err != nil {
749+ s.runtime.RemoveContainer(ctx, container.ID, true)
750+ return log.WrapErr(err, "failed to start attachment container")
751+ }
752+
753+ // Track attachment
754+ s.mu.Lock()
755+ s.attachments[ownerDomain] = append(s.attachments[ownerDomain], container.ID)
756+ s.mu.Unlock()
757+
758+ // Start log collection for attachment
759+ s.startLogCollection(ctx, container.ID, containerName)
760+
761+ log.Info().Str(zerowrap.FieldEntityID, container.ID).Msg("attachment deployed successfully")
617 return nil762 return nil
618 }763 }
619 764
@@ -703,3 +848,101 @@ func mergeEnvironmentVariables(dockerfileEnv, userEnv []string) []string {
703 848
704 return result849 return result
705 }850 }
851+
852+// extractServiceName gets service name from image reference.
853+// "my-postgres:latest" → "postgres", "redis:7" → "redis"
854+func extractServiceName(image string) string {
855+ // Remove tag
856+ parts := strings.Split(image, ":")
857+ name := parts[0]
858+
859+ // Remove registry prefix if present
860+ if strings.Contains(name, "/") {
861+ nameParts := strings.Split(name, "/")
862+ name = nameParts[len(nameParts)-1]
863+ }
864+
865+ // Remove common prefixes like "my-"
866+ name = strings.TrimPrefix(name, "my-")
867+
868+ return name
869+}
870+
871+// sanitizeName makes a domain safe for container naming.
872+func sanitizeName(domain string) string {
873+ // Replace dots and other special chars with dashes
874+ result := strings.ReplaceAll(domain, ".", "-")
875+ result = strings.ReplaceAll(result, ":", "-")
876+ result = strings.ReplaceAll(result, "/", "-")
877+ return result
878+}
879+
880+// findContainerByName finds a container by its name.
881+func (s *Service) findContainerByName(ctx context.Context, name string) *domain.Container {
882+ containers, err := s.runtime.ListContainers(ctx, true)
883+ if err != nil {
884+ return nil
885+ }
886+
887+ for _, c := range containers {
888+ if c.Name == name {
889+ return c
890+ }
891+ }
892+ return nil
893+}
894+
895+// waitForReady waits for a container to be ready.
896+// Uses simple "running + delay" approach for universal compatibility.
897+func (s *Service) waitForReady(ctx context.Context, containerID string) error {
898+ log := zerowrap.FromCtx(ctx)
899+
900+ // Poll for container to be running (max 30 seconds)
901+ for i := 0; i < 30; i++ {
902+ running, err := s.runtime.IsContainerRunning(ctx, containerID)
903+ if err != nil {
904+ return err
905+ }
906+ if running {
907+ break
908+ }
909+ if i == 29 {
910+ return fmt.Errorf("container did not start within 30 seconds")
911+ }
912+ time.Sleep(time.Second)
913+ }
914+
915+ // Additional readiness delay (configurable, default 5 seconds)
916+ delay := s.config.ReadinessDelay
917+ if delay == 0 {
918+ delay = 5 * time.Second
919+ }
920+
921+ log.Debug().Dur("delay", delay).Msg("waiting for container readiness")
922+ time.Sleep(delay)
923+
924+ // Verify still running after delay
925+ running, err := s.runtime.IsContainerRunning(ctx, containerID)
926+ if err != nil {
927+ return err
928+ }
929+ if !running {
930+ return fmt.Errorf("container stopped during readiness delay")
931+ }
932+
933+ return nil
934+}
935+
936+// publishContainerDeployed publishes a container.deployed event.
937+func (s *Service) publishContainerDeployed(ctx context.Context, domainName, containerID string) {
938+ payload := &domain.ContainerEventPayload{
939+ ContainerID: containerID,
940+ Domain: domainName,
941+ Action: "deployed",
942+ }
943+
944+ if err := s.eventBus.Publish(domain.EventContainerDeployed, payload); err != nil {
945+ log := zerowrap.FromCtx(ctx)
946+ log.Warn().Err(err).Msg("failed to publish container deployed event")
947+ }
948+}
M internal/usecase/container/service_test.go +46 -8
@@ -25,6 +25,7 @@ func TestService_Deploy_Success(t *testing.T) {
2525 config := Config{
2626 NetworkIsolation: false,
2727 VolumeAutoCreate: false,
28+ ReadinessDelay: 0, // No delay for tests
2829 }
2930
3031 svc := NewService(runtime, envLoader, eventBus, nil, config)
@@ -63,6 +64,9 @@ func TestService_Deploy_Success(t *testing.T) {
6364 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(createdContainer, nil)
6465 runtime.EXPECT().StartContainer(mock.Anything, "container-123").Return(nil)
6566
67+ // Wait for ready: IsContainerRunning (first check returns true) + verify after delay
68+ runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(true, nil).Times(2)
69+
6670 // Re-inspect after start
6771 runningContainer := &domain.Container{
6872 ID: "container-123",
@@ -73,6 +77,9 @@ func TestService_Deploy_Success(t *testing.T) {
7377 }
7478 runtime.EXPECT().InspectContainer(mock.Anything, "container-123").Return(runningContainer, nil)
7579
80+ // Publish container deployed event
81+ eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
82+
7683 result, err := svc.Deploy(ctx, route)
7784
7885 assert.NoError(t, err)
@@ -116,7 +123,9 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) {
116123 envLoader := mocks.NewMockEnvLoader(t)
117124 eventBus := mocks.NewMockEventPublisher(t)
118125
119- config := Config{}
126+ config := Config{
127+ ReadinessDelay: 0, // No delay for tests
128+ }
120129 svc := NewService(runtime, envLoader, eventBus, nil, config)
121130 ctx := testContext()
122131
@@ -133,10 +142,6 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) {
133142 Image: "myapp:v2",
134143 }
135144
136- // Stop and remove existing container
137- runtime.EXPECT().StopContainer(mock.Anything, "old-container").Return(nil)
138- runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil)
139-
140145 // Cleanup orphans
141146 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
142147
@@ -149,15 +154,32 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) {
149154 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
150155 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
151156
152- // Create new container
153- newContainer := &domain.Container{ID: "new-container", Status: "created"}
154- runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(newContainer, nil)
157+ // Create new container (with -new suffix for zero-downtime)
158+ newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
159+ runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
160+ return cfg.Name == "gordon-test.example.com-new"
161+ })).Return(newContainer, nil)
155162 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
163+
164+ // Wait for ready
165+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
166+
167+ // Inspect after ready
156168 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
157169 ID: "new-container",
158170 Status: "running",
159171 }, nil)
160172
173+ // Publish event
174+ eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
175+
176+ // Now cleanup old container (after new one is ready)
177+ runtime.EXPECT().StopContainer(mock.Anything, "old-container").Return(nil)
178+ runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil)
179+
180+ // Rename new container to canonical name
181+ runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
182+
161183 result, err := svc.Deploy(ctx, route)
162184
163185 assert.NoError(t, err)
@@ -177,6 +199,7 @@ func TestService_Deploy_WithNetworkIsolation(t *testing.T) {
177199 config := Config{
178200 NetworkIsolation: true,
179201 NetworkPrefix: "gordon",
202+ ReadinessDelay: 0, // No delay for tests
180203 }
181204 svc := NewService(runtime, envLoader, eventBus, nil, config)
182205 ctx := testContext()
@@ -201,11 +224,18 @@ func TestService_Deploy_WithNetworkIsolation(t *testing.T) {
201224 return cfg.NetworkMode == "gordon-test-example-com"
202225 })).Return(container, nil)
203226 runtime.EXPECT().StartContainer(mock.Anything, "container-123").Return(nil)
227+
228+ // Wait for ready
229+ runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(true, nil).Times(2)
230+
204231 runtime.EXPECT().InspectContainer(mock.Anything, "container-123").Return(&domain.Container{
205232 ID: "container-123",
206233 Status: "running",
207234 }, nil)
208235
236+ // Publish event
237+ eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
238+
209239 result, err := svc.Deploy(ctx, route)
210240
211241 assert.NoError(t, err)
@@ -220,6 +250,7 @@ func TestService_Deploy_WithVolumeAutoCreate(t *testing.T) {
220250 config := Config{
221251 VolumeAutoCreate: true,
222252 VolumePrefix: "gordon",
253+ ReadinessDelay: 0, // No delay for tests
223254 }
224255 svc := NewService(runtime, envLoader, eventBus, nil, config)
225256 ctx := testContext()
@@ -246,11 +277,18 @@ func TestService_Deploy_WithVolumeAutoCreate(t *testing.T) {
246277 return len(cfg.Volumes) == 2
247278 })).Return(container, nil)
248279 runtime.EXPECT().StartContainer(mock.Anything, "container-123").Return(nil)
280+
281+ // Wait for ready
282+ runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(true, nil).Times(2)
283+
249284 runtime.EXPECT().InspectContainer(mock.Anything, "container-123").Return(&domain.Container{
250285 ID: "container-123",
251286 Status: "running",
252287 }, nil)
253288
289+ // Publish event
290+ eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
291+
254292 result, err := svc.Deploy(ctx, route)
255293
256294 assert.NoError(t, err)
@@ -25,6 +25,7 @@ func TestService_Deploy_Success(t *testing.T) {
25 config := Config{25 config := Config{
26 NetworkIsolation: false,26 NetworkIsolation: false,
27 VolumeAutoCreate: false,27 VolumeAutoCreate: false,
28+ ReadinessDelay: 0, // No delay for tests
28 }29 }
29 30
30 svc := NewService(runtime, envLoader, eventBus, nil, config)31 svc := NewService(runtime, envLoader, eventBus, nil, config)
@@ -63,6 +64,9 @@ func TestService_Deploy_Success(t *testing.T) {
63 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(createdContainer, nil)64 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(createdContainer, nil)
64 runtime.EXPECT().StartContainer(mock.Anything, "container-123").Return(nil)65 runtime.EXPECT().StartContainer(mock.Anything, "container-123").Return(nil)
65 66
67+ // Wait for ready: IsContainerRunning (first check returns true) + verify after delay
68+ runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(true, nil).Times(2)
69+
66 // Re-inspect after start70 // Re-inspect after start
67 runningContainer := &domain.Container{71 runningContainer := &domain.Container{
68 ID: "container-123",72 ID: "container-123",
@@ -73,6 +77,9 @@ func TestService_Deploy_Success(t *testing.T) {
73 }77 }
74 runtime.EXPECT().InspectContainer(mock.Anything, "container-123").Return(runningContainer, nil)78 runtime.EXPECT().InspectContainer(mock.Anything, "container-123").Return(runningContainer, nil)
75 79
80+ // Publish container deployed event
81+ eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
82+
76 result, err := svc.Deploy(ctx, route)83 result, err := svc.Deploy(ctx, route)
77 84
78 assert.NoError(t, err)85 assert.NoError(t, err)
@@ -116,7 +123,9 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) {
116 envLoader := mocks.NewMockEnvLoader(t)123 envLoader := mocks.NewMockEnvLoader(t)
117 eventBus := mocks.NewMockEventPublisher(t)124 eventBus := mocks.NewMockEventPublisher(t)
118 125
119- config := Config{}126+ config := Config{
127+ ReadinessDelay: 0, // No delay for tests
128+ }
120 svc := NewService(runtime, envLoader, eventBus, nil, config)129 svc := NewService(runtime, envLoader, eventBus, nil, config)
121 ctx := testContext()130 ctx := testContext()
122 131
@@ -133,10 +142,6 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) {
133 Image: "myapp:v2",142 Image: "myapp:v2",
134 }143 }
135 144
136- // Stop and remove existing container
137- runtime.EXPECT().StopContainer(mock.Anything, "old-container").Return(nil)
138- runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil)
139-
140 // Cleanup orphans145 // Cleanup orphans
141 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)146 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
142 147
@@ -149,15 +154,32 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) {
149 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)154 envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
150 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)155 runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil)
151 156
152- // Create new container157+ // Create new container (with -new suffix for zero-downtime)
153- newContainer := &domain.Container{ID: "new-container", Status: "created"}158+ newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"}
154- runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(newContainer, nil)159+ runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
160+ return cfg.Name == "gordon-test.example.com-new"
161+ })).Return(newContainer, nil)
155 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)162 runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil)
163+
164+ // Wait for ready
165+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2)
166+
167+ // Inspect after ready
156 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{168 runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{
157 ID: "new-container",169 ID: "new-container",
158 Status: "running",170 Status: "running",
159 }, nil)171 }, nil)
160 172
173+ // Publish event
174+ eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
175+
176+ // Now cleanup old container (after new one is ready)
177+ runtime.EXPECT().StopContainer(mock.Anything, "old-container").Return(nil)
178+ runtime.EXPECT().RemoveContainer(mock.Anything, "old-container", true).Return(nil)
179+
180+ // Rename new container to canonical name
181+ runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil)
182+
161 result, err := svc.Deploy(ctx, route)183 result, err := svc.Deploy(ctx, route)
162 184
163 assert.NoError(t, err)185 assert.NoError(t, err)
@@ -177,6 +199,7 @@ func TestService_Deploy_WithNetworkIsolation(t *testing.T) {
177 config := Config{199 config := Config{
178 NetworkIsolation: true,200 NetworkIsolation: true,
179 NetworkPrefix: "gordon",201 NetworkPrefix: "gordon",
202+ ReadinessDelay: 0, // No delay for tests
180 }203 }
181 svc := NewService(runtime, envLoader, eventBus, nil, config)204 svc := NewService(runtime, envLoader, eventBus, nil, config)
182 ctx := testContext()205 ctx := testContext()
@@ -201,11 +224,18 @@ func TestService_Deploy_WithNetworkIsolation(t *testing.T) {
201 return cfg.NetworkMode == "gordon-test-example-com"224 return cfg.NetworkMode == "gordon-test-example-com"
202 })).Return(container, nil)225 })).Return(container, nil)
203 runtime.EXPECT().StartContainer(mock.Anything, "container-123").Return(nil)226 runtime.EXPECT().StartContainer(mock.Anything, "container-123").Return(nil)
227+
228+ // Wait for ready
229+ runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(true, nil).Times(2)
230+
204 runtime.EXPECT().InspectContainer(mock.Anything, "container-123").Return(&domain.Container{231 runtime.EXPECT().InspectContainer(mock.Anything, "container-123").Return(&domain.Container{
205 ID: "container-123",232 ID: "container-123",
206 Status: "running",233 Status: "running",
207 }, nil)234 }, nil)
208 235
236+ // Publish event
237+ eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
238+
209 result, err := svc.Deploy(ctx, route)239 result, err := svc.Deploy(ctx, route)
210 240
211 assert.NoError(t, err)241 assert.NoError(t, err)
@@ -220,6 +250,7 @@ func TestService_Deploy_WithVolumeAutoCreate(t *testing.T) {
220 config := Config{250 config := Config{
221 VolumeAutoCreate: true,251 VolumeAutoCreate: true,
222 VolumePrefix: "gordon",252 VolumePrefix: "gordon",
253+ ReadinessDelay: 0, // No delay for tests
223 }254 }
224 svc := NewService(runtime, envLoader, eventBus, nil, config)255 svc := NewService(runtime, envLoader, eventBus, nil, config)
225 ctx := testContext()256 ctx := testContext()
@@ -246,11 +277,18 @@ func TestService_Deploy_WithVolumeAutoCreate(t *testing.T) {
246 return len(cfg.Volumes) == 2277 return len(cfg.Volumes) == 2
247 })).Return(container, nil)278 })).Return(container, nil)
248 runtime.EXPECT().StartContainer(mock.Anything, "container-123").Return(nil)279 runtime.EXPECT().StartContainer(mock.Anything, "container-123").Return(nil)
280+
281+ // Wait for ready
282+ runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(true, nil).Times(2)
283+
249 runtime.EXPECT().InspectContainer(mock.Anything, "container-123").Return(&domain.Container{284 runtime.EXPECT().InspectContainer(mock.Anything, "container-123").Return(&domain.Container{
250 ID: "container-123",285 ID: "container-123",
251 Status: "running",286 Status: "running",
252 }, nil)287 }, nil)
253 288
289+ // Publish event
290+ eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
291+
254 result, err := svc.Deploy(ctx, route)292 result, err := svc.Deploy(ctx, route)
255 293
256 assert.NoError(t, err)294 assert.NoError(t, err)

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