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