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 ( | |||
| 8 | 8 | "slices" | |
| 9 | 9 | "strings" | |
| 10 | 10 | "sync" | |
| 11 | + | "time" | |
| 11 | 12 | ||
| 12 | 13 | "github.com/bnema/zerowrap" | |
| 13 | 14 | ||
| @@ -29,17 +30,19 @@ type Config struct { | |||
| 29 | 30 | DNSSuffix string | |
| 30 | 31 | NetworkGroups map[string][]string | |
| 31 | 32 | Attachments map[string][]string | |
| 33 | + | ReadinessDelay time.Duration // Delay after container starts before considering it ready | |
| 32 | 34 | } | |
| 33 | 35 | ||
| 34 | 36 | // Service implements the ContainerService interface. | |
| 35 | 37 | 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 | |
| 43 | 46 | } | |
| 44 | 47 | ||
| 45 | 48 | // NewService creates a new container service. | |
| @@ -51,16 +54,18 @@ func NewService( | |||
| 51 | 54 | config Config, | |
| 52 | 55 | ) *Service { | |
| 53 | 56 | 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), | |
| 60 | 64 | } | |
| 61 | 65 | } | |
| 62 | 66 | ||
| 63 | 67 | // Deploy creates and starts a container for the given route. | |
| 68 | + | // Implements zero-downtime deployment: new container starts before old one stops. | |
| 64 | 69 | func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Container, error) { | |
| 65 | 70 | // Enrich context with use case fields for all downstream logs | |
| 66 | 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 | 76 | log := zerowrap.FromCtx(ctx) | |
| 72 | 77 | ||
| 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() | |
| 90 | 82 | ||
| 91 | - | // Clean up orphaned containers | |
| 83 | + | // Clean up orphaned containers (containers with same name but not tracked) | |
| 92 | 84 | if err := s.cleanupOrphanedContainers(ctx, route.Domain); err != nil { | |
| 93 | 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 | 91 | 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 | 105 | // Get exposed ports | |
| 103 | 106 | exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, imageRef) | |
| 104 | 107 | if err != nil { | |
| @@ -118,16 +121,16 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta | |||
| 118 | 121 | return nil, err | |
| 119 | 122 | } | |
| 120 | 123 | ||
| 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) | |
| 125 | 128 | } | |
| 126 | 129 | ||
| 127 | 130 | // Create container | |
| 128 | 131 | containerConfig := &domain.ContainerConfig{ | |
| 129 | 132 | Image: imageRef, | |
| 130 | - | Name: fmt.Sprintf("gordon-%s", route.Domain), | |
| 133 | + | Name: containerName, | |
| 131 | 134 | Ports: exposedPorts, | |
| 132 | 135 | Env: envVars, | |
| 133 | 136 | Volumes: volumes, | |
| @@ -142,41 +145,54 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta | |||
| 142 | 145 | AutoRemove: false, | |
| 143 | 146 | } | |
| 144 | 147 | ||
| 145 | - | container, err := s.runtime.CreateContainer(ctx, containerConfig) | |
| 148 | + | newContainer, err := s.runtime.CreateContainer(ctx, containerConfig) | |
| 146 | 149 | if err != nil { | |
| 147 | 150 | return nil, log.WrapErr(err, "failed to create container") | |
| 148 | 151 | } | |
| 149 | 152 | ||
| 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) | |
| 153 | 156 | return nil, log.WrapErr(err, "failed to start container") | |
| 154 | 157 | } | |
| 155 | 158 | ||
| 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) | |
| 158 | 167 | if err != nil { | |
| 159 | 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) | |
| 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() | |
| 164 | 175 | ||
| 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) | |
| 166 | 186 | ||
| 167 | 187 | log.Info(). | |
| 168 | 188 | 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). | |
| 171 | 191 | Str("network", networkName). | |
| 192 | + | Bool("zero_downtime", hasExisting). | |
| 172 | 193 | Msg("container deployed successfully") | |
| 173 | 194 | ||
| 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 | |
| 180 | 196 | } | |
| 181 | 197 | ||
| 182 | 198 | // Stop stops a running container. | |
| @@ -373,6 +389,44 @@ func (s *Service) UpdateConfig(config Config) { | |||
| 373 | 389 | ||
| 374 | 390 | // 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 | 430 | // startLogCollection starts log collection for a container in the background. | |
| 377 | 431 | // Errors are logged but don't fail the calling operation. | |
| 378 | 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 | 649 | 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 | 653 | attachments, ok := s.config.Attachments[domainName] | |
| 600 | 654 | if !ok { | |
| 601 | 655 | return nil | |
| @@ -603,7 +657,7 @@ func (s *Service) deployAttachments(ctx context.Context, domainName string) erro | |||
| 603 | 657 | ||
| 604 | 658 | log := zerowrap.FromCtx(ctx) | |
| 605 | 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 | 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 | 665 | 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 | 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 | 762 | return nil | |
| 618 | 763 | } | |
| 619 | 764 | ||
| @@ -703,3 +848,101 @@ func mergeEnvironmentVariables(dockerfileEnv, userEnv []string) []string { | |||
| 703 | 848 | ||
| 704 | 849 | 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 | + | } | |
| @@ -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 string | 30 | DNSSuffix string | ||
| 30 | NetworkGroups map[string][]string | 31 | NetworkGroups map[string][]string | ||
| 31 | Attachments map[string][]string | 32 | 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.ContainerRuntime | 38 | + | runtime out.ContainerRuntime |
| 37 | - | envLoader out.EnvLoader | 39 | + | envLoader out.EnvLoader |
| 38 | - | eventBus out.EventPublisher | 40 | + | eventBus out.EventPublisher |
| 39 | - | logWriter out.ContainerLogWriter | 41 | + | logWriter out.ContainerLogWriter |
| 40 | - | config Config | 42 | + | config Config |
| 41 | - | containers map[string]*domain.Container | 43 | + | containers map[string]*domain.Container |
| 42 | - | mu sync.RWMutex | 44 | + | 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 logs | 70 | // 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 domain | 81 | + | 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 containers | 83 | + | // 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, err | 91 | 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 ports | 105 | // 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, err | 121 | return nil, err | ||
| 119 | } | 122 | } | ||
| 120 | 123 | ||||
| 121 | - | // Setup network | 124 | + | // 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 container | 130 | // 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 container | 153 | + | // 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 info | 159 | + | // 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] = 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) | |||
| 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 attachments | 195 | + | 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 methods | 390 | // 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 nil | 649 | 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 nil | 655 | 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 nil | 665 | 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 nil | 762 | return nil | ||
| 618 | } | 763 | } | ||
| 619 | 764 | ||||
| @@ -703,3 +848,101 @@ func mergeEnvironmentVariables(dockerfileEnv, userEnv []string) []string { | |||||
| 703 | 848 | ||||
| 704 | return result | 849 | 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) { | |||
| 25 | 25 | config := Config{ | |
| 26 | 26 | NetworkIsolation: false, | |
| 27 | 27 | VolumeAutoCreate: false, | |
| 28 | + | ReadinessDelay: 0, // No delay for tests | |
| 28 | 29 | } | |
| 29 | 30 | ||
| 30 | 31 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| @@ -63,6 +64,9 @@ func TestService_Deploy_Success(t *testing.T) { | |||
| 63 | 64 | runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(createdContainer, nil) | |
| 64 | 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 | 70 | // Re-inspect after start | |
| 67 | 71 | runningContainer := &domain.Container{ | |
| 68 | 72 | ID: "container-123", | |
| @@ -73,6 +77,9 @@ func TestService_Deploy_Success(t *testing.T) { | |||
| 73 | 77 | } | |
| 74 | 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 | 83 | result, err := svc.Deploy(ctx, route) | |
| 77 | 84 | ||
| 78 | 85 | assert.NoError(t, err) | |
| @@ -116,7 +123,9 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) { | |||
| 116 | 123 | envLoader := mocks.NewMockEnvLoader(t) | |
| 117 | 124 | eventBus := mocks.NewMockEventPublisher(t) | |
| 118 | 125 | ||
| 119 | - | config := Config{} | |
| 126 | + | config := Config{ | |
| 127 | + | ReadinessDelay: 0, // No delay for tests | |
| 128 | + | } | |
| 120 | 129 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 121 | 130 | ctx := testContext() | |
| 122 | 131 | ||
| @@ -133,10 +142,6 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) { | |||
| 133 | 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 | 145 | // Cleanup orphans | |
| 141 | 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 | 154 | envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil) | |
| 150 | 155 | runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil) | |
| 151 | 156 | ||
| 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) | |
| 155 | 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 | 168 | runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{ | |
| 157 | 169 | ID: "new-container", | |
| 158 | 170 | Status: "running", | |
| 159 | 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 | 183 | result, err := svc.Deploy(ctx, route) | |
| 162 | 184 | ||
| 163 | 185 | assert.NoError(t, err) | |
| @@ -177,6 +199,7 @@ func TestService_Deploy_WithNetworkIsolation(t *testing.T) { | |||
| 177 | 199 | config := Config{ | |
| 178 | 200 | NetworkIsolation: true, | |
| 179 | 201 | NetworkPrefix: "gordon", | |
| 202 | + | ReadinessDelay: 0, // No delay for tests | |
| 180 | 203 | } | |
| 181 | 204 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 182 | 205 | ctx := testContext() | |
| @@ -201,11 +224,18 @@ func TestService_Deploy_WithNetworkIsolation(t *testing.T) { | |||
| 201 | 224 | return cfg.NetworkMode == "gordon-test-example-com" | |
| 202 | 225 | })).Return(container, nil) | |
| 203 | 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 | 231 | runtime.EXPECT().InspectContainer(mock.Anything, "container-123").Return(&domain.Container{ | |
| 205 | 232 | ID: "container-123", | |
| 206 | 233 | Status: "running", | |
| 207 | 234 | }, nil) | |
| 208 | 235 | ||
| 236 | + | // Publish event | |
| 237 | + | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil) | |
| 238 | + | ||
| 209 | 239 | result, err := svc.Deploy(ctx, route) | |
| 210 | 240 | ||
| 211 | 241 | assert.NoError(t, err) | |
| @@ -220,6 +250,7 @@ func TestService_Deploy_WithVolumeAutoCreate(t *testing.T) { | |||
| 220 | 250 | config := Config{ | |
| 221 | 251 | VolumeAutoCreate: true, | |
| 222 | 252 | VolumePrefix: "gordon", | |
| 253 | + | ReadinessDelay: 0, // No delay for tests | |
| 223 | 254 | } | |
| 224 | 255 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 225 | 256 | ctx := testContext() | |
| @@ -246,11 +277,18 @@ func TestService_Deploy_WithVolumeAutoCreate(t *testing.T) { | |||
| 246 | 277 | return len(cfg.Volumes) == 2 | |
| 247 | 278 | })).Return(container, nil) | |
| 248 | 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 | 284 | runtime.EXPECT().InspectContainer(mock.Anything, "container-123").Return(&domain.Container{ | |
| 250 | 285 | ID: "container-123", | |
| 251 | 286 | Status: "running", | |
| 252 | 287 | }, nil) | |
| 253 | 288 | ||
| 289 | + | // Publish event | |
| 290 | + | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil) | |
| 291 | + | ||
| 254 | 292 | result, err := svc.Deploy(ctx, route) | |
| 255 | 293 | ||
| 256 | 294 | 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 start | 70 | // 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 orphans | 145 | // 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 container | 157 | + | // 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) == 2 | 277 | 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) | ||