v2.5.0 b9b6391

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	attachments, ok := s.config.Attachments[domainName]
941	if !ok {
942		return nil
943	}
944
945	log := zerowrap.FromCtx(ctx)
946	for _, svc := range attachments {
947		if err := s.deployAttachedService(ctx, domainName, svc, networkName); err != nil {
948			log.WrapErrWithFields(err, "failed to deploy attachment", map[string]any{zerowrap.FieldService: svc, "domain": domainName})
949		}
950	}
951
952	return nil
953}
954
955func (s *Service) getAttachmentsForDomain(ctx context.Context, domainName string) []domain.Attachment {
956	containers, err := s.runtime.ListContainers(ctx, true)
957	if err != nil {
958		return nil
959	}
960
961	return s.filterAttachments(ctx, containers, domainName)
962}
963
964// getAllAttachments fetches all containers once and returns attachments grouped by domain.
965// This avoids N+1 queries when listing multiple routes.
966func (s *Service) getAllAttachments(ctx context.Context) map[string][]domain.Attachment {
967	containers, err := s.runtime.ListContainers(ctx, true)
968	if err != nil {
969		return nil
970	}
971
972	result := make(map[string][]domain.Attachment)
973	for _, container := range containers {
974		if container.Labels == nil {
975			continue
976		}
977		if container.Labels[domain.LabelAttachment] != "true" {
978			continue
979		}
980		ownerDomain := container.Labels[domain.LabelAttachedTo]
981		if ownerDomain == "" {
982			continue
983		}
984		image := container.Image
985		serviceName := container.Name
986		if labelImage, ok := container.Labels[domain.LabelImage]; ok && labelImage != "" {
987			image = labelImage
988			serviceName = extractServiceName(labelImage)
989		}
990		// Get the container's network for display in the routes table
991		network := ""
992		if networkName, err := s.runtime.GetContainerNetwork(ctx, container.ID); err == nil {
993			network = networkName
994		}
995		attachment := domain.Attachment{
996			Name:        serviceName,
997			Image:       s.stripRegistryPrefix(image),
998			ContainerID: container.ID,
999			Status:      container.Status,
1000			Network:     network,
1001		}
1002		result[ownerDomain] = append(result[ownerDomain], attachment)
1003	}
1004
1005	return result
1006}
1007
1008// filterAttachments extracts attachments for a specific domain from a container list.
1009func (s *Service) filterAttachments(ctx context.Context, containers []*domain.Container, domainName string) []domain.Attachment {
1010	attachments := make([]domain.Attachment, 0)
1011	for _, container := range containers {
1012		if container.Labels == nil {
1013			continue
1014		}
1015		if container.Labels[domain.LabelAttachment] != "true" {
1016			continue
1017		}
1018		if container.Labels[domain.LabelAttachedTo] != domainName {
1019			continue
1020		}
1021		image := container.Image
1022		serviceName := container.Name
1023		if labelImage, ok := container.Labels[domain.LabelImage]; ok && labelImage != "" {
1024			image = labelImage
1025			serviceName = extractServiceName(labelImage)
1026		}
1027		// Get the container's network for display in the routes table
1028		network := ""
1029		if networkName, err := s.runtime.GetContainerNetwork(ctx, container.ID); err == nil {
1030			network = networkName
1031		}
1032		attachment := domain.Attachment{
1033			Name:        serviceName,
1034			Image:       s.stripRegistryPrefix(image),
1035			ContainerID: container.ID,
1036			Status:      container.Status,
1037			Network:     network,
1038		}
1039		attachments = append(attachments, attachment)
1040	}
1041
1042	return attachments
1043}
1044
1045func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, serviceImage, networkName string) error {
1046	log := zerowrap.FromCtx(ctx)
1047
1048	// Parse service name from image (e.g., "my-postgres:latest" → "postgres")
1049	serviceName := extractServiceName(serviceImage)
1050	containerName := fmt.Sprintf("gordon-%s-%s", sanitizeName(ownerDomain), serviceName)
1051
1052	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1053		"attachment":     serviceName,
1054		"container_name": containerName,
1055		"owner_domain":   ownerDomain,
1056	})
1057
1058	// Check if already running (idempotent)
1059	existingContainer := s.findContainerByName(ctx, containerName)
1060	if existingContainer != nil && existingContainer.Status == string(domain.ContainerStatusRunning) {
1061		log.Debug().Str("container_name", containerName).Msg("attachment already running, skipping")
1062		return nil
1063	}
1064
1065	// Remove existing stopped container if present
1066	if existingContainer != nil {
1067		log.Info().Str("container_name", containerName).Msg("removing stopped attachment container")
1068		if err := s.runtime.RemoveContainer(ctx, existingContainer.ID, true); err != nil {
1069			log.WrapErr(err, "failed to remove existing attachment container")
1070		}
1071	}
1072
1073	log.Info().Str(zerowrap.FieldService, serviceImage).Msg("deploying attached service")
1074
1075	// Ensure image (canonical ref is returned; internal pulls may use the local registry).
1076	imageRef := s.buildImageRef(serviceImage)
1077	actualImageRef, err := s.ensureImage(ctx, imageRef)
1078	if err != nil {
1079		return err
1080	}
1081
1082	// Get image metadata
1083	exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, actualImageRef)
1084	if err != nil {
1085		log.WrapErr(err, "failed to get exposed ports for attachment, using defaults")
1086		exposedPorts = []int{}
1087	}
1088
1089	// Setup volumes (attachments need persistent data)
1090	volumes, err := s.setupVolumes(ctx, containerName, actualImageRef)
1091	if err != nil {
1092		log.WrapErr(err, "failed to setup volumes for attachment")
1093		volumes = make(map[string]string)
1094	}
1095
1096	// Load environment (attachment-specific env file)
1097	envVars, err := s.loadEnvironment(ctx, containerName, actualImageRef)
1098	if err != nil {
1099		log.WrapErr(err, "failed to load environment for attachment")
1100		envVars = []string{}
1101	}
1102
1103	// Create container with attachment labels (use actual ref, track original in labels)
1104	config := &domain.ContainerConfig{
1105		Image:       actualImageRef,
1106		Name:        containerName,
1107		Hostname:    serviceName, // Internal DNS: postgres, redis, etc.
1108		Ports:       exposedPorts,
1109		Env:         envVars,
1110		Volumes:     volumes,
1111		NetworkMode: networkName, // Same network as main app
1112		Labels: map[string]string{
1113			"gordon.managed":     "true",
1114			"gordon.attachment":  "true",
1115			"gordon.attached-to": ownerDomain,
1116			"gordon.image":       serviceImage,
1117		},
1118	}
1119
1120	container, err := s.runtime.CreateContainer(ctx, config)
1121	if err != nil {
1122		return log.WrapErr(err, "failed to create attachment container")
1123	}
1124
1125	// Start container
1126	if err := s.runtime.StartContainer(ctx, container.ID); err != nil {
1127		s.runtime.RemoveContainer(ctx, container.ID, true)
1128		return log.WrapErr(err, "failed to start attachment container")
1129	}
1130
1131	// Track attachment
1132	s.mu.Lock()
1133	s.attachments[ownerDomain] = append(s.attachments[ownerDomain], container.ID)
1134	s.mu.Unlock()
1135
1136	// Start log collection for attachment
1137	s.startLogCollection(ctx, container.ID, containerName)
1138
1139	log.Info().Str(zerowrap.FieldEntityID, container.ID).Msg("attachment deployed successfully")
1140	return nil
1141}
1142
1143func (s *Service) stopAllManagedContainers(ctx context.Context) error {
1144	log := zerowrap.FromCtx(ctx)
1145
1146	s.mu.RLock()
1147	containers := make(map[string]*domain.Container, len(s.containers))
1148	maps.Copy(containers, s.containers)
1149	s.mu.RUnlock()
1150
1151	if len(containers) == 0 {
1152		log.Info().Msg("no managed containers to stop")
1153		return nil
1154	}
1155
1156	log.Info().Int(zerowrap.FieldCount, len(containers)).Msg("stopping all managed containers...")
1157
1158	errorCount := 0
1159	for d, c := range containers {
1160		log.Info().Str("domain", d).Str(zerowrap.FieldEntityID, c.ID).Msg("stopping managed container")
1161
1162		if err := s.runtime.StopContainer(ctx, c.ID); err != nil {
1163			log.WrapErrWithFields(err, "failed to stop container", map[string]any{"domain": d, zerowrap.FieldEntityID: c.ID})
1164			errorCount++
1165			continue
1166		}
1167
1168		s.mu.Lock()
1169		delete(s.containers, d)
1170		s.mu.Unlock()
1171
1172		log.Info().Str("domain", d).Str(zerowrap.FieldEntityID, c.ID).Msg("container stopped successfully")
1173	}
1174
1175	if errorCount > 0 {
1176		return fmt.Errorf("failed to stop %d containers", errorCount)
1177	}
1178
1179	log.Info().Msg("all managed containers stopped successfully")
1180	return nil
1181}
1182
1183// Utility functions
1184
1185func normalizeImageRef(image string) string {
1186	// Extract repository from image reference, handling port numbers correctly.
1187	// Examples:
1188	//   nginx:latest -> docker.io/library/nginx
1189	//   user/repo:tag -> docker.io/user/repo
1190	//   localhost:5000/image:tag -> localhost:5000/image
1191	//   registry.example.com/image:tag -> registry.example.com/image
1192	//
1193	// Note: This function assumes valid Docker image references. Edge cases like
1194	// "registry.com:5000" (registry with port but no image name) are not valid
1195	// image references per Docker's naming conventions, which require at least
1196	// one path component after the registry (e.g., "registry.com:5000/image").
1197
1198	// Find the tag separator: the last colon that isn't part of a port number.
1199	// A colon is part of a port if there's no slash after it until the next colon.
1200	repo := image
1201	lastColon := strings.LastIndex(image, ":")
1202	if lastColon != -1 {
1203		afterColon := image[lastColon+1:]
1204		// If there's no slash after the colon, it's the tag separator
1205		if !strings.Contains(afterColon, "/") {
1206			repo = image[:lastColon]
1207		}
1208	}
1209
1210	if !strings.Contains(repo, "/") {
1211		return "docker.io/library/" + repo
1212	}
1213
1214	if strings.Count(repo, "/") == 1 && !strings.Contains(strings.Split(repo, "/")[0], ".") && !strings.Contains(strings.Split(repo, "/")[0], ":") {
1215		return "docker.io/" + repo
1216	}
1217
1218	return repo
1219}
1220
1221func generateVolumeName(prefix, domainName, volumePath string) string {
1222	return fmt.Sprintf("%s-%s-%s",
1223		prefix,
1224		strings.ReplaceAll(domainName, ".", "-"),
1225		strings.ReplaceAll(strings.Trim(volumePath, "/"), "/", "-"))
1226}
1227
1228func mergeEnvironmentVariables(dockerfileEnv, userEnv []string) []string {
1229	envMap := make(map[string]string)
1230
1231	for _, env := range dockerfileEnv {
1232		if k, v, ok := strings.Cut(env, "="); ok {
1233			envMap[k] = v
1234		}
1235	}
1236
1237	for _, env := range userEnv {
1238		if k, v, ok := strings.Cut(env, "="); ok {
1239			envMap[k] = v
1240		}
1241	}
1242
1243	result := make([]string, 0, len(envMap))
1244	for k, v := range envMap {
1245		result = append(result, k+"="+v)
1246	}
1247
1248	return result
1249}
1250
1251// extractServiceName gets service name from image reference.
1252// "my-postgres:latest" → "postgres", "redis:7" → "redis"
1253func extractServiceName(image string) string {
1254	// Remove tag
1255	parts := strings.Split(image, ":")
1256	name := parts[0]
1257
1258	// Remove registry prefix if present
1259	if strings.Contains(name, "/") {
1260		nameParts := strings.Split(name, "/")
1261		name = nameParts[len(nameParts)-1]
1262	}
1263
1264	// Remove common prefixes like "my-"
1265	name = strings.TrimPrefix(name, "my-")
1266
1267	return name
1268}
1269
1270// sanitizeName makes a domain safe for container naming.
1271func sanitizeName(domain string) string {
1272	// Replace dots and other special chars with dashes
1273	result := strings.ReplaceAll(domain, ".", "-")
1274	result = strings.ReplaceAll(result, ":", "-")
1275	result = strings.ReplaceAll(result, "/", "-")
1276	return result
1277}
1278
1279// findContainerByName finds a container by its name.
1280func (s *Service) findContainerByName(ctx context.Context, name string) *domain.Container {
1281	containers, err := s.runtime.ListContainers(ctx, true)
1282	if err != nil {
1283		return nil
1284	}
1285
1286	for _, c := range containers {
1287		if c.Name == name {
1288			return c
1289		}
1290	}
1291	return nil
1292}
1293
1294// waitForReady waits for a container to be ready.
1295// Uses simple "running + delay" approach for universal compatibility.
1296func (s *Service) waitForReady(ctx context.Context, containerID string) error {
1297	log := zerowrap.FromCtx(ctx)
1298
1299	// Poll for container to be running (max 30 seconds)
1300	for i := 0; i < 30; i++ {
1301		running, err := s.runtime.IsContainerRunning(ctx, containerID)
1302		if err != nil {
1303			return err
1304		}
1305		if running {
1306			break
1307		}
1308		if i == 29 {
1309			return fmt.Errorf("container did not start within 30 seconds")
1310		}
1311		time.Sleep(time.Second)
1312	}
1313
1314	// Additional readiness delay (configurable, default 5 seconds)
1315	delay := s.config.ReadinessDelay
1316	if delay == 0 {
1317		delay = 5 * time.Second
1318	}
1319
1320	log.Debug().Dur("delay", delay).Msg("waiting for container readiness")
1321	time.Sleep(delay)
1322
1323	// Verify still running after delay
1324	running, err := s.runtime.IsContainerRunning(ctx, containerID)
1325	if err != nil {
1326		return err
1327	}
1328	if !running {
1329		return fmt.Errorf("container stopped during readiness delay")
1330	}
1331
1332	return nil
1333}
1334
1335// publishContainerDeployed publishes a container.deployed event.
1336func (s *Service) publishContainerDeployed(ctx context.Context, domainName, containerID string) {
1337	payload := &domain.ContainerEventPayload{
1338		ContainerID: containerID,
1339		Domain:      domainName,
1340		Action:      "deployed",
1341	}
1342
1343	if err := s.eventBus.Publish(domain.EventContainerDeployed, payload); err != nil {
1344		log := zerowrap.FromCtx(ctx)
1345		log.Warn().Err(err).Msg("failed to publish container deployed event")
1346	}
1347}
1348
1349// rewriteToRegistryDomain rewrites an image reference to use the configured registry domain.
1350// e.g., "myapp:latest" -> "registry.example.com/myapp:latest"
1351func rewriteToRegistryDomain(imageRef, registryDomain string) string {
1352	if registryDomain == "" {
1353		return imageRef
1354	}
1355
1356	prefix := registryDomain + "/"
1357	if strings.HasPrefix(imageRef, prefix) {
1358		return imageRef
1359	}
1360
1361	return prefix + imageRef
1362}
1363
1364// rewriteToLocalRegistry rewrites an image reference to use the local registry address.
1365// e.g., "registry.example.com/myapp:latest" -> "localhost:5000/myapp:latest"
1366func rewriteToLocalRegistry(imageRef, registryDomain string, registryPort int) string {
1367	if imageRef == "" {
1368		return imageRef
1369	}
1370
1371	localRegistry := fmt.Sprintf("localhost:%d", registryPort)
1372	localPrefix := localRegistry + "/"
1373	imageRef = strings.TrimPrefix(imageRef, localPrefix)
1374
1375	registryDomain = strings.TrimSuffix(registryDomain, "/")
1376	if registryDomain != "" {
1377		prefix := registryDomain + "/"
1378		imageRef = strings.TrimPrefix(imageRef, prefix)
1379	}
1380
1381	return localPrefix + imageRef
1382}

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