1// Package container implements the container management use case.
2package container
3
4import (
5 "context"
6 "errors"
7 "fmt"
8 "maps"
9 "slices"
10 "strings"
11 "sync"
12 "time"
13
14 "github.com/bnema/zerowrap"
15
16 "github.com/bnema/gordon/internal/boundaries/out"
17 "github.com/bnema/gordon/internal/domain"
18)
19
20// Config holds configuration needed by the container service.
21type Config struct {
22 RegistryAuthEnabled bool
23 RegistryDomain string
24 RegistryPort int
25 ServiceTokenUsername string
26 ServiceToken string
27 InternalRegistryUsername string
28 InternalRegistryPassword string
29 PullPolicy string
30 VolumeAutoCreate bool
31 VolumePrefix string
32 VolumePreserve bool
33 NetworkIsolation bool
34 NetworkPrefix string
35 DNSSuffix string
36 NetworkGroups map[string][]string
37 Attachments map[string][]string
38 ReadinessDelay time.Duration // Delay after container starts before considering it ready
39 ReadinessMode string // Readiness strategy: auto, docker-health, delay
40 HealthTimeout time.Duration // Max wait for health-based readiness
41 DrainDelay time.Duration // Grace period after cache invalidation before stopping old container
42 DrainDelayConfigured bool // True when deploy.drain_delay was explicitly configured
43 DrainMode string // Drain strategy: auto, inflight, delay
44 DrainTimeout time.Duration // Max wait for in-flight requests to drain
45}
46
47const (
48 PullPolicyAlways = "always"
49 PullPolicyIfNotPresent = "if-not-present"
50 PullPolicyIfTagChanged = "if-tag-changed"
51
52 // readinessRecoveryWindow is an additional grace window used when a container
53 // is briefly not running at the end of readiness delay. This avoids false
54 // negatives during short startup flaps.
55 readinessRecoveryWindow = 30 * time.Second
56 internalPullMaxAttempts = 3
57)
58
59// Service implements the ContainerService interface.
60type Service struct {
61 runtime out.ContainerRuntime
62 envLoader out.EnvLoader
63 eventBus out.EventPublisher
64 logWriter out.ContainerLogWriter
65 cacheInvalidator out.ProxyCacheInvalidator
66 drainWaiter out.ProxyDrainWaiter
67 config Config
68 containers map[string]*domain.Container
69 attachments map[string][]string // ownerDomain → []containerIDs
70 mu sync.RWMutex
71 deployMu sync.Map // per-domain deploy locks (domain → *domainDeployLock)
72}
73
74// domainDeployLock is a context-aware mutex using a buffered channel.
75// It allows Lock() to be interrupted by context cancellation.
76type domainDeployLock struct {
77 ch chan struct{}
78}
79
80// newDomainDeployLock creates a new lock that is initially unlocked.
81func newDomainDeployLock() *domainDeployLock {
82 l := &domainDeployLock{ch: make(chan struct{}, 1)}
83 l.ch <- struct{}{}
84 return l
85}
86
87// Lock acquires the lock or returns an error if the context is cancelled.
88func (l *domainDeployLock) Lock(ctx context.Context) error {
89 // Fast path: honor already-canceled contexts before blocking.
90 if err := ctx.Err(); err != nil {
91 return err
92 }
93 select {
94 case <-ctx.Done():
95 return ctx.Err()
96 case <-l.ch:
97 // Re-check context after acquisition: if canceled while we were
98 // in the select, release the token so the lock isn't consumed.
99 if err := ctx.Err(); err != nil {
100 l.ch <- struct{}{}
101 return err
102 }
103 return nil
104 }
105}
106
107// Unlock releases the lock.
108func (l *domainDeployLock) Unlock() {
109 select {
110 case l.ch <- struct{}{}:
111 default:
112 // This should never happen if Lock/Unlock are used correctly.
113 // Panic to detect double-unlocks or other misuse early.
114 panic("domainDeployLock: double unlock detected")
115 }
116}
117
118// NewService creates a new container service.
119func NewService(
120 runtime out.ContainerRuntime,
121 envLoader out.EnvLoader,
122 eventBus out.EventPublisher,
123 logWriter out.ContainerLogWriter,
124 config Config,
125) *Service {
126 return &Service{
127 runtime: runtime,
128 envLoader: envLoader,
129 eventBus: eventBus,
130 logWriter: logWriter,
131 config: config,
132 containers: make(map[string]*domain.Container),
133 attachments: make(map[string][]string),
134 }
135}
136
137// SetProxyCacheInvalidator sets the proxy cache invalidator for synchronous
138// cache invalidation during zero-downtime deployments. This must be called
139// after construction because the proxy service is created after the container
140// service during application initialization.
141func (s *Service) SetProxyCacheInvalidator(inv out.ProxyCacheInvalidator) {
142 s.mu.Lock()
143 s.cacheInvalidator = inv
144 s.mu.Unlock()
145}
146
147// SetProxyDrainWaiter sets the proxy in-flight drain waiter used during
148// zero-downtime replacement before stopping the old container.
149func (s *Service) SetProxyDrainWaiter(waiter out.ProxyDrainWaiter) {
150 s.mu.Lock()
151 s.drainWaiter = waiter
152 s.mu.Unlock()
153}
154
155// domainDeployMu returns a per-domain lock for serializing deploy operations.
156func (s *Service) domainDeployMu(domain string) *domainDeployLock {
157 v, _ := s.deployMu.LoadOrStore(domain, newDomainDeployLock())
158 // Type assertion is safe: LoadOrStore always stores *domainDeployLock values,
159 // and any concurrent LoadOrStore calls will also store *domainDeployLock.
160 return v.(*domainDeployLock)
161}
162
163// acquireDomainDeployLock acquires the per-domain deploy lock with context cancellation support.
164// Returns an unlock function that must be called to release the lock.
165func (s *Service) acquireDomainDeployLock(ctx context.Context, domain string) (func(), error) {
166 lock := s.domainDeployMu(domain)
167 if err := lock.Lock(ctx); err != nil {
168 return nil, err
169 }
170 return func() { lock.Unlock() }, nil
171}
172
173// buildContainerConfig constructs the container configuration for deployment.
174func (s *Service) deploymentContainerName(containerDomain string, existing *domain.Container) string {
175 canonicalName := fmt.Sprintf("gordon-%s", containerDomain)
176 if existing == nil {
177 return canonicalName
178 }
179
180 // Alternate temporary names if the tracked container was left with a temp suffix.
181 // This prevents name collisions after interrupted zero-downtime deploys.
182 newName := canonicalName + "-new"
183 nextName := canonicalName + "-next"
184 switch existing.Name {
185 case newName:
186 return nextName
187 case nextName:
188 return newName
189 default:
190 return newName
191 }
192}
193
194func (s *Service) buildContainerConfig(containerDomain, image, actualImageRef string, exposedPorts []int, envVars []string, volumes map[string]string, networkName string, existing *domain.Container) *domain.ContainerConfig {
195 containerName := s.deploymentContainerName(containerDomain, existing)
196
197 return &domain.ContainerConfig{
198 Image: actualImageRef,
199 Name: containerName,
200 Ports: exposedPorts,
201 Env: envVars,
202 Volumes: volumes,
203 NetworkMode: networkName,
204 Hostname: containerDomain,
205 Labels: map[string]string{
206 "gordon.domain": containerDomain,
207 "gordon.image": image,
208 "gordon.managed": "true",
209 "gordon.route": containerDomain,
210 },
211 AutoRemove: false,
212 }
213}
214
215// Deploy creates and starts a container for the given route.
216// Implements zero-downtime deployment: new container starts before old one stops.
217func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Container, error) {
218 // Serialize deploys for the same domain to prevent race conditions
219 // (e.g. multiple image.pushed events + explicit deploy call from CLI).
220 unlock, err := s.acquireDomainDeployLock(ctx, route.Domain)
221 if err != nil {
222 return nil, err
223 }
224 defer unlock()
225
226 // Enrich context with use case fields for all downstream logs
227 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
228 zerowrap.FieldLayer: "usecase",
229 zerowrap.FieldUseCase: "Deploy",
230 "domain": route.Domain,
231 })
232 log := zerowrap.FromCtx(ctx)
233
234 existing, hasExisting := s.getTrackedContainer(route.Domain)
235
236 resources, err := s.prepareDeployResources(ctx, route, existing)
237 if err != nil {
238 return nil, err
239 }
240
241 newContainer, err := s.createStartedContainer(ctx, route, existing, resources)
242 if err != nil {
243 return nil, err
244 }
245
246 invalidated := s.activateDeployedContainer(ctx, route.Domain, newContainer)
247 s.finalizePreviousContainer(ctx, route.Domain, existing, hasExisting, invalidated, newContainer.ID)
248
249 // Start container log collection (non-blocking, errors don't fail deployment)
250 s.startLogCollection(ctx, newContainer.ID, route.Domain)
251
252 log.Info().
253 Str("image", route.Image).
254 Str(zerowrap.FieldEntityID, newContainer.ID).
255 Ints("ports", newContainer.Ports).
256 Str("network", resources.networkName).
257 Bool("zero_downtime", hasExisting).
258 Msg("container deployed successfully")
259
260 return newContainer, nil
261}
262
263type deployResources struct {
264 networkName string
265 actualImageRef string
266 exposedPorts []int
267 envVars []string
268 volumes map[string]string
269}
270
271func (s *Service) getTrackedContainer(domainName string) (*domain.Container, bool) {
272 s.mu.RLock()
273 container, ok := s.containers[domainName]
274 s.mu.RUnlock()
275 return container, ok
276}
277
278func (s *Service) prepareDeployResources(ctx context.Context, route domain.Route, existing *domain.Container) (*deployResources, error) {
279 log := zerowrap.FromCtx(ctx)
280
281 existingID := ""
282 if existing != nil {
283 existingID = existing.ID
284 }
285 if err := s.cleanupOrphanedContainers(ctx, route.Domain, existingID); err != nil {
286 log.WrapErr(err, "failed to cleanup orphaned containers")
287 }
288
289 imageRef := s.buildImageRef(route.Image)
290 actualImageRef, err := s.ensureImage(ctx, imageRef)
291 if err != nil {
292 return nil, err
293 }
294
295 networkName := s.getNetworkForApp(route.Domain)
296 if err := s.createNetworkIfNeeded(ctx, networkName); err != nil {
297 return nil, log.WrapErr(err, "failed to create network")
298 }
299 if err := s.deployAttachments(ctx, route.Domain, networkName); err != nil {
300 return nil, log.WrapErr(err, "failed to deploy attachments")
301 }
302
303 exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, actualImageRef)
304 if err != nil {
305 log.WrapErr(err, "failed to get exposed ports, using defaults")
306 exposedPorts = []int{80, 8080, 3000}
307 }
308
309 envVars, err := s.loadEnvironment(ctx, route.Domain, actualImageRef)
310 if err != nil {
311 return nil, err
312 }
313
314 volumes, err := s.setupVolumes(ctx, route.Domain, actualImageRef)
315 if err != nil {
316 return nil, err
317 }
318
319 return &deployResources{
320 networkName: networkName,
321 actualImageRef: actualImageRef,
322 exposedPorts: exposedPorts,
323 envVars: envVars,
324 volumes: volumes,
325 }, nil
326}
327
328func (s *Service) createStartedContainer(ctx context.Context, route domain.Route, existing *domain.Container, resources *deployResources) (*domain.Container, error) {
329 log := zerowrap.FromCtx(ctx)
330
331 containerConfig := s.buildContainerConfig(
332 route.Domain,
333 route.Image,
334 resources.actualImageRef,
335 resources.exposedPorts,
336 resources.envVars,
337 resources.volumes,
338 resources.networkName,
339 existing,
340 )
341
342 newContainer, err := s.runtime.CreateContainer(ctx, containerConfig)
343 if err != nil {
344 return nil, log.WrapErr(err, "failed to create container")
345 }
346 if err := s.runtime.StartContainer(ctx, newContainer.ID); err != nil {
347 s.runtime.RemoveContainer(ctx, newContainer.ID, true)
348 return nil, log.WrapErr(err, "failed to start container")
349 }
350 if err := s.waitForReady(ctx, newContainer.ID); err != nil {
351 s.cleanupFailedContainer(ctx, newContainer.ID)
352 return nil, log.WrapErr(err, "container failed readiness check")
353 }
354
355 inspected, err := s.runtime.InspectContainer(ctx, newContainer.ID)
356 if err != nil {
357 s.cleanupFailedContainer(ctx, newContainer.ID)
358 return nil, log.WrapErr(err, "failed to inspect started container")
359 }
360
361 return inspected, nil
362}
363
364func (s *Service) activateDeployedContainer(ctx context.Context, domainName string, container *domain.Container) bool {
365 s.mu.Lock()
366 s.containers[domainName] = container
367 s.mu.Unlock()
368
369 s.publishContainerDeployed(ctx, domainName, container.ID)
370
371 s.mu.RLock()
372 inv := s.cacheInvalidator
373 s.mu.RUnlock()
374 if inv != nil {
375 inv.InvalidateTarget(ctx, domainName)
376 return true
377 }
378
379 return false
380}
381
382func (s *Service) finalizePreviousContainer(ctx context.Context, domainName string, existing *domain.Container, hasExisting, invalidated bool, newContainerID string) {
383 if !hasExisting {
384 return
385 }
386 log := zerowrap.FromCtx(ctx)
387
388 if invalidated {
389 s.waitForDrain(ctx, existing.ID)
390 }
391
392 if err := ctx.Err(); err != nil {
393 log.Debug().Err(err).Str("domain", domainName).Str("new_container_id", newContainerID).Msg("skipping old container cleanup due to canceled context")
394 return
395 }
396
397 s.cleanupOldContainer(ctx, existing, newContainerID, domainName)
398}
399
400func (s *Service) waitForDrain(ctx context.Context, oldContainerID string) {
401 log := zerowrap.FromCtx(ctx)
402
403 s.mu.RLock()
404 cfg := s.config
405 waiter := s.drainWaiter
406 s.mu.RUnlock()
407
408 drainMode := cfg.DrainMode
409 if drainMode == "" {
410 drainMode = "delay"
411 }
412
413 shouldUseInFlight := (drainMode == "inflight" || drainMode == "auto") && waiter != nil
414 if shouldUseInFlight {
415 timeout := cfg.DrainTimeout
416 if timeout == 0 {
417 timeout = 30 * time.Second
418 }
419 drained := waiter.WaitForNoInFlight(ctx, oldContainerID, timeout)
420 if !drained {
421 log.Warn().
422 Str("old_container_id", oldContainerID).
423 Dur("drain_timeout", timeout).
424 Msg("drain wait timed out; old container may still have in-flight traffic")
425 }
426 return
427 }
428
429 drainDelay := 2 * time.Second
430 if cfg.DrainDelayConfigured {
431 drainDelay = cfg.DrainDelay
432 }
433 if drainDelay <= 0 {
434 return
435 }
436 select {
437 case <-time.After(drainDelay):
438 case <-ctx.Done():
439 }
440}
441
442// Restart restarts a running container for the given domain.
443func (s *Service) Restart(ctx context.Context, domainName string, withAttachments bool) error {
444 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
445 zerowrap.FieldLayer: "usecase",
446 zerowrap.FieldUseCase: "Restart",
447 "domain": domainName,
448 })
449 log := zerowrap.FromCtx(ctx)
450
451 s.mu.RLock()
452 container, exists := s.containers[domainName]
453 attachments := s.attachments[domainName]
454 var attachmentIDs []string
455 if len(attachments) > 0 {
456 attachmentIDs = make([]string, len(attachments))
457 copy(attachmentIDs, attachments)
458 }
459 s.mu.RUnlock()
460
461 if !exists || container == nil {
462 return domain.ErrContainerNotFound
463 }
464
465 // Restart the main container
466 if err := s.runtime.RestartContainer(ctx, container.ID); err != nil {
467 if !isContainerNotFoundError(err) {
468 return log.WrapErr(err, "failed to restart container")
469 }
470
471 // In-memory container ID can become stale after external runtime changes.
472 // Re-sync and retry once with the latest tracked container.
473 log.Warn().
474 Err(err).
475 Str(zerowrap.FieldEntityID, container.ID).
476 Msg("tracked container missing during restart, attempting state reconciliation")
477
478 if syncErr := s.SyncContainers(ctx); syncErr != nil {
479 log.Warn().Err(syncErr).Msg("failed to sync container state during restart recovery")
480 }
481
482 s.mu.RLock()
483 refreshed, refreshedExists := s.containers[domainName]
484 attachmentIDs = append([]string{}, s.attachments[domainName]...)
485 s.mu.RUnlock()
486
487 if !refreshedExists || refreshed == nil {
488 return domain.ErrContainerNotFound
489 }
490 if err := s.runtime.RestartContainer(ctx, refreshed.ID); err != nil {
491 return log.WrapErr(err, "failed to restart container after state reconciliation")
492 }
493 container = refreshed
494 }
495 log.Info().Str(zerowrap.FieldEntityID, container.ID).Msg("container restarted")
496
497 // Restart attachments if requested
498 if withAttachments && len(attachmentIDs) > 0 {
499 for _, attachID := range attachmentIDs {
500 if err := s.runtime.RestartContainer(ctx, attachID); err != nil {
501 log.Warn().Err(err).Str("attachment_id", attachID).Msg("failed to restart attachment")
502 continue // Don't fail the whole operation for one attachment
503 }
504 log.Info().Str("attachment_id", attachID).Msg("attachment restarted")
505 }
506 }
507
508 return nil
509}
510
511// Stop stops a running container.
512func (s *Service) Stop(ctx context.Context, containerID string) error {
513 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
514 zerowrap.FieldLayer: "usecase",
515 zerowrap.FieldUseCase: "Stop",
516 zerowrap.FieldEntityID: containerID,
517 })
518 log := zerowrap.FromCtx(ctx)
519
520 // Stop log collection before stopping container
521 if s.logWriter != nil {
522 if err := s.logWriter.StopLogging(containerID); err != nil {
523 log.Warn().Err(err).Msg("failed to stop container log collection")
524 }
525 }
526
527 if err := s.runtime.StopContainer(ctx, containerID); err != nil {
528 return log.WrapErr(err, "failed to stop container")
529 }
530
531 log.Info().Msg("container stopped")
532 return nil
533}
534
535// Remove removes a container.
536func (s *Service) Remove(ctx context.Context, containerID string, force bool) error {
537 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
538 zerowrap.FieldLayer: "usecase",
539 zerowrap.FieldUseCase: "Remove",
540 zerowrap.FieldEntityID: containerID,
541 })
542 log := zerowrap.FromCtx(ctx)
543
544 // Stop log collection before removing container
545 if s.logWriter != nil {
546 if err := s.logWriter.StopLogging(containerID); err != nil {
547 log.Warn().Err(err).Msg("failed to stop container log collection")
548 }
549 }
550
551 // Find domain and attachment IDs for cleanup
552 containerDomain, attachmentIDs, cfg := s.findContainerContext(containerID)
553
554 if err := s.runtime.RemoveContainer(ctx, containerID, force); err != nil {
555 return log.WrapErr(err, "failed to remove container")
556 }
557
558 // Cleanup volumes
559 if containerDomain != "" && !cfg.VolumePreserve {
560 if err := s.cleanupVolumesForDomain(ctx, containerDomain); err != nil {
561 log.WrapErrWithFields(err, "failed to cleanup volumes", map[string]any{"domain": containerDomain})
562 }
563 }
564
565 // Clean up attachment containers
566 s.removeAttachments(ctx, attachmentIDs)
567
568 // Remove from tracking
569 s.mu.Lock()
570 var removedDomain string
571 for d, c := range s.containers {
572 if c.ID == containerID {
573 delete(s.containers, d)
574 removedDomain = d
575 log.Info().Str("domain", d).Msg("container removed")
576 break
577 }
578 }
579 if removedDomain != "" {
580 delete(s.attachments, removedDomain)
581 }
582 s.mu.Unlock()
583
584 // Note: We intentionally do NOT clean up deployMu entries to avoid a race condition:
585 // If Remove deletes the mutex entry while a concurrent Deploy is acquiring the lock,
586 // the Deploy would create a fresh mutex, breaking serialization and allowing concurrent
587 // deploys to the same domain. The memory footprint is acceptable (one small struct per
588 // domain ever deployed), and this is the safer choice for correctness.
589
590 // Cleanup network
591 if removedDomain != "" && cfg.NetworkIsolation {
592 networkName := s.getNetworkForApp(removedDomain)
593 if err := s.cleanupNetworkIfEmpty(ctx, networkName); err != nil {
594 log.WrapErrWithFields(err, "failed to cleanup network", map[string]any{"network": networkName})
595 }
596 }
597
598 return nil
599}
600
601// findContainerContext looks up the domain, attachment IDs, and config snapshot for a container.
602func (s *Service) findContainerContext(containerID string) (string, []string, Config) {
603 s.mu.RLock()
604 defer s.mu.RUnlock()
605 cfg := s.config
606 var containerDomain string
607 for d, c := range s.containers {
608 if c.ID == containerID {
609 containerDomain = d
610 break
611 }
612 }
613 var attachmentIDs []string
614 if containerDomain != "" {
615 attachmentIDs = append(attachmentIDs, s.attachments[containerDomain]...)
616 }
617 return containerDomain, attachmentIDs, cfg
618}
619
620// removeAttachments stops and removes attachment containers best-effort.
621func (s *Service) removeAttachments(ctx context.Context, attachmentIDs []string) {
622 log := zerowrap.FromCtx(ctx)
623 for _, attachID := range attachmentIDs {
624 if s.logWriter != nil {
625 if err := s.logWriter.StopLogging(attachID); err != nil {
626 log.Warn().Err(err).Str("attachment_id", attachID).Msg("failed to stop attachment log collection")
627 }
628 }
629 if err := s.runtime.StopContainer(ctx, attachID); err != nil {
630 log.Warn().Err(err).Str("attachment_id", attachID).Msg("failed to stop attachment container")
631 }
632 if err := s.runtime.RemoveContainer(ctx, attachID, true); err != nil {
633 log.Warn().Err(err).Str("attachment_id", attachID).Msg("failed to remove attachment container")
634 }
635 }
636}
637
638// Get retrieves a container by domain.
639func (s *Service) Get(_ context.Context, domainName string) (*domain.Container, bool) {
640 s.mu.RLock()
641 defer s.mu.RUnlock()
642 container, exists := s.containers[domainName]
643 return container, exists
644}
645
646// List returns all managed containers.
647func (s *Service) List(_ context.Context) map[string]*domain.Container {
648 s.mu.RLock()
649 defer s.mu.RUnlock()
650
651 result := make(map[string]*domain.Container, len(s.containers))
652 maps.Copy(result, s.containers)
653 return result
654}
655
656// ListRoutesWithDetails returns routes with network and attachment info.
657// Note: Container map is copied under lock, then external calls are made without lock.
658// If containers are removed between copy and runtime calls, errors are handled gracefully
659// (network becomes empty string). This trade-off avoids holding locks during I/O.
660func (s *Service) ListRoutesWithDetails(ctx context.Context) []domain.RouteInfo {
661 s.mu.RLock()
662 containers := make(map[string]*domain.Container, len(s.containers))
663 maps.Copy(containers, s.containers)
664 s.mu.RUnlock()
665
666 // Fetch all attachments once to avoid N+1 queries
667 attachmentsByDomain := s.getAllAttachments(ctx)
668
669 results := make([]domain.RouteInfo, 0, len(containers))
670 for domainName, container := range containers {
671 network := ""
672 image := ""
673 containerID := ""
674 status := ""
675 if container != nil {
676 containerID = container.ID
677 status = container.Status
678 image = container.Image
679 if container.Labels != nil {
680 if labelImage, ok := container.Labels[domain.LabelImage]; ok && labelImage != "" {
681 image = labelImage
682 }
683 }
684 // Strip registry domain prefix from image for cleaner display
685 image = s.stripRegistryPrefix(image)
686 if networkName, err := s.runtime.GetContainerNetwork(ctx, container.ID); err == nil {
687 network = networkName
688 }
689 }
690
691 results = append(results, domain.RouteInfo{
692 Domain: domainName,
693 Image: image,
694 ContainerID: containerID,
695 ContainerStatus: status,
696 Network: network,
697 Attachments: attachmentsByDomain[domainName],
698 })
699 }
700
701 return results
702}
703
704// stripRegistryPrefix removes the configured registry domain prefix from an image reference.
705// For example, "reg.example.com/myapp:latest" becomes "myapp:latest" if registry domain is "reg.example.com".
706func (s *Service) stripRegistryPrefix(image string) string {
707 s.mu.RLock()
708 cfg := s.config
709 s.mu.RUnlock()
710
711 if cfg.RegistryDomain == "" {
712 return image
713 }
714 prefix := strings.TrimSuffix(cfg.RegistryDomain, "/") + "/"
715 if strings.HasPrefix(image, prefix) {
716 return strings.TrimPrefix(image, prefix)
717 }
718 return image
719}
720
721// ListAttachments returns attachments for a domain.
722func (s *Service) ListAttachments(ctx context.Context, domainName string) []domain.Attachment {
723 return s.getAttachmentsForDomain(ctx, domainName)
724}
725
726// ListNetworks returns Gordon-managed networks.
727func (s *Service) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) {
728 s.mu.RLock()
729 cfg := s.config
730 s.mu.RUnlock()
731
732 networks, err := s.runtime.ListNetworks(ctx)
733 if err != nil {
734 return nil, err
735 }
736
737 var filtered []*domain.NetworkInfo
738 for _, network := range networks {
739 if strings.HasPrefix(network.Name, cfg.NetworkPrefix+"-") {
740 filtered = append(filtered, network)
741 }
742 }
743
744 return filtered, nil
745}
746
747// HealthCheck performs health checks on all containers.
748func (s *Service) HealthCheck(ctx context.Context) map[string]bool {
749 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
750 zerowrap.FieldLayer: "usecase",
751 zerowrap.FieldUseCase: "HealthCheck",
752 })
753 log := zerowrap.FromCtx(ctx)
754
755 // Copy container IDs under lock, then release before making Docker API calls
756 s.mu.RLock()
757 snapshot := make(map[string]string, len(s.containers))
758 for d, c := range s.containers {
759 snapshot[d] = c.ID
760 }
761 s.mu.RUnlock()
762
763 health := make(map[string]bool, len(snapshot))
764 for d, id := range snapshot {
765 running, err := s.runtime.IsContainerRunning(ctx, id)
766 if err != nil {
767 log.WrapErrWithFields(err, "health check failed", map[string]any{"domain": d, zerowrap.FieldEntityID: id})
768 health[d] = false
769 } else {
770 health[d] = running
771 }
772 }
773 return health
774}
775
776// SyncContainers synchronizes containers with runtime state.
777func (s *Service) SyncContainers(ctx context.Context) error {
778 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
779 zerowrap.FieldLayer: "usecase",
780 zerowrap.FieldUseCase: "SyncContainers",
781 })
782 log := zerowrap.FromCtx(ctx)
783
784 // List containers without holding lock to avoid blocking during Docker API call
785 allContainers, err := s.runtime.ListContainers(ctx, false)
786 if err != nil {
787 return log.WrapErr(err, "failed to list containers")
788 }
789
790 managed := make(map[string]*domain.Container)
791 for _, c := range allContainers {
792 if c.Labels != nil {
793 if d, ok := c.Labels["gordon.domain"]; ok && c.Labels["gordon.managed"] == "true" {
794 managed[d] = c
795 }
796 }
797 }
798
799 s.mu.Lock()
800 s.containers = managed
801 s.mu.Unlock()
802
803 log.Info().Int(zerowrap.FieldCount, len(managed)).Msg("container state synchronized")
804 return nil
805}
806
807// AutoStart starts containers for the provided routes that aren't running.
808func (s *Service) AutoStart(ctx context.Context, routes []domain.Route) error {
809 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
810 zerowrap.FieldLayer: "usecase",
811 zerowrap.FieldUseCase: "AutoStart",
812 })
813 log := zerowrap.FromCtx(ctx)
814
815 log.Info().Int("route_count", len(routes)).Msg("auto-starting containers for configured routes")
816
817 var started, skipped, errors int
818 for _, route := range routes {
819 // Check if container already exists and is running
820 if _, exists := s.Get(ctx, route.Domain); exists {
821 log.Debug().Str("domain", route.Domain).Msg("container already running, skipping")
822 skipped++
823 continue
824 }
825
826 log.Info().
827 Str("domain", route.Domain).
828 Str("image", route.Image).
829 Msg("auto-starting container for route")
830
831 if _, err := s.Deploy(ctx, route); err != nil {
832 log.Warn().Err(err).Str("domain", route.Domain).Msg("failed to auto-start container")
833 errors++
834 continue
835 }
836
837 started++
838 }
839
840 log.Info().
841 Int("started", started).
842 Int("skipped", skipped).
843 Int("errors", errors).
844 Msg("auto-start completed")
845
846 if errors > 0 {
847 return fmt.Errorf("auto-start completed with %d errors", errors)
848 }
849 return nil
850}
851
852// Shutdown gracefully shuts down all managed containers.
853func (s *Service) Shutdown(ctx context.Context) error {
854 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
855 zerowrap.FieldLayer: "usecase",
856 zerowrap.FieldUseCase: "Shutdown",
857 })
858 log := zerowrap.FromCtx(ctx)
859 log.Info().Msg("shutting down container manager...")
860
861 if err := s.stopAllManagedContainers(ctx); err != nil {
862 log.WrapErr(err, "failed to stop all containers during shutdown")
863 }
864
865 // Close log writer to stop all log collection
866 if s.logWriter != nil {
867 if err := s.logWriter.Close(); err != nil {
868 log.Warn().Err(err).Msg("failed to close container log writer")
869 }
870 }
871
872 log.Info().Msg("container manager shutdown complete")
873 return nil
874}
875
876// UpdateConfig updates the service configuration.
877func (s *Service) UpdateConfig(config Config) {
878 s.mu.Lock()
879 s.config = config
880 s.mu.Unlock()
881}
882
883// Helper methods
884
885// cleanupFailedContainer stops and removes a container that failed to start properly.
886func (s *Service) cleanupFailedContainer(ctx context.Context, containerID string) {
887 log := zerowrap.FromCtx(ctx)
888 if err := s.runtime.StopContainer(ctx, containerID); err != nil {
889 log.Warn().Err(err).Str(zerowrap.FieldEntityID, containerID).Msg("failed to stop container after failure")
890 }
891 if err := s.runtime.RemoveContainer(ctx, containerID, true); err != nil {
892 log.Warn().Err(err).Str(zerowrap.FieldEntityID, containerID).Msg("failed to remove container after failure")
893 }
894}
895
896// cleanupOldContainer stops and removes an old container after zero-downtime switch.
897// It also renames the new container to the canonical name.
898func (s *Service) cleanupOldContainer(ctx context.Context, old *domain.Container, newContainerID, domainName string) {
899 log := zerowrap.FromCtx(ctx)
900 log.Info().Str(zerowrap.FieldEntityID, old.ID).Msg("stopping old container after zero-downtime switch")
901
902 if s.logWriter != nil {
903 if err := s.logWriter.StopLogging(old.ID); err != nil {
904 log.Warn().Err(err).Str(zerowrap.FieldEntityID, old.ID).Msg("failed to stop logging for old container")
905 }
906 }
907
908 if err := s.runtime.StopContainer(ctx, old.ID); err != nil {
909 log.Warn().Err(err).Str(zerowrap.FieldEntityID, old.ID).Msg("failed to stop old container")
910 }
911
912 if err := s.runtime.RemoveContainer(ctx, old.ID, true); err != nil {
913 log.Warn().Err(err).Str(zerowrap.FieldEntityID, old.ID).Msg("failed to remove old container")
914 }
915
916 // Rename new container to canonical name
917 canonicalName := fmt.Sprintf("gordon-%s", domainName)
918 if err := s.runtime.RenameContainer(ctx, newContainerID, canonicalName); err != nil {
919 log.Warn().Err(err).Str("canonical_name", canonicalName).Msg("failed to rename container to canonical name")
920 }
921}
922
923// startLogCollection starts log collection for a container in the background.
924// Errors are logged but don't fail the calling operation.
925func (s *Service) startLogCollection(ctx context.Context, containerID, domainName string) {
926 if s.logWriter == nil {
927 return
928 }
929
930 log := zerowrap.FromCtx(ctx)
931
932 logStream, err := s.runtime.GetContainerLogs(ctx, containerID, true)
933 if err != nil {
934 log.Warn().Err(err).Msg("failed to get container logs for collection")
935 return
936 }
937
938 if err := s.logWriter.StartLogging(ctx, containerID, domainName, logStream); err != nil {
939 log.Warn().Err(err).Msg("failed to start container log collection")
940 logStream.Close()
941 }
942}
943
944func (s *Service) buildImageRef(image string) string {
945 s.mu.RLock()
946 cfg := s.config
947 s.mu.RUnlock()
948
949 if !cfg.RegistryAuthEnabled || cfg.RegistryDomain == "" {
950 return image
951 }
952
953 // Normalize registry domain by trimming any trailing slash
954 reg := strings.TrimSuffix(cfg.RegistryDomain, "/")
955
956 // Check if image already has the registry domain prefix
957 prefix := reg + "/"
958 if strings.HasPrefix(image, prefix) {
959 return image
960 }
961
962 // Check if image already has an explicit registry (host:port/ or host.domain/).
963 // We need to detect patterns like:
964 // - "docker.io/library/nginx:latest" (has dot and slash)
965 // - "localhost:5001/myapp:latest" (has host:port/ pattern)
966 // - "myregistry:5000/app:v1" (has host:port/ pattern)
967 // - "[fd00::1]:5000/app:v1" (IPv6 with port)
968 if hasExplicitRegistry(image) {
969 return image
970 }
971
972 return fmt.Sprintf("%s/%s", reg, image)
973}
974
975// hasExplicitRegistry checks if an image reference already includes an explicit registry.
976// Returns true for patterns like "docker.io/image", "localhost:5000/image", "localhost/image",
977// "registry:8080/image", "[fd00::1]/image", "[fd00::1]:5000/image".
978func hasExplicitRegistry(image string) bool {
979 // Find the first slash which separates registry from image name
980 slashIdx := strings.Index(image, "/")
981 if slashIdx == -1 {
982 return false // No slash means no registry prefix (e.g., "myapp:latest")
983 }
984
985 // Extract the part before the first slash (potential registry)
986 registryPart := image[:slashIdx]
987
988 // Check for bracketed IPv6 address (e.g., "[fd00::1]" or "[fd00::1]:5000")
989 if strings.HasPrefix(registryPart, "[") {
990 // Look for closing bracket
991 if closeBracket := strings.Index(registryPart, "]"); closeBracket != -1 {
992 // Valid bracketed IPv6: either ends at ] or has ]:port
993 return true
994 }
995 }
996
997 // Check for localhost (with or without port)
998 if registryPart == "localhost" {
999 return true
1000 }
1001
1002 // Check for host:port pattern (e.g., "localhost:5000", "registry:8080")
1003 if colonIdx := strings.LastIndex(registryPart, ":"); colonIdx != -1 {
1004 port := registryPart[colonIdx+1:]
1005 // If everything after colon is digits, it's a port
1006 if len(port) > 0 && isNumeric(port) {
1007 return true
1008 }
1009 }
1010
1011 // Check for domain pattern (has a dot, e.g., "docker.io", "gcr.io")
1012 if strings.Contains(registryPart, ".") {
1013 return true
1014 }
1015
1016 return false
1017}
1018
1019// isNumeric checks if a string contains only digits.
1020func isNumeric(s string) bool {
1021 for _, c := range s {
1022 if c < '0' || c > '9' {
1023 return false
1024 }
1025 }
1026 return true
1027}
1028
1029func normalizePullPolicy(policy string) string {
1030 switch strings.ToLower(strings.TrimSpace(policy)) {
1031 case PullPolicyAlways:
1032 return PullPolicyAlways
1033 case PullPolicyIfTagChanged:
1034 return PullPolicyIfTagChanged
1035 case PullPolicyIfNotPresent:
1036 return PullPolicyIfNotPresent
1037 default:
1038 return PullPolicyIfNotPresent
1039 }
1040}
1041
1042func isDigestRef(imageRef string) bool {
1043 return strings.Contains(imageRef, "@sha256:")
1044}
1045
1046func (s *Service) pullRefForDeploy(ctx context.Context, imageRef string) (string, bool) {
1047 if !domain.IsInternalDeploy(ctx) {
1048 return imageRef, false
1049 }
1050 s.mu.RLock()
1051 cfg := s.config
1052 s.mu.RUnlock()
1053 return rewriteToLocalRegistry(imageRef, cfg.RegistryDomain, cfg.RegistryPort), true
1054}
1055
1056// ensureImage ensures the image is available locally, pulling if needed.
1057// Returns the image reference to use for container operations:
1058// - For digest references (@sha256:...), returns the pullRef since Docker can't tag digests
1059// - For tagged images, returns the original imageRef after tagging the pulled image
1060func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, error) {
1061 ctx = zerowrap.CtxWithField(ctx, "image", imageRef)
1062 log := zerowrap.FromCtx(ctx)
1063
1064 // Determine if this is an internal deploy and what reference to use for pulls.
1065 pullRef, isInternal := s.pullRefForDeploy(ctx, imageRef)
1066 if pullRef != imageRef {
1067 log.Info().
1068 Str("original_ref", imageRef).
1069 Str("pull_ref", pullRef).
1070 Msg("internal deploy: using localhost registry for pull")
1071 }
1072
1073 found, err := s.ensureLocalImage(ctx, imageRef, pullRef)
1074 if err != nil {
1075 return "", err
1076 }
1077 if found {
1078 return imageRef, nil
1079 }
1080
1081 // Pull image
1082 log.Info().Msg("pulling image from registry")
1083
1084 if err := s.pullImage(ctx, pullRef, isInternal); err != nil {
1085 return "", err
1086 }
1087
1088 // For digest references, we can't create a tag (Docker doesn't allow it).
1089 // In this case, use the pullRef directly since the image is available under that reference.
1090 if strings.Contains(imageRef, "@sha256:") {
1091 log.Info().
1092 Str("pull_ref", pullRef).
1093 Msg("digest reference: using pull reference for container operations")
1094 return pullRef, nil
1095 }
1096
1097 if err := s.tagImageIfNeeded(ctx, pullRef, imageRef); err != nil {
1098 return "", err
1099 }
1100
1101 // Clean up the temporary pull reference tag to avoid duplicate entries
1102 if pullRef != imageRef {
1103 if err := s.runtime.UntagImage(ctx, pullRef); err != nil {
1104 // Log but don't fail - the canonical tag is already applied
1105 log.Debug().Err(err).Str("pull_ref", pullRef).Msg("failed to remove temporary pull tag")
1106 }
1107 }
1108
1109 log.Info().Msg("image pulled successfully")
1110 return imageRef, nil
1111}
1112
1113func (s *Service) ensureLocalImage(ctx context.Context, imageRef, pullRef string) (bool, error) {
1114 log := zerowrap.FromCtx(ctx)
1115
1116 // For internal deploys (image push events), always pull fresh image
1117 // because the same tag may reference new content (e.g., latest tag updated)
1118 if domain.IsInternalDeploy(ctx) {
1119 log.Info().Msg("internal deploy detected, forcing image pull to ensure latest content")
1120 return false, nil
1121 }
1122
1123 s.mu.RLock()
1124 cfg := s.config
1125 s.mu.RUnlock()
1126
1127 pullPolicy := normalizePullPolicy(cfg.PullPolicy)
1128 switch pullPolicy {
1129 case PullPolicyAlways:
1130 log.Info().Str("pull_policy", pullPolicy).Msg("pull policy forces image pull")
1131 return false, nil
1132 case PullPolicyIfTagChanged:
1133 if !isDigestRef(imageRef) {
1134 log.Info().Str("pull_policy", pullPolicy).Msg("tag reference detected, pulling to check for updates")
1135 return false, nil
1136 }
1137 }
1138
1139 localImages, err := s.runtime.ListImages(ctx)
1140 if err != nil {
1141 log.WrapErr(err, "failed to list local images, will attempt pull")
1142 return false, nil
1143 }
1144
1145 normalizedRef := normalizeImageRef(imageRef)
1146 normalizedPullRef := normalizeImageRef(pullRef)
1147 for _, img := range localImages {
1148 normalizedImage := normalizeImageRef(img)
1149 if normalizedImage == normalizedRef {
1150 log.Info().Msg("image found locally, skipping pull")
1151 return true, nil
1152 }
1153 if normalizedImage == normalizedPullRef {
1154 if err := s.tagImageIfNeeded(ctx, pullRef, imageRef); err != nil {
1155 return false, err
1156 }
1157 // Clean up the temporary pull reference tag
1158 if pullRef != imageRef {
1159 if err := s.runtime.UntagImage(ctx, pullRef); err != nil {
1160 log.Debug().Err(err).Str("pull_ref", pullRef).Msg("failed to remove temporary pull tag")
1161 }
1162 }
1163 log.Info().Msg("image found locally, skipping pull")
1164 return true, nil
1165 }
1166 }
1167
1168 return false, nil
1169}
1170
1171func (s *Service) pullImage(ctx context.Context, pullRef string, isInternal bool) error {
1172 s.mu.RLock()
1173 cfg := s.config
1174 s.mu.RUnlock()
1175
1176 log := zerowrap.FromCtx(ctx)
1177
1178 switch {
1179 case isInternal && cfg.RegistryAuthEnabled:
1180 if cfg.InternalRegistryUsername == "" || cfg.InternalRegistryPassword == "" {
1181 return log.WrapErr(fmt.Errorf("internal registry auth not configured"), "failed to pull image for internal deploy")
1182 }
1183 if err := s.pullImageWithRetry(ctx, func(ctx context.Context) error {
1184 return s.runtime.PullImageWithAuth(ctx, pullRef, cfg.InternalRegistryUsername, cfg.InternalRegistryPassword)
1185 }, isInternal); err != nil {
1186 return log.WrapErr(err, "failed to pull image with internal auth")
1187 }
1188 case isInternal:
1189 if err := s.pullImageWithRetry(ctx, func(ctx context.Context) error {
1190 return s.runtime.PullImage(ctx, pullRef)
1191 }, isInternal); err != nil {
1192 return log.WrapErr(err, "failed to pull image")
1193 }
1194 case cfg.RegistryAuthEnabled:
1195 if cfg.ServiceTokenUsername == "" || cfg.ServiceToken == "" {
1196 return log.WrapErr(fmt.Errorf("registry service token not configured"), "failed to pull image for registry auth")
1197 }
1198 if err := s.runtime.PullImageWithAuth(ctx, pullRef, cfg.ServiceTokenUsername, cfg.ServiceToken); err != nil {
1199 return log.WrapErr(err, "failed to pull image with auth")
1200 }
1201 default:
1202 if err := s.runtime.PullImage(ctx, pullRef); err != nil {
1203 return log.WrapErr(err, "failed to pull image")
1204 }
1205 }
1206
1207 return nil
1208}
1209
1210func (s *Service) pullImageWithRetry(ctx context.Context, pullFn func(context.Context) error, isInternal bool) error {
1211 log := zerowrap.FromCtx(ctx)
1212
1213 for attempt := 1; attempt <= internalPullMaxAttempts; attempt++ {
1214 err := pullFn(ctx)
1215 if err == nil {
1216 return nil
1217 }
1218 if !isInternal || !isConnectionRefusedError(err) || attempt == internalPullMaxAttempts {
1219 return fmt.Errorf("internal image pull failed after %d attempts: %w", attempt, err)
1220 }
1221
1222 backoff := time.Duration(attempt) * time.Second
1223 log.Warn().
1224 Err(err).
1225 Int("attempt", attempt).
1226 Dur("backoff", backoff).
1227 Msg("internal image pull failed with connection refused, retrying")
1228
1229 select {
1230 case <-time.After(backoff):
1231 case <-ctx.Done():
1232 return ctx.Err()
1233 }
1234 }
1235 return nil
1236}
1237
1238func (s *Service) tagImageIfNeeded(ctx context.Context, sourceRef, targetRef string) error {
1239 if sourceRef == targetRef {
1240 return nil
1241 }
1242
1243 // Cannot create a tag with a digest reference - Docker/Podman doesn't allow it.
1244 // When using digest references (image@sha256:...), skip tagging as the image
1245 // is already available by its digest.
1246 if strings.Contains(targetRef, "@sha256:") {
1247 return nil
1248 }
1249
1250 log := zerowrap.FromCtx(ctx)
1251 if err := s.runtime.TagImage(ctx, sourceRef, targetRef); err != nil {
1252 return log.WrapErr(err, "failed to tag image from pull reference")
1253 }
1254 return nil
1255}
1256
1257func (s *Service) loadEnvironment(ctx context.Context, domainName, imageRef string) ([]string, error) {
1258 log := zerowrap.FromCtx(ctx)
1259
1260 userEnvVars, err := s.envLoader.LoadEnv(ctx, domainName)
1261 if err != nil {
1262 return nil, log.WrapErr(err, "failed to load environment variables")
1263 }
1264
1265 dockerfileEnvVars, err := s.runtime.InspectImageEnv(ctx, imageRef)
1266 if err != nil {
1267 log.WrapErr(err, "failed to inspect image environment")
1268 dockerfileEnvVars = []string{}
1269 }
1270
1271 return mergeEnvironmentVariables(dockerfileEnvVars, userEnvVars), nil
1272}
1273
1274func (s *Service) setupVolumes(ctx context.Context, domainName, imageRef string) (map[string]string, error) {
1275 s.mu.RLock()
1276 cfg := s.config
1277 s.mu.RUnlock()
1278
1279 log := zerowrap.FromCtx(ctx)
1280 volumes := make(map[string]string)
1281
1282 if !cfg.VolumeAutoCreate {
1283 return volumes, nil
1284 }
1285
1286 volumePaths, err := s.runtime.InspectImageVolumes(ctx, imageRef)
1287 if err != nil {
1288 log.WrapErr(err, "failed to inspect image volumes")
1289 return volumes, nil
1290 }
1291
1292 for _, path := range volumePaths {
1293 name := generateVolumeName(cfg.VolumePrefix, domainName, path)
1294
1295 exists, err := s.runtime.VolumeExists(ctx, name)
1296 if err != nil {
1297 log.WrapErrWithFields(err, "failed to check volume", map[string]any{"volume": name})
1298 continue
1299 }
1300
1301 if !exists {
1302 if err := s.runtime.CreateVolume(ctx, name); err != nil {
1303 log.WrapErrWithFields(err, "failed to create volume", map[string]any{"volume": name})
1304 continue
1305 }
1306 log.Info().Str("volume", name).Str(zerowrap.FieldPath, path).Msg("created volume")
1307 }
1308
1309 volumes[path] = name
1310 }
1311
1312 return volumes, nil
1313}
1314
1315func (s *Service) getNetworkForApp(domainName string) string {
1316 s.mu.RLock()
1317 cfg := s.config
1318 s.mu.RUnlock()
1319
1320 if !cfg.NetworkIsolation {
1321 return "bridge"
1322 }
1323
1324 for groupName, domains := range cfg.NetworkGroups {
1325 if slices.Contains(domains, domainName) {
1326 return s.generateNetworkName(groupName)
1327 }
1328 }
1329
1330 return s.generateNetworkName(domainName)
1331}
1332
1333func (s *Service) generateNetworkName(identifier string) string {
1334 s.mu.RLock()
1335 cfg := s.config
1336 s.mu.RUnlock()
1337
1338 return fmt.Sprintf("%s-%s", cfg.NetworkPrefix, strings.ReplaceAll(identifier, ".", "-"))
1339}
1340
1341func (s *Service) createNetworkIfNeeded(ctx context.Context, networkName string) error {
1342 if networkName == "bridge" || networkName == "default" {
1343 return nil
1344 }
1345
1346 ctx = zerowrap.CtxWithField(ctx, "network", networkName)
1347 log := zerowrap.FromCtx(ctx)
1348
1349 exists, err := s.runtime.NetworkExists(ctx, networkName)
1350 if err != nil {
1351 return log.WrapErr(err, "failed to check network existence")
1352 }
1353
1354 if !exists {
1355 if err := s.runtime.CreateNetwork(ctx, networkName, map[string]string{"driver": "bridge"}); err != nil {
1356 return log.WrapErr(err, "failed to create network")
1357 }
1358 log.Info().Msg("created network for app isolation")
1359 }
1360
1361 return nil
1362}
1363
1364func (s *Service) cleanupOrphanedContainers(ctx context.Context, domainName string, skipContainerID string) error {
1365 log := zerowrap.FromCtx(ctx)
1366 expectedName := fmt.Sprintf("gordon-%s", domainName)
1367 expectedNewName := expectedName + "-new"
1368 expectedNextName := expectedName + "-next"
1369
1370 allContainers, err := s.runtime.ListContainers(ctx, true)
1371 if err != nil {
1372 return err
1373 }
1374
1375 for _, c := range allContainers {
1376 if (c.Name == expectedName || c.Name == expectedNewName || c.Name == expectedNextName) && c.ID != skipContainerID {
1377 log.Info().Str(zerowrap.FieldEntityID, c.ID).Str(zerowrap.FieldStatus, c.Status).Msg("found orphaned container, removing")
1378
1379 if err := s.runtime.StopContainer(ctx, c.ID); err != nil {
1380 log.WrapErrWithFields(err, "failed to stop orphaned container", map[string]any{zerowrap.FieldEntityID: c.ID})
1381 }
1382
1383 if err := s.runtime.RemoveContainer(ctx, c.ID, true); err != nil {
1384 log.WrapErrWithFields(err, "failed to remove orphaned container", map[string]any{zerowrap.FieldEntityID: c.ID})
1385 }
1386 }
1387 }
1388
1389 return nil
1390}
1391
1392func (s *Service) cleanupVolumesForDomain(_ context.Context, _ string) error {
1393 return nil
1394}
1395
1396func (s *Service) cleanupNetworkIfEmpty(ctx context.Context, networkName string) error {
1397 if networkName == "bridge" || networkName == "default" {
1398 return nil
1399 }
1400
1401 ctx = zerowrap.CtxWithField(ctx, "network", networkName)
1402 log := zerowrap.FromCtx(ctx)
1403
1404 networks, err := s.runtime.ListNetworks(ctx)
1405 if err != nil {
1406 return log.WrapErr(err, "failed to list networks")
1407 }
1408
1409 for _, n := range networks {
1410 if n.Name == networkName && len(n.Containers) == 0 {
1411 if err := s.runtime.RemoveNetwork(ctx, networkName); err != nil {
1412 return err
1413 }
1414 log.Info().Msg("cleaned up empty network")
1415 break
1416 }
1417 }
1418
1419 return nil
1420}
1421
1422func (s *Service) deployAttachments(ctx context.Context, domainName, networkName string) error {
1423 // Collect attachments from both domain-specific and network group configs
1424 attachments := s.resolveAttachmentsForDomain(domainName)
1425 if len(attachments) == 0 {
1426 return nil
1427 }
1428
1429 log := zerowrap.FromCtx(ctx)
1430 var deployed []string // track successfully deployed attachment IDs for rollback
1431 for _, svc := range attachments {
1432 if err := s.deployAttachedService(ctx, domainName, svc, networkName); err != nil {
1433 log.WrapErrWithFields(err, "failed to deploy attachment", map[string]any{zerowrap.FieldService: svc, "domain": domainName})
1434
1435 // Rollback: clean up already-deployed attachments
1436 for _, id := range deployed {
1437 if stopErr := s.runtime.StopContainer(ctx, id); stopErr != nil {
1438 log.Warn().Err(stopErr).Str("attachment_id", id).Msg("rollback: failed to stop attachment")
1439 }
1440 if rmErr := s.runtime.RemoveContainer(ctx, id, true); rmErr != nil {
1441 log.Warn().Err(rmErr).Str("attachment_id", id).Msg("rollback: failed to remove attachment")
1442 }
1443 }
1444 // Deregister rolled-back attachments
1445 if len(deployed) > 0 {
1446 s.mu.Lock()
1447 remaining := s.attachments[domainName]
1448 filtered := remaining[:0]
1449 rollbackSet := make(map[string]bool, len(deployed))
1450 for _, id := range deployed {
1451 rollbackSet[id] = true
1452 }
1453 for _, id := range remaining {
1454 if !rollbackSet[id] {
1455 filtered = append(filtered, id)
1456 }
1457 }
1458 s.attachments[domainName] = filtered
1459 s.mu.Unlock()
1460 }
1461
1462 return fmt.Errorf("failed to deploy attachment %q (rolled back %d already-deployed)", svc, len(deployed))
1463 }
1464 // Collect IDs of attachments tracked under this domain after successful deploy
1465 s.mu.RLock()
1466 ids := s.attachments[domainName]
1467 if len(ids) > 0 {
1468 latest := ids[len(ids)-1]
1469 deployed = append(deployed, latest)
1470 }
1471 s.mu.RUnlock()
1472 }
1473 return nil
1474}
1475
1476// resolveAttachmentsForDomain returns attachments for a domain by checking:
1477// 1. Direct domain attachments (attachments[domain])
1478// 2. Network group attachments (attachments[group] where domain is in network_groups[group])
1479func (s *Service) resolveAttachmentsForDomain(domainName string) []string {
1480 s.mu.RLock()
1481 cfg := s.config
1482 s.mu.RUnlock()
1483
1484 seen := make(map[string]bool)
1485 var result []string
1486
1487 // First, add domain-specific attachments
1488 if domainAttachments, ok := cfg.Attachments[domainName]; ok {
1489 for _, img := range domainAttachments {
1490 if !seen[img] {
1491 seen[img] = true
1492 result = append(result, img)
1493 }
1494 }
1495 }
1496
1497 // Then, find which network group this domain belongs to and add group attachments
1498 for groupName, domains := range cfg.NetworkGroups {
1499 if slices.Contains(domains, domainName) {
1500 if groupAttachments, ok := cfg.Attachments[groupName]; ok {
1501 for _, img := range groupAttachments {
1502 if !seen[img] {
1503 seen[img] = true
1504 result = append(result, img)
1505 }
1506 }
1507 }
1508 break // Domain can only be in one network group
1509 }
1510 }
1511
1512 return result
1513}
1514
1515func (s *Service) getAttachmentsForDomain(ctx context.Context, domainName string) []domain.Attachment {
1516 containers, err := s.runtime.ListContainers(ctx, true)
1517 if err != nil {
1518 return nil
1519 }
1520
1521 return s.filterAttachments(ctx, containers, domainName)
1522}
1523
1524// getAllAttachments fetches all containers once and returns attachments grouped by domain.
1525// This avoids N+1 queries when listing multiple routes.
1526func (s *Service) getAllAttachments(ctx context.Context) map[string][]domain.Attachment {
1527 containers, err := s.runtime.ListContainers(ctx, true)
1528 if err != nil {
1529 return nil
1530 }
1531
1532 result := make(map[string][]domain.Attachment)
1533 for _, container := range containers {
1534 if container.Labels == nil {
1535 continue
1536 }
1537 if container.Labels[domain.LabelAttachment] != "true" {
1538 continue
1539 }
1540 ownerDomain := container.Labels[domain.LabelAttachedTo]
1541 if ownerDomain == "" {
1542 continue
1543 }
1544 image := container.Image
1545 serviceName := container.Name
1546 if labelImage, ok := container.Labels[domain.LabelImage]; ok && labelImage != "" {
1547 image = labelImage
1548 serviceName = extractServiceName(labelImage)
1549 }
1550 // Get the container's network for display in the routes table
1551 network := ""
1552 if networkName, err := s.runtime.GetContainerNetwork(ctx, container.ID); err == nil {
1553 network = networkName
1554 }
1555 attachment := domain.Attachment{
1556 Name: serviceName,
1557 Image: s.stripRegistryPrefix(image),
1558 ContainerID: container.ID,
1559 Status: container.Status,
1560 Network: network,
1561 Ports: append([]int(nil), container.Ports...),
1562 }
1563 result[ownerDomain] = append(result[ownerDomain], attachment)
1564 }
1565
1566 return result
1567}
1568
1569// filterAttachments extracts attachments for a specific domain from a container list.
1570func (s *Service) filterAttachments(ctx context.Context, containers []*domain.Container, domainName string) []domain.Attachment {
1571 attachments := make([]domain.Attachment, 0)
1572 for _, container := range containers {
1573 if container.Labels == nil {
1574 continue
1575 }
1576 if container.Labels[domain.LabelAttachment] != "true" {
1577 continue
1578 }
1579 if container.Labels[domain.LabelAttachedTo] != domainName {
1580 continue
1581 }
1582 image := container.Image
1583 serviceName := container.Name
1584 if labelImage, ok := container.Labels[domain.LabelImage]; ok && labelImage != "" {
1585 image = labelImage
1586 serviceName = extractServiceName(labelImage)
1587 }
1588 // Get the container's network for display in the routes table
1589 network := ""
1590 if networkName, err := s.runtime.GetContainerNetwork(ctx, container.ID); err == nil {
1591 network = networkName
1592 }
1593 attachment := domain.Attachment{
1594 Name: serviceName,
1595 Image: s.stripRegistryPrefix(image),
1596 ContainerID: container.ID,
1597 Status: container.Status,
1598 Network: network,
1599 Ports: append([]int(nil), container.Ports...),
1600 }
1601 attachments = append(attachments, attachment)
1602 }
1603
1604 return attachments
1605}
1606
1607func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, serviceImage, networkName string) error {
1608 log := zerowrap.FromCtx(ctx)
1609
1610 // Parse service name from image (e.g., "my-postgres:latest" → "postgres")
1611 serviceName := extractServiceName(serviceImage)
1612 containerName := fmt.Sprintf("gordon-%s-%s", sanitizeName(ownerDomain), serviceName)
1613
1614 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1615 "attachment": serviceName,
1616 "container_name": containerName,
1617 "owner_domain": ownerDomain,
1618 })
1619
1620 // Check if already running (idempotent)
1621 // Try new name first, then fall back to legacy name for backwards compatibility
1622 existingContainer := s.findContainerByName(ctx, containerName)
1623 if existingContainer == nil {
1624 // Try legacy naming scheme for backwards compatibility during upgrades
1625 containerNameLegacy := fmt.Sprintf("gordon-%s-%s", sanitizeNameLegacy(ownerDomain), serviceName)
1626 existingContainer = s.findContainerByName(ctx, containerNameLegacy)
1627 if existingContainer != nil {
1628 log.Info().Str("container_name", containerNameLegacy).Msg("found attachment with legacy naming, will be replaced")
1629 // Remove the old container and recreate with new name
1630 if err := s.runtime.StopContainer(ctx, existingContainer.ID); err != nil {
1631 return log.WrapErr(err, "failed to stop legacy attachment container")
1632 }
1633 if err := s.runtime.RemoveContainer(ctx, existingContainer.ID, true); err != nil {
1634 return log.WrapErr(err, "failed to remove legacy attachment container")
1635 }
1636 existingContainer = nil // Clear so we create new one below
1637 }
1638 }
1639
1640 if existingContainer != nil && existingContainer.Status == string(domain.ContainerStatusRunning) {
1641 log.Debug().Str("container_name", containerName).Msg("attachment already running, skipping")
1642 return nil
1643 }
1644
1645 // Remove existing stopped container if present
1646 if existingContainer != nil {
1647 log.Info().Str("container_name", containerName).Msg("removing stopped attachment container")
1648 if err := s.runtime.RemoveContainer(ctx, existingContainer.ID, true); err != nil {
1649 return log.WrapErr(err, "failed to remove existing attachment container")
1650 }
1651 }
1652
1653 log.Info().Str(zerowrap.FieldService, serviceImage).Msg("deploying attached service")
1654
1655 // Ensure image (canonical ref is returned; internal pulls may use the local registry).
1656 imageRef := s.buildImageRef(serviceImage)
1657 actualImageRef, err := s.ensureImage(ctx, imageRef)
1658 if err != nil {
1659 return err
1660 }
1661
1662 // Get image metadata
1663 exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, actualImageRef)
1664 if err != nil {
1665 log.WrapErr(err, "failed to get exposed ports for attachment, using defaults")
1666 exposedPorts = []int{}
1667 }
1668
1669 // Setup volumes (attachments need persistent data)
1670 volumes, err := s.setupVolumes(ctx, containerName, actualImageRef)
1671 if err != nil {
1672 log.WrapErr(err, "failed to setup volumes for attachment")
1673 volumes = make(map[string]string)
1674 }
1675
1676 // Load environment (attachment-specific env file)
1677 envVars, err := s.loadEnvironment(ctx, containerName, actualImageRef)
1678 if err != nil {
1679 log.WrapErr(err, "failed to load environment for attachment")
1680 envVars = []string{}
1681 }
1682
1683 // Create container with attachment labels (use actual ref, track original in labels)
1684 config := &domain.ContainerConfig{
1685 Image: actualImageRef,
1686 Name: containerName,
1687 Hostname: serviceName, // Internal DNS: postgres, redis, etc.
1688 Ports: exposedPorts,
1689 Env: envVars,
1690 Volumes: volumes,
1691 NetworkMode: networkName, // Same network as main app
1692 Labels: map[string]string{
1693 "gordon.managed": "true",
1694 "gordon.attachment": "true",
1695 "gordon.attached-to": ownerDomain,
1696 "gordon.image": serviceImage,
1697 },
1698 }
1699
1700 container, err := s.runtime.CreateContainer(ctx, config)
1701 if err != nil {
1702 return log.WrapErr(err, "failed to create attachment container")
1703 }
1704
1705 // Start container
1706 if err := s.runtime.StartContainer(ctx, container.ID); err != nil {
1707 s.runtime.RemoveContainer(ctx, container.ID, true)
1708 return log.WrapErr(err, "failed to start attachment container")
1709 }
1710
1711 // Track attachment
1712 s.mu.Lock()
1713 s.attachments[ownerDomain] = append(s.attachments[ownerDomain], container.ID)
1714 s.mu.Unlock()
1715
1716 // Start log collection for attachment
1717 s.startLogCollection(ctx, container.ID, containerName)
1718
1719 log.Info().Str(zerowrap.FieldEntityID, container.ID).Msg("attachment deployed successfully")
1720 return nil
1721}
1722
1723func (s *Service) stopAllManagedContainers(ctx context.Context) error {
1724 log := zerowrap.FromCtx(ctx)
1725
1726 s.mu.RLock()
1727 containers := make(map[string]*domain.Container, len(s.containers))
1728 maps.Copy(containers, s.containers)
1729 s.mu.RUnlock()
1730
1731 if len(containers) == 0 {
1732 log.Info().Msg("no managed containers to stop")
1733 return nil
1734 }
1735
1736 log.Info().Int(zerowrap.FieldCount, len(containers)).Msg("stopping all managed containers...")
1737
1738 errorCount := 0
1739 for d, c := range containers {
1740 log.Info().Str("domain", d).Str(zerowrap.FieldEntityID, c.ID).Msg("stopping managed container")
1741
1742 if err := s.runtime.StopContainer(ctx, c.ID); err != nil {
1743 log.WrapErrWithFields(err, "failed to stop container", map[string]any{"domain": d, zerowrap.FieldEntityID: c.ID})
1744 errorCount++
1745 continue
1746 }
1747
1748 s.mu.Lock()
1749 delete(s.containers, d)
1750 s.mu.Unlock()
1751
1752 log.Info().Str("domain", d).Str(zerowrap.FieldEntityID, c.ID).Msg("container stopped successfully")
1753 }
1754
1755 if errorCount > 0 {
1756 return fmt.Errorf("failed to stop %d containers", errorCount)
1757 }
1758
1759 log.Info().Msg("all managed containers stopped successfully")
1760 return nil
1761}
1762
1763// Utility functions
1764
1765func normalizeImageRef(image string) string {
1766 // Extract repository from image reference, handling port numbers correctly.
1767 // Examples:
1768 // nginx:latest -> docker.io/library/nginx
1769 // user/repo:tag -> docker.io/user/repo
1770 // localhost:5000/image:tag -> localhost:5000/image
1771 // registry.example.com/image:tag -> registry.example.com/image
1772 //
1773 // Note: This function assumes valid Docker image references. Edge cases like
1774 // "registry.com:5000" (registry with port but no image name) are not valid
1775 // image references per Docker's naming conventions, which require at least
1776 // one path component after the registry (e.g., "registry.com:5000/image").
1777
1778 // Find the tag separator: the last colon that isn't part of a port number.
1779 // A colon is part of a port if there's no slash after it until the next colon.
1780 repo := image
1781 lastColon := strings.LastIndex(image, ":")
1782 if lastColon != -1 {
1783 afterColon := image[lastColon+1:]
1784 // If there's no slash after the colon, it's the tag separator
1785 if !strings.Contains(afterColon, "/") {
1786 repo = image[:lastColon]
1787 }
1788 }
1789
1790 if !strings.Contains(repo, "/") {
1791 return "docker.io/library/" + repo
1792 }
1793
1794 if strings.Count(repo, "/") == 1 && !strings.Contains(strings.Split(repo, "/")[0], ".") && !strings.Contains(strings.Split(repo, "/")[0], ":") {
1795 return "docker.io/" + repo
1796 }
1797
1798 return repo
1799}
1800
1801func generateVolumeName(prefix, domainName, volumePath string) string {
1802 return fmt.Sprintf("%s-%s-%s",
1803 prefix,
1804 strings.ReplaceAll(domainName, ".", "-"),
1805 strings.ReplaceAll(strings.Trim(volumePath, "/"), "/", "-"))
1806}
1807
1808func mergeEnvironmentVariables(dockerfileEnv, userEnv []string) []string {
1809 envMap := make(map[string]string)
1810
1811 for _, env := range dockerfileEnv {
1812 if k, v, ok := strings.Cut(env, "="); ok {
1813 envMap[k] = v
1814 }
1815 }
1816
1817 for _, env := range userEnv {
1818 if k, v, ok := strings.Cut(env, "="); ok {
1819 envMap[k] = v
1820 }
1821 }
1822
1823 result := make([]string, 0, len(envMap))
1824 for k, v := range envMap {
1825 result = append(result, k+"="+v)
1826 }
1827
1828 return result
1829}
1830
1831// extractServiceName gets service name from image reference.
1832// "my-postgres:latest" → "postgres", "redis:7" → "redis"
1833func extractServiceName(image string) string {
1834 // Remove tag
1835 parts := strings.Split(image, ":")
1836 name := parts[0]
1837
1838 // Remove registry prefix if present
1839 if strings.Contains(name, "/") {
1840 nameParts := strings.Split(name, "/")
1841 name = nameParts[len(nameParts)-1]
1842 }
1843
1844 // Remove common prefixes like "my-"
1845 name = strings.TrimPrefix(name, "my-")
1846
1847 return name
1848}
1849
1850// sanitizeName is a convenience wrapper around domain.SanitizeDomainForContainer.
1851func sanitizeName(d string) string {
1852 return domain.SanitizeDomainForContainer(d)
1853}
1854
1855// sanitizeNameLegacy is a convenience wrapper around domain.SanitizeDomainForContainerLegacy.
1856func sanitizeNameLegacy(d string) string {
1857 return domain.SanitizeDomainForContainerLegacy(d)
1858}
1859
1860func isContainerNotFoundError(err error) bool {
1861 if err == nil {
1862 return false
1863 }
1864 if errors.Is(err, domain.ErrContainerNotFound) {
1865 return true
1866 }
1867 msg := strings.ToLower(err.Error())
1868 return strings.Contains(msg, "no such container") ||
1869 strings.Contains(msg, "no container with name or id") ||
1870 strings.Contains(msg, "container not found")
1871}
1872
1873func isConnectionRefusedError(err error) bool {
1874 if err == nil {
1875 return false
1876 }
1877 return strings.Contains(strings.ToLower(err.Error()), "connection refused")
1878}
1879
1880// findContainerByName finds a container by its name.
1881func (s *Service) findContainerByName(ctx context.Context, name string) *domain.Container {
1882 containers, err := s.runtime.ListContainers(ctx, true)
1883 if err != nil {
1884 return nil
1885 }
1886
1887 for _, c := range containers {
1888 if c.Name == name {
1889 return c
1890 }
1891 }
1892 return nil
1893}
1894
1895func (s *Service) waitForReady(ctx context.Context, containerID string) error {
1896 if err := s.pollContainerRunning(ctx, containerID); err != nil {
1897 return err
1898 }
1899
1900 s.mu.RLock()
1901 cfg := s.config
1902 s.mu.RUnlock()
1903
1904 readinessMode := cfg.ReadinessMode
1905 if readinessMode == "" {
1906 readinessMode = "delay"
1907 }
1908
1909 switch readinessMode {
1910 case "delay":
1911 return s.waitForReadyByDelay(ctx, containerID)
1912 case "docker-health":
1913 _, hasHealthcheck, err := s.runtime.GetContainerHealthStatus(ctx, containerID)
1914 if err != nil {
1915 return err
1916 }
1917 if !hasHealthcheck {
1918 return errors.New("no healthcheck detected")
1919 }
1920 return s.waitForHealthy(ctx, containerID, cfg.HealthTimeout)
1921 default: // auto
1922 _, hasHealthcheck, err := s.runtime.GetContainerHealthStatus(ctx, containerID)
1923 if err != nil {
1924 return err
1925 }
1926 if hasHealthcheck {
1927 return s.waitForHealthy(ctx, containerID, cfg.HealthTimeout)
1928 }
1929 return s.waitForReadyByDelay(ctx, containerID)
1930 }
1931}
1932
1933// waitForReadyByDelay waits using the legacy running+delay strategy.
1934func (s *Service) waitForReadyByDelay(ctx context.Context, containerID string) error {
1935 log := zerowrap.FromCtx(ctx)
1936
1937 s.mu.RLock()
1938 cfg := s.config
1939 s.mu.RUnlock()
1940
1941 delay := cfg.ReadinessDelay
1942 if delay == 0 {
1943 delay = 5 * time.Second
1944 }
1945
1946 log.Debug().Dur("delay", delay).Msg("waiting for container readiness")
1947 select {
1948 case <-time.After(delay):
1949 case <-ctx.Done():
1950 return ctx.Err()
1951 }
1952
1953 // Verify still running after delay
1954 running, err := s.runtime.IsContainerRunning(ctx, containerID)
1955 if err != nil {
1956 return err
1957 }
1958 if running {
1959 return nil
1960 }
1961
1962 return s.waitForRecovery(ctx, containerID)
1963}
1964
1965func (s *Service) waitForHealthy(ctx context.Context, containerID string, timeout time.Duration) error {
1966 if timeout == 0 {
1967 timeout = 90 * time.Second
1968 }
1969 waitCtx, cancel := context.WithTimeout(ctx, timeout)
1970 defer cancel()
1971
1972 lastStatus := "starting"
1973 for {
1974 status, hasHealthcheck, err := s.runtime.GetContainerHealthStatus(waitCtx, containerID)
1975 if err != nil {
1976 return err
1977 }
1978 if !hasHealthcheck {
1979 return errors.New("no healthcheck detected")
1980 }
1981 if status == "healthy" {
1982 return nil
1983 }
1984 if status != "" {
1985 lastStatus = status
1986 }
1987
1988 select {
1989 case <-time.After(time.Second):
1990 case <-waitCtx.Done():
1991 return fmt.Errorf("container healthcheck timeout after %s (last status: %s)", timeout, lastStatus)
1992 }
1993 }
1994}
1995
1996// pollContainerRunning polls until the container is running (max 30 seconds).
1997func (s *Service) pollContainerRunning(ctx context.Context, containerID string) error {
1998 for i := 0; i < 30; i++ {
1999 running, err := s.runtime.IsContainerRunning(ctx, containerID)
2000 if err != nil {
2001 return err
2002 }
2003 if running {
2004 return nil
2005 }
2006 if i == 29 {
2007 return fmt.Errorf("container did not start within 30 seconds")
2008 }
2009 select {
2010 case <-time.After(time.Second):
2011 case <-ctx.Done():
2012 return ctx.Err()
2013 }
2014 }
2015 return fmt.Errorf("container did not start within 30 seconds")
2016}
2017
2018// waitForRecovery waits for a container to recover within the recovery window.
2019func (s *Service) waitForRecovery(ctx context.Context, containerID string) error {
2020 log := zerowrap.FromCtx(ctx)
2021 log.Warn().
2022 Dur("recovery_window", readinessRecoveryWindow).
2023 Msg("container not running after readiness delay, waiting for recovery")
2024
2025 deadline := time.Now().Add(readinessRecoveryWindow)
2026 for time.Now().Before(deadline) {
2027 select {
2028 case <-time.After(time.Second):
2029 case <-ctx.Done():
2030 return ctx.Err()
2031 }
2032
2033 running, err := s.runtime.IsContainerRunning(ctx, containerID)
2034 if err != nil {
2035 return err
2036 }
2037 if running {
2038 log.Info().Msg("container recovered during readiness recovery window")
2039 return nil
2040 }
2041 }
2042
2043 return fmt.Errorf("container not running after readiness delay and recovery window")
2044}
2045
2046// publishContainerDeployed publishes a container.deployed event.
2047func (s *Service) publishContainerDeployed(ctx context.Context, domainName, containerID string) {
2048 payload := &domain.ContainerEventPayload{
2049 ContainerID: containerID,
2050 Domain: domainName,
2051 Action: "deployed",
2052 }
2053
2054 if err := s.eventBus.Publish(domain.EventContainerDeployed, payload); err != nil {
2055 log := zerowrap.FromCtx(ctx)
2056 log.Warn().Err(err).Msg("failed to publish container deployed event")
2057 }
2058}
2059
2060// rewriteToRegistryDomain rewrites an image reference to use the configured registry domain.
2061// e.g., "myapp:latest" -> "registry.example.com/myapp:latest"
2062func rewriteToRegistryDomain(imageRef, registryDomain string) string {
2063 if registryDomain == "" {
2064 return imageRef
2065 }
2066
2067 prefix := registryDomain + "/"
2068 if strings.HasPrefix(imageRef, prefix) {
2069 return imageRef
2070 }
2071
2072 return prefix + imageRef
2073}
2074
2075// rewriteToLocalRegistry rewrites an image reference to use the local registry address.
2076// e.g., "registry.example.com/myapp:latest" -> "localhost:5000/myapp:latest"
2077func rewriteToLocalRegistry(imageRef, registryDomain string, registryPort int) string {
2078 if imageRef == "" {
2079 return imageRef
2080 }
2081
2082 localRegistry := fmt.Sprintf("localhost:%d", registryPort)
2083 localPrefix := localRegistry + "/"
2084 imageRef = strings.TrimPrefix(imageRef, localPrefix)
2085
2086 registryDomain = strings.TrimSuffix(registryDomain, "/")
2087 if registryDomain != "" {
2088 prefix := registryDomain + "/"
2089 imageRef = strings.TrimPrefix(imageRef, prefix)
2090 }
2091
2092 return localPrefix + imageRef
2093}