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