v2.10.3 f7e7684

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		log.WrapErr(err, "failed to deploy some 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 for cleanup
375	var containerDomain string
376	s.mu.RLock()
377	for d, c := range s.containers {
378		if c.ID == containerID {
379			containerDomain = d
380			break
381		}
382	}
383	s.mu.RUnlock()
384
385	if err := s.runtime.RemoveContainer(ctx, containerID, force); err != nil {
386		return log.WrapErr(err, "failed to remove container")
387	}
388
389	// Cleanup volumes
390	if containerDomain != "" && !s.config.VolumePreserve {
391		if err := s.cleanupVolumesForDomain(ctx, containerDomain); err != nil {
392			log.WrapErrWithFields(err, "failed to cleanup volumes", map[string]any{"domain": containerDomain})
393		}
394	}
395
396	// Remove from tracking
397	s.mu.Lock()
398	var removedDomain string
399	for d, c := range s.containers {
400		if c.ID == containerID {
401			delete(s.containers, d)
402			removedDomain = d
403			log.Info().Str("domain", d).Msg("container removed")
404			break
405		}
406	}
407	s.mu.Unlock()
408
409	// Note: We intentionally do NOT clean up deployMu entries to avoid a race condition:
410	// If Remove deletes the mutex entry while a concurrent Deploy is acquiring the lock,
411	// the Deploy would create a fresh mutex, breaking serialization and allowing concurrent
412	// deploys to the same domain. The memory footprint is acceptable (one small struct per
413	// domain ever deployed), and this is the safer choice for correctness.
414
415	// Cleanup network
416	if removedDomain != "" && s.config.NetworkIsolation {
417		networkName := s.getNetworkForApp(removedDomain)
418		if err := s.cleanupNetworkIfEmpty(ctx, networkName); err != nil {
419			log.WrapErrWithFields(err, "failed to cleanup network", map[string]any{"network": networkName})
420		}
421	}
422
423	return nil
424}
425
426// Get retrieves a container by domain.
427func (s *Service) Get(_ context.Context, domainName string) (*domain.Container, bool) {
428	s.mu.RLock()
429	defer s.mu.RUnlock()
430	container, exists := s.containers[domainName]
431	return container, exists
432}
433
434// List returns all managed containers.
435func (s *Service) List(_ context.Context) map[string]*domain.Container {
436	s.mu.RLock()
437	defer s.mu.RUnlock()
438
439	result := make(map[string]*domain.Container, len(s.containers))
440	maps.Copy(result, s.containers)
441	return result
442}
443
444// ListRoutesWithDetails returns routes with network and attachment info.
445// Note: Container map is copied under lock, then external calls are made without lock.
446// If containers are removed between copy and runtime calls, errors are handled gracefully
447// (network becomes empty string). This trade-off avoids holding locks during I/O.
448func (s *Service) ListRoutesWithDetails(ctx context.Context) []domain.RouteInfo {
449	s.mu.RLock()
450	containers := make(map[string]*domain.Container, len(s.containers))
451	maps.Copy(containers, s.containers)
452	s.mu.RUnlock()
453
454	// Fetch all attachments once to avoid N+1 queries
455	attachmentsByDomain := s.getAllAttachments(ctx)
456
457	results := make([]domain.RouteInfo, 0, len(containers))
458	for domainName, container := range containers {
459		network := ""
460		image := ""
461		containerID := ""
462		status := ""
463		if container != nil {
464			containerID = container.ID
465			status = container.Status
466			image = container.Image
467			if container.Labels != nil {
468				if labelImage, ok := container.Labels[domain.LabelImage]; ok && labelImage != "" {
469					image = labelImage
470				}
471			}
472			// Strip registry domain prefix from image for cleaner display
473			image = s.stripRegistryPrefix(image)
474			if networkName, err := s.runtime.GetContainerNetwork(ctx, container.ID); err == nil {
475				network = networkName
476			}
477		}
478
479		results = append(results, domain.RouteInfo{
480			Domain:          domainName,
481			Image:           image,
482			ContainerID:     containerID,
483			ContainerStatus: status,
484			Network:         network,
485			Attachments:     attachmentsByDomain[domainName],
486		})
487	}
488
489	return results
490}
491
492// stripRegistryPrefix removes the configured registry domain prefix from an image reference.
493// For example, "reg.example.com/myapp:latest" becomes "myapp:latest" if registry domain is "reg.example.com".
494func (s *Service) stripRegistryPrefix(image string) string {
495	if s.config.RegistryDomain == "" {
496		return image
497	}
498	prefix := strings.TrimSuffix(s.config.RegistryDomain, "/") + "/"
499	if strings.HasPrefix(image, prefix) {
500		return strings.TrimPrefix(image, prefix)
501	}
502	return image
503}
504
505// ListAttachments returns attachments for a domain.
506func (s *Service) ListAttachments(ctx context.Context, domainName string) []domain.Attachment {
507	return s.getAttachmentsForDomain(ctx, domainName)
508}
509
510// ListNetworks returns Gordon-managed networks.
511func (s *Service) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) {
512	networks, err := s.runtime.ListNetworks(ctx)
513	if err != nil {
514		return nil, err
515	}
516
517	var filtered []*domain.NetworkInfo
518	for _, network := range networks {
519		if strings.HasPrefix(network.Name, s.config.NetworkPrefix+"-") {
520			filtered = append(filtered, network)
521		}
522	}
523
524	return filtered, nil
525}
526
527// HealthCheck performs health checks on all containers.
528func (s *Service) HealthCheck(ctx context.Context) map[string]bool {
529	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
530		zerowrap.FieldLayer:   "usecase",
531		zerowrap.FieldUseCase: "HealthCheck",
532	})
533	log := zerowrap.FromCtx(ctx)
534
535	s.mu.RLock()
536	defer s.mu.RUnlock()
537
538	health := make(map[string]bool, len(s.containers))
539	for d, c := range s.containers {
540		running, err := s.runtime.IsContainerRunning(ctx, c.ID)
541		if err != nil {
542			log.WrapErrWithFields(err, "health check failed", map[string]any{"domain": d, zerowrap.FieldEntityID: c.ID})
543			health[d] = false
544		} else {
545			health[d] = running
546		}
547	}
548	return health
549}
550
551// SyncContainers synchronizes containers with runtime state.
552func (s *Service) SyncContainers(ctx context.Context) error {
553	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
554		zerowrap.FieldLayer:   "usecase",
555		zerowrap.FieldUseCase: "SyncContainers",
556	})
557	log := zerowrap.FromCtx(ctx)
558
559	s.mu.Lock()
560	defer s.mu.Unlock()
561
562	allContainers, err := s.runtime.ListContainers(ctx, false)
563	if err != nil {
564		return log.WrapErr(err, "failed to list containers")
565	}
566
567	managed := make(map[string]*domain.Container)
568	for _, c := range allContainers {
569		if c.Labels != nil {
570			if d, ok := c.Labels["gordon.domain"]; ok && c.Labels["gordon.managed"] == "true" {
571				managed[d] = c
572			}
573		}
574	}
575
576	s.containers = managed
577	log.Info().Int(zerowrap.FieldCount, len(managed)).Msg("container state synchronized")
578	return nil
579}
580
581// AutoStart starts containers for the provided routes that aren't running.
582func (s *Service) AutoStart(ctx context.Context, routes []domain.Route) error {
583	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
584		zerowrap.FieldLayer:   "usecase",
585		zerowrap.FieldUseCase: "AutoStart",
586	})
587	log := zerowrap.FromCtx(ctx)
588
589	log.Info().Int("route_count", len(routes)).Msg("auto-starting containers for configured routes")
590
591	var started, skipped, errors int
592	for _, route := range routes {
593		// Check if container already exists and is running
594		if _, exists := s.Get(ctx, route.Domain); exists {
595			log.Debug().Str("domain", route.Domain).Msg("container already running, skipping")
596			skipped++
597			continue
598		}
599
600		log.Info().
601			Str("domain", route.Domain).
602			Str("image", route.Image).
603			Msg("auto-starting container for route")
604
605		if _, err := s.Deploy(ctx, route); err != nil {
606			log.Warn().Err(err).Str("domain", route.Domain).Msg("failed to auto-start container")
607			errors++
608			continue
609		}
610
611		started++
612	}
613
614	log.Info().
615		Int("started", started).
616		Int("skipped", skipped).
617		Int("errors", errors).
618		Msg("auto-start completed")
619
620	if errors > 0 {
621		return fmt.Errorf("auto-start completed with %d errors", errors)
622	}
623	return nil
624}
625
626// Shutdown gracefully shuts down all managed containers.
627func (s *Service) Shutdown(ctx context.Context) error {
628	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
629		zerowrap.FieldLayer:   "usecase",
630		zerowrap.FieldUseCase: "Shutdown",
631	})
632	log := zerowrap.FromCtx(ctx)
633	log.Info().Msg("shutting down container manager...")
634
635	if err := s.stopAllManagedContainers(ctx); err != nil {
636		log.WrapErr(err, "failed to stop all containers during shutdown")
637	}
638
639	// Close log writer to stop all log collection
640	if s.logWriter != nil {
641		if err := s.logWriter.Close(); err != nil {
642			log.Warn().Err(err).Msg("failed to close container log writer")
643		}
644	}
645
646	log.Info().Msg("container manager shutdown complete")
647	return nil
648}
649
650// UpdateConfig updates the service configuration.
651func (s *Service) UpdateConfig(config Config) {
652	s.config = config
653}
654
655// Helper methods
656
657// cleanupFailedContainer stops and removes a container that failed to start properly.
658func (s *Service) cleanupFailedContainer(ctx context.Context, containerID string) {
659	log := zerowrap.FromCtx(ctx)
660	if err := s.runtime.StopContainer(ctx, containerID); err != nil {
661		log.Warn().Err(err).Str(zerowrap.FieldEntityID, containerID).Msg("failed to stop container after failure")
662	}
663	if err := s.runtime.RemoveContainer(ctx, containerID, true); err != nil {
664		log.Warn().Err(err).Str(zerowrap.FieldEntityID, containerID).Msg("failed to remove container after failure")
665	}
666}
667
668// cleanupOldContainer stops and removes an old container after zero-downtime switch.
669// It also renames the new container to the canonical name.
670func (s *Service) cleanupOldContainer(ctx context.Context, old *domain.Container, newContainerID, domainName string) {
671	log := zerowrap.FromCtx(ctx)
672	log.Info().Str(zerowrap.FieldEntityID, old.ID).Msg("stopping old container after zero-downtime switch")
673
674	if s.logWriter != nil {
675		if err := s.logWriter.StopLogging(old.ID); err != nil {
676			log.Warn().Err(err).Str(zerowrap.FieldEntityID, old.ID).Msg("failed to stop logging for old container")
677		}
678	}
679
680	if err := s.runtime.StopContainer(ctx, old.ID); err != nil {
681		log.Warn().Err(err).Str(zerowrap.FieldEntityID, old.ID).Msg("failed to stop old container")
682	}
683
684	if err := s.runtime.RemoveContainer(ctx, old.ID, true); err != nil {
685		log.Warn().Err(err).Str(zerowrap.FieldEntityID, old.ID).Msg("failed to remove old container")
686	}
687
688	// Rename new container to canonical name
689	canonicalName := fmt.Sprintf("gordon-%s", domainName)
690	if err := s.runtime.RenameContainer(ctx, newContainerID, canonicalName); err != nil {
691		log.Warn().Err(err).Str("canonical_name", canonicalName).Msg("failed to rename container to canonical name")
692	}
693}
694
695// startLogCollection starts log collection for a container in the background.
696// Errors are logged but don't fail the calling operation.
697func (s *Service) startLogCollection(ctx context.Context, containerID, domainName string) {
698	if s.logWriter == nil {
699		return
700	}
701
702	log := zerowrap.FromCtx(ctx)
703
704	logStream, err := s.runtime.GetContainerLogs(ctx, containerID, true)
705	if err != nil {
706		log.Warn().Err(err).Msg("failed to get container logs for collection")
707		return
708	}
709
710	if err := s.logWriter.StartLogging(ctx, containerID, domainName, logStream); err != nil {
711		log.Warn().Err(err).Msg("failed to start container log collection")
712		logStream.Close()
713	}
714}
715
716func (s *Service) buildImageRef(image string) string {
717	if !s.config.RegistryAuthEnabled || s.config.RegistryDomain == "" {
718		return image
719	}
720
721	// Normalize registry domain by trimming any trailing slash
722	reg := strings.TrimSuffix(s.config.RegistryDomain, "/")
723
724	// Check if image already has the registry domain prefix
725	prefix := reg + "/"
726	if strings.HasPrefix(image, prefix) {
727		return image
728	}
729
730	// Check if image already has an explicit registry (host:port/ or host.domain/).
731	// We need to detect patterns like:
732	// - "docker.io/library/nginx:latest" (has dot and slash)
733	// - "localhost:5001/myapp:latest" (has host:port/ pattern)
734	// - "myregistry:5000/app:v1" (has host:port/ pattern)
735	// - "[fd00::1]:5000/app:v1" (IPv6 with port)
736	if hasExplicitRegistry(image) {
737		return image
738	}
739
740	return fmt.Sprintf("%s/%s", reg, image)
741}
742
743// hasExplicitRegistry checks if an image reference already includes an explicit registry.
744// Returns true for patterns like "docker.io/image", "localhost:5000/image", "localhost/image",
745// "registry:8080/image", "[fd00::1]/image", "[fd00::1]:5000/image".
746func hasExplicitRegistry(image string) bool {
747	// Find the first slash which separates registry from image name
748	slashIdx := strings.Index(image, "/")
749	if slashIdx == -1 {
750		return false // No slash means no registry prefix (e.g., "myapp:latest")
751	}
752
753	// Extract the part before the first slash (potential registry)
754	registryPart := image[:slashIdx]
755
756	// Check for bracketed IPv6 address (e.g., "[fd00::1]" or "[fd00::1]:5000")
757	if strings.HasPrefix(registryPart, "[") {
758		// Look for closing bracket
759		if closeBracket := strings.Index(registryPart, "]"); closeBracket != -1 {
760			// Valid bracketed IPv6: either ends at ] or has ]:port
761			return true
762		}
763	}
764
765	// Check for localhost (with or without port)
766	if registryPart == "localhost" {
767		return true
768	}
769
770	// Check for host:port pattern (e.g., "localhost:5000", "registry:8080")
771	if colonIdx := strings.LastIndex(registryPart, ":"); colonIdx != -1 {
772		port := registryPart[colonIdx+1:]
773		// If everything after colon is digits, it's a port
774		if len(port) > 0 && isNumeric(port) {
775			return true
776		}
777	}
778
779	// Check for domain pattern (has a dot, e.g., "docker.io", "gcr.io")
780	if strings.Contains(registryPart, ".") {
781		return true
782	}
783
784	return false
785}
786
787// isNumeric checks if a string contains only digits.
788func isNumeric(s string) bool {
789	for _, c := range s {
790		if c < '0' || c > '9' {
791			return false
792		}
793	}
794	return true
795}
796
797func normalizePullPolicy(policy string) string {
798	switch strings.ToLower(strings.TrimSpace(policy)) {
799	case PullPolicyAlways:
800		return PullPolicyAlways
801	case PullPolicyIfTagChanged:
802		return PullPolicyIfTagChanged
803	case PullPolicyIfNotPresent:
804		return PullPolicyIfNotPresent
805	default:
806		return PullPolicyIfNotPresent
807	}
808}
809
810func isDigestRef(imageRef string) bool {
811	return strings.Contains(imageRef, "@sha256:")
812}
813
814func (s *Service) pullRefForDeploy(ctx context.Context, imageRef string) (string, bool) {
815	if !domain.IsInternalDeploy(ctx) {
816		return imageRef, false
817	}
818	return rewriteToLocalRegistry(imageRef, s.config.RegistryDomain, s.config.RegistryPort), true
819}
820
821// ensureImage ensures the image is available locally, pulling if needed.
822// Returns the image reference to use for container operations:
823//   - For digest references (@sha256:...), returns the pullRef since Docker can't tag digests
824//   - For tagged images, returns the original imageRef after tagging the pulled image
825func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, error) {
826	ctx = zerowrap.CtxWithField(ctx, "image", imageRef)
827	log := zerowrap.FromCtx(ctx)
828
829	// Determine if this is an internal deploy and what reference to use for pulls.
830	pullRef, isInternal := s.pullRefForDeploy(ctx, imageRef)
831	if pullRef != imageRef {
832		log.Info().
833			Str("original_ref", imageRef).
834			Str("pull_ref", pullRef).
835			Msg("internal deploy: using localhost registry for pull")
836	}
837
838	found, err := s.ensureLocalImage(ctx, imageRef, pullRef)
839	if err != nil {
840		return "", err
841	}
842	if found {
843		return imageRef, nil
844	}
845
846	// Pull image
847	log.Info().Msg("pulling image from registry")
848
849	if err := s.pullImage(ctx, pullRef, isInternal); err != nil {
850		return "", err
851	}
852
853	// For digest references, we can't create a tag (Docker doesn't allow it).
854	// In this case, use the pullRef directly since the image is available under that reference.
855	if strings.Contains(imageRef, "@sha256:") {
856		log.Info().
857			Str("pull_ref", pullRef).
858			Msg("digest reference: using pull reference for container operations")
859		return pullRef, nil
860	}
861
862	if err := s.tagImageIfNeeded(ctx, pullRef, imageRef); err != nil {
863		return "", err
864	}
865
866	// Clean up the temporary pull reference tag to avoid duplicate entries
867	if pullRef != imageRef {
868		if err := s.runtime.UntagImage(ctx, pullRef); err != nil {
869			// Log but don't fail - the canonical tag is already applied
870			log.Debug().Err(err).Str("pull_ref", pullRef).Msg("failed to remove temporary pull tag")
871		}
872	}
873
874	log.Info().Msg("image pulled successfully")
875	return imageRef, nil
876}
877
878func (s *Service) ensureLocalImage(ctx context.Context, imageRef, pullRef string) (bool, error) {
879	log := zerowrap.FromCtx(ctx)
880
881	// For internal deploys (image push events), always pull fresh image
882	// because the same tag may reference new content (e.g., latest tag updated)
883	if domain.IsInternalDeploy(ctx) {
884		log.Info().Msg("internal deploy detected, forcing image pull to ensure latest content")
885		return false, nil
886	}
887
888	pullPolicy := normalizePullPolicy(s.config.PullPolicy)
889	switch pullPolicy {
890	case PullPolicyAlways:
891		log.Info().Str("pull_policy", pullPolicy).Msg("pull policy forces image pull")
892		return false, nil
893	case PullPolicyIfTagChanged:
894		if !isDigestRef(imageRef) {
895			log.Info().Str("pull_policy", pullPolicy).Msg("tag reference detected, pulling to check for updates")
896			return false, nil
897		}
898	}
899
900	localImages, err := s.runtime.ListImages(ctx)
901	if err != nil {
902		log.WrapErr(err, "failed to list local images, will attempt pull")
903		return false, nil
904	}
905
906	normalizedRef := normalizeImageRef(imageRef)
907	normalizedPullRef := normalizeImageRef(pullRef)
908	for _, img := range localImages {
909		normalizedImage := normalizeImageRef(img)
910		if normalizedImage == normalizedRef {
911			log.Info().Msg("image found locally, skipping pull")
912			return true, nil
913		}
914		if normalizedImage == normalizedPullRef {
915			if err := s.tagImageIfNeeded(ctx, pullRef, imageRef); err != nil {
916				return false, err
917			}
918			// Clean up the temporary pull reference tag
919			if pullRef != imageRef {
920				if err := s.runtime.UntagImage(ctx, pullRef); err != nil {
921					log.Debug().Err(err).Str("pull_ref", pullRef).Msg("failed to remove temporary pull tag")
922				}
923			}
924			log.Info().Msg("image found locally, skipping pull")
925			return true, nil
926		}
927	}
928
929	return false, nil
930}
931
932func (s *Service) pullImage(ctx context.Context, pullRef string, isInternal bool) error {
933	log := zerowrap.FromCtx(ctx)
934
935	switch {
936	case isInternal && s.config.RegistryAuthEnabled:
937		if s.config.InternalRegistryUsername == "" || s.config.InternalRegistryPassword == "" {
938			return log.WrapErr(fmt.Errorf("internal registry auth not configured"), "failed to pull image for internal deploy")
939		}
940		if err := s.runtime.PullImageWithAuth(ctx, pullRef, s.config.InternalRegistryUsername, s.config.InternalRegistryPassword); err != nil {
941			return log.WrapErr(err, "failed to pull image with internal auth")
942		}
943	case isInternal:
944		if err := s.runtime.PullImage(ctx, pullRef); err != nil {
945			return log.WrapErr(err, "failed to pull image")
946		}
947	case s.config.RegistryAuthEnabled:
948		if s.config.ServiceTokenUsername == "" || s.config.ServiceToken == "" {
949			return log.WrapErr(fmt.Errorf("registry service token not configured"), "failed to pull image for registry auth")
950		}
951		if err := s.runtime.PullImageWithAuth(ctx, pullRef, s.config.ServiceTokenUsername, s.config.ServiceToken); err != nil {
952			return log.WrapErr(err, "failed to pull image with auth")
953		}
954	default:
955		if err := s.runtime.PullImage(ctx, pullRef); err != nil {
956			return log.WrapErr(err, "failed to pull image")
957		}
958	}
959
960	return nil
961}
962
963func (s *Service) tagImageIfNeeded(ctx context.Context, sourceRef, targetRef string) error {
964	if sourceRef == targetRef {
965		return nil
966	}
967
968	// Cannot create a tag with a digest reference - Docker/Podman doesn't allow it.
969	// When using digest references (image@sha256:...), skip tagging as the image
970	// is already available by its digest.
971	if strings.Contains(targetRef, "@sha256:") {
972		return nil
973	}
974
975	log := zerowrap.FromCtx(ctx)
976	if err := s.runtime.TagImage(ctx, sourceRef, targetRef); err != nil {
977		return log.WrapErr(err, "failed to tag image from pull reference")
978	}
979	return nil
980}
981
982func (s *Service) loadEnvironment(ctx context.Context, domainName, imageRef string) ([]string, error) {
983	log := zerowrap.FromCtx(ctx)
984
985	userEnvVars, err := s.envLoader.LoadEnv(ctx, domainName)
986	if err != nil {
987		return nil, log.WrapErr(err, "failed to load environment variables")
988	}
989
990	dockerfileEnvVars, err := s.runtime.InspectImageEnv(ctx, imageRef)
991	if err != nil {
992		log.WrapErr(err, "failed to inspect image environment")
993		dockerfileEnvVars = []string{}
994	}
995
996	return mergeEnvironmentVariables(dockerfileEnvVars, userEnvVars), nil
997}
998
999func (s *Service) setupVolumes(ctx context.Context, domainName, imageRef string) (map[string]string, error) {
1000	log := zerowrap.FromCtx(ctx)
1001	volumes := make(map[string]string)
1002
1003	if !s.config.VolumeAutoCreate {
1004		return volumes, nil
1005	}
1006
1007	volumePaths, err := s.runtime.InspectImageVolumes(ctx, imageRef)
1008	if err != nil {
1009		log.WrapErr(err, "failed to inspect image volumes")
1010		return volumes, nil
1011	}
1012
1013	for _, path := range volumePaths {
1014		name := generateVolumeName(s.config.VolumePrefix, domainName, path)
1015
1016		exists, err := s.runtime.VolumeExists(ctx, name)
1017		if err != nil {
1018			log.WrapErrWithFields(err, "failed to check volume", map[string]any{"volume": name})
1019			continue
1020		}
1021
1022		if !exists {
1023			if err := s.runtime.CreateVolume(ctx, name); err != nil {
1024				log.WrapErrWithFields(err, "failed to create volume", map[string]any{"volume": name})
1025				continue
1026			}
1027			log.Info().Str("volume", name).Str(zerowrap.FieldPath, path).Msg("created volume")
1028		}
1029
1030		volumes[path] = name
1031	}
1032
1033	return volumes, nil
1034}
1035
1036func (s *Service) getNetworkForApp(domainName string) string {
1037	if !s.config.NetworkIsolation {
1038		return "bridge"
1039	}
1040
1041	for groupName, domains := range s.config.NetworkGroups {
1042		if slices.Contains(domains, domainName) {
1043			return s.generateNetworkName(groupName)
1044		}
1045	}
1046
1047	return s.generateNetworkName(domainName)
1048}
1049
1050func (s *Service) generateNetworkName(identifier string) string {
1051	return fmt.Sprintf("%s-%s", s.config.NetworkPrefix, strings.ReplaceAll(identifier, ".", "-"))
1052}
1053
1054func (s *Service) createNetworkIfNeeded(ctx context.Context, networkName string) error {
1055	if networkName == "bridge" || networkName == "default" {
1056		return nil
1057	}
1058
1059	ctx = zerowrap.CtxWithField(ctx, "network", networkName)
1060	log := zerowrap.FromCtx(ctx)
1061
1062	exists, err := s.runtime.NetworkExists(ctx, networkName)
1063	if err != nil {
1064		return log.WrapErr(err, "failed to check network existence")
1065	}
1066
1067	if !exists {
1068		if err := s.runtime.CreateNetwork(ctx, networkName, map[string]string{"driver": "bridge"}); err != nil {
1069			return log.WrapErr(err, "failed to create network")
1070		}
1071		log.Info().Msg("created network for app isolation")
1072	}
1073
1074	return nil
1075}
1076
1077func (s *Service) cleanupOrphanedContainers(ctx context.Context, domainName string, skipContainerID string) error {
1078	log := zerowrap.FromCtx(ctx)
1079	expectedName := fmt.Sprintf("gordon-%s", domainName)
1080
1081	allContainers, err := s.runtime.ListContainers(ctx, true)
1082	if err != nil {
1083		return err
1084	}
1085
1086	for _, c := range allContainers {
1087		if c.Name == expectedName && c.ID != skipContainerID {
1088			log.Info().Str(zerowrap.FieldEntityID, c.ID).Str(zerowrap.FieldStatus, c.Status).Msg("found orphaned container, removing")
1089
1090			if err := s.runtime.StopContainer(ctx, c.ID); err != nil {
1091				log.WrapErrWithFields(err, "failed to stop orphaned container", map[string]any{zerowrap.FieldEntityID: c.ID})
1092			}
1093
1094			if err := s.runtime.RemoveContainer(ctx, c.ID, true); err != nil {
1095				log.WrapErrWithFields(err, "failed to remove orphaned container", map[string]any{zerowrap.FieldEntityID: c.ID})
1096			}
1097		}
1098	}
1099
1100	return nil
1101}
1102
1103func (s *Service) cleanupVolumesForDomain(_ context.Context, _ string) error {
1104	return nil
1105}
1106
1107func (s *Service) cleanupNetworkIfEmpty(ctx context.Context, networkName string) error {
1108	if networkName == "bridge" || networkName == "default" {
1109		return nil
1110	}
1111
1112	ctx = zerowrap.CtxWithField(ctx, "network", networkName)
1113	log := zerowrap.FromCtx(ctx)
1114
1115	networks, err := s.runtime.ListNetworks(ctx)
1116	if err != nil {
1117		return log.WrapErr(err, "failed to list networks")
1118	}
1119
1120	for _, n := range networks {
1121		if n.Name == networkName && len(n.Containers) == 0 {
1122			if err := s.runtime.RemoveNetwork(ctx, networkName); err != nil {
1123				return err
1124			}
1125			log.Info().Msg("cleaned up empty network")
1126			break
1127		}
1128	}
1129
1130	return nil
1131}
1132
1133func (s *Service) deployAttachments(ctx context.Context, domainName, networkName string) error {
1134	// Collect attachments from both domain-specific and network group configs
1135	attachments := s.resolveAttachmentsForDomain(domainName)
1136	if len(attachments) == 0 {
1137		return nil
1138	}
1139
1140	log := zerowrap.FromCtx(ctx)
1141	for _, svc := range attachments {
1142		if err := s.deployAttachedService(ctx, domainName, svc, networkName); err != nil {
1143			log.WrapErrWithFields(err, "failed to deploy attachment", map[string]any{zerowrap.FieldService: svc, "domain": domainName})
1144		}
1145	}
1146
1147	return nil
1148}
1149
1150// resolveAttachmentsForDomain returns attachments for a domain by checking:
1151// 1. Direct domain attachments (attachments[domain])
1152// 2. Network group attachments (attachments[group] where domain is in network_groups[group])
1153func (s *Service) resolveAttachmentsForDomain(domainName string) []string {
1154	seen := make(map[string]bool)
1155	var result []string
1156
1157	// First, add domain-specific attachments
1158	if domainAttachments, ok := s.config.Attachments[domainName]; ok {
1159		for _, img := range domainAttachments {
1160			if !seen[img] {
1161				seen[img] = true
1162				result = append(result, img)
1163			}
1164		}
1165	}
1166
1167	// Then, find which network group this domain belongs to and add group attachments
1168	for groupName, domains := range s.config.NetworkGroups {
1169		if slices.Contains(domains, domainName) {
1170			if groupAttachments, ok := s.config.Attachments[groupName]; ok {
1171				for _, img := range groupAttachments {
1172					if !seen[img] {
1173						seen[img] = true
1174						result = append(result, img)
1175					}
1176				}
1177			}
1178			break // Domain can only be in one network group
1179		}
1180	}
1181
1182	return result
1183}
1184
1185func (s *Service) getAttachmentsForDomain(ctx context.Context, domainName string) []domain.Attachment {
1186	containers, err := s.runtime.ListContainers(ctx, true)
1187	if err != nil {
1188		return nil
1189	}
1190
1191	return s.filterAttachments(ctx, containers, domainName)
1192}
1193
1194// getAllAttachments fetches all containers once and returns attachments grouped by domain.
1195// This avoids N+1 queries when listing multiple routes.
1196func (s *Service) getAllAttachments(ctx context.Context) map[string][]domain.Attachment {
1197	containers, err := s.runtime.ListContainers(ctx, true)
1198	if err != nil {
1199		return nil
1200	}
1201
1202	result := make(map[string][]domain.Attachment)
1203	for _, container := range containers {
1204		if container.Labels == nil {
1205			continue
1206		}
1207		if container.Labels[domain.LabelAttachment] != "true" {
1208			continue
1209		}
1210		ownerDomain := container.Labels[domain.LabelAttachedTo]
1211		if ownerDomain == "" {
1212			continue
1213		}
1214		image := container.Image
1215		serviceName := container.Name
1216		if labelImage, ok := container.Labels[domain.LabelImage]; ok && labelImage != "" {
1217			image = labelImage
1218			serviceName = extractServiceName(labelImage)
1219		}
1220		// Get the container's network for display in the routes table
1221		network := ""
1222		if networkName, err := s.runtime.GetContainerNetwork(ctx, container.ID); err == nil {
1223			network = networkName
1224		}
1225		attachment := domain.Attachment{
1226			Name:        serviceName,
1227			Image:       s.stripRegistryPrefix(image),
1228			ContainerID: container.ID,
1229			Status:      container.Status,
1230			Network:     network,
1231		}
1232		result[ownerDomain] = append(result[ownerDomain], attachment)
1233	}
1234
1235	return result
1236}
1237
1238// filterAttachments extracts attachments for a specific domain from a container list.
1239func (s *Service) filterAttachments(ctx context.Context, containers []*domain.Container, domainName string) []domain.Attachment {
1240	attachments := make([]domain.Attachment, 0)
1241	for _, container := range containers {
1242		if container.Labels == nil {
1243			continue
1244		}
1245		if container.Labels[domain.LabelAttachment] != "true" {
1246			continue
1247		}
1248		if container.Labels[domain.LabelAttachedTo] != domainName {
1249			continue
1250		}
1251		image := container.Image
1252		serviceName := container.Name
1253		if labelImage, ok := container.Labels[domain.LabelImage]; ok && labelImage != "" {
1254			image = labelImage
1255			serviceName = extractServiceName(labelImage)
1256		}
1257		// Get the container's network for display in the routes table
1258		network := ""
1259		if networkName, err := s.runtime.GetContainerNetwork(ctx, container.ID); err == nil {
1260			network = networkName
1261		}
1262		attachment := domain.Attachment{
1263			Name:        serviceName,
1264			Image:       s.stripRegistryPrefix(image),
1265			ContainerID: container.ID,
1266			Status:      container.Status,
1267			Network:     network,
1268		}
1269		attachments = append(attachments, attachment)
1270	}
1271
1272	return attachments
1273}
1274
1275func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, serviceImage, networkName string) error {
1276	log := zerowrap.FromCtx(ctx)
1277
1278	// Parse service name from image (e.g., "my-postgres:latest" → "postgres")
1279	serviceName := extractServiceName(serviceImage)
1280	containerName := fmt.Sprintf("gordon-%s-%s", sanitizeName(ownerDomain), serviceName)
1281
1282	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1283		"attachment":     serviceName,
1284		"container_name": containerName,
1285		"owner_domain":   ownerDomain,
1286	})
1287
1288	// Check if already running (idempotent)
1289	// Try new name first, then fall back to legacy name for backwards compatibility
1290	existingContainer := s.findContainerByName(ctx, containerName)
1291	if existingContainer == nil {
1292		// Try legacy naming scheme for backwards compatibility during upgrades
1293		containerNameLegacy := fmt.Sprintf("gordon-%s-%s", sanitizeNameLegacy(ownerDomain), serviceName)
1294		existingContainer = s.findContainerByName(ctx, containerNameLegacy)
1295		if existingContainer != nil {
1296			log.Info().Str("container_name", containerNameLegacy).Msg("found attachment with legacy naming, will be replaced")
1297			// Remove the old container and recreate with new name
1298			if err := s.runtime.StopContainer(ctx, existingContainer.ID); err != nil {
1299				return log.WrapErr(err, "failed to stop legacy attachment container")
1300			}
1301			if err := s.runtime.RemoveContainer(ctx, existingContainer.ID, true); err != nil {
1302				return log.WrapErr(err, "failed to remove legacy attachment container")
1303			}
1304			existingContainer = nil // Clear so we create new one below
1305		}
1306	}
1307
1308	if existingContainer != nil && existingContainer.Status == string(domain.ContainerStatusRunning) {
1309		log.Debug().Str("container_name", containerName).Msg("attachment already running, skipping")
1310		return nil
1311	}
1312
1313	// Remove existing stopped container if present
1314	if existingContainer != nil {
1315		log.Info().Str("container_name", containerName).Msg("removing stopped attachment container")
1316		if err := s.runtime.RemoveContainer(ctx, existingContainer.ID, true); err != nil {
1317			return log.WrapErr(err, "failed to remove existing attachment container")
1318		}
1319	}
1320
1321	log.Info().Str(zerowrap.FieldService, serviceImage).Msg("deploying attached service")
1322
1323	// Ensure image (canonical ref is returned; internal pulls may use the local registry).
1324	imageRef := s.buildImageRef(serviceImage)
1325	actualImageRef, err := s.ensureImage(ctx, imageRef)
1326	if err != nil {
1327		return err
1328	}
1329
1330	// Get image metadata
1331	exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, actualImageRef)
1332	if err != nil {
1333		log.WrapErr(err, "failed to get exposed ports for attachment, using defaults")
1334		exposedPorts = []int{}
1335	}
1336
1337	// Setup volumes (attachments need persistent data)
1338	volumes, err := s.setupVolumes(ctx, containerName, actualImageRef)
1339	if err != nil {
1340		log.WrapErr(err, "failed to setup volumes for attachment")
1341		volumes = make(map[string]string)
1342	}
1343
1344	// Load environment (attachment-specific env file)
1345	envVars, err := s.loadEnvironment(ctx, containerName, actualImageRef)
1346	if err != nil {
1347		log.WrapErr(err, "failed to load environment for attachment")
1348		envVars = []string{}
1349	}
1350
1351	// Create container with attachment labels (use actual ref, track original in labels)
1352	config := &domain.ContainerConfig{
1353		Image:       actualImageRef,
1354		Name:        containerName,
1355		Hostname:    serviceName, // Internal DNS: postgres, redis, etc.
1356		Ports:       exposedPorts,
1357		Env:         envVars,
1358		Volumes:     volumes,
1359		NetworkMode: networkName, // Same network as main app
1360		Labels: map[string]string{
1361			"gordon.managed":     "true",
1362			"gordon.attachment":  "true",
1363			"gordon.attached-to": ownerDomain,
1364			"gordon.image":       serviceImage,
1365		},
1366	}
1367
1368	container, err := s.runtime.CreateContainer(ctx, config)
1369	if err != nil {
1370		return log.WrapErr(err, "failed to create attachment container")
1371	}
1372
1373	// Start container
1374	if err := s.runtime.StartContainer(ctx, container.ID); err != nil {
1375		s.runtime.RemoveContainer(ctx, container.ID, true)
1376		return log.WrapErr(err, "failed to start attachment container")
1377	}
1378
1379	// Track attachment
1380	s.mu.Lock()
1381	s.attachments[ownerDomain] = append(s.attachments[ownerDomain], container.ID)
1382	s.mu.Unlock()
1383
1384	// Start log collection for attachment
1385	s.startLogCollection(ctx, container.ID, containerName)
1386
1387	log.Info().Str(zerowrap.FieldEntityID, container.ID).Msg("attachment deployed successfully")
1388	return nil
1389}
1390
1391func (s *Service) stopAllManagedContainers(ctx context.Context) error {
1392	log := zerowrap.FromCtx(ctx)
1393
1394	s.mu.RLock()
1395	containers := make(map[string]*domain.Container, len(s.containers))
1396	maps.Copy(containers, s.containers)
1397	s.mu.RUnlock()
1398
1399	if len(containers) == 0 {
1400		log.Info().Msg("no managed containers to stop")
1401		return nil
1402	}
1403
1404	log.Info().Int(zerowrap.FieldCount, len(containers)).Msg("stopping all managed containers...")
1405
1406	errorCount := 0
1407	for d, c := range containers {
1408		log.Info().Str("domain", d).Str(zerowrap.FieldEntityID, c.ID).Msg("stopping managed container")
1409
1410		if err := s.runtime.StopContainer(ctx, c.ID); err != nil {
1411			log.WrapErrWithFields(err, "failed to stop container", map[string]any{"domain": d, zerowrap.FieldEntityID: c.ID})
1412			errorCount++
1413			continue
1414		}
1415
1416		s.mu.Lock()
1417		delete(s.containers, d)
1418		s.mu.Unlock()
1419
1420		log.Info().Str("domain", d).Str(zerowrap.FieldEntityID, c.ID).Msg("container stopped successfully")
1421	}
1422
1423	if errorCount > 0 {
1424		return fmt.Errorf("failed to stop %d containers", errorCount)
1425	}
1426
1427	log.Info().Msg("all managed containers stopped successfully")
1428	return nil
1429}
1430
1431// Utility functions
1432
1433func normalizeImageRef(image string) string {
1434	// Extract repository from image reference, handling port numbers correctly.
1435	// Examples:
1436	//   nginx:latest -> docker.io/library/nginx
1437	//   user/repo:tag -> docker.io/user/repo
1438	//   localhost:5000/image:tag -> localhost:5000/image
1439	//   registry.example.com/image:tag -> registry.example.com/image
1440	//
1441	// Note: This function assumes valid Docker image references. Edge cases like
1442	// "registry.com:5000" (registry with port but no image name) are not valid
1443	// image references per Docker's naming conventions, which require at least
1444	// one path component after the registry (e.g., "registry.com:5000/image").
1445
1446	// Find the tag separator: the last colon that isn't part of a port number.
1447	// A colon is part of a port if there's no slash after it until the next colon.
1448	repo := image
1449	lastColon := strings.LastIndex(image, ":")
1450	if lastColon != -1 {
1451		afterColon := image[lastColon+1:]
1452		// If there's no slash after the colon, it's the tag separator
1453		if !strings.Contains(afterColon, "/") {
1454			repo = image[:lastColon]
1455		}
1456	}
1457
1458	if !strings.Contains(repo, "/") {
1459		return "docker.io/library/" + repo
1460	}
1461
1462	if strings.Count(repo, "/") == 1 && !strings.Contains(strings.Split(repo, "/")[0], ".") && !strings.Contains(strings.Split(repo, "/")[0], ":") {
1463		return "docker.io/" + repo
1464	}
1465
1466	return repo
1467}
1468
1469func generateVolumeName(prefix, domainName, volumePath string) string {
1470	return fmt.Sprintf("%s-%s-%s",
1471		prefix,
1472		strings.ReplaceAll(domainName, ".", "-"),
1473		strings.ReplaceAll(strings.Trim(volumePath, "/"), "/", "-"))
1474}
1475
1476func mergeEnvironmentVariables(dockerfileEnv, userEnv []string) []string {
1477	envMap := make(map[string]string)
1478
1479	for _, env := range dockerfileEnv {
1480		if k, v, ok := strings.Cut(env, "="); ok {
1481			envMap[k] = v
1482		}
1483	}
1484
1485	for _, env := range userEnv {
1486		if k, v, ok := strings.Cut(env, "="); ok {
1487			envMap[k] = v
1488		}
1489	}
1490
1491	result := make([]string, 0, len(envMap))
1492	for k, v := range envMap {
1493		result = append(result, k+"="+v)
1494	}
1495
1496	return result
1497}
1498
1499// extractServiceName gets service name from image reference.
1500// "my-postgres:latest" → "postgres", "redis:7" → "redis"
1501func extractServiceName(image string) string {
1502	// Remove tag
1503	parts := strings.Split(image, ":")
1504	name := parts[0]
1505
1506	// Remove registry prefix if present
1507	if strings.Contains(name, "/") {
1508		nameParts := strings.Split(name, "/")
1509		name = nameParts[len(nameParts)-1]
1510	}
1511
1512	// Remove common prefixes like "my-"
1513	name = strings.TrimPrefix(name, "my-")
1514
1515	return name
1516}
1517
1518// sanitizeName is a convenience wrapper around domain.SanitizeDomainForContainer.
1519func sanitizeName(d string) string {
1520	return domain.SanitizeDomainForContainer(d)
1521}
1522
1523// sanitizeNameLegacy is a convenience wrapper around domain.SanitizeDomainForContainerLegacy.
1524func sanitizeNameLegacy(d string) string {
1525	return domain.SanitizeDomainForContainerLegacy(d)
1526}
1527
1528// findContainerByName finds a container by its name.
1529func (s *Service) findContainerByName(ctx context.Context, name string) *domain.Container {
1530	containers, err := s.runtime.ListContainers(ctx, true)
1531	if err != nil {
1532		return nil
1533	}
1534
1535	for _, c := range containers {
1536		if c.Name == name {
1537			return c
1538		}
1539	}
1540	return nil
1541}
1542
1543// waitForReady waits for a container to be ready.
1544// Uses simple "running + delay" approach for universal compatibility.
1545func (s *Service) waitForReady(ctx context.Context, containerID string) error {
1546	log := zerowrap.FromCtx(ctx)
1547
1548	// Poll for container to be running (max 30 seconds)
1549	for i := 0; i < 30; i++ {
1550		running, err := s.runtime.IsContainerRunning(ctx, containerID)
1551		if err != nil {
1552			return err
1553		}
1554		if running {
1555			break
1556		}
1557		if i == 29 {
1558			return fmt.Errorf("container did not start within 30 seconds")
1559		}
1560		time.Sleep(time.Second)
1561	}
1562
1563	// Additional readiness delay (configurable, default 5 seconds)
1564	delay := s.config.ReadinessDelay
1565	if delay == 0 {
1566		delay = 5 * time.Second
1567	}
1568
1569	log.Debug().Dur("delay", delay).Msg("waiting for container readiness")
1570	time.Sleep(delay)
1571
1572	// Verify still running after delay
1573	running, err := s.runtime.IsContainerRunning(ctx, containerID)
1574	if err != nil {
1575		return err
1576	}
1577	if !running {
1578		return fmt.Errorf("container stopped during readiness delay")
1579	}
1580
1581	return nil
1582}
1583
1584// publishContainerDeployed publishes a container.deployed event.
1585func (s *Service) publishContainerDeployed(ctx context.Context, domainName, containerID string) {
1586	payload := &domain.ContainerEventPayload{
1587		ContainerID: containerID,
1588		Domain:      domainName,
1589		Action:      "deployed",
1590	}
1591
1592	if err := s.eventBus.Publish(domain.EventContainerDeployed, payload); err != nil {
1593		log := zerowrap.FromCtx(ctx)
1594		log.Warn().Err(err).Msg("failed to publish container deployed event")
1595	}
1596}
1597
1598// rewriteToRegistryDomain rewrites an image reference to use the configured registry domain.
1599// e.g., "myapp:latest" -> "registry.example.com/myapp:latest"
1600func rewriteToRegistryDomain(imageRef, registryDomain string) string {
1601	if registryDomain == "" {
1602		return imageRef
1603	}
1604
1605	prefix := registryDomain + "/"
1606	if strings.HasPrefix(imageRef, prefix) {
1607		return imageRef
1608	}
1609
1610	return prefix + imageRef
1611}
1612
1613// rewriteToLocalRegistry rewrites an image reference to use the local registry address.
1614// e.g., "registry.example.com/myapp:latest" -> "localhost:5000/myapp:latest"
1615func rewriteToLocalRegistry(imageRef, registryDomain string, registryPort int) string {
1616	if imageRef == "" {
1617		return imageRef
1618	}
1619
1620	localRegistry := fmt.Sprintf("localhost:%d", registryPort)
1621	localPrefix := localRegistry + "/"
1622	imageRef = strings.TrimPrefix(imageRef, localPrefix)
1623
1624	registryDomain = strings.TrimSuffix(registryDomain, "/")
1625	if registryDomain != "" {
1626		prefix := registryDomain + "/"
1627		imageRef = strings.TrimPrefix(imageRef, prefix)
1628	}
1629
1630	return localPrefix + imageRef
1631}

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