v2.4.1 7c3b292

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	"gordon/internal/boundaries/out"
16	"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	RegistryUsername         string
25	RegistryPassword         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}
57
58// NewService creates a new container service.
59func NewService(
60	runtime out.ContainerRuntime,
61	envLoader out.EnvLoader,
62	eventBus out.EventPublisher,
63	logWriter out.ContainerLogWriter,
64	config Config,
65) *Service {
66	return &Service{
67		runtime:     runtime,
68		envLoader:   envLoader,
69		eventBus:    eventBus,
70		logWriter:   logWriter,
71		config:      config,
72		containers:  make(map[string]*domain.Container),
73		attachments: make(map[string][]string),
74	}
75}
76
77// Deploy creates and starts a container for the given route.
78// Implements zero-downtime deployment: new container starts before old one stops.
79func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Container, error) {
80	// Enrich context with use case fields for all downstream logs
81	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
82		zerowrap.FieldLayer:   "usecase",
83		zerowrap.FieldUseCase: "Deploy",
84		"domain":              route.Domain,
85	})
86	log := zerowrap.FromCtx(ctx)
87
88	// Get existing container (if any) without holding lock
89	s.mu.RLock()
90	existing, hasExisting := s.containers[route.Domain]
91	s.mu.RUnlock()
92
93	// Clean up orphaned containers (containers with same name but not tracked)
94	// Skip the currently tracked container to preserve zero-downtime deployment
95	existingID := ""
96	if hasExisting {
97		existingID = existing.ID
98	}
99	if err := s.cleanupOrphanedContainers(ctx, route.Domain, existingID); err != nil {
100		log.WrapErr(err, "failed to cleanup orphaned containers")
101	}
102
103	// Build image reference and ensure it's available.
104	// ensureImage returns the canonical reference; internal pulls may use the local registry.
105	imageRef := s.buildImageRef(route.Image)
106	actualImageRef, err := s.ensureImage(ctx, imageRef)
107	if err != nil {
108		return nil, err
109	}
110
111	// Setup network FIRST (attachments need it)
112	networkName := s.getNetworkForApp(route.Domain)
113	if err := s.createNetworkIfNeeded(ctx, networkName); err != nil {
114		return nil, log.WrapErr(err, "failed to create network")
115	}
116
117	// Deploy attachments BEFORE main container (dependencies first)
118	if err := s.deployAttachments(ctx, route.Domain, networkName); err != nil {
119		log.WrapErr(err, "failed to deploy some attachments")
120	}
121
122	// Get exposed ports (use actual image ref from pull)
123	exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, actualImageRef)
124	if err != nil {
125		log.WrapErr(err, "failed to get exposed ports, using defaults")
126		exposedPorts = []int{80, 8080, 3000}
127	}
128
129	// Load environment
130	envVars, err := s.loadEnvironment(ctx, route.Domain, actualImageRef)
131	if err != nil {
132		return nil, err
133	}
134
135	// Setup volumes (use actual image ref from pull)
136	volumes, err := s.setupVolumes(ctx, route.Domain, actualImageRef)
137	if err != nil {
138		return nil, err
139	}
140
141	// Determine container name (use temp suffix for zero-downtime if existing)
142	containerName := fmt.Sprintf("gordon-%s", route.Domain)
143	if hasExisting {
144		containerName = fmt.Sprintf("gordon-%s-new", route.Domain)
145	}
146
147	// Create container (use actual image ref from pull, but track original in labels)
148	containerConfig := &domain.ContainerConfig{
149		Image:       actualImageRef,
150		Name:        containerName,
151		Ports:       exposedPorts,
152		Env:         envVars,
153		Volumes:     volumes,
154		NetworkMode: networkName,
155		Hostname:    route.Domain,
156		Labels: map[string]string{
157			"gordon.domain":  route.Domain,
158			"gordon.image":   route.Image,
159			"gordon.managed": "true",
160			"gordon.route":   route.Domain,
161		},
162		AutoRemove: false,
163	}
164
165	newContainer, err := s.runtime.CreateContainer(ctx, containerConfig)
166	if err != nil {
167		return nil, log.WrapErr(err, "failed to create container")
168	}
169
170	// Start new container
171	if err := s.runtime.StartContainer(ctx, newContainer.ID); err != nil {
172		s.runtime.RemoveContainer(ctx, newContainer.ID, true)
173		return nil, log.WrapErr(err, "failed to start container")
174	}
175
176	// Wait for new container to be ready
177	if err := s.waitForReady(ctx, newContainer.ID); err != nil {
178		s.cleanupFailedContainer(ctx, newContainer.ID)
179		return nil, log.WrapErr(err, "container failed readiness check")
180	}
181
182	// Re-inspect for updated info (ports, etc.)
183	newContainer, err = s.runtime.InspectContainer(ctx, newContainer.ID)
184	if err != nil {
185		return nil, log.WrapErr(err, "failed to inspect started container")
186	}
187
188	// ATOMIC SWITCH: Update tracking first (proxy will now route to new container)
189	s.mu.Lock()
190	s.containers[route.Domain] = newContainer
191	s.mu.Unlock()
192
193	// Publish container deployed event (proxy will invalidate cache)
194	s.publishContainerDeployed(ctx, route.Domain, newContainer.ID)
195
196	// NOW stop and remove old container (traffic already going to new one)
197	if hasExisting {
198		s.cleanupOldContainer(ctx, existing, newContainer.ID, route.Domain)
199	}
200
201	// Start container log collection (non-blocking, errors don't fail deployment)
202	s.startLogCollection(ctx, newContainer.ID, route.Domain)
203
204	log.Info().
205		Str("image", route.Image).
206		Str(zerowrap.FieldEntityID, newContainer.ID).
207		Ints("ports", newContainer.Ports).
208		Str("network", networkName).
209		Bool("zero_downtime", hasExisting).
210		Msg("container deployed successfully")
211
212	return newContainer, nil
213}
214
215// Stop stops a running container.
216func (s *Service) Stop(ctx context.Context, containerID string) error {
217	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
218		zerowrap.FieldLayer:    "usecase",
219		zerowrap.FieldUseCase:  "Stop",
220		zerowrap.FieldEntityID: containerID,
221	})
222	log := zerowrap.FromCtx(ctx)
223
224	// Stop log collection before stopping container
225	if s.logWriter != nil {
226		if err := s.logWriter.StopLogging(containerID); err != nil {
227			log.Warn().Err(err).Msg("failed to stop container log collection")
228		}
229	}
230
231	if err := s.runtime.StopContainer(ctx, containerID); err != nil {
232		return log.WrapErr(err, "failed to stop container")
233	}
234
235	log.Info().Msg("container stopped")
236	return nil
237}
238
239// Remove removes a container.
240func (s *Service) Remove(ctx context.Context, containerID string, force bool) error {
241	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
242		zerowrap.FieldLayer:    "usecase",
243		zerowrap.FieldUseCase:  "Remove",
244		zerowrap.FieldEntityID: containerID,
245	})
246	log := zerowrap.FromCtx(ctx)
247
248	// Stop log collection before removing container
249	if s.logWriter != nil {
250		if err := s.logWriter.StopLogging(containerID); err != nil {
251			log.Warn().Err(err).Msg("failed to stop container log collection")
252		}
253	}
254
255	// Find domain for cleanup
256	var containerDomain string
257	s.mu.RLock()
258	for d, c := range s.containers {
259		if c.ID == containerID {
260			containerDomain = d
261			break
262		}
263	}
264	s.mu.RUnlock()
265
266	if err := s.runtime.RemoveContainer(ctx, containerID, force); err != nil {
267		return log.WrapErr(err, "failed to remove container")
268	}
269
270	// Cleanup volumes
271	if containerDomain != "" && !s.config.VolumePreserve {
272		if err := s.cleanupVolumesForDomain(ctx, containerDomain); err != nil {
273			log.WrapErrWithFields(err, "failed to cleanup volumes", map[string]any{"domain": containerDomain})
274		}
275	}
276
277	// Remove from tracking
278	s.mu.Lock()
279	var removedDomain string
280	for d, c := range s.containers {
281		if c.ID == containerID {
282			delete(s.containers, d)
283			removedDomain = d
284			log.Info().Str("domain", d).Msg("container removed")
285			break
286		}
287	}
288	s.mu.Unlock()
289
290	// Cleanup network
291	if removedDomain != "" && s.config.NetworkIsolation {
292		networkName := s.getNetworkForApp(removedDomain)
293		if err := s.cleanupNetworkIfEmpty(ctx, networkName); err != nil {
294			log.WrapErrWithFields(err, "failed to cleanup network", map[string]any{"network": networkName})
295		}
296	}
297
298	return nil
299}
300
301// Get retrieves a container by domain.
302func (s *Service) Get(_ context.Context, domainName string) (*domain.Container, bool) {
303	s.mu.RLock()
304	defer s.mu.RUnlock()
305	container, exists := s.containers[domainName]
306	return container, exists
307}
308
309// List returns all managed containers.
310func (s *Service) List(_ context.Context) map[string]*domain.Container {
311	s.mu.RLock()
312	defer s.mu.RUnlock()
313
314	result := make(map[string]*domain.Container, len(s.containers))
315	maps.Copy(result, s.containers)
316	return result
317}
318
319// HealthCheck performs health checks on all containers.
320func (s *Service) HealthCheck(ctx context.Context) map[string]bool {
321	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
322		zerowrap.FieldLayer:   "usecase",
323		zerowrap.FieldUseCase: "HealthCheck",
324	})
325	log := zerowrap.FromCtx(ctx)
326
327	s.mu.RLock()
328	defer s.mu.RUnlock()
329
330	health := make(map[string]bool, len(s.containers))
331	for d, c := range s.containers {
332		running, err := s.runtime.IsContainerRunning(ctx, c.ID)
333		if err != nil {
334			log.WrapErrWithFields(err, "health check failed", map[string]any{"domain": d, zerowrap.FieldEntityID: c.ID})
335			health[d] = false
336		} else {
337			health[d] = running
338		}
339	}
340	return health
341}
342
343// SyncContainers synchronizes containers with runtime state.
344func (s *Service) SyncContainers(ctx context.Context) error {
345	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
346		zerowrap.FieldLayer:   "usecase",
347		zerowrap.FieldUseCase: "SyncContainers",
348	})
349	log := zerowrap.FromCtx(ctx)
350
351	s.mu.Lock()
352	defer s.mu.Unlock()
353
354	allContainers, err := s.runtime.ListContainers(ctx, false)
355	if err != nil {
356		return log.WrapErr(err, "failed to list containers")
357	}
358
359	managed := make(map[string]*domain.Container)
360	for _, c := range allContainers {
361		if c.Labels != nil {
362			if d, ok := c.Labels["gordon.domain"]; ok && c.Labels["gordon.managed"] == "true" {
363				managed[d] = c
364			}
365		}
366	}
367
368	s.containers = managed
369	log.Info().Int(zerowrap.FieldCount, len(managed)).Msg("container state synchronized")
370	return nil
371}
372
373// AutoStart starts containers for the provided routes that aren't running.
374func (s *Service) AutoStart(ctx context.Context, routes []domain.Route) error {
375	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
376		zerowrap.FieldLayer:   "usecase",
377		zerowrap.FieldUseCase: "AutoStart",
378	})
379	log := zerowrap.FromCtx(ctx)
380
381	log.Info().Int("route_count", len(routes)).Msg("auto-starting containers for configured routes")
382
383	var started, skipped, errors int
384	for _, route := range routes {
385		// Check if container already exists and is running
386		if _, exists := s.Get(ctx, route.Domain); exists {
387			log.Debug().Str("domain", route.Domain).Msg("container already running, skipping")
388			skipped++
389			continue
390		}
391
392		log.Info().
393			Str("domain", route.Domain).
394			Str("image", route.Image).
395			Msg("auto-starting container for route")
396
397		if _, err := s.Deploy(ctx, route); err != nil {
398			log.Warn().Err(err).Str("domain", route.Domain).Msg("failed to auto-start container")
399			errors++
400			continue
401		}
402
403		started++
404	}
405
406	log.Info().
407		Int("started", started).
408		Int("skipped", skipped).
409		Int("errors", errors).
410		Msg("auto-start completed")
411
412	if errors > 0 {
413		return fmt.Errorf("auto-start completed with %d errors", errors)
414	}
415	return nil
416}
417
418// Shutdown gracefully shuts down all managed containers.
419func (s *Service) Shutdown(ctx context.Context) error {
420	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
421		zerowrap.FieldLayer:   "usecase",
422		zerowrap.FieldUseCase: "Shutdown",
423	})
424	log := zerowrap.FromCtx(ctx)
425	log.Info().Msg("shutting down container manager...")
426
427	if err := s.stopAllManagedContainers(ctx); err != nil {
428		log.WrapErr(err, "failed to stop all containers during shutdown")
429	}
430
431	// Close log writer to stop all log collection
432	if s.logWriter != nil {
433		if err := s.logWriter.Close(); err != nil {
434			log.Warn().Err(err).Msg("failed to close container log writer")
435		}
436	}
437
438	log.Info().Msg("container manager shutdown complete")
439	return nil
440}
441
442// UpdateConfig updates the service configuration.
443func (s *Service) UpdateConfig(config Config) {
444	s.config = config
445}
446
447// Helper methods
448
449// cleanupFailedContainer stops and removes a container that failed to start properly.
450func (s *Service) cleanupFailedContainer(ctx context.Context, containerID string) {
451	log := zerowrap.FromCtx(ctx)
452	if err := s.runtime.StopContainer(ctx, containerID); err != nil {
453		log.Warn().Err(err).Str(zerowrap.FieldEntityID, containerID).Msg("failed to stop container after failure")
454	}
455	if err := s.runtime.RemoveContainer(ctx, containerID, true); err != nil {
456		log.Warn().Err(err).Str(zerowrap.FieldEntityID, containerID).Msg("failed to remove container after failure")
457	}
458}
459
460// cleanupOldContainer stops and removes an old container after zero-downtime switch.
461// It also renames the new container to the canonical name.
462func (s *Service) cleanupOldContainer(ctx context.Context, old *domain.Container, newContainerID, domainName string) {
463	log := zerowrap.FromCtx(ctx)
464	log.Info().Str(zerowrap.FieldEntityID, old.ID).Msg("stopping old container after zero-downtime switch")
465
466	if s.logWriter != nil {
467		if err := s.logWriter.StopLogging(old.ID); err != nil {
468			log.Warn().Err(err).Str(zerowrap.FieldEntityID, old.ID).Msg("failed to stop logging for old container")
469		}
470	}
471
472	if err := s.runtime.StopContainer(ctx, old.ID); err != nil {
473		log.Warn().Err(err).Str(zerowrap.FieldEntityID, old.ID).Msg("failed to stop old container")
474	}
475
476	if err := s.runtime.RemoveContainer(ctx, old.ID, true); err != nil {
477		log.Warn().Err(err).Str(zerowrap.FieldEntityID, old.ID).Msg("failed to remove old container")
478	}
479
480	// Rename new container to canonical name
481	canonicalName := fmt.Sprintf("gordon-%s", domainName)
482	if err := s.runtime.RenameContainer(ctx, newContainerID, canonicalName); err != nil {
483		log.Warn().Err(err).Str("canonical_name", canonicalName).Msg("failed to rename container to canonical name")
484	}
485}
486
487// startLogCollection starts log collection for a container in the background.
488// Errors are logged but don't fail the calling operation.
489func (s *Service) startLogCollection(ctx context.Context, containerID, domainName string) {
490	if s.logWriter == nil {
491		return
492	}
493
494	log := zerowrap.FromCtx(ctx)
495
496	logStream, err := s.runtime.GetContainerLogs(ctx, containerID, true)
497	if err != nil {
498		log.Warn().Err(err).Msg("failed to get container logs for collection")
499		return
500	}
501
502	if err := s.logWriter.StartLogging(ctx, containerID, domainName, logStream); err != nil {
503		log.Warn().Err(err).Msg("failed to start container log collection")
504		logStream.Close()
505	}
506}
507
508func (s *Service) buildImageRef(image string) string {
509	if !s.config.RegistryAuthEnabled || s.config.RegistryDomain == "" {
510		return image
511	}
512
513	repoPart := strings.Split(image, ":")[0]
514	if strings.HasPrefix(repoPart, s.config.RegistryDomain+"/") {
515		return image
516	}
517	if strings.Contains(repoPart, ".") && strings.Contains(repoPart, "/") {
518		return image
519	}
520	return fmt.Sprintf("%s/%s", s.config.RegistryDomain, image)
521}
522
523func normalizePullPolicy(policy string) string {
524	switch strings.ToLower(strings.TrimSpace(policy)) {
525	case PullPolicyAlways:
526		return PullPolicyAlways
527	case PullPolicyIfTagChanged:
528		return PullPolicyIfTagChanged
529	case PullPolicyIfNotPresent:
530		return PullPolicyIfNotPresent
531	default:
532		return PullPolicyIfNotPresent
533	}
534}
535
536func isDigestRef(imageRef string) bool {
537	return strings.Contains(imageRef, "@sha256:")
538}
539
540func (s *Service) pullRefForDeploy(ctx context.Context, imageRef string) (string, bool) {
541	if !domain.IsInternalDeploy(ctx) {
542		return imageRef, false
543	}
544	return rewriteToLocalRegistry(imageRef, s.config.RegistryDomain, s.config.RegistryPort), true
545}
546
547// ensureImage ensures the image is available locally, pulling if needed.
548// Returns the image reference to use for container operations:
549//   - For digest references (@sha256:...), returns the pullRef since Docker can't tag digests
550//   - For tagged images, returns the original imageRef after tagging the pulled image
551func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, error) {
552	ctx = zerowrap.CtxWithField(ctx, "image", imageRef)
553	log := zerowrap.FromCtx(ctx)
554
555	// Determine if this is an internal deploy and what reference to use for pulls.
556	pullRef, isInternal := s.pullRefForDeploy(ctx, imageRef)
557	if pullRef != imageRef {
558		log.Info().
559			Str("original_ref", imageRef).
560			Str("pull_ref", pullRef).
561			Msg("internal deploy: using localhost registry for pull")
562	}
563
564	found, err := s.ensureLocalImage(ctx, imageRef, pullRef)
565	if err != nil {
566		return "", err
567	}
568	if found {
569		return imageRef, nil
570	}
571
572	// Pull image
573	log.Info().Msg("pulling image from registry")
574
575	if err := s.pullImage(ctx, pullRef, isInternal); err != nil {
576		return "", err
577	}
578
579	// For digest references, we can't create a tag (Docker doesn't allow it).
580	// In this case, use the pullRef directly since the image is available under that reference.
581	if strings.Contains(imageRef, "@sha256:") {
582		log.Info().
583			Str("pull_ref", pullRef).
584			Msg("digest reference: using pull reference for container operations")
585		return pullRef, nil
586	}
587
588	if err := s.tagImageIfNeeded(ctx, pullRef, imageRef); err != nil {
589		return "", err
590	}
591
592	// Clean up the temporary pull reference tag to avoid duplicate entries
593	if pullRef != imageRef {
594		if err := s.runtime.UntagImage(ctx, pullRef); err != nil {
595			// Log but don't fail - the canonical tag is already applied
596			log.Debug().Err(err).Str("pull_ref", pullRef).Msg("failed to remove temporary pull tag")
597		}
598	}
599
600	log.Info().Msg("image pulled successfully")
601	return imageRef, nil
602}
603
604func (s *Service) ensureLocalImage(ctx context.Context, imageRef, pullRef string) (bool, error) {
605	log := zerowrap.FromCtx(ctx)
606
607	// For internal deploys (image push events), always pull fresh image
608	// because the same tag may reference new content (e.g., latest tag updated)
609	if domain.IsInternalDeploy(ctx) {
610		log.Info().Msg("internal deploy detected, forcing image pull to ensure latest content")
611		return false, nil
612	}
613
614	pullPolicy := normalizePullPolicy(s.config.PullPolicy)
615	switch pullPolicy {
616	case PullPolicyAlways:
617		log.Info().Str("pull_policy", pullPolicy).Msg("pull policy forces image pull")
618		return false, nil
619	case PullPolicyIfTagChanged:
620		if !isDigestRef(imageRef) {
621			log.Info().Str("pull_policy", pullPolicy).Msg("tag reference detected, pulling to check for updates")
622			return false, nil
623		}
624	}
625
626	localImages, err := s.runtime.ListImages(ctx)
627	if err != nil {
628		log.WrapErr(err, "failed to list local images, will attempt pull")
629		return false, nil
630	}
631
632	normalizedRef := normalizeImageRef(imageRef)
633	normalizedPullRef := normalizeImageRef(pullRef)
634	for _, img := range localImages {
635		normalizedImage := normalizeImageRef(img)
636		if normalizedImage == normalizedRef {
637			log.Info().Msg("image found locally, skipping pull")
638			return true, nil
639		}
640		if normalizedImage == normalizedPullRef {
641			if err := s.tagImageIfNeeded(ctx, pullRef, imageRef); err != nil {
642				return false, err
643			}
644			// Clean up the temporary pull reference tag
645			if pullRef != imageRef {
646				if err := s.runtime.UntagImage(ctx, pullRef); err != nil {
647					log.Debug().Err(err).Str("pull_ref", pullRef).Msg("failed to remove temporary pull tag")
648				}
649			}
650			log.Info().Msg("image found locally, skipping pull")
651			return true, nil
652		}
653	}
654
655	return false, nil
656}
657
658func (s *Service) pullImage(ctx context.Context, pullRef string, isInternal bool) error {
659	log := zerowrap.FromCtx(ctx)
660
661	switch {
662	case isInternal && s.config.RegistryAuthEnabled:
663		if s.config.InternalRegistryUsername == "" || s.config.InternalRegistryPassword == "" {
664			return log.WrapErr(fmt.Errorf("internal registry auth not configured"), "failed to pull image for internal deploy")
665		}
666		if err := s.runtime.PullImageWithAuth(ctx, pullRef, s.config.InternalRegistryUsername, s.config.InternalRegistryPassword); err != nil {
667			return log.WrapErr(err, "failed to pull image with internal auth")
668		}
669	case isInternal:
670		if err := s.runtime.PullImage(ctx, pullRef); err != nil {
671			return log.WrapErr(err, "failed to pull image")
672		}
673	case s.config.RegistryAuthEnabled:
674		if err := s.runtime.PullImageWithAuth(ctx, pullRef, s.config.RegistryUsername, s.config.RegistryPassword); err != nil {
675			return log.WrapErr(err, "failed to pull image with auth")
676		}
677	default:
678		if err := s.runtime.PullImage(ctx, pullRef); err != nil {
679			return log.WrapErr(err, "failed to pull image")
680		}
681	}
682
683	return nil
684}
685
686func (s *Service) tagImageIfNeeded(ctx context.Context, sourceRef, targetRef string) error {
687	if sourceRef == targetRef {
688		return nil
689	}
690
691	// Cannot create a tag with a digest reference - Docker/Podman doesn't allow it.
692	// When using digest references (image@sha256:...), skip tagging as the image
693	// is already available by its digest.
694	if strings.Contains(targetRef, "@sha256:") {
695		return nil
696	}
697
698	log := zerowrap.FromCtx(ctx)
699	if err := s.runtime.TagImage(ctx, sourceRef, targetRef); err != nil {
700		return log.WrapErr(err, "failed to tag image from pull reference")
701	}
702	return nil
703}
704
705func (s *Service) loadEnvironment(ctx context.Context, domainName, imageRef string) ([]string, error) {
706	log := zerowrap.FromCtx(ctx)
707
708	userEnvVars, err := s.envLoader.LoadEnv(ctx, domainName)
709	if err != nil {
710		return nil, log.WrapErr(err, "failed to load environment variables")
711	}
712
713	dockerfileEnvVars, err := s.runtime.InspectImageEnv(ctx, imageRef)
714	if err != nil {
715		log.WrapErr(err, "failed to inspect image environment")
716		dockerfileEnvVars = []string{}
717	}
718
719	return mergeEnvironmentVariables(dockerfileEnvVars, userEnvVars), nil
720}
721
722func (s *Service) setupVolumes(ctx context.Context, domainName, imageRef string) (map[string]string, error) {
723	log := zerowrap.FromCtx(ctx)
724	volumes := make(map[string]string)
725
726	if !s.config.VolumeAutoCreate {
727		return volumes, nil
728	}
729
730	volumePaths, err := s.runtime.InspectImageVolumes(ctx, imageRef)
731	if err != nil {
732		log.WrapErr(err, "failed to inspect image volumes")
733		return volumes, nil
734	}
735
736	for _, path := range volumePaths {
737		name := generateVolumeName(s.config.VolumePrefix, domainName, path)
738
739		exists, err := s.runtime.VolumeExists(ctx, name)
740		if err != nil {
741			log.WrapErrWithFields(err, "failed to check volume", map[string]any{"volume": name})
742			continue
743		}
744
745		if !exists {
746			if err := s.runtime.CreateVolume(ctx, name); err != nil {
747				log.WrapErrWithFields(err, "failed to create volume", map[string]any{"volume": name})
748				continue
749			}
750			log.Info().Str("volume", name).Str(zerowrap.FieldPath, path).Msg("created volume")
751		}
752
753		volumes[path] = name
754	}
755
756	return volumes, nil
757}
758
759func (s *Service) getNetworkForApp(domainName string) string {
760	if !s.config.NetworkIsolation {
761		return "bridge"
762	}
763
764	for groupName, domains := range s.config.NetworkGroups {
765		if slices.Contains(domains, domainName) {
766			return s.generateNetworkName(groupName)
767		}
768	}
769
770	return s.generateNetworkName(domainName)
771}
772
773func (s *Service) generateNetworkName(identifier string) string {
774	return fmt.Sprintf("%s-%s", s.config.NetworkPrefix, strings.ReplaceAll(identifier, ".", "-"))
775}
776
777func (s *Service) createNetworkIfNeeded(ctx context.Context, networkName string) error {
778	if networkName == "bridge" || networkName == "default" {
779		return nil
780	}
781
782	ctx = zerowrap.CtxWithField(ctx, "network", networkName)
783	log := zerowrap.FromCtx(ctx)
784
785	exists, err := s.runtime.NetworkExists(ctx, networkName)
786	if err != nil {
787		return log.WrapErr(err, "failed to check network existence")
788	}
789
790	if !exists {
791		if err := s.runtime.CreateNetwork(ctx, networkName, map[string]string{"driver": "bridge"}); err != nil {
792			return log.WrapErr(err, "failed to create network")
793		}
794		log.Info().Msg("created network for app isolation")
795	}
796
797	return nil
798}
799
800func (s *Service) cleanupOrphanedContainers(ctx context.Context, domainName string, skipContainerID string) error {
801	log := zerowrap.FromCtx(ctx)
802	expectedName := fmt.Sprintf("gordon-%s", domainName)
803
804	allContainers, err := s.runtime.ListContainers(ctx, true)
805	if err != nil {
806		return err
807	}
808
809	for _, c := range allContainers {
810		if c.Name == expectedName && c.ID != skipContainerID {
811			log.Info().Str(zerowrap.FieldEntityID, c.ID).Str(zerowrap.FieldStatus, c.Status).Msg("found orphaned container, removing")
812
813			if err := s.runtime.StopContainer(ctx, c.ID); err != nil {
814				log.WrapErrWithFields(err, "failed to stop orphaned container", map[string]any{zerowrap.FieldEntityID: c.ID})
815			}
816
817			if err := s.runtime.RemoveContainer(ctx, c.ID, true); err != nil {
818				log.WrapErrWithFields(err, "failed to remove orphaned container", map[string]any{zerowrap.FieldEntityID: c.ID})
819			}
820		}
821	}
822
823	return nil
824}
825
826func (s *Service) cleanupVolumesForDomain(_ context.Context, _ string) error {
827	return nil
828}
829
830func (s *Service) cleanupNetworkIfEmpty(ctx context.Context, networkName string) error {
831	if networkName == "bridge" || networkName == "default" {
832		return nil
833	}
834
835	ctx = zerowrap.CtxWithField(ctx, "network", networkName)
836	log := zerowrap.FromCtx(ctx)
837
838	networks, err := s.runtime.ListNetworks(ctx)
839	if err != nil {
840		return log.WrapErr(err, "failed to list networks")
841	}
842
843	for _, n := range networks {
844		if n.Name == networkName && len(n.Containers) == 0 {
845			if err := s.runtime.RemoveNetwork(ctx, networkName); err != nil {
846				return err
847			}
848			log.Info().Msg("cleaned up empty network")
849			break
850		}
851	}
852
853	return nil
854}
855
856func (s *Service) deployAttachments(ctx context.Context, domainName, networkName string) error {
857	attachments, ok := s.config.Attachments[domainName]
858	if !ok {
859		return nil
860	}
861
862	log := zerowrap.FromCtx(ctx)
863	for _, svc := range attachments {
864		if err := s.deployAttachedService(ctx, domainName, svc, networkName); err != nil {
865			log.WrapErrWithFields(err, "failed to deploy attachment", map[string]any{zerowrap.FieldService: svc, "domain": domainName})
866		}
867	}
868
869	return nil
870}
871
872func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, serviceImage, networkName string) error {
873	log := zerowrap.FromCtx(ctx)
874
875	// Parse service name from image (e.g., "my-postgres:latest" → "postgres")
876	serviceName := extractServiceName(serviceImage)
877	containerName := fmt.Sprintf("gordon-%s-%s", sanitizeName(ownerDomain), serviceName)
878
879	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
880		"attachment":     serviceName,
881		"container_name": containerName,
882		"owner_domain":   ownerDomain,
883	})
884
885	// Check if already running (idempotent)
886	existingContainer := s.findContainerByName(ctx, containerName)
887	if existingContainer != nil && existingContainer.Status == string(domain.ContainerStatusRunning) {
888		log.Debug().Str("container_name", containerName).Msg("attachment already running, skipping")
889		return nil
890	}
891
892	// Remove existing stopped container if present
893	if existingContainer != nil {
894		log.Info().Str("container_name", containerName).Msg("removing stopped attachment container")
895		if err := s.runtime.RemoveContainer(ctx, existingContainer.ID, true); err != nil {
896			log.WrapErr(err, "failed to remove existing attachment container")
897		}
898	}
899
900	log.Info().Str(zerowrap.FieldService, serviceImage).Msg("deploying attached service")
901
902	// Ensure image (canonical ref is returned; internal pulls may use the local registry).
903	imageRef := s.buildImageRef(serviceImage)
904	actualImageRef, err := s.ensureImage(ctx, imageRef)
905	if err != nil {
906		return err
907	}
908
909	// Get image metadata
910	exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, actualImageRef)
911	if err != nil {
912		log.WrapErr(err, "failed to get exposed ports for attachment, using defaults")
913		exposedPorts = []int{}
914	}
915
916	// Setup volumes (attachments need persistent data)
917	volumes, err := s.setupVolumes(ctx, containerName, actualImageRef)
918	if err != nil {
919		log.WrapErr(err, "failed to setup volumes for attachment")
920		volumes = make(map[string]string)
921	}
922
923	// Load environment (attachment-specific env file)
924	envVars, err := s.loadEnvironment(ctx, containerName, actualImageRef)
925	if err != nil {
926		log.WrapErr(err, "failed to load environment for attachment")
927		envVars = []string{}
928	}
929
930	// Create container with attachment labels (use actual ref, track original in labels)
931	config := &domain.ContainerConfig{
932		Image:       actualImageRef,
933		Name:        containerName,
934		Hostname:    serviceName, // Internal DNS: postgres, redis, etc.
935		Ports:       exposedPorts,
936		Env:         envVars,
937		Volumes:     volumes,
938		NetworkMode: networkName, // Same network as main app
939		Labels: map[string]string{
940			"gordon.managed":     "true",
941			"gordon.attachment":  "true",
942			"gordon.attached-to": ownerDomain,
943			"gordon.image":       serviceImage,
944		},
945	}
946
947	container, err := s.runtime.CreateContainer(ctx, config)
948	if err != nil {
949		return log.WrapErr(err, "failed to create attachment container")
950	}
951
952	// Start container
953	if err := s.runtime.StartContainer(ctx, container.ID); err != nil {
954		s.runtime.RemoveContainer(ctx, container.ID, true)
955		return log.WrapErr(err, "failed to start attachment container")
956	}
957
958	// Track attachment
959	s.mu.Lock()
960	s.attachments[ownerDomain] = append(s.attachments[ownerDomain], container.ID)
961	s.mu.Unlock()
962
963	// Start log collection for attachment
964	s.startLogCollection(ctx, container.ID, containerName)
965
966	log.Info().Str(zerowrap.FieldEntityID, container.ID).Msg("attachment deployed successfully")
967	return nil
968}
969
970func (s *Service) stopAllManagedContainers(ctx context.Context) error {
971	log := zerowrap.FromCtx(ctx)
972
973	s.mu.RLock()
974	containers := make(map[string]*domain.Container, len(s.containers))
975	maps.Copy(containers, s.containers)
976	s.mu.RUnlock()
977
978	if len(containers) == 0 {
979		log.Info().Msg("no managed containers to stop")
980		return nil
981	}
982
983	log.Info().Int(zerowrap.FieldCount, len(containers)).Msg("stopping all managed containers...")
984
985	errorCount := 0
986	for d, c := range containers {
987		log.Info().Str("domain", d).Str(zerowrap.FieldEntityID, c.ID).Msg("stopping managed container")
988
989		if err := s.runtime.StopContainer(ctx, c.ID); err != nil {
990			log.WrapErrWithFields(err, "failed to stop container", map[string]any{"domain": d, zerowrap.FieldEntityID: c.ID})
991			errorCount++
992			continue
993		}
994
995		s.mu.Lock()
996		delete(s.containers, d)
997		s.mu.Unlock()
998
999		log.Info().Str("domain", d).Str(zerowrap.FieldEntityID, c.ID).Msg("container stopped successfully")
1000	}
1001
1002	if errorCount > 0 {
1003		return fmt.Errorf("failed to stop %d containers", errorCount)
1004	}
1005
1006	log.Info().Msg("all managed containers stopped successfully")
1007	return nil
1008}
1009
1010// Utility functions
1011
1012func normalizeImageRef(image string) string {
1013	// Extract repository from image reference, handling port numbers correctly.
1014	// Examples:
1015	//   nginx:latest -> docker.io/library/nginx
1016	//   user/repo:tag -> docker.io/user/repo
1017	//   localhost:5000/image:tag -> localhost:5000/image
1018	//   registry.example.com/image:tag -> registry.example.com/image
1019	//
1020	// Note: This function assumes valid Docker image references. Edge cases like
1021	// "registry.com:5000" (registry with port but no image name) are not valid
1022	// image references per Docker's naming conventions, which require at least
1023	// one path component after the registry (e.g., "registry.com:5000/image").
1024
1025	// Find the tag separator: the last colon that isn't part of a port number.
1026	// A colon is part of a port if there's no slash after it until the next colon.
1027	repo := image
1028	lastColon := strings.LastIndex(image, ":")
1029	if lastColon != -1 {
1030		afterColon := image[lastColon+1:]
1031		// If there's no slash after the colon, it's the tag separator
1032		if !strings.Contains(afterColon, "/") {
1033			repo = image[:lastColon]
1034		}
1035	}
1036
1037	if !strings.Contains(repo, "/") {
1038		return "docker.io/library/" + repo
1039	}
1040
1041	if strings.Count(repo, "/") == 1 && !strings.Contains(strings.Split(repo, "/")[0], ".") && !strings.Contains(strings.Split(repo, "/")[0], ":") {
1042		return "docker.io/" + repo
1043	}
1044
1045	return repo
1046}
1047
1048func generateVolumeName(prefix, domainName, volumePath string) string {
1049	return fmt.Sprintf("%s-%s-%s",
1050		prefix,
1051		strings.ReplaceAll(domainName, ".", "-"),
1052		strings.ReplaceAll(strings.Trim(volumePath, "/"), "/", "-"))
1053}
1054
1055func mergeEnvironmentVariables(dockerfileEnv, userEnv []string) []string {
1056	envMap := make(map[string]string)
1057
1058	for _, env := range dockerfileEnv {
1059		if k, v, ok := strings.Cut(env, "="); ok {
1060			envMap[k] = v
1061		}
1062	}
1063
1064	for _, env := range userEnv {
1065		if k, v, ok := strings.Cut(env, "="); ok {
1066			envMap[k] = v
1067		}
1068	}
1069
1070	result := make([]string, 0, len(envMap))
1071	for k, v := range envMap {
1072		result = append(result, k+"="+v)
1073	}
1074
1075	return result
1076}
1077
1078// extractServiceName gets service name from image reference.
1079// "my-postgres:latest" → "postgres", "redis:7" → "redis"
1080func extractServiceName(image string) string {
1081	// Remove tag
1082	parts := strings.Split(image, ":")
1083	name := parts[0]
1084
1085	// Remove registry prefix if present
1086	if strings.Contains(name, "/") {
1087		nameParts := strings.Split(name, "/")
1088		name = nameParts[len(nameParts)-1]
1089	}
1090
1091	// Remove common prefixes like "my-"
1092	name = strings.TrimPrefix(name, "my-")
1093
1094	return name
1095}
1096
1097// sanitizeName makes a domain safe for container naming.
1098func sanitizeName(domain string) string {
1099	// Replace dots and other special chars with dashes
1100	result := strings.ReplaceAll(domain, ".", "-")
1101	result = strings.ReplaceAll(result, ":", "-")
1102	result = strings.ReplaceAll(result, "/", "-")
1103	return result
1104}
1105
1106// findContainerByName finds a container by its name.
1107func (s *Service) findContainerByName(ctx context.Context, name string) *domain.Container {
1108	containers, err := s.runtime.ListContainers(ctx, true)
1109	if err != nil {
1110		return nil
1111	}
1112
1113	for _, c := range containers {
1114		if c.Name == name {
1115			return c
1116		}
1117	}
1118	return nil
1119}
1120
1121// waitForReady waits for a container to be ready.
1122// Uses simple "running + delay" approach for universal compatibility.
1123func (s *Service) waitForReady(ctx context.Context, containerID string) error {
1124	log := zerowrap.FromCtx(ctx)
1125
1126	// Poll for container to be running (max 30 seconds)
1127	for i := 0; i < 30; i++ {
1128		running, err := s.runtime.IsContainerRunning(ctx, containerID)
1129		if err != nil {
1130			return err
1131		}
1132		if running {
1133			break
1134		}
1135		if i == 29 {
1136			return fmt.Errorf("container did not start within 30 seconds")
1137		}
1138		time.Sleep(time.Second)
1139	}
1140
1141	// Additional readiness delay (configurable, default 5 seconds)
1142	delay := s.config.ReadinessDelay
1143	if delay == 0 {
1144		delay = 5 * time.Second
1145	}
1146
1147	log.Debug().Dur("delay", delay).Msg("waiting for container readiness")
1148	time.Sleep(delay)
1149
1150	// Verify still running after delay
1151	running, err := s.runtime.IsContainerRunning(ctx, containerID)
1152	if err != nil {
1153		return err
1154	}
1155	if !running {
1156		return fmt.Errorf("container stopped during readiness delay")
1157	}
1158
1159	return nil
1160}
1161
1162// publishContainerDeployed publishes a container.deployed event.
1163func (s *Service) publishContainerDeployed(ctx context.Context, domainName, containerID string) {
1164	payload := &domain.ContainerEventPayload{
1165		ContainerID: containerID,
1166		Domain:      domainName,
1167		Action:      "deployed",
1168	}
1169
1170	if err := s.eventBus.Publish(domain.EventContainerDeployed, payload); err != nil {
1171		log := zerowrap.FromCtx(ctx)
1172		log.Warn().Err(err).Msg("failed to publish container deployed event")
1173	}
1174}
1175
1176// rewriteToRegistryDomain rewrites an image reference to use the configured registry domain.
1177// e.g., "myapp:latest" -> "registry.example.com/myapp:latest"
1178func rewriteToRegistryDomain(imageRef, registryDomain string) string {
1179	if registryDomain == "" {
1180		return imageRef
1181	}
1182
1183	prefix := registryDomain + "/"
1184	if strings.HasPrefix(imageRef, prefix) {
1185		return imageRef
1186	}
1187
1188	return prefix + imageRef
1189}
1190
1191// rewriteToLocalRegistry rewrites an image reference to use the local registry address.
1192// e.g., "registry.example.com/myapp:latest" -> "localhost:5000/myapp:latest"
1193func rewriteToLocalRegistry(imageRef, registryDomain string, registryPort int) string {
1194	if imageRef == "" {
1195		return imageRef
1196	}
1197
1198	localRegistry := fmt.Sprintf("localhost:%d", registryPort)
1199	localPrefix := localRegistry + "/"
1200	imageRef = strings.TrimPrefix(imageRef, localPrefix)
1201
1202	registryDomain = strings.TrimSuffix(registryDomain, "/")
1203	if registryDomain != "" {
1204		prefix := registryDomain + "/"
1205		imageRef = strings.TrimPrefix(imageRef, prefix)
1206	}
1207
1208	return localPrefix + imageRef
1209}

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