v2.15.1 c808592

service.go RawHistoryFolder

1// Package container implements the container management use case.
2package container
3
4import (
5	"context"
6	"errors"
7	"fmt"
8	"maps"
9	"slices"
10	"strings"
11	"sync"
12	"time"
13
14	"github.com/bnema/zerowrap"
15
16	"github.com/bnema/gordon/internal/boundaries/out"
17	"github.com/bnema/gordon/internal/domain"
18)
19
20// Config holds configuration needed by the container service.
21type Config struct {
22	RegistryAuthEnabled      bool
23	RegistryDomain           string
24	RegistryPort             int
25	ServiceTokenUsername     string
26	ServiceToken             string
27	InternalRegistryUsername string
28	InternalRegistryPassword string
29	PullPolicy               string
30	VolumeAutoCreate         bool
31	VolumePrefix             string
32	VolumePreserve           bool
33	NetworkIsolation         bool
34	NetworkPrefix            string
35	DNSSuffix                string
36	NetworkGroups            map[string][]string
37	Attachments              map[string][]string
38	ReadinessDelay           time.Duration // Delay after container starts before considering it ready
39	ReadinessMode            string        // Readiness strategy: auto, docker-health, delay
40	HealthTimeout            time.Duration // Max wait for health-based readiness
41	DrainDelay               time.Duration // Grace period after cache invalidation before stopping old container
42	DrainDelayConfigured     bool          // True when deploy.drain_delay was explicitly configured
43	DrainMode                string        // Drain strategy: auto, inflight, delay
44	DrainTimeout             time.Duration // Max wait for in-flight requests to drain
45}
46
47const (
48	PullPolicyAlways       = "always"
49	PullPolicyIfNotPresent = "if-not-present"
50	PullPolicyIfTagChanged = "if-tag-changed"
51
52	// readinessRecoveryWindow is an additional grace window used when a container
53	// is briefly not running at the end of readiness delay. This avoids false
54	// negatives during short startup flaps.
55	readinessRecoveryWindow = 30 * time.Second
56	internalPullMaxAttempts = 3
57)
58
59// Service implements the ContainerService interface.
60type Service struct {
61	runtime          out.ContainerRuntime
62	envLoader        out.EnvLoader
63	eventBus         out.EventPublisher
64	logWriter        out.ContainerLogWriter
65	cacheInvalidator out.ProxyCacheInvalidator
66	drainWaiter      out.ProxyDrainWaiter
67	config           Config
68	containers       map[string]*domain.Container
69	attachments      map[string][]string // ownerDomain → []containerIDs
70	mu               sync.RWMutex
71	deployMu         sync.Map // per-domain deploy locks (domain → *domainDeployLock)
72	monitor          *Monitor
73}
74
75// domainDeployLock is a context-aware mutex using a buffered channel.
76// It allows Lock() to be interrupted by context cancellation.
77type domainDeployLock struct {
78	ch chan struct{}
79}
80
81// newDomainDeployLock creates a new lock that is initially unlocked.
82func newDomainDeployLock() *domainDeployLock {
83	l := &domainDeployLock{ch: make(chan struct{}, 1)}
84	l.ch <- struct{}{}
85	return l
86}
87
88// Lock acquires the lock or returns an error if the context is cancelled.
89func (l *domainDeployLock) Lock(ctx context.Context) error {
90	// Fast path: honor already-canceled contexts before blocking.
91	if err := ctx.Err(); err != nil {
92		return err
93	}
94	select {
95	case <-ctx.Done():
96		return ctx.Err()
97	case <-l.ch:
98		// Re-check context after acquisition: if canceled while we were
99		// in the select, release the token so the lock isn't consumed.
100		if err := ctx.Err(); err != nil {
101			l.ch <- struct{}{}
102			return err
103		}
104		return nil
105	}
106}
107
108// Unlock releases the lock.
109func (l *domainDeployLock) Unlock() {
110	select {
111	case l.ch <- struct{}{}:
112	default:
113		// This should never happen if Lock/Unlock are used correctly.
114		// Panic to detect double-unlocks or other misuse early.
115		panic("domainDeployLock: double unlock detected")
116	}
117}
118
119// NewService creates a new container service.
120func NewService(
121	runtime out.ContainerRuntime,
122	envLoader out.EnvLoader,
123	eventBus out.EventPublisher,
124	logWriter out.ContainerLogWriter,
125	config Config,
126) *Service {
127	return &Service{
128		runtime:     runtime,
129		envLoader:   envLoader,
130		eventBus:    eventBus,
131		logWriter:   logWriter,
132		config:      config,
133		containers:  make(map[string]*domain.Container),
134		attachments: make(map[string][]string),
135	}
136}
137
138// SetProxyCacheInvalidator sets the proxy cache invalidator for synchronous
139// cache invalidation during zero-downtime deployments. This must be called
140// after construction because the proxy service is created after the container
141// service during application initialization.
142func (s *Service) SetProxyCacheInvalidator(inv out.ProxyCacheInvalidator) {
143	s.mu.Lock()
144	s.cacheInvalidator = inv
145	s.mu.Unlock()
146}
147
148// SetProxyDrainWaiter sets the proxy in-flight drain waiter used during
149// zero-downtime replacement before stopping the old container.
150func (s *Service) SetProxyDrainWaiter(waiter out.ProxyDrainWaiter) {
151	s.mu.Lock()
152	s.drainWaiter = waiter
153	s.mu.Unlock()
154}
155
156// domainDeployMu returns a per-domain lock for serializing deploy operations.
157func (s *Service) domainDeployMu(domain string) *domainDeployLock {
158	v, _ := s.deployMu.LoadOrStore(domain, newDomainDeployLock())
159	// Type assertion is safe: LoadOrStore always stores *domainDeployLock values,
160	// and any concurrent LoadOrStore calls will also store *domainDeployLock.
161	return v.(*domainDeployLock)
162}
163
164// acquireDomainDeployLock acquires the per-domain deploy lock with context cancellation support.
165// Returns an unlock function that must be called to release the lock.
166func (s *Service) acquireDomainDeployLock(ctx context.Context, domain string) (func(), error) {
167	lock := s.domainDeployMu(domain)
168	if err := lock.Lock(ctx); err != nil {
169		return nil, err
170	}
171	return func() { lock.Unlock() }, nil
172}
173
174// buildContainerConfig constructs the container configuration for deployment.
175func (s *Service) deploymentContainerName(containerDomain string, existing *domain.Container) string {
176	canonicalName := fmt.Sprintf("gordon-%s", containerDomain)
177	if existing == nil {
178		return canonicalName
179	}
180
181	// Alternate temporary names if the tracked container was left with a temp suffix.
182	// This prevents name collisions after interrupted zero-downtime deploys.
183	newName := canonicalName + "-new"
184	nextName := canonicalName + "-next"
185	switch existing.Name {
186	case newName:
187		return nextName
188	case nextName:
189		return newName
190	default:
191		return newName
192	}
193}
194
195func (s *Service) buildContainerConfig(containerDomain, image, actualImageRef string, exposedPorts []int, envVars []string, volumes map[string]string, networkName string, existing *domain.Container) *domain.ContainerConfig {
196	containerName := s.deploymentContainerName(containerDomain, existing)
197
198	return &domain.ContainerConfig{
199		Image:       actualImageRef,
200		Name:        containerName,
201		Ports:       exposedPorts,
202		Env:         envVars,
203		Volumes:     volumes,
204		NetworkMode: networkName,
205		Hostname:    containerDomain,
206		Labels: map[string]string{
207			"gordon.domain":  containerDomain,
208			"gordon.image":   image,
209			"gordon.managed": "true",
210			"gordon.route":   containerDomain,
211		},
212		AutoRemove: false,
213	}
214}
215
216// Deploy creates and starts a container for the given route.
217// Implements zero-downtime deployment: new container starts before old one stops.
218func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Container, error) {
219	// Serialize deploys for the same domain to prevent race conditions
220	// (e.g. multiple image.pushed events + explicit deploy call from CLI).
221	unlock, err := s.acquireDomainDeployLock(ctx, route.Domain)
222	if err != nil {
223		return nil, err
224	}
225	defer unlock()
226
227	// Enrich context with use case fields for all downstream logs
228	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
229		zerowrap.FieldLayer:   "usecase",
230		zerowrap.FieldUseCase: "Deploy",
231		"domain":              route.Domain,
232	})
233	log := zerowrap.FromCtx(ctx)
234
235	existing, hasExisting := s.getTrackedContainer(route.Domain)
236
237	resources, err := s.prepareDeployResources(ctx, route, existing)
238	if err != nil {
239		return nil, err
240	}
241
242	newContainer, err := s.createStartedContainer(ctx, route, existing, resources)
243	if err != nil {
244		return nil, err
245	}
246
247	invalidated := s.activateDeployedContainer(ctx, route.Domain, newContainer)
248	s.finalizePreviousContainer(ctx, route.Domain, existing, hasExisting, invalidated, newContainer.ID)
249
250	// Start container log collection (non-blocking, errors don't fail deployment)
251	s.startLogCollection(ctx, newContainer.ID, route.Domain)
252
253	log.Info().
254		Str("image", route.Image).
255		Str(zerowrap.FieldEntityID, newContainer.ID).
256		Ints("ports", newContainer.Ports).
257		Str("network", resources.networkName).
258		Bool("zero_downtime", hasExisting).
259		Msg("container deployed successfully")
260
261	return newContainer, nil
262}
263
264type deployResources struct {
265	networkName    string
266	actualImageRef string
267	exposedPorts   []int
268	envVars        []string
269	volumes        map[string]string
270}
271
272func (s *Service) getTrackedContainer(domainName string) (*domain.Container, bool) {
273	s.mu.RLock()
274	container, ok := s.containers[domainName]
275	s.mu.RUnlock()
276	return container, ok
277}
278
279func (s *Service) prepareDeployResources(ctx context.Context, route domain.Route, existing *domain.Container) (*deployResources, error) {
280	log := zerowrap.FromCtx(ctx)
281
282	existingID := ""
283	if existing != nil {
284		existingID = existing.ID
285	}
286	if err := s.cleanupOrphanedContainers(ctx, route.Domain, existingID); err != nil {
287		log.WrapErr(err, "failed to cleanup orphaned containers")
288	}
289
290	imageRef := s.buildImageRef(route.Image)
291	actualImageRef, err := s.ensureImage(ctx, imageRef)
292	if err != nil {
293		return nil, err
294	}
295
296	networkName := s.getNetworkForApp(route.Domain)
297	if err := s.createNetworkIfNeeded(ctx, networkName); err != nil {
298		return nil, log.WrapErr(err, "failed to create network")
299	}
300	if err := s.deployAttachments(ctx, route.Domain, networkName); err != nil {
301		return nil, log.WrapErr(err, "failed to deploy attachments")
302	}
303
304	exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, actualImageRef)
305	if err != nil {
306		log.WrapErr(err, "failed to get exposed ports, using defaults")
307		exposedPorts = []int{80, 8080, 3000}
308	}
309
310	envVars, err := s.loadEnvironment(ctx, route.Domain, actualImageRef)
311	if err != nil {
312		return nil, err
313	}
314
315	volumes, err := s.setupVolumes(ctx, route.Domain, actualImageRef)
316	if err != nil {
317		return nil, err
318	}
319
320	return &deployResources{
321		networkName:    networkName,
322		actualImageRef: actualImageRef,
323		exposedPorts:   exposedPorts,
324		envVars:        envVars,
325		volumes:        volumes,
326	}, nil
327}
328
329func (s *Service) createStartedContainer(ctx context.Context, route domain.Route, existing *domain.Container, resources *deployResources) (*domain.Container, error) {
330	log := zerowrap.FromCtx(ctx)
331
332	containerConfig := s.buildContainerConfig(
333		route.Domain,
334		route.Image,
335		resources.actualImageRef,
336		resources.exposedPorts,
337		resources.envVars,
338		resources.volumes,
339		resources.networkName,
340		existing,
341	)
342
343	newContainer, err := s.runtime.CreateContainer(ctx, containerConfig)
344	if err != nil {
345		return nil, log.WrapErr(err, "failed to create container")
346	}
347	if err := s.runtime.StartContainer(ctx, newContainer.ID); err != nil {
348		s.runtime.RemoveContainer(ctx, newContainer.ID, true)
349		return nil, log.WrapErr(err, "failed to start container")
350	}
351	if !domain.IsSkipReadiness(ctx) {
352		if err := s.waitForReady(ctx, newContainer.ID); err != nil {
353			s.cleanupFailedContainer(ctx, newContainer.ID)
354			return nil, log.WrapErr(err, "container failed readiness check")
355		}
356	}
357
358	inspected, err := s.runtime.InspectContainer(ctx, newContainer.ID)
359	if err != nil {
360		s.cleanupFailedContainer(ctx, newContainer.ID)
361		return nil, log.WrapErr(err, "failed to inspect started container")
362	}
363
364	return inspected, nil
365}
366
367func (s *Service) activateDeployedContainer(ctx context.Context, domainName string, container *domain.Container) bool {
368	s.mu.Lock()
369	s.containers[domainName] = container
370	s.mu.Unlock()
371
372	s.publishContainerDeployed(ctx, domainName, container.ID)
373
374	s.mu.RLock()
375	inv := s.cacheInvalidator
376	s.mu.RUnlock()
377	if inv != nil {
378		inv.InvalidateTarget(ctx, domainName)
379		return true
380	}
381
382	return false
383}
384
385func (s *Service) finalizePreviousContainer(ctx context.Context, domainName string, existing *domain.Container, hasExisting, invalidated bool, newContainerID string) {
386	if !hasExisting {
387		return
388	}
389	log := zerowrap.FromCtx(ctx)
390
391	if invalidated {
392		s.waitForDrain(ctx, existing.ID)
393	}
394
395	if err := ctx.Err(); err != nil {
396		log.Debug().Err(err).Str("domain", domainName).Str("new_container_id", newContainerID).Msg("skipping old container cleanup due to canceled context")
397		return
398	}
399
400	s.cleanupOldContainer(ctx, existing, newContainerID, domainName)
401}
402
403func (s *Service) waitForDrain(ctx context.Context, oldContainerID string) {
404	log := zerowrap.FromCtx(ctx)
405
406	s.mu.RLock()
407	cfg := s.config
408	waiter := s.drainWaiter
409	s.mu.RUnlock()
410
411	drainMode := cfg.DrainMode
412	if drainMode == "" {
413		drainMode = "delay"
414	}
415
416	shouldUseInFlight := (drainMode == "inflight" || drainMode == "auto") && waiter != nil
417	if shouldUseInFlight {
418		timeout := cfg.DrainTimeout
419		if timeout == 0 {
420			timeout = 30 * time.Second
421		}
422		drained := waiter.WaitForNoInFlight(ctx, oldContainerID, timeout)
423		if !drained {
424			log.Warn().
425				Str("old_container_id", oldContainerID).
426				Dur("drain_timeout", timeout).
427				Msg("drain wait timed out; old container may still have in-flight traffic")
428		}
429		return
430	}
431
432	drainDelay := 2 * time.Second
433	if cfg.DrainDelayConfigured {
434		drainDelay = cfg.DrainDelay
435	}
436	if drainDelay <= 0 {
437		return
438	}
439	select {
440	case <-time.After(drainDelay):
441	case <-ctx.Done():
442	}
443}
444
445// Restart restarts a running container for the given domain.
446func (s *Service) Restart(ctx context.Context, domainName string, withAttachments bool) error {
447	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
448		zerowrap.FieldLayer:   "usecase",
449		zerowrap.FieldUseCase: "Restart",
450		"domain":              domainName,
451	})
452	log := zerowrap.FromCtx(ctx)
453
454	s.mu.RLock()
455	container, exists := s.containers[domainName]
456	attachments := s.attachments[domainName]
457	var attachmentIDs []string
458	if len(attachments) > 0 {
459		attachmentIDs = make([]string, len(attachments))
460		copy(attachmentIDs, attachments)
461	}
462	s.mu.RUnlock()
463
464	if !exists || container == nil {
465		return domain.ErrContainerNotFound
466	}
467
468	// Restart the main container
469	if err := s.runtime.RestartContainer(ctx, container.ID); err != nil {
470		if !isContainerNotFoundError(err) {
471			return log.WrapErr(err, "failed to restart container")
472		}
473
474		// In-memory container ID can become stale after external runtime changes.
475		// Re-sync and retry once with the latest tracked container.
476		log.Warn().
477			Err(err).
478			Str(zerowrap.FieldEntityID, container.ID).
479			Msg("tracked container missing during restart, attempting state reconciliation")
480
481		if syncErr := s.SyncContainers(ctx); syncErr != nil {
482			log.Warn().Err(syncErr).Msg("failed to sync container state during restart recovery")
483		}
484
485		s.mu.RLock()
486		refreshed, refreshedExists := s.containers[domainName]
487		attachmentIDs = append([]string{}, s.attachments[domainName]...)
488		s.mu.RUnlock()
489
490		if !refreshedExists || refreshed == nil {
491			return domain.ErrContainerNotFound
492		}
493		if err := s.runtime.RestartContainer(ctx, refreshed.ID); err != nil {
494			return log.WrapErr(err, "failed to restart container after state reconciliation")
495		}
496		container = refreshed
497	}
498	log.Info().Str(zerowrap.FieldEntityID, container.ID).Msg("container restarted")
499
500	// Restart attachments if requested
501	if withAttachments && len(attachmentIDs) > 0 {
502		for _, attachID := range attachmentIDs {
503			if err := s.runtime.RestartContainer(ctx, attachID); err != nil {
504				log.Warn().Err(err).Str("attachment_id", attachID).Msg("failed to restart attachment")
505				continue // Don't fail the whole operation for one attachment
506			}
507			log.Info().Str("attachment_id", attachID).Msg("attachment restarted")
508		}
509	}
510
511	return nil
512}
513
514// Stop stops a running container.
515func (s *Service) Stop(ctx context.Context, containerID string) error {
516	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
517		zerowrap.FieldLayer:    "usecase",
518		zerowrap.FieldUseCase:  "Stop",
519		zerowrap.FieldEntityID: containerID,
520	})
521	log := zerowrap.FromCtx(ctx)
522
523	// Stop log collection before stopping container
524	if s.logWriter != nil {
525		if err := s.logWriter.StopLogging(containerID); err != nil {
526			log.Warn().Err(err).Msg("failed to stop container log collection")
527		}
528	}
529
530	if err := s.runtime.StopContainer(ctx, containerID); err != nil {
531		return log.WrapErr(err, "failed to stop container")
532	}
533
534	log.Info().Msg("container stopped")
535	return nil
536}
537
538// Remove removes a container.
539func (s *Service) Remove(ctx context.Context, containerID string, force bool) error {
540	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
541		zerowrap.FieldLayer:    "usecase",
542		zerowrap.FieldUseCase:  "Remove",
543		zerowrap.FieldEntityID: containerID,
544	})
545	log := zerowrap.FromCtx(ctx)
546
547	// Stop log collection before removing container
548	if s.logWriter != nil {
549		if err := s.logWriter.StopLogging(containerID); err != nil {
550			log.Warn().Err(err).Msg("failed to stop container log collection")
551		}
552	}
553
554	// Find domain and attachment IDs for cleanup
555	containerDomain, attachmentIDs, cfg := s.findContainerContext(containerID)
556
557	if err := s.runtime.RemoveContainer(ctx, containerID, force); err != nil {
558		return log.WrapErr(err, "failed to remove container")
559	}
560
561	// Cleanup volumes
562	if containerDomain != "" && !cfg.VolumePreserve {
563		if err := s.cleanupVolumesForDomain(ctx, containerDomain); err != nil {
564			log.WrapErrWithFields(err, "failed to cleanup volumes", map[string]any{"domain": containerDomain})
565		}
566	}
567
568	// Clean up attachment containers
569	s.removeAttachments(ctx, attachmentIDs)
570
571	// Remove from tracking
572	s.mu.Lock()
573	var removedDomain string
574	for d, c := range s.containers {
575		if c.ID == containerID {
576			delete(s.containers, d)
577			removedDomain = d
578			log.Info().Str("domain", d).Msg("container removed")
579			break
580		}
581	}
582	if removedDomain != "" {
583		delete(s.attachments, removedDomain)
584	}
585	s.mu.Unlock()
586
587	// Note: We intentionally do NOT clean up deployMu entries to avoid a race condition:
588	// If Remove deletes the mutex entry while a concurrent Deploy is acquiring the lock,
589	// the Deploy would create a fresh mutex, breaking serialization and allowing concurrent
590	// deploys to the same domain. The memory footprint is acceptable (one small struct per
591	// domain ever deployed), and this is the safer choice for correctness.
592
593	// Cleanup network
594	if removedDomain != "" && cfg.NetworkIsolation {
595		networkName := s.getNetworkForApp(removedDomain)
596		if err := s.cleanupNetworkIfEmpty(ctx, networkName); err != nil {
597			log.WrapErrWithFields(err, "failed to cleanup network", map[string]any{"network": networkName})
598		}
599	}
600
601	return nil
602}
603
604// findContainerContext looks up the domain, attachment IDs, and config snapshot for a container.
605func (s *Service) findContainerContext(containerID string) (string, []string, Config) {
606	s.mu.RLock()
607	defer s.mu.RUnlock()
608	cfg := s.config
609	var containerDomain string
610	for d, c := range s.containers {
611		if c.ID == containerID {
612			containerDomain = d
613			break
614		}
615	}
616	var attachmentIDs []string
617	if containerDomain != "" {
618		attachmentIDs = append(attachmentIDs, s.attachments[containerDomain]...)
619	}
620	return containerDomain, attachmentIDs, cfg
621}
622
623// removeAttachments stops and removes attachment containers best-effort.
624func (s *Service) removeAttachments(ctx context.Context, attachmentIDs []string) {
625	log := zerowrap.FromCtx(ctx)
626	for _, attachID := range attachmentIDs {
627		if s.logWriter != nil {
628			if err := s.logWriter.StopLogging(attachID); err != nil {
629				log.Warn().Err(err).Str("attachment_id", attachID).Msg("failed to stop attachment log collection")
630			}
631		}
632		if err := s.runtime.StopContainer(ctx, attachID); err != nil {
633			log.Warn().Err(err).Str("attachment_id", attachID).Msg("failed to stop attachment container")
634		}
635		if err := s.runtime.RemoveContainer(ctx, attachID, true); err != nil {
636			log.Warn().Err(err).Str("attachment_id", attachID).Msg("failed to remove attachment container")
637		}
638	}
639}
640
641// Get retrieves a container by domain.
642func (s *Service) Get(_ context.Context, domainName string) (*domain.Container, bool) {
643	s.mu.RLock()
644	defer s.mu.RUnlock()
645	container, exists := s.containers[domainName]
646	return container, exists
647}
648
649// List returns all managed containers.
650func (s *Service) List(_ context.Context) map[string]*domain.Container {
651	s.mu.RLock()
652	defer s.mu.RUnlock()
653
654	result := make(map[string]*domain.Container, len(s.containers))
655	maps.Copy(result, s.containers)
656	return result
657}
658
659// ListRoutesWithDetails returns routes with network and attachment info.
660// Note: Container map is copied under lock, then external calls are made without lock.
661// If containers are removed between copy and runtime calls, errors are handled gracefully
662// (network becomes empty string). This trade-off avoids holding locks during I/O.
663func (s *Service) ListRoutesWithDetails(ctx context.Context) []domain.RouteInfo {
664	s.mu.RLock()
665	containers := make(map[string]*domain.Container, len(s.containers))
666	maps.Copy(containers, s.containers)
667	s.mu.RUnlock()
668
669	// Fetch all attachments once to avoid N+1 queries
670	attachmentsByDomain := s.getAllAttachments(ctx)
671
672	results := make([]domain.RouteInfo, 0, len(containers))
673	for domainName, container := range containers {
674		network := ""
675		image := ""
676		containerID := ""
677		status := ""
678		if container != nil {
679			containerID = container.ID
680			status = container.Status
681			image = container.Image
682			if container.Labels != nil {
683				if labelImage, ok := container.Labels[domain.LabelImage]; ok && labelImage != "" {
684					image = labelImage
685				}
686			}
687			// Strip registry domain prefix from image for cleaner display
688			image = s.stripRegistryPrefix(image)
689			if networkName, err := s.runtime.GetContainerNetwork(ctx, container.ID); err == nil {
690				network = networkName
691			}
692		}
693
694		results = append(results, domain.RouteInfo{
695			Domain:          domainName,
696			Image:           image,
697			ContainerID:     containerID,
698			ContainerStatus: status,
699			Network:         network,
700			Attachments:     attachmentsByDomain[domainName],
701		})
702	}
703
704	return results
705}
706
707// stripRegistryPrefix removes the configured registry domain prefix from an image reference.
708// For example, "reg.example.com/myapp:latest" becomes "myapp:latest" if registry domain is "reg.example.com".
709func (s *Service) stripRegistryPrefix(image string) string {
710	s.mu.RLock()
711	cfg := s.config
712	s.mu.RUnlock()
713
714	if cfg.RegistryDomain == "" {
715		return image
716	}
717	prefix := strings.TrimSuffix(cfg.RegistryDomain, "/") + "/"
718	if strings.HasPrefix(image, prefix) {
719		return strings.TrimPrefix(image, prefix)
720	}
721	return image
722}
723
724// ListAttachments returns attachments for a domain.
725func (s *Service) ListAttachments(ctx context.Context, domainName string) []domain.Attachment {
726	return s.getAttachmentsForDomain(ctx, domainName)
727}
728
729// ListNetworks returns Gordon-managed networks.
730func (s *Service) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) {
731	s.mu.RLock()
732	cfg := s.config
733	s.mu.RUnlock()
734
735	networks, err := s.runtime.ListNetworks(ctx)
736	if err != nil {
737		return nil, err
738	}
739
740	var filtered []*domain.NetworkInfo
741	for _, network := range networks {
742		if strings.HasPrefix(network.Name, cfg.NetworkPrefix+"-") {
743			filtered = append(filtered, network)
744		}
745	}
746
747	return filtered, nil
748}
749
750// HealthCheck performs health checks on all containers.
751func (s *Service) HealthCheck(ctx context.Context) map[string]bool {
752	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
753		zerowrap.FieldLayer:   "usecase",
754		zerowrap.FieldUseCase: "HealthCheck",
755	})
756	log := zerowrap.FromCtx(ctx)
757
758	// Copy container IDs under lock, then release before making Docker API calls
759	s.mu.RLock()
760	snapshot := make(map[string]string, len(s.containers))
761	for d, c := range s.containers {
762		snapshot[d] = c.ID
763	}
764	s.mu.RUnlock()
765
766	health := make(map[string]bool, len(snapshot))
767	for d, id := range snapshot {
768		running, err := s.runtime.IsContainerRunning(ctx, id)
769		if err != nil {
770			log.WrapErrWithFields(err, "health check failed", map[string]any{"domain": d, zerowrap.FieldEntityID: id})
771			health[d] = false
772		} else {
773			health[d] = running
774		}
775	}
776	return health
777}
778
779// SyncContainers synchronizes containers with runtime state.
780func (s *Service) SyncContainers(ctx context.Context) error {
781	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
782		zerowrap.FieldLayer:   "usecase",
783		zerowrap.FieldUseCase: "SyncContainers",
784	})
785	log := zerowrap.FromCtx(ctx)
786
787	// List containers without holding lock to avoid blocking during Docker API call
788	allContainers, err := s.runtime.ListContainers(ctx, false)
789	if err != nil {
790		return log.WrapErr(err, "failed to list containers")
791	}
792
793	managed := make(map[string]*domain.Container)
794	for _, c := range allContainers {
795		if c.Labels != nil {
796			if d, ok := c.Labels["gordon.domain"]; ok && c.Labels["gordon.managed"] == "true" {
797				managed[d] = c
798			}
799		}
800	}
801
802	s.mu.Lock()
803	s.containers = managed
804	s.mu.Unlock()
805
806	log.Info().Int(zerowrap.FieldCount, len(managed)).Msg("container state synchronized")
807	return nil
808}
809
810// AutoStart starts containers for the provided routes that aren't running.
811// It skips readiness checks to avoid blocking boot; the background monitor
812// handles crash recovery.
813func (s *Service) AutoStart(ctx context.Context, routes []domain.Route) error {
814	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
815		zerowrap.FieldLayer:   "usecase",
816		zerowrap.FieldUseCase: "AutoStart",
817	})
818	log := zerowrap.FromCtx(ctx)
819
820	log.Info().Int("route_count", len(routes)).Msg("auto-starting containers for configured routes")
821
822	// Filter out routes that already have a running container.
823	var pending []domain.Route
824	skipped := 0
825	for _, route := range routes {
826		if _, exists := s.Get(ctx, route.Domain); exists {
827			log.Debug().Str("domain", route.Domain).Msg("container already running, skipping")
828			skipped++
829		} else {
830			pending = append(pending, route)
831		}
832	}
833
834	if len(pending) == 0 {
835		log.Info().Int("skipped", skipped).Msg("auto-start completed, all containers already running")
836		return nil
837	}
838
839	// Deploy all pending routes concurrently with readiness checks skipped.
840	deployCtx := domain.WithSkipReadiness(ctx)
841
842	type result struct {
843		route domain.Route
844		err   error
845	}
846	results := make(chan result, len(pending))
847	sem := make(chan struct{}, 4) // limit concurrency
848
849	for _, route := range pending {
850		sem <- struct{}{}
851		go func(r domain.Route) {
852			defer func() { <-sem }()
853
854			log.Info().Str("domain", r.Domain).Str("image", r.Image).Msg("auto-starting container for route")
855
856			if _, err := s.Deploy(deployCtx, r); err != nil {
857				results <- result{route: r, err: err}
858				return
859			}
860			results <- result{route: r}
861		}(route)
862	}
863
864	var started, errCount int
865	for i := 0; i < len(pending); i++ {
866		res := <-results
867		if res.err != nil {
868			log.Warn().Err(res.err).Str("domain", res.route.Domain).Msg("failed to auto-start container")
869			errCount++
870		} else {
871			started++
872		}
873	}
874
875	log.Info().
876		Int("started", started).
877		Int("skipped", skipped).
878		Int("errors", errCount).
879		Msg("auto-start completed")
880
881	if errCount > 0 {
882		return fmt.Errorf("auto-start completed with %d errors", errCount)
883	}
884	return nil
885}
886
887// Shutdown gracefully shuts down all managed containers.
888func (s *Service) Shutdown(ctx context.Context) error {
889	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
890		zerowrap.FieldLayer:   "usecase",
891		zerowrap.FieldUseCase: "Shutdown",
892	})
893	log := zerowrap.FromCtx(ctx)
894	log.Info().Msg("shutting down container manager...")
895
896	// Containers are left running across Gordon restarts.
897	// SyncContainers + AutoStart will pick them back up on next boot.
898
899	// Close log writer to stop all log collection
900	if s.logWriter != nil {
901		if err := s.logWriter.Close(); err != nil {
902			log.Warn().Err(err).Msg("failed to close container log writer")
903		}
904	}
905
906	log.Info().Msg("container manager shutdown complete")
907	return nil
908}
909
910// StartMonitor begins background monitoring of tracked containers.
911// The monitor restarts crashed containers and detects crash loops.
912// Safe to call multiple times; subsequent calls are no-ops.
913func (s *Service) StartMonitor(ctx context.Context) {
914	s.mu.Lock()
915	if s.monitor != nil {
916		s.mu.Unlock()
917		return
918	}
919	s.monitor = newMonitor(s)
920	m := s.monitor
921	m.Start(ctx)
922	s.mu.Unlock()
923}
924
925// StopMonitor stops the background container monitor.
926// Safe to call multiple times; subsequent calls are no-ops.
927func (s *Service) StopMonitor() {
928	s.mu.Lock()
929	m := s.monitor
930	s.monitor = nil
931	s.mu.Unlock()
932	if m != nil {
933		m.Stop()
934	}
935}
936
937// UpdateConfig updates the service configuration.
938func (s *Service) UpdateConfig(config Config) {
939	s.mu.Lock()
940	s.config = config
941	s.mu.Unlock()
942}
943
944// Helper methods
945
946// cleanupFailedContainer stops and removes a container that failed to start properly.
947func (s *Service) cleanupFailedContainer(ctx context.Context, containerID string) {
948	log := zerowrap.FromCtx(ctx)
949	if err := s.runtime.StopContainer(ctx, containerID); err != nil {
950		log.Warn().Err(err).Str(zerowrap.FieldEntityID, containerID).Msg("failed to stop container after failure")
951	}
952	if err := s.runtime.RemoveContainer(ctx, containerID, true); err != nil {
953		log.Warn().Err(err).Str(zerowrap.FieldEntityID, containerID).Msg("failed to remove container after failure")
954	}
955}
956
957// cleanupOldContainer stops and removes an old container after zero-downtime switch.
958// It also renames the new container to the canonical name.
959func (s *Service) cleanupOldContainer(ctx context.Context, old *domain.Container, newContainerID, domainName string) {
960	log := zerowrap.FromCtx(ctx)
961	log.Info().Str(zerowrap.FieldEntityID, old.ID).Msg("stopping old container after zero-downtime switch")
962
963	if s.logWriter != nil {
964		if err := s.logWriter.StopLogging(old.ID); err != nil {
965			log.Warn().Err(err).Str(zerowrap.FieldEntityID, old.ID).Msg("failed to stop logging for old container")
966		}
967	}
968
969	if err := s.runtime.StopContainer(ctx, old.ID); err != nil {
970		log.Warn().Err(err).Str(zerowrap.FieldEntityID, old.ID).Msg("failed to stop old container")
971	}
972
973	if err := s.runtime.RemoveContainer(ctx, old.ID, true); err != nil {
974		log.Warn().Err(err).Str(zerowrap.FieldEntityID, old.ID).Msg("failed to remove old container")
975	}
976
977	// Rename new container to canonical name
978	canonicalName := fmt.Sprintf("gordon-%s", domainName)
979	if err := s.runtime.RenameContainer(ctx, newContainerID, canonicalName); err != nil {
980		log.Warn().Err(err).Str("canonical_name", canonicalName).Msg("failed to rename container to canonical name")
981	}
982}
983
984// startLogCollection starts log collection for a container in the background.
985// Errors are logged but don't fail the calling operation.
986func (s *Service) startLogCollection(ctx context.Context, containerID, domainName string) {
987	if s.logWriter == nil {
988		return
989	}
990
991	log := zerowrap.FromCtx(ctx)
992
993	logStream, err := s.runtime.GetContainerLogs(ctx, containerID, true)
994	if err != nil {
995		log.Warn().Err(err).Msg("failed to get container logs for collection")
996		return
997	}
998
999	if err := s.logWriter.StartLogging(ctx, containerID, domainName, logStream); err != nil {
1000		log.Warn().Err(err).Msg("failed to start container log collection")
1001		logStream.Close()
1002	}
1003}
1004
1005func (s *Service) buildImageRef(image string) string {
1006	s.mu.RLock()
1007	cfg := s.config
1008	s.mu.RUnlock()
1009
1010	if !cfg.RegistryAuthEnabled || cfg.RegistryDomain == "" {
1011		return image
1012	}
1013
1014	// Normalize registry domain by trimming any trailing slash
1015	reg := strings.TrimSuffix(cfg.RegistryDomain, "/")
1016
1017	// Check if image already has the registry domain prefix
1018	prefix := reg + "/"
1019	if strings.HasPrefix(image, prefix) {
1020		return image
1021	}
1022
1023	// Check if image already has an explicit registry (host:port/ or host.domain/).
1024	// We need to detect patterns like:
1025	// - "docker.io/library/nginx:latest" (has dot and slash)
1026	// - "localhost:5001/myapp:latest" (has host:port/ pattern)
1027	// - "myregistry:5000/app:v1" (has host:port/ pattern)
1028	// - "[fd00::1]:5000/app:v1" (IPv6 with port)
1029	if hasExplicitRegistry(image) {
1030		return image
1031	}
1032
1033	return fmt.Sprintf("%s/%s", reg, image)
1034}
1035
1036// hasExplicitRegistry checks if an image reference already includes an explicit registry.
1037// Returns true for patterns like "docker.io/image", "localhost:5000/image", "localhost/image",
1038// "registry:8080/image", "[fd00::1]/image", "[fd00::1]:5000/image".
1039func hasExplicitRegistry(image string) bool {
1040	// Find the first slash which separates registry from image name
1041	slashIdx := strings.Index(image, "/")
1042	if slashIdx == -1 {
1043		return false // No slash means no registry prefix (e.g., "myapp:latest")
1044	}
1045
1046	// Extract the part before the first slash (potential registry)
1047	registryPart := image[:slashIdx]
1048
1049	// Check for bracketed IPv6 address (e.g., "[fd00::1]" or "[fd00::1]:5000")
1050	if strings.HasPrefix(registryPart, "[") {
1051		// Look for closing bracket
1052		if closeBracket := strings.Index(registryPart, "]"); closeBracket != -1 {
1053			// Valid bracketed IPv6: either ends at ] or has ]:port
1054			return true
1055		}
1056	}
1057
1058	// Check for localhost (with or without port)
1059	if registryPart == "localhost" {
1060		return true
1061	}
1062
1063	// Check for host:port pattern (e.g., "localhost:5000", "registry:8080")
1064	if colonIdx := strings.LastIndex(registryPart, ":"); colonIdx != -1 {
1065		port := registryPart[colonIdx+1:]
1066		// If everything after colon is digits, it's a port
1067		if len(port) > 0 && isNumeric(port) {
1068			return true
1069		}
1070	}
1071
1072	// Check for domain pattern (has a dot, e.g., "docker.io", "gcr.io")
1073	if strings.Contains(registryPart, ".") {
1074		return true
1075	}
1076
1077	return false
1078}
1079
1080// isNumeric checks if a string contains only digits.
1081func isNumeric(s string) bool {
1082	for _, c := range s {
1083		if c < '0' || c > '9' {
1084			return false
1085		}
1086	}
1087	return true
1088}
1089
1090func normalizePullPolicy(policy string) string {
1091	switch strings.ToLower(strings.TrimSpace(policy)) {
1092	case PullPolicyAlways:
1093		return PullPolicyAlways
1094	case PullPolicyIfTagChanged:
1095		return PullPolicyIfTagChanged
1096	case PullPolicyIfNotPresent:
1097		return PullPolicyIfNotPresent
1098	default:
1099		return PullPolicyIfNotPresent
1100	}
1101}
1102
1103func isDigestRef(imageRef string) bool {
1104	return strings.Contains(imageRef, "@sha256:")
1105}
1106
1107func (s *Service) pullRefForDeploy(ctx context.Context, imageRef string) (string, bool) {
1108	if !domain.IsInternalDeploy(ctx) {
1109		return imageRef, false
1110	}
1111	s.mu.RLock()
1112	cfg := s.config
1113	s.mu.RUnlock()
1114	return rewriteToLocalRegistry(imageRef, cfg.RegistryDomain, cfg.RegistryPort), true
1115}
1116
1117// ensureImage ensures the image is available locally, pulling if needed.
1118// Returns the image reference to use for container operations:
1119//   - For digest references (@sha256:...), returns the pullRef since Docker can't tag digests
1120//   - For tagged images, returns the original imageRef after tagging the pulled image
1121func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, error) {
1122	ctx = zerowrap.CtxWithField(ctx, "image", imageRef)
1123	log := zerowrap.FromCtx(ctx)
1124
1125	// Determine if this is an internal deploy and what reference to use for pulls.
1126	pullRef, isInternal := s.pullRefForDeploy(ctx, imageRef)
1127	if pullRef != imageRef {
1128		log.Info().
1129			Str("original_ref", imageRef).
1130			Str("pull_ref", pullRef).
1131			Msg("internal deploy: using localhost registry for pull")
1132	}
1133
1134	found, err := s.ensureLocalImage(ctx, imageRef, pullRef)
1135	if err != nil {
1136		return "", err
1137	}
1138	if found {
1139		return imageRef, nil
1140	}
1141
1142	// Pull image
1143	log.Info().Msg("pulling image from registry")
1144
1145	if err := s.pullImage(ctx, pullRef, isInternal); err != nil {
1146		return "", err
1147	}
1148
1149	// For digest references, we can't create a tag (Docker doesn't allow it).
1150	// In this case, use the pullRef directly since the image is available under that reference.
1151	if strings.Contains(imageRef, "@sha256:") {
1152		log.Info().
1153			Str("pull_ref", pullRef).
1154			Msg("digest reference: using pull reference for container operations")
1155		return pullRef, nil
1156	}
1157
1158	if err := s.tagImageIfNeeded(ctx, pullRef, imageRef); err != nil {
1159		return "", err
1160	}
1161
1162	// Clean up the temporary pull reference tag to avoid duplicate entries
1163	if pullRef != imageRef {
1164		if err := s.runtime.UntagImage(ctx, pullRef); err != nil {
1165			// Log but don't fail - the canonical tag is already applied
1166			log.Debug().Err(err).Str("pull_ref", pullRef).Msg("failed to remove temporary pull tag")
1167		}
1168	}
1169
1170	log.Info().Msg("image pulled successfully")
1171	return imageRef, nil
1172}
1173
1174func (s *Service) ensureLocalImage(ctx context.Context, imageRef, pullRef string) (bool, error) {
1175	log := zerowrap.FromCtx(ctx)
1176
1177	// For internal deploys (image push events), always pull fresh image
1178	// because the same tag may reference new content (e.g., latest tag updated)
1179	if domain.IsInternalDeploy(ctx) {
1180		log.Info().Msg("internal deploy detected, forcing image pull to ensure latest content")
1181		return false, nil
1182	}
1183
1184	s.mu.RLock()
1185	cfg := s.config
1186	s.mu.RUnlock()
1187
1188	pullPolicy := normalizePullPolicy(cfg.PullPolicy)
1189	switch pullPolicy {
1190	case PullPolicyAlways:
1191		log.Info().Str("pull_policy", pullPolicy).Msg("pull policy forces image pull")
1192		return false, nil
1193	case PullPolicyIfTagChanged:
1194		if !isDigestRef(imageRef) {
1195			log.Info().Str("pull_policy", pullPolicy).Msg("tag reference detected, pulling to check for updates")
1196			return false, nil
1197		}
1198	}
1199
1200	localImages, err := s.runtime.ListImages(ctx)
1201	if err != nil {
1202		log.WrapErr(err, "failed to list local images, will attempt pull")
1203		return false, nil
1204	}
1205
1206	normalizedRef := normalizeImageRef(imageRef)
1207	normalizedPullRef := normalizeImageRef(pullRef)
1208	for _, img := range localImages {
1209		normalizedImage := normalizeImageRef(img)
1210		if normalizedImage == normalizedRef {
1211			log.Info().Msg("image found locally, skipping pull")
1212			return true, nil
1213		}
1214		if normalizedImage == normalizedPullRef {
1215			if err := s.tagImageIfNeeded(ctx, pullRef, imageRef); err != nil {
1216				return false, err
1217			}
1218			// Clean up the temporary pull reference tag
1219			if pullRef != imageRef {
1220				if err := s.runtime.UntagImage(ctx, pullRef); err != nil {
1221					log.Debug().Err(err).Str("pull_ref", pullRef).Msg("failed to remove temporary pull tag")
1222				}
1223			}
1224			log.Info().Msg("image found locally, skipping pull")
1225			return true, nil
1226		}
1227	}
1228
1229	return false, nil
1230}
1231
1232func (s *Service) pullImage(ctx context.Context, pullRef string, isInternal bool) error {
1233	s.mu.RLock()
1234	cfg := s.config
1235	s.mu.RUnlock()
1236
1237	log := zerowrap.FromCtx(ctx)
1238
1239	switch {
1240	case isInternal && cfg.RegistryAuthEnabled:
1241		if cfg.InternalRegistryUsername == "" || cfg.InternalRegistryPassword == "" {
1242			return log.WrapErr(fmt.Errorf("internal registry auth not configured"), "failed to pull image for internal deploy")
1243		}
1244		if err := s.pullImageWithRetry(ctx, func(ctx context.Context) error {
1245			return s.runtime.PullImageWithAuth(ctx, pullRef, cfg.InternalRegistryUsername, cfg.InternalRegistryPassword)
1246		}, isInternal); err != nil {
1247			return log.WrapErr(err, "failed to pull image with internal auth")
1248		}
1249	case isInternal:
1250		if err := s.pullImageWithRetry(ctx, func(ctx context.Context) error {
1251			return s.runtime.PullImage(ctx, pullRef)
1252		}, isInternal); err != nil {
1253			return log.WrapErr(err, "failed to pull image")
1254		}
1255	case cfg.RegistryAuthEnabled:
1256		if cfg.ServiceTokenUsername == "" || cfg.ServiceToken == "" {
1257			return log.WrapErr(fmt.Errorf("registry service token not configured"), "failed to pull image for registry auth")
1258		}
1259		if err := s.runtime.PullImageWithAuth(ctx, pullRef, cfg.ServiceTokenUsername, cfg.ServiceToken); err != nil {
1260			return log.WrapErr(err, "failed to pull image with auth")
1261		}
1262	default:
1263		if err := s.runtime.PullImage(ctx, pullRef); err != nil {
1264			return log.WrapErr(err, "failed to pull image")
1265		}
1266	}
1267
1268	return nil
1269}
1270
1271func (s *Service) pullImageWithRetry(ctx context.Context, pullFn func(context.Context) error, isInternal bool) error {
1272	log := zerowrap.FromCtx(ctx)
1273
1274	for attempt := 1; attempt <= internalPullMaxAttempts; attempt++ {
1275		err := pullFn(ctx)
1276		if err == nil {
1277			return nil
1278		}
1279		if !isInternal || !isConnectionRefusedError(err) || attempt == internalPullMaxAttempts {
1280			return fmt.Errorf("internal image pull failed after %d attempts: %w", attempt, err)
1281		}
1282
1283		backoff := time.Duration(attempt) * time.Second
1284		log.Warn().
1285			Err(err).
1286			Int("attempt", attempt).
1287			Dur("backoff", backoff).
1288			Msg("internal image pull failed with connection refused, retrying")
1289
1290		select {
1291		case <-time.After(backoff):
1292		case <-ctx.Done():
1293			return ctx.Err()
1294		}
1295	}
1296	return nil
1297}
1298
1299func (s *Service) tagImageIfNeeded(ctx context.Context, sourceRef, targetRef string) error {
1300	if sourceRef == targetRef {
1301		return nil
1302	}
1303
1304	// Cannot create a tag with a digest reference - Docker/Podman doesn't allow it.
1305	// When using digest references (image@sha256:...), skip tagging as the image
1306	// is already available by its digest.
1307	if strings.Contains(targetRef, "@sha256:") {
1308		return nil
1309	}
1310
1311	log := zerowrap.FromCtx(ctx)
1312	if err := s.runtime.TagImage(ctx, sourceRef, targetRef); err != nil {
1313		return log.WrapErr(err, "failed to tag image from pull reference")
1314	}
1315	return nil
1316}
1317
1318func (s *Service) loadEnvironment(ctx context.Context, domainName, imageRef string) ([]string, error) {
1319	log := zerowrap.FromCtx(ctx)
1320
1321	userEnvVars, err := s.envLoader.LoadEnv(ctx, domainName)
1322	if err != nil {
1323		return nil, log.WrapErr(err, "failed to load environment variables")
1324	}
1325
1326	dockerfileEnvVars, err := s.runtime.InspectImageEnv(ctx, imageRef)
1327	if err != nil {
1328		log.WrapErr(err, "failed to inspect image environment")
1329		dockerfileEnvVars = []string{}
1330	}
1331
1332	return mergeEnvironmentVariables(dockerfileEnvVars, userEnvVars), nil
1333}
1334
1335func (s *Service) setupVolumes(ctx context.Context, domainName, imageRef string) (map[string]string, error) {
1336	s.mu.RLock()
1337	cfg := s.config
1338	s.mu.RUnlock()
1339
1340	log := zerowrap.FromCtx(ctx)
1341	volumes := make(map[string]string)
1342
1343	if !cfg.VolumeAutoCreate {
1344		return volumes, nil
1345	}
1346
1347	volumePaths, err := s.runtime.InspectImageVolumes(ctx, imageRef)
1348	if err != nil {
1349		log.WrapErr(err, "failed to inspect image volumes")
1350		return volumes, nil
1351	}
1352
1353	for _, path := range volumePaths {
1354		name := generateVolumeName(cfg.VolumePrefix, domainName, path)
1355
1356		exists, err := s.runtime.VolumeExists(ctx, name)
1357		if err != nil {
1358			log.WrapErrWithFields(err, "failed to check volume", map[string]any{"volume": name})
1359			continue
1360		}
1361
1362		if !exists {
1363			if err := s.runtime.CreateVolume(ctx, name); err != nil {
1364				log.WrapErrWithFields(err, "failed to create volume", map[string]any{"volume": name})
1365				continue
1366			}
1367			log.Info().Str("volume", name).Str(zerowrap.FieldPath, path).Msg("created volume")
1368		}
1369
1370		volumes[path] = name
1371	}
1372
1373	return volumes, nil
1374}
1375
1376func (s *Service) getNetworkForApp(domainName string) string {
1377	s.mu.RLock()
1378	cfg := s.config
1379	s.mu.RUnlock()
1380
1381	if !cfg.NetworkIsolation {
1382		return "bridge"
1383	}
1384
1385	for groupName, domains := range cfg.NetworkGroups {
1386		if slices.Contains(domains, domainName) {
1387			return s.generateNetworkName(groupName)
1388		}
1389	}
1390
1391	return s.generateNetworkName(domainName)
1392}
1393
1394func (s *Service) generateNetworkName(identifier string) string {
1395	s.mu.RLock()
1396	cfg := s.config
1397	s.mu.RUnlock()
1398
1399	return fmt.Sprintf("%s-%s", cfg.NetworkPrefix, strings.ReplaceAll(identifier, ".", "-"))
1400}
1401
1402func (s *Service) createNetworkIfNeeded(ctx context.Context, networkName string) error {
1403	if networkName == "bridge" || networkName == "default" {
1404		return nil
1405	}
1406
1407	ctx = zerowrap.CtxWithField(ctx, "network", networkName)
1408	log := zerowrap.FromCtx(ctx)
1409
1410	exists, err := s.runtime.NetworkExists(ctx, networkName)
1411	if err != nil {
1412		return log.WrapErr(err, "failed to check network existence")
1413	}
1414
1415	if !exists {
1416		if err := s.runtime.CreateNetwork(ctx, networkName, map[string]string{"driver": "bridge"}); err != nil {
1417			return log.WrapErr(err, "failed to create network")
1418		}
1419		log.Info().Msg("created network for app isolation")
1420	}
1421
1422	return nil
1423}
1424
1425func (s *Service) cleanupOrphanedContainers(ctx context.Context, domainName string, skipContainerID string) error {
1426	log := zerowrap.FromCtx(ctx)
1427	expectedName := fmt.Sprintf("gordon-%s", domainName)
1428	expectedNewName := expectedName + "-new"
1429	expectedNextName := expectedName + "-next"
1430
1431	allContainers, err := s.runtime.ListContainers(ctx, true)
1432	if err != nil {
1433		return err
1434	}
1435
1436	for _, c := range allContainers {
1437		if (c.Name == expectedName || c.Name == expectedNewName || c.Name == expectedNextName) && c.ID != skipContainerID {
1438			log.Info().Str(zerowrap.FieldEntityID, c.ID).Str(zerowrap.FieldStatus, c.Status).Msg("found orphaned container, removing")
1439
1440			if err := s.runtime.StopContainer(ctx, c.ID); err != nil {
1441				log.WrapErrWithFields(err, "failed to stop orphaned container", map[string]any{zerowrap.FieldEntityID: c.ID})
1442			}
1443
1444			if err := s.runtime.RemoveContainer(ctx, c.ID, true); err != nil {
1445				log.WrapErrWithFields(err, "failed to remove orphaned container", map[string]any{zerowrap.FieldEntityID: c.ID})
1446			}
1447		}
1448	}
1449
1450	return nil
1451}
1452
1453func (s *Service) cleanupVolumesForDomain(_ context.Context, _ string) error {
1454	return nil
1455}
1456
1457func (s *Service) cleanupNetworkIfEmpty(ctx context.Context, networkName string) error {
1458	if networkName == "bridge" || networkName == "default" {
1459		return nil
1460	}
1461
1462	ctx = zerowrap.CtxWithField(ctx, "network", networkName)
1463	log := zerowrap.FromCtx(ctx)
1464
1465	networks, err := s.runtime.ListNetworks(ctx)
1466	if err != nil {
1467		return log.WrapErr(err, "failed to list networks")
1468	}
1469
1470	for _, n := range networks {
1471		if n.Name == networkName && len(n.Containers) == 0 {
1472			if err := s.runtime.RemoveNetwork(ctx, networkName); err != nil {
1473				return err
1474			}
1475			log.Info().Msg("cleaned up empty network")
1476			break
1477		}
1478	}
1479
1480	return nil
1481}
1482
1483func (s *Service) deployAttachments(ctx context.Context, domainName, networkName string) error {
1484	// Collect attachments from both domain-specific and network group configs
1485	attachments := s.resolveAttachmentsForDomain(domainName)
1486	if len(attachments) == 0 {
1487		return nil
1488	}
1489
1490	log := zerowrap.FromCtx(ctx)
1491	var deployed []string // track successfully deployed attachment IDs for rollback
1492	for _, svc := range attachments {
1493		if err := s.deployAttachedService(ctx, domainName, svc, networkName); err != nil {
1494			log.WrapErrWithFields(err, "failed to deploy attachment", map[string]any{zerowrap.FieldService: svc, "domain": domainName})
1495
1496			// Rollback: clean up already-deployed attachments
1497			for _, id := range deployed {
1498				if stopErr := s.runtime.StopContainer(ctx, id); stopErr != nil {
1499					log.Warn().Err(stopErr).Str("attachment_id", id).Msg("rollback: failed to stop attachment")
1500				}
1501				if rmErr := s.runtime.RemoveContainer(ctx, id, true); rmErr != nil {
1502					log.Warn().Err(rmErr).Str("attachment_id", id).Msg("rollback: failed to remove attachment")
1503				}
1504			}
1505			// Deregister rolled-back attachments
1506			if len(deployed) > 0 {
1507				s.mu.Lock()
1508				remaining := s.attachments[domainName]
1509				filtered := remaining[:0]
1510				rollbackSet := make(map[string]bool, len(deployed))
1511				for _, id := range deployed {
1512					rollbackSet[id] = true
1513				}
1514				for _, id := range remaining {
1515					if !rollbackSet[id] {
1516						filtered = append(filtered, id)
1517					}
1518				}
1519				s.attachments[domainName] = filtered
1520				s.mu.Unlock()
1521			}
1522
1523			return fmt.Errorf("failed to deploy attachment %q (rolled back %d already-deployed)", svc, len(deployed))
1524		}
1525		// Collect IDs of attachments tracked under this domain after successful deploy
1526		s.mu.RLock()
1527		ids := s.attachments[domainName]
1528		if len(ids) > 0 {
1529			latest := ids[len(ids)-1]
1530			deployed = append(deployed, latest)
1531		}
1532		s.mu.RUnlock()
1533	}
1534	return nil
1535}
1536
1537// resolveAttachmentsForDomain returns attachments for a domain by checking:
1538// 1. Direct domain attachments (attachments[domain])
1539// 2. Network group attachments (attachments[group] where domain is in network_groups[group])
1540func (s *Service) resolveAttachmentsForDomain(domainName string) []string {
1541	s.mu.RLock()
1542	cfg := s.config
1543	s.mu.RUnlock()
1544
1545	seen := make(map[string]bool)
1546	var result []string
1547
1548	// First, add domain-specific attachments
1549	if domainAttachments, ok := cfg.Attachments[domainName]; ok {
1550		for _, img := range domainAttachments {
1551			if !seen[img] {
1552				seen[img] = true
1553				result = append(result, img)
1554			}
1555		}
1556	}
1557
1558	// Then, find which network group this domain belongs to and add group attachments
1559	for groupName, domains := range cfg.NetworkGroups {
1560		if slices.Contains(domains, domainName) {
1561			if groupAttachments, ok := cfg.Attachments[groupName]; ok {
1562				for _, img := range groupAttachments {
1563					if !seen[img] {
1564						seen[img] = true
1565						result = append(result, img)
1566					}
1567				}
1568			}
1569			break // Domain can only be in one network group
1570		}
1571	}
1572
1573	return result
1574}
1575
1576func (s *Service) getAttachmentsForDomain(ctx context.Context, domainName string) []domain.Attachment {
1577	containers, err := s.runtime.ListContainers(ctx, true)
1578	if err != nil {
1579		return nil
1580	}
1581
1582	return s.filterAttachments(ctx, containers, domainName)
1583}
1584
1585// getAllAttachments fetches all containers once and returns attachments grouped by domain.
1586// This avoids N+1 queries when listing multiple routes.
1587func (s *Service) getAllAttachments(ctx context.Context) map[string][]domain.Attachment {
1588	containers, err := s.runtime.ListContainers(ctx, true)
1589	if err != nil {
1590		return nil
1591	}
1592
1593	result := make(map[string][]domain.Attachment)
1594	for _, container := range containers {
1595		if container.Labels == nil {
1596			continue
1597		}
1598		if container.Labels[domain.LabelAttachment] != "true" {
1599			continue
1600		}
1601		ownerDomain := container.Labels[domain.LabelAttachedTo]
1602		if ownerDomain == "" {
1603			continue
1604		}
1605		image := container.Image
1606		serviceName := container.Name
1607		if labelImage, ok := container.Labels[domain.LabelImage]; ok && labelImage != "" {
1608			image = labelImage
1609			serviceName = extractServiceName(labelImage)
1610		}
1611		// Get the container's network for display in the routes table
1612		network := ""
1613		if networkName, err := s.runtime.GetContainerNetwork(ctx, container.ID); err == nil {
1614			network = networkName
1615		}
1616		attachment := domain.Attachment{
1617			Name:        serviceName,
1618			Image:       s.stripRegistryPrefix(image),
1619			ContainerID: container.ID,
1620			Status:      container.Status,
1621			Network:     network,
1622			Ports:       append([]int(nil), container.Ports...),
1623		}
1624		result[ownerDomain] = append(result[ownerDomain], attachment)
1625	}
1626
1627	return result
1628}
1629
1630// filterAttachments extracts attachments for a specific domain from a container list.
1631func (s *Service) filterAttachments(ctx context.Context, containers []*domain.Container, domainName string) []domain.Attachment {
1632	attachments := make([]domain.Attachment, 0)
1633	for _, container := range containers {
1634		if container.Labels == nil {
1635			continue
1636		}
1637		if container.Labels[domain.LabelAttachment] != "true" {
1638			continue
1639		}
1640		if container.Labels[domain.LabelAttachedTo] != domainName {
1641			continue
1642		}
1643		image := container.Image
1644		serviceName := container.Name
1645		if labelImage, ok := container.Labels[domain.LabelImage]; ok && labelImage != "" {
1646			image = labelImage
1647			serviceName = extractServiceName(labelImage)
1648		}
1649		// Get the container's network for display in the routes table
1650		network := ""
1651		if networkName, err := s.runtime.GetContainerNetwork(ctx, container.ID); err == nil {
1652			network = networkName
1653		}
1654		attachment := domain.Attachment{
1655			Name:        serviceName,
1656			Image:       s.stripRegistryPrefix(image),
1657			ContainerID: container.ID,
1658			Status:      container.Status,
1659			Network:     network,
1660			Ports:       append([]int(nil), container.Ports...),
1661		}
1662		attachments = append(attachments, attachment)
1663	}
1664
1665	return attachments
1666}
1667
1668func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, serviceImage, networkName string) error {
1669	log := zerowrap.FromCtx(ctx)
1670
1671	// Parse service name from image (e.g., "my-postgres:latest" → "postgres")
1672	serviceName := extractServiceName(serviceImage)
1673	containerName := fmt.Sprintf("gordon-%s-%s", sanitizeName(ownerDomain), serviceName)
1674
1675	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1676		"attachment":     serviceName,
1677		"container_name": containerName,
1678		"owner_domain":   ownerDomain,
1679	})
1680
1681	// Check if already running (idempotent)
1682	// Try new name first, then fall back to legacy name for backwards compatibility
1683	existingContainer := s.findContainerByName(ctx, containerName)
1684	if existingContainer == nil {
1685		// Try legacy naming scheme for backwards compatibility during upgrades
1686		containerNameLegacy := fmt.Sprintf("gordon-%s-%s", sanitizeNameLegacy(ownerDomain), serviceName)
1687		existingContainer = s.findContainerByName(ctx, containerNameLegacy)
1688		if existingContainer != nil {
1689			log.Info().Str("container_name", containerNameLegacy).Msg("found attachment with legacy naming, will be replaced")
1690			// Remove the old container and recreate with new name
1691			if err := s.runtime.StopContainer(ctx, existingContainer.ID); err != nil {
1692				return log.WrapErr(err, "failed to stop legacy attachment container")
1693			}
1694			if err := s.runtime.RemoveContainer(ctx, existingContainer.ID, true); err != nil {
1695				return log.WrapErr(err, "failed to remove legacy attachment container")
1696			}
1697			existingContainer = nil // Clear so we create new one below
1698		}
1699	}
1700
1701	if existingContainer != nil && existingContainer.Status == string(domain.ContainerStatusRunning) {
1702		log.Debug().Str("container_name", containerName).Msg("attachment already running, skipping")
1703		return nil
1704	}
1705
1706	// Remove existing stopped container if present
1707	if existingContainer != nil {
1708		log.Info().Str("container_name", containerName).Msg("removing stopped attachment container")
1709		if err := s.runtime.RemoveContainer(ctx, existingContainer.ID, true); err != nil {
1710			return log.WrapErr(err, "failed to remove existing attachment container")
1711		}
1712	}
1713
1714	log.Info().Str(zerowrap.FieldService, serviceImage).Msg("deploying attached service")
1715
1716	// Ensure image (canonical ref is returned; internal pulls may use the local registry).
1717	imageRef := s.buildImageRef(serviceImage)
1718	actualImageRef, err := s.ensureImage(ctx, imageRef)
1719	if err != nil {
1720		return err
1721	}
1722
1723	// Get image metadata
1724	exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, actualImageRef)
1725	if err != nil {
1726		log.WrapErr(err, "failed to get exposed ports for attachment, using defaults")
1727		exposedPorts = []int{}
1728	}
1729
1730	// Setup volumes (attachments need persistent data)
1731	volumes, err := s.setupVolumes(ctx, containerName, actualImageRef)
1732	if err != nil {
1733		log.WrapErr(err, "failed to setup volumes for attachment")
1734		volumes = make(map[string]string)
1735	}
1736
1737	// Load environment (attachment-specific env file)
1738	envVars, err := s.loadEnvironment(ctx, containerName, actualImageRef)
1739	if err != nil {
1740		log.WrapErr(err, "failed to load environment for attachment")
1741		envVars = []string{}
1742	}
1743
1744	// Create container with attachment labels (use actual ref, track original in labels)
1745	config := &domain.ContainerConfig{
1746		Image:       actualImageRef,
1747		Name:        containerName,
1748		Hostname:    serviceName, // Internal DNS: postgres, redis, etc.
1749		Ports:       exposedPorts,
1750		Env:         envVars,
1751		Volumes:     volumes,
1752		NetworkMode: networkName, // Same network as main app
1753		Labels: map[string]string{
1754			"gordon.managed":     "true",
1755			"gordon.attachment":  "true",
1756			"gordon.attached-to": ownerDomain,
1757			"gordon.image":       serviceImage,
1758		},
1759	}
1760
1761	container, err := s.runtime.CreateContainer(ctx, config)
1762	if err != nil {
1763		return log.WrapErr(err, "failed to create attachment container")
1764	}
1765
1766	// Start container
1767	if err := s.runtime.StartContainer(ctx, container.ID); err != nil {
1768		s.runtime.RemoveContainer(ctx, container.ID, true)
1769		return log.WrapErr(err, "failed to start attachment container")
1770	}
1771
1772	// Track attachment
1773	s.mu.Lock()
1774	s.attachments[ownerDomain] = append(s.attachments[ownerDomain], container.ID)
1775	s.mu.Unlock()
1776
1777	// Start log collection for attachment
1778	s.startLogCollection(ctx, container.ID, containerName)
1779
1780	log.Info().Str(zerowrap.FieldEntityID, container.ID).Msg("attachment deployed successfully")
1781	return nil
1782}
1783
1784// Utility functions
1785
1786func normalizeImageRef(image string) string {
1787	// Extract repository from image reference, handling port numbers correctly.
1788	// Examples:
1789	//   nginx:latest -> docker.io/library/nginx
1790	//   user/repo:tag -> docker.io/user/repo
1791	//   localhost:5000/image:tag -> localhost:5000/image
1792	//   registry.example.com/image:tag -> registry.example.com/image
1793	//
1794	// Note: This function assumes valid Docker image references. Edge cases like
1795	// "registry.com:5000" (registry with port but no image name) are not valid
1796	// image references per Docker's naming conventions, which require at least
1797	// one path component after the registry (e.g., "registry.com:5000/image").
1798
1799	// Find the tag separator: the last colon that isn't part of a port number.
1800	// A colon is part of a port if there's no slash after it until the next colon.
1801	repo := image
1802	lastColon := strings.LastIndex(image, ":")
1803	if lastColon != -1 {
1804		afterColon := image[lastColon+1:]
1805		// If there's no slash after the colon, it's the tag separator
1806		if !strings.Contains(afterColon, "/") {
1807			repo = image[:lastColon]
1808		}
1809	}
1810
1811	if !strings.Contains(repo, "/") {
1812		return "docker.io/library/" + repo
1813	}
1814
1815	if strings.Count(repo, "/") == 1 && !strings.Contains(strings.Split(repo, "/")[0], ".") && !strings.Contains(strings.Split(repo, "/")[0], ":") {
1816		return "docker.io/" + repo
1817	}
1818
1819	return repo
1820}
1821
1822func generateVolumeName(prefix, domainName, volumePath string) string {
1823	return fmt.Sprintf("%s-%s-%s",
1824		prefix,
1825		strings.ReplaceAll(domainName, ".", "-"),
1826		strings.ReplaceAll(strings.Trim(volumePath, "/"), "/", "-"))
1827}
1828
1829func mergeEnvironmentVariables(dockerfileEnv, userEnv []string) []string {
1830	envMap := make(map[string]string)
1831
1832	for _, env := range dockerfileEnv {
1833		if k, v, ok := strings.Cut(env, "="); ok {
1834			envMap[k] = v
1835		}
1836	}
1837
1838	for _, env := range userEnv {
1839		if k, v, ok := strings.Cut(env, "="); ok {
1840			envMap[k] = v
1841		}
1842	}
1843
1844	result := make([]string, 0, len(envMap))
1845	for k, v := range envMap {
1846		result = append(result, k+"="+v)
1847	}
1848
1849	return result
1850}
1851
1852// extractServiceName gets service name from image reference.
1853// "my-postgres:latest" → "postgres", "redis:7" → "redis"
1854func extractServiceName(image string) string {
1855	// Remove tag
1856	parts := strings.Split(image, ":")
1857	name := parts[0]
1858
1859	// Remove registry prefix if present
1860	if strings.Contains(name, "/") {
1861		nameParts := strings.Split(name, "/")
1862		name = nameParts[len(nameParts)-1]
1863	}
1864
1865	// Remove common prefixes like "my-"
1866	name = strings.TrimPrefix(name, "my-")
1867
1868	return name
1869}
1870
1871// sanitizeName is a convenience wrapper around domain.SanitizeDomainForContainer.
1872func sanitizeName(d string) string {
1873	return domain.SanitizeDomainForContainer(d)
1874}
1875
1876// sanitizeNameLegacy is a convenience wrapper around domain.SanitizeDomainForContainerLegacy.
1877func sanitizeNameLegacy(d string) string {
1878	return domain.SanitizeDomainForContainerLegacy(d)
1879}
1880
1881func isContainerNotFoundError(err error) bool {
1882	if err == nil {
1883		return false
1884	}
1885	if errors.Is(err, domain.ErrContainerNotFound) {
1886		return true
1887	}
1888	msg := strings.ToLower(err.Error())
1889	return strings.Contains(msg, "no such container") ||
1890		strings.Contains(msg, "no container with name or id") ||
1891		strings.Contains(msg, "container not found")
1892}
1893
1894func isConnectionRefusedError(err error) bool {
1895	if err == nil {
1896		return false
1897	}
1898	return strings.Contains(strings.ToLower(err.Error()), "connection refused")
1899}
1900
1901// findContainerByName finds a container by its name.
1902func (s *Service) findContainerByName(ctx context.Context, name string) *domain.Container {
1903	containers, err := s.runtime.ListContainers(ctx, true)
1904	if err != nil {
1905		return nil
1906	}
1907
1908	for _, c := range containers {
1909		if c.Name == name {
1910			return c
1911		}
1912	}
1913	return nil
1914}
1915
1916func (s *Service) waitForReady(ctx context.Context, containerID string) error {
1917	if err := s.pollContainerRunning(ctx, containerID); err != nil {
1918		return err
1919	}
1920
1921	s.mu.RLock()
1922	cfg := s.config
1923	s.mu.RUnlock()
1924
1925	readinessMode := cfg.ReadinessMode
1926	if readinessMode == "" {
1927		readinessMode = "delay"
1928	}
1929
1930	switch readinessMode {
1931	case "delay":
1932		return s.waitForReadyByDelay(ctx, containerID)
1933	case "docker-health":
1934		_, hasHealthcheck, err := s.runtime.GetContainerHealthStatus(ctx, containerID)
1935		if err != nil {
1936			return err
1937		}
1938		if !hasHealthcheck {
1939			return errors.New("no healthcheck detected")
1940		}
1941		return s.waitForHealthy(ctx, containerID, cfg.HealthTimeout)
1942	default: // auto
1943		_, hasHealthcheck, err := s.runtime.GetContainerHealthStatus(ctx, containerID)
1944		if err != nil {
1945			return err
1946		}
1947		if hasHealthcheck {
1948			return s.waitForHealthy(ctx, containerID, cfg.HealthTimeout)
1949		}
1950		return s.waitForReadyByDelay(ctx, containerID)
1951	}
1952}
1953
1954// waitForReadyByDelay waits using the legacy running+delay strategy.
1955func (s *Service) waitForReadyByDelay(ctx context.Context, containerID string) error {
1956	log := zerowrap.FromCtx(ctx)
1957
1958	s.mu.RLock()
1959	cfg := s.config
1960	s.mu.RUnlock()
1961
1962	delay := cfg.ReadinessDelay
1963	if delay == 0 {
1964		delay = 5 * time.Second
1965	}
1966
1967	log.Debug().Dur("delay", delay).Msg("waiting for container readiness")
1968	select {
1969	case <-time.After(delay):
1970	case <-ctx.Done():
1971		return ctx.Err()
1972	}
1973
1974	// Verify still running after delay
1975	running, err := s.runtime.IsContainerRunning(ctx, containerID)
1976	if err != nil {
1977		return err
1978	}
1979	if running {
1980		return nil
1981	}
1982
1983	return s.waitForRecovery(ctx, containerID)
1984}
1985
1986func (s *Service) waitForHealthy(ctx context.Context, containerID string, timeout time.Duration) error {
1987	if timeout == 0 {
1988		timeout = 90 * time.Second
1989	}
1990	waitCtx, cancel := context.WithTimeout(ctx, timeout)
1991	defer cancel()
1992
1993	var lastStatus string
1994	for {
1995		status, hasHealthcheck, err := s.runtime.GetContainerHealthStatus(waitCtx, containerID)
1996		if err != nil {
1997			return err
1998		}
1999		if !hasHealthcheck {
2000			return errors.New("no healthcheck detected")
2001		}
2002		// Treat empty health status as transitional startup. Some runtimes can
2003		// temporarily report an empty status before first probe results.
2004		if status == "" {
2005			status = "starting"
2006		}
2007		if status == "healthy" {
2008			return nil
2009		}
2010		lastStatus = status
2011
2012		select {
2013		case <-time.After(time.Second):
2014		case <-waitCtx.Done():
2015			return fmt.Errorf("container healthcheck timeout after %s (last status: %s)", timeout, lastStatus)
2016		}
2017	}
2018}
2019
2020// pollContainerRunning polls until the container is running (max 30 seconds).
2021func (s *Service) pollContainerRunning(ctx context.Context, containerID string) error {
2022	for i := 0; i < 30; i++ {
2023		running, err := s.runtime.IsContainerRunning(ctx, containerID)
2024		if err != nil {
2025			return err
2026		}
2027		if running {
2028			return nil
2029		}
2030		if i == 29 {
2031			return fmt.Errorf("container did not start within 30 seconds")
2032		}
2033		select {
2034		case <-time.After(time.Second):
2035		case <-ctx.Done():
2036			return ctx.Err()
2037		}
2038	}
2039	return fmt.Errorf("container did not start within 30 seconds")
2040}
2041
2042// waitForRecovery waits for a container to recover within the recovery window.
2043func (s *Service) waitForRecovery(ctx context.Context, containerID string) error {
2044	log := zerowrap.FromCtx(ctx)
2045	log.Warn().
2046		Dur("recovery_window", readinessRecoveryWindow).
2047		Msg("container not running after readiness delay, waiting for recovery")
2048
2049	deadline := time.Now().Add(readinessRecoveryWindow)
2050	for time.Now().Before(deadline) {
2051		select {
2052		case <-time.After(time.Second):
2053		case <-ctx.Done():
2054			return ctx.Err()
2055		}
2056
2057		running, err := s.runtime.IsContainerRunning(ctx, containerID)
2058		if err != nil {
2059			return err
2060		}
2061		if running {
2062			log.Info().Msg("container recovered during readiness recovery window")
2063			return nil
2064		}
2065	}
2066
2067	return fmt.Errorf("container not running after readiness delay and recovery window")
2068}
2069
2070// publishContainerDeployed publishes a container.deployed event.
2071func (s *Service) publishContainerDeployed(ctx context.Context, domainName, containerID string) {
2072	payload := &domain.ContainerEventPayload{
2073		ContainerID: containerID,
2074		Domain:      domainName,
2075		Action:      "deployed",
2076	}
2077
2078	if err := s.eventBus.Publish(domain.EventContainerDeployed, payload); err != nil {
2079		log := zerowrap.FromCtx(ctx)
2080		log.Warn().Err(err).Msg("failed to publish container deployed event")
2081	}
2082}
2083
2084// rewriteToRegistryDomain rewrites an image reference to use the configured registry domain.
2085// e.g., "myapp:latest" -> "registry.example.com/myapp:latest"
2086func rewriteToRegistryDomain(imageRef, registryDomain string) string {
2087	if registryDomain == "" {
2088		return imageRef
2089	}
2090
2091	prefix := registryDomain + "/"
2092	if strings.HasPrefix(imageRef, prefix) {
2093		return imageRef
2094	}
2095
2096	return prefix + imageRef
2097}
2098
2099// rewriteToLocalRegistry rewrites an image reference to use the local registry address.
2100// e.g., "registry.example.com/myapp:latest" -> "localhost:5000/myapp:latest"
2101func rewriteToLocalRegistry(imageRef, registryDomain string, registryPort int) string {
2102	if imageRef == "" {
2103		return imageRef
2104	}
2105
2106	localRegistry := fmt.Sprintf("localhost:%d", registryPort)
2107	localPrefix := localRegistry + "/"
2108	imageRef = strings.TrimPrefix(imageRef, localPrefix)
2109
2110	registryDomain = strings.TrimSuffix(registryDomain, "/")
2111	if registryDomain != "" {
2112		prefix := registryDomain + "/"
2113		imageRef = strings.TrimPrefix(imageRef, prefix)
2114	}
2115
2116	return localPrefix + imageRef
2117}

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 8

Tags 106

↑↓ moveenter openesc close