v2.30.0-pre 7341e0e

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

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