v2.11.0 dca6537

service.go RawHistoryFolder

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

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