v2.10.1 43db1bb

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

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