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 PullPolicy string
29 VolumeAutoCreate bool
30 VolumePrefix string
31 VolumePreserve bool
32 NetworkIsolation bool
33 NetworkPrefix string
34 DNSSuffix string
35 NetworkGroups map[string][]string
36 Attachments map[string][]string
37 ReadinessDelay time.Duration // Delay after container starts before considering it ready
38}
39
40const (
41 PullPolicyAlways = "always"
42 PullPolicyIfNotPresent = "if-not-present"
43 PullPolicyIfTagChanged = "if-tag-changed"
44)
45
46// Service implements the ContainerService interface.
47type Service struct {
48 runtime out.ContainerRuntime
49 envLoader out.EnvLoader
50 eventBus out.EventPublisher
51 logWriter out.ContainerLogWriter
52 config Config
53 containers map[string]*domain.Container
54 attachments map[string][]string // ownerDomain → []containerIDs
55 mu sync.RWMutex
56}
57
58// NewService creates a new container service.
59func NewService(
60 runtime out.ContainerRuntime,
61 envLoader out.EnvLoader,
62 eventBus out.EventPublisher,
63 logWriter out.ContainerLogWriter,
64 config Config,
65) *Service {
66 return &Service{
67 runtime: runtime,
68 envLoader: envLoader,
69 eventBus: eventBus,
70 logWriter: logWriter,
71 config: config,
72 containers: make(map[string]*domain.Container),
73 attachments: make(map[string][]string),
74 }
75}
76
77// Deploy creates and starts a container for the given route.
78// Implements zero-downtime deployment: new container starts before old one stops.
79func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Container, error) {
80 // Enrich context with use case fields for all downstream logs
81 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
82 zerowrap.FieldLayer: "usecase",
83 zerowrap.FieldUseCase: "Deploy",
84 "domain": route.Domain,
85 })
86 log := zerowrap.FromCtx(ctx)
87
88 // Get existing container (if any) without holding lock
89 s.mu.RLock()
90 existing, hasExisting := s.containers[route.Domain]
91 s.mu.RUnlock()
92
93 // Clean up orphaned containers (containers with same name but not tracked)
94 // Skip the currently tracked container to preserve zero-downtime deployment
95 existingID := ""
96 if hasExisting {
97 existingID = existing.ID
98 }
99 if err := s.cleanupOrphanedContainers(ctx, route.Domain, existingID); err != nil {
100 log.WrapErr(err, "failed to cleanup orphaned containers")
101 }
102
103 // Build image reference and ensure it's available.
104 // ensureImage returns the canonical reference; internal pulls may use the local registry.
105 imageRef := s.buildImageRef(route.Image)
106 actualImageRef, err := s.ensureImage(ctx, imageRef)
107 if err != nil {
108 return nil, err
109 }
110
111 // Setup network FIRST (attachments need it)
112 networkName := s.getNetworkForApp(route.Domain)
113 if err := s.createNetworkIfNeeded(ctx, networkName); err != nil {
114 return nil, log.WrapErr(err, "failed to create network")
115 }
116
117 // Deploy attachments BEFORE main container (dependencies first)
118 if err := s.deployAttachments(ctx, route.Domain, networkName); err != nil {
119 log.WrapErr(err, "failed to deploy some attachments")
120 }
121
122 // Get exposed ports (use actual image ref from pull)
123 exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, actualImageRef)
124 if err != nil {
125 log.WrapErr(err, "failed to get exposed ports, using defaults")
126 exposedPorts = []int{80, 8080, 3000}
127 }
128
129 // Load environment
130 envVars, err := s.loadEnvironment(ctx, route.Domain, actualImageRef)
131 if err != nil {
132 return nil, err
133 }
134
135 // Setup volumes (use actual image ref from pull)
136 volumes, err := s.setupVolumes(ctx, route.Domain, actualImageRef)
137 if err != nil {
138 return nil, err
139 }
140
141 // Determine container name (use temp suffix for zero-downtime if existing)
142 containerName := fmt.Sprintf("gordon-%s", route.Domain)
143 if hasExisting {
144 containerName = fmt.Sprintf("gordon-%s-new", route.Domain)
145 }
146
147 // Create container (use actual image ref from pull, but track original in labels)
148 containerConfig := &domain.ContainerConfig{
149 Image: actualImageRef,
150 Name: containerName,
151 Ports: exposedPorts,
152 Env: envVars,
153 Volumes: volumes,
154 NetworkMode: networkName,
155 Hostname: route.Domain,
156 Labels: map[string]string{
157 "gordon.domain": route.Domain,
158 "gordon.image": route.Image,
159 "gordon.managed": "true",
160 "gordon.route": route.Domain,
161 },
162 AutoRemove: false,
163 }
164
165 newContainer, err := s.runtime.CreateContainer(ctx, containerConfig)
166 if err != nil {
167 return nil, log.WrapErr(err, "failed to create container")
168 }
169
170 // Start new container
171 if err := s.runtime.StartContainer(ctx, newContainer.ID); err != nil {
172 s.runtime.RemoveContainer(ctx, newContainer.ID, true)
173 return nil, log.WrapErr(err, "failed to start container")
174 }
175
176 // Wait for new container to be ready
177 if err := s.waitForReady(ctx, newContainer.ID); err != nil {
178 s.cleanupFailedContainer(ctx, newContainer.ID)
179 return nil, log.WrapErr(err, "container failed readiness check")
180 }
181
182 // Re-inspect for updated info (ports, etc.)
183 newContainer, err = s.runtime.InspectContainer(ctx, newContainer.ID)
184 if err != nil {
185 return nil, log.WrapErr(err, "failed to inspect started container")
186 }
187
188 // ATOMIC SWITCH: Update tracking first (proxy will now route to new container)
189 s.mu.Lock()
190 s.containers[route.Domain] = newContainer
191 s.mu.Unlock()
192
193 // Publish container deployed event (proxy will invalidate cache)
194 s.publishContainerDeployed(ctx, route.Domain, newContainer.ID)
195
196 // NOW stop and remove old container (traffic already going to new one)
197 if hasExisting {
198 s.cleanupOldContainer(ctx, existing, newContainer.ID, route.Domain)
199 }
200
201 // Start container log collection (non-blocking, errors don't fail deployment)
202 s.startLogCollection(ctx, newContainer.ID, route.Domain)
203
204 log.Info().
205 Str("image", route.Image).
206 Str(zerowrap.FieldEntityID, newContainer.ID).
207 Ints("ports", newContainer.Ports).
208 Str("network", networkName).
209 Bool("zero_downtime", hasExisting).
210 Msg("container deployed successfully")
211
212 return newContainer, nil
213}
214
215// Stop stops a running container.
216func (s *Service) Stop(ctx context.Context, containerID string) error {
217 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
218 zerowrap.FieldLayer: "usecase",
219 zerowrap.FieldUseCase: "Stop",
220 zerowrap.FieldEntityID: containerID,
221 })
222 log := zerowrap.FromCtx(ctx)
223
224 // Stop log collection before stopping container
225 if s.logWriter != nil {
226 if err := s.logWriter.StopLogging(containerID); err != nil {
227 log.Warn().Err(err).Msg("failed to stop container log collection")
228 }
229 }
230
231 if err := s.runtime.StopContainer(ctx, containerID); err != nil {
232 return log.WrapErr(err, "failed to stop container")
233 }
234
235 log.Info().Msg("container stopped")
236 return nil
237}
238
239// Remove removes a container.
240func (s *Service) Remove(ctx context.Context, containerID string, force bool) error {
241 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
242 zerowrap.FieldLayer: "usecase",
243 zerowrap.FieldUseCase: "Remove",
244 zerowrap.FieldEntityID: containerID,
245 })
246 log := zerowrap.FromCtx(ctx)
247
248 // Stop log collection before removing container
249 if s.logWriter != nil {
250 if err := s.logWriter.StopLogging(containerID); err != nil {
251 log.Warn().Err(err).Msg("failed to stop container log collection")
252 }
253 }
254
255 // Find domain for cleanup
256 var containerDomain string
257 s.mu.RLock()
258 for d, c := range s.containers {
259 if c.ID == containerID {
260 containerDomain = d
261 break
262 }
263 }
264 s.mu.RUnlock()
265
266 if err := s.runtime.RemoveContainer(ctx, containerID, force); err != nil {
267 return log.WrapErr(err, "failed to remove container")
268 }
269
270 // Cleanup volumes
271 if containerDomain != "" && !s.config.VolumePreserve {
272 if err := s.cleanupVolumesForDomain(ctx, containerDomain); err != nil {
273 log.WrapErrWithFields(err, "failed to cleanup volumes", map[string]any{"domain": containerDomain})
274 }
275 }
276
277 // Remove from tracking
278 s.mu.Lock()
279 var removedDomain string
280 for d, c := range s.containers {
281 if c.ID == containerID {
282 delete(s.containers, d)
283 removedDomain = d
284 log.Info().Str("domain", d).Msg("container removed")
285 break
286 }
287 }
288 s.mu.Unlock()
289
290 // Cleanup network
291 if removedDomain != "" && s.config.NetworkIsolation {
292 networkName := s.getNetworkForApp(removedDomain)
293 if err := s.cleanupNetworkIfEmpty(ctx, networkName); err != nil {
294 log.WrapErrWithFields(err, "failed to cleanup network", map[string]any{"network": networkName})
295 }
296 }
297
298 return nil
299}
300
301// Get retrieves a container by domain.
302func (s *Service) Get(_ context.Context, domainName string) (*domain.Container, bool) {
303 s.mu.RLock()
304 defer s.mu.RUnlock()
305 container, exists := s.containers[domainName]
306 return container, exists
307}
308
309// List returns all managed containers.
310func (s *Service) List(_ context.Context) map[string]*domain.Container {
311 s.mu.RLock()
312 defer s.mu.RUnlock()
313
314 result := make(map[string]*domain.Container, len(s.containers))
315 maps.Copy(result, s.containers)
316 return result
317}
318
319// ListRoutesWithDetails returns routes with network and attachment info.
320// Note: Container map is copied under lock, then external calls are made without lock.
321// If containers are removed between copy and runtime calls, errors are handled gracefully
322// (network becomes empty string). This trade-off avoids holding locks during I/O.
323func (s *Service) ListRoutesWithDetails(ctx context.Context) []domain.RouteInfo {
324 s.mu.RLock()
325 containers := make(map[string]*domain.Container, len(s.containers))
326 maps.Copy(containers, s.containers)
327 s.mu.RUnlock()
328
329 // Fetch all attachments once to avoid N+1 queries
330 attachmentsByDomain := s.getAllAttachments(ctx)
331
332 results := make([]domain.RouteInfo, 0, len(containers))
333 for domainName, container := range containers {
334 network := ""
335 image := ""
336 containerID := ""
337 status := ""
338 if container != nil {
339 containerID = container.ID
340 status = container.Status
341 image = container.Image
342 if container.Labels != nil {
343 if labelImage, ok := container.Labels[domain.LabelImage]; ok && labelImage != "" {
344 image = labelImage
345 }
346 }
347 // Strip registry domain prefix from image for cleaner display
348 image = s.stripRegistryPrefix(image)
349 if networkName, err := s.runtime.GetContainerNetwork(ctx, container.ID); err == nil {
350 network = networkName
351 }
352 }
353
354 results = append(results, domain.RouteInfo{
355 Domain: domainName,
356 Image: image,
357 ContainerID: containerID,
358 ContainerStatus: status,
359 Network: network,
360 Attachments: attachmentsByDomain[domainName],
361 })
362 }
363
364 return results
365}
366
367// stripRegistryPrefix removes the configured registry domain prefix from an image reference.
368// For example, "reg.example.com/myapp:latest" becomes "myapp:latest" if registry domain is "reg.example.com".
369func (s *Service) stripRegistryPrefix(image string) string {
370 if s.config.RegistryDomain == "" {
371 return image
372 }
373 prefix := strings.TrimSuffix(s.config.RegistryDomain, "/") + "/"
374 if strings.HasPrefix(image, prefix) {
375 return strings.TrimPrefix(image, prefix)
376 }
377 return image
378}
379
380// ListAttachments returns attachments for a domain.
381func (s *Service) ListAttachments(ctx context.Context, domainName string) []domain.Attachment {
382 return s.getAttachmentsForDomain(ctx, domainName)
383}
384
385// ListNetworks returns Gordon-managed networks.
386func (s *Service) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) {
387 networks, err := s.runtime.ListNetworks(ctx)
388 if err != nil {
389 return nil, err
390 }
391
392 var filtered []*domain.NetworkInfo
393 for _, network := range networks {
394 if strings.HasPrefix(network.Name, s.config.NetworkPrefix+"-") {
395 filtered = append(filtered, network)
396 }
397 }
398
399 return filtered, nil
400}
401
402// HealthCheck performs health checks on all containers.
403func (s *Service) HealthCheck(ctx context.Context) map[string]bool {
404 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
405 zerowrap.FieldLayer: "usecase",
406 zerowrap.FieldUseCase: "HealthCheck",
407 })
408 log := zerowrap.FromCtx(ctx)
409
410 s.mu.RLock()
411 defer s.mu.RUnlock()
412
413 health := make(map[string]bool, len(s.containers))
414 for d, c := range s.containers {
415 running, err := s.runtime.IsContainerRunning(ctx, c.ID)
416 if err != nil {
417 log.WrapErrWithFields(err, "health check failed", map[string]any{"domain": d, zerowrap.FieldEntityID: c.ID})
418 health[d] = false
419 } else {
420 health[d] = running
421 }
422 }
423 return health
424}
425
426// SyncContainers synchronizes containers with runtime state.
427func (s *Service) SyncContainers(ctx context.Context) error {
428 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
429 zerowrap.FieldLayer: "usecase",
430 zerowrap.FieldUseCase: "SyncContainers",
431 })
432 log := zerowrap.FromCtx(ctx)
433
434 s.mu.Lock()
435 defer s.mu.Unlock()
436
437 allContainers, err := s.runtime.ListContainers(ctx, false)
438 if err != nil {
439 return log.WrapErr(err, "failed to list containers")
440 }
441
442 managed := make(map[string]*domain.Container)
443 for _, c := range allContainers {
444 if c.Labels != nil {
445 if d, ok := c.Labels["gordon.domain"]; ok && c.Labels["gordon.managed"] == "true" {
446 managed[d] = c
447 }
448 }
449 }
450
451 s.containers = managed
452 log.Info().Int(zerowrap.FieldCount, len(managed)).Msg("container state synchronized")
453 return nil
454}
455
456// AutoStart starts containers for the provided routes that aren't running.
457func (s *Service) AutoStart(ctx context.Context, routes []domain.Route) error {
458 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
459 zerowrap.FieldLayer: "usecase",
460 zerowrap.FieldUseCase: "AutoStart",
461 })
462 log := zerowrap.FromCtx(ctx)
463
464 log.Info().Int("route_count", len(routes)).Msg("auto-starting containers for configured routes")
465
466 var started, skipped, errors int
467 for _, route := range routes {
468 // Check if container already exists and is running
469 if _, exists := s.Get(ctx, route.Domain); exists {
470 log.Debug().Str("domain", route.Domain).Msg("container already running, skipping")
471 skipped++
472 continue
473 }
474
475 log.Info().
476 Str("domain", route.Domain).
477 Str("image", route.Image).
478 Msg("auto-starting container for route")
479
480 if _, err := s.Deploy(ctx, route); err != nil {
481 log.Warn().Err(err).Str("domain", route.Domain).Msg("failed to auto-start container")
482 errors++
483 continue
484 }
485
486 started++
487 }
488
489 log.Info().
490 Int("started", started).
491 Int("skipped", skipped).
492 Int("errors", errors).
493 Msg("auto-start completed")
494
495 if errors > 0 {
496 return fmt.Errorf("auto-start completed with %d errors", errors)
497 }
498 return nil
499}
500
501// Shutdown gracefully shuts down all managed containers.
502func (s *Service) Shutdown(ctx context.Context) error {
503 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
504 zerowrap.FieldLayer: "usecase",
505 zerowrap.FieldUseCase: "Shutdown",
506 })
507 log := zerowrap.FromCtx(ctx)
508 log.Info().Msg("shutting down container manager...")
509
510 if err := s.stopAllManagedContainers(ctx); err != nil {
511 log.WrapErr(err, "failed to stop all containers during shutdown")
512 }
513
514 // Close log writer to stop all log collection
515 if s.logWriter != nil {
516 if err := s.logWriter.Close(); err != nil {
517 log.Warn().Err(err).Msg("failed to close container log writer")
518 }
519 }
520
521 log.Info().Msg("container manager shutdown complete")
522 return nil
523}
524
525// UpdateConfig updates the service configuration.
526func (s *Service) UpdateConfig(config Config) {
527 s.config = config
528}
529
530// Helper methods
531
532// cleanupFailedContainer stops and removes a container that failed to start properly.
533func (s *Service) cleanupFailedContainer(ctx context.Context, containerID string) {
534 log := zerowrap.FromCtx(ctx)
535 if err := s.runtime.StopContainer(ctx, containerID); err != nil {
536 log.Warn().Err(err).Str(zerowrap.FieldEntityID, containerID).Msg("failed to stop container after failure")
537 }
538 if err := s.runtime.RemoveContainer(ctx, containerID, true); err != nil {
539 log.Warn().Err(err).Str(zerowrap.FieldEntityID, containerID).Msg("failed to remove container after failure")
540 }
541}
542
543// cleanupOldContainer stops and removes an old container after zero-downtime switch.
544// It also renames the new container to the canonical name.
545func (s *Service) cleanupOldContainer(ctx context.Context, old *domain.Container, newContainerID, domainName string) {
546 log := zerowrap.FromCtx(ctx)
547 log.Info().Str(zerowrap.FieldEntityID, old.ID).Msg("stopping old container after zero-downtime switch")
548
549 if s.logWriter != nil {
550 if err := s.logWriter.StopLogging(old.ID); err != nil {
551 log.Warn().Err(err).Str(zerowrap.FieldEntityID, old.ID).Msg("failed to stop logging for old container")
552 }
553 }
554
555 if err := s.runtime.StopContainer(ctx, old.ID); err != nil {
556 log.Warn().Err(err).Str(zerowrap.FieldEntityID, old.ID).Msg("failed to stop old container")
557 }
558
559 if err := s.runtime.RemoveContainer(ctx, old.ID, true); err != nil {
560 log.Warn().Err(err).Str(zerowrap.FieldEntityID, old.ID).Msg("failed to remove old container")
561 }
562
563 // Rename new container to canonical name
564 canonicalName := fmt.Sprintf("gordon-%s", domainName)
565 if err := s.runtime.RenameContainer(ctx, newContainerID, canonicalName); err != nil {
566 log.Warn().Err(err).Str("canonical_name", canonicalName).Msg("failed to rename container to canonical name")
567 }
568}
569
570// startLogCollection starts log collection for a container in the background.
571// Errors are logged but don't fail the calling operation.
572func (s *Service) startLogCollection(ctx context.Context, containerID, domainName string) {
573 if s.logWriter == nil {
574 return
575 }
576
577 log := zerowrap.FromCtx(ctx)
578
579 logStream, err := s.runtime.GetContainerLogs(ctx, containerID, true)
580 if err != nil {
581 log.Warn().Err(err).Msg("failed to get container logs for collection")
582 return
583 }
584
585 if err := s.logWriter.StartLogging(ctx, containerID, domainName, logStream); err != nil {
586 log.Warn().Err(err).Msg("failed to start container log collection")
587 logStream.Close()
588 }
589}
590
591func (s *Service) buildImageRef(image string) string {
592 if !s.config.RegistryAuthEnabled || s.config.RegistryDomain == "" {
593 return image
594 }
595
596 repoPart := strings.Split(image, ":")[0]
597 if strings.HasPrefix(repoPart, s.config.RegistryDomain+"/") {
598 return image
599 }
600 if strings.Contains(repoPart, ".") && strings.Contains(repoPart, "/") {
601 return image
602 }
603 return fmt.Sprintf("%s/%s", s.config.RegistryDomain, image)
604}
605
606func normalizePullPolicy(policy string) string {
607 switch strings.ToLower(strings.TrimSpace(policy)) {
608 case PullPolicyAlways:
609 return PullPolicyAlways
610 case PullPolicyIfTagChanged:
611 return PullPolicyIfTagChanged
612 case PullPolicyIfNotPresent:
613 return PullPolicyIfNotPresent
614 default:
615 return PullPolicyIfNotPresent
616 }
617}
618
619func isDigestRef(imageRef string) bool {
620 return strings.Contains(imageRef, "@sha256:")
621}
622
623func (s *Service) pullRefForDeploy(ctx context.Context, imageRef string) (string, bool) {
624 if !domain.IsInternalDeploy(ctx) {
625 return imageRef, false
626 }
627 return rewriteToLocalRegistry(imageRef, s.config.RegistryDomain, s.config.RegistryPort), true
628}
629
630// ensureImage ensures the image is available locally, pulling if needed.
631// Returns the image reference to use for container operations:
632// - For digest references (@sha256:...), returns the pullRef since Docker can't tag digests
633// - For tagged images, returns the original imageRef after tagging the pulled image
634func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, error) {
635 ctx = zerowrap.CtxWithField(ctx, "image", imageRef)
636 log := zerowrap.FromCtx(ctx)
637
638 // Determine if this is an internal deploy and what reference to use for pulls.
639 pullRef, isInternal := s.pullRefForDeploy(ctx, imageRef)
640 if pullRef != imageRef {
641 log.Info().
642 Str("original_ref", imageRef).
643 Str("pull_ref", pullRef).
644 Msg("internal deploy: using localhost registry for pull")
645 }
646
647 found, err := s.ensureLocalImage(ctx, imageRef, pullRef)
648 if err != nil {
649 return "", err
650 }
651 if found {
652 return imageRef, nil
653 }
654
655 // Pull image
656 log.Info().Msg("pulling image from registry")
657
658 if err := s.pullImage(ctx, pullRef, isInternal); err != nil {
659 return "", err
660 }
661
662 // For digest references, we can't create a tag (Docker doesn't allow it).
663 // In this case, use the pullRef directly since the image is available under that reference.
664 if strings.Contains(imageRef, "@sha256:") {
665 log.Info().
666 Str("pull_ref", pullRef).
667 Msg("digest reference: using pull reference for container operations")
668 return pullRef, nil
669 }
670
671 if err := s.tagImageIfNeeded(ctx, pullRef, imageRef); err != nil {
672 return "", err
673 }
674
675 // Clean up the temporary pull reference tag to avoid duplicate entries
676 if pullRef != imageRef {
677 if err := s.runtime.UntagImage(ctx, pullRef); err != nil {
678 // Log but don't fail - the canonical tag is already applied
679 log.Debug().Err(err).Str("pull_ref", pullRef).Msg("failed to remove temporary pull tag")
680 }
681 }
682
683 log.Info().Msg("image pulled successfully")
684 return imageRef, nil
685}
686
687func (s *Service) ensureLocalImage(ctx context.Context, imageRef, pullRef string) (bool, error) {
688 log := zerowrap.FromCtx(ctx)
689
690 // For internal deploys (image push events), always pull fresh image
691 // because the same tag may reference new content (e.g., latest tag updated)
692 if domain.IsInternalDeploy(ctx) {
693 log.Info().Msg("internal deploy detected, forcing image pull to ensure latest content")
694 return false, nil
695 }
696
697 pullPolicy := normalizePullPolicy(s.config.PullPolicy)
698 switch pullPolicy {
699 case PullPolicyAlways:
700 log.Info().Str("pull_policy", pullPolicy).Msg("pull policy forces image pull")
701 return false, nil
702 case PullPolicyIfTagChanged:
703 if !isDigestRef(imageRef) {
704 log.Info().Str("pull_policy", pullPolicy).Msg("tag reference detected, pulling to check for updates")
705 return false, nil
706 }
707 }
708
709 localImages, err := s.runtime.ListImages(ctx)
710 if err != nil {
711 log.WrapErr(err, "failed to list local images, will attempt pull")
712 return false, nil
713 }
714
715 normalizedRef := normalizeImageRef(imageRef)
716 normalizedPullRef := normalizeImageRef(pullRef)
717 for _, img := range localImages {
718 normalizedImage := normalizeImageRef(img)
719 if normalizedImage == normalizedRef {
720 log.Info().Msg("image found locally, skipping pull")
721 return true, nil
722 }
723 if normalizedImage == normalizedPullRef {
724 if err := s.tagImageIfNeeded(ctx, pullRef, imageRef); err != nil {
725 return false, err
726 }
727 // Clean up the temporary pull reference tag
728 if pullRef != imageRef {
729 if err := s.runtime.UntagImage(ctx, pullRef); err != nil {
730 log.Debug().Err(err).Str("pull_ref", pullRef).Msg("failed to remove temporary pull tag")
731 }
732 }
733 log.Info().Msg("image found locally, skipping pull")
734 return true, nil
735 }
736 }
737
738 return false, nil
739}
740
741func (s *Service) pullImage(ctx context.Context, pullRef string, isInternal bool) error {
742 log := zerowrap.FromCtx(ctx)
743
744 switch {
745 case isInternal && s.config.RegistryAuthEnabled:
746 if s.config.InternalRegistryUsername == "" || s.config.InternalRegistryPassword == "" {
747 return log.WrapErr(fmt.Errorf("internal registry auth not configured"), "failed to pull image for internal deploy")
748 }
749 if err := s.runtime.PullImageWithAuth(ctx, pullRef, s.config.InternalRegistryUsername, s.config.InternalRegistryPassword); err != nil {
750 return log.WrapErr(err, "failed to pull image with internal auth")
751 }
752 case isInternal:
753 if err := s.runtime.PullImage(ctx, pullRef); err != nil {
754 return log.WrapErr(err, "failed to pull image")
755 }
756 case s.config.RegistryAuthEnabled:
757 if err := s.runtime.PullImageWithAuth(ctx, pullRef, s.config.RegistryUsername, s.config.RegistryPassword); err != nil {
758 return log.WrapErr(err, "failed to pull image with auth")
759 }
760 default:
761 if err := s.runtime.PullImage(ctx, pullRef); err != nil {
762 return log.WrapErr(err, "failed to pull image")
763 }
764 }
765
766 return nil
767}
768
769func (s *Service) tagImageIfNeeded(ctx context.Context, sourceRef, targetRef string) error {
770 if sourceRef == targetRef {
771 return nil
772 }
773
774 // Cannot create a tag with a digest reference - Docker/Podman doesn't allow it.
775 // When using digest references (image@sha256:...), skip tagging as the image
776 // is already available by its digest.
777 if strings.Contains(targetRef, "@sha256:") {
778 return nil
779 }
780
781 log := zerowrap.FromCtx(ctx)
782 if err := s.runtime.TagImage(ctx, sourceRef, targetRef); err != nil {
783 return log.WrapErr(err, "failed to tag image from pull reference")
784 }
785 return nil
786}
787
788func (s *Service) loadEnvironment(ctx context.Context, domainName, imageRef string) ([]string, error) {
789 log := zerowrap.FromCtx(ctx)
790
791 userEnvVars, err := s.envLoader.LoadEnv(ctx, domainName)
792 if err != nil {
793 return nil, log.WrapErr(err, "failed to load environment variables")
794 }
795
796 dockerfileEnvVars, err := s.runtime.InspectImageEnv(ctx, imageRef)
797 if err != nil {
798 log.WrapErr(err, "failed to inspect image environment")
799 dockerfileEnvVars = []string{}
800 }
801
802 return mergeEnvironmentVariables(dockerfileEnvVars, userEnvVars), nil
803}
804
805func (s *Service) setupVolumes(ctx context.Context, domainName, imageRef string) (map[string]string, error) {
806 log := zerowrap.FromCtx(ctx)
807 volumes := make(map[string]string)
808
809 if !s.config.VolumeAutoCreate {
810 return volumes, nil
811 }
812
813 volumePaths, err := s.runtime.InspectImageVolumes(ctx, imageRef)
814 if err != nil {
815 log.WrapErr(err, "failed to inspect image volumes")
816 return volumes, nil
817 }
818
819 for _, path := range volumePaths {
820 name := generateVolumeName(s.config.VolumePrefix, domainName, path)
821
822 exists, err := s.runtime.VolumeExists(ctx, name)
823 if err != nil {
824 log.WrapErrWithFields(err, "failed to check volume", map[string]any{"volume": name})
825 continue
826 }
827
828 if !exists {
829 if err := s.runtime.CreateVolume(ctx, name); err != nil {
830 log.WrapErrWithFields(err, "failed to create volume", map[string]any{"volume": name})
831 continue
832 }
833 log.Info().Str("volume", name).Str(zerowrap.FieldPath, path).Msg("created volume")
834 }
835
836 volumes[path] = name
837 }
838
839 return volumes, nil
840}
841
842func (s *Service) getNetworkForApp(domainName string) string {
843 if !s.config.NetworkIsolation {
844 return "bridge"
845 }
846
847 for groupName, domains := range s.config.NetworkGroups {
848 if slices.Contains(domains, domainName) {
849 return s.generateNetworkName(groupName)
850 }
851 }
852
853 return s.generateNetworkName(domainName)
854}
855
856func (s *Service) generateNetworkName(identifier string) string {
857 return fmt.Sprintf("%s-%s", s.config.NetworkPrefix, strings.ReplaceAll(identifier, ".", "-"))
858}
859
860func (s *Service) createNetworkIfNeeded(ctx context.Context, networkName string) error {
861 if networkName == "bridge" || networkName == "default" {
862 return nil
863 }
864
865 ctx = zerowrap.CtxWithField(ctx, "network", networkName)
866 log := zerowrap.FromCtx(ctx)
867
868 exists, err := s.runtime.NetworkExists(ctx, networkName)
869 if err != nil {
870 return log.WrapErr(err, "failed to check network existence")
871 }
872
873 if !exists {
874 if err := s.runtime.CreateNetwork(ctx, networkName, map[string]string{"driver": "bridge"}); err != nil {
875 return log.WrapErr(err, "failed to create network")
876 }
877 log.Info().Msg("created network for app isolation")
878 }
879
880 return nil
881}
882
883func (s *Service) cleanupOrphanedContainers(ctx context.Context, domainName string, skipContainerID string) error {
884 log := zerowrap.FromCtx(ctx)
885 expectedName := fmt.Sprintf("gordon-%s", domainName)
886
887 allContainers, err := s.runtime.ListContainers(ctx, true)
888 if err != nil {
889 return err
890 }
891
892 for _, c := range allContainers {
893 if c.Name == expectedName && c.ID != skipContainerID {
894 log.Info().Str(zerowrap.FieldEntityID, c.ID).Str(zerowrap.FieldStatus, c.Status).Msg("found orphaned container, removing")
895
896 if err := s.runtime.StopContainer(ctx, c.ID); err != nil {
897 log.WrapErrWithFields(err, "failed to stop orphaned container", map[string]any{zerowrap.FieldEntityID: c.ID})
898 }
899
900 if err := s.runtime.RemoveContainer(ctx, c.ID, true); err != nil {
901 log.WrapErrWithFields(err, "failed to remove orphaned container", map[string]any{zerowrap.FieldEntityID: c.ID})
902 }
903 }
904 }
905
906 return nil
907}
908
909func (s *Service) cleanupVolumesForDomain(_ context.Context, _ string) error {
910 return nil
911}
912
913func (s *Service) cleanupNetworkIfEmpty(ctx context.Context, networkName string) error {
914 if networkName == "bridge" || networkName == "default" {
915 return nil
916 }
917
918 ctx = zerowrap.CtxWithField(ctx, "network", networkName)
919 log := zerowrap.FromCtx(ctx)
920
921 networks, err := s.runtime.ListNetworks(ctx)
922 if err != nil {
923 return log.WrapErr(err, "failed to list networks")
924 }
925
926 for _, n := range networks {
927 if n.Name == networkName && len(n.Containers) == 0 {
928 if err := s.runtime.RemoveNetwork(ctx, networkName); err != nil {
929 return err
930 }
931 log.Info().Msg("cleaned up empty network")
932 break
933 }
934 }
935
936 return nil
937}
938
939func (s *Service) deployAttachments(ctx context.Context, domainName, networkName string) error {
940 attachments, ok := s.config.Attachments[domainName]
941 if !ok {
942 return nil
943 }
944
945 log := zerowrap.FromCtx(ctx)
946 for _, svc := range attachments {
947 if err := s.deployAttachedService(ctx, domainName, svc, networkName); err != nil {
948 log.WrapErrWithFields(err, "failed to deploy attachment", map[string]any{zerowrap.FieldService: svc, "domain": domainName})
949 }
950 }
951
952 return nil
953}
954
955func (s *Service) getAttachmentsForDomain(ctx context.Context, domainName string) []domain.Attachment {
956 containers, err := s.runtime.ListContainers(ctx, true)
957 if err != nil {
958 return nil
959 }
960
961 return s.filterAttachments(ctx, containers, domainName)
962}
963
964// getAllAttachments fetches all containers once and returns attachments grouped by domain.
965// This avoids N+1 queries when listing multiple routes.
966func (s *Service) getAllAttachments(ctx context.Context) map[string][]domain.Attachment {
967 containers, err := s.runtime.ListContainers(ctx, true)
968 if err != nil {
969 return nil
970 }
971
972 result := make(map[string][]domain.Attachment)
973 for _, container := range containers {
974 if container.Labels == nil {
975 continue
976 }
977 if container.Labels[domain.LabelAttachment] != "true" {
978 continue
979 }
980 ownerDomain := container.Labels[domain.LabelAttachedTo]
981 if ownerDomain == "" {
982 continue
983 }
984 image := container.Image
985 serviceName := container.Name
986 if labelImage, ok := container.Labels[domain.LabelImage]; ok && labelImage != "" {
987 image = labelImage
988 serviceName = extractServiceName(labelImage)
989 }
990 // Get the container's network for display in the routes table
991 network := ""
992 if networkName, err := s.runtime.GetContainerNetwork(ctx, container.ID); err == nil {
993 network = networkName
994 }
995 attachment := domain.Attachment{
996 Name: serviceName,
997 Image: s.stripRegistryPrefix(image),
998 ContainerID: container.ID,
999 Status: container.Status,
1000 Network: network,
1001 }
1002 result[ownerDomain] = append(result[ownerDomain], attachment)
1003 }
1004
1005 return result
1006}
1007
1008// filterAttachments extracts attachments for a specific domain from a container list.
1009func (s *Service) filterAttachments(ctx context.Context, containers []*domain.Container, domainName string) []domain.Attachment {
1010 attachments := make([]domain.Attachment, 0)
1011 for _, container := range containers {
1012 if container.Labels == nil {
1013 continue
1014 }
1015 if container.Labels[domain.LabelAttachment] != "true" {
1016 continue
1017 }
1018 if container.Labels[domain.LabelAttachedTo] != domainName {
1019 continue
1020 }
1021 image := container.Image
1022 serviceName := container.Name
1023 if labelImage, ok := container.Labels[domain.LabelImage]; ok && labelImage != "" {
1024 image = labelImage
1025 serviceName = extractServiceName(labelImage)
1026 }
1027 // Get the container's network for display in the routes table
1028 network := ""
1029 if networkName, err := s.runtime.GetContainerNetwork(ctx, container.ID); err == nil {
1030 network = networkName
1031 }
1032 attachment := domain.Attachment{
1033 Name: serviceName,
1034 Image: s.stripRegistryPrefix(image),
1035 ContainerID: container.ID,
1036 Status: container.Status,
1037 Network: network,
1038 }
1039 attachments = append(attachments, attachment)
1040 }
1041
1042 return attachments
1043}
1044
1045func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, serviceImage, networkName string) error {
1046 log := zerowrap.FromCtx(ctx)
1047
1048 // Parse service name from image (e.g., "my-postgres:latest" → "postgres")
1049 serviceName := extractServiceName(serviceImage)
1050 containerName := fmt.Sprintf("gordon-%s-%s", sanitizeName(ownerDomain), serviceName)
1051
1052 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1053 "attachment": serviceName,
1054 "container_name": containerName,
1055 "owner_domain": ownerDomain,
1056 })
1057
1058 // Check if already running (idempotent)
1059 existingContainer := s.findContainerByName(ctx, containerName)
1060 if existingContainer != nil && existingContainer.Status == string(domain.ContainerStatusRunning) {
1061 log.Debug().Str("container_name", containerName).Msg("attachment already running, skipping")
1062 return nil
1063 }
1064
1065 // Remove existing stopped container if present
1066 if existingContainer != nil {
1067 log.Info().Str("container_name", containerName).Msg("removing stopped attachment container")
1068 if err := s.runtime.RemoveContainer(ctx, existingContainer.ID, true); err != nil {
1069 log.WrapErr(err, "failed to remove existing attachment container")
1070 }
1071 }
1072
1073 log.Info().Str(zerowrap.FieldService, serviceImage).Msg("deploying attached service")
1074
1075 // Ensure image (canonical ref is returned; internal pulls may use the local registry).
1076 imageRef := s.buildImageRef(serviceImage)
1077 actualImageRef, err := s.ensureImage(ctx, imageRef)
1078 if err != nil {
1079 return err
1080 }
1081
1082 // Get image metadata
1083 exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, actualImageRef)
1084 if err != nil {
1085 log.WrapErr(err, "failed to get exposed ports for attachment, using defaults")
1086 exposedPorts = []int{}
1087 }
1088
1089 // Setup volumes (attachments need persistent data)
1090 volumes, err := s.setupVolumes(ctx, containerName, actualImageRef)
1091 if err != nil {
1092 log.WrapErr(err, "failed to setup volumes for attachment")
1093 volumes = make(map[string]string)
1094 }
1095
1096 // Load environment (attachment-specific env file)
1097 envVars, err := s.loadEnvironment(ctx, containerName, actualImageRef)
1098 if err != nil {
1099 log.WrapErr(err, "failed to load environment for attachment")
1100 envVars = []string{}
1101 }
1102
1103 // Create container with attachment labels (use actual ref, track original in labels)
1104 config := &domain.ContainerConfig{
1105 Image: actualImageRef,
1106 Name: containerName,
1107 Hostname: serviceName, // Internal DNS: postgres, redis, etc.
1108 Ports: exposedPorts,
1109 Env: envVars,
1110 Volumes: volumes,
1111 NetworkMode: networkName, // Same network as main app
1112 Labels: map[string]string{
1113 "gordon.managed": "true",
1114 "gordon.attachment": "true",
1115 "gordon.attached-to": ownerDomain,
1116 "gordon.image": serviceImage,
1117 },
1118 }
1119
1120 container, err := s.runtime.CreateContainer(ctx, config)
1121 if err != nil {
1122 return log.WrapErr(err, "failed to create attachment container")
1123 }
1124
1125 // Start container
1126 if err := s.runtime.StartContainer(ctx, container.ID); err != nil {
1127 s.runtime.RemoveContainer(ctx, container.ID, true)
1128 return log.WrapErr(err, "failed to start attachment container")
1129 }
1130
1131 // Track attachment
1132 s.mu.Lock()
1133 s.attachments[ownerDomain] = append(s.attachments[ownerDomain], container.ID)
1134 s.mu.Unlock()
1135
1136 // Start log collection for attachment
1137 s.startLogCollection(ctx, container.ID, containerName)
1138
1139 log.Info().Str(zerowrap.FieldEntityID, container.ID).Msg("attachment deployed successfully")
1140 return nil
1141}
1142
1143func (s *Service) stopAllManagedContainers(ctx context.Context) error {
1144 log := zerowrap.FromCtx(ctx)
1145
1146 s.mu.RLock()
1147 containers := make(map[string]*domain.Container, len(s.containers))
1148 maps.Copy(containers, s.containers)
1149 s.mu.RUnlock()
1150
1151 if len(containers) == 0 {
1152 log.Info().Msg("no managed containers to stop")
1153 return nil
1154 }
1155
1156 log.Info().Int(zerowrap.FieldCount, len(containers)).Msg("stopping all managed containers...")
1157
1158 errorCount := 0
1159 for d, c := range containers {
1160 log.Info().Str("domain", d).Str(zerowrap.FieldEntityID, c.ID).Msg("stopping managed container")
1161
1162 if err := s.runtime.StopContainer(ctx, c.ID); err != nil {
1163 log.WrapErrWithFields(err, "failed to stop container", map[string]any{"domain": d, zerowrap.FieldEntityID: c.ID})
1164 errorCount++
1165 continue
1166 }
1167
1168 s.mu.Lock()
1169 delete(s.containers, d)
1170 s.mu.Unlock()
1171
1172 log.Info().Str("domain", d).Str(zerowrap.FieldEntityID, c.ID).Msg("container stopped successfully")
1173 }
1174
1175 if errorCount > 0 {
1176 return fmt.Errorf("failed to stop %d containers", errorCount)
1177 }
1178
1179 log.Info().Msg("all managed containers stopped successfully")
1180 return nil
1181}
1182
1183// Utility functions
1184
1185func normalizeImageRef(image string) string {
1186 // Extract repository from image reference, handling port numbers correctly.
1187 // Examples:
1188 // nginx:latest -> docker.io/library/nginx
1189 // user/repo:tag -> docker.io/user/repo
1190 // localhost:5000/image:tag -> localhost:5000/image
1191 // registry.example.com/image:tag -> registry.example.com/image
1192 //
1193 // Note: This function assumes valid Docker image references. Edge cases like
1194 // "registry.com:5000" (registry with port but no image name) are not valid
1195 // image references per Docker's naming conventions, which require at least
1196 // one path component after the registry (e.g., "registry.com:5000/image").
1197
1198 // Find the tag separator: the last colon that isn't part of a port number.
1199 // A colon is part of a port if there's no slash after it until the next colon.
1200 repo := image
1201 lastColon := strings.LastIndex(image, ":")
1202 if lastColon != -1 {
1203 afterColon := image[lastColon+1:]
1204 // If there's no slash after the colon, it's the tag separator
1205 if !strings.Contains(afterColon, "/") {
1206 repo = image[:lastColon]
1207 }
1208 }
1209
1210 if !strings.Contains(repo, "/") {
1211 return "docker.io/library/" + repo
1212 }
1213
1214 if strings.Count(repo, "/") == 1 && !strings.Contains(strings.Split(repo, "/")[0], ".") && !strings.Contains(strings.Split(repo, "/")[0], ":") {
1215 return "docker.io/" + repo
1216 }
1217
1218 return repo
1219}
1220
1221func generateVolumeName(prefix, domainName, volumePath string) string {
1222 return fmt.Sprintf("%s-%s-%s",
1223 prefix,
1224 strings.ReplaceAll(domainName, ".", "-"),
1225 strings.ReplaceAll(strings.Trim(volumePath, "/"), "/", "-"))
1226}
1227
1228func mergeEnvironmentVariables(dockerfileEnv, userEnv []string) []string {
1229 envMap := make(map[string]string)
1230
1231 for _, env := range dockerfileEnv {
1232 if k, v, ok := strings.Cut(env, "="); ok {
1233 envMap[k] = v
1234 }
1235 }
1236
1237 for _, env := range userEnv {
1238 if k, v, ok := strings.Cut(env, "="); ok {
1239 envMap[k] = v
1240 }
1241 }
1242
1243 result := make([]string, 0, len(envMap))
1244 for k, v := range envMap {
1245 result = append(result, k+"="+v)
1246 }
1247
1248 return result
1249}
1250
1251// extractServiceName gets service name from image reference.
1252// "my-postgres:latest" → "postgres", "redis:7" → "redis"
1253func extractServiceName(image string) string {
1254 // Remove tag
1255 parts := strings.Split(image, ":")
1256 name := parts[0]
1257
1258 // Remove registry prefix if present
1259 if strings.Contains(name, "/") {
1260 nameParts := strings.Split(name, "/")
1261 name = nameParts[len(nameParts)-1]
1262 }
1263
1264 // Remove common prefixes like "my-"
1265 name = strings.TrimPrefix(name, "my-")
1266
1267 return name
1268}
1269
1270// sanitizeName makes a domain safe for container naming.
1271func sanitizeName(domain string) string {
1272 // Replace dots and other special chars with dashes
1273 result := strings.ReplaceAll(domain, ".", "-")
1274 result = strings.ReplaceAll(result, ":", "-")
1275 result = strings.ReplaceAll(result, "/", "-")
1276 return result
1277}
1278
1279// findContainerByName finds a container by its name.
1280func (s *Service) findContainerByName(ctx context.Context, name string) *domain.Container {
1281 containers, err := s.runtime.ListContainers(ctx, true)
1282 if err != nil {
1283 return nil
1284 }
1285
1286 for _, c := range containers {
1287 if c.Name == name {
1288 return c
1289 }
1290 }
1291 return nil
1292}
1293
1294// waitForReady waits for a container to be ready.
1295// Uses simple "running + delay" approach for universal compatibility.
1296func (s *Service) waitForReady(ctx context.Context, containerID string) error {
1297 log := zerowrap.FromCtx(ctx)
1298
1299 // Poll for container to be running (max 30 seconds)
1300 for i := 0; i < 30; i++ {
1301 running, err := s.runtime.IsContainerRunning(ctx, containerID)
1302 if err != nil {
1303 return err
1304 }
1305 if running {
1306 break
1307 }
1308 if i == 29 {
1309 return fmt.Errorf("container did not start within 30 seconds")
1310 }
1311 time.Sleep(time.Second)
1312 }
1313
1314 // Additional readiness delay (configurable, default 5 seconds)
1315 delay := s.config.ReadinessDelay
1316 if delay == 0 {
1317 delay = 5 * time.Second
1318 }
1319
1320 log.Debug().Dur("delay", delay).Msg("waiting for container readiness")
1321 time.Sleep(delay)
1322
1323 // Verify still running after delay
1324 running, err := s.runtime.IsContainerRunning(ctx, containerID)
1325 if err != nil {
1326 return err
1327 }
1328 if !running {
1329 return fmt.Errorf("container stopped during readiness delay")
1330 }
1331
1332 return nil
1333}
1334
1335// publishContainerDeployed publishes a container.deployed event.
1336func (s *Service) publishContainerDeployed(ctx context.Context, domainName, containerID string) {
1337 payload := &domain.ContainerEventPayload{
1338 ContainerID: containerID,
1339 Domain: domainName,
1340 Action: "deployed",
1341 }
1342
1343 if err := s.eventBus.Publish(domain.EventContainerDeployed, payload); err != nil {
1344 log := zerowrap.FromCtx(ctx)
1345 log.Warn().Err(err).Msg("failed to publish container deployed event")
1346 }
1347}
1348
1349// rewriteToRegistryDomain rewrites an image reference to use the configured registry domain.
1350// e.g., "myapp:latest" -> "registry.example.com/myapp:latest"
1351func rewriteToRegistryDomain(imageRef, registryDomain string) string {
1352 if registryDomain == "" {
1353 return imageRef
1354 }
1355
1356 prefix := registryDomain + "/"
1357 if strings.HasPrefix(imageRef, prefix) {
1358 return imageRef
1359 }
1360
1361 return prefix + imageRef
1362}
1363
1364// rewriteToLocalRegistry rewrites an image reference to use the local registry address.
1365// e.g., "registry.example.com/myapp:latest" -> "localhost:5000/myapp:latest"
1366func rewriteToLocalRegistry(imageRef, registryDomain string, registryPort int) string {
1367 if imageRef == "" {
1368 return imageRef
1369 }
1370
1371 localRegistry := fmt.Sprintf("localhost:%d", registryPort)
1372 localPrefix := localRegistry + "/"
1373 imageRef = strings.TrimPrefix(imageRef, localPrefix)
1374
1375 registryDomain = strings.TrimSuffix(registryDomain, "/")
1376 if registryDomain != "" {
1377 prefix := registryDomain + "/"
1378 imageRef = strings.TrimPrefix(imageRef, prefix)
1379 }
1380
1381 return localPrefix + imageRef
1382}