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