v2.1.0 0fb867d

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 configured routes.
362func (s *Service) AutoStart(_ context.Context) error {
363	return nil
364}
365
366// Shutdown gracefully shuts down all managed containers.
367func (s *Service) Shutdown(ctx context.Context) error {
368	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
369		zerowrap.FieldLayer:   "usecase",
370		zerowrap.FieldUseCase: "Shutdown",
371	})
372	log := zerowrap.FromCtx(ctx)
373	log.Info().Msg("shutting down container manager...")
374
375	if err := s.stopAllManagedContainers(ctx); err != nil {
376		log.WrapErr(err, "failed to stop all containers during shutdown")
377	}
378
379	// Close log writer to stop all log collection
380	if s.logWriter != nil {
381		if err := s.logWriter.Close(); err != nil {
382			log.Warn().Err(err).Msg("failed to close container log writer")
383		}
384	}
385
386	log.Info().Msg("container manager shutdown complete")
387	return nil
388}
389
390// UpdateConfig updates the service configuration.
391func (s *Service) UpdateConfig(config Config) {
392	s.config = config
393}
394
395// Helper methods
396
397// cleanupFailedContainer stops and removes a container that failed to start properly.
398func (s *Service) cleanupFailedContainer(ctx context.Context, containerID string) {
399	log := zerowrap.FromCtx(ctx)
400	if err := s.runtime.StopContainer(ctx, containerID); err != nil {
401		log.Warn().Err(err).Str(zerowrap.FieldEntityID, containerID).Msg("failed to stop container after failure")
402	}
403	if err := s.runtime.RemoveContainer(ctx, containerID, true); err != nil {
404		log.Warn().Err(err).Str(zerowrap.FieldEntityID, containerID).Msg("failed to remove container after failure")
405	}
406}
407
408// cleanupOldContainer stops and removes an old container after zero-downtime switch.
409// It also renames the new container to the canonical name.
410func (s *Service) cleanupOldContainer(ctx context.Context, old *domain.Container, newContainerID, domainName string) {
411	log := zerowrap.FromCtx(ctx)
412	log.Info().Str(zerowrap.FieldEntityID, old.ID).Msg("stopping old container after zero-downtime switch")
413
414	if s.logWriter != nil {
415		if err := s.logWriter.StopLogging(old.ID); err != nil {
416			log.Warn().Err(err).Str(zerowrap.FieldEntityID, old.ID).Msg("failed to stop logging for old container")
417		}
418	}
419
420	if err := s.runtime.StopContainer(ctx, old.ID); err != nil {
421		log.Warn().Err(err).Str(zerowrap.FieldEntityID, old.ID).Msg("failed to stop old container")
422	}
423
424	if err := s.runtime.RemoveContainer(ctx, old.ID, true); err != nil {
425		log.Warn().Err(err).Str(zerowrap.FieldEntityID, old.ID).Msg("failed to remove old container")
426	}
427
428	// Rename new container to canonical name
429	canonicalName := fmt.Sprintf("gordon-%s", domainName)
430	if err := s.runtime.RenameContainer(ctx, newContainerID, canonicalName); err != nil {
431		log.Warn().Err(err).Str("canonical_name", canonicalName).Msg("failed to rename container to canonical name")
432	}
433}
434
435// startLogCollection starts log collection for a container in the background.
436// Errors are logged but don't fail the calling operation.
437func (s *Service) startLogCollection(ctx context.Context, containerID, domainName string) {
438	if s.logWriter == nil {
439		return
440	}
441
442	log := zerowrap.FromCtx(ctx)
443
444	logStream, err := s.runtime.GetContainerLogs(ctx, containerID, true)
445	if err != nil {
446		log.Warn().Err(err).Msg("failed to get container logs for collection")
447		return
448	}
449
450	if err := s.logWriter.StartLogging(ctx, containerID, domainName, logStream); err != nil {
451		log.Warn().Err(err).Msg("failed to start container log collection")
452		logStream.Close()
453	}
454}
455
456func (s *Service) buildImageRef(image string) string {
457	if !s.config.RegistryAuthEnabled || s.config.RegistryDomain == "" {
458		return image
459	}
460
461	repoPart := strings.Split(image, ":")[0]
462	if strings.HasPrefix(repoPart, s.config.RegistryDomain+"/") {
463		return image
464	}
465	if strings.Contains(repoPart, ".") && strings.Contains(repoPart, "/") {
466		return image
467	}
468	return fmt.Sprintf("%s/%s", s.config.RegistryDomain, image)
469}
470
471func (s *Service) pullRefForDeploy(ctx context.Context, imageRef string) (string, bool) {
472	if !domain.IsInternalDeploy(ctx) {
473		return imageRef, false
474	}
475	return rewriteToLocalRegistry(imageRef, s.config.RegistryDomain, s.config.RegistryPort), true
476}
477
478// ensureImage ensures the image is available locally, pulling if needed.
479// Returns the canonical image reference to use for container operations.
480func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, error) {
481	ctx = zerowrap.CtxWithField(ctx, "image", imageRef)
482	log := zerowrap.FromCtx(ctx)
483
484	// Determine if this is an internal deploy and what reference to use for pulls.
485	pullRef, isInternal := s.pullRefForDeploy(ctx, imageRef)
486	if pullRef != imageRef {
487		log.Info().
488			Str("original_ref", imageRef).
489			Str("pull_ref", pullRef).
490			Msg("internal deploy: using localhost registry for pull")
491	}
492
493	found, err := s.ensureLocalImage(ctx, imageRef, pullRef)
494	if err != nil {
495		return "", err
496	}
497	if found {
498		return imageRef, nil
499	}
500
501	// Pull image
502	log.Info().Msg("pulling image from registry")
503
504	if err := s.pullImage(ctx, pullRef, isInternal); err != nil {
505		return "", err
506	}
507
508	if err := s.tagImageIfNeeded(ctx, pullRef, imageRef); err != nil {
509		return "", err
510	}
511
512	// Clean up the temporary pull reference tag to avoid duplicate entries
513	if pullRef != imageRef {
514		if err := s.runtime.UntagImage(ctx, pullRef); err != nil {
515			// Log but don't fail - the canonical tag is already applied
516			log.Debug().Err(err).Str("pull_ref", pullRef).Msg("failed to remove temporary pull tag")
517		}
518	}
519
520	log.Info().Msg("image pulled successfully")
521	return imageRef, nil
522}
523
524func (s *Service) ensureLocalImage(ctx context.Context, imageRef, pullRef string) (bool, error) {
525	log := zerowrap.FromCtx(ctx)
526
527	localImages, err := s.runtime.ListImages(ctx)
528	if err != nil {
529		log.WrapErr(err, "failed to list local images, will attempt pull")
530		return false, nil
531	}
532
533	normalizedRef := normalizeImageRef(imageRef)
534	normalizedPullRef := normalizeImageRef(pullRef)
535	for _, img := range localImages {
536		normalizedImage := normalizeImageRef(img)
537		if normalizedImage == normalizedRef {
538			log.Info().Msg("image found locally, skipping pull")
539			return true, nil
540		}
541		if normalizedImage == normalizedPullRef {
542			if err := s.tagImageIfNeeded(ctx, pullRef, imageRef); err != nil {
543				return false, err
544			}
545			// Clean up the temporary pull reference tag
546			if pullRef != imageRef {
547				if err := s.runtime.UntagImage(ctx, pullRef); err != nil {
548					log.Debug().Err(err).Str("pull_ref", pullRef).Msg("failed to remove temporary pull tag")
549				}
550			}
551			log.Info().Msg("image found locally, skipping pull")
552			return true, nil
553		}
554	}
555
556	return false, nil
557}
558
559func (s *Service) pullImage(ctx context.Context, pullRef string, isInternal bool) error {
560	log := zerowrap.FromCtx(ctx)
561
562	switch {
563	case isInternal && s.config.RegistryAuthEnabled:
564		if s.config.InternalRegistryUsername == "" || s.config.InternalRegistryPassword == "" {
565			return log.WrapErr(fmt.Errorf("internal registry auth not configured"), "failed to pull image for internal deploy")
566		}
567		if err := s.runtime.PullImageWithAuth(ctx, pullRef, s.config.InternalRegistryUsername, s.config.InternalRegistryPassword); err != nil {
568			return log.WrapErr(err, "failed to pull image with internal auth")
569		}
570	case isInternal:
571		if err := s.runtime.PullImage(ctx, pullRef); err != nil {
572			return log.WrapErr(err, "failed to pull image")
573		}
574	case s.config.RegistryAuthEnabled:
575		if err := s.runtime.PullImageWithAuth(ctx, pullRef, s.config.RegistryUsername, s.config.RegistryPassword); err != nil {
576			return log.WrapErr(err, "failed to pull image with auth")
577		}
578	default:
579		if err := s.runtime.PullImage(ctx, pullRef); err != nil {
580			return log.WrapErr(err, "failed to pull image")
581		}
582	}
583
584	return nil
585}
586
587func (s *Service) tagImageIfNeeded(ctx context.Context, sourceRef, targetRef string) error {
588	if sourceRef == targetRef {
589		return nil
590	}
591
592	log := zerowrap.FromCtx(ctx)
593	if err := s.runtime.TagImage(ctx, sourceRef, targetRef); err != nil {
594		return log.WrapErr(err, "failed to tag image from pull reference")
595	}
596	return nil
597}
598
599func (s *Service) loadEnvironment(ctx context.Context, domainName, imageRef string) ([]string, error) {
600	log := zerowrap.FromCtx(ctx)
601
602	userEnvVars, err := s.envLoader.LoadEnv(ctx, domainName)
603	if err != nil {
604		return nil, log.WrapErr(err, "failed to load environment variables")
605	}
606
607	dockerfileEnvVars, err := s.runtime.InspectImageEnv(ctx, imageRef)
608	if err != nil {
609		log.WrapErr(err, "failed to inspect image environment")
610		dockerfileEnvVars = []string{}
611	}
612
613	return mergeEnvironmentVariables(dockerfileEnvVars, userEnvVars), nil
614}
615
616func (s *Service) setupVolumes(ctx context.Context, domainName, imageRef string) (map[string]string, error) {
617	log := zerowrap.FromCtx(ctx)
618	volumes := make(map[string]string)
619
620	if !s.config.VolumeAutoCreate {
621		return volumes, nil
622	}
623
624	volumePaths, err := s.runtime.InspectImageVolumes(ctx, imageRef)
625	if err != nil {
626		log.WrapErr(err, "failed to inspect image volumes")
627		return volumes, nil
628	}
629
630	for _, path := range volumePaths {
631		name := generateVolumeName(s.config.VolumePrefix, domainName, path)
632
633		exists, err := s.runtime.VolumeExists(ctx, name)
634		if err != nil {
635			log.WrapErrWithFields(err, "failed to check volume", map[string]any{"volume": name})
636			continue
637		}
638
639		if !exists {
640			if err := s.runtime.CreateVolume(ctx, name); err != nil {
641				log.WrapErrWithFields(err, "failed to create volume", map[string]any{"volume": name})
642				continue
643			}
644			log.Info().Str("volume", name).Str(zerowrap.FieldPath, path).Msg("created volume")
645		}
646
647		volumes[path] = name
648	}
649
650	return volumes, nil
651}
652
653func (s *Service) getNetworkForApp(domainName string) string {
654	if !s.config.NetworkIsolation {
655		return "bridge"
656	}
657
658	for groupName, domains := range s.config.NetworkGroups {
659		if slices.Contains(domains, domainName) {
660			return s.generateNetworkName(groupName)
661		}
662	}
663
664	return s.generateNetworkName(domainName)
665}
666
667func (s *Service) generateNetworkName(identifier string) string {
668	return fmt.Sprintf("%s-%s", s.config.NetworkPrefix, strings.ReplaceAll(identifier, ".", "-"))
669}
670
671func (s *Service) createNetworkIfNeeded(ctx context.Context, networkName string) error {
672	if networkName == "bridge" || networkName == "default" {
673		return nil
674	}
675
676	ctx = zerowrap.CtxWithField(ctx, "network", networkName)
677	log := zerowrap.FromCtx(ctx)
678
679	exists, err := s.runtime.NetworkExists(ctx, networkName)
680	if err != nil {
681		return log.WrapErr(err, "failed to check network existence")
682	}
683
684	if !exists {
685		if err := s.runtime.CreateNetwork(ctx, networkName, map[string]string{"driver": "bridge"}); err != nil {
686			return log.WrapErr(err, "failed to create network")
687		}
688		log.Info().Msg("created network for app isolation")
689	}
690
691	return nil
692}
693
694func (s *Service) cleanupOrphanedContainers(ctx context.Context, domainName string) error {
695	log := zerowrap.FromCtx(ctx)
696	expectedName := fmt.Sprintf("gordon-%s", domainName)
697
698	allContainers, err := s.runtime.ListContainers(ctx, true)
699	if err != nil {
700		return err
701	}
702
703	for _, c := range allContainers {
704		if c.Name == expectedName {
705			log.Info().Str(zerowrap.FieldEntityID, c.ID).Str(zerowrap.FieldStatus, c.Status).Msg("found orphaned container, removing")
706
707			if err := s.runtime.StopContainer(ctx, c.ID); err != nil {
708				log.WrapErrWithFields(err, "failed to stop orphaned container", map[string]any{zerowrap.FieldEntityID: c.ID})
709			}
710
711			if err := s.runtime.RemoveContainer(ctx, c.ID, true); err != nil {
712				log.WrapErrWithFields(err, "failed to remove orphaned container", map[string]any{zerowrap.FieldEntityID: c.ID})
713			}
714		}
715	}
716
717	return nil
718}
719
720func (s *Service) cleanupVolumesForDomain(_ context.Context, _ string) error {
721	return nil
722}
723
724func (s *Service) cleanupNetworkIfEmpty(ctx context.Context, networkName string) error {
725	if networkName == "bridge" || networkName == "default" {
726		return nil
727	}
728
729	ctx = zerowrap.CtxWithField(ctx, "network", networkName)
730	log := zerowrap.FromCtx(ctx)
731
732	networks, err := s.runtime.ListNetworks(ctx)
733	if err != nil {
734		return log.WrapErr(err, "failed to list networks")
735	}
736
737	for _, n := range networks {
738		if n.Name == networkName && len(n.Containers) == 0 {
739			if err := s.runtime.RemoveNetwork(ctx, networkName); err != nil {
740				return err
741			}
742			log.Info().Msg("cleaned up empty network")
743			break
744		}
745	}
746
747	return nil
748}
749
750func (s *Service) deployAttachments(ctx context.Context, domainName, networkName string) error {
751	attachments, ok := s.config.Attachments[domainName]
752	if !ok {
753		return nil
754	}
755
756	log := zerowrap.FromCtx(ctx)
757	for _, svc := range attachments {
758		if err := s.deployAttachedService(ctx, domainName, svc, networkName); err != nil {
759			log.WrapErrWithFields(err, "failed to deploy attachment", map[string]any{zerowrap.FieldService: svc, "domain": domainName})
760		}
761	}
762
763	return nil
764}
765
766func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, serviceImage, networkName string) error {
767	log := zerowrap.FromCtx(ctx)
768
769	// Parse service name from image (e.g., "my-postgres:latest" → "postgres")
770	serviceName := extractServiceName(serviceImage)
771	containerName := fmt.Sprintf("gordon-%s-%s", sanitizeName(ownerDomain), serviceName)
772
773	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
774		"attachment":     serviceName,
775		"container_name": containerName,
776		"owner_domain":   ownerDomain,
777	})
778
779	// Check if already running (idempotent)
780	existingContainer := s.findContainerByName(ctx, containerName)
781	if existingContainer != nil && existingContainer.Status == string(domain.ContainerStatusRunning) {
782		log.Debug().Str("container_name", containerName).Msg("attachment already running, skipping")
783		return nil
784	}
785
786	// Remove existing stopped container if present
787	if existingContainer != nil {
788		log.Info().Str("container_name", containerName).Msg("removing stopped attachment container")
789		if err := s.runtime.RemoveContainer(ctx, existingContainer.ID, true); err != nil {
790			log.WrapErr(err, "failed to remove existing attachment container")
791		}
792	}
793
794	log.Info().Str(zerowrap.FieldService, serviceImage).Msg("deploying attached service")
795
796	// Ensure image (canonical ref is returned; internal pulls may use the local registry).
797	imageRef := s.buildImageRef(serviceImage)
798	actualImageRef, err := s.ensureImage(ctx, imageRef)
799	if err != nil {
800		return err
801	}
802
803	// Get image metadata
804	exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, actualImageRef)
805	if err != nil {
806		log.WrapErr(err, "failed to get exposed ports for attachment, using defaults")
807		exposedPorts = []int{}
808	}
809
810	// Setup volumes (attachments need persistent data)
811	volumes, err := s.setupVolumes(ctx, containerName, actualImageRef)
812	if err != nil {
813		log.WrapErr(err, "failed to setup volumes for attachment")
814		volumes = make(map[string]string)
815	}
816
817	// Load environment (attachment-specific env file)
818	envVars, err := s.loadEnvironment(ctx, containerName, actualImageRef)
819	if err != nil {
820		log.WrapErr(err, "failed to load environment for attachment")
821		envVars = []string{}
822	}
823
824	// Create container with attachment labels (use actual ref, track original in labels)
825	config := &domain.ContainerConfig{
826		Image:       actualImageRef,
827		Name:        containerName,
828		Hostname:    serviceName, // Internal DNS: postgres, redis, etc.
829		Ports:       exposedPorts,
830		Env:         envVars,
831		Volumes:     volumes,
832		NetworkMode: networkName, // Same network as main app
833		Labels: map[string]string{
834			"gordon.managed":     "true",
835			"gordon.attachment":  "true",
836			"gordon.attached-to": ownerDomain,
837			"gordon.image":       serviceImage,
838		},
839	}
840
841	container, err := s.runtime.CreateContainer(ctx, config)
842	if err != nil {
843		return log.WrapErr(err, "failed to create attachment container")
844	}
845
846	// Start container
847	if err := s.runtime.StartContainer(ctx, container.ID); err != nil {
848		s.runtime.RemoveContainer(ctx, container.ID, true)
849		return log.WrapErr(err, "failed to start attachment container")
850	}
851
852	// Track attachment
853	s.mu.Lock()
854	s.attachments[ownerDomain] = append(s.attachments[ownerDomain], container.ID)
855	s.mu.Unlock()
856
857	// Start log collection for attachment
858	s.startLogCollection(ctx, container.ID, containerName)
859
860	log.Info().Str(zerowrap.FieldEntityID, container.ID).Msg("attachment deployed successfully")
861	return nil
862}
863
864func (s *Service) stopAllManagedContainers(ctx context.Context) error {
865	log := zerowrap.FromCtx(ctx)
866
867	s.mu.RLock()
868	containers := make(map[string]*domain.Container, len(s.containers))
869	maps.Copy(containers, s.containers)
870	s.mu.RUnlock()
871
872	if len(containers) == 0 {
873		log.Info().Msg("no managed containers to stop")
874		return nil
875	}
876
877	log.Info().Int(zerowrap.FieldCount, len(containers)).Msg("stopping all managed containers...")
878
879	errorCount := 0
880	for d, c := range containers {
881		log.Info().Str("domain", d).Str(zerowrap.FieldEntityID, c.ID).Msg("stopping managed container")
882
883		if err := s.runtime.StopContainer(ctx, c.ID); err != nil {
884			log.WrapErrWithFields(err, "failed to stop container", map[string]any{"domain": d, zerowrap.FieldEntityID: c.ID})
885			errorCount++
886			continue
887		}
888
889		s.mu.Lock()
890		delete(s.containers, d)
891		s.mu.Unlock()
892
893		log.Info().Str("domain", d).Str(zerowrap.FieldEntityID, c.ID).Msg("container stopped successfully")
894	}
895
896	if errorCount > 0 {
897		return fmt.Errorf("failed to stop %d containers", errorCount)
898	}
899
900	log.Info().Msg("all managed containers stopped successfully")
901	return nil
902}
903
904// Utility functions
905
906func normalizeImageRef(image string) string {
907	// Extract repository from image reference, handling port numbers correctly.
908	// Examples:
909	//   nginx:latest -> docker.io/library/nginx
910	//   user/repo:tag -> docker.io/user/repo
911	//   localhost:5000/image:tag -> localhost:5000/image
912	//   registry.example.com/image:tag -> registry.example.com/image
913
914	// Find the tag separator: the last colon that isn't part of a port number.
915	// A colon is part of a port if there's no slash after it until the next colon.
916	repo := image
917	lastColon := strings.LastIndex(image, ":")
918	if lastColon != -1 {
919		afterColon := image[lastColon+1:]
920		// If there's no slash after the colon, it's the tag separator
921		if !strings.Contains(afterColon, "/") {
922			repo = image[:lastColon]
923		}
924	}
925
926	if !strings.Contains(repo, "/") {
927		return "docker.io/library/" + repo
928	}
929
930	if strings.Count(repo, "/") == 1 && !strings.Contains(strings.Split(repo, "/")[0], ".") && !strings.Contains(strings.Split(repo, "/")[0], ":") {
931		return "docker.io/" + repo
932	}
933
934	return repo
935}
936
937func generateVolumeName(prefix, domainName, volumePath string) string {
938	return fmt.Sprintf("%s-%s-%s",
939		prefix,
940		strings.ReplaceAll(domainName, ".", "-"),
941		strings.ReplaceAll(strings.Trim(volumePath, "/"), "/", "-"))
942}
943
944func mergeEnvironmentVariables(dockerfileEnv, userEnv []string) []string {
945	envMap := make(map[string]string)
946
947	for _, env := range dockerfileEnv {
948		if k, v, ok := strings.Cut(env, "="); ok {
949			envMap[k] = v
950		}
951	}
952
953	for _, env := range userEnv {
954		if k, v, ok := strings.Cut(env, "="); ok {
955			envMap[k] = v
956		}
957	}
958
959	result := make([]string, 0, len(envMap))
960	for k, v := range envMap {
961		result = append(result, k+"="+v)
962	}
963
964	return result
965}
966
967// extractServiceName gets service name from image reference.
968// "my-postgres:latest" → "postgres", "redis:7" → "redis"
969func extractServiceName(image string) string {
970	// Remove tag
971	parts := strings.Split(image, ":")
972	name := parts[0]
973
974	// Remove registry prefix if present
975	if strings.Contains(name, "/") {
976		nameParts := strings.Split(name, "/")
977		name = nameParts[len(nameParts)-1]
978	}
979
980	// Remove common prefixes like "my-"
981	name = strings.TrimPrefix(name, "my-")
982
983	return name
984}
985
986// sanitizeName makes a domain safe for container naming.
987func sanitizeName(domain string) string {
988	// Replace dots and other special chars with dashes
989	result := strings.ReplaceAll(domain, ".", "-")
990	result = strings.ReplaceAll(result, ":", "-")
991	result = strings.ReplaceAll(result, "/", "-")
992	return result
993}
994
995// findContainerByName finds a container by its name.
996func (s *Service) findContainerByName(ctx context.Context, name string) *domain.Container {
997	containers, err := s.runtime.ListContainers(ctx, true)
998	if err != nil {
999		return nil
1000	}
1001
1002	for _, c := range containers {
1003		if c.Name == name {
1004			return c
1005		}
1006	}
1007	return nil
1008}
1009
1010// waitForReady waits for a container to be ready.
1011// Uses simple "running + delay" approach for universal compatibility.
1012func (s *Service) waitForReady(ctx context.Context, containerID string) error {
1013	log := zerowrap.FromCtx(ctx)
1014
1015	// Poll for container to be running (max 30 seconds)
1016	for i := 0; i < 30; i++ {
1017		running, err := s.runtime.IsContainerRunning(ctx, containerID)
1018		if err != nil {
1019			return err
1020		}
1021		if running {
1022			break
1023		}
1024		if i == 29 {
1025			return fmt.Errorf("container did not start within 30 seconds")
1026		}
1027		time.Sleep(time.Second)
1028	}
1029
1030	// Additional readiness delay (configurable, default 5 seconds)
1031	delay := s.config.ReadinessDelay
1032	if delay == 0 {
1033		delay = 5 * time.Second
1034	}
1035
1036	log.Debug().Dur("delay", delay).Msg("waiting for container readiness")
1037	time.Sleep(delay)
1038
1039	// Verify still running after delay
1040	running, err := s.runtime.IsContainerRunning(ctx, containerID)
1041	if err != nil {
1042		return err
1043	}
1044	if !running {
1045		return fmt.Errorf("container stopped during readiness delay")
1046	}
1047
1048	return nil
1049}
1050
1051// publishContainerDeployed publishes a container.deployed event.
1052func (s *Service) publishContainerDeployed(ctx context.Context, domainName, containerID string) {
1053	payload := &domain.ContainerEventPayload{
1054		ContainerID: containerID,
1055		Domain:      domainName,
1056		Action:      "deployed",
1057	}
1058
1059	if err := s.eventBus.Publish(domain.EventContainerDeployed, payload); err != nil {
1060		log := zerowrap.FromCtx(ctx)
1061		log.Warn().Err(err).Msg("failed to publish container deployed event")
1062	}
1063}
1064
1065// rewriteToRegistryDomain rewrites an image reference to use the configured registry domain.
1066// e.g., "myapp:latest" -> "registry.example.com/myapp:latest"
1067func rewriteToRegistryDomain(imageRef, registryDomain string) string {
1068	if registryDomain == "" {
1069		return imageRef
1070	}
1071
1072	prefix := registryDomain + "/"
1073	if strings.HasPrefix(imageRef, prefix) {
1074		return imageRef
1075	}
1076
1077	return prefix + imageRef
1078}
1079
1080// rewriteToLocalRegistry rewrites an image reference to use the local registry address.
1081// e.g., "registry.example.com/myapp:latest" -> "localhost:5000/myapp:latest"
1082func rewriteToLocalRegistry(imageRef, registryDomain string, registryPort int) string {
1083	if imageRef == "" {
1084		return imageRef
1085	}
1086
1087	localRegistry := fmt.Sprintf("localhost:%d", registryPort)
1088	localPrefix := localRegistry + "/"
1089	imageRef = strings.TrimPrefix(imageRef, localPrefix)
1090
1091	registryDomain = strings.TrimSuffix(registryDomain, "/")
1092	if registryDomain != "" {
1093		prefix := registryDomain + "/"
1094		imageRef = strings.TrimPrefix(imageRef, prefix)
1095	}
1096
1097	return localPrefix + imageRef
1098}

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