v2.2.7 aefe19e

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

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