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