v2.14.0 5e11a00

service.go RawHistoryFolder

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

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