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// HealthCheck performs health checks on all containers.
320func (s *Service) HealthCheck(ctx context.Context) map[string]bool {
321 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
322 zerowrap.FieldLayer: "usecase",
323 zerowrap.FieldUseCase: "HealthCheck",
324 })
325 log := zerowrap.FromCtx(ctx)
326
327 s.mu.RLock()
328 defer s.mu.RUnlock()
329
330 health := make(map[string]bool, len(s.containers))
331 for d, c := range s.containers {
332 running, err := s.runtime.IsContainerRunning(ctx, c.ID)
333 if err != nil {
334 log.WrapErrWithFields(err, "health check failed", map[string]any{"domain": d, zerowrap.FieldEntityID: c.ID})
335 health[d] = false
336 } else {
337 health[d] = running
338 }
339 }
340 return health
341}
342
343// SyncContainers synchronizes containers with runtime state.
344func (s *Service) SyncContainers(ctx context.Context) error {
345 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
346 zerowrap.FieldLayer: "usecase",
347 zerowrap.FieldUseCase: "SyncContainers",
348 })
349 log := zerowrap.FromCtx(ctx)
350
351 s.mu.Lock()
352 defer s.mu.Unlock()
353
354 allContainers, err := s.runtime.ListContainers(ctx, false)
355 if err != nil {
356 return log.WrapErr(err, "failed to list containers")
357 }
358
359 managed := make(map[string]*domain.Container)
360 for _, c := range allContainers {
361 if c.Labels != nil {
362 if d, ok := c.Labels["gordon.domain"]; ok && c.Labels["gordon.managed"] == "true" {
363 managed[d] = c
364 }
365 }
366 }
367
368 s.containers = managed
369 log.Info().Int(zerowrap.FieldCount, len(managed)).Msg("container state synchronized")
370 return nil
371}
372
373// AutoStart starts containers for the provided routes that aren't running.
374func (s *Service) AutoStart(ctx context.Context, routes []domain.Route) error {
375 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
376 zerowrap.FieldLayer: "usecase",
377 zerowrap.FieldUseCase: "AutoStart",
378 })
379 log := zerowrap.FromCtx(ctx)
380
381 log.Info().Int("route_count", len(routes)).Msg("auto-starting containers for configured routes")
382
383 var started, skipped, errors int
384 for _, route := range routes {
385 // Check if container already exists and is running
386 if _, exists := s.Get(ctx, route.Domain); exists {
387 log.Debug().Str("domain", route.Domain).Msg("container already running, skipping")
388 skipped++
389 continue
390 }
391
392 log.Info().
393 Str("domain", route.Domain).
394 Str("image", route.Image).
395 Msg("auto-starting container for route")
396
397 if _, err := s.Deploy(ctx, route); err != nil {
398 log.Warn().Err(err).Str("domain", route.Domain).Msg("failed to auto-start container")
399 errors++
400 continue
401 }
402
403 started++
404 }
405
406 log.Info().
407 Int("started", started).
408 Int("skipped", skipped).
409 Int("errors", errors).
410 Msg("auto-start completed")
411
412 if errors > 0 {
413 return fmt.Errorf("auto-start completed with %d errors", errors)
414 }
415 return nil
416}
417
418// Shutdown gracefully shuts down all managed containers.
419func (s *Service) Shutdown(ctx context.Context) error {
420 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
421 zerowrap.FieldLayer: "usecase",
422 zerowrap.FieldUseCase: "Shutdown",
423 })
424 log := zerowrap.FromCtx(ctx)
425 log.Info().Msg("shutting down container manager...")
426
427 if err := s.stopAllManagedContainers(ctx); err != nil {
428 log.WrapErr(err, "failed to stop all containers during shutdown")
429 }
430
431 // Close log writer to stop all log collection
432 if s.logWriter != nil {
433 if err := s.logWriter.Close(); err != nil {
434 log.Warn().Err(err).Msg("failed to close container log writer")
435 }
436 }
437
438 log.Info().Msg("container manager shutdown complete")
439 return nil
440}
441
442// UpdateConfig updates the service configuration.
443func (s *Service) UpdateConfig(config Config) {
444 s.config = config
445}
446
447// Helper methods
448
449// cleanupFailedContainer stops and removes a container that failed to start properly.
450func (s *Service) cleanupFailedContainer(ctx context.Context, containerID string) {
451 log := zerowrap.FromCtx(ctx)
452 if err := s.runtime.StopContainer(ctx, containerID); err != nil {
453 log.Warn().Err(err).Str(zerowrap.FieldEntityID, containerID).Msg("failed to stop container after failure")
454 }
455 if err := s.runtime.RemoveContainer(ctx, containerID, true); err != nil {
456 log.Warn().Err(err).Str(zerowrap.FieldEntityID, containerID).Msg("failed to remove container after failure")
457 }
458}
459
460// cleanupOldContainer stops and removes an old container after zero-downtime switch.
461// It also renames the new container to the canonical name.
462func (s *Service) cleanupOldContainer(ctx context.Context, old *domain.Container, newContainerID, domainName string) {
463 log := zerowrap.FromCtx(ctx)
464 log.Info().Str(zerowrap.FieldEntityID, old.ID).Msg("stopping old container after zero-downtime switch")
465
466 if s.logWriter != nil {
467 if err := s.logWriter.StopLogging(old.ID); err != nil {
468 log.Warn().Err(err).Str(zerowrap.FieldEntityID, old.ID).Msg("failed to stop logging for old container")
469 }
470 }
471
472 if err := s.runtime.StopContainer(ctx, old.ID); err != nil {
473 log.Warn().Err(err).Str(zerowrap.FieldEntityID, old.ID).Msg("failed to stop old container")
474 }
475
476 if err := s.runtime.RemoveContainer(ctx, old.ID, true); err != nil {
477 log.Warn().Err(err).Str(zerowrap.FieldEntityID, old.ID).Msg("failed to remove old container")
478 }
479
480 // Rename new container to canonical name
481 canonicalName := fmt.Sprintf("gordon-%s", domainName)
482 if err := s.runtime.RenameContainer(ctx, newContainerID, canonicalName); err != nil {
483 log.Warn().Err(err).Str("canonical_name", canonicalName).Msg("failed to rename container to canonical name")
484 }
485}
486
487// startLogCollection starts log collection for a container in the background.
488// Errors are logged but don't fail the calling operation.
489func (s *Service) startLogCollection(ctx context.Context, containerID, domainName string) {
490 if s.logWriter == nil {
491 return
492 }
493
494 log := zerowrap.FromCtx(ctx)
495
496 logStream, err := s.runtime.GetContainerLogs(ctx, containerID, true)
497 if err != nil {
498 log.Warn().Err(err).Msg("failed to get container logs for collection")
499 return
500 }
501
502 if err := s.logWriter.StartLogging(ctx, containerID, domainName, logStream); err != nil {
503 log.Warn().Err(err).Msg("failed to start container log collection")
504 logStream.Close()
505 }
506}
507
508func (s *Service) buildImageRef(image string) string {
509 if !s.config.RegistryAuthEnabled || s.config.RegistryDomain == "" {
510 return image
511 }
512
513 repoPart := strings.Split(image, ":")[0]
514 if strings.HasPrefix(repoPart, s.config.RegistryDomain+"/") {
515 return image
516 }
517 if strings.Contains(repoPart, ".") && strings.Contains(repoPart, "/") {
518 return image
519 }
520 return fmt.Sprintf("%s/%s", s.config.RegistryDomain, image)
521}
522
523func normalizePullPolicy(policy string) string {
524 switch strings.ToLower(strings.TrimSpace(policy)) {
525 case PullPolicyAlways:
526 return PullPolicyAlways
527 case PullPolicyIfTagChanged:
528 return PullPolicyIfTagChanged
529 case PullPolicyIfNotPresent:
530 return PullPolicyIfNotPresent
531 default:
532 return PullPolicyIfNotPresent
533 }
534}
535
536func isDigestRef(imageRef string) bool {
537 return strings.Contains(imageRef, "@sha256:")
538}
539
540func (s *Service) pullRefForDeploy(ctx context.Context, imageRef string) (string, bool) {
541 if !domain.IsInternalDeploy(ctx) {
542 return imageRef, false
543 }
544 return rewriteToLocalRegistry(imageRef, s.config.RegistryDomain, s.config.RegistryPort), true
545}
546
547// ensureImage ensures the image is available locally, pulling if needed.
548// Returns the image reference to use for container operations:
549// - For digest references (@sha256:...), returns the pullRef since Docker can't tag digests
550// - For tagged images, returns the original imageRef after tagging the pulled image
551func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, error) {
552 ctx = zerowrap.CtxWithField(ctx, "image", imageRef)
553 log := zerowrap.FromCtx(ctx)
554
555 // Determine if this is an internal deploy and what reference to use for pulls.
556 pullRef, isInternal := s.pullRefForDeploy(ctx, imageRef)
557 if pullRef != imageRef {
558 log.Info().
559 Str("original_ref", imageRef).
560 Str("pull_ref", pullRef).
561 Msg("internal deploy: using localhost registry for pull")
562 }
563
564 found, err := s.ensureLocalImage(ctx, imageRef, pullRef)
565 if err != nil {
566 return "", err
567 }
568 if found {
569 return imageRef, nil
570 }
571
572 // Pull image
573 log.Info().Msg("pulling image from registry")
574
575 if err := s.pullImage(ctx, pullRef, isInternal); err != nil {
576 return "", err
577 }
578
579 // For digest references, we can't create a tag (Docker doesn't allow it).
580 // In this case, use the pullRef directly since the image is available under that reference.
581 if strings.Contains(imageRef, "@sha256:") {
582 log.Info().
583 Str("pull_ref", pullRef).
584 Msg("digest reference: using pull reference for container operations")
585 return pullRef, nil
586 }
587
588 if err := s.tagImageIfNeeded(ctx, pullRef, imageRef); err != nil {
589 return "", err
590 }
591
592 // Clean up the temporary pull reference tag to avoid duplicate entries
593 if pullRef != imageRef {
594 if err := s.runtime.UntagImage(ctx, pullRef); err != nil {
595 // Log but don't fail - the canonical tag is already applied
596 log.Debug().Err(err).Str("pull_ref", pullRef).Msg("failed to remove temporary pull tag")
597 }
598 }
599
600 log.Info().Msg("image pulled successfully")
601 return imageRef, nil
602}
603
604func (s *Service) ensureLocalImage(ctx context.Context, imageRef, pullRef string) (bool, error) {
605 log := zerowrap.FromCtx(ctx)
606
607 // For internal deploys (image push events), always pull fresh image
608 // because the same tag may reference new content (e.g., latest tag updated)
609 if domain.IsInternalDeploy(ctx) {
610 log.Info().Msg("internal deploy detected, forcing image pull to ensure latest content")
611 return false, nil
612 }
613
614 pullPolicy := normalizePullPolicy(s.config.PullPolicy)
615 switch pullPolicy {
616 case PullPolicyAlways:
617 log.Info().Str("pull_policy", pullPolicy).Msg("pull policy forces image pull")
618 return false, nil
619 case PullPolicyIfTagChanged:
620 if !isDigestRef(imageRef) {
621 log.Info().Str("pull_policy", pullPolicy).Msg("tag reference detected, pulling to check for updates")
622 return false, nil
623 }
624 }
625
626 localImages, err := s.runtime.ListImages(ctx)
627 if err != nil {
628 log.WrapErr(err, "failed to list local images, will attempt pull")
629 return false, nil
630 }
631
632 normalizedRef := normalizeImageRef(imageRef)
633 normalizedPullRef := normalizeImageRef(pullRef)
634 for _, img := range localImages {
635 normalizedImage := normalizeImageRef(img)
636 if normalizedImage == normalizedRef {
637 log.Info().Msg("image found locally, skipping pull")
638 return true, nil
639 }
640 if normalizedImage == normalizedPullRef {
641 if err := s.tagImageIfNeeded(ctx, pullRef, imageRef); err != nil {
642 return false, err
643 }
644 // Clean up the temporary pull reference tag
645 if pullRef != imageRef {
646 if err := s.runtime.UntagImage(ctx, pullRef); err != nil {
647 log.Debug().Err(err).Str("pull_ref", pullRef).Msg("failed to remove temporary pull tag")
648 }
649 }
650 log.Info().Msg("image found locally, skipping pull")
651 return true, nil
652 }
653 }
654
655 return false, nil
656}
657
658func (s *Service) pullImage(ctx context.Context, pullRef string, isInternal bool) error {
659 log := zerowrap.FromCtx(ctx)
660
661 switch {
662 case isInternal && s.config.RegistryAuthEnabled:
663 if s.config.InternalRegistryUsername == "" || s.config.InternalRegistryPassword == "" {
664 return log.WrapErr(fmt.Errorf("internal registry auth not configured"), "failed to pull image for internal deploy")
665 }
666 if err := s.runtime.PullImageWithAuth(ctx, pullRef, s.config.InternalRegistryUsername, s.config.InternalRegistryPassword); err != nil {
667 return log.WrapErr(err, "failed to pull image with internal auth")
668 }
669 case isInternal:
670 if err := s.runtime.PullImage(ctx, pullRef); err != nil {
671 return log.WrapErr(err, "failed to pull image")
672 }
673 case s.config.RegistryAuthEnabled:
674 if err := s.runtime.PullImageWithAuth(ctx, pullRef, s.config.RegistryUsername, s.config.RegistryPassword); err != nil {
675 return log.WrapErr(err, "failed to pull image with auth")
676 }
677 default:
678 if err := s.runtime.PullImage(ctx, pullRef); err != nil {
679 return log.WrapErr(err, "failed to pull image")
680 }
681 }
682
683 return nil
684}
685
686func (s *Service) tagImageIfNeeded(ctx context.Context, sourceRef, targetRef string) error {
687 if sourceRef == targetRef {
688 return nil
689 }
690
691 // Cannot create a tag with a digest reference - Docker/Podman doesn't allow it.
692 // When using digest references (image@sha256:...), skip tagging as the image
693 // is already available by its digest.
694 if strings.Contains(targetRef, "@sha256:") {
695 return nil
696 }
697
698 log := zerowrap.FromCtx(ctx)
699 if err := s.runtime.TagImage(ctx, sourceRef, targetRef); err != nil {
700 return log.WrapErr(err, "failed to tag image from pull reference")
701 }
702 return nil
703}
704
705func (s *Service) loadEnvironment(ctx context.Context, domainName, imageRef string) ([]string, error) {
706 log := zerowrap.FromCtx(ctx)
707
708 userEnvVars, err := s.envLoader.LoadEnv(ctx, domainName)
709 if err != nil {
710 return nil, log.WrapErr(err, "failed to load environment variables")
711 }
712
713 dockerfileEnvVars, err := s.runtime.InspectImageEnv(ctx, imageRef)
714 if err != nil {
715 log.WrapErr(err, "failed to inspect image environment")
716 dockerfileEnvVars = []string{}
717 }
718
719 return mergeEnvironmentVariables(dockerfileEnvVars, userEnvVars), nil
720}
721
722func (s *Service) setupVolumes(ctx context.Context, domainName, imageRef string) (map[string]string, error) {
723 log := zerowrap.FromCtx(ctx)
724 volumes := make(map[string]string)
725
726 if !s.config.VolumeAutoCreate {
727 return volumes, nil
728 }
729
730 volumePaths, err := s.runtime.InspectImageVolumes(ctx, imageRef)
731 if err != nil {
732 log.WrapErr(err, "failed to inspect image volumes")
733 return volumes, nil
734 }
735
736 for _, path := range volumePaths {
737 name := generateVolumeName(s.config.VolumePrefix, domainName, path)
738
739 exists, err := s.runtime.VolumeExists(ctx, name)
740 if err != nil {
741 log.WrapErrWithFields(err, "failed to check volume", map[string]any{"volume": name})
742 continue
743 }
744
745 if !exists {
746 if err := s.runtime.CreateVolume(ctx, name); err != nil {
747 log.WrapErrWithFields(err, "failed to create volume", map[string]any{"volume": name})
748 continue
749 }
750 log.Info().Str("volume", name).Str(zerowrap.FieldPath, path).Msg("created volume")
751 }
752
753 volumes[path] = name
754 }
755
756 return volumes, nil
757}
758
759func (s *Service) getNetworkForApp(domainName string) string {
760 if !s.config.NetworkIsolation {
761 return "bridge"
762 }
763
764 for groupName, domains := range s.config.NetworkGroups {
765 if slices.Contains(domains, domainName) {
766 return s.generateNetworkName(groupName)
767 }
768 }
769
770 return s.generateNetworkName(domainName)
771}
772
773func (s *Service) generateNetworkName(identifier string) string {
774 return fmt.Sprintf("%s-%s", s.config.NetworkPrefix, strings.ReplaceAll(identifier, ".", "-"))
775}
776
777func (s *Service) createNetworkIfNeeded(ctx context.Context, networkName string) error {
778 if networkName == "bridge" || networkName == "default" {
779 return nil
780 }
781
782 ctx = zerowrap.CtxWithField(ctx, "network", networkName)
783 log := zerowrap.FromCtx(ctx)
784
785 exists, err := s.runtime.NetworkExists(ctx, networkName)
786 if err != nil {
787 return log.WrapErr(err, "failed to check network existence")
788 }
789
790 if !exists {
791 if err := s.runtime.CreateNetwork(ctx, networkName, map[string]string{"driver": "bridge"}); err != nil {
792 return log.WrapErr(err, "failed to create network")
793 }
794 log.Info().Msg("created network for app isolation")
795 }
796
797 return nil
798}
799
800func (s *Service) cleanupOrphanedContainers(ctx context.Context, domainName string, skipContainerID string) error {
801 log := zerowrap.FromCtx(ctx)
802 expectedName := fmt.Sprintf("gordon-%s", domainName)
803
804 allContainers, err := s.runtime.ListContainers(ctx, true)
805 if err != nil {
806 return err
807 }
808
809 for _, c := range allContainers {
810 if c.Name == expectedName && c.ID != skipContainerID {
811 log.Info().Str(zerowrap.FieldEntityID, c.ID).Str(zerowrap.FieldStatus, c.Status).Msg("found orphaned container, removing")
812
813 if err := s.runtime.StopContainer(ctx, c.ID); err != nil {
814 log.WrapErrWithFields(err, "failed to stop orphaned container", map[string]any{zerowrap.FieldEntityID: c.ID})
815 }
816
817 if err := s.runtime.RemoveContainer(ctx, c.ID, true); err != nil {
818 log.WrapErrWithFields(err, "failed to remove orphaned container", map[string]any{zerowrap.FieldEntityID: c.ID})
819 }
820 }
821 }
822
823 return nil
824}
825
826func (s *Service) cleanupVolumesForDomain(_ context.Context, _ string) error {
827 return nil
828}
829
830func (s *Service) cleanupNetworkIfEmpty(ctx context.Context, networkName string) error {
831 if networkName == "bridge" || networkName == "default" {
832 return nil
833 }
834
835 ctx = zerowrap.CtxWithField(ctx, "network", networkName)
836 log := zerowrap.FromCtx(ctx)
837
838 networks, err := s.runtime.ListNetworks(ctx)
839 if err != nil {
840 return log.WrapErr(err, "failed to list networks")
841 }
842
843 for _, n := range networks {
844 if n.Name == networkName && len(n.Containers) == 0 {
845 if err := s.runtime.RemoveNetwork(ctx, networkName); err != nil {
846 return err
847 }
848 log.Info().Msg("cleaned up empty network")
849 break
850 }
851 }
852
853 return nil
854}
855
856func (s *Service) deployAttachments(ctx context.Context, domainName, networkName string) error {
857 attachments, ok := s.config.Attachments[domainName]
858 if !ok {
859 return nil
860 }
861
862 log := zerowrap.FromCtx(ctx)
863 for _, svc := range attachments {
864 if err := s.deployAttachedService(ctx, domainName, svc, networkName); err != nil {
865 log.WrapErrWithFields(err, "failed to deploy attachment", map[string]any{zerowrap.FieldService: svc, "domain": domainName})
866 }
867 }
868
869 return nil
870}
871
872func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, serviceImage, networkName string) error {
873 log := zerowrap.FromCtx(ctx)
874
875 // Parse service name from image (e.g., "my-postgres:latest" → "postgres")
876 serviceName := extractServiceName(serviceImage)
877 containerName := fmt.Sprintf("gordon-%s-%s", sanitizeName(ownerDomain), serviceName)
878
879 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
880 "attachment": serviceName,
881 "container_name": containerName,
882 "owner_domain": ownerDomain,
883 })
884
885 // Check if already running (idempotent)
886 existingContainer := s.findContainerByName(ctx, containerName)
887 if existingContainer != nil && existingContainer.Status == string(domain.ContainerStatusRunning) {
888 log.Debug().Str("container_name", containerName).Msg("attachment already running, skipping")
889 return nil
890 }
891
892 // Remove existing stopped container if present
893 if existingContainer != nil {
894 log.Info().Str("container_name", containerName).Msg("removing stopped attachment container")
895 if err := s.runtime.RemoveContainer(ctx, existingContainer.ID, true); err != nil {
896 log.WrapErr(err, "failed to remove existing attachment container")
897 }
898 }
899
900 log.Info().Str(zerowrap.FieldService, serviceImage).Msg("deploying attached service")
901
902 // Ensure image (canonical ref is returned; internal pulls may use the local registry).
903 imageRef := s.buildImageRef(serviceImage)
904 actualImageRef, err := s.ensureImage(ctx, imageRef)
905 if err != nil {
906 return err
907 }
908
909 // Get image metadata
910 exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, actualImageRef)
911 if err != nil {
912 log.WrapErr(err, "failed to get exposed ports for attachment, using defaults")
913 exposedPorts = []int{}
914 }
915
916 // Setup volumes (attachments need persistent data)
917 volumes, err := s.setupVolumes(ctx, containerName, actualImageRef)
918 if err != nil {
919 log.WrapErr(err, "failed to setup volumes for attachment")
920 volumes = make(map[string]string)
921 }
922
923 // Load environment (attachment-specific env file)
924 envVars, err := s.loadEnvironment(ctx, containerName, actualImageRef)
925 if err != nil {
926 log.WrapErr(err, "failed to load environment for attachment")
927 envVars = []string{}
928 }
929
930 // Create container with attachment labels (use actual ref, track original in labels)
931 config := &domain.ContainerConfig{
932 Image: actualImageRef,
933 Name: containerName,
934 Hostname: serviceName, // Internal DNS: postgres, redis, etc.
935 Ports: exposedPorts,
936 Env: envVars,
937 Volumes: volumes,
938 NetworkMode: networkName, // Same network as main app
939 Labels: map[string]string{
940 "gordon.managed": "true",
941 "gordon.attachment": "true",
942 "gordon.attached-to": ownerDomain,
943 "gordon.image": serviceImage,
944 },
945 }
946
947 container, err := s.runtime.CreateContainer(ctx, config)
948 if err != nil {
949 return log.WrapErr(err, "failed to create attachment container")
950 }
951
952 // Start container
953 if err := s.runtime.StartContainer(ctx, container.ID); err != nil {
954 s.runtime.RemoveContainer(ctx, container.ID, true)
955 return log.WrapErr(err, "failed to start attachment container")
956 }
957
958 // Track attachment
959 s.mu.Lock()
960 s.attachments[ownerDomain] = append(s.attachments[ownerDomain], container.ID)
961 s.mu.Unlock()
962
963 // Start log collection for attachment
964 s.startLogCollection(ctx, container.ID, containerName)
965
966 log.Info().Str(zerowrap.FieldEntityID, container.ID).Msg("attachment deployed successfully")
967 return nil
968}
969
970func (s *Service) stopAllManagedContainers(ctx context.Context) error {
971 log := zerowrap.FromCtx(ctx)
972
973 s.mu.RLock()
974 containers := make(map[string]*domain.Container, len(s.containers))
975 maps.Copy(containers, s.containers)
976 s.mu.RUnlock()
977
978 if len(containers) == 0 {
979 log.Info().Msg("no managed containers to stop")
980 return nil
981 }
982
983 log.Info().Int(zerowrap.FieldCount, len(containers)).Msg("stopping all managed containers...")
984
985 errorCount := 0
986 for d, c := range containers {
987 log.Info().Str("domain", d).Str(zerowrap.FieldEntityID, c.ID).Msg("stopping managed container")
988
989 if err := s.runtime.StopContainer(ctx, c.ID); err != nil {
990 log.WrapErrWithFields(err, "failed to stop container", map[string]any{"domain": d, zerowrap.FieldEntityID: c.ID})
991 errorCount++
992 continue
993 }
994
995 s.mu.Lock()
996 delete(s.containers, d)
997 s.mu.Unlock()
998
999 log.Info().Str("domain", d).Str(zerowrap.FieldEntityID, c.ID).Msg("container stopped successfully")
1000 }
1001
1002 if errorCount > 0 {
1003 return fmt.Errorf("failed to stop %d containers", errorCount)
1004 }
1005
1006 log.Info().Msg("all managed containers stopped successfully")
1007 return nil
1008}
1009
1010// Utility functions
1011
1012func normalizeImageRef(image string) string {
1013 // Extract repository from image reference, handling port numbers correctly.
1014 // Examples:
1015 // nginx:latest -> docker.io/library/nginx
1016 // user/repo:tag -> docker.io/user/repo
1017 // localhost:5000/image:tag -> localhost:5000/image
1018 // registry.example.com/image:tag -> registry.example.com/image
1019 //
1020 // Note: This function assumes valid Docker image references. Edge cases like
1021 // "registry.com:5000" (registry with port but no image name) are not valid
1022 // image references per Docker's naming conventions, which require at least
1023 // one path component after the registry (e.g., "registry.com:5000/image").
1024
1025 // Find the tag separator: the last colon that isn't part of a port number.
1026 // A colon is part of a port if there's no slash after it until the next colon.
1027 repo := image
1028 lastColon := strings.LastIndex(image, ":")
1029 if lastColon != -1 {
1030 afterColon := image[lastColon+1:]
1031 // If there's no slash after the colon, it's the tag separator
1032 if !strings.Contains(afterColon, "/") {
1033 repo = image[:lastColon]
1034 }
1035 }
1036
1037 if !strings.Contains(repo, "/") {
1038 return "docker.io/library/" + repo
1039 }
1040
1041 if strings.Count(repo, "/") == 1 && !strings.Contains(strings.Split(repo, "/")[0], ".") && !strings.Contains(strings.Split(repo, "/")[0], ":") {
1042 return "docker.io/" + repo
1043 }
1044
1045 return repo
1046}
1047
1048func generateVolumeName(prefix, domainName, volumePath string) string {
1049 return fmt.Sprintf("%s-%s-%s",
1050 prefix,
1051 strings.ReplaceAll(domainName, ".", "-"),
1052 strings.ReplaceAll(strings.Trim(volumePath, "/"), "/", "-"))
1053}
1054
1055func mergeEnvironmentVariables(dockerfileEnv, userEnv []string) []string {
1056 envMap := make(map[string]string)
1057
1058 for _, env := range dockerfileEnv {
1059 if k, v, ok := strings.Cut(env, "="); ok {
1060 envMap[k] = v
1061 }
1062 }
1063
1064 for _, env := range userEnv {
1065 if k, v, ok := strings.Cut(env, "="); ok {
1066 envMap[k] = v
1067 }
1068 }
1069
1070 result := make([]string, 0, len(envMap))
1071 for k, v := range envMap {
1072 result = append(result, k+"="+v)
1073 }
1074
1075 return result
1076}
1077
1078// extractServiceName gets service name from image reference.
1079// "my-postgres:latest" → "postgres", "redis:7" → "redis"
1080func extractServiceName(image string) string {
1081 // Remove tag
1082 parts := strings.Split(image, ":")
1083 name := parts[0]
1084
1085 // Remove registry prefix if present
1086 if strings.Contains(name, "/") {
1087 nameParts := strings.Split(name, "/")
1088 name = nameParts[len(nameParts)-1]
1089 }
1090
1091 // Remove common prefixes like "my-"
1092 name = strings.TrimPrefix(name, "my-")
1093
1094 return name
1095}
1096
1097// sanitizeName makes a domain safe for container naming.
1098func sanitizeName(domain string) string {
1099 // Replace dots and other special chars with dashes
1100 result := strings.ReplaceAll(domain, ".", "-")
1101 result = strings.ReplaceAll(result, ":", "-")
1102 result = strings.ReplaceAll(result, "/", "-")
1103 return result
1104}
1105
1106// findContainerByName finds a container by its name.
1107func (s *Service) findContainerByName(ctx context.Context, name string) *domain.Container {
1108 containers, err := s.runtime.ListContainers(ctx, true)
1109 if err != nil {
1110 return nil
1111 }
1112
1113 for _, c := range containers {
1114 if c.Name == name {
1115 return c
1116 }
1117 }
1118 return nil
1119}
1120
1121// waitForReady waits for a container to be ready.
1122// Uses simple "running + delay" approach for universal compatibility.
1123func (s *Service) waitForReady(ctx context.Context, containerID string) error {
1124 log := zerowrap.FromCtx(ctx)
1125
1126 // Poll for container to be running (max 30 seconds)
1127 for i := 0; i < 30; i++ {
1128 running, err := s.runtime.IsContainerRunning(ctx, containerID)
1129 if err != nil {
1130 return err
1131 }
1132 if running {
1133 break
1134 }
1135 if i == 29 {
1136 return fmt.Errorf("container did not start within 30 seconds")
1137 }
1138 time.Sleep(time.Second)
1139 }
1140
1141 // Additional readiness delay (configurable, default 5 seconds)
1142 delay := s.config.ReadinessDelay
1143 if delay == 0 {
1144 delay = 5 * time.Second
1145 }
1146
1147 log.Debug().Dur("delay", delay).Msg("waiting for container readiness")
1148 time.Sleep(delay)
1149
1150 // Verify still running after delay
1151 running, err := s.runtime.IsContainerRunning(ctx, containerID)
1152 if err != nil {
1153 return err
1154 }
1155 if !running {
1156 return fmt.Errorf("container stopped during readiness delay")
1157 }
1158
1159 return nil
1160}
1161
1162// publishContainerDeployed publishes a container.deployed event.
1163func (s *Service) publishContainerDeployed(ctx context.Context, domainName, containerID string) {
1164 payload := &domain.ContainerEventPayload{
1165 ContainerID: containerID,
1166 Domain: domainName,
1167 Action: "deployed",
1168 }
1169
1170 if err := s.eventBus.Publish(domain.EventContainerDeployed, payload); err != nil {
1171 log := zerowrap.FromCtx(ctx)
1172 log.Warn().Err(err).Msg("failed to publish container deployed event")
1173 }
1174}
1175
1176// rewriteToRegistryDomain rewrites an image reference to use the configured registry domain.
1177// e.g., "myapp:latest" -> "registry.example.com/myapp:latest"
1178func rewriteToRegistryDomain(imageRef, registryDomain string) string {
1179 if registryDomain == "" {
1180 return imageRef
1181 }
1182
1183 prefix := registryDomain + "/"
1184 if strings.HasPrefix(imageRef, prefix) {
1185 return imageRef
1186 }
1187
1188 return prefix + imageRef
1189}
1190
1191// rewriteToLocalRegistry rewrites an image reference to use the local registry address.
1192// e.g., "registry.example.com/myapp:latest" -> "localhost:5000/myapp:latest"
1193func rewriteToLocalRegistry(imageRef, registryDomain string, registryPort int) string {
1194 if imageRef == "" {
1195 return imageRef
1196 }
1197
1198 localRegistry := fmt.Sprintf("localhost:%d", registryPort)
1199 localPrefix := localRegistry + "/"
1200 imageRef = strings.TrimPrefix(imageRef, localPrefix)
1201
1202 registryDomain = strings.TrimSuffix(registryDomain, "/")
1203 if registryDomain != "" {
1204 prefix := registryDomain + "/"
1205 imageRef = strings.TrimPrefix(imageRef, prefix)
1206 }
1207
1208 return localPrefix + imageRef
1209}