v2.30.0-pre6 511705f

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	"strconv"
11	"strings"
12	"sync"
13	"time"
14
15	"github.com/bnema/zerowrap"
16	"go.opentelemetry.io/otel"
17	"go.opentelemetry.io/otel/attribute"
18	"go.opentelemetry.io/otel/codes"
19	"go.opentelemetry.io/otel/metric"
20	"go.opentelemetry.io/otel/trace"
21
22	"github.com/bnema/gordon/internal/adapters/out/telemetry"
23	"github.com/bnema/gordon/internal/boundaries/out"
24	"github.com/bnema/gordon/internal/domain"
25)
26
27// Config holds configuration needed by the container service.
28type Config struct {
29	RegistryAuthEnabled        bool
30	RegistryDomain             string
31	RegistryPort               int
32	ServiceTokenUsername       string
33	ServiceToken               string
34	InternalRegistryUsername   string
35	InternalRegistryPassword   string
36	PullPolicy                 string
37	VolumeAutoCreate           bool
38	VolumePrefix               string
39	VolumePreserve             bool
40	NetworkIsolation           bool
41	NetworkPrefix              string
42	NetworkGroups              map[string][]string
43	Attachments                map[string][]string
44	ReadinessDelay             time.Duration // Delay after container starts before considering it ready
45	ReadinessMode              string        // Readiness strategy: auto, docker-health, delay
46	HealthTimeout              time.Duration // Max wait for health-based readiness
47	DrainDelay                 time.Duration // Grace period after cache invalidation before stopping old container
48	DrainDelayConfigured       bool          // True when deploy.drain_delay was explicitly configured
49	DrainMode                  string        // Drain strategy: auto, inflight, delay
50	DrainTimeout               time.Duration // Max wait for in-flight requests to drain
51	StabilizationDelay         time.Duration // Post-switch monitoring window (default 2s)
52	TCPProbeTimeout            time.Duration // TCP probe timeout (default 30s)
53	HTTPProbeTimeout           time.Duration // HTTP probe timeout (default 60s)
54	AttachmentReadinessTimeout time.Duration // Max wait for attachment readiness (default 30s)
55	DefaultMemoryLimit         int64         // Default memory limit in bytes for containers (0 = no limit)
56	DefaultNanoCPUs            int64         // Default CPU quota in nanoseconds for containers (0 = no limit)
57	DefaultPidsLimit           int64         // Default max PIDs for containers (0 = no limit)
58}
59
60var tracer = otel.Tracer("gordon.container")
61
62const (
63	PullPolicyAlways       = "always"
64	PullPolicyIfNotPresent = "if-not-present"
65	PullPolicyIfTagChanged = "if-tag-changed"
66
67	// readinessRecoveryWindow is an additional grace window used when a container
68	// is briefly not running at the end of readiness delay. This avoids false
69	// negatives during short startup flaps.
70	readinessRecoveryWindow = 30 * time.Second
71	internalPullMaxAttempts = 3
72)
73
74// Service implements the ContainerService interface.
75type Service struct {
76	runtime          out.ContainerRuntime
77	envLoader        out.EnvLoader
78	eventBus         out.EventPublisher
79	logWriter        out.ContainerLogWriter
80	cacheInvalidator out.ProxyCacheInvalidator
81	drainWaiter      out.ProxyDrainWaiter
82	config           Config
83	configProvider   AttachmentConfigProvider // live config reads for attachments/networks (may be nil)
84	metrics          *telemetry.Metrics
85	containers       map[string]*domain.Container
86	attachments      map[string][]string // ownerDomain → []containerIDs
87	managedCount     int64               // tracks UpDownCounter value for delta computation
88	mu               sync.RWMutex
89	deployMu         sync.Map       // per-domain deploy locks (domain → *domainDeployLock)
90	cleanupWg        sync.WaitGroup // tracks background old-container cleanup goroutines
91	monitor          *Monitor
92}
93
94// domainDeployLock is a context-aware mutex using a buffered channel.
95// It allows Lock() to be interrupted by context cancellation.
96type domainDeployLock struct {
97	ch chan struct{}
98}
99
100// newDomainDeployLock creates a new lock that is initially unlocked.
101func newDomainDeployLock() *domainDeployLock {
102	l := &domainDeployLock{ch: make(chan struct{}, 1)}
103	l.ch <- struct{}{}
104	return l
105}
106
107// Lock acquires the lock or returns an error if the context is cancelled.
108func (l *domainDeployLock) Lock(ctx context.Context) error {
109	// Fast path: honor already-canceled contexts before blocking.
110	if err := ctx.Err(); err != nil {
111		return err
112	}
113	select {
114	case <-ctx.Done():
115		return ctx.Err()
116	case <-l.ch:
117		// Re-check context after acquisition: if canceled while we were
118		// in the select, release the token so the lock isn't consumed.
119		if err := ctx.Err(); err != nil {
120			l.ch <- struct{}{}
121			return err
122		}
123		return nil
124	}
125}
126
127// Unlock releases the lock.
128func (l *domainDeployLock) Unlock() {
129	select {
130	case l.ch <- struct{}{}:
131	default:
132		// This should never happen if Lock/Unlock are used correctly.
133		// Panic to detect double-unlocks or other misuse early.
134		panic("domainDeployLock: double unlock detected")
135	}
136}
137
138// NewService creates a new container service.
139func NewService(
140	runtime out.ContainerRuntime,
141	envLoader out.EnvLoader,
142	eventBus out.EventPublisher,
143	logWriter out.ContainerLogWriter,
144	config Config,
145	configProvider AttachmentConfigProvider,
146) *Service {
147	return &Service{
148		runtime:        runtime,
149		envLoader:      envLoader,
150		eventBus:       eventBus,
151		logWriter:      logWriter,
152		config:         config,
153		configProvider: configProvider,
154		containers:     make(map[string]*domain.Container),
155		attachments:    make(map[string][]string),
156	}
157}
158
159// SetMetrics sets the telemetry metrics for the container service.
160func (s *Service) SetMetrics(m *telemetry.Metrics) {
161	s.metrics = m
162}
163
164// SetProxyCacheInvalidator sets the proxy cache invalidator for synchronous
165// cache invalidation during zero-downtime deployments. This must be called
166// after construction because the proxy service is created after the container
167// service during application initialization.
168func (s *Service) SetProxyCacheInvalidator(inv out.ProxyCacheInvalidator) {
169	s.mu.Lock()
170	s.cacheInvalidator = inv
171	s.mu.Unlock()
172}
173
174// SetProxyDrainWaiter sets the proxy in-flight drain waiter used during
175// zero-downtime replacement before stopping the old container.
176func (s *Service) SetProxyDrainWaiter(waiter out.ProxyDrainWaiter) {
177	s.mu.Lock()
178	s.drainWaiter = waiter
179	s.mu.Unlock()
180}
181
182// domainDeployMu returns a per-domain lock for serializing deploy operations.
183func (s *Service) domainDeployMu(domain string) *domainDeployLock {
184	v, _ := s.deployMu.LoadOrStore(domain, newDomainDeployLock())
185	// Type assertion is safe: LoadOrStore always stores *domainDeployLock values,
186	// and any concurrent LoadOrStore calls will also store *domainDeployLock.
187	return v.(*domainDeployLock)
188}
189
190// acquireDomainDeployLock acquires the per-domain deploy lock with context cancellation support.
191// Returns an unlock function that must be called to release the lock.
192func (s *Service) acquireDomainDeployLock(ctx context.Context, domain string) (func(), error) {
193	lock := s.domainDeployMu(domain)
194	if err := lock.Lock(ctx); err != nil {
195		return nil, err
196	}
197	return func() { lock.Unlock() }, nil
198}
199
200// buildContainerConfig constructs the container configuration for deployment.
201func (s *Service) deploymentContainerName(containerDomain string, existing *domain.Container) string {
202	canonicalName := fmt.Sprintf("gordon-%s", containerDomain)
203	if existing == nil {
204		return canonicalName
205	}
206
207	// Alternate temporary names if the tracked container was left with a temp suffix.
208	// This prevents name collisions after interrupted zero-downtime deploys.
209	newName := canonicalName + "-new"
210	nextName := canonicalName + "-next"
211	switch existing.Name {
212	case newName:
213		return nextName
214	case nextName:
215		return newName
216	default:
217		return newName
218	}
219}
220
221// containerConfigInput groups the parameters for building a container configuration.
222type containerConfigInput struct {
223	Domain       string
224	Image        string
225	ImageRef     string
226	ExposedPorts []int
227	EnvVars      []string
228	EnvHash      string
229	Volumes      map[string]string
230	NetworkName  string
231	ImageLabels  map[string]string
232	Existing     *domain.Container
233}
234
235func (s *Service) buildContainerConfig(in containerConfigInput) *domain.ContainerConfig {
236	s.mu.RLock()
237	cfg := s.config
238	s.mu.RUnlock()
239
240	containerName := s.deploymentContainerName(in.Domain, in.Existing)
241	labels := map[string]string{
242		domain.LabelDomain:  in.Domain,
243		domain.LabelEnvHash: in.EnvHash,
244		domain.LabelImage:   in.Image,
245		domain.LabelManaged: "true",
246		domain.LabelRoute:   in.Domain,
247	}
248
249	// Propagate proxy/port labels from image so readiness probes can find them
250	for _, key := range []string{domain.LabelProxyPort, domain.LabelPort, domain.LabelHealth} {
251		if v, ok := in.ImageLabels[key]; ok && v != "" {
252			labels[key] = v
253		}
254	}
255
256	// Ensure the label-specified proxy port is included in exposed ports
257	// so Docker creates a host port binding for it.
258	ports := in.ExposedPorts
259	if portStr, ok := labels[domain.LabelProxyPort]; ok {
260		if port, err := strconv.Atoi(portStr); err == nil && port > 0 && port <= 65535 {
261			if !slices.Contains(ports, port) {
262				ports = append(ports, port)
263			}
264		}
265	}
266
267	return &domain.ContainerConfig{
268		Image:       in.ImageRef,
269		Name:        containerName,
270		Ports:       ports,
271		Env:         in.EnvVars,
272		Volumes:     in.Volumes,
273		NetworkMode: in.NetworkName,
274		Hostname:    in.Domain,
275		Labels:      labels,
276		AutoRemove:  false,
277		MemoryLimit: cfg.DefaultMemoryLimit,
278		NanoCPUs:    cfg.DefaultNanoCPUs,
279		PidsLimit:   cfg.DefaultPidsLimit,
280	}
281}
282
283// Deploy creates and starts a container for the given route.
284// Implements zero-downtime deployment: new container starts before old one stops.
285func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Container, error) {
286	ctx, span := tracer.Start(ctx, "container.deploy",
287		trace.WithAttributes(
288			attribute.String("domain", route.Domain),
289			attribute.String("image", route.Image),
290		))
291	defer span.End()
292
293	deployStart := time.Now()
294
295	// Serialize deploys for the same domain to prevent race conditions
296	// (e.g. multiple image.pushed events + explicit deploy call from CLI).
297	unlock, err := s.acquireDomainDeployLock(ctx, route.Domain)
298	if err != nil {
299		return nil, err
300	}
301	defer unlock()
302
303	// Enrich context with use case fields for all downstream logs
304	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
305		zerowrap.FieldLayer:   "usecase",
306		zerowrap.FieldUseCase: "Deploy",
307		"domain":              route.Domain,
308	})
309	log := zerowrap.FromCtx(ctx)
310
311	// Record deploy metrics and trace status
312	defer s.recordDeployMetrics(ctx, span, route, deployStart, &err)
313
314	existing, hasExisting := s.resolveExistingContainer(ctx, route.Domain)
315
316	resources, err := s.prepareDeployResources(ctx, route, existing)
317	if err != nil {
318		return nil, err
319	}
320
321	// Skip redundant deploy: if the existing container is already running
322	// the exact same image (by Docker image ID), return it immediately.
323	// This prevents the double-deploy caused by the event-based deploy
324	// (triggered by image.pushed) racing with the explicit CLI deploy call.
325	if hasExisting {
326		existingForSkip := existing
327		if existingForSkip.ImageID == "" && existingForSkip.Image != "" && normalizeImageRef(existingForSkip.Image) == normalizeImageRef(route.Image) {
328			existingForSkip = s.containerForRedundantCheck(ctx, existingForSkip)
329		}
330		if existingForSkip.ImageID != "" {
331			if skip, container := s.skipRedundantDeploy(ctx, existingForSkip, resources.actualImageRef, resources.envHash); skip {
332				return container, nil
333			}
334		}
335	}
336
337	newContainer, err := s.createStartedContainer(ctx, route, existing, resources)
338	if err != nil {
339		return nil, err
340	}
341
342	invalidated := s.activateDeployedContainer(ctx, route.Domain, newContainer)
343
344	// Post-switch stabilization: verify new container stays running
345	if hasExisting {
346		stable, stabilizeErr := s.stabilizeNewContainer(ctx, route.Domain, newContainer, existing)
347		if stabilizeErr != nil {
348			// Both old and new containers are dead; assign to named return so
349			// the deferred recordDeployMetrics and span see the failure.
350			err = stabilizeErr
351			return nil, err
352		}
353		if !stable {
354			// Rollback performed — old container is restored
355			return existing, nil
356		}
357	}
358
359	// Finalize old container in the background — the new container is already
360	// serving traffic, so there's no reason to block the deploy response while
361	// waiting for the old container to stop (which can take 20s if the app
362	// doesn't handle SIGTERM).
363	s.cleanupWg.Add(1)
364	go func() {
365		defer s.cleanupWg.Done()
366		s.finalizePreviousContainer(context.WithoutCancel(ctx), route.Domain, existing, hasExisting, invalidated, newContainer.ID)
367	}()
368
369	// Start container log collection (non-blocking, errors don't fail deployment)
370	s.startLogCollection(ctx, newContainer.ID, route.Domain)
371
372	log.Info().
373		Str("image", route.Image).
374		Str(zerowrap.FieldEntityID, newContainer.ID).
375		Ints("ports", newContainer.Ports).
376		Str("network", resources.networkName).
377		Bool("zero_downtime", hasExisting).
378		Msg("container deployed successfully")
379
380	return newContainer, nil
381}
382
383// recordDeployMetrics records span error status and deploy metrics at the end of a Deploy call.
384// It is called via defer and receives a pointer to the named return error so it can observe
385// the final error value after all deferred functions have run.
386func (s *Service) recordDeployMetrics(ctx context.Context, span trace.Span, route domain.Route, start time.Time, errPtr *error) {
387	err := *errPtr
388	if err != nil {
389		span.RecordError(err)
390		span.SetStatus(codes.Error, err.Error())
391	}
392	if s.metrics == nil {
393		return
394	}
395	attrs := metric.WithAttributes(
396		attribute.String("domain", route.Domain),
397		attribute.String("image", route.Image),
398	)
399	s.metrics.DeployTotal.Add(ctx, 1, attrs)
400	s.metrics.DeployDuration.Record(ctx, time.Since(start).Seconds(), attrs)
401	if err != nil {
402		s.metrics.DeployErrors.Add(ctx, 1, attrs)
403	}
404}
405
406// skipRedundantDeploy checks whether the existing container is already running
407// the same image (by Docker image ID) as the one we are about to deploy.
408// When a push triggers both an event-based deploy and an explicit CLI deploy,
409// the second one arrives after the first has already completed; this avoids
410// a full redundant create-start-readiness cycle.
411func (s *Service) skipRedundantDeploy(ctx context.Context, existing *domain.Container, actualImageRef, envHash string) (bool, *domain.Container) {
412	log := zerowrap.FromCtx(ctx)
413
414	newImageID, err := s.runtime.GetImageID(ctx, actualImageRef)
415	if err != nil {
416		log.Debug().Err(err).Msg("cannot resolve image ID for redundancy check, proceeding with deploy")
417		return false, nil
418	}
419
420	if existing.ImageID != newImageID {
421		return false, nil
422	}
423
424	// Verify the container is actually still running before skipping.
425	running, err := s.runtime.IsContainerRunning(ctx, existing.ID)
426	if err != nil || !running {
427		return false, nil
428	}
429
430	if existing.Labels != nil {
431		existingEnvHash, hasEnvHash := existing.Labels[domain.LabelEnvHash]
432		if !hasEnvHash || existingEnvHash != envHash {
433			log.Info().
434				Str("container_id", existing.ID).
435				Bool("has_env_hash", hasEnvHash).
436				Msg("env changed, proceeding with deploy despite same image")
437			return false, nil
438		}
439	} else {
440		return false, nil
441	}
442
443	log.Info().
444		Str("container_id", existing.ID).
445		Str("image_id", newImageID).
446		Msg("skipping redundant deploy: container already running this image")
447
448	return true, existing
449}
450
451func (s *Service) containerForRedundantCheck(ctx context.Context, existing *domain.Container) *domain.Container {
452	if existing == nil || existing.ImageID != "" || existing.ID == "" {
453		return existing
454	}
455
456	log := zerowrap.FromCtx(ctx)
457	inspected, err := s.runtime.InspectContainer(ctx, existing.ID)
458	if err != nil {
459		log.Debug().Err(err).Str("container_id", existing.ID).Msg("cannot inspect existing container for redundancy check")
460		return existing
461	}
462	if inspected == nil || inspected.ImageID == "" {
463		return existing
464	}
465
466	existingCopy := *existing
467	existingCopy.ImageID = inspected.ImageID
468	return &existingCopy
469}
470
471type deployResources struct {
472	networkName    string
473	actualImageRef string
474	exposedPorts   []int
475	imageLabels    map[string]string
476	envVars        []string
477	envHash        string
478	volumes        map[string]string
479}
480
481// resolveExistingContainer returns the currently running container for a domain.
482// It first checks the in-memory map (fast path). If not found, it queries the
483// runtime for a running container with matching name and managed label. This
484// handles cases where Gordon restarted and in-memory state is stale.
485//
486// The slow path checks canonical, -new, and -next names because a previous
487// deploy may have been interrupted (e.g. eventbus timeout, Gordon restart)
488// leaving the active container under a temp name. Canonical is preferred when
489// multiple matches exist.
490func (s *Service) resolveExistingContainer(ctx context.Context, domainName string) (*domain.Container, bool) {
491	// Fast path: check in-memory state
492	s.mu.RLock()
493	container, ok := s.containers[domainName]
494	s.mu.RUnlock()
495	if ok {
496		return container, true
497	}
498
499	// Slow path: query runtime for running containers
500	log := zerowrap.FromCtx(ctx)
501	canonicalName := fmt.Sprintf("gordon-%s", domainName)
502	candidateNames := map[string]bool{
503		canonicalName:           true,
504		canonicalName + "-new":  true,
505		canonicalName + "-next": true,
506	}
507
508	running, err := s.runtime.ListContainers(ctx, false)
509	if err != nil {
510		log.Warn().Err(err).Msg("failed to list running containers for existing container resolution")
511		return nil, false
512	}
513
514	// Prefer canonical name; fall back to any running managed temp container.
515	var best *domain.Container
516	for _, c := range running {
517		if !candidateNames[c.Name] || c.Labels[domain.LabelManaged] != "true" {
518			continue
519		}
520		if best == nil || c.Name == canonicalName {
521			best = c
522		}
523	}
524
525	if best != nil {
526		log.Info().
527			Str("container_id", best.ID).
528			Str("container_name", best.Name).
529			Msg("resolved existing container from runtime (in-memory state was stale)")
530
531		// Update in-memory state so subsequent lookups are fast
532		s.mu.Lock()
533		if _, alreadyTracked := s.containers[domainName]; !alreadyTracked {
534			s.managedCount++
535		}
536		s.containers[domainName] = best
537		s.mu.Unlock()
538
539		return best, true
540	}
541
542	return nil, false
543}
544
545func (s *Service) prepareDeployResources(ctx context.Context, route domain.Route, existing *domain.Container) (*deployResources, error) {
546	log := zerowrap.FromCtx(ctx)
547
548	existingID := ""
549	if existing != nil {
550		existingID = existing.ID
551	}
552	if err := s.cleanupOrphanedContainers(ctx, route.Domain, existingID); err != nil {
553		log.WrapErr(err, "failed to cleanup orphaned containers")
554	}
555
556	imageRef := s.buildImageRef(route.Image)
557	actualImageRef, err := s.ensureImage(ctx, imageRef)
558	if err != nil {
559		return nil, err
560	}
561
562	networkName := s.getNetworkForApp(route.Domain)
563	if err := s.createNetworkIfNeeded(ctx, networkName); err != nil {
564		return nil, log.WrapErr(err, "failed to create network")
565	}
566	if err := s.deployAttachments(ctx, route.Domain, networkName); err != nil {
567		return nil, log.WrapErr(err, "failed to deploy attachments")
568	}
569
570	exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, actualImageRef)
571	if err != nil {
572		log.WrapErr(err, "failed to get exposed ports, using defaults")
573		exposedPorts = []int{80, 8080, 3000}
574	}
575
576	imageLabels, err := s.runtime.GetImageLabels(ctx, actualImageRef)
577	if err != nil {
578		log.Warn().Err(err).Msg("failed to get image labels, skipping label propagation")
579		imageLabels = nil
580	}
581
582	envVars, err := s.loadEnvironment(ctx, route.Env, route.Domain, actualImageRef)
583	if err != nil {
584		return nil, err
585	}
586	envHash := hashEnvironment(envVars)
587
588	volumes, err := s.setupVolumes(ctx, route.Domain, actualImageRef)
589	if err != nil {
590		return nil, err
591	}
592
593	return &deployResources{
594		networkName:    networkName,
595		actualImageRef: actualImageRef,
596		exposedPorts:   exposedPorts,
597		imageLabels:    imageLabels,
598		envVars:        envVars,
599		envHash:        envHash,
600		volumes:        volumes,
601	}, nil
602}
603
604func (s *Service) createStartedContainer(ctx context.Context, route domain.Route, existing *domain.Container, resources *deployResources) (*domain.Container, error) {
605	ctx, span := tracer.Start(ctx, "container.create_and_start")
606	defer span.End()
607
608	log := zerowrap.FromCtx(ctx)
609
610	containerConfig := s.buildContainerConfig(containerConfigInput{
611		Domain:       route.Domain,
612		Image:        route.Image,
613		ImageRef:     resources.actualImageRef,
614		ExposedPorts: resources.exposedPorts,
615		EnvVars:      resources.envVars,
616		EnvHash:      resources.envHash,
617		Volumes:      resources.volumes,
618		NetworkName:  resources.networkName,
619		ImageLabels:  resources.imageLabels,
620		Existing:     existing,
621	})
622
623	newContainer, err := s.runtime.CreateContainer(ctx, containerConfig)
624	if err != nil {
625		return nil, log.WrapErr(err, "failed to create container")
626	}
627	if err := s.runtime.StartContainer(ctx, newContainer.ID); err != nil {
628		s.runtime.RemoveContainer(ctx, newContainer.ID, true)
629		return nil, log.WrapErr(err, "failed to start container")
630	}
631	if !domain.IsSkipReadiness(ctx) {
632		if err := s.waitForReady(ctx, newContainer.ID, containerConfig); err != nil {
633			s.cleanupFailedContainer(ctx, newContainer.ID)
634			return nil, log.WrapErr(err, "container failed readiness check")
635		}
636	}
637
638	inspected, err := s.runtime.InspectContainer(ctx, newContainer.ID)
639	if err != nil {
640		s.cleanupFailedContainer(ctx, newContainer.ID)
641		return nil, log.WrapErr(err, "failed to inspect started container")
642	}
643
644	return inspected, nil
645}
646
647func (s *Service) activateDeployedContainer(ctx context.Context, domainName string, container *domain.Container) bool {
648	s.mu.Lock()
649	_, wasTracked := s.containers[domainName]
650	s.containers[domainName] = container
651	if !wasTracked {
652		s.managedCount++
653	}
654	s.mu.Unlock()
655
656	// Track managed container count (only increment for new domains, not replacements)
657	if !wasTracked && s.metrics != nil {
658		s.metrics.ManagedContainers.Add(ctx, 1)
659	}
660
661	s.publishContainerDeployed(ctx, domainName, container.ID)
662
663	s.mu.RLock()
664	inv := s.cacheInvalidator
665	s.mu.RUnlock()
666	if inv != nil {
667		inv.InvalidateTarget(ctx, domainName)
668		return true
669	}
670
671	return false
672}
673
674// stabilizeNewContainer monitors the new container briefly after traffic switch.
675// If it crashes during this window, rolls back to old container.
676// Returns true if stabilization succeeded, false if rollback was performed.
677// Returns an error only when both new and old containers are dead.
678func (s *Service) stabilizeNewContainer(ctx context.Context, domainName string, newContainer, oldContainer *domain.Container) (bool, error) {
679	log := zerowrap.FromCtx(ctx)
680
681	if oldContainer == nil {
682		return true, nil
683	}
684
685	s.mu.RLock()
686	delay := s.config.StabilizationDelay
687	s.mu.RUnlock()
688	if delay == 0 {
689		delay = 2 * time.Second
690	}
691
692	// Brief stabilization: verify new container is still running after delay
693	select {
694	case <-time.After(delay):
695	case <-ctx.Done():
696		return true, nil
697	}
698
699	running, err := s.runtime.IsContainerRunning(ctx, newContainer.ID)
700	if err != nil || !running {
701		log.Error().
702			Str("new_container", newContainer.ID).
703			Str("old_container", oldContainer.ID).
704			Msg("new container crashed during stabilization, rolling back to old")
705
706		// Verify old container is still running before restoring it.
707		// If both old and new are dead (e.g., OOM), returning a dead
708		// container as "existing" would leave the domain in a broken state.
709		oldRunning, oldErr := s.runtime.IsContainerRunning(ctx, oldContainer.ID)
710		if oldErr != nil || !oldRunning {
711			log.Error().
712				Str("old_container", oldContainer.ID).
713				Msg("old container is also not running, cannot rollback")
714
715			// Cleanup failed new container
716			if stopErr := s.runtime.StopContainer(ctx, newContainer.ID); stopErr != nil {
717				log.WrapErrWithFields(stopErr, "failed to stop failed new container during rollback", map[string]any{zerowrap.FieldEntityID: newContainer.ID})
718			}
719			if removeErr := s.runtime.RemoveContainer(ctx, newContainer.ID, true); removeErr != nil {
720				log.WrapErrWithFields(removeErr, "failed to remove failed new container during rollback", map[string]any{zerowrap.FieldEntityID: newContainer.ID})
721			}
722
723			return false, fmt.Errorf("stabilization failed: new container crashed and old container %s is also not running", oldContainer.ID)
724		}
725
726		// Rollback: restore old container as tracked
727		s.mu.Lock()
728		s.containers[domainName] = oldContainer
729		s.mu.Unlock()
730
731		// Re-invalidate proxy cache to point back to old
732		s.mu.RLock()
733		inv := s.cacheInvalidator
734		s.mu.RUnlock()
735		if inv != nil {
736			inv.InvalidateTarget(ctx, domainName)
737		}
738
739		// Cleanup failed new container
740		if stopErr := s.runtime.StopContainer(ctx, newContainer.ID); stopErr != nil {
741			log.WrapErrWithFields(stopErr, "failed to stop failed new container during rollback", map[string]any{zerowrap.FieldEntityID: newContainer.ID})
742		}
743		if removeErr := s.runtime.RemoveContainer(ctx, newContainer.ID, true); removeErr != nil {
744			log.WrapErrWithFields(removeErr, "failed to remove failed new container during rollback", map[string]any{zerowrap.FieldEntityID: newContainer.ID})
745		}
746
747		return false, nil
748	}
749
750	return true, nil
751}
752
753func (s *Service) finalizePreviousContainer(ctx context.Context, domainName string, existing *domain.Container, hasExisting, invalidated bool, newContainerID string) {
754	if !hasExisting {
755		return
756	}
757
758	if invalidated {
759		s.waitForDrain(ctx, existing.ID)
760	}
761
762	s.cleanupOldContainer(ctx, existing, newContainerID, domainName)
763}
764
765func (s *Service) waitForDrain(ctx context.Context, oldContainerID string) {
766	log := zerowrap.FromCtx(ctx)
767
768	s.mu.RLock()
769	cfg := s.config
770	waiter := s.drainWaiter
771	s.mu.RUnlock()
772
773	drainMode := cfg.DrainMode
774	if drainMode == "" {
775		drainMode = "delay"
776	}
777
778	shouldUseInFlight := (drainMode == "inflight" || drainMode == "auto") && waiter != nil
779	if shouldUseInFlight {
780		timeout := cfg.DrainTimeout
781		if timeout == 0 {
782			timeout = 30 * time.Second
783		}
784		drained := waiter.WaitForNoInFlight(ctx, oldContainerID, timeout)
785		if !drained {
786			log.Warn().
787				Str("old_container_id", oldContainerID).
788				Dur("drain_timeout", timeout).
789				Msg("drain wait timed out; old container may still have in-flight traffic")
790		}
791		return
792	}
793
794	drainDelay := 2 * time.Second
795	if cfg.DrainDelayConfigured {
796		drainDelay = cfg.DrainDelay
797	}
798	if drainDelay <= 0 {
799		return
800	}
801	select {
802	case <-time.After(drainDelay):
803	case <-ctx.Done():
804	}
805}
806
807// restartContainerWithRecovery restarts a container, reconciling stale state if needed.
808// It returns the (possibly refreshed) container and updated attachment IDs.
809func (s *Service) restartContainerWithRecovery(ctx context.Context, domainName string, container *domain.Container, attachmentIDs []string) (*domain.Container, []string, error) {
810	log := zerowrap.FromCtx(ctx)
811
812	if err := s.runtime.RestartContainer(ctx, container.ID); err != nil {
813		if !isContainerNotFoundError(err) {
814			return nil, nil, log.WrapErr(err, "failed to restart container")
815		}
816
817		// In-memory container ID can become stale after external runtime changes.
818		// Re-sync and retry once with the latest tracked container.
819		log.Warn().
820			Err(err).
821			Str(zerowrap.FieldEntityID, container.ID).
822			Msg("tracked container missing during restart, attempting state reconciliation")
823
824		if syncErr := s.SyncContainers(ctx); syncErr != nil {
825			log.Warn().Err(syncErr).Msg("failed to sync container state during restart recovery")
826		}
827
828		s.mu.RLock()
829		refreshed, refreshedExists := s.containers[domainName]
830		attachmentIDs = append([]string{}, s.attachments[domainName]...)
831		s.mu.RUnlock()
832
833		if !refreshedExists || refreshed == nil {
834			return nil, nil, domain.ErrContainerNotFound
835		}
836		if err := s.runtime.RestartContainer(ctx, refreshed.ID); err != nil {
837			return nil, nil, log.WrapErr(err, "failed to restart container after state reconciliation")
838		}
839		return refreshed, attachmentIDs, nil
840	}
841
842	return container, attachmentIDs, nil
843}
844
845// Restart restarts a running container for the given domain.
846func (s *Service) Restart(ctx context.Context, domainName string, withAttachments bool) error {
847	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
848		zerowrap.FieldLayer:   "usecase",
849		zerowrap.FieldUseCase: "Restart",
850		"domain":              domainName,
851	})
852	log := zerowrap.FromCtx(ctx)
853
854	s.mu.RLock()
855	container, exists := s.containers[domainName]
856	attachments := s.attachments[domainName]
857	var attachmentIDs []string
858	if len(attachments) > 0 {
859		attachmentIDs = make([]string, len(attachments))
860		copy(attachmentIDs, attachments)
861	}
862	s.mu.RUnlock()
863
864	if !exists || container == nil {
865		return domain.ErrContainerNotFound
866	}
867
868	// When attachments are requested, sync runtime state first to get accurate
869	// attachment tracking, then check if configured attachments are deployed.
870	// If fewer attachment containers are tracked than configured, fail early with
871	// guidance rather than restarting the main container into a broken state
872	// (e.g., missing database hostname).
873	if withAttachments {
874		if syncErr := s.SyncContainers(ctx); syncErr != nil {
875			log.Warn().Err(syncErr).Msg("failed to sync container state before attachment check")
876		}
877
878		// Re-read attachment IDs after sync
879		s.mu.RLock()
880		if freshAttachments := s.attachments[domainName]; len(freshAttachments) > 0 {
881			attachmentIDs = make([]string, len(freshAttachments))
882			copy(attachmentIDs, freshAttachments)
883		} else {
884			attachmentIDs = nil
885		}
886		s.mu.RUnlock()
887
888		configuredAttachments := s.resolveAttachmentsForDomain(domainName)
889		if len(configuredAttachments) > 0 && len(attachmentIDs) < len(configuredAttachments) {
890			missing := len(configuredAttachments) - len(attachmentIDs)
891			return fmt.Errorf("%w: domain %q has %d configured attachment(s) but only %d deployed (%d missing)",
892				domain.ErrAttachmentNotDeployed, domainName, len(configuredAttachments), len(attachmentIDs), missing)
893		}
894	}
895
896	// Restart the main container (with stale-state recovery)
897	container, attachmentIDs, err := s.restartContainerWithRecovery(ctx, domainName, container, attachmentIDs)
898	if err != nil {
899		return err
900	}
901	log.Info().Str(zerowrap.FieldEntityID, container.ID).Msg("container restarted")
902
903	// Record restart metric
904	if s.metrics != nil {
905		s.metrics.ContainerRestarts.Add(ctx, 1, metric.WithAttributes(
906			attribute.String("domain", domainName),
907			attribute.String("source", "api"),
908		))
909	}
910
911	// Restart attachments if requested
912	if withAttachments && len(attachmentIDs) > 0 {
913		for _, attachID := range attachmentIDs {
914			if err := s.runtime.RestartContainer(ctx, attachID); err != nil {
915				log.Warn().Err(err).Str("attachment_id", attachID).Msg("failed to restart attachment")
916				continue // Don't fail the whole operation for one attachment
917			}
918			log.Info().Str("attachment_id", attachID).Msg("attachment restarted")
919		}
920	}
921
922	return nil
923}
924
925// Stop stops a running container.
926func (s *Service) Stop(ctx context.Context, containerID string) error {
927	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
928		zerowrap.FieldLayer:    "usecase",
929		zerowrap.FieldUseCase:  "Stop",
930		zerowrap.FieldEntityID: containerID,
931	})
932	log := zerowrap.FromCtx(ctx)
933
934	// Stop log collection before stopping container
935	if s.logWriter != nil {
936		if err := s.logWriter.StopLogging(containerID); err != nil {
937			log.Warn().Err(err).Msg("failed to stop container log collection")
938		}
939	}
940
941	if err := s.runtime.StopContainer(ctx, containerID); err != nil {
942		return log.WrapErr(err, "failed to stop container")
943	}
944
945	log.Info().Msg("container stopped")
946	return nil
947}
948
949// Remove removes a container.
950func (s *Service) Remove(ctx context.Context, containerID string, force bool) error {
951	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
952		zerowrap.FieldLayer:    "usecase",
953		zerowrap.FieldUseCase:  "Remove",
954		zerowrap.FieldEntityID: containerID,
955	})
956	log := zerowrap.FromCtx(ctx)
957
958	// Stop log collection before removing container
959	if s.logWriter != nil {
960		if err := s.logWriter.StopLogging(containerID); err != nil {
961			log.Warn().Err(err).Msg("failed to stop container log collection")
962		}
963	}
964
965	// Find domain and attachment IDs for cleanup
966	containerDomain, attachmentIDs, cfg := s.findContainerContext(containerID)
967
968	if err := s.runtime.RemoveContainer(ctx, containerID, force); err != nil {
969		return log.WrapErr(err, "failed to remove container")
970	}
971
972	// Cleanup volumes
973	if containerDomain != "" && !cfg.VolumePreserve {
974		if err := s.cleanupVolumesForDomain(ctx, containerDomain); err != nil {
975			log.WrapErrWithFields(err, "failed to cleanup volumes", map[string]any{"domain": containerDomain})
976		}
977	}
978
979	// Clean up attachment containers
980	s.removeAttachments(ctx, attachmentIDs)
981
982	// Remove from tracking
983	s.mu.Lock()
984	var removedDomain string
985	for d, c := range s.containers {
986		if c.ID == containerID {
987			delete(s.containers, d)
988			removedDomain = d
989			s.managedCount--
990			log.Info().Str("domain", d).Msg("container removed")
991			break
992		}
993	}
994	if removedDomain != "" {
995		delete(s.attachments, removedDomain)
996	}
997	s.mu.Unlock()
998
999	// Decrement managed container count
1000	if removedDomain != "" && s.metrics != nil {
1001		s.metrics.ManagedContainers.Add(ctx, -1)
1002	}
1003
1004	// Note: We intentionally do NOT clean up deployMu entries to avoid a race condition:
1005	// If Remove deletes the mutex entry while a concurrent Deploy is acquiring the lock,
1006	// the Deploy would create a fresh mutex, breaking serialization and allowing concurrent
1007	// deploys to the same domain. The memory footprint is acceptable (one small struct per
1008	// domain ever deployed), and this is the safer choice for correctness.
1009
1010	// Cleanup network
1011	if removedDomain != "" && cfg.NetworkIsolation {
1012		networkName := s.getNetworkForApp(removedDomain)
1013		if err := s.cleanupNetworkIfEmpty(ctx, networkName); err != nil {
1014			log.WrapErrWithFields(err, "failed to cleanup network", map[string]any{"network": networkName})
1015		}
1016	}
1017
1018	return nil
1019}
1020
1021// findContainerContext looks up the domain, attachment IDs, and config snapshot for a container.
1022func (s *Service) findContainerContext(containerID string) (string, []string, Config) {
1023	s.mu.RLock()
1024	defer s.mu.RUnlock()
1025	cfg := s.config
1026	var containerDomain string
1027	for d, c := range s.containers {
1028		if c.ID == containerID {
1029			containerDomain = d
1030			break
1031		}
1032	}
1033	var attachmentIDs []string
1034	if containerDomain != "" {
1035		attachmentIDs = append(attachmentIDs, s.attachments[containerDomain]...)
1036	}
1037	return containerDomain, attachmentIDs, cfg
1038}
1039
1040// removeAttachments stops and removes attachment containers best-effort.
1041func (s *Service) removeAttachments(ctx context.Context, attachmentIDs []string) {
1042	log := zerowrap.FromCtx(ctx)
1043	for _, attachID := range attachmentIDs {
1044		if s.logWriter != nil {
1045			if err := s.logWriter.StopLogging(attachID); err != nil {
1046				log.Warn().Err(err).Str("attachment_id", attachID).Msg("failed to stop attachment log collection")
1047			}
1048		}
1049		if err := s.runtime.StopContainer(ctx, attachID); err != nil {
1050			log.Warn().Err(err).Str("attachment_id", attachID).Msg("failed to stop attachment container")
1051		}
1052		if err := s.runtime.RemoveContainer(ctx, attachID, true); err != nil {
1053			log.Warn().Err(err).Str("attachment_id", attachID).Msg("failed to remove attachment container")
1054		}
1055	}
1056}
1057
1058// Get retrieves a container by domain.
1059func (s *Service) Get(_ context.Context, domainName string) (*domain.Container, bool) {
1060	s.mu.RLock()
1061	defer s.mu.RUnlock()
1062	container, exists := s.containers[domainName]
1063	return container, exists
1064}
1065
1066// List returns all managed containers.
1067func (s *Service) List(_ context.Context) map[string]*domain.Container {
1068	s.mu.RLock()
1069	defer s.mu.RUnlock()
1070
1071	result := make(map[string]*domain.Container, len(s.containers))
1072	maps.Copy(result, s.containers)
1073	return result
1074}
1075
1076// ListRoutesWithDetails returns routes with network and attachment info.
1077// Note: Container map is copied under lock, then external calls are made without lock.
1078// If containers are removed between copy and runtime calls, errors are handled gracefully
1079// (network becomes empty string). This trade-off avoids holding locks during I/O.
1080func (s *Service) ListRoutesWithDetails(ctx context.Context) []domain.RouteInfo {
1081	s.mu.RLock()
1082	containers := make(map[string]*domain.Container, len(s.containers))
1083	maps.Copy(containers, s.containers)
1084	s.mu.RUnlock()
1085
1086	// Fetch all attachments once to avoid N+1 queries
1087	attachmentsByDomain := s.getAllAttachments(ctx)
1088
1089	results := make([]domain.RouteInfo, 0, len(containers))
1090	for domainName, container := range containers {
1091		network := ""
1092		image := ""
1093		containerID := ""
1094		status := ""
1095		if container != nil {
1096			containerID = container.ID
1097			status = container.Status
1098			image = container.Image
1099			if container.Labels != nil {
1100				if labelImage, ok := container.Labels[domain.LabelImage]; ok && labelImage != "" {
1101					image = labelImage
1102				}
1103			}
1104			// Strip registry domain prefix from image for cleaner display
1105			image = s.stripRegistryPrefix(image)
1106			if networkName, err := s.runtime.GetContainerNetwork(ctx, container.ID); err == nil {
1107				network = networkName
1108			}
1109		}
1110
1111		results = append(results, domain.RouteInfo{
1112			Domain:          domainName,
1113			Image:           image,
1114			ContainerID:     containerID,
1115			ContainerStatus: status,
1116			Network:         network,
1117			Attachments:     attachmentsByDomain[domainName],
1118		})
1119	}
1120
1121	return results
1122}
1123
1124// stripRegistryPrefix removes the configured registry domain prefix from an image reference.
1125// For example, "reg.example.com/myapp:latest" becomes "myapp:latest" if registry domain is "reg.example.com".
1126func (s *Service) stripRegistryPrefix(image string) string {
1127	s.mu.RLock()
1128	cfg := s.config
1129	s.mu.RUnlock()
1130
1131	if cfg.RegistryDomain == "" {
1132		return image
1133	}
1134	prefix := strings.TrimSuffix(cfg.RegistryDomain, "/") + "/"
1135	if strings.HasPrefix(image, prefix) {
1136		return strings.TrimPrefix(image, prefix)
1137	}
1138	return image
1139}
1140
1141// ListAttachments returns attachments for a domain.
1142func (s *Service) ListAttachments(ctx context.Context, domainName string) []domain.Attachment {
1143	return s.getAttachmentsForDomain(ctx, domainName)
1144}
1145
1146// ListNetworks returns Gordon-managed networks.
1147func (s *Service) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) {
1148	s.mu.RLock()
1149	cfg := s.config
1150	s.mu.RUnlock()
1151
1152	networks, err := s.runtime.ListNetworks(ctx)
1153	if err != nil {
1154		return nil, err
1155	}
1156
1157	var filtered []*domain.NetworkInfo
1158	for _, network := range networks {
1159		if strings.HasPrefix(network.Name, cfg.NetworkPrefix+"-") && network.Labels[domain.LabelManaged] == "true" {
1160			filtered = append(filtered, network)
1161		}
1162	}
1163
1164	return filtered, nil
1165}
1166
1167// HealthCheck performs health checks on all containers.
1168func (s *Service) HealthCheck(ctx context.Context) map[string]bool {
1169	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1170		zerowrap.FieldLayer:   "usecase",
1171		zerowrap.FieldUseCase: "HealthCheck",
1172	})
1173	log := zerowrap.FromCtx(ctx)
1174
1175	// Copy container IDs under lock, then release before making Docker API calls
1176	s.mu.RLock()
1177	snapshot := make(map[string]string, len(s.containers))
1178	for d, c := range s.containers {
1179		snapshot[d] = c.ID
1180	}
1181	s.mu.RUnlock()
1182
1183	health := make(map[string]bool, len(snapshot))
1184	for d, id := range snapshot {
1185		running, err := s.runtime.IsContainerRunning(ctx, id)
1186		if err != nil {
1187			log.WrapErrWithFields(err, "health check failed", map[string]any{"domain": d, zerowrap.FieldEntityID: id})
1188			health[d] = false
1189		} else {
1190			health[d] = running
1191		}
1192	}
1193	return health
1194}
1195
1196// SyncContainers synchronizes containers with runtime state.
1197func (s *Service) SyncContainers(ctx context.Context) error {
1198	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1199		zerowrap.FieldLayer:   "usecase",
1200		zerowrap.FieldUseCase: "SyncContainers",
1201	})
1202	log := zerowrap.FromCtx(ctx)
1203
1204	// List containers without holding lock to avoid blocking during Docker API call
1205	allContainers, err := s.runtime.ListContainers(ctx, false)
1206	if err != nil {
1207		return log.WrapErr(err, "failed to list containers")
1208	}
1209
1210	managed := make(map[string]*domain.Container)
1211	attachments := make(map[string][]string)
1212	for _, c := range allContainers {
1213		if c.Labels == nil || c.Labels[domain.LabelManaged] != "true" {
1214			continue
1215		}
1216		if c.Labels[domain.LabelAttachment] == "true" {
1217			owner := c.Labels[domain.LabelAttachedTo]
1218			if owner != "" {
1219				attachments[owner] = append(attachments[owner], c.ID)
1220			}
1221			continue
1222		}
1223		if d, ok := c.Labels[domain.LabelDomain]; ok {
1224			managed[d] = c
1225		}
1226	}
1227
1228	s.mu.Lock()
1229	s.containers = managed
1230	s.attachments = attachments
1231	newCount := int64(len(managed))
1232	delta := newCount - s.managedCount
1233	s.managedCount = newCount
1234	s.mu.Unlock()
1235
1236	// Report initial/delta managed container count to OTel UpDownCounter.
1237	if delta != 0 && s.metrics != nil {
1238		s.metrics.ManagedContainers.Add(ctx, delta)
1239	}
1240
1241	log.Info().Int(zerowrap.FieldCount, len(managed)).Msg("container state synchronized")
1242	return nil
1243}
1244
1245// AutoStart starts containers for the provided routes that aren't running.
1246// It skips readiness checks to avoid blocking boot; the background monitor
1247// handles crash recovery.
1248func (s *Service) AutoStart(ctx context.Context, routes []domain.Route) error {
1249	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1250		zerowrap.FieldLayer:   "usecase",
1251		zerowrap.FieldUseCase: "AutoStart",
1252	})
1253	log := zerowrap.FromCtx(ctx)
1254
1255	log.Info().Int("route_count", len(routes)).Msg("auto-starting containers for configured routes")
1256
1257	// Filter out routes that already have a running container.
1258	var pending []domain.Route
1259	skipped := 0
1260	for _, route := range routes {
1261		if _, exists := s.Get(ctx, route.Domain); exists {
1262			log.Debug().Str("domain", route.Domain).Msg("container already running, skipping")
1263			skipped++
1264		} else {
1265			pending = append(pending, route)
1266		}
1267	}
1268
1269	if len(pending) == 0 {
1270		log.Info().Int("skipped", skipped).Msg("auto-start completed, all containers already running")
1271		return nil
1272	}
1273
1274	// Deploy all pending routes concurrently with readiness checks skipped.
1275	deployCtx := domain.WithSkipReadiness(ctx)
1276
1277	type result struct {
1278		route domain.Route
1279		err   error
1280	}
1281	results := make(chan result, len(pending))
1282	sem := make(chan struct{}, 4) // limit concurrency
1283
1284	for _, route := range pending {
1285		sem <- struct{}{}
1286		go func(r domain.Route) {
1287			defer func() { <-sem }()
1288
1289			log.Info().Str("domain", r.Domain).Str("image", r.Image).Msg("auto-starting container for route")
1290
1291			if _, err := s.Deploy(deployCtx, r); err != nil {
1292				results <- result{route: r, err: err}
1293				return
1294			}
1295			results <- result{route: r}
1296		}(route)
1297	}
1298
1299	var started, errCount int
1300	for i := 0; i < len(pending); i++ {
1301		res := <-results
1302		if res.err != nil {
1303			log.Warn().Err(res.err).Str("domain", res.route.Domain).Msg("failed to auto-start container")
1304			errCount++
1305		} else {
1306			started++
1307		}
1308	}
1309
1310	log.Info().
1311		Int("started", started).
1312		Int("skipped", skipped).
1313		Int("errors", errCount).
1314		Msg("auto-start completed")
1315
1316	if errCount > 0 {
1317		return fmt.Errorf("auto-start completed with %d errors", errCount)
1318	}
1319	return nil
1320}
1321
1322// Shutdown gracefully shuts down all managed containers.
1323func (s *Service) Shutdown(ctx context.Context) error {
1324	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1325		zerowrap.FieldLayer:   "usecase",
1326		zerowrap.FieldUseCase: "Shutdown",
1327	})
1328	log := zerowrap.FromCtx(ctx)
1329	log.Info().Msg("shutting down container manager...")
1330
1331	// Wait for any in-flight background cleanup goroutines to finish
1332	s.cleanupWg.Wait()
1333
1334	// Containers are left running across Gordon restarts.
1335	// SyncContainers + AutoStart will pick them back up on next boot.
1336
1337	// Close log writer to stop all log collection
1338	if s.logWriter != nil {
1339		if err := s.logWriter.Close(); err != nil {
1340			log.Warn().Err(err).Msg("failed to close container log writer")
1341		}
1342	}
1343
1344	log.Info().Msg("container manager shutdown complete")
1345	return nil
1346}
1347
1348// WaitForCleanup blocks until all background cleanup goroutines complete.
1349// Intended for use in tests to avoid mock assertion races.
1350func (s *Service) WaitForCleanup() {
1351	s.cleanupWg.Wait()
1352}
1353
1354// StartMonitor begins background monitoring of tracked containers.
1355// The monitor restarts crashed containers and detects crash loops.
1356// Safe to call multiple times; subsequent calls are no-ops.
1357func (s *Service) StartMonitor(ctx context.Context) {
1358	s.mu.Lock()
1359	if s.monitor != nil {
1360		s.mu.Unlock()
1361		return
1362	}
1363	s.monitor = newMonitor(s)
1364	m := s.monitor
1365	m.Start(ctx)
1366	s.mu.Unlock()
1367}
1368
1369// StopMonitor stops the background container monitor.
1370// Safe to call multiple times; subsequent calls are no-ops.
1371func (s *Service) StopMonitor() {
1372	s.mu.Lock()
1373	m := s.monitor
1374	s.monitor = nil
1375	s.mu.Unlock()
1376	if m != nil {
1377		m.Stop()
1378	}
1379}
1380
1381// UpdateConfig updates the service configuration.
1382func (s *Service) UpdateConfig(config Config) {
1383	s.mu.Lock()
1384	s.config = config
1385	s.mu.Unlock()
1386}
1387
1388// UpdateAttachments updates only the attachment configuration in the service.
1389// This is called after a config reload to propagate attachment changes without restart.
1390// The incoming map is deep-copied so external callers cannot mutate service state.
1391func (s *Service) UpdateAttachments(attachments map[string][]string) {
1392	var copied map[string][]string
1393	if attachments != nil {
1394		copied = make(map[string][]string, len(attachments))
1395		for k, v := range attachments {
1396			sl := make([]string, len(v))
1397			copy(sl, v)
1398			copied[k] = sl
1399		}
1400	}
1401	s.mu.Lock()
1402	s.config.Attachments = copied
1403	s.mu.Unlock()
1404}
1405
1406// Helper methods
1407
1408// cleanupFailedContainer stops and removes a container that failed to start properly.
1409func (s *Service) cleanupFailedContainer(ctx context.Context, containerID string) {
1410	log := zerowrap.FromCtx(ctx)
1411	if err := s.runtime.StopContainer(ctx, containerID); err != nil {
1412		log.Warn().Err(err).Str(zerowrap.FieldEntityID, containerID).Msg("failed to stop container after failure")
1413	}
1414	if err := s.runtime.RemoveContainer(ctx, containerID, true); err != nil {
1415		log.Warn().Err(err).Str(zerowrap.FieldEntityID, containerID).Msg("failed to remove container after failure")
1416	}
1417}
1418
1419// cleanupOldContainer stops and removes an old container after zero-downtime switch.
1420// It also renames the new container to the canonical name.
1421func (s *Service) cleanupOldContainer(ctx context.Context, old *domain.Container, newContainerID, domainName string) {
1422	log := zerowrap.FromCtx(ctx)
1423	log.Info().Str(zerowrap.FieldEntityID, old.ID).Msg("stopping old container after zero-downtime switch")
1424
1425	if s.logWriter != nil {
1426		if err := s.logWriter.StopLogging(old.ID); err != nil {
1427			log.Warn().Err(err).Str(zerowrap.FieldEntityID, old.ID).Msg("failed to stop logging for old container")
1428		}
1429	}
1430
1431	if err := s.runtime.StopContainer(ctx, old.ID); err != nil {
1432		log.Warn().Err(err).Str(zerowrap.FieldEntityID, old.ID).Msg("failed to stop old container")
1433	}
1434
1435	if err := s.runtime.RemoveContainer(ctx, old.ID, true); err != nil {
1436		log.Warn().Err(err).Str(zerowrap.FieldEntityID, old.ID).Msg("failed to remove old container")
1437	}
1438
1439	// Rename new container to canonical name
1440	canonicalName := fmt.Sprintf("gordon-%s", domainName)
1441	if err := s.runtime.RenameContainer(ctx, newContainerID, canonicalName); err != nil {
1442		log.Warn().Err(err).Str("canonical_name", canonicalName).Msg("failed to rename container to canonical name")
1443	}
1444}
1445
1446// startLogCollection starts log collection for a container in the background.
1447// Errors are logged but don't fail the calling operation.
1448func (s *Service) startLogCollection(ctx context.Context, containerID, domainName string) {
1449	if s.logWriter == nil {
1450		return
1451	}
1452
1453	log := zerowrap.FromCtx(ctx)
1454
1455	// Use context.WithoutCancel so the log stream outlives the HTTP request.
1456	// Without this, the Docker log stream closes when the deploy response completes.
1457	bgCtx := context.WithoutCancel(ctx)
1458
1459	logStream, err := s.runtime.GetContainerLogs(bgCtx, containerID, true)
1460	if err != nil {
1461		log.Warn().Err(err).Msg("failed to get container logs for collection")
1462		return
1463	}
1464
1465	if err := s.logWriter.StartLogging(bgCtx, containerID, domainName, logStream); err != nil {
1466		log.Warn().Err(err).Msg("failed to start container log collection")
1467		logStream.Close()
1468	}
1469}
1470
1471func (s *Service) buildImageRef(image string) string {
1472	s.mu.RLock()
1473	cfg := s.config
1474	s.mu.RUnlock()
1475
1476	if !cfg.RegistryAuthEnabled || cfg.RegistryDomain == "" {
1477		return image
1478	}
1479
1480	// Normalize registry domain by trimming any trailing slash
1481	reg := strings.TrimSuffix(cfg.RegistryDomain, "/")
1482
1483	// Check if image already has the registry domain prefix
1484	prefix := reg + "/"
1485	if strings.HasPrefix(image, prefix) {
1486		return image
1487	}
1488
1489	// Check if image already has an explicit registry (host:port/ or host.domain/).
1490	// We need to detect patterns like:
1491	// - "docker.io/library/nginx:latest" (has dot and slash)
1492	// - "localhost:5001/myapp:latest" (has host:port/ pattern)
1493	// - "myregistry:5000/app:v1" (has host:port/ pattern)
1494	// - "[fd00::1]:5000/app:v1" (IPv6 with port)
1495	if hasExplicitRegistry(image) {
1496		return image
1497	}
1498
1499	return fmt.Sprintf("%s/%s", reg, image)
1500}
1501
1502// hasExplicitRegistry checks if an image reference already includes an explicit registry.
1503// Returns true for patterns like "docker.io/image", "localhost:5000/image", "localhost/image",
1504// "registry:8080/image", "[fd00::1]/image", "[fd00::1]:5000/image".
1505func hasExplicitRegistry(image string) bool {
1506	// Find the first slash which separates registry from image name
1507	slashIdx := strings.Index(image, "/")
1508	if slashIdx == -1 {
1509		return false // No slash means no registry prefix (e.g., "myapp:latest")
1510	}
1511
1512	// Extract the part before the first slash (potential registry)
1513	registryPart := image[:slashIdx]
1514
1515	// Check for bracketed IPv6 address (e.g., "[fd00::1]" or "[fd00::1]:5000")
1516	if strings.HasPrefix(registryPart, "[") {
1517		// Look for closing bracket
1518		if closeBracket := strings.Index(registryPart, "]"); closeBracket != -1 {
1519			// Valid bracketed IPv6: either ends at ] or has ]:port
1520			return true
1521		}
1522	}
1523
1524	// Check for localhost (with or without port)
1525	if registryPart == "localhost" {
1526		return true
1527	}
1528
1529	// Check for host:port pattern (e.g., "localhost:5000", "registry:8080")
1530	if colonIdx := strings.LastIndex(registryPart, ":"); colonIdx != -1 {
1531		port := registryPart[colonIdx+1:]
1532		// If everything after colon is digits, it's a port
1533		if len(port) > 0 && isNumeric(port) {
1534			return true
1535		}
1536	}
1537
1538	// Check for domain pattern (has a dot, e.g., "docker.io", "gcr.io")
1539	if strings.Contains(registryPart, ".") {
1540		return true
1541	}
1542
1543	return false
1544}
1545
1546// isNumeric checks if a string contains only digits.
1547func isNumeric(s string) bool {
1548	for _, c := range s {
1549		if c < '0' || c > '9' {
1550			return false
1551		}
1552	}
1553	return true
1554}
1555
1556func normalizePullPolicy(policy string) string {
1557	switch strings.ToLower(strings.TrimSpace(policy)) {
1558	case PullPolicyAlways:
1559		return PullPolicyAlways
1560	case PullPolicyIfTagChanged:
1561		return PullPolicyIfTagChanged
1562	case PullPolicyIfNotPresent:
1563		return PullPolicyIfNotPresent
1564	default:
1565		return PullPolicyIfNotPresent
1566	}
1567}
1568
1569func isDigestRef(imageRef string) bool {
1570	return strings.Contains(imageRef, "@sha256:")
1571}
1572
1573func (s *Service) pullRefForDeploy(ctx context.Context, imageRef string) (string, bool) {
1574	if !domain.IsInternalDeploy(ctx) {
1575		return imageRef, false
1576	}
1577	s.mu.RLock()
1578	cfg := s.config
1579	s.mu.RUnlock()
1580	return rewriteToLocalRegistry(imageRef, cfg.RegistryDomain, cfg.RegistryPort), true
1581}
1582
1583// ensureImage ensures the image is available locally, pulling if needed.
1584// Returns the image reference to use for container operations:
1585//   - For digest references (@sha256:...), returns the pullRef since Docker can't tag digests
1586//   - For tagged images, returns the original imageRef after tagging the pulled image
1587func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, error) {
1588	ctx, span := tracer.Start(ctx, "container.ensure_image",
1589		trace.WithAttributes(attribute.String("image", imageRef)))
1590	defer span.End()
1591
1592	ctx = zerowrap.CtxWithField(ctx, "image", imageRef)
1593	log := zerowrap.FromCtx(ctx)
1594
1595	// Determine if this is an internal deploy and what reference to use for pulls.
1596	pullRef, isInternal := s.pullRefForDeploy(ctx, imageRef)
1597	if pullRef != imageRef {
1598		log.Info().
1599			Str("original_ref", imageRef).
1600			Str("pull_ref", pullRef).
1601			Msg("internal deploy: using localhost registry for pull")
1602	}
1603
1604	found, err := s.ensureLocalImage(ctx, imageRef, pullRef)
1605	if err != nil {
1606		return "", err
1607	}
1608	if found {
1609		return imageRef, nil
1610	}
1611
1612	// Pull image
1613	log.Info().Msg("pulling image from registry")
1614
1615	if err := s.pullImage(ctx, pullRef, isInternal); err != nil {
1616		return "", err
1617	}
1618
1619	// For digest references, we can't create a tag (Docker doesn't allow it).
1620	// In this case, use the pullRef directly since the image is available under that reference.
1621	if strings.Contains(imageRef, "@sha256:") {
1622		log.Info().
1623			Str("pull_ref", pullRef).
1624			Msg("digest reference: using pull reference for container operations")
1625		return pullRef, nil
1626	}
1627
1628	if err := s.tagImageIfNeeded(ctx, pullRef, imageRef); err != nil {
1629		return "", err
1630	}
1631
1632	// Clean up the temporary pull reference tag to avoid duplicate entries
1633	if pullRef != imageRef {
1634		if err := s.runtime.UntagImage(ctx, pullRef); err != nil {
1635			// Log but don't fail - the canonical tag is already applied
1636			log.Debug().Err(err).Str("pull_ref", pullRef).Msg("failed to remove temporary pull tag")
1637		}
1638	}
1639
1640	log.Info().Msg("image pulled successfully")
1641	return imageRef, nil
1642}
1643
1644func (s *Service) ensureLocalImage(ctx context.Context, imageRef, pullRef string) (bool, error) {
1645	log := zerowrap.FromCtx(ctx)
1646
1647	// For internal deploys (image push events), always pull fresh image
1648	// because the same tag may reference new content (e.g., latest tag updated)
1649	if domain.IsInternalDeploy(ctx) {
1650		log.Info().Msg("internal deploy detected, forcing image pull to ensure latest content")
1651		return false, nil
1652	}
1653
1654	s.mu.RLock()
1655	cfg := s.config
1656	s.mu.RUnlock()
1657
1658	pullPolicy := normalizePullPolicy(cfg.PullPolicy)
1659	switch pullPolicy {
1660	case PullPolicyAlways:
1661		log.Info().Str("pull_policy", pullPolicy).Msg("pull policy forces image pull")
1662		return false, nil
1663	case PullPolicyIfTagChanged:
1664		if !isDigestRef(imageRef) {
1665			log.Info().Str("pull_policy", pullPolicy).Msg("tag reference detected, pulling to check for updates")
1666			return false, nil
1667		}
1668	}
1669
1670	localImages, err := s.runtime.ListImages(ctx)
1671	if err != nil {
1672		log.WrapErr(err, "failed to list local images, will attempt pull")
1673		return false, nil
1674	}
1675
1676	normalizedRef := normalizeImageRef(imageRef)
1677	normalizedPullRef := normalizeImageRef(pullRef)
1678	for _, img := range localImages {
1679		normalizedImage := normalizeImageRef(img)
1680		if normalizedImage == normalizedRef {
1681			log.Info().Msg("image found locally, skipping pull")
1682			return true, nil
1683		}
1684		if normalizedImage == normalizedPullRef {
1685			if err := s.tagImageIfNeeded(ctx, pullRef, imageRef); err != nil {
1686				return false, err
1687			}
1688			// Clean up the temporary pull reference tag
1689			if pullRef != imageRef {
1690				if err := s.runtime.UntagImage(ctx, pullRef); err != nil {
1691					log.Debug().Err(err).Str("pull_ref", pullRef).Msg("failed to remove temporary pull tag")
1692				}
1693			}
1694			log.Info().Msg("image found locally, skipping pull")
1695			return true, nil
1696		}
1697	}
1698
1699	return false, nil
1700}
1701
1702func (s *Service) pullImage(ctx context.Context, pullRef string, isInternal bool) error {
1703	s.mu.RLock()
1704	cfg := s.config
1705	s.mu.RUnlock()
1706
1707	log := zerowrap.FromCtx(ctx)
1708
1709	switch {
1710	case isInternal && cfg.RegistryAuthEnabled:
1711		if cfg.InternalRegistryUsername == "" || cfg.InternalRegistryPassword == "" {
1712			return log.WrapErr(fmt.Errorf("internal registry auth not configured"), "failed to pull image for internal deploy")
1713		}
1714		if err := s.pullImageWithRetry(ctx, func(ctx context.Context) error {
1715			return s.runtime.PullImageWithAuth(ctx, pullRef, cfg.InternalRegistryUsername, cfg.InternalRegistryPassword)
1716		}, isInternal); err != nil {
1717			return log.WrapErr(err, "failed to pull image with internal auth")
1718		}
1719	case isInternal:
1720		if err := s.pullImageWithRetry(ctx, func(ctx context.Context) error {
1721			return s.runtime.PullImage(ctx, pullRef)
1722		}, isInternal); err != nil {
1723			return log.WrapErr(err, "failed to pull image")
1724		}
1725	case cfg.RegistryAuthEnabled:
1726		if cfg.ServiceTokenUsername == "" || cfg.ServiceToken == "" {
1727			return log.WrapErr(fmt.Errorf("registry service token not configured"), "failed to pull image for registry auth")
1728		}
1729		if err := s.runtime.PullImageWithAuth(ctx, pullRef, cfg.ServiceTokenUsername, cfg.ServiceToken); err != nil {
1730			return log.WrapErr(err, "failed to pull image with auth")
1731		}
1732	default:
1733		if err := s.runtime.PullImage(ctx, pullRef); err != nil {
1734			return log.WrapErr(err, "failed to pull image")
1735		}
1736	}
1737
1738	return nil
1739}
1740
1741func (s *Service) pullImageWithRetry(ctx context.Context, pullFn func(context.Context) error, isInternal bool) error {
1742	log := zerowrap.FromCtx(ctx)
1743
1744	for attempt := 1; attempt <= internalPullMaxAttempts; attempt++ {
1745		err := pullFn(ctx)
1746		if err == nil {
1747			return nil
1748		}
1749		if !isInternal || !isConnectionRefusedError(err) || attempt == internalPullMaxAttempts {
1750			return fmt.Errorf("internal image pull failed after %d attempts: %w", attempt, err)
1751		}
1752
1753		backoff := time.Duration(attempt) * time.Second
1754		log.Warn().
1755			Err(err).
1756			Int("attempt", attempt).
1757			Dur("backoff", backoff).
1758			Msg("internal image pull failed with connection refused, retrying")
1759
1760		select {
1761		case <-time.After(backoff):
1762		case <-ctx.Done():
1763			return ctx.Err()
1764		}
1765	}
1766	return nil
1767}
1768
1769func (s *Service) tagImageIfNeeded(ctx context.Context, sourceRef, targetRef string) error {
1770	if sourceRef == targetRef {
1771		return nil
1772	}
1773
1774	// Cannot create a tag with a digest reference - Docker/Podman doesn't allow it.
1775	// When using digest references (image@sha256:...), skip tagging as the image
1776	// is already available by its digest.
1777	if strings.Contains(targetRef, "@sha256:") {
1778		return nil
1779	}
1780
1781	log := zerowrap.FromCtx(ctx)
1782	if err := s.runtime.TagImage(ctx, sourceRef, targetRef); err != nil {
1783		return log.WrapErr(err, "failed to tag image from pull reference")
1784	}
1785	return nil
1786}
1787
1788func (s *Service) loadEnvironment(ctx context.Context, preResolved []string, domainName, imageRef string) ([]string, error) {
1789	log := zerowrap.FromCtx(ctx)
1790
1791	var userEnvVars []string
1792	if len(preResolved) > 0 {
1793		userEnvVars = preResolved
1794	} else {
1795		var err error
1796		userEnvVars, err = s.envLoader.LoadEnv(ctx, domainName)
1797		if err != nil {
1798			return nil, log.WrapErr(err, "failed to load environment variables")
1799		}
1800	}
1801
1802	dockerfileEnvVars, err := s.runtime.InspectImageEnv(ctx, imageRef)
1803	if err != nil {
1804		log.WrapErr(err, "failed to inspect image environment")
1805		dockerfileEnvVars = []string{}
1806	}
1807
1808	return mergeEnvironmentVariables(dockerfileEnvVars, userEnvVars), nil
1809}
1810
1811func (s *Service) setupVolumes(ctx context.Context, domainName, imageRef string) (map[string]string, error) {
1812	s.mu.RLock()
1813	cfg := s.config
1814	s.mu.RUnlock()
1815
1816	log := zerowrap.FromCtx(ctx)
1817	volumes := make(map[string]string)
1818
1819	if !cfg.VolumeAutoCreate {
1820		return volumes, nil
1821	}
1822
1823	volumePaths, err := s.runtime.InspectImageVolumes(ctx, imageRef)
1824	if err != nil {
1825		log.WrapErr(err, "failed to inspect image volumes")
1826		return volumes, nil
1827	}
1828
1829	for _, path := range volumePaths {
1830		name := generateVolumeName(cfg.VolumePrefix, domainName, path)
1831
1832		exists, err := s.runtime.VolumeExists(ctx, name)
1833		if err != nil {
1834			log.WrapErrWithFields(err, "failed to check volume", map[string]any{"volume": name})
1835			continue
1836		}
1837
1838		if !exists {
1839			if err := s.runtime.CreateVolume(ctx, name); err != nil {
1840				log.WrapErrWithFields(err, "failed to create volume", map[string]any{"volume": name})
1841				continue
1842			}
1843			log.Info().Str("volume", name).Str(zerowrap.FieldPath, path).Msg("created volume")
1844		}
1845
1846		volumes[path] = name
1847	}
1848
1849	return volumes, nil
1850}
1851
1852func (s *Service) getNetworkForApp(domainName string) string {
1853	var networkIsolation bool
1854	var networkGroups map[string][]string
1855
1856	if s.configProvider != nil {
1857		snap := s.configProvider.GetAttachmentConfig()
1858		networkGroups = snap.NetworkGroups
1859		s.mu.RLock()
1860		networkIsolation = s.config.NetworkIsolation
1861		s.mu.RUnlock()
1862	} else {
1863		s.mu.RLock()
1864		networkIsolation = s.config.NetworkIsolation
1865		networkGroups = s.config.NetworkGroups
1866		s.mu.RUnlock()
1867	}
1868
1869	if !networkIsolation {
1870		return "bridge"
1871	}
1872
1873	for groupName, domains := range networkGroups {
1874		if slices.Contains(domains, domainName) {
1875			return s.generateNetworkName(groupName)
1876		}
1877	}
1878
1879	return s.generateNetworkName(domainName)
1880}
1881
1882func (s *Service) generateNetworkName(identifier string) string {
1883	s.mu.RLock()
1884	cfg := s.config
1885	s.mu.RUnlock()
1886
1887	return fmt.Sprintf("%s-%s", cfg.NetworkPrefix, strings.ReplaceAll(identifier, ".", "-"))
1888}
1889
1890func (s *Service) createNetworkIfNeeded(ctx context.Context, networkName string) error {
1891	if networkName == "bridge" || networkName == "default" {
1892		return nil
1893	}
1894
1895	ctx = zerowrap.CtxWithField(ctx, "network", networkName)
1896	log := zerowrap.FromCtx(ctx)
1897
1898	exists, err := s.runtime.NetworkExists(ctx, networkName)
1899	if err != nil {
1900		return log.WrapErr(err, "failed to check network existence")
1901	}
1902
1903	if !exists {
1904		if err := s.runtime.CreateNetwork(ctx, networkName, map[string]string{"driver": "bridge"}); err != nil {
1905			return log.WrapErr(err, "failed to create network")
1906		}
1907		log.Info().Msg("created network for app isolation")
1908	}
1909
1910	return nil
1911}
1912
1913func (s *Service) cleanupOrphanedContainers(ctx context.Context, domainName string, skipContainerID string) error {
1914	log := zerowrap.FromCtx(ctx)
1915	expectedName := fmt.Sprintf("gordon-%s", domainName)
1916	expectedNewName := expectedName + "-new"
1917	expectedNextName := expectedName + "-next"
1918
1919	allContainers, err := s.runtime.ListContainers(ctx, true)
1920	if err != nil {
1921		return err
1922	}
1923
1924	for _, c := range allContainers {
1925		if (c.Name == expectedName || c.Name == expectedNewName || c.Name == expectedNextName) && c.ID != skipContainerID {
1926			// Skip any running container regardless of name — a running
1927			// temp container may be actively serving traffic while the
1928			// new container stabilizes, or may be from a concurrent deploy.
1929			if c.Status == "running" {
1930				log.Debug().
1931					Str(zerowrap.FieldEntityID, c.ID).
1932					Str("container_name", c.Name).
1933					Str(zerowrap.FieldStatus, c.Status).
1934					Msg("skipping running container during orphan cleanup")
1935				continue
1936			}
1937
1938			log.Info().Str(zerowrap.FieldEntityID, c.ID).Str("container_name", c.Name).Str(zerowrap.FieldStatus, c.Status).Msg("found orphaned container, removing")
1939
1940			if err := s.runtime.StopContainer(ctx, c.ID); err != nil {
1941				log.WrapErrWithFields(err, "failed to stop orphaned container", map[string]any{zerowrap.FieldEntityID: c.ID})
1942			}
1943
1944			if err := s.runtime.RemoveContainer(ctx, c.ID, true); err != nil {
1945				log.WrapErrWithFields(err, "failed to remove orphaned container", map[string]any{zerowrap.FieldEntityID: c.ID})
1946			}
1947		}
1948	}
1949
1950	return nil
1951}
1952
1953func (s *Service) cleanupVolumesForDomain(_ context.Context, _ string) error {
1954	return nil
1955}
1956
1957func (s *Service) cleanupNetworkIfEmpty(ctx context.Context, networkName string) error {
1958	if networkName == "bridge" || networkName == "default" {
1959		return nil
1960	}
1961
1962	ctx = zerowrap.CtxWithField(ctx, "network", networkName)
1963	log := zerowrap.FromCtx(ctx)
1964
1965	networks, err := s.runtime.ListNetworks(ctx)
1966	if err != nil {
1967		return log.WrapErr(err, "failed to list networks")
1968	}
1969
1970	for _, n := range networks {
1971		if n.Name == networkName && len(n.Containers) == 0 {
1972			if err := s.runtime.RemoveNetwork(ctx, networkName); err != nil {
1973				return err
1974			}
1975			log.Info().Msg("cleaned up empty network")
1976			break
1977		}
1978	}
1979
1980	return nil
1981}
1982
1983func (s *Service) deployAttachments(ctx context.Context, domainName, networkName string) error {
1984	// Collect attachments from both domain-specific and network group configs
1985	attachments := s.resolveAttachmentsForDomain(domainName)
1986	if len(attachments) == 0 {
1987		return nil
1988	}
1989
1990	log := zerowrap.FromCtx(ctx)
1991	var deployed []string // track successfully deployed attachment IDs for rollback
1992	for _, svc := range attachments {
1993		if err := s.deployAttachedService(ctx, domainName, svc, networkName); err != nil {
1994			log.WrapErrWithFields(err, "failed to deploy attachment", map[string]any{zerowrap.FieldService: svc, "domain": domainName})
1995
1996			// Rollback: clean up already-deployed attachments
1997			for _, id := range deployed {
1998				if stopErr := s.runtime.StopContainer(ctx, id); stopErr != nil {
1999					log.Warn().Err(stopErr).Str("attachment_id", id).Msg("rollback: failed to stop attachment")
2000				}
2001				if rmErr := s.runtime.RemoveContainer(ctx, id, true); rmErr != nil {
2002					log.Warn().Err(rmErr).Str("attachment_id", id).Msg("rollback: failed to remove attachment")
2003				}
2004			}
2005			// Deregister rolled-back attachments
2006			if len(deployed) > 0 {
2007				s.mu.Lock()
2008				remaining := s.attachments[domainName]
2009				filtered := remaining[:0]
2010				rollbackSet := make(map[string]bool, len(deployed))
2011				for _, id := range deployed {
2012					rollbackSet[id] = true
2013				}
2014				for _, id := range remaining {
2015					if !rollbackSet[id] {
2016						filtered = append(filtered, id)
2017					}
2018				}
2019				s.attachments[domainName] = filtered
2020				s.mu.Unlock()
2021			}
2022
2023			return fmt.Errorf("failed to deploy attachment %q (rolled back %d already-deployed)", svc, len(deployed))
2024		}
2025		// Collect IDs of attachments tracked under this domain after successful deploy
2026		s.mu.RLock()
2027		ids := s.attachments[domainName]
2028		if len(ids) > 0 {
2029			latest := ids[len(ids)-1]
2030			deployed = append(deployed, latest)
2031		}
2032		s.mu.RUnlock()
2033	}
2034	return nil
2035}
2036
2037// resolveAttachmentsForDomain returns attachments for a domain by checking:
2038// 1. Direct domain attachments (attachments[domain])
2039// 2. Network group attachments (attachments[group] where domain is in network_groups[group])
2040func (s *Service) resolveAttachmentsForDomain(domainName string) []string {
2041	var attachments map[string][]string
2042	var networkGroups map[string][]string
2043
2044	if s.configProvider != nil {
2045		snap := s.configProvider.GetAttachmentConfig()
2046		attachments = snap.Attachments
2047		networkGroups = snap.NetworkGroups
2048	} else {
2049		s.mu.RLock()
2050		attachments = s.config.Attachments
2051		networkGroups = s.config.NetworkGroups
2052		s.mu.RUnlock()
2053	}
2054
2055	seen := make(map[string]bool)
2056	var result []string
2057
2058	// First, add domain-specific attachments
2059	if domainAttachments, ok := attachments[domainName]; ok {
2060		for _, img := range domainAttachments {
2061			if !seen[img] {
2062				seen[img] = true
2063				result = append(result, img)
2064			}
2065		}
2066	}
2067
2068	// Then, find which network group this domain belongs to and add group attachments
2069	for groupName, domains := range networkGroups {
2070		if slices.Contains(domains, domainName) {
2071			if groupAttachments, ok := attachments[groupName]; ok {
2072				for _, img := range groupAttachments {
2073					if !seen[img] {
2074						seen[img] = true
2075						result = append(result, img)
2076					}
2077				}
2078			}
2079			break // Domain can only be in one network group
2080		}
2081	}
2082
2083	return result
2084}
2085
2086func (s *Service) getAttachmentsForDomain(ctx context.Context, domainName string) []domain.Attachment {
2087	containers, err := s.runtime.ListContainers(ctx, true)
2088	if err != nil {
2089		return nil
2090	}
2091
2092	return s.filterAttachments(ctx, containers, domainName)
2093}
2094
2095// getAllAttachments fetches all containers once and returns attachments grouped by domain.
2096// This avoids N+1 queries when listing multiple routes.
2097func (s *Service) getAllAttachments(ctx context.Context) map[string][]domain.Attachment {
2098	containers, err := s.runtime.ListContainers(ctx, true)
2099	if err != nil {
2100		return nil
2101	}
2102
2103	result := make(map[string][]domain.Attachment)
2104	for _, container := range containers {
2105		if container.Labels == nil {
2106			continue
2107		}
2108		if container.Labels[domain.LabelAttachment] != "true" {
2109			continue
2110		}
2111		ownerDomain := container.Labels[domain.LabelAttachedTo]
2112		if ownerDomain == "" {
2113			continue
2114		}
2115		image := container.Image
2116		serviceName := container.Name
2117		if labelImage, ok := container.Labels[domain.LabelImage]; ok && labelImage != "" {
2118			image = labelImage
2119			serviceName = extractServiceName(labelImage)
2120		}
2121		// Get the container's network for display in the routes table
2122		network := ""
2123		if networkName, err := s.runtime.GetContainerNetwork(ctx, container.ID); err == nil {
2124			network = networkName
2125		}
2126		attachment := domain.Attachment{
2127			Name:        serviceName,
2128			Image:       s.stripRegistryPrefix(image),
2129			ContainerID: container.ID,
2130			Status:      container.Status,
2131			Network:     network,
2132			Ports:       append([]int(nil), container.Ports...),
2133		}
2134		result[ownerDomain] = append(result[ownerDomain], attachment)
2135	}
2136
2137	return result
2138}
2139
2140// filterAttachments extracts attachments for a specific domain from a container list.
2141func (s *Service) filterAttachments(ctx context.Context, containers []*domain.Container, domainName string) []domain.Attachment {
2142	attachments := make([]domain.Attachment, 0)
2143	for _, container := range containers {
2144		if container.Labels == nil {
2145			continue
2146		}
2147		if container.Labels[domain.LabelAttachment] != "true" {
2148			continue
2149		}
2150		if container.Labels[domain.LabelAttachedTo] != domainName {
2151			continue
2152		}
2153		image := container.Image
2154		serviceName := container.Name
2155		if labelImage, ok := container.Labels[domain.LabelImage]; ok && labelImage != "" {
2156			image = labelImage
2157			serviceName = extractServiceName(labelImage)
2158		}
2159		// Get the container's network for display in the routes table
2160		network := ""
2161		if networkName, err := s.runtime.GetContainerNetwork(ctx, container.ID); err == nil {
2162			network = networkName
2163		}
2164		attachment := domain.Attachment{
2165			Name:        serviceName,
2166			Image:       s.stripRegistryPrefix(image),
2167			ContainerID: container.ID,
2168			Status:      container.Status,
2169			Network:     network,
2170			Ports:       append([]int(nil), container.Ports...),
2171		}
2172		attachments = append(attachments, attachment)
2173	}
2174
2175	return attachments
2176}
2177
2178func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, serviceImage, networkName string) error {
2179	log := zerowrap.FromCtx(ctx)
2180
2181	// Parse service name from image (e.g., "my-postgres:latest" → "postgres")
2182	serviceName := extractServiceName(serviceImage)
2183	containerName := fmt.Sprintf("gordon-%s-%s", sanitizeName(ownerDomain), serviceName)
2184
2185	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
2186		"attachment":     serviceName,
2187		"container_name": containerName,
2188		"owner_domain":   ownerDomain,
2189	})
2190
2191	// Check if already running (idempotent)
2192	// Try new name first, then fall back to legacy name for backwards compatibility
2193	existingContainer, err := s.resolveExistingAttachment(ctx, ownerDomain, serviceName)
2194	if err != nil {
2195		return err
2196	}
2197
2198	if existingContainer != nil {
2199		shouldSkip, err := s.handleRunningAttachment(ctx, existingContainer, containerName, serviceImage)
2200		if err != nil {
2201			return err
2202		}
2203		if shouldSkip {
2204			return nil
2205		}
2206		if existingContainer.Status == string(domain.ContainerStatusRunning) {
2207			existingContainer = nil
2208		}
2209	}
2210
2211	if err := s.removeStoppedAttachment(ctx, existingContainer, containerName); err != nil {
2212		return err
2213	}
2214
2215	log.Info().Str(zerowrap.FieldService, serviceImage).Msg("deploying attached service")
2216
2217	// Ensure image (canonical ref is returned; internal pulls may use the local registry).
2218	imageRef := s.buildImageRef(serviceImage)
2219	actualImageRef, err := s.ensureImage(ctx, imageRef)
2220	if err != nil {
2221		return err
2222	}
2223
2224	// Get image metadata
2225	exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, actualImageRef)
2226	if err != nil {
2227		log.WrapErr(err, "failed to get exposed ports for attachment, using defaults")
2228		exposedPorts = []int{}
2229	}
2230
2231	// Setup volumes (attachments need persistent data)
2232	volumes, err := s.setupVolumes(ctx, containerName, actualImageRef)
2233	if err != nil {
2234		log.WrapErr(err, "failed to setup volumes for attachment")
2235		volumes = make(map[string]string)
2236	}
2237
2238	// Load environment (attachment-specific env file)
2239	envVars, err := s.loadEnvironment(ctx, nil, containerName, actualImageRef)
2240	if err != nil {
2241		log.WrapErr(err, "failed to load environment for attachment")
2242		envVars = []string{}
2243	}
2244	envHash := hashEnvironment(envVars)
2245
2246	s.mu.RLock()
2247	cfg := s.config
2248	s.mu.RUnlock()
2249
2250	// Create container with attachment labels (use actual ref, track original in labels)
2251	config := &domain.ContainerConfig{
2252		Image:       actualImageRef,
2253		Name:        containerName,
2254		Hostname:    serviceName, // Internal DNS: postgres, redis, etc.
2255		Aliases:     []string{serviceName},
2256		Ports:       exposedPorts,
2257		Env:         envVars,
2258		Volumes:     volumes,
2259		NetworkMode: networkName, // Same network as main app
2260		Labels: map[string]string{
2261			domain.LabelManaged:    "true",
2262			domain.LabelAttachment: "true",
2263			domain.LabelAttachedTo: ownerDomain,
2264			domain.LabelEnvHash:    envHash,
2265			domain.LabelImage:      serviceImage,
2266		},
2267		MemoryLimit: cfg.DefaultMemoryLimit,
2268		NanoCPUs:    cfg.DefaultNanoCPUs,
2269		PidsLimit:   cfg.DefaultPidsLimit,
2270	}
2271
2272	container, err := s.runtime.CreateContainer(ctx, config)
2273	if err != nil {
2274		return log.WrapErr(err, "failed to create attachment container")
2275	}
2276
2277	// Start container
2278	if err := s.runtime.StartContainer(ctx, container.ID); err != nil {
2279		s.runtime.RemoveContainer(ctx, container.ID, true)
2280		return log.WrapErr(err, "failed to start attachment container")
2281	}
2282
2283	// Wait for attachment to be ready before proceeding
2284	if err := s.waitForAttachmentReady(ctx, container.ID, config); err != nil {
2285		log.WrapErr(err, "attachment readiness check failed, cleaning up")
2286		if stopErr := s.runtime.StopContainer(ctx, container.ID); stopErr != nil {
2287			log.Warn().Err(stopErr).Str(zerowrap.FieldEntityID, container.ID).Msg("failed to stop unready attachment")
2288		}
2289		s.runtime.RemoveContainer(ctx, container.ID, true)
2290		return fmt.Errorf("attachment %q not ready: %w", serviceName, err)
2291	}
2292
2293	// Track attachment
2294	s.mu.Lock()
2295	s.attachments[ownerDomain] = append(s.attachments[ownerDomain], container.ID)
2296	s.mu.Unlock()
2297
2298	// Start log collection for attachment
2299	s.startLogCollection(ctx, container.ID, containerName)
2300
2301	log.Info().Str(zerowrap.FieldEntityID, container.ID).Msg("attachment deployed successfully")
2302	return nil
2303}
2304
2305func (s *Service) attachmentEnvDrifted(ctx context.Context, existing *domain.Container, containerName, serviceImage string) (bool, error) {
2306	currentEnv, err := s.loadEnvironment(ctx, nil, containerName, s.buildImageRef(serviceImage))
2307	if err != nil {
2308		return false, err
2309	}
2310
2311	currentHash := hashEnvironment(currentEnv)
2312	existingHash, ok := existing.Labels[domain.LabelEnvHash]
2313	if ok && existingHash == currentHash {
2314		return false, nil
2315	}
2316
2317	return true, nil
2318}
2319
2320func (s *Service) handleRunningAttachment(ctx context.Context, existing *domain.Container, containerName, serviceImage string) (bool, error) {
2321	if existing.Status != string(domain.ContainerStatusRunning) {
2322		return false, nil
2323	}
2324
2325	log := zerowrap.FromCtx(ctx)
2326
2327	// Check image drift
2328	existingImage := existing.Labels[domain.LabelImage]
2329	imageDrifted := existingImage != "" && existingImage != serviceImage
2330
2331	// Check env drift
2332	envDrifted, err := s.attachmentEnvDrifted(ctx, existing, containerName, serviceImage)
2333	if err != nil {
2334		return false, log.WrapErr(err, "failed to check attachment env drift")
2335	}
2336
2337	if !envDrifted && !imageDrifted {
2338		log.Debug().Str("container_name", containerName).Msg("attachment already running with current env and image, skipping")
2339		return true, nil
2340	}
2341
2342	if imageDrifted {
2343		log.Info().Str("container_name", containerName).Str("existing_image", existingImage).Str("desired_image", serviceImage).Msg("attachment image changed, recreating")
2344	}
2345	if envDrifted {
2346		log.Info().Str("container_name", containerName).Msg("attachment env changed, recreating")
2347	}
2348
2349	if err := s.runtime.StopContainer(ctx, existing.ID); err != nil {
2350		return false, log.WrapErr(err, "failed to stop attachment for drift update")
2351	}
2352	if err := s.runtime.RemoveContainer(ctx, existing.ID, true); err != nil {
2353		return false, log.WrapErr(err, "failed to remove attachment for drift update")
2354	}
2355
2356	return false, nil
2357}
2358
2359func (s *Service) removeStoppedAttachment(ctx context.Context, existing *domain.Container, containerName string) error {
2360	if existing == nil {
2361		return nil
2362	}
2363
2364	log := zerowrap.FromCtx(ctx)
2365	log.Info().Str("container_name", containerName).Msg("removing stopped attachment container")
2366	if err := s.runtime.RemoveContainer(ctx, existing.ID, true); err != nil {
2367		return log.WrapErr(err, "failed to remove existing attachment container")
2368	}
2369
2370	return nil
2371}
2372
2373func (s *Service) resolveExistingAttachment(ctx context.Context, ownerDomain, serviceName string) (*domain.Container, error) {
2374	log := zerowrap.FromCtx(ctx)
2375	containerName := fmt.Sprintf("gordon-%s-%s", sanitizeName(ownerDomain), serviceName)
2376	existingContainer := s.findContainerByName(ctx, containerName)
2377	if existingContainer != nil {
2378		return existingContainer, nil
2379	}
2380
2381	containerNameLegacy := fmt.Sprintf("gordon-%s-%s", sanitizeNameLegacy(ownerDomain), serviceName)
2382	existingContainer = s.findContainerByName(ctx, containerNameLegacy)
2383	if existingContainer == nil {
2384		return nil, nil
2385	}
2386
2387	log.Info().Str("container_name", containerNameLegacy).Msg("found attachment with legacy naming, will be replaced")
2388	if err := s.runtime.StopContainer(ctx, existingContainer.ID); err != nil {
2389		return nil, log.WrapErr(err, "failed to stop legacy attachment container")
2390	}
2391	if err := s.runtime.RemoveContainer(ctx, existingContainer.ID, true); err != nil {
2392		return nil, log.WrapErr(err, "failed to remove legacy attachment container")
2393	}
2394
2395	return nil, nil
2396}
2397
2398// Utility functions
2399
2400func normalizeImageRef(image string) string {
2401	// Extract repository from image reference, handling port numbers correctly.
2402	// Examples:
2403	//   nginx:latest -> docker.io/library/nginx
2404	//   user/repo:tag -> docker.io/user/repo
2405	//   localhost:5000/image:tag -> localhost:5000/image
2406	//   registry.example.com/image:tag -> registry.example.com/image
2407	//
2408	// Note: This function assumes valid Docker image references. Edge cases like
2409	// "registry.com:5000" (registry with port but no image name) are not valid
2410	// image references per Docker's naming conventions, which require at least
2411	// one path component after the registry (e.g., "registry.com:5000/image").
2412
2413	// Find the tag separator: the last colon that isn't part of a port number.
2414	// A colon is part of a port if there's no slash after it until the next colon.
2415	repo := image
2416	lastColon := strings.LastIndex(image, ":")
2417	if lastColon != -1 {
2418		afterColon := image[lastColon+1:]
2419		// If there's no slash after the colon, it's the tag separator
2420		if !strings.Contains(afterColon, "/") {
2421			repo = image[:lastColon]
2422		}
2423	}
2424
2425	if !strings.Contains(repo, "/") {
2426		return "docker.io/library/" + repo
2427	}
2428
2429	if strings.Count(repo, "/") == 1 && !strings.Contains(strings.Split(repo, "/")[0], ".") && !strings.Contains(strings.Split(repo, "/")[0], ":") {
2430		return "docker.io/" + repo
2431	}
2432
2433	return repo
2434}
2435
2436func generateVolumeName(prefix, domainName, volumePath string) string {
2437	return fmt.Sprintf("%s-%s-%s",
2438		prefix,
2439		strings.ReplaceAll(domainName, ".", "-"),
2440		strings.ReplaceAll(strings.Trim(volumePath, "/"), "/", "-"))
2441}
2442
2443func mergeEnvironmentVariables(dockerfileEnv, userEnv []string) []string {
2444	envMap := make(map[string]string)
2445
2446	for _, env := range dockerfileEnv {
2447		if k, v, ok := strings.Cut(env, "="); ok {
2448			envMap[k] = v
2449		}
2450	}
2451
2452	for _, env := range userEnv {
2453		if k, v, ok := strings.Cut(env, "="); ok {
2454			envMap[k] = v
2455		}
2456	}
2457
2458	keys := make([]string, 0, len(envMap))
2459	for k := range envMap {
2460		keys = append(keys, k)
2461	}
2462	slices.Sort(keys)
2463
2464	result := make([]string, 0, len(envMap))
2465	for _, k := range keys {
2466		result = append(result, k+"="+envMap[k])
2467	}
2468
2469	return result
2470}
2471
2472// extractServiceName gets service name from image reference.
2473// "my-postgres:latest" → "postgres", "redis:7" → "redis"
2474func extractServiceName(image string) string {
2475	// Remove tag
2476	parts := strings.Split(image, ":")
2477	name := parts[0]
2478
2479	// Remove registry prefix if present
2480	if strings.Contains(name, "/") {
2481		nameParts := strings.Split(name, "/")
2482		name = nameParts[len(nameParts)-1]
2483	}
2484
2485	// Remove common prefixes like "my-"
2486	name = strings.TrimPrefix(name, "my-")
2487
2488	return name
2489}
2490
2491// sanitizeName is a convenience wrapper around domain.SanitizeDomainForContainer.
2492func sanitizeName(d string) string {
2493	return domain.SanitizeDomainForContainer(d)
2494}
2495
2496// sanitizeNameLegacy is a convenience wrapper around domain.SanitizeDomainForContainerLegacy.
2497func sanitizeNameLegacy(d string) string {
2498	return domain.SanitizeDomainForContainerLegacy(d)
2499}
2500
2501func isContainerNotFoundError(err error) bool {
2502	if err == nil {
2503		return false
2504	}
2505	if errors.Is(err, domain.ErrContainerNotFound) {
2506		return true
2507	}
2508	msg := strings.ToLower(err.Error())
2509	return strings.Contains(msg, "no such container") ||
2510		strings.Contains(msg, "no container with name or id") ||
2511		strings.Contains(msg, "container not found")
2512}
2513
2514func isConnectionRefusedError(err error) bool {
2515	if err == nil {
2516		return false
2517	}
2518	return strings.Contains(strings.ToLower(err.Error()), "connection refused")
2519}
2520
2521// findContainerByName finds a container by its name.
2522func (s *Service) findContainerByName(ctx context.Context, name string) *domain.Container {
2523	containers, err := s.runtime.ListContainers(ctx, true)
2524	if err != nil {
2525		return nil
2526	}
2527
2528	for _, c := range containers {
2529		if c.Name == name {
2530			return c
2531		}
2532	}
2533	return nil
2534}
2535
2536func (s *Service) waitForReady(ctx context.Context, containerID string, containerConfig *domain.ContainerConfig) error {
2537	if err := s.pollContainerRunning(ctx, containerID); err != nil {
2538		return err
2539	}
2540
2541	s.mu.RLock()
2542	cfg := s.config
2543	s.mu.RUnlock()
2544
2545	readinessMode := cfg.ReadinessMode
2546	if readinessMode == "" {
2547		readinessMode = "delay"
2548	}
2549
2550	switch readinessMode {
2551	case "delay":
2552		return s.waitForReadyByDelay(ctx, containerID)
2553	case "docker-health":
2554		_, hasHealthcheck, err := s.runtime.GetContainerHealthStatus(ctx, containerID)
2555		if err != nil {
2556			return err
2557		}
2558		if !hasHealthcheck {
2559			return errors.New("no healthcheck detected")
2560		}
2561		return s.waitForHealthy(ctx, containerID, cfg.HealthTimeout)
2562	default: // auto
2563		return s.readinessCascade(ctx, containerID, containerConfig, cfg)
2564	}
2565}
2566
2567// readinessCascade auto-detects the strongest available readiness signal:
2568//  1. Docker healthcheck (if present) → wait for healthy status
2569//  2. HTTP probe (if gordon.health label set) → GET until 2xx/3xx
2570//  3. Default HTTP probe (GET / on exposed port) → wait for 2xx/3xx
2571//  4. TCP probe (if port info available) → connect until accepted
2572//  5. Delay fallback (last resort) → waitForReadyByDelay
2573func (s *Service) readinessCascade(ctx context.Context, containerID string, containerConfig *domain.ContainerConfig, cfg Config) error {
2574	log := zerowrap.FromCtx(ctx)
2575
2576	// 1. Docker healthcheck
2577	_, hasHealthcheck, err := s.runtime.GetContainerHealthStatus(ctx, containerID)
2578	if err != nil {
2579		return err
2580	}
2581	if hasHealthcheck {
2582		log.Info().Msg("readiness cascade: using Docker healthcheck")
2583		return s.waitForHealthy(ctx, containerID, cfg.HealthTimeout)
2584	}
2585
2586	// 2. HTTP probe via gordon.health label
2587	if probed, probeErr := s.tryHTTPProbe(ctx, containerID, containerConfig, cfg); probed {
2588		return probeErr
2589	}
2590
2591	// 3. Default HTTP probe on root path (if port info available)
2592	if probed, probeErr := s.tryDefaultHTTPProbe(ctx, containerID, containerConfig, cfg); probed {
2593		return probeErr
2594	}
2595
2596	// 4. TCP probe (if port info available)
2597	if probed, probeErr := s.tryTCPProbe(ctx, containerID, containerConfig, cfg); probed {
2598		return probeErr
2599	}
2600
2601	// 5. Delay fallback
2602	log.Info().Msg("readiness cascade: using delay fallback")
2603	return s.waitForReadyByDelay(ctx, containerID)
2604}
2605
2606// waitForAttachmentReady runs a reduced readiness cascade for attachments:
2607// Docker healthcheck → TCP probe → delay fallback.
2608// HTTP probes are skipped — attachments are typically databases/caches.
2609func (s *Service) waitForAttachmentReady(ctx context.Context, containerID string, containerConfig *domain.ContainerConfig) error {
2610	if err := s.pollContainerRunning(ctx, containerID); err != nil {
2611		return err
2612	}
2613
2614	log := zerowrap.FromCtx(ctx)
2615
2616	s.mu.RLock()
2617	cfg := s.config
2618	s.mu.RUnlock()
2619
2620	// 1. Docker healthcheck (if present)
2621	_, hasHealthcheck, err := s.runtime.GetContainerHealthStatus(ctx, containerID)
2622	if err != nil {
2623		return err
2624	}
2625	if hasHealthcheck {
2626		log.Info().Msg("attachment readiness: using Docker healthcheck")
2627		timeout := cfg.HealthTimeout
2628		if timeout == 0 {
2629			timeout = 90 * time.Second
2630		}
2631		return s.waitForHealthy(ctx, containerID, timeout)
2632	}
2633
2634	// 2. TCP probe (if port info available)
2635	if containerConfig != nil && len(containerConfig.Ports) > 0 {
2636		ip, port, probeErr := s.resolveProbeEndpoint(ctx, containerID, containerConfig)
2637		if probeErr == nil && ip != "" && port > 0 {
2638			addr := fmt.Sprintf("%s:%d", ip, port)
2639			timeout := cfg.AttachmentReadinessTimeout
2640			if timeout == 0 {
2641				timeout = 30 * time.Second
2642			}
2643			log.Info().Str("addr", addr).Dur("timeout", timeout).Msg("attachment readiness: using TCP probe")
2644			return tcpProbe(ctx, addr, timeout)
2645		}
2646		log.Debug().Err(probeErr).Msg("attachment readiness: TCP probe skipped, could not resolve endpoint")
2647	}
2648
2649	// 3. Delay fallback
2650	log.Info().Msg("attachment readiness: using delay fallback")
2651	return s.waitForReadyByDelay(ctx, containerID)
2652}
2653
2654// tryHTTPProbe attempts an HTTP probe if the gordon.health label is set.
2655// Returns (true, err) if the probe was attempted, (false, nil) if skipped.
2656func (s *Service) tryHTTPProbe(ctx context.Context, containerID string, containerConfig *domain.ContainerConfig, cfg Config) (bool, error) {
2657	log := zerowrap.FromCtx(ctx)
2658	if containerConfig == nil {
2659		return false, nil
2660	}
2661	healthPath, ok := containerConfig.Labels[domain.LabelHealth]
2662	if !ok || healthPath == "" {
2663		return false, nil
2664	}
2665	ip, port, probeErr := s.resolveProbeEndpoint(ctx, containerID, containerConfig)
2666	if probeErr != nil || ip == "" || port <= 0 {
2667		log.Debug().Err(probeErr).Msg("readiness cascade: HTTP probe skipped, could not resolve container endpoint")
2668		return false, nil
2669	}
2670	url := fmt.Sprintf("http://%s:%d%s", ip, port, healthPath)
2671	timeout := cfg.HTTPProbeTimeout
2672	if timeout == 0 {
2673		timeout = 60 * time.Second
2674	}
2675	log.Info().Str("url", url).Dur("timeout", timeout).Msg("readiness cascade: using HTTP probe")
2676	return true, httpProbe(ctx, url, timeout)
2677}
2678
2679// tryDefaultHTTPProbe attempts an HTTP GET on "/" using the container's
2680// exposed port. This is the default readiness check for web containers —
2681// it verifies the app is actually serving HTTP, not just accepting TCP.
2682// Returns (true, err) if the probe was attempted, (false, nil) if skipped.
2683func (s *Service) tryDefaultHTTPProbe(ctx context.Context, containerID string, containerConfig *domain.ContainerConfig, cfg Config) (bool, error) {
2684	log := zerowrap.FromCtx(ctx)
2685	if containerConfig == nil || len(containerConfig.Ports) == 0 {
2686		return false, nil
2687	}
2688	ip, port, probeErr := s.resolveProbeEndpoint(ctx, containerID, containerConfig)
2689	if probeErr != nil || ip == "" || port <= 0 {
2690		log.Debug().Err(probeErr).Msg("readiness cascade: default HTTP probe skipped, could not resolve container endpoint")
2691		return false, nil
2692	}
2693	url := fmt.Sprintf("http://%s:%d/", ip, port)
2694	timeout := cfg.HTTPProbeTimeout
2695	if timeout == 0 {
2696		timeout = 60 * time.Second
2697	}
2698	log.Info().Str("url", url).Dur("timeout", timeout).Msg("readiness cascade: using default HTTP alive probe")
2699	return true, httpAliveProbe(ctx, url, timeout)
2700}
2701
2702// tryTCPProbe attempts a TCP probe if port info is available.
2703// Returns (true, err) if the probe was attempted, (false, nil) if skipped.
2704func (s *Service) tryTCPProbe(ctx context.Context, containerID string, containerConfig *domain.ContainerConfig, cfg Config) (bool, error) {
2705	log := zerowrap.FromCtx(ctx)
2706	if containerConfig == nil || len(containerConfig.Ports) == 0 {
2707		return false, nil
2708	}
2709	ip, port, probeErr := s.resolveProbeEndpoint(ctx, containerID, containerConfig)
2710	if probeErr != nil || ip == "" || port <= 0 {
2711		log.Debug().Err(probeErr).Msg("readiness cascade: TCP probe skipped, could not resolve container endpoint")
2712		return false, nil
2713	}
2714	addr := fmt.Sprintf("%s:%d", ip, port)
2715	timeout := cfg.TCPProbeTimeout
2716	if timeout == 0 {
2717		timeout = 30 * time.Second
2718	}
2719	log.Info().Str("addr", addr).Dur("timeout", timeout).Msg("readiness cascade: using TCP probe")
2720	return true, tcpProbe(ctx, addr, timeout)
2721}
2722
2723// resolveContainerEndpoint returns a host-reachable address for probing.
2724// In rootless podman/Docker setups, container internal IPs are not routable
2725// from the host. We use the host port binding (127.0.0.1:<mapped_port>)
2726// which is always reachable. Falls back to the container's internal IP
2727// only if no host port mapping exists (e.g. host-network mode).
2728func (s *Service) resolveProbeEndpoint(ctx context.Context, containerID string, containerConfig *domain.ContainerConfig) (string, int, error) {
2729	log := zerowrap.FromCtx(ctx)
2730
2731	internalPort := 0
2732	if containerConfig != nil {
2733		if labels := containerConfig.Labels; labels != nil {
2734			for _, key := range []string{domain.LabelProxyPort, domain.LabelPort} {
2735				if portStr, ok := labels[key]; ok && portStr != "" {
2736					port, err := strconv.Atoi(portStr)
2737					if err == nil && port > 0 && port <= 65535 {
2738						internalPort = port
2739						break
2740					}
2741					log.Warn().Str("label", key).Str("port_value", portStr).Msg("invalid probe port label value")
2742				}
2743			}
2744		}
2745	}
2746
2747	if internalPort == 0 {
2748		_, resolvedPort, err := s.runtime.GetContainerNetworkInfo(ctx, containerID)
2749		if err != nil {
2750			return "", 0, err
2751		}
2752		internalPort = resolvedPort
2753	}
2754
2755	hostPort, hostErr := s.runtime.GetContainerPort(ctx, containerID, internalPort)
2756	if hostErr == nil && hostPort > 0 {
2757		log.Debug().
2758			Int("internal_port", internalPort).
2759			Int("host_port", hostPort).
2760			Msg("resolved container endpoint via host port binding")
2761		return "127.0.0.1", hostPort, nil
2762	}
2763
2764	ip, _, fallbackErr := s.runtime.GetContainerNetworkInfo(ctx, containerID)
2765	if fallbackErr != nil {
2766		return "", 0, fallbackErr
2767	}
2768	log.Debug().
2769		Str("ip", ip).
2770		Int("port", internalPort).
2771		Msg("resolved container endpoint via internal IP (no host port binding)")
2772	return ip, internalPort, nil
2773}
2774
2775// waitForReadyByDelay waits using the legacy running+delay strategy.
2776func (s *Service) waitForReadyByDelay(ctx context.Context, containerID string) error {
2777	log := zerowrap.FromCtx(ctx)
2778
2779	s.mu.RLock()
2780	cfg := s.config
2781	s.mu.RUnlock()
2782
2783	delay := cfg.ReadinessDelay
2784	if delay == 0 {
2785		delay = 5 * time.Second
2786	}
2787
2788	log.Debug().Dur("delay", delay).Msg("waiting for container readiness")
2789	select {
2790	case <-time.After(delay):
2791	case <-ctx.Done():
2792		return ctx.Err()
2793	}
2794
2795	// Verify still running after delay
2796	running, err := s.runtime.IsContainerRunning(ctx, containerID)
2797	if err != nil {
2798		return err
2799	}
2800	if running {
2801		return nil
2802	}
2803
2804	return s.waitForRecovery(ctx, containerID)
2805}
2806
2807func (s *Service) waitForHealthy(ctx context.Context, containerID string, timeout time.Duration) error {
2808	if timeout == 0 {
2809		timeout = 90 * time.Second
2810	}
2811	waitCtx, cancel := context.WithTimeout(ctx, timeout)
2812	defer cancel()
2813
2814	var lastStatus string
2815	for {
2816		status, hasHealthcheck, err := s.runtime.GetContainerHealthStatus(waitCtx, containerID)
2817		if err != nil {
2818			return err
2819		}
2820		if !hasHealthcheck {
2821			return errors.New("no healthcheck detected")
2822		}
2823		// Treat empty health status as transitional startup. Some runtimes can
2824		// temporarily report an empty status before first probe results.
2825		if status == "" {
2826			status = "starting"
2827		}
2828		if status == "healthy" {
2829			return nil
2830		}
2831		lastStatus = status
2832
2833		select {
2834		case <-time.After(time.Second):
2835		case <-waitCtx.Done():
2836			return fmt.Errorf("container healthcheck timeout after %s (last status: %s)", timeout, lastStatus)
2837		}
2838	}
2839}
2840
2841// pollContainerRunning polls until the container is running (max 30 seconds).
2842func (s *Service) pollContainerRunning(ctx context.Context, containerID string) error {
2843	for i := 0; i < 30; i++ {
2844		running, err := s.runtime.IsContainerRunning(ctx, containerID)
2845		if err != nil {
2846			return err
2847		}
2848		if running {
2849			return nil
2850		}
2851		if i == 29 {
2852			return fmt.Errorf("container did not start within 30 seconds")
2853		}
2854		select {
2855		case <-time.After(time.Second):
2856		case <-ctx.Done():
2857			return ctx.Err()
2858		}
2859	}
2860	return fmt.Errorf("container did not start within 30 seconds")
2861}
2862
2863// waitForRecovery waits for a container to recover within the recovery window.
2864func (s *Service) waitForRecovery(ctx context.Context, containerID string) error {
2865	log := zerowrap.FromCtx(ctx)
2866	log.Warn().
2867		Dur("recovery_window", readinessRecoveryWindow).
2868		Msg("container not running after readiness delay, waiting for recovery")
2869
2870	deadline := time.Now().Add(readinessRecoveryWindow)
2871	for time.Now().Before(deadline) {
2872		select {
2873		case <-time.After(time.Second):
2874		case <-ctx.Done():
2875			return ctx.Err()
2876		}
2877
2878		running, err := s.runtime.IsContainerRunning(ctx, containerID)
2879		if err != nil {
2880			return err
2881		}
2882		if running {
2883			log.Info().Msg("container recovered during readiness recovery window")
2884			return nil
2885		}
2886	}
2887
2888	return fmt.Errorf("container not running after readiness delay and recovery window")
2889}
2890
2891// publishContainerDeployed publishes a container.deployed event.
2892func (s *Service) publishContainerDeployed(ctx context.Context, domainName, containerID string) {
2893	payload := &domain.ContainerEventPayload{
2894		ContainerID: containerID,
2895		Domain:      domainName,
2896		Action:      "deployed",
2897	}
2898
2899	if err := s.eventBus.Publish(domain.EventContainerDeployed, payload); err != nil {
2900		log := zerowrap.FromCtx(ctx)
2901		log.Warn().Err(err).Msg("failed to publish container deployed event")
2902	}
2903}
2904
2905// rewriteToRegistryDomain rewrites an image reference to use the configured registry domain.
2906// e.g., "myapp:latest" -> "registry.example.com/myapp:latest"
2907func rewriteToRegistryDomain(imageRef, registryDomain string) string {
2908	if registryDomain == "" {
2909		return imageRef
2910	}
2911
2912	prefix := registryDomain + "/"
2913	if strings.HasPrefix(imageRef, prefix) {
2914		return imageRef
2915	}
2916
2917	return prefix + imageRef
2918}
2919
2920// rewriteToLocalRegistry rewrites an image reference to use the local registry address.
2921// e.g., "registry.example.com/myapp:latest" -> "localhost:5000/myapp:latest"
2922func rewriteToLocalRegistry(imageRef, registryDomain string, registryPort int) string {
2923	if imageRef == "" {
2924		return imageRef
2925	}
2926
2927	localRegistry := fmt.Sprintf("localhost:%d", registryPort)
2928	localPrefix := localRegistry + "/"
2929	imageRef = strings.TrimPrefix(imageRef, localPrefix)
2930
2931	registryDomain = strings.TrimSuffix(registryDomain, "/")
2932	if registryDomain != "" {
2933		prefix := registryDomain + "/"
2934		imageRef = strings.TrimPrefix(imageRef, prefix)
2935	}
2936
2937	return localPrefix + imageRef
2938}

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