1// Package container implements the container management use case.
2package container
3
4import (
5 "context"
6 "fmt"
7 "maps"
8 "slices"
9 "strings"
10 "sync"
11 "time"
12
13 "github.com/bnema/zerowrap"
14
15 "gordon/internal/boundaries/out"
16 "gordon/internal/domain"
17)
18
19// Config holds configuration needed by the container service.
20type Config struct {
21 RegistryAuthEnabled bool
22 RegistryDomain string
23 RegistryPort int
24 RegistryUsername string
25 RegistryPassword string
26 InternalRegistryUsername string
27 InternalRegistryPassword string
28 VolumeAutoCreate bool
29 VolumePrefix string
30 VolumePreserve bool
31 NetworkIsolation bool
32 NetworkPrefix string
33 DNSSuffix string
34 NetworkGroups map[string][]string
35 Attachments map[string][]string
36 ReadinessDelay time.Duration // Delay after container starts before considering it ready
37}
38
39// Service implements the ContainerService interface.
40type Service struct {
41 runtime out.ContainerRuntime
42 envLoader out.EnvLoader
43 eventBus out.EventPublisher
44 logWriter out.ContainerLogWriter
45 config Config
46 containers map[string]*domain.Container
47 attachments map[string][]string // ownerDomain → []containerIDs
48 mu sync.RWMutex
49}
50
51// NewService creates a new container service.
52func NewService(
53 runtime out.ContainerRuntime,
54 envLoader out.EnvLoader,
55 eventBus out.EventPublisher,
56 logWriter out.ContainerLogWriter,
57 config Config,
58) *Service {
59 return &Service{
60 runtime: runtime,
61 envLoader: envLoader,
62 eventBus: eventBus,
63 logWriter: logWriter,
64 config: config,
65 containers: make(map[string]*domain.Container),
66 attachments: make(map[string][]string),
67 }
68}
69
70// Deploy creates and starts a container for the given route.
71// Implements zero-downtime deployment: new container starts before old one stops.
72func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Container, error) {
73 // Enrich context with use case fields for all downstream logs
74 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
75 zerowrap.FieldLayer: "usecase",
76 zerowrap.FieldUseCase: "Deploy",
77 "domain": route.Domain,
78 })
79 log := zerowrap.FromCtx(ctx)
80
81 // Get existing container (if any) without holding lock
82 s.mu.RLock()
83 existing, hasExisting := s.containers[route.Domain]
84 s.mu.RUnlock()
85
86 // Clean up orphaned containers (containers with same name but not tracked)
87 if err := s.cleanupOrphanedContainers(ctx, route.Domain); err != nil {
88 log.WrapErr(err, "failed to cleanup orphaned containers")
89 }
90
91 // Build image reference and ensure it's available.
92 // ensureImage returns the canonical reference; internal pulls may use the local registry.
93 imageRef := s.buildImageRef(route.Image)
94 actualImageRef, err := s.ensureImage(ctx, imageRef)
95 if err != nil {
96 return nil, err
97 }
98
99 // Setup network FIRST (attachments need it)
100 networkName := s.getNetworkForApp(route.Domain)
101 if err := s.createNetworkIfNeeded(ctx, networkName); err != nil {
102 return nil, log.WrapErr(err, "failed to create network")
103 }
104
105 // Deploy attachments BEFORE main container (dependencies first)
106 if err := s.deployAttachments(ctx, route.Domain, networkName); err != nil {
107 log.WrapErr(err, "failed to deploy some attachments")
108 }
109
110 // Get exposed ports (use actual image ref from pull)
111 exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, actualImageRef)
112 if err != nil {
113 log.WrapErr(err, "failed to get exposed ports, using defaults")
114 exposedPorts = []int{80, 8080, 3000}
115 }
116
117 // Load environment
118 envVars, err := s.loadEnvironment(ctx, route.Domain, actualImageRef)
119 if err != nil {
120 return nil, err
121 }
122
123 // Setup volumes (use actual image ref from pull)
124 volumes, err := s.setupVolumes(ctx, route.Domain, actualImageRef)
125 if err != nil {
126 return nil, err
127 }
128
129 // Determine container name (use temp suffix for zero-downtime if existing)
130 containerName := fmt.Sprintf("gordon-%s", route.Domain)
131 if hasExisting {
132 containerName = fmt.Sprintf("gordon-%s-new", route.Domain)
133 }
134
135 // Create container (use actual image ref from pull, but track original in labels)
136 containerConfig := &domain.ContainerConfig{
137 Image: actualImageRef,
138 Name: containerName,
139 Ports: exposedPorts,
140 Env: envVars,
141 Volumes: volumes,
142 NetworkMode: networkName,
143 Hostname: route.Domain,
144 Labels: map[string]string{
145 "gordon.domain": route.Domain,
146 "gordon.image": route.Image,
147 "gordon.managed": "true",
148 "gordon.route": route.Domain,
149 },
150 AutoRemove: false,
151 }
152
153 newContainer, err := s.runtime.CreateContainer(ctx, containerConfig)
154 if err != nil {
155 return nil, log.WrapErr(err, "failed to create container")
156 }
157
158 // Start new container
159 if err := s.runtime.StartContainer(ctx, newContainer.ID); err != nil {
160 s.runtime.RemoveContainer(ctx, newContainer.ID, true)
161 return nil, log.WrapErr(err, "failed to start container")
162 }
163
164 // Wait for new container to be ready
165 if err := s.waitForReady(ctx, newContainer.ID); err != nil {
166 s.cleanupFailedContainer(ctx, newContainer.ID)
167 return nil, log.WrapErr(err, "container failed readiness check")
168 }
169
170 // Re-inspect for updated info (ports, etc.)
171 newContainer, err = s.runtime.InspectContainer(ctx, newContainer.ID)
172 if err != nil {
173 return nil, log.WrapErr(err, "failed to inspect started container")
174 }
175
176 // ATOMIC SWITCH: Update tracking first (proxy will now route to new container)
177 s.mu.Lock()
178 s.containers[route.Domain] = newContainer
179 s.mu.Unlock()
180
181 // Publish container deployed event (proxy will invalidate cache)
182 s.publishContainerDeployed(ctx, route.Domain, newContainer.ID)
183
184 // NOW stop and remove old container (traffic already going to new one)
185 if hasExisting {
186 s.cleanupOldContainer(ctx, existing, newContainer.ID, route.Domain)
187 }
188
189 // Start container log collection (non-blocking, errors don't fail deployment)
190 s.startLogCollection(ctx, newContainer.ID, route.Domain)
191
192 log.Info().
193 Str("image", route.Image).
194 Str(zerowrap.FieldEntityID, newContainer.ID).
195 Ints("ports", newContainer.Ports).
196 Str("network", networkName).
197 Bool("zero_downtime", hasExisting).
198 Msg("container deployed successfully")
199
200 return newContainer, nil
201}
202
203// Stop stops a running container.
204func (s *Service) Stop(ctx context.Context, containerID string) error {
205 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
206 zerowrap.FieldLayer: "usecase",
207 zerowrap.FieldUseCase: "Stop",
208 zerowrap.FieldEntityID: containerID,
209 })
210 log := zerowrap.FromCtx(ctx)
211
212 // Stop log collection before stopping container
213 if s.logWriter != nil {
214 if err := s.logWriter.StopLogging(containerID); err != nil {
215 log.Warn().Err(err).Msg("failed to stop container log collection")
216 }
217 }
218
219 if err := s.runtime.StopContainer(ctx, containerID); err != nil {
220 return log.WrapErr(err, "failed to stop container")
221 }
222
223 log.Info().Msg("container stopped")
224 return nil
225}
226
227// Remove removes a container.
228func (s *Service) Remove(ctx context.Context, containerID string, force bool) error {
229 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
230 zerowrap.FieldLayer: "usecase",
231 zerowrap.FieldUseCase: "Remove",
232 zerowrap.FieldEntityID: containerID,
233 })
234 log := zerowrap.FromCtx(ctx)
235
236 // Stop log collection before removing container
237 if s.logWriter != nil {
238 if err := s.logWriter.StopLogging(containerID); err != nil {
239 log.Warn().Err(err).Msg("failed to stop container log collection")
240 }
241 }
242
243 // Find domain for cleanup
244 var containerDomain string
245 s.mu.RLock()
246 for d, c := range s.containers {
247 if c.ID == containerID {
248 containerDomain = d
249 break
250 }
251 }
252 s.mu.RUnlock()
253
254 if err := s.runtime.RemoveContainer(ctx, containerID, force); err != nil {
255 return log.WrapErr(err, "failed to remove container")
256 }
257
258 // Cleanup volumes
259 if containerDomain != "" && !s.config.VolumePreserve {
260 if err := s.cleanupVolumesForDomain(ctx, containerDomain); err != nil {
261 log.WrapErrWithFields(err, "failed to cleanup volumes", map[string]any{"domain": containerDomain})
262 }
263 }
264
265 // Remove from tracking
266 s.mu.Lock()
267 var removedDomain string
268 for d, c := range s.containers {
269 if c.ID == containerID {
270 delete(s.containers, d)
271 removedDomain = d
272 log.Info().Str("domain", d).Msg("container removed")
273 break
274 }
275 }
276 s.mu.Unlock()
277
278 // Cleanup network
279 if removedDomain != "" && s.config.NetworkIsolation {
280 networkName := s.getNetworkForApp(removedDomain)
281 if err := s.cleanupNetworkIfEmpty(ctx, networkName); err != nil {
282 log.WrapErrWithFields(err, "failed to cleanup network", map[string]any{"network": networkName})
283 }
284 }
285
286 return nil
287}
288
289// Get retrieves a container by domain.
290func (s *Service) Get(_ context.Context, domainName string) (*domain.Container, bool) {
291 s.mu.RLock()
292 defer s.mu.RUnlock()
293 container, exists := s.containers[domainName]
294 return container, exists
295}
296
297// List returns all managed containers.
298func (s *Service) List(_ context.Context) map[string]*domain.Container {
299 s.mu.RLock()
300 defer s.mu.RUnlock()
301
302 result := make(map[string]*domain.Container, len(s.containers))
303 maps.Copy(result, s.containers)
304 return result
305}
306
307// HealthCheck performs health checks on all containers.
308func (s *Service) HealthCheck(ctx context.Context) map[string]bool {
309 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
310 zerowrap.FieldLayer: "usecase",
311 zerowrap.FieldUseCase: "HealthCheck",
312 })
313 log := zerowrap.FromCtx(ctx)
314
315 s.mu.RLock()
316 defer s.mu.RUnlock()
317
318 health := make(map[string]bool, len(s.containers))
319 for d, c := range s.containers {
320 running, err := s.runtime.IsContainerRunning(ctx, c.ID)
321 if err != nil {
322 log.WrapErrWithFields(err, "health check failed", map[string]any{"domain": d, zerowrap.FieldEntityID: c.ID})
323 health[d] = false
324 } else {
325 health[d] = running
326 }
327 }
328 return health
329}
330
331// SyncContainers synchronizes containers with runtime state.
332func (s *Service) SyncContainers(ctx context.Context) error {
333 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
334 zerowrap.FieldLayer: "usecase",
335 zerowrap.FieldUseCase: "SyncContainers",
336 })
337 log := zerowrap.FromCtx(ctx)
338
339 s.mu.Lock()
340 defer s.mu.Unlock()
341
342 allContainers, err := s.runtime.ListContainers(ctx, false)
343 if err != nil {
344 return log.WrapErr(err, "failed to list containers")
345 }
346
347 managed := make(map[string]*domain.Container)
348 for _, c := range allContainers {
349 if c.Labels != nil {
350 if d, ok := c.Labels["gordon.domain"]; ok && c.Labels["gordon.managed"] == "true" {
351 managed[d] = c
352 }
353 }
354 }
355
356 s.containers = managed
357 log.Info().Int(zerowrap.FieldCount, len(managed)).Msg("container state synchronized")
358 return nil
359}
360
361// AutoStart starts containers for configured routes.
362func (s *Service) AutoStart(_ context.Context) error {
363 return nil
364}
365
366// Shutdown gracefully shuts down all managed containers.
367func (s *Service) Shutdown(ctx context.Context) error {
368 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
369 zerowrap.FieldLayer: "usecase",
370 zerowrap.FieldUseCase: "Shutdown",
371 })
372 log := zerowrap.FromCtx(ctx)
373 log.Info().Msg("shutting down container manager...")
374
375 if err := s.stopAllManagedContainers(ctx); err != nil {
376 log.WrapErr(err, "failed to stop all containers during shutdown")
377 }
378
379 // Close log writer to stop all log collection
380 if s.logWriter != nil {
381 if err := s.logWriter.Close(); err != nil {
382 log.Warn().Err(err).Msg("failed to close container log writer")
383 }
384 }
385
386 log.Info().Msg("container manager shutdown complete")
387 return nil
388}
389
390// UpdateConfig updates the service configuration.
391func (s *Service) UpdateConfig(config Config) {
392 s.config = config
393}
394
395// Helper methods
396
397// cleanupFailedContainer stops and removes a container that failed to start properly.
398func (s *Service) cleanupFailedContainer(ctx context.Context, containerID string) {
399 log := zerowrap.FromCtx(ctx)
400 if err := s.runtime.StopContainer(ctx, containerID); err != nil {
401 log.Warn().Err(err).Str(zerowrap.FieldEntityID, containerID).Msg("failed to stop container after failure")
402 }
403 if err := s.runtime.RemoveContainer(ctx, containerID, true); err != nil {
404 log.Warn().Err(err).Str(zerowrap.FieldEntityID, containerID).Msg("failed to remove container after failure")
405 }
406}
407
408// cleanupOldContainer stops and removes an old container after zero-downtime switch.
409// It also renames the new container to the canonical name.
410func (s *Service) cleanupOldContainer(ctx context.Context, old *domain.Container, newContainerID, domainName string) {
411 log := zerowrap.FromCtx(ctx)
412 log.Info().Str(zerowrap.FieldEntityID, old.ID).Msg("stopping old container after zero-downtime switch")
413
414 if s.logWriter != nil {
415 if err := s.logWriter.StopLogging(old.ID); err != nil {
416 log.Warn().Err(err).Str(zerowrap.FieldEntityID, old.ID).Msg("failed to stop logging for old container")
417 }
418 }
419
420 if err := s.runtime.StopContainer(ctx, old.ID); err != nil {
421 log.Warn().Err(err).Str(zerowrap.FieldEntityID, old.ID).Msg("failed to stop old container")
422 }
423
424 if err := s.runtime.RemoveContainer(ctx, old.ID, true); err != nil {
425 log.Warn().Err(err).Str(zerowrap.FieldEntityID, old.ID).Msg("failed to remove old container")
426 }
427
428 // Rename new container to canonical name
429 canonicalName := fmt.Sprintf("gordon-%s", domainName)
430 if err := s.runtime.RenameContainer(ctx, newContainerID, canonicalName); err != nil {
431 log.Warn().Err(err).Str("canonical_name", canonicalName).Msg("failed to rename container to canonical name")
432 }
433}
434
435// startLogCollection starts log collection for a container in the background.
436// Errors are logged but don't fail the calling operation.
437func (s *Service) startLogCollection(ctx context.Context, containerID, domainName string) {
438 if s.logWriter == nil {
439 return
440 }
441
442 log := zerowrap.FromCtx(ctx)
443
444 logStream, err := s.runtime.GetContainerLogs(ctx, containerID, true)
445 if err != nil {
446 log.Warn().Err(err).Msg("failed to get container logs for collection")
447 return
448 }
449
450 if err := s.logWriter.StartLogging(ctx, containerID, domainName, logStream); err != nil {
451 log.Warn().Err(err).Msg("failed to start container log collection")
452 logStream.Close()
453 }
454}
455
456func (s *Service) buildImageRef(image string) string {
457 if !s.config.RegistryAuthEnabled || s.config.RegistryDomain == "" {
458 return image
459 }
460
461 repoPart := strings.Split(image, ":")[0]
462 if strings.HasPrefix(repoPart, s.config.RegistryDomain+"/") {
463 return image
464 }
465 if strings.Contains(repoPart, ".") && strings.Contains(repoPart, "/") {
466 return image
467 }
468 return fmt.Sprintf("%s/%s", s.config.RegistryDomain, image)
469}
470
471func (s *Service) pullRefForDeploy(ctx context.Context, imageRef string) (string, bool) {
472 if !domain.IsInternalDeploy(ctx) {
473 return imageRef, false
474 }
475 return rewriteToLocalRegistry(imageRef, s.config.RegistryDomain, s.config.RegistryPort), true
476}
477
478// ensureImage ensures the image is available locally, pulling if needed.
479// Returns the canonical image reference to use for container operations.
480func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, error) {
481 ctx = zerowrap.CtxWithField(ctx, "image", imageRef)
482 log := zerowrap.FromCtx(ctx)
483
484 // Determine if this is an internal deploy and what reference to use for pulls.
485 pullRef, isInternal := s.pullRefForDeploy(ctx, imageRef)
486 if pullRef != imageRef {
487 log.Info().
488 Str("original_ref", imageRef).
489 Str("pull_ref", pullRef).
490 Msg("internal deploy: using localhost registry for pull")
491 }
492
493 found, err := s.ensureLocalImage(ctx, imageRef, pullRef)
494 if err != nil {
495 return "", err
496 }
497 if found {
498 return imageRef, nil
499 }
500
501 // Pull image
502 log.Info().Msg("pulling image from registry")
503
504 if err := s.pullImage(ctx, pullRef, isInternal); err != nil {
505 return "", err
506 }
507
508 if err := s.tagImageIfNeeded(ctx, pullRef, imageRef); err != nil {
509 return "", err
510 }
511
512 // Clean up the temporary pull reference tag to avoid duplicate entries
513 if pullRef != imageRef {
514 if err := s.runtime.UntagImage(ctx, pullRef); err != nil {
515 // Log but don't fail - the canonical tag is already applied
516 log.Debug().Err(err).Str("pull_ref", pullRef).Msg("failed to remove temporary pull tag")
517 }
518 }
519
520 log.Info().Msg("image pulled successfully")
521 return imageRef, nil
522}
523
524func (s *Service) ensureLocalImage(ctx context.Context, imageRef, pullRef string) (bool, error) {
525 log := zerowrap.FromCtx(ctx)
526
527 localImages, err := s.runtime.ListImages(ctx)
528 if err != nil {
529 log.WrapErr(err, "failed to list local images, will attempt pull")
530 return false, nil
531 }
532
533 normalizedRef := normalizeImageRef(imageRef)
534 normalizedPullRef := normalizeImageRef(pullRef)
535 for _, img := range localImages {
536 normalizedImage := normalizeImageRef(img)
537 if normalizedImage == normalizedRef {
538 log.Info().Msg("image found locally, skipping pull")
539 return true, nil
540 }
541 if normalizedImage == normalizedPullRef {
542 if err := s.tagImageIfNeeded(ctx, pullRef, imageRef); err != nil {
543 return false, err
544 }
545 // Clean up the temporary pull reference tag
546 if pullRef != imageRef {
547 if err := s.runtime.UntagImage(ctx, pullRef); err != nil {
548 log.Debug().Err(err).Str("pull_ref", pullRef).Msg("failed to remove temporary pull tag")
549 }
550 }
551 log.Info().Msg("image found locally, skipping pull")
552 return true, nil
553 }
554 }
555
556 return false, nil
557}
558
559func (s *Service) pullImage(ctx context.Context, pullRef string, isInternal bool) error {
560 log := zerowrap.FromCtx(ctx)
561
562 switch {
563 case isInternal && s.config.RegistryAuthEnabled:
564 if s.config.InternalRegistryUsername == "" || s.config.InternalRegistryPassword == "" {
565 return log.WrapErr(fmt.Errorf("internal registry auth not configured"), "failed to pull image for internal deploy")
566 }
567 if err := s.runtime.PullImageWithAuth(ctx, pullRef, s.config.InternalRegistryUsername, s.config.InternalRegistryPassword); err != nil {
568 return log.WrapErr(err, "failed to pull image with internal auth")
569 }
570 case isInternal:
571 if err := s.runtime.PullImage(ctx, pullRef); err != nil {
572 return log.WrapErr(err, "failed to pull image")
573 }
574 case s.config.RegistryAuthEnabled:
575 if err := s.runtime.PullImageWithAuth(ctx, pullRef, s.config.RegistryUsername, s.config.RegistryPassword); err != nil {
576 return log.WrapErr(err, "failed to pull image with auth")
577 }
578 default:
579 if err := s.runtime.PullImage(ctx, pullRef); err != nil {
580 return log.WrapErr(err, "failed to pull image")
581 }
582 }
583
584 return nil
585}
586
587func (s *Service) tagImageIfNeeded(ctx context.Context, sourceRef, targetRef string) error {
588 if sourceRef == targetRef {
589 return nil
590 }
591
592 log := zerowrap.FromCtx(ctx)
593 if err := s.runtime.TagImage(ctx, sourceRef, targetRef); err != nil {
594 return log.WrapErr(err, "failed to tag image from pull reference")
595 }
596 return nil
597}
598
599func (s *Service) loadEnvironment(ctx context.Context, domainName, imageRef string) ([]string, error) {
600 log := zerowrap.FromCtx(ctx)
601
602 userEnvVars, err := s.envLoader.LoadEnv(ctx, domainName)
603 if err != nil {
604 return nil, log.WrapErr(err, "failed to load environment variables")
605 }
606
607 dockerfileEnvVars, err := s.runtime.InspectImageEnv(ctx, imageRef)
608 if err != nil {
609 log.WrapErr(err, "failed to inspect image environment")
610 dockerfileEnvVars = []string{}
611 }
612
613 return mergeEnvironmentVariables(dockerfileEnvVars, userEnvVars), nil
614}
615
616func (s *Service) setupVolumes(ctx context.Context, domainName, imageRef string) (map[string]string, error) {
617 log := zerowrap.FromCtx(ctx)
618 volumes := make(map[string]string)
619
620 if !s.config.VolumeAutoCreate {
621 return volumes, nil
622 }
623
624 volumePaths, err := s.runtime.InspectImageVolumes(ctx, imageRef)
625 if err != nil {
626 log.WrapErr(err, "failed to inspect image volumes")
627 return volumes, nil
628 }
629
630 for _, path := range volumePaths {
631 name := generateVolumeName(s.config.VolumePrefix, domainName, path)
632
633 exists, err := s.runtime.VolumeExists(ctx, name)
634 if err != nil {
635 log.WrapErrWithFields(err, "failed to check volume", map[string]any{"volume": name})
636 continue
637 }
638
639 if !exists {
640 if err := s.runtime.CreateVolume(ctx, name); err != nil {
641 log.WrapErrWithFields(err, "failed to create volume", map[string]any{"volume": name})
642 continue
643 }
644 log.Info().Str("volume", name).Str(zerowrap.FieldPath, path).Msg("created volume")
645 }
646
647 volumes[path] = name
648 }
649
650 return volumes, nil
651}
652
653func (s *Service) getNetworkForApp(domainName string) string {
654 if !s.config.NetworkIsolation {
655 return "bridge"
656 }
657
658 for groupName, domains := range s.config.NetworkGroups {
659 if slices.Contains(domains, domainName) {
660 return s.generateNetworkName(groupName)
661 }
662 }
663
664 return s.generateNetworkName(domainName)
665}
666
667func (s *Service) generateNetworkName(identifier string) string {
668 return fmt.Sprintf("%s-%s", s.config.NetworkPrefix, strings.ReplaceAll(identifier, ".", "-"))
669}
670
671func (s *Service) createNetworkIfNeeded(ctx context.Context, networkName string) error {
672 if networkName == "bridge" || networkName == "default" {
673 return nil
674 }
675
676 ctx = zerowrap.CtxWithField(ctx, "network", networkName)
677 log := zerowrap.FromCtx(ctx)
678
679 exists, err := s.runtime.NetworkExists(ctx, networkName)
680 if err != nil {
681 return log.WrapErr(err, "failed to check network existence")
682 }
683
684 if !exists {
685 if err := s.runtime.CreateNetwork(ctx, networkName, map[string]string{"driver": "bridge"}); err != nil {
686 return log.WrapErr(err, "failed to create network")
687 }
688 log.Info().Msg("created network for app isolation")
689 }
690
691 return nil
692}
693
694func (s *Service) cleanupOrphanedContainers(ctx context.Context, domainName string) error {
695 log := zerowrap.FromCtx(ctx)
696 expectedName := fmt.Sprintf("gordon-%s", domainName)
697
698 allContainers, err := s.runtime.ListContainers(ctx, true)
699 if err != nil {
700 return err
701 }
702
703 for _, c := range allContainers {
704 if c.Name == expectedName {
705 log.Info().Str(zerowrap.FieldEntityID, c.ID).Str(zerowrap.FieldStatus, c.Status).Msg("found orphaned container, removing")
706
707 if err := s.runtime.StopContainer(ctx, c.ID); err != nil {
708 log.WrapErrWithFields(err, "failed to stop orphaned container", map[string]any{zerowrap.FieldEntityID: c.ID})
709 }
710
711 if err := s.runtime.RemoveContainer(ctx, c.ID, true); err != nil {
712 log.WrapErrWithFields(err, "failed to remove orphaned container", map[string]any{zerowrap.FieldEntityID: c.ID})
713 }
714 }
715 }
716
717 return nil
718}
719
720func (s *Service) cleanupVolumesForDomain(_ context.Context, _ string) error {
721 return nil
722}
723
724func (s *Service) cleanupNetworkIfEmpty(ctx context.Context, networkName string) error {
725 if networkName == "bridge" || networkName == "default" {
726 return nil
727 }
728
729 ctx = zerowrap.CtxWithField(ctx, "network", networkName)
730 log := zerowrap.FromCtx(ctx)
731
732 networks, err := s.runtime.ListNetworks(ctx)
733 if err != nil {
734 return log.WrapErr(err, "failed to list networks")
735 }
736
737 for _, n := range networks {
738 if n.Name == networkName && len(n.Containers) == 0 {
739 if err := s.runtime.RemoveNetwork(ctx, networkName); err != nil {
740 return err
741 }
742 log.Info().Msg("cleaned up empty network")
743 break
744 }
745 }
746
747 return nil
748}
749
750func (s *Service) deployAttachments(ctx context.Context, domainName, networkName string) error {
751 attachments, ok := s.config.Attachments[domainName]
752 if !ok {
753 return nil
754 }
755
756 log := zerowrap.FromCtx(ctx)
757 for _, svc := range attachments {
758 if err := s.deployAttachedService(ctx, domainName, svc, networkName); err != nil {
759 log.WrapErrWithFields(err, "failed to deploy attachment", map[string]any{zerowrap.FieldService: svc, "domain": domainName})
760 }
761 }
762
763 return nil
764}
765
766func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, serviceImage, networkName string) error {
767 log := zerowrap.FromCtx(ctx)
768
769 // Parse service name from image (e.g., "my-postgres:latest" → "postgres")
770 serviceName := extractServiceName(serviceImage)
771 containerName := fmt.Sprintf("gordon-%s-%s", sanitizeName(ownerDomain), serviceName)
772
773 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
774 "attachment": serviceName,
775 "container_name": containerName,
776 "owner_domain": ownerDomain,
777 })
778
779 // Check if already running (idempotent)
780 existingContainer := s.findContainerByName(ctx, containerName)
781 if existingContainer != nil && existingContainer.Status == string(domain.ContainerStatusRunning) {
782 log.Debug().Str("container_name", containerName).Msg("attachment already running, skipping")
783 return nil
784 }
785
786 // Remove existing stopped container if present
787 if existingContainer != nil {
788 log.Info().Str("container_name", containerName).Msg("removing stopped attachment container")
789 if err := s.runtime.RemoveContainer(ctx, existingContainer.ID, true); err != nil {
790 log.WrapErr(err, "failed to remove existing attachment container")
791 }
792 }
793
794 log.Info().Str(zerowrap.FieldService, serviceImage).Msg("deploying attached service")
795
796 // Ensure image (canonical ref is returned; internal pulls may use the local registry).
797 imageRef := s.buildImageRef(serviceImage)
798 actualImageRef, err := s.ensureImage(ctx, imageRef)
799 if err != nil {
800 return err
801 }
802
803 // Get image metadata
804 exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, actualImageRef)
805 if err != nil {
806 log.WrapErr(err, "failed to get exposed ports for attachment, using defaults")
807 exposedPorts = []int{}
808 }
809
810 // Setup volumes (attachments need persistent data)
811 volumes, err := s.setupVolumes(ctx, containerName, actualImageRef)
812 if err != nil {
813 log.WrapErr(err, "failed to setup volumes for attachment")
814 volumes = make(map[string]string)
815 }
816
817 // Load environment (attachment-specific env file)
818 envVars, err := s.loadEnvironment(ctx, containerName, actualImageRef)
819 if err != nil {
820 log.WrapErr(err, "failed to load environment for attachment")
821 envVars = []string{}
822 }
823
824 // Create container with attachment labels (use actual ref, track original in labels)
825 config := &domain.ContainerConfig{
826 Image: actualImageRef,
827 Name: containerName,
828 Hostname: serviceName, // Internal DNS: postgres, redis, etc.
829 Ports: exposedPorts,
830 Env: envVars,
831 Volumes: volumes,
832 NetworkMode: networkName, // Same network as main app
833 Labels: map[string]string{
834 "gordon.managed": "true",
835 "gordon.attachment": "true",
836 "gordon.attached-to": ownerDomain,
837 "gordon.image": serviceImage,
838 },
839 }
840
841 container, err := s.runtime.CreateContainer(ctx, config)
842 if err != nil {
843 return log.WrapErr(err, "failed to create attachment container")
844 }
845
846 // Start container
847 if err := s.runtime.StartContainer(ctx, container.ID); err != nil {
848 s.runtime.RemoveContainer(ctx, container.ID, true)
849 return log.WrapErr(err, "failed to start attachment container")
850 }
851
852 // Track attachment
853 s.mu.Lock()
854 s.attachments[ownerDomain] = append(s.attachments[ownerDomain], container.ID)
855 s.mu.Unlock()
856
857 // Start log collection for attachment
858 s.startLogCollection(ctx, container.ID, containerName)
859
860 log.Info().Str(zerowrap.FieldEntityID, container.ID).Msg("attachment deployed successfully")
861 return nil
862}
863
864func (s *Service) stopAllManagedContainers(ctx context.Context) error {
865 log := zerowrap.FromCtx(ctx)
866
867 s.mu.RLock()
868 containers := make(map[string]*domain.Container, len(s.containers))
869 maps.Copy(containers, s.containers)
870 s.mu.RUnlock()
871
872 if len(containers) == 0 {
873 log.Info().Msg("no managed containers to stop")
874 return nil
875 }
876
877 log.Info().Int(zerowrap.FieldCount, len(containers)).Msg("stopping all managed containers...")
878
879 errorCount := 0
880 for d, c := range containers {
881 log.Info().Str("domain", d).Str(zerowrap.FieldEntityID, c.ID).Msg("stopping managed container")
882
883 if err := s.runtime.StopContainer(ctx, c.ID); err != nil {
884 log.WrapErrWithFields(err, "failed to stop container", map[string]any{"domain": d, zerowrap.FieldEntityID: c.ID})
885 errorCount++
886 continue
887 }
888
889 s.mu.Lock()
890 delete(s.containers, d)
891 s.mu.Unlock()
892
893 log.Info().Str("domain", d).Str(zerowrap.FieldEntityID, c.ID).Msg("container stopped successfully")
894 }
895
896 if errorCount > 0 {
897 return fmt.Errorf("failed to stop %d containers", errorCount)
898 }
899
900 log.Info().Msg("all managed containers stopped successfully")
901 return nil
902}
903
904// Utility functions
905
906func normalizeImageRef(image string) string {
907 // Extract repository from image reference, handling port numbers correctly.
908 // Examples:
909 // nginx:latest -> docker.io/library/nginx
910 // user/repo:tag -> docker.io/user/repo
911 // localhost:5000/image:tag -> localhost:5000/image
912 // registry.example.com/image:tag -> registry.example.com/image
913
914 // Find the tag separator: the last colon that isn't part of a port number.
915 // A colon is part of a port if there's no slash after it until the next colon.
916 repo := image
917 lastColon := strings.LastIndex(image, ":")
918 if lastColon != -1 {
919 afterColon := image[lastColon+1:]
920 // If there's no slash after the colon, it's the tag separator
921 if !strings.Contains(afterColon, "/") {
922 repo = image[:lastColon]
923 }
924 }
925
926 if !strings.Contains(repo, "/") {
927 return "docker.io/library/" + repo
928 }
929
930 if strings.Count(repo, "/") == 1 && !strings.Contains(strings.Split(repo, "/")[0], ".") && !strings.Contains(strings.Split(repo, "/")[0], ":") {
931 return "docker.io/" + repo
932 }
933
934 return repo
935}
936
937func generateVolumeName(prefix, domainName, volumePath string) string {
938 return fmt.Sprintf("%s-%s-%s",
939 prefix,
940 strings.ReplaceAll(domainName, ".", "-"),
941 strings.ReplaceAll(strings.Trim(volumePath, "/"), "/", "-"))
942}
943
944func mergeEnvironmentVariables(dockerfileEnv, userEnv []string) []string {
945 envMap := make(map[string]string)
946
947 for _, env := range dockerfileEnv {
948 if k, v, ok := strings.Cut(env, "="); ok {
949 envMap[k] = v
950 }
951 }
952
953 for _, env := range userEnv {
954 if k, v, ok := strings.Cut(env, "="); ok {
955 envMap[k] = v
956 }
957 }
958
959 result := make([]string, 0, len(envMap))
960 for k, v := range envMap {
961 result = append(result, k+"="+v)
962 }
963
964 return result
965}
966
967// extractServiceName gets service name from image reference.
968// "my-postgres:latest" → "postgres", "redis:7" → "redis"
969func extractServiceName(image string) string {
970 // Remove tag
971 parts := strings.Split(image, ":")
972 name := parts[0]
973
974 // Remove registry prefix if present
975 if strings.Contains(name, "/") {
976 nameParts := strings.Split(name, "/")
977 name = nameParts[len(nameParts)-1]
978 }
979
980 // Remove common prefixes like "my-"
981 name = strings.TrimPrefix(name, "my-")
982
983 return name
984}
985
986// sanitizeName makes a domain safe for container naming.
987func sanitizeName(domain string) string {
988 // Replace dots and other special chars with dashes
989 result := strings.ReplaceAll(domain, ".", "-")
990 result = strings.ReplaceAll(result, ":", "-")
991 result = strings.ReplaceAll(result, "/", "-")
992 return result
993}
994
995// findContainerByName finds a container by its name.
996func (s *Service) findContainerByName(ctx context.Context, name string) *domain.Container {
997 containers, err := s.runtime.ListContainers(ctx, true)
998 if err != nil {
999 return nil
1000 }
1001
1002 for _, c := range containers {
1003 if c.Name == name {
1004 return c
1005 }
1006 }
1007 return nil
1008}
1009
1010// waitForReady waits for a container to be ready.
1011// Uses simple "running + delay" approach for universal compatibility.
1012func (s *Service) waitForReady(ctx context.Context, containerID string) error {
1013 log := zerowrap.FromCtx(ctx)
1014
1015 // Poll for container to be running (max 30 seconds)
1016 for i := 0; i < 30; i++ {
1017 running, err := s.runtime.IsContainerRunning(ctx, containerID)
1018 if err != nil {
1019 return err
1020 }
1021 if running {
1022 break
1023 }
1024 if i == 29 {
1025 return fmt.Errorf("container did not start within 30 seconds")
1026 }
1027 time.Sleep(time.Second)
1028 }
1029
1030 // Additional readiness delay (configurable, default 5 seconds)
1031 delay := s.config.ReadinessDelay
1032 if delay == 0 {
1033 delay = 5 * time.Second
1034 }
1035
1036 log.Debug().Dur("delay", delay).Msg("waiting for container readiness")
1037 time.Sleep(delay)
1038
1039 // Verify still running after delay
1040 running, err := s.runtime.IsContainerRunning(ctx, containerID)
1041 if err != nil {
1042 return err
1043 }
1044 if !running {
1045 return fmt.Errorf("container stopped during readiness delay")
1046 }
1047
1048 return nil
1049}
1050
1051// publishContainerDeployed publishes a container.deployed event.
1052func (s *Service) publishContainerDeployed(ctx context.Context, domainName, containerID string) {
1053 payload := &domain.ContainerEventPayload{
1054 ContainerID: containerID,
1055 Domain: domainName,
1056 Action: "deployed",
1057 }
1058
1059 if err := s.eventBus.Publish(domain.EventContainerDeployed, payload); err != nil {
1060 log := zerowrap.FromCtx(ctx)
1061 log.Warn().Err(err).Msg("failed to publish container deployed event")
1062 }
1063}
1064
1065// rewriteToRegistryDomain rewrites an image reference to use the configured registry domain.
1066// e.g., "myapp:latest" -> "registry.example.com/myapp:latest"
1067func rewriteToRegistryDomain(imageRef, registryDomain string) string {
1068 if registryDomain == "" {
1069 return imageRef
1070 }
1071
1072 prefix := registryDomain + "/"
1073 if strings.HasPrefix(imageRef, prefix) {
1074 return imageRef
1075 }
1076
1077 return prefix + imageRef
1078}
1079
1080// rewriteToLocalRegistry rewrites an image reference to use the local registry address.
1081// e.g., "registry.example.com/myapp:latest" -> "localhost:5000/myapp:latest"
1082func rewriteToLocalRegistry(imageRef, registryDomain string, registryPort int) string {
1083 if imageRef == "" {
1084 return imageRef
1085 }
1086
1087 localRegistry := fmt.Sprintf("localhost:%d", registryPort)
1088 localPrefix := localRegistry + "/"
1089 imageRef = strings.TrimPrefix(imageRef, localPrefix)
1090
1091 registryDomain = strings.TrimSuffix(registryDomain, "/")
1092 if registryDomain != "" {
1093 prefix := registryDomain + "/"
1094 imageRef = strings.TrimPrefix(imageRef, prefix)
1095 }
1096
1097 return localPrefix + imageRef
1098}