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