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 repoPart := strings.Split(image, ":")[0]
640 if strings.HasPrefix(repoPart, s.config.RegistryDomain+"/") {
641 return image
642 }
643 if strings.Contains(repoPart, ".") && strings.Contains(repoPart, "/") {
644 return image
645 }
646 return fmt.Sprintf("%s/%s", s.config.RegistryDomain, image)
647}
648
649func normalizePullPolicy(policy string) string {
650 switch strings.ToLower(strings.TrimSpace(policy)) {
651 case PullPolicyAlways:
652 return PullPolicyAlways
653 case PullPolicyIfTagChanged:
654 return PullPolicyIfTagChanged
655 case PullPolicyIfNotPresent:
656 return PullPolicyIfNotPresent
657 default:
658 return PullPolicyIfNotPresent
659 }
660}
661
662func isDigestRef(imageRef string) bool {
663 return strings.Contains(imageRef, "@sha256:")
664}
665
666func (s *Service) pullRefForDeploy(ctx context.Context, imageRef string) (string, bool) {
667 if !domain.IsInternalDeploy(ctx) {
668 return imageRef, false
669 }
670 return rewriteToLocalRegistry(imageRef, s.config.RegistryDomain, s.config.RegistryPort), true
671}
672
673// ensureImage ensures the image is available locally, pulling if needed.
674// Returns the image reference to use for container operations:
675// - For digest references (@sha256:...), returns the pullRef since Docker can't tag digests
676// - For tagged images, returns the original imageRef after tagging the pulled image
677func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, error) {
678 ctx = zerowrap.CtxWithField(ctx, "image", imageRef)
679 log := zerowrap.FromCtx(ctx)
680
681 // Determine if this is an internal deploy and what reference to use for pulls.
682 pullRef, isInternal := s.pullRefForDeploy(ctx, imageRef)
683 if pullRef != imageRef {
684 log.Info().
685 Str("original_ref", imageRef).
686 Str("pull_ref", pullRef).
687 Msg("internal deploy: using localhost registry for pull")
688 }
689
690 found, err := s.ensureLocalImage(ctx, imageRef, pullRef)
691 if err != nil {
692 return "", err
693 }
694 if found {
695 return imageRef, nil
696 }
697
698 // Pull image
699 log.Info().Msg("pulling image from registry")
700
701 if err := s.pullImage(ctx, pullRef, isInternal); err != nil {
702 return "", err
703 }
704
705 // For digest references, we can't create a tag (Docker doesn't allow it).
706 // In this case, use the pullRef directly since the image is available under that reference.
707 if strings.Contains(imageRef, "@sha256:") {
708 log.Info().
709 Str("pull_ref", pullRef).
710 Msg("digest reference: using pull reference for container operations")
711 return pullRef, nil
712 }
713
714 if err := s.tagImageIfNeeded(ctx, pullRef, imageRef); err != nil {
715 return "", err
716 }
717
718 // Clean up the temporary pull reference tag to avoid duplicate entries
719 if pullRef != imageRef {
720 if err := s.runtime.UntagImage(ctx, pullRef); err != nil {
721 // Log but don't fail - the canonical tag is already applied
722 log.Debug().Err(err).Str("pull_ref", pullRef).Msg("failed to remove temporary pull tag")
723 }
724 }
725
726 log.Info().Msg("image pulled successfully")
727 return imageRef, nil
728}
729
730func (s *Service) ensureLocalImage(ctx context.Context, imageRef, pullRef string) (bool, error) {
731 log := zerowrap.FromCtx(ctx)
732
733 // For internal deploys (image push events), always pull fresh image
734 // because the same tag may reference new content (e.g., latest tag updated)
735 if domain.IsInternalDeploy(ctx) {
736 log.Info().Msg("internal deploy detected, forcing image pull to ensure latest content")
737 return false, nil
738 }
739
740 pullPolicy := normalizePullPolicy(s.config.PullPolicy)
741 switch pullPolicy {
742 case PullPolicyAlways:
743 log.Info().Str("pull_policy", pullPolicy).Msg("pull policy forces image pull")
744 return false, nil
745 case PullPolicyIfTagChanged:
746 if !isDigestRef(imageRef) {
747 log.Info().Str("pull_policy", pullPolicy).Msg("tag reference detected, pulling to check for updates")
748 return false, nil
749 }
750 }
751
752 localImages, err := s.runtime.ListImages(ctx)
753 if err != nil {
754 log.WrapErr(err, "failed to list local images, will attempt pull")
755 return false, nil
756 }
757
758 normalizedRef := normalizeImageRef(imageRef)
759 normalizedPullRef := normalizeImageRef(pullRef)
760 for _, img := range localImages {
761 normalizedImage := normalizeImageRef(img)
762 if normalizedImage == normalizedRef {
763 log.Info().Msg("image found locally, skipping pull")
764 return true, nil
765 }
766 if normalizedImage == normalizedPullRef {
767 if err := s.tagImageIfNeeded(ctx, pullRef, imageRef); err != nil {
768 return false, err
769 }
770 // Clean up the temporary pull reference tag
771 if pullRef != imageRef {
772 if err := s.runtime.UntagImage(ctx, pullRef); err != nil {
773 log.Debug().Err(err).Str("pull_ref", pullRef).Msg("failed to remove temporary pull tag")
774 }
775 }
776 log.Info().Msg("image found locally, skipping pull")
777 return true, nil
778 }
779 }
780
781 return false, nil
782}
783
784func (s *Service) pullImage(ctx context.Context, pullRef string, isInternal bool) error {
785 log := zerowrap.FromCtx(ctx)
786
787 switch {
788 case isInternal && s.config.RegistryAuthEnabled:
789 if s.config.InternalRegistryUsername == "" || s.config.InternalRegistryPassword == "" {
790 return log.WrapErr(fmt.Errorf("internal registry auth not configured"), "failed to pull image for internal deploy")
791 }
792 if err := s.runtime.PullImageWithAuth(ctx, pullRef, s.config.InternalRegistryUsername, s.config.InternalRegistryPassword); err != nil {
793 return log.WrapErr(err, "failed to pull image with internal auth")
794 }
795 case isInternal:
796 if err := s.runtime.PullImage(ctx, pullRef); err != nil {
797 return log.WrapErr(err, "failed to pull image")
798 }
799 case s.config.RegistryAuthEnabled:
800 if s.config.ServiceTokenUsername == "" || s.config.ServiceToken == "" {
801 return log.WrapErr(fmt.Errorf("registry service token not configured"), "failed to pull image for registry auth")
802 }
803 if err := s.runtime.PullImageWithAuth(ctx, pullRef, s.config.ServiceTokenUsername, s.config.ServiceToken); err != nil {
804 return log.WrapErr(err, "failed to pull image with auth")
805 }
806 default:
807 if err := s.runtime.PullImage(ctx, pullRef); err != nil {
808 return log.WrapErr(err, "failed to pull image")
809 }
810 }
811
812 return nil
813}
814
815func (s *Service) tagImageIfNeeded(ctx context.Context, sourceRef, targetRef string) error {
816 if sourceRef == targetRef {
817 return nil
818 }
819
820 // Cannot create a tag with a digest reference - Docker/Podman doesn't allow it.
821 // When using digest references (image@sha256:...), skip tagging as the image
822 // is already available by its digest.
823 if strings.Contains(targetRef, "@sha256:") {
824 return nil
825 }
826
827 log := zerowrap.FromCtx(ctx)
828 if err := s.runtime.TagImage(ctx, sourceRef, targetRef); err != nil {
829 return log.WrapErr(err, "failed to tag image from pull reference")
830 }
831 return nil
832}
833
834func (s *Service) loadEnvironment(ctx context.Context, domainName, imageRef string) ([]string, error) {
835 log := zerowrap.FromCtx(ctx)
836
837 userEnvVars, err := s.envLoader.LoadEnv(ctx, domainName)
838 if err != nil {
839 return nil, log.WrapErr(err, "failed to load environment variables")
840 }
841
842 dockerfileEnvVars, err := s.runtime.InspectImageEnv(ctx, imageRef)
843 if err != nil {
844 log.WrapErr(err, "failed to inspect image environment")
845 dockerfileEnvVars = []string{}
846 }
847
848 return mergeEnvironmentVariables(dockerfileEnvVars, userEnvVars), nil
849}
850
851func (s *Service) setupVolumes(ctx context.Context, domainName, imageRef string) (map[string]string, error) {
852 log := zerowrap.FromCtx(ctx)
853 volumes := make(map[string]string)
854
855 if !s.config.VolumeAutoCreate {
856 return volumes, nil
857 }
858
859 volumePaths, err := s.runtime.InspectImageVolumes(ctx, imageRef)
860 if err != nil {
861 log.WrapErr(err, "failed to inspect image volumes")
862 return volumes, nil
863 }
864
865 for _, path := range volumePaths {
866 name := generateVolumeName(s.config.VolumePrefix, domainName, path)
867
868 exists, err := s.runtime.VolumeExists(ctx, name)
869 if err != nil {
870 log.WrapErrWithFields(err, "failed to check volume", map[string]any{"volume": name})
871 continue
872 }
873
874 if !exists {
875 if err := s.runtime.CreateVolume(ctx, name); err != nil {
876 log.WrapErrWithFields(err, "failed to create volume", map[string]any{"volume": name})
877 continue
878 }
879 log.Info().Str("volume", name).Str(zerowrap.FieldPath, path).Msg("created volume")
880 }
881
882 volumes[path] = name
883 }
884
885 return volumes, nil
886}
887
888func (s *Service) getNetworkForApp(domainName string) string {
889 if !s.config.NetworkIsolation {
890 return "bridge"
891 }
892
893 for groupName, domains := range s.config.NetworkGroups {
894 if slices.Contains(domains, domainName) {
895 return s.generateNetworkName(groupName)
896 }
897 }
898
899 return s.generateNetworkName(domainName)
900}
901
902func (s *Service) generateNetworkName(identifier string) string {
903 return fmt.Sprintf("%s-%s", s.config.NetworkPrefix, strings.ReplaceAll(identifier, ".", "-"))
904}
905
906func (s *Service) createNetworkIfNeeded(ctx context.Context, networkName string) error {
907 if networkName == "bridge" || networkName == "default" {
908 return nil
909 }
910
911 ctx = zerowrap.CtxWithField(ctx, "network", networkName)
912 log := zerowrap.FromCtx(ctx)
913
914 exists, err := s.runtime.NetworkExists(ctx, networkName)
915 if err != nil {
916 return log.WrapErr(err, "failed to check network existence")
917 }
918
919 if !exists {
920 if err := s.runtime.CreateNetwork(ctx, networkName, map[string]string{"driver": "bridge"}); err != nil {
921 return log.WrapErr(err, "failed to create network")
922 }
923 log.Info().Msg("created network for app isolation")
924 }
925
926 return nil
927}
928
929func (s *Service) cleanupOrphanedContainers(ctx context.Context, domainName string, skipContainerID string) error {
930 log := zerowrap.FromCtx(ctx)
931 expectedName := fmt.Sprintf("gordon-%s", domainName)
932
933 allContainers, err := s.runtime.ListContainers(ctx, true)
934 if err != nil {
935 return err
936 }
937
938 for _, c := range allContainers {
939 if c.Name == expectedName && c.ID != skipContainerID {
940 log.Info().Str(zerowrap.FieldEntityID, c.ID).Str(zerowrap.FieldStatus, c.Status).Msg("found orphaned container, removing")
941
942 if err := s.runtime.StopContainer(ctx, c.ID); err != nil {
943 log.WrapErrWithFields(err, "failed to stop orphaned container", map[string]any{zerowrap.FieldEntityID: c.ID})
944 }
945
946 if err := s.runtime.RemoveContainer(ctx, c.ID, true); err != nil {
947 log.WrapErrWithFields(err, "failed to remove orphaned container", map[string]any{zerowrap.FieldEntityID: c.ID})
948 }
949 }
950 }
951
952 return nil
953}
954
955func (s *Service) cleanupVolumesForDomain(_ context.Context, _ string) error {
956 return nil
957}
958
959func (s *Service) cleanupNetworkIfEmpty(ctx context.Context, networkName string) error {
960 if networkName == "bridge" || networkName == "default" {
961 return nil
962 }
963
964 ctx = zerowrap.CtxWithField(ctx, "network", networkName)
965 log := zerowrap.FromCtx(ctx)
966
967 networks, err := s.runtime.ListNetworks(ctx)
968 if err != nil {
969 return log.WrapErr(err, "failed to list networks")
970 }
971
972 for _, n := range networks {
973 if n.Name == networkName && len(n.Containers) == 0 {
974 if err := s.runtime.RemoveNetwork(ctx, networkName); err != nil {
975 return err
976 }
977 log.Info().Msg("cleaned up empty network")
978 break
979 }
980 }
981
982 return nil
983}
984
985func (s *Service) deployAttachments(ctx context.Context, domainName, networkName string) error {
986 // Collect attachments from both domain-specific and network group configs
987 attachments := s.resolveAttachmentsForDomain(domainName)
988 if len(attachments) == 0 {
989 return nil
990 }
991
992 log := zerowrap.FromCtx(ctx)
993 for _, svc := range attachments {
994 if err := s.deployAttachedService(ctx, domainName, svc, networkName); err != nil {
995 log.WrapErrWithFields(err, "failed to deploy attachment", map[string]any{zerowrap.FieldService: svc, "domain": domainName})
996 }
997 }
998
999 return nil
1000}
1001
1002// resolveAttachmentsForDomain returns attachments for a domain by checking:
1003// 1. Direct domain attachments (attachments[domain])
1004// 2. Network group attachments (attachments[group] where domain is in network_groups[group])
1005func (s *Service) resolveAttachmentsForDomain(domainName string) []string {
1006 seen := make(map[string]bool)
1007 var result []string
1008
1009 // First, add domain-specific attachments
1010 if domainAttachments, ok := s.config.Attachments[domainName]; ok {
1011 for _, img := range domainAttachments {
1012 if !seen[img] {
1013 seen[img] = true
1014 result = append(result, img)
1015 }
1016 }
1017 }
1018
1019 // Then, find which network group this domain belongs to and add group attachments
1020 for groupName, domains := range s.config.NetworkGroups {
1021 if slices.Contains(domains, domainName) {
1022 if groupAttachments, ok := s.config.Attachments[groupName]; ok {
1023 for _, img := range groupAttachments {
1024 if !seen[img] {
1025 seen[img] = true
1026 result = append(result, img)
1027 }
1028 }
1029 }
1030 break // Domain can only be in one network group
1031 }
1032 }
1033
1034 return result
1035}
1036
1037func (s *Service) getAttachmentsForDomain(ctx context.Context, domainName string) []domain.Attachment {
1038 containers, err := s.runtime.ListContainers(ctx, true)
1039 if err != nil {
1040 return nil
1041 }
1042
1043 return s.filterAttachments(ctx, containers, domainName)
1044}
1045
1046// getAllAttachments fetches all containers once and returns attachments grouped by domain.
1047// This avoids N+1 queries when listing multiple routes.
1048func (s *Service) getAllAttachments(ctx context.Context) map[string][]domain.Attachment {
1049 containers, err := s.runtime.ListContainers(ctx, true)
1050 if err != nil {
1051 return nil
1052 }
1053
1054 result := make(map[string][]domain.Attachment)
1055 for _, container := range containers {
1056 if container.Labels == nil {
1057 continue
1058 }
1059 if container.Labels[domain.LabelAttachment] != "true" {
1060 continue
1061 }
1062 ownerDomain := container.Labels[domain.LabelAttachedTo]
1063 if ownerDomain == "" {
1064 continue
1065 }
1066 image := container.Image
1067 serviceName := container.Name
1068 if labelImage, ok := container.Labels[domain.LabelImage]; ok && labelImage != "" {
1069 image = labelImage
1070 serviceName = extractServiceName(labelImage)
1071 }
1072 // Get the container's network for display in the routes table
1073 network := ""
1074 if networkName, err := s.runtime.GetContainerNetwork(ctx, container.ID); err == nil {
1075 network = networkName
1076 }
1077 attachment := domain.Attachment{
1078 Name: serviceName,
1079 Image: s.stripRegistryPrefix(image),
1080 ContainerID: container.ID,
1081 Status: container.Status,
1082 Network: network,
1083 }
1084 result[ownerDomain] = append(result[ownerDomain], attachment)
1085 }
1086
1087 return result
1088}
1089
1090// filterAttachments extracts attachments for a specific domain from a container list.
1091func (s *Service) filterAttachments(ctx context.Context, containers []*domain.Container, domainName string) []domain.Attachment {
1092 attachments := make([]domain.Attachment, 0)
1093 for _, container := range containers {
1094 if container.Labels == nil {
1095 continue
1096 }
1097 if container.Labels[domain.LabelAttachment] != "true" {
1098 continue
1099 }
1100 if container.Labels[domain.LabelAttachedTo] != domainName {
1101 continue
1102 }
1103 image := container.Image
1104 serviceName := container.Name
1105 if labelImage, ok := container.Labels[domain.LabelImage]; ok && labelImage != "" {
1106 image = labelImage
1107 serviceName = extractServiceName(labelImage)
1108 }
1109 // Get the container's network for display in the routes table
1110 network := ""
1111 if networkName, err := s.runtime.GetContainerNetwork(ctx, container.ID); err == nil {
1112 network = networkName
1113 }
1114 attachment := domain.Attachment{
1115 Name: serviceName,
1116 Image: s.stripRegistryPrefix(image),
1117 ContainerID: container.ID,
1118 Status: container.Status,
1119 Network: network,
1120 }
1121 attachments = append(attachments, attachment)
1122 }
1123
1124 return attachments
1125}
1126
1127func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, serviceImage, networkName string) error {
1128 log := zerowrap.FromCtx(ctx)
1129
1130 // Parse service name from image (e.g., "my-postgres:latest" → "postgres")
1131 serviceName := extractServiceName(serviceImage)
1132 containerName := fmt.Sprintf("gordon-%s-%s", sanitizeName(ownerDomain), serviceName)
1133
1134 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1135 "attachment": serviceName,
1136 "container_name": containerName,
1137 "owner_domain": ownerDomain,
1138 })
1139
1140 // Check if already running (idempotent)
1141 // Try new name first, then fall back to legacy name for backwards compatibility
1142 existingContainer := s.findContainerByName(ctx, containerName)
1143 if existingContainer == nil {
1144 // Try legacy naming scheme for backwards compatibility during upgrades
1145 containerNameLegacy := fmt.Sprintf("gordon-%s-%s", sanitizeNameLegacy(ownerDomain), serviceName)
1146 existingContainer = s.findContainerByName(ctx, containerNameLegacy)
1147 if existingContainer != nil {
1148 log.Info().Str("container_name", containerNameLegacy).Msg("found attachment with legacy naming, will be replaced")
1149 // Remove the old container and recreate with new name
1150 if err := s.runtime.StopContainer(ctx, existingContainer.ID); err != nil {
1151 return log.WrapErr(err, "failed to stop legacy attachment container")
1152 }
1153 if err := s.runtime.RemoveContainer(ctx, existingContainer.ID, true); err != nil {
1154 return log.WrapErr(err, "failed to remove legacy attachment container")
1155 }
1156 existingContainer = nil // Clear so we create new one below
1157 }
1158 }
1159
1160 if existingContainer != nil && existingContainer.Status == string(domain.ContainerStatusRunning) {
1161 log.Debug().Str("container_name", containerName).Msg("attachment already running, skipping")
1162 return nil
1163 }
1164
1165 // Remove existing stopped container if present
1166 if existingContainer != nil {
1167 log.Info().Str("container_name", containerName).Msg("removing stopped attachment container")
1168 if err := s.runtime.RemoveContainer(ctx, existingContainer.ID, true); err != nil {
1169 return log.WrapErr(err, "failed to remove existing attachment container")
1170 }
1171 }
1172
1173 log.Info().Str(zerowrap.FieldService, serviceImage).Msg("deploying attached service")
1174
1175 // Ensure image (canonical ref is returned; internal pulls may use the local registry).
1176 imageRef := s.buildImageRef(serviceImage)
1177 actualImageRef, err := s.ensureImage(ctx, imageRef)
1178 if err != nil {
1179 return err
1180 }
1181
1182 // Get image metadata
1183 exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, actualImageRef)
1184 if err != nil {
1185 log.WrapErr(err, "failed to get exposed ports for attachment, using defaults")
1186 exposedPorts = []int{}
1187 }
1188
1189 // Setup volumes (attachments need persistent data)
1190 volumes, err := s.setupVolumes(ctx, containerName, actualImageRef)
1191 if err != nil {
1192 log.WrapErr(err, "failed to setup volumes for attachment")
1193 volumes = make(map[string]string)
1194 }
1195
1196 // Load environment (attachment-specific env file)
1197 envVars, err := s.loadEnvironment(ctx, containerName, actualImageRef)
1198 if err != nil {
1199 log.WrapErr(err, "failed to load environment for attachment")
1200 envVars = []string{}
1201 }
1202
1203 // Create container with attachment labels (use actual ref, track original in labels)
1204 config := &domain.ContainerConfig{
1205 Image: actualImageRef,
1206 Name: containerName,
1207 Hostname: serviceName, // Internal DNS: postgres, redis, etc.
1208 Ports: exposedPorts,
1209 Env: envVars,
1210 Volumes: volumes,
1211 NetworkMode: networkName, // Same network as main app
1212 Labels: map[string]string{
1213 "gordon.managed": "true",
1214 "gordon.attachment": "true",
1215 "gordon.attached-to": ownerDomain,
1216 "gordon.image": serviceImage,
1217 },
1218 }
1219
1220 container, err := s.runtime.CreateContainer(ctx, config)
1221 if err != nil {
1222 return log.WrapErr(err, "failed to create attachment container")
1223 }
1224
1225 // Start container
1226 if err := s.runtime.StartContainer(ctx, container.ID); err != nil {
1227 s.runtime.RemoveContainer(ctx, container.ID, true)
1228 return log.WrapErr(err, "failed to start attachment container")
1229 }
1230
1231 // Track attachment
1232 s.mu.Lock()
1233 s.attachments[ownerDomain] = append(s.attachments[ownerDomain], container.ID)
1234 s.mu.Unlock()
1235
1236 // Start log collection for attachment
1237 s.startLogCollection(ctx, container.ID, containerName)
1238
1239 log.Info().Str(zerowrap.FieldEntityID, container.ID).Msg("attachment deployed successfully")
1240 return nil
1241}
1242
1243func (s *Service) stopAllManagedContainers(ctx context.Context) error {
1244 log := zerowrap.FromCtx(ctx)
1245
1246 s.mu.RLock()
1247 containers := make(map[string]*domain.Container, len(s.containers))
1248 maps.Copy(containers, s.containers)
1249 s.mu.RUnlock()
1250
1251 if len(containers) == 0 {
1252 log.Info().Msg("no managed containers to stop")
1253 return nil
1254 }
1255
1256 log.Info().Int(zerowrap.FieldCount, len(containers)).Msg("stopping all managed containers...")
1257
1258 errorCount := 0
1259 for d, c := range containers {
1260 log.Info().Str("domain", d).Str(zerowrap.FieldEntityID, c.ID).Msg("stopping managed container")
1261
1262 if err := s.runtime.StopContainer(ctx, c.ID); err != nil {
1263 log.WrapErrWithFields(err, "failed to stop container", map[string]any{"domain": d, zerowrap.FieldEntityID: c.ID})
1264 errorCount++
1265 continue
1266 }
1267
1268 s.mu.Lock()
1269 delete(s.containers, d)
1270 s.mu.Unlock()
1271
1272 log.Info().Str("domain", d).Str(zerowrap.FieldEntityID, c.ID).Msg("container stopped successfully")
1273 }
1274
1275 if errorCount > 0 {
1276 return fmt.Errorf("failed to stop %d containers", errorCount)
1277 }
1278
1279 log.Info().Msg("all managed containers stopped successfully")
1280 return nil
1281}
1282
1283// Utility functions
1284
1285func normalizeImageRef(image string) string {
1286 // Extract repository from image reference, handling port numbers correctly.
1287 // Examples:
1288 // nginx:latest -> docker.io/library/nginx
1289 // user/repo:tag -> docker.io/user/repo
1290 // localhost:5000/image:tag -> localhost:5000/image
1291 // registry.example.com/image:tag -> registry.example.com/image
1292 //
1293 // Note: This function assumes valid Docker image references. Edge cases like
1294 // "registry.com:5000" (registry with port but no image name) are not valid
1295 // image references per Docker's naming conventions, which require at least
1296 // one path component after the registry (e.g., "registry.com:5000/image").
1297
1298 // Find the tag separator: the last colon that isn't part of a port number.
1299 // A colon is part of a port if there's no slash after it until the next colon.
1300 repo := image
1301 lastColon := strings.LastIndex(image, ":")
1302 if lastColon != -1 {
1303 afterColon := image[lastColon+1:]
1304 // If there's no slash after the colon, it's the tag separator
1305 if !strings.Contains(afterColon, "/") {
1306 repo = image[:lastColon]
1307 }
1308 }
1309
1310 if !strings.Contains(repo, "/") {
1311 return "docker.io/library/" + repo
1312 }
1313
1314 if strings.Count(repo, "/") == 1 && !strings.Contains(strings.Split(repo, "/")[0], ".") && !strings.Contains(strings.Split(repo, "/")[0], ":") {
1315 return "docker.io/" + repo
1316 }
1317
1318 return repo
1319}
1320
1321func generateVolumeName(prefix, domainName, volumePath string) string {
1322 return fmt.Sprintf("%s-%s-%s",
1323 prefix,
1324 strings.ReplaceAll(domainName, ".", "-"),
1325 strings.ReplaceAll(strings.Trim(volumePath, "/"), "/", "-"))
1326}
1327
1328func mergeEnvironmentVariables(dockerfileEnv, userEnv []string) []string {
1329 envMap := make(map[string]string)
1330
1331 for _, env := range dockerfileEnv {
1332 if k, v, ok := strings.Cut(env, "="); ok {
1333 envMap[k] = v
1334 }
1335 }
1336
1337 for _, env := range userEnv {
1338 if k, v, ok := strings.Cut(env, "="); ok {
1339 envMap[k] = v
1340 }
1341 }
1342
1343 result := make([]string, 0, len(envMap))
1344 for k, v := range envMap {
1345 result = append(result, k+"="+v)
1346 }
1347
1348 return result
1349}
1350
1351// extractServiceName gets service name from image reference.
1352// "my-postgres:latest" → "postgres", "redis:7" → "redis"
1353func extractServiceName(image string) string {
1354 // Remove tag
1355 parts := strings.Split(image, ":")
1356 name := parts[0]
1357
1358 // Remove registry prefix if present
1359 if strings.Contains(name, "/") {
1360 nameParts := strings.Split(name, "/")
1361 name = nameParts[len(nameParts)-1]
1362 }
1363
1364 // Remove common prefixes like "my-"
1365 name = strings.TrimPrefix(name, "my-")
1366
1367 return name
1368}
1369
1370// sanitizeName is a convenience wrapper around domain.SanitizeDomainForContainer.
1371func sanitizeName(d string) string {
1372 return domain.SanitizeDomainForContainer(d)
1373}
1374
1375// sanitizeNameLegacy is a convenience wrapper around domain.SanitizeDomainForContainerLegacy.
1376func sanitizeNameLegacy(d string) string {
1377 return domain.SanitizeDomainForContainerLegacy(d)
1378}
1379
1380// findContainerByName finds a container by its name.
1381func (s *Service) findContainerByName(ctx context.Context, name string) *domain.Container {
1382 containers, err := s.runtime.ListContainers(ctx, true)
1383 if err != nil {
1384 return nil
1385 }
1386
1387 for _, c := range containers {
1388 if c.Name == name {
1389 return c
1390 }
1391 }
1392 return nil
1393}
1394
1395// waitForReady waits for a container to be ready.
1396// Uses simple "running + delay" approach for universal compatibility.
1397func (s *Service) waitForReady(ctx context.Context, containerID string) error {
1398 log := zerowrap.FromCtx(ctx)
1399
1400 // Poll for container to be running (max 30 seconds)
1401 for i := 0; i < 30; i++ {
1402 running, err := s.runtime.IsContainerRunning(ctx, containerID)
1403 if err != nil {
1404 return err
1405 }
1406 if running {
1407 break
1408 }
1409 if i == 29 {
1410 return fmt.Errorf("container did not start within 30 seconds")
1411 }
1412 time.Sleep(time.Second)
1413 }
1414
1415 // Additional readiness delay (configurable, default 5 seconds)
1416 delay := s.config.ReadinessDelay
1417 if delay == 0 {
1418 delay = 5 * time.Second
1419 }
1420
1421 log.Debug().Dur("delay", delay).Msg("waiting for container readiness")
1422 time.Sleep(delay)
1423
1424 // Verify still running after delay
1425 running, err := s.runtime.IsContainerRunning(ctx, containerID)
1426 if err != nil {
1427 return err
1428 }
1429 if !running {
1430 return fmt.Errorf("container stopped during readiness delay")
1431 }
1432
1433 return nil
1434}
1435
1436// publishContainerDeployed publishes a container.deployed event.
1437func (s *Service) publishContainerDeployed(ctx context.Context, domainName, containerID string) {
1438 payload := &domain.ContainerEventPayload{
1439 ContainerID: containerID,
1440 Domain: domainName,
1441 Action: "deployed",
1442 }
1443
1444 if err := s.eventBus.Publish(domain.EventContainerDeployed, payload); err != nil {
1445 log := zerowrap.FromCtx(ctx)
1446 log.Warn().Err(err).Msg("failed to publish container deployed event")
1447 }
1448}
1449
1450// rewriteToRegistryDomain rewrites an image reference to use the configured registry domain.
1451// e.g., "myapp:latest" -> "registry.example.com/myapp:latest"
1452func rewriteToRegistryDomain(imageRef, registryDomain string) string {
1453 if registryDomain == "" {
1454 return imageRef
1455 }
1456
1457 prefix := registryDomain + "/"
1458 if strings.HasPrefix(imageRef, prefix) {
1459 return imageRef
1460 }
1461
1462 return prefix + imageRef
1463}
1464
1465// rewriteToLocalRegistry rewrites an image reference to use the local registry address.
1466// e.g., "registry.example.com/myapp:latest" -> "localhost:5000/myapp:latest"
1467func rewriteToLocalRegistry(imageRef, registryDomain string, registryPort int) string {
1468 if imageRef == "" {
1469 return imageRef
1470 }
1471
1472 localRegistry := fmt.Sprintf("localhost:%d", registryPort)
1473 localPrefix := localRegistry + "/"
1474 imageRef = strings.TrimPrefix(imageRef, localPrefix)
1475
1476 registryDomain = strings.TrimSuffix(registryDomain, "/")
1477 if registryDomain != "" {
1478 prefix := registryDomain + "/"
1479 imageRef = strings.TrimPrefix(imageRef, prefix)
1480 }
1481
1482 return localPrefix + imageRef
1483}