v2.7.0 fac7705

service.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close