v2.9.0 8ebdfe0

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

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