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