v2.13.0 7748089

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

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