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