v2.10.2 705579a

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

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