v2.2.2 dbdcf7e

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

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