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 "gordon/internal/boundaries/out"
16 "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 RegistryUsername string
25 RegistryPassword 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// Stop stops a running container.
216func (s *Service) Stop(ctx context.Context, containerID string) error {
217 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
218 zerowrap.FieldLayer: "usecase",
219 zerowrap.FieldUseCase: "Stop",
220 zerowrap.FieldEntityID: containerID,
221 })
222 log := zerowrap.FromCtx(ctx)
223
224 // Stop log collection before stopping container
225 if s.logWriter != nil {
226 if err := s.logWriter.StopLogging(containerID); err != nil {
227 log.Warn().Err(err).Msg("failed to stop container log collection")
228 }
229 }
230
231 if err := s.runtime.StopContainer(ctx, containerID); err != nil {
232 return log.WrapErr(err, "failed to stop container")
233 }
234
235 log.Info().Msg("container stopped")
236 return nil
237}
238
239// Remove removes a container.
240func (s *Service) Remove(ctx context.Context, containerID string, force bool) error {
241 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
242 zerowrap.FieldLayer: "usecase",
243 zerowrap.FieldUseCase: "Remove",
244 zerowrap.FieldEntityID: containerID,
245 })
246 log := zerowrap.FromCtx(ctx)
247
248 // Stop log collection before removing container
249 if s.logWriter != nil {
250 if err := s.logWriter.StopLogging(containerID); err != nil {
251 log.Warn().Err(err).Msg("failed to stop container log collection")
252 }
253 }
254
255 // Find domain for cleanup
256 var containerDomain string
257 s.mu.RLock()
258 for d, c := range s.containers {
259 if c.ID == containerID {
260 containerDomain = d
261 break
262 }
263 }
264 s.mu.RUnlock()
265
266 if err := s.runtime.RemoveContainer(ctx, containerID, force); err != nil {
267 return log.WrapErr(err, "failed to remove container")
268 }
269
270 // Cleanup volumes
271 if containerDomain != "" && !s.config.VolumePreserve {
272 if err := s.cleanupVolumesForDomain(ctx, containerDomain); err != nil {
273 log.WrapErrWithFields(err, "failed to cleanup volumes", map[string]any{"domain": containerDomain})
274 }
275 }
276
277 // Remove from tracking
278 s.mu.Lock()
279 var removedDomain string
280 for d, c := range s.containers {
281 if c.ID == containerID {
282 delete(s.containers, d)
283 removedDomain = d
284 log.Info().Str("domain", d).Msg("container removed")
285 break
286 }
287 }
288 s.mu.Unlock()
289
290 // Cleanup network
291 if removedDomain != "" && s.config.NetworkIsolation {
292 networkName := s.getNetworkForApp(removedDomain)
293 if err := s.cleanupNetworkIfEmpty(ctx, networkName); err != nil {
294 log.WrapErrWithFields(err, "failed to cleanup network", map[string]any{"network": networkName})
295 }
296 }
297
298 return nil
299}
300
301// Get retrieves a container by domain.
302func (s *Service) Get(_ context.Context, domainName string) (*domain.Container, bool) {
303 s.mu.RLock()
304 defer s.mu.RUnlock()
305 container, exists := s.containers[domainName]
306 return container, exists
307}
308
309// List returns all managed containers.
310func (s *Service) List(_ context.Context) map[string]*domain.Container {
311 s.mu.RLock()
312 defer s.mu.RUnlock()
313
314 result := make(map[string]*domain.Container, len(s.containers))
315 maps.Copy(result, s.containers)
316 return result
317}
318
319// ListRoutesWithDetails returns routes with network and attachment info.
320// Note: Container map is copied under lock, then external calls are made without lock.
321// If containers are removed between copy and runtime calls, errors are handled gracefully
322// (network becomes empty string). This trade-off avoids holding locks during I/O.
323func (s *Service) ListRoutesWithDetails(ctx context.Context) []domain.RouteInfo {
324 s.mu.RLock()
325 containers := make(map[string]*domain.Container, len(s.containers))
326 maps.Copy(containers, s.containers)
327 s.mu.RUnlock()
328
329 // Fetch all attachments once to avoid N+1 queries
330 attachmentsByDomain := s.getAllAttachments(ctx)
331
332 results := make([]domain.RouteInfo, 0, len(containers))
333 for domainName, container := range containers {
334 network := ""
335 image := ""
336 containerID := ""
337 status := ""
338 if container != nil {
339 containerID = container.ID
340 status = container.Status
341 image = container.Image
342 if container.Labels != nil {
343 if labelImage, ok := container.Labels[domain.LabelImage]; ok && labelImage != "" {
344 image = labelImage
345 }
346 }
347 // Strip registry domain prefix from image for cleaner display
348 image = s.stripRegistryPrefix(image)
349 if networkName, err := s.runtime.GetContainerNetwork(ctx, container.ID); err == nil {
350 network = networkName
351 }
352 }
353
354 results = append(results, domain.RouteInfo{
355 Domain: domainName,
356 Image: image,
357 ContainerID: containerID,
358 ContainerStatus: status,
359 Network: network,
360 Attachments: attachmentsByDomain[domainName],
361 })
362 }
363
364 return results
365}
366
367// stripRegistryPrefix removes the configured registry domain prefix from an image reference.
368// For example, "reg.example.com/myapp:latest" becomes "myapp:latest" if registry domain is "reg.example.com".
369func (s *Service) stripRegistryPrefix(image string) string {
370 if s.config.RegistryDomain == "" {
371 return image
372 }
373 prefix := strings.TrimSuffix(s.config.RegistryDomain, "/") + "/"
374 if strings.HasPrefix(image, prefix) {
375 return strings.TrimPrefix(image, prefix)
376 }
377 return image
378}
379
380// ListAttachments returns attachments for a domain.
381func (s *Service) ListAttachments(ctx context.Context, domainName string) []domain.Attachment {
382 return s.getAttachmentsForDomain(ctx, domainName)
383}
384
385// ListNetworks returns Gordon-managed networks.
386func (s *Service) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) {
387 networks, err := s.runtime.ListNetworks(ctx)
388 if err != nil {
389 return nil, err
390 }
391
392 var filtered []*domain.NetworkInfo
393 for _, network := range networks {
394 if strings.HasPrefix(network.Name, s.config.NetworkPrefix+"-") {
395 filtered = append(filtered, network)
396 }
397 }
398
399 return filtered, nil
400}
401
402// HealthCheck performs health checks on all containers.
403func (s *Service) HealthCheck(ctx context.Context) map[string]bool {
404 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
405 zerowrap.FieldLayer: "usecase",
406 zerowrap.FieldUseCase: "HealthCheck",
407 })
408 log := zerowrap.FromCtx(ctx)
409
410 s.mu.RLock()
411 defer s.mu.RUnlock()
412
413 health := make(map[string]bool, len(s.containers))
414 for d, c := range s.containers {
415 running, err := s.runtime.IsContainerRunning(ctx, c.ID)
416 if err != nil {
417 log.WrapErrWithFields(err, "health check failed", map[string]any{"domain": d, zerowrap.FieldEntityID: c.ID})
418 health[d] = false
419 } else {
420 health[d] = running
421 }
422 }
423 return health
424}
425
426// SyncContainers synchronizes containers with runtime state.
427func (s *Service) SyncContainers(ctx context.Context) error {
428 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
429 zerowrap.FieldLayer: "usecase",
430 zerowrap.FieldUseCase: "SyncContainers",
431 })
432 log := zerowrap.FromCtx(ctx)
433
434 s.mu.Lock()
435 defer s.mu.Unlock()
436
437 allContainers, err := s.runtime.ListContainers(ctx, false)
438 if err != nil {
439 return log.WrapErr(err, "failed to list containers")
440 }
441
442 managed := make(map[string]*domain.Container)
443 for _, c := range allContainers {
444 if c.Labels != nil {
445 if d, ok := c.Labels["gordon.domain"]; ok && c.Labels["gordon.managed"] == "true" {
446 managed[d] = c
447 }
448 }
449 }
450
451 s.containers = managed
452 log.Info().Int(zerowrap.FieldCount, len(managed)).Msg("container state synchronized")
453 return nil
454}
455
456// AutoStart starts containers for the provided routes that aren't running.
457func (s *Service) AutoStart(ctx context.Context, routes []domain.Route) error {
458 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
459 zerowrap.FieldLayer: "usecase",
460 zerowrap.FieldUseCase: "AutoStart",
461 })
462 log := zerowrap.FromCtx(ctx)
463
464 log.Info().Int("route_count", len(routes)).Msg("auto-starting containers for configured routes")
465
466 var started, skipped, errors int
467 for _, route := range routes {
468 // Check if container already exists and is running
469 if _, exists := s.Get(ctx, route.Domain); exists {
470 log.Debug().Str("domain", route.Domain).Msg("container already running, skipping")
471 skipped++
472 continue
473 }
474
475 log.Info().
476 Str("domain", route.Domain).
477 Str("image", route.Image).
478 Msg("auto-starting container for route")
479
480 if _, err := s.Deploy(ctx, route); err != nil {
481 log.Warn().Err(err).Str("domain", route.Domain).Msg("failed to auto-start container")
482 errors++
483 continue
484 }
485
486 started++
487 }
488
489 log.Info().
490 Int("started", started).
491 Int("skipped", skipped).
492 Int("errors", errors).
493 Msg("auto-start completed")
494
495 if errors > 0 {
496 return fmt.Errorf("auto-start completed with %d errors", errors)
497 }
498 return nil
499}
500
501// Shutdown gracefully shuts down all managed containers.
502func (s *Service) Shutdown(ctx context.Context) error {
503 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
504 zerowrap.FieldLayer: "usecase",
505 zerowrap.FieldUseCase: "Shutdown",
506 })
507 log := zerowrap.FromCtx(ctx)
508 log.Info().Msg("shutting down container manager...")
509
510 if err := s.stopAllManagedContainers(ctx); err != nil {
511 log.WrapErr(err, "failed to stop all containers during shutdown")
512 }
513
514 // Close log writer to stop all log collection
515 if s.logWriter != nil {
516 if err := s.logWriter.Close(); err != nil {
517 log.Warn().Err(err).Msg("failed to close container log writer")
518 }
519 }
520
521 log.Info().Msg("container manager shutdown complete")
522 return nil
523}
524
525// UpdateConfig updates the service configuration.
526func (s *Service) UpdateConfig(config Config) {
527 s.config = config
528}
529
530// Helper methods
531
532// cleanupFailedContainer stops and removes a container that failed to start properly.
533func (s *Service) cleanupFailedContainer(ctx context.Context, containerID string) {
534 log := zerowrap.FromCtx(ctx)
535 if err := s.runtime.StopContainer(ctx, containerID); err != nil {
536 log.Warn().Err(err).Str(zerowrap.FieldEntityID, containerID).Msg("failed to stop container after failure")
537 }
538 if err := s.runtime.RemoveContainer(ctx, containerID, true); err != nil {
539 log.Warn().Err(err).Str(zerowrap.FieldEntityID, containerID).Msg("failed to remove container after failure")
540 }
541}
542
543// cleanupOldContainer stops and removes an old container after zero-downtime switch.
544// It also renames the new container to the canonical name.
545func (s *Service) cleanupOldContainer(ctx context.Context, old *domain.Container, newContainerID, domainName string) {
546 log := zerowrap.FromCtx(ctx)
547 log.Info().Str(zerowrap.FieldEntityID, old.ID).Msg("stopping old container after zero-downtime switch")
548
549 if s.logWriter != nil {
550 if err := s.logWriter.StopLogging(old.ID); err != nil {
551 log.Warn().Err(err).Str(zerowrap.FieldEntityID, old.ID).Msg("failed to stop logging for old container")
552 }
553 }
554
555 if err := s.runtime.StopContainer(ctx, old.ID); err != nil {
556 log.Warn().Err(err).Str(zerowrap.FieldEntityID, old.ID).Msg("failed to stop old container")
557 }
558
559 if err := s.runtime.RemoveContainer(ctx, old.ID, true); err != nil {
560 log.Warn().Err(err).Str(zerowrap.FieldEntityID, old.ID).Msg("failed to remove old container")
561 }
562
563 // Rename new container to canonical name
564 canonicalName := fmt.Sprintf("gordon-%s", domainName)
565 if err := s.runtime.RenameContainer(ctx, newContainerID, canonicalName); err != nil {
566 log.Warn().Err(err).Str("canonical_name", canonicalName).Msg("failed to rename container to canonical name")
567 }
568}
569
570// startLogCollection starts log collection for a container in the background.
571// Errors are logged but don't fail the calling operation.
572func (s *Service) startLogCollection(ctx context.Context, containerID, domainName string) {
573 if s.logWriter == nil {
574 return
575 }
576
577 log := zerowrap.FromCtx(ctx)
578
579 logStream, err := s.runtime.GetContainerLogs(ctx, containerID, true)
580 if err != nil {
581 log.Warn().Err(err).Msg("failed to get container logs for collection")
582 return
583 }
584
585 if err := s.logWriter.StartLogging(ctx, containerID, domainName, logStream); err != nil {
586 log.Warn().Err(err).Msg("failed to start container log collection")
587 logStream.Close()
588 }
589}
590
591func (s *Service) buildImageRef(image string) string {
592 if !s.config.RegistryAuthEnabled || s.config.RegistryDomain == "" {
593 return image
594 }
595
596 repoPart := strings.Split(image, ":")[0]
597 if strings.HasPrefix(repoPart, s.config.RegistryDomain+"/") {
598 return image
599 }
600 if strings.Contains(repoPart, ".") && strings.Contains(repoPart, "/") {
601 return image
602 }
603 return fmt.Sprintf("%s/%s", s.config.RegistryDomain, image)
604}
605
606func normalizePullPolicy(policy string) string {
607 switch strings.ToLower(strings.TrimSpace(policy)) {
608 case PullPolicyAlways:
609 return PullPolicyAlways
610 case PullPolicyIfTagChanged:
611 return PullPolicyIfTagChanged
612 case PullPolicyIfNotPresent:
613 return PullPolicyIfNotPresent
614 default:
615 return PullPolicyIfNotPresent
616 }
617}
618
619func isDigestRef(imageRef string) bool {
620 return strings.Contains(imageRef, "@sha256:")
621}
622
623func (s *Service) pullRefForDeploy(ctx context.Context, imageRef string) (string, bool) {
624 if !domain.IsInternalDeploy(ctx) {
625 return imageRef, false
626 }
627 return rewriteToLocalRegistry(imageRef, s.config.RegistryDomain, s.config.RegistryPort), true
628}
629
630// ensureImage ensures the image is available locally, pulling if needed.
631// Returns the image reference to use for container operations:
632// - For digest references (@sha256:...), returns the pullRef since Docker can't tag digests
633// - For tagged images, returns the original imageRef after tagging the pulled image
634func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, error) {
635 ctx = zerowrap.CtxWithField(ctx, "image", imageRef)
636 log := zerowrap.FromCtx(ctx)
637
638 // Determine if this is an internal deploy and what reference to use for pulls.
639 pullRef, isInternal := s.pullRefForDeploy(ctx, imageRef)
640 if pullRef != imageRef {
641 log.Info().
642 Str("original_ref", imageRef).
643 Str("pull_ref", pullRef).
644 Msg("internal deploy: using localhost registry for pull")
645 }
646
647 found, err := s.ensureLocalImage(ctx, imageRef, pullRef)
648 if err != nil {
649 return "", err
650 }
651 if found {
652 return imageRef, nil
653 }
654
655 // Pull image
656 log.Info().Msg("pulling image from registry")
657
658 if err := s.pullImage(ctx, pullRef, isInternal); err != nil {
659 return "", err
660 }
661
662 // For digest references, we can't create a tag (Docker doesn't allow it).
663 // In this case, use the pullRef directly since the image is available under that reference.
664 if strings.Contains(imageRef, "@sha256:") {
665 log.Info().
666 Str("pull_ref", pullRef).
667 Msg("digest reference: using pull reference for container operations")
668 return pullRef, nil
669 }
670
671 if err := s.tagImageIfNeeded(ctx, pullRef, imageRef); err != nil {
672 return "", err
673 }
674
675 // Clean up the temporary pull reference tag to avoid duplicate entries
676 if pullRef != imageRef {
677 if err := s.runtime.UntagImage(ctx, pullRef); err != nil {
678 // Log but don't fail - the canonical tag is already applied
679 log.Debug().Err(err).Str("pull_ref", pullRef).Msg("failed to remove temporary pull tag")
680 }
681 }
682
683 log.Info().Msg("image pulled successfully")
684 return imageRef, nil
685}
686
687func (s *Service) ensureLocalImage(ctx context.Context, imageRef, pullRef string) (bool, error) {
688 log := zerowrap.FromCtx(ctx)
689
690 // For internal deploys (image push events), always pull fresh image
691 // because the same tag may reference new content (e.g., latest tag updated)
692 if domain.IsInternalDeploy(ctx) {
693 log.Info().Msg("internal deploy detected, forcing image pull to ensure latest content")
694 return false, nil
695 }
696
697 pullPolicy := normalizePullPolicy(s.config.PullPolicy)
698 switch pullPolicy {
699 case PullPolicyAlways:
700 log.Info().Str("pull_policy", pullPolicy).Msg("pull policy forces image pull")
701 return false, nil
702 case PullPolicyIfTagChanged:
703 if !isDigestRef(imageRef) {
704 log.Info().Str("pull_policy", pullPolicy).Msg("tag reference detected, pulling to check for updates")
705 return false, nil
706 }
707 }
708
709 localImages, err := s.runtime.ListImages(ctx)
710 if err != nil {
711 log.WrapErr(err, "failed to list local images, will attempt pull")
712 return false, nil
713 }
714
715 normalizedRef := normalizeImageRef(imageRef)
716 normalizedPullRef := normalizeImageRef(pullRef)
717 for _, img := range localImages {
718 normalizedImage := normalizeImageRef(img)
719 if normalizedImage == normalizedRef {
720 log.Info().Msg("image found locally, skipping pull")
721 return true, nil
722 }
723 if normalizedImage == normalizedPullRef {
724 if err := s.tagImageIfNeeded(ctx, pullRef, imageRef); err != nil {
725 return false, err
726 }
727 // Clean up the temporary pull reference tag
728 if pullRef != imageRef {
729 if err := s.runtime.UntagImage(ctx, pullRef); err != nil {
730 log.Debug().Err(err).Str("pull_ref", pullRef).Msg("failed to remove temporary pull tag")
731 }
732 }
733 log.Info().Msg("image found locally, skipping pull")
734 return true, nil
735 }
736 }
737
738 return false, nil
739}
740
741func (s *Service) pullImage(ctx context.Context, pullRef string, isInternal bool) error {
742 log := zerowrap.FromCtx(ctx)
743
744 switch {
745 case isInternal && s.config.RegistryAuthEnabled:
746 if s.config.InternalRegistryUsername == "" || s.config.InternalRegistryPassword == "" {
747 return log.WrapErr(fmt.Errorf("internal registry auth not configured"), "failed to pull image for internal deploy")
748 }
749 if err := s.runtime.PullImageWithAuth(ctx, pullRef, s.config.InternalRegistryUsername, s.config.InternalRegistryPassword); err != nil {
750 return log.WrapErr(err, "failed to pull image with internal auth")
751 }
752 case isInternal:
753 if err := s.runtime.PullImage(ctx, pullRef); err != nil {
754 return log.WrapErr(err, "failed to pull image")
755 }
756 case s.config.RegistryAuthEnabled:
757 if err := s.runtime.PullImageWithAuth(ctx, pullRef, s.config.RegistryUsername, s.config.RegistryPassword); err != nil {
758 return log.WrapErr(err, "failed to pull image with auth")
759 }
760 default:
761 if err := s.runtime.PullImage(ctx, pullRef); err != nil {
762 return log.WrapErr(err, "failed to pull image")
763 }
764 }
765
766 return nil
767}
768
769func (s *Service) tagImageIfNeeded(ctx context.Context, sourceRef, targetRef string) error {
770 if sourceRef == targetRef {
771 return nil
772 }
773
774 // Cannot create a tag with a digest reference - Docker/Podman doesn't allow it.
775 // When using digest references (image@sha256:...), skip tagging as the image
776 // is already available by its digest.
777 if strings.Contains(targetRef, "@sha256:") {
778 return nil
779 }
780
781 log := zerowrap.FromCtx(ctx)
782 if err := s.runtime.TagImage(ctx, sourceRef, targetRef); err != nil {
783 return log.WrapErr(err, "failed to tag image from pull reference")
784 }
785 return nil
786}
787
788func (s *Service) loadEnvironment(ctx context.Context, domainName, imageRef string) ([]string, error) {
789 log := zerowrap.FromCtx(ctx)
790
791 userEnvVars, err := s.envLoader.LoadEnv(ctx, domainName)
792 if err != nil {
793 return nil, log.WrapErr(err, "failed to load environment variables")
794 }
795
796 dockerfileEnvVars, err := s.runtime.InspectImageEnv(ctx, imageRef)
797 if err != nil {
798 log.WrapErr(err, "failed to inspect image environment")
799 dockerfileEnvVars = []string{}
800 }
801
802 return mergeEnvironmentVariables(dockerfileEnvVars, userEnvVars), nil
803}
804
805func (s *Service) setupVolumes(ctx context.Context, domainName, imageRef string) (map[string]string, error) {
806 log := zerowrap.FromCtx(ctx)
807 volumes := make(map[string]string)
808
809 if !s.config.VolumeAutoCreate {
810 return volumes, nil
811 }
812
813 volumePaths, err := s.runtime.InspectImageVolumes(ctx, imageRef)
814 if err != nil {
815 log.WrapErr(err, "failed to inspect image volumes")
816 return volumes, nil
817 }
818
819 for _, path := range volumePaths {
820 name := generateVolumeName(s.config.VolumePrefix, domainName, path)
821
822 exists, err := s.runtime.VolumeExists(ctx, name)
823 if err != nil {
824 log.WrapErrWithFields(err, "failed to check volume", map[string]any{"volume": name})
825 continue
826 }
827
828 if !exists {
829 if err := s.runtime.CreateVolume(ctx, name); err != nil {
830 log.WrapErrWithFields(err, "failed to create volume", map[string]any{"volume": name})
831 continue
832 }
833 log.Info().Str("volume", name).Str(zerowrap.FieldPath, path).Msg("created volume")
834 }
835
836 volumes[path] = name
837 }
838
839 return volumes, nil
840}
841
842func (s *Service) getNetworkForApp(domainName string) string {
843 if !s.config.NetworkIsolation {
844 return "bridge"
845 }
846
847 for groupName, domains := range s.config.NetworkGroups {
848 if slices.Contains(domains, domainName) {
849 return s.generateNetworkName(groupName)
850 }
851 }
852
853 return s.generateNetworkName(domainName)
854}
855
856func (s *Service) generateNetworkName(identifier string) string {
857 return fmt.Sprintf("%s-%s", s.config.NetworkPrefix, strings.ReplaceAll(identifier, ".", "-"))
858}
859
860func (s *Service) createNetworkIfNeeded(ctx context.Context, networkName string) error {
861 if networkName == "bridge" || networkName == "default" {
862 return nil
863 }
864
865 ctx = zerowrap.CtxWithField(ctx, "network", networkName)
866 log := zerowrap.FromCtx(ctx)
867
868 exists, err := s.runtime.NetworkExists(ctx, networkName)
869 if err != nil {
870 return log.WrapErr(err, "failed to check network existence")
871 }
872
873 if !exists {
874 if err := s.runtime.CreateNetwork(ctx, networkName, map[string]string{"driver": "bridge"}); err != nil {
875 return log.WrapErr(err, "failed to create network")
876 }
877 log.Info().Msg("created network for app isolation")
878 }
879
880 return nil
881}
882
883func (s *Service) cleanupOrphanedContainers(ctx context.Context, domainName string, skipContainerID string) error {
884 log := zerowrap.FromCtx(ctx)
885 expectedName := fmt.Sprintf("gordon-%s", domainName)
886
887 allContainers, err := s.runtime.ListContainers(ctx, true)
888 if err != nil {
889 return err
890 }
891
892 for _, c := range allContainers {
893 if c.Name == expectedName && c.ID != skipContainerID {
894 log.Info().Str(zerowrap.FieldEntityID, c.ID).Str(zerowrap.FieldStatus, c.Status).Msg("found orphaned container, removing")
895
896 if err := s.runtime.StopContainer(ctx, c.ID); err != nil {
897 log.WrapErrWithFields(err, "failed to stop orphaned container", map[string]any{zerowrap.FieldEntityID: c.ID})
898 }
899
900 if err := s.runtime.RemoveContainer(ctx, c.ID, true); err != nil {
901 log.WrapErrWithFields(err, "failed to remove orphaned container", map[string]any{zerowrap.FieldEntityID: c.ID})
902 }
903 }
904 }
905
906 return nil
907}
908
909func (s *Service) cleanupVolumesForDomain(_ context.Context, _ string) error {
910 return nil
911}
912
913func (s *Service) cleanupNetworkIfEmpty(ctx context.Context, networkName string) error {
914 if networkName == "bridge" || networkName == "default" {
915 return nil
916 }
917
918 ctx = zerowrap.CtxWithField(ctx, "network", networkName)
919 log := zerowrap.FromCtx(ctx)
920
921 networks, err := s.runtime.ListNetworks(ctx)
922 if err != nil {
923 return log.WrapErr(err, "failed to list networks")
924 }
925
926 for _, n := range networks {
927 if n.Name == networkName && len(n.Containers) == 0 {
928 if err := s.runtime.RemoveNetwork(ctx, networkName); err != nil {
929 return err
930 }
931 log.Info().Msg("cleaned up empty network")
932 break
933 }
934 }
935
936 return nil
937}
938
939func (s *Service) deployAttachments(ctx context.Context, domainName, networkName string) error {
940 // Collect attachments from both domain-specific and network group configs
941 attachments := s.resolveAttachmentsForDomain(domainName)
942 if len(attachments) == 0 {
943 return nil
944 }
945
946 log := zerowrap.FromCtx(ctx)
947 for _, svc := range attachments {
948 if err := s.deployAttachedService(ctx, domainName, svc, networkName); err != nil {
949 log.WrapErrWithFields(err, "failed to deploy attachment", map[string]any{zerowrap.FieldService: svc, "domain": domainName})
950 }
951 }
952
953 return nil
954}
955
956// resolveAttachmentsForDomain returns attachments for a domain by checking:
957// 1. Direct domain attachments (attachments[domain])
958// 2. Network group attachments (attachments[group] where domain is in network_groups[group])
959func (s *Service) resolveAttachmentsForDomain(domainName string) []string {
960 seen := make(map[string]bool)
961 var result []string
962
963 // First, add domain-specific attachments
964 if domainAttachments, ok := s.config.Attachments[domainName]; ok {
965 for _, img := range domainAttachments {
966 if !seen[img] {
967 seen[img] = true
968 result = append(result, img)
969 }
970 }
971 }
972
973 // Then, find which network group this domain belongs to and add group attachments
974 for groupName, domains := range s.config.NetworkGroups {
975 if slices.Contains(domains, domainName) {
976 if groupAttachments, ok := s.config.Attachments[groupName]; ok {
977 for _, img := range groupAttachments {
978 if !seen[img] {
979 seen[img] = true
980 result = append(result, img)
981 }
982 }
983 }
984 break // Domain can only be in one network group
985 }
986 }
987
988 return result
989}
990
991func (s *Service) getAttachmentsForDomain(ctx context.Context, domainName string) []domain.Attachment {
992 containers, err := s.runtime.ListContainers(ctx, true)
993 if err != nil {
994 return nil
995 }
996
997 return s.filterAttachments(ctx, containers, domainName)
998}
999
1000// getAllAttachments fetches all containers once and returns attachments grouped by domain.
1001// This avoids N+1 queries when listing multiple routes.
1002func (s *Service) getAllAttachments(ctx context.Context) map[string][]domain.Attachment {
1003 containers, err := s.runtime.ListContainers(ctx, true)
1004 if err != nil {
1005 return nil
1006 }
1007
1008 result := make(map[string][]domain.Attachment)
1009 for _, container := range containers {
1010 if container.Labels == nil {
1011 continue
1012 }
1013 if container.Labels[domain.LabelAttachment] != "true" {
1014 continue
1015 }
1016 ownerDomain := container.Labels[domain.LabelAttachedTo]
1017 if ownerDomain == "" {
1018 continue
1019 }
1020 image := container.Image
1021 serviceName := container.Name
1022 if labelImage, ok := container.Labels[domain.LabelImage]; ok && labelImage != "" {
1023 image = labelImage
1024 serviceName = extractServiceName(labelImage)
1025 }
1026 // Get the container's network for display in the routes table
1027 network := ""
1028 if networkName, err := s.runtime.GetContainerNetwork(ctx, container.ID); err == nil {
1029 network = networkName
1030 }
1031 attachment := domain.Attachment{
1032 Name: serviceName,
1033 Image: s.stripRegistryPrefix(image),
1034 ContainerID: container.ID,
1035 Status: container.Status,
1036 Network: network,
1037 }
1038 result[ownerDomain] = append(result[ownerDomain], attachment)
1039 }
1040
1041 return result
1042}
1043
1044// filterAttachments extracts attachments for a specific domain from a container list.
1045func (s *Service) filterAttachments(ctx context.Context, containers []*domain.Container, domainName string) []domain.Attachment {
1046 attachments := make([]domain.Attachment, 0)
1047 for _, container := range containers {
1048 if container.Labels == nil {
1049 continue
1050 }
1051 if container.Labels[domain.LabelAttachment] != "true" {
1052 continue
1053 }
1054 if container.Labels[domain.LabelAttachedTo] != domainName {
1055 continue
1056 }
1057 image := container.Image
1058 serviceName := container.Name
1059 if labelImage, ok := container.Labels[domain.LabelImage]; ok && labelImage != "" {
1060 image = labelImage
1061 serviceName = extractServiceName(labelImage)
1062 }
1063 // Get the container's network for display in the routes table
1064 network := ""
1065 if networkName, err := s.runtime.GetContainerNetwork(ctx, container.ID); err == nil {
1066 network = networkName
1067 }
1068 attachment := domain.Attachment{
1069 Name: serviceName,
1070 Image: s.stripRegistryPrefix(image),
1071 ContainerID: container.ID,
1072 Status: container.Status,
1073 Network: network,
1074 }
1075 attachments = append(attachments, attachment)
1076 }
1077
1078 return attachments
1079}
1080
1081func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, serviceImage, networkName string) error {
1082 log := zerowrap.FromCtx(ctx)
1083
1084 // Parse service name from image (e.g., "my-postgres:latest" → "postgres")
1085 serviceName := extractServiceName(serviceImage)
1086 containerName := fmt.Sprintf("gordon-%s-%s", sanitizeName(ownerDomain), serviceName)
1087
1088 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1089 "attachment": serviceName,
1090 "container_name": containerName,
1091 "owner_domain": ownerDomain,
1092 })
1093
1094 // Check if already running (idempotent)
1095 existingContainer := s.findContainerByName(ctx, containerName)
1096 if existingContainer != nil && existingContainer.Status == string(domain.ContainerStatusRunning) {
1097 log.Debug().Str("container_name", containerName).Msg("attachment already running, skipping")
1098 return nil
1099 }
1100
1101 // Remove existing stopped container if present
1102 if existingContainer != nil {
1103 log.Info().Str("container_name", containerName).Msg("removing stopped attachment container")
1104 if err := s.runtime.RemoveContainer(ctx, existingContainer.ID, true); err != nil {
1105 log.WrapErr(err, "failed to remove existing attachment container")
1106 }
1107 }
1108
1109 log.Info().Str(zerowrap.FieldService, serviceImage).Msg("deploying attached service")
1110
1111 // Ensure image (canonical ref is returned; internal pulls may use the local registry).
1112 imageRef := s.buildImageRef(serviceImage)
1113 actualImageRef, err := s.ensureImage(ctx, imageRef)
1114 if err != nil {
1115 return err
1116 }
1117
1118 // Get image metadata
1119 exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, actualImageRef)
1120 if err != nil {
1121 log.WrapErr(err, "failed to get exposed ports for attachment, using defaults")
1122 exposedPorts = []int{}
1123 }
1124
1125 // Setup volumes (attachments need persistent data)
1126 volumes, err := s.setupVolumes(ctx, containerName, actualImageRef)
1127 if err != nil {
1128 log.WrapErr(err, "failed to setup volumes for attachment")
1129 volumes = make(map[string]string)
1130 }
1131
1132 // Load environment (attachment-specific env file)
1133 envVars, err := s.loadEnvironment(ctx, containerName, actualImageRef)
1134 if err != nil {
1135 log.WrapErr(err, "failed to load environment for attachment")
1136 envVars = []string{}
1137 }
1138
1139 // Create container with attachment labels (use actual ref, track original in labels)
1140 config := &domain.ContainerConfig{
1141 Image: actualImageRef,
1142 Name: containerName,
1143 Hostname: serviceName, // Internal DNS: postgres, redis, etc.
1144 Ports: exposedPorts,
1145 Env: envVars,
1146 Volumes: volumes,
1147 NetworkMode: networkName, // Same network as main app
1148 Labels: map[string]string{
1149 "gordon.managed": "true",
1150 "gordon.attachment": "true",
1151 "gordon.attached-to": ownerDomain,
1152 "gordon.image": serviceImage,
1153 },
1154 }
1155
1156 container, err := s.runtime.CreateContainer(ctx, config)
1157 if err != nil {
1158 return log.WrapErr(err, "failed to create attachment container")
1159 }
1160
1161 // Start container
1162 if err := s.runtime.StartContainer(ctx, container.ID); err != nil {
1163 s.runtime.RemoveContainer(ctx, container.ID, true)
1164 return log.WrapErr(err, "failed to start attachment container")
1165 }
1166
1167 // Track attachment
1168 s.mu.Lock()
1169 s.attachments[ownerDomain] = append(s.attachments[ownerDomain], container.ID)
1170 s.mu.Unlock()
1171
1172 // Start log collection for attachment
1173 s.startLogCollection(ctx, container.ID, containerName)
1174
1175 log.Info().Str(zerowrap.FieldEntityID, container.ID).Msg("attachment deployed successfully")
1176 return nil
1177}
1178
1179func (s *Service) stopAllManagedContainers(ctx context.Context) error {
1180 log := zerowrap.FromCtx(ctx)
1181
1182 s.mu.RLock()
1183 containers := make(map[string]*domain.Container, len(s.containers))
1184 maps.Copy(containers, s.containers)
1185 s.mu.RUnlock()
1186
1187 if len(containers) == 0 {
1188 log.Info().Msg("no managed containers to stop")
1189 return nil
1190 }
1191
1192 log.Info().Int(zerowrap.FieldCount, len(containers)).Msg("stopping all managed containers...")
1193
1194 errorCount := 0
1195 for d, c := range containers {
1196 log.Info().Str("domain", d).Str(zerowrap.FieldEntityID, c.ID).Msg("stopping managed container")
1197
1198 if err := s.runtime.StopContainer(ctx, c.ID); err != nil {
1199 log.WrapErrWithFields(err, "failed to stop container", map[string]any{"domain": d, zerowrap.FieldEntityID: c.ID})
1200 errorCount++
1201 continue
1202 }
1203
1204 s.mu.Lock()
1205 delete(s.containers, d)
1206 s.mu.Unlock()
1207
1208 log.Info().Str("domain", d).Str(zerowrap.FieldEntityID, c.ID).Msg("container stopped successfully")
1209 }
1210
1211 if errorCount > 0 {
1212 return fmt.Errorf("failed to stop %d containers", errorCount)
1213 }
1214
1215 log.Info().Msg("all managed containers stopped successfully")
1216 return nil
1217}
1218
1219// Utility functions
1220
1221func normalizeImageRef(image string) string {
1222 // Extract repository from image reference, handling port numbers correctly.
1223 // Examples:
1224 // nginx:latest -> docker.io/library/nginx
1225 // user/repo:tag -> docker.io/user/repo
1226 // localhost:5000/image:tag -> localhost:5000/image
1227 // registry.example.com/image:tag -> registry.example.com/image
1228 //
1229 // Note: This function assumes valid Docker image references. Edge cases like
1230 // "registry.com:5000" (registry with port but no image name) are not valid
1231 // image references per Docker's naming conventions, which require at least
1232 // one path component after the registry (e.g., "registry.com:5000/image").
1233
1234 // Find the tag separator: the last colon that isn't part of a port number.
1235 // A colon is part of a port if there's no slash after it until the next colon.
1236 repo := image
1237 lastColon := strings.LastIndex(image, ":")
1238 if lastColon != -1 {
1239 afterColon := image[lastColon+1:]
1240 // If there's no slash after the colon, it's the tag separator
1241 if !strings.Contains(afterColon, "/") {
1242 repo = image[:lastColon]
1243 }
1244 }
1245
1246 if !strings.Contains(repo, "/") {
1247 return "docker.io/library/" + repo
1248 }
1249
1250 if strings.Count(repo, "/") == 1 && !strings.Contains(strings.Split(repo, "/")[0], ".") && !strings.Contains(strings.Split(repo, "/")[0], ":") {
1251 return "docker.io/" + repo
1252 }
1253
1254 return repo
1255}
1256
1257func generateVolumeName(prefix, domainName, volumePath string) string {
1258 return fmt.Sprintf("%s-%s-%s",
1259 prefix,
1260 strings.ReplaceAll(domainName, ".", "-"),
1261 strings.ReplaceAll(strings.Trim(volumePath, "/"), "/", "-"))
1262}
1263
1264func mergeEnvironmentVariables(dockerfileEnv, userEnv []string) []string {
1265 envMap := make(map[string]string)
1266
1267 for _, env := range dockerfileEnv {
1268 if k, v, ok := strings.Cut(env, "="); ok {
1269 envMap[k] = v
1270 }
1271 }
1272
1273 for _, env := range userEnv {
1274 if k, v, ok := strings.Cut(env, "="); ok {
1275 envMap[k] = v
1276 }
1277 }
1278
1279 result := make([]string, 0, len(envMap))
1280 for k, v := range envMap {
1281 result = append(result, k+"="+v)
1282 }
1283
1284 return result
1285}
1286
1287// extractServiceName gets service name from image reference.
1288// "my-postgres:latest" → "postgres", "redis:7" → "redis"
1289func extractServiceName(image string) string {
1290 // Remove tag
1291 parts := strings.Split(image, ":")
1292 name := parts[0]
1293
1294 // Remove registry prefix if present
1295 if strings.Contains(name, "/") {
1296 nameParts := strings.Split(name, "/")
1297 name = nameParts[len(nameParts)-1]
1298 }
1299
1300 // Remove common prefixes like "my-"
1301 name = strings.TrimPrefix(name, "my-")
1302
1303 return name
1304}
1305
1306// sanitizeName makes a domain safe for container naming.
1307func sanitizeName(domain string) string {
1308 // Replace dots and other special chars with dashes
1309 result := strings.ReplaceAll(domain, ".", "-")
1310 result = strings.ReplaceAll(result, ":", "-")
1311 result = strings.ReplaceAll(result, "/", "-")
1312 return result
1313}
1314
1315// findContainerByName finds a container by its name.
1316func (s *Service) findContainerByName(ctx context.Context, name string) *domain.Container {
1317 containers, err := s.runtime.ListContainers(ctx, true)
1318 if err != nil {
1319 return nil
1320 }
1321
1322 for _, c := range containers {
1323 if c.Name == name {
1324 return c
1325 }
1326 }
1327 return nil
1328}
1329
1330// waitForReady waits for a container to be ready.
1331// Uses simple "running + delay" approach for universal compatibility.
1332func (s *Service) waitForReady(ctx context.Context, containerID string) error {
1333 log := zerowrap.FromCtx(ctx)
1334
1335 // Poll for container to be running (max 30 seconds)
1336 for i := 0; i < 30; i++ {
1337 running, err := s.runtime.IsContainerRunning(ctx, containerID)
1338 if err != nil {
1339 return err
1340 }
1341 if running {
1342 break
1343 }
1344 if i == 29 {
1345 return fmt.Errorf("container did not start within 30 seconds")
1346 }
1347 time.Sleep(time.Second)
1348 }
1349
1350 // Additional readiness delay (configurable, default 5 seconds)
1351 delay := s.config.ReadinessDelay
1352 if delay == 0 {
1353 delay = 5 * time.Second
1354 }
1355
1356 log.Debug().Dur("delay", delay).Msg("waiting for container readiness")
1357 time.Sleep(delay)
1358
1359 // Verify still running after delay
1360 running, err := s.runtime.IsContainerRunning(ctx, containerID)
1361 if err != nil {
1362 return err
1363 }
1364 if !running {
1365 return fmt.Errorf("container stopped during readiness delay")
1366 }
1367
1368 return nil
1369}
1370
1371// publishContainerDeployed publishes a container.deployed event.
1372func (s *Service) publishContainerDeployed(ctx context.Context, domainName, containerID string) {
1373 payload := &domain.ContainerEventPayload{
1374 ContainerID: containerID,
1375 Domain: domainName,
1376 Action: "deployed",
1377 }
1378
1379 if err := s.eventBus.Publish(domain.EventContainerDeployed, payload); err != nil {
1380 log := zerowrap.FromCtx(ctx)
1381 log.Warn().Err(err).Msg("failed to publish container deployed event")
1382 }
1383}
1384
1385// rewriteToRegistryDomain rewrites an image reference to use the configured registry domain.
1386// e.g., "myapp:latest" -> "registry.example.com/myapp:latest"
1387func rewriteToRegistryDomain(imageRef, registryDomain string) string {
1388 if registryDomain == "" {
1389 return imageRef
1390 }
1391
1392 prefix := registryDomain + "/"
1393 if strings.HasPrefix(imageRef, prefix) {
1394 return imageRef
1395 }
1396
1397 return prefix + imageRef
1398}
1399
1400// rewriteToLocalRegistry rewrites an image reference to use the local registry address.
1401// e.g., "registry.example.com/myapp:latest" -> "localhost:5000/myapp:latest"
1402func rewriteToLocalRegistry(imageRef, registryDomain string, registryPort int) string {
1403 if imageRef == "" {
1404 return imageRef
1405 }
1406
1407 localRegistry := fmt.Sprintf("localhost:%d", registryPort)
1408 localPrefix := localRegistry + "/"
1409 imageRef = strings.TrimPrefix(imageRef, localPrefix)
1410
1411 registryDomain = strings.TrimSuffix(registryDomain, "/")
1412 if registryDomain != "" {
1413 prefix := registryDomain + "/"
1414 imageRef = strings.TrimPrefix(imageRef, prefix)
1415 }
1416
1417 return localPrefix + imageRef
1418}