2.0.0 4803e67

manager.go RawHistoryFolder

1package container
2
3import (
4	"context"
5	"fmt"
6	"strings"
7	"sync"
8
9	"github.com/rs/zerolog/log"
10
11	"gordon/internal/config"
12	"gordon/internal/env"
13	"gordon/internal/logging"
14	"gordon/pkg/runtime"
15)
16
17// Manager handles container lifecycle and management
18type Manager struct {
19	runtime    runtime.Runtime
20	config     *config.Config
21	containers map[string]*runtime.Container // map[domain] -> container
22	envLoader  *env.Loader
23	logManager *logging.LogManager
24	mu         sync.RWMutex
25}
26
27// NewManager creates a new container manager
28func NewManager(cfg *config.Config) (*Manager, error) {
29	// Test runtime connectivity with background context
30	ctx := context.Background()
31
32	// Create runtime using the factory
33	rt, err := CreateRuntime(ctx, cfg)
34	if err != nil {
35		return nil, fmt.Errorf("failed to create container runtime: %w", err)
36	}
37
38	// Test runtime connectivity
39	if err := rt.Ping(ctx); err != nil {
40		return nil, fmt.Errorf("runtime not available: %w", err)
41	}
42
43	version, err := rt.Version(ctx)
44	if err != nil {
45		log.Warn().Err(err).Msg("Could not get runtime version")
46	} else {
47		log.Info().Str("runtime", cfg.Server.Runtime).Str("version", version).Msg("Container runtime connected")
48	}
49
50	// Create environment loader
51	envLoader := env.NewLoader(cfg)
52
53	// Register secret providers based on config
54	for _, providerName := range cfg.Env.Providers {
55		switch providerName {
56		case "pass":
57			envLoader.RegisterSecretProvider("pass", env.NewPassProvider())
58		case "sops":
59			envLoader.RegisterSecretProvider("sops", env.NewSopsProvider())
60		}
61	}
62
63	// Ensure env directory exists
64	if err := envLoader.EnsureEnvDir(); err != nil {
65		log.Warn().Err(err).Msg("Failed to ensure env directory exists")
66	}
67
68	// Create empty env files for all configured routes (if they don't exist)
69	if err := envLoader.CreateEnvFilesForRoutes(); err != nil {
70		log.Warn().Err(err).Msg("Failed to create env files for routes")
71	}
72
73	// Create log manager
74	logManager := logging.NewLogManager(cfg, rt)
75
76	return &Manager{
77		runtime:    rt,
78		config:     cfg,
79		containers: make(map[string]*runtime.Container),
80		envLoader:  envLoader,
81		logManager: logManager,
82	}, nil
83}
84
85// DeployContainer deploys a container for a specific route
86func (m *Manager) DeployContainer(ctx context.Context, route config.Route) (*runtime.Container, error) {
87	m.mu.Lock()
88	defer m.mu.Unlock()
89
90	// Check if container already exists for this domain
91	if existing, exists := m.containers[route.Domain]; exists {
92		log.Info().Str("domain", route.Domain).Str("container", existing.ID).Msg("Container already exists, restarting")
93
94		// Stop and remove existing container
95		if err := m.runtime.StopContainer(ctx, existing.ID); err != nil {
96			log.Warn().Err(err).Str("container", existing.ID).Msg("Failed to stop existing container")
97		}
98
99		if err := m.runtime.RemoveContainer(ctx, existing.ID, true); err != nil {
100			log.Warn().Err(err).Str("container", existing.ID).Msg("Failed to remove existing container")
101		}
102
103		delete(m.containers, route.Domain)
104	}
105
106	// Also check for orphaned containers with the same name in the runtime
107	expectedContainerName := fmt.Sprintf("gordon-%s", route.Domain)
108	allContainers, err := m.runtime.ListContainers(ctx, true) // include stopped containers
109	if err != nil {
110		log.Warn().Err(err).Msg("Failed to list all containers, proceeding with deployment")
111	} else {
112		for _, container := range allContainers {
113			if container.Name == expectedContainerName {
114				log.Info().
115					Str("domain", route.Domain).
116					Str("container", container.ID).
117					Str("status", container.Status).
118					Msg("Found orphaned container with same name, removing")
119
120				// Stop if running
121				if err := m.runtime.StopContainer(ctx, container.ID); err != nil {
122					log.Warn().Err(err).Str("container", container.ID).Msg("Failed to stop orphaned container")
123				}
124
125				// Force remove
126				if err := m.runtime.RemoveContainer(ctx, container.ID, true); err != nil {
127					log.Warn().Err(err).Str("container", container.ID).Msg("Failed to remove orphaned container")
128				} else {
129					log.Info().Str("container", container.ID).Msg("Successfully removed orphaned container")
130				}
131			}
132		}
133	}
134
135	// Construct the full image reference
136	imageRef := route.Image
137
138	// If registry auth is enabled and image doesn't already start with our registry domain, prepend it
139	if m.config.RegistryAuth.Enabled && m.config.Server.RegistryDomain != "" {
140		repoPart := strings.Split(imageRef, ":")[0]
141		// Don't prepend if:
142		// 1. Image already starts with our registry domain
143		// 2. Image looks like an external registry (contains both dots and slashes, indicating registry.com/repo format)
144		if !strings.HasPrefix(repoPart, m.config.Server.RegistryDomain+"/") && 
145		   !(strings.Contains(repoPart, ".") && strings.Contains(repoPart, "/")) {
146			imageRef = fmt.Sprintf("%s/%s", m.config.Server.RegistryDomain, route.Image)
147		}
148	}
149
150	// Check if image exists locally first
151	imageAvailable := false
152	localImages, err := m.runtime.ListImages(ctx)
153	if err != nil {
154		log.Warn().Err(err).Msg("Failed to list local images, will attempt to pull from registry")
155	} else {
156		// Normalize image references for comparison
157		normalizedImageRef := normalizeImageRef(imageRef)
158
159		for _, localImage := range localImages {
160			if normalizeImageRef(localImage) == normalizedImageRef {
161				imageAvailable = true
162				log.Info().
163					Str("image", imageRef).
164					Msg("Image found locally, skipping pull")
165				break
166			}
167		}
168	}
169
170	// Pull the image only if not available locally
171	if !imageAvailable {
172		log.Info().Str("image", imageRef).Msg("Image not found locally, pulling from registry")
173
174		// Use authenticated pull if registry auth is configured
175		if m.config.RegistryAuth.Enabled {
176			log.Info().
177				Str("image", imageRef).
178				Str("username", m.config.RegistryAuth.Username).
179				Msg("Pulling image with authentication")
180
181			err := m.runtime.PullImageWithAuth(ctx, imageRef, m.config.RegistryAuth.Username, m.config.RegistryAuth.Password)
182			if err != nil {
183				// Provide better error message with context
184				availableImages := make([]string, 0, len(localImages))
185				for _, img := range localImages {
186					availableImages = append(availableImages, img)
187				}
188
189				return nil, fmt.Errorf("failed to pull image '%s' from registry '%s' with authentication. "+
190					"Please check: 1) Image name spelling, 2) Registry credentials, 3) Image exists in registry. "+
191					"Available local images: %v. Error: %w",
192					imageRef, m.config.Server.RegistryDomain, availableImages, err)
193			}
194			log.Info().Str("image", imageRef).Msg("Image pulled successfully with authentication")
195		} else {
196			log.Info().Str("image", imageRef).Msg("Pulling image without authentication")
197
198			err := m.runtime.PullImage(ctx, imageRef)
199			if err != nil {
200				// Provide better error message with context
201				availableImages := make([]string, 0, len(localImages))
202				for _, img := range localImages {
203					availableImages = append(availableImages, img)
204				}
205
206				return nil, fmt.Errorf("failed to pull image '%s' from public registry. "+
207					"Please check: 1) Image name spelling, 2) Image exists in registry. "+
208					"Available local images: %v. Error: %w",
209					imageRef, availableImages, err)
210			}
211			log.Info().Str("image", imageRef).Msg("Image pulled successfully")
212		}
213	}
214
215	// Get exposed ports from the image
216	exposedPorts, err := m.runtime.GetImageExposedPorts(ctx, imageRef)
217	if err != nil {
218		log.Warn().Err(err).Str("image", imageRef).Msg("Failed to get exposed ports from image, using defaults")
219		exposedPorts = []int{80, 8080, 3000} // Fallback to common web server ports
220	}
221
222	// Load environment variables for this route
223	userEnvVars, err := m.envLoader.LoadEnvForRoute(route.Domain)
224	if err != nil {
225		log.Error().Err(err).Str("domain", route.Domain).Msg("Failed to load environment variables for route")
226		return nil, fmt.Errorf("failed to load environment variables for %s: %w", route.Domain, err)
227	}
228
229	// Get environment variables from image ENV directives
230	dockerfileEnvVars, err := m.runtime.InspectImageEnv(ctx, imageRef)
231	if err != nil {
232		log.Warn().Err(err).Str("image", imageRef).Msg("Failed to inspect image environment variables, proceeding without them")
233		dockerfileEnvVars = []string{}
234	} else if len(dockerfileEnvVars) > 0 {
235		log.Info().Str("image", imageRef).Strs("dockerfile_env", dockerfileEnvVars).Msg("Found ENV directives in image")
236	}
237
238	// Merge Dockerfile ENV with user-provided env vars (user env takes precedence)
239	envVars := mergeEnvironmentVariables(dockerfileEnvVars, userEnvVars)
240
241	// Handle volume auto-creation if enabled
242	volumes := make(map[string]string)
243	if m.config.Volumes.AutoCreate {
244		// Get volume paths from image VOLUME directives
245		volumePaths, err := m.runtime.InspectImageVolumes(ctx, imageRef)
246		if err != nil {
247			log.Warn().Err(err).Str("image", imageRef).Msg("Failed to inspect image volumes, proceeding without volumes")
248		} else if len(volumePaths) > 0 {
249			log.Info().Str("image", imageRef).Strs("volume_paths", volumePaths).Msg("Found VOLUME directives in image")
250
251			for _, volumePath := range volumePaths {
252				volumeName := generateVolumeName(m.config.Volumes.Prefix, route.Domain, volumePath)
253
254				// Check if volume exists, create if it doesn't
255				exists, err := m.runtime.VolumeExists(ctx, volumeName)
256				if err != nil {
257					log.Warn().Err(err).Str("volume", volumeName).Msg("Failed to check if volume exists")
258					continue
259				}
260
261				if !exists {
262					if err := m.runtime.CreateVolume(ctx, volumeName); err != nil {
263						log.Error().Err(err).Str("volume", volumeName).Msg("Failed to create volume")
264						continue
265					}
266					log.Info().Str("volume", volumeName).Str("path", volumePath).Msg("Created volume for container")
267				} else {
268					log.Debug().Str("volume", volumeName).Str("path", volumePath).Msg("Volume already exists, reusing")
269				}
270
271				volumes[volumePath] = volumeName
272			}
273		}
274	}
275
276	// Determine which network to use for this app
277	networkName := m.GetNetworkForApp(route.Domain)
278	
279	// Create network if it doesn't exist
280	if err := m.CreateNetworkIfNeeded(ctx, networkName); err != nil {
281		return nil, fmt.Errorf("failed to create network for %s: %w", route.Domain, err)
282	}
283
284	// Create container configuration
285	containerConfig := &runtime.ContainerConfig{
286		Image:       imageRef,
287		Name:        fmt.Sprintf("gordon-%s", route.Domain),
288		Ports:       exposedPorts,
289		Env:         envVars,
290		Volumes:     volumes,
291		NetworkMode: networkName,
292		Hostname:    route.Domain,
293		Labels: map[string]string{
294			"gordon.domain":  route.Domain,
295			"gordon.image":   route.Image,
296			"gordon.managed": "true",
297			"gordon.route":   route.Domain,
298		},
299		AutoRemove: false, // Keep containers for inspection
300	}
301
302	// Create the container
303	container, err := m.runtime.CreateContainer(ctx, containerConfig)
304	if err != nil {
305		return nil, fmt.Errorf("failed to create container for %s: %w", route.Domain, err)
306	}
307
308	// Start the container
309	if err := m.runtime.StartContainer(ctx, container.ID); err != nil {
310		// Clean up the created container
311		m.runtime.RemoveContainer(ctx, container.ID, true)
312		return nil, fmt.Errorf("failed to start container for %s: %w", route.Domain, err)
313	}
314
315	// Re-inspect to get updated port information
316	container, err = m.runtime.InspectContainer(ctx, container.ID)
317	if err != nil {
318		return nil, fmt.Errorf("failed to inspect started container: %w", err)
319	}
320
321	// Store the container mapping
322	m.containers[route.Domain] = container
323
324	// Start log collection for the container
325	containerName := container.Name
326	if containerName == "" {
327		containerName = route.Domain
328	}
329	if err := m.logManager.StartCollection(container.ID, containerName); err != nil {
330		log.Warn().
331			Err(err).
332			Str("container", container.ID).
333			Str("domain", route.Domain).
334			Msg("Failed to start log collection for container")
335	}
336
337	log.Info().
338		Str("domain", route.Domain).
339		Str("image", route.Image).
340		Str("container", container.ID).
341		Ints("ports", container.Ports).
342		Str("network", networkName).
343		Msg("Container deployed successfully")
344
345	// Deploy attachments for this specific app
346	if attachments, ok := m.config.Attachments[route.Domain]; ok {
347		for _, serviceImage := range attachments {
348			if err := m.DeployAttachedService(ctx, route.Domain, serviceImage); err != nil {
349				log.Error().Err(err).Str("service", serviceImage).Str("domain", route.Domain).Msg("Failed to deploy attachment")
350				// Don't fail the main deployment if attachment fails
351			}
352		}
353	}
354	
355	// Check if app is part of a network group with attachments
356	for groupName, domains := range m.config.NetworkGroups {
357		for _, d := range domains {
358			if d == route.Domain {
359				// Deploy group attachments if not already deployed
360				if attachments, ok := m.config.Attachments[groupName]; ok {
361					for _, serviceImage := range attachments {
362						if err := m.DeployAttachedService(ctx, groupName, serviceImage); err != nil {
363							log.Error().Err(err).Str("service", serviceImage).Str("group", groupName).Msg("Failed to deploy group attachment")
364							// Don't fail the main deployment if attachment fails
365						}
366					}
367				}
368				break
369			}
370		}
371	}
372
373	return container, nil
374}
375
376// GetContainer returns the container for a domain
377func (m *Manager) GetContainer(domain string) (*runtime.Container, bool) {
378	m.mu.RLock()
379	defer m.mu.RUnlock()
380
381	container, exists := m.containers[domain]
382	return container, exists
383}
384
385// GetContainerPort returns the host port for a domain's container
386func (m *Manager) GetContainerPort(ctx context.Context, domain string, internalPort int) (int, error) {
387	m.mu.RLock()
388	container, exists := m.containers[domain]
389	m.mu.RUnlock()
390
391	if !exists {
392		return 0, fmt.Errorf("no container found for domain %s", domain)
393	}
394
395	return m.runtime.GetContainerPort(ctx, container.ID, internalPort)
396}
397
398// ListContainers returns all managed containers
399func (m *Manager) ListContainers() map[string]*runtime.Container {
400	m.mu.RLock()
401	defer m.mu.RUnlock()
402
403	// Return a copy of the map to avoid race conditions
404	result := make(map[string]*runtime.Container)
405	for domain, container := range m.containers {
406		result[domain] = container
407	}
408	return result
409}
410
411// FindContainerByDomain returns the container ID for a domain
412func (m *Manager) FindContainerByDomain(domain string) (string, bool) {
413	m.mu.RLock()
414	defer m.mu.RUnlock()
415
416	if container, exists := m.containers[domain]; exists {
417		return container.ID, true
418	}
419	return "", false
420}
421
422// FindDomainByContainerID returns the domain for a container ID
423func (m *Manager) FindDomainByContainerID(containerID string) (string, bool) {
424	m.mu.RLock()
425	defer m.mu.RUnlock()
426
427	for domain, container := range m.containers {
428		if container.ID == containerID {
429			return domain, true
430		}
431	}
432	return "", false
433}
434
435// StopContainer stops a container by ID
436func (m *Manager) StopContainer(ctx context.Context, containerID string) error {
437	if err := m.runtime.StopContainer(ctx, containerID); err != nil {
438		return fmt.Errorf("failed to stop container %s: %w", containerID, err)
439	}
440
441	// Stop log collection for the container
442	m.logManager.StopCollection(containerID)
443
444	log.Info().Str("container", containerID).Msg("Container stopped")
445	return nil
446}
447
448// StopContainerByDomain stops a container for a domain
449func (m *Manager) StopContainerByDomain(ctx context.Context, domain string) error {
450	m.mu.RLock()
451	container, exists := m.containers[domain]
452	m.mu.RUnlock()
453
454	if !exists {
455		return fmt.Errorf("no container found for domain %s", domain)
456	}
457
458	return m.StopContainer(ctx, container.ID)
459}
460
461// RemoveContainer removes a container by ID
462func (m *Manager) RemoveContainer(ctx context.Context, containerID string, force bool) error {
463	// Find the domain for this container before removal for volume cleanup
464	var containerDomain string
465	m.mu.RLock()
466	for domain, container := range m.containers {
467		if container.ID == containerID {
468			containerDomain = domain
469			break
470		}
471	}
472	m.mu.RUnlock()
473
474	if err := m.runtime.RemoveContainer(ctx, containerID, force); err != nil {
475		return fmt.Errorf("failed to remove container %s: %w", containerID, err)
476	}
477
478	// Stop log collection for the container
479	m.logManager.StopCollection(containerID)
480
481	// Clean up volumes if preserve is disabled and we found the domain
482	if containerDomain != "" && !m.config.Volumes.Preserve {
483		if err := m.cleanupVolumesForDomain(ctx, containerDomain); err != nil {
484			log.Warn().Err(err).Str("domain", containerDomain).Msg("Failed to cleanup volumes during container removal")
485		}
486	}
487
488	// Remove from our tracking map and cleanup network if needed
489	m.mu.Lock()
490	var removedDomain string
491	for domain, container := range m.containers {
492		if container.ID == containerID {
493			delete(m.containers, domain)
494			removedDomain = domain
495			log.Info().Str("domain", domain).Str("container", containerID).Msg("Container removed")
496			break
497		}
498	}
499	m.mu.Unlock()
500
501	// If we removed an app container, check if we need to cleanup its network
502	if removedDomain != "" && m.config.NetworkIsolation.Enabled {
503		networkName := m.GetNetworkForApp(removedDomain)
504		if err := m.cleanupNetworkIfEmpty(ctx, networkName); err != nil {
505			log.Warn().Err(err).Str("network", networkName).Msg("Failed to cleanup network after container removal")
506		}
507	}
508
509	return nil
510}
511
512// RemoveContainerByDomain removes a container for a domain
513func (m *Manager) RemoveContainerByDomain(ctx context.Context, domain string, force bool) error {
514	m.mu.RLock()
515	container, exists := m.containers[domain]
516	m.mu.RUnlock()
517
518	if !exists {
519		return fmt.Errorf("no container found for domain %s", domain)
520	}
521
522	return m.RemoveContainer(ctx, container.ID, force)
523}
524
525// SyncContainers synchronizes the internal state with the actual running containers
526func (m *Manager) SyncContainers(ctx context.Context) error {
527	m.mu.Lock()
528	defer m.mu.Unlock()
529
530	// Get all containers from the runtime
531	allContainers, err := m.runtime.ListContainers(ctx, false) // only running containers
532	if err != nil {
533		return fmt.Errorf("failed to list containers: %w", err)
534	}
535
536	// Find containers managed by Gordon
537	managedContainers := make(map[string]*runtime.Container)
538	for _, container := range allContainers {
539		if container.Labels != nil {
540			if domain, exists := container.Labels["gordon.domain"]; exists && container.Labels["gordon.managed"] == "true" {
541				managedContainers[domain] = container
542			}
543		}
544	}
545
546	// Update our internal state
547	m.containers = managedContainers
548
549	log.Info().Int("count", len(managedContainers)).Msg("Container state synchronized")
550	return nil
551}
552
553// AutoStartContainers automatically starts containers that match configured routes
554func (m *Manager) AutoStartContainers(ctx context.Context) error {
555	routes := m.config.GetRoutes()
556	if len(routes) == 0 {
557		log.Info().Msg("No routes configured, skipping auto-start")
558		return nil
559	}
560
561	// Get all containers (including stopped ones) to check for matches
562	allContainers, err := m.runtime.ListContainers(ctx, true)
563	if err != nil {
564		return fmt.Errorf("failed to list all containers: %w", err)
565	}
566
567	// Create maps for quick lookup
568	gordonManagedContainers := make(map[string]*runtime.Container) // domain -> container
569	containersByImage := make(map[string][]*runtime.Container)     // image -> containers
570
571	for _, container := range allContainers {
572		// Track Gordon-managed containers by domain
573		if container.Labels != nil && container.Labels["gordon.managed"] == "true" {
574			if domain, exists := container.Labels["gordon.domain"]; exists {
575				gordonManagedContainers[domain] = container
576			}
577		}
578
579		// Track all containers by image (normalize image name)
580		normalizedImage := m.normalizeImageName(container.Image)
581		containersByImage[normalizedImage] = append(containersByImage[normalizedImage], container)
582	}
583
584	startedCount := 0
585	for _, route := range routes {
586		// Build the expected image reference
587		expectedImage := route.Image
588		if m.config.RegistryAuth.Enabled && m.config.Server.RegistryDomain != "" {
589			if !strings.Contains(strings.Split(expectedImage, ":")[0], ".") {
590				expectedImage = fmt.Sprintf("%s/%s", m.config.Server.RegistryDomain, route.Image)
591			}
592		}
593
594		// First, check if we already have a Gordon-managed container for this domain
595		if existingContainer, exists := gordonManagedContainers[route.Domain]; exists {
596			isRunning, err := m.runtime.IsContainerRunning(ctx, existingContainer.ID)
597			if err != nil {
598				log.Warn().Err(err).Str("domain", route.Domain).Str("container", existingContainer.ID).Msg("Failed to check container status")
599				continue
600			}
601
602			if isRunning {
603				log.Info().Str("domain", route.Domain).Str("container", existingContainer.ID).Msg("Gordon-managed container already running")
604				m.mu.Lock()
605				m.containers[route.Domain] = existingContainer
606				m.mu.Unlock()
607				continue
608			}
609
610			// Container exists but is stopped, start it
611			log.Info().Str("domain", route.Domain).Str("container", existingContainer.ID).Msg("Starting existing Gordon-managed container")
612			if err := m.runtime.StartContainer(ctx, existingContainer.ID); err != nil {
613				log.Error().Err(err).Str("domain", route.Domain).Str("container", existingContainer.ID).Msg("Failed to start existing container")
614				continue
615			}
616
617			// Re-inspect to get updated information
618			container, err := m.runtime.InspectContainer(ctx, existingContainer.ID)
619			if err != nil {
620				log.Error().Err(err).Str("container", existingContainer.ID).Msg("Failed to inspect started container")
621				continue
622			}
623
624			m.mu.Lock()
625			m.containers[route.Domain] = container
626			m.mu.Unlock()
627			startedCount++
628
629			// Start log collection for the container
630			containerName := container.Name
631			if containerName == "" {
632				containerName = route.Domain
633			}
634			if err := m.logManager.StartCollection(container.ID, containerName); err != nil {
635				log.Warn().
636					Err(err).
637					Str("container", container.ID).
638					Str("domain", route.Domain).
639					Msg("Failed to start log collection for auto-started container")
640			}
641
642			log.Info().Str("domain", route.Domain).Str("container", existingContainer.ID).Msg("Existing Gordon-managed container started successfully")
643			continue
644		}
645
646		// Second, check if there are existing containers with the same image and clean them up
647		normalizedExpectedImage := m.normalizeImageName(expectedImage)
648		if containers, exists := containersByImage[normalizedExpectedImage]; exists {
649			// Stop and remove existing containers with the same image to avoid conflicts
650			for _, container := range containers {
651				// Skip Gordon-managed containers (already handled above)
652				if container.Labels != nil && container.Labels["gordon.managed"] == "true" {
653					continue
654				}
655
656				log.Info().Str("domain", route.Domain).Str("container", container.ID).Str("image", container.Image).Msg("Stopping existing container with same image to avoid conflicts")
657
658				// Stop the container if it's running
659				isRunning, err := m.runtime.IsContainerRunning(ctx, container.ID)
660				if err != nil {
661					log.Warn().Err(err).Str("container", container.ID).Msg("Failed to check container status")
662					continue
663				}
664
665				if isRunning {
666					if err := m.runtime.StopContainer(ctx, container.ID); err != nil {
667						log.Warn().Err(err).Str("container", container.ID).Msg("Failed to stop existing container")
668						continue
669					}
670				}
671
672				// Remove the container to clean up
673				if err := m.runtime.RemoveContainer(ctx, container.ID, true); err != nil {
674					log.Warn().Err(err).Str("container", container.ID).Msg("Failed to remove existing container")
675				} else {
676					log.Info().Str("container", container.ID).Msg("Removed existing container with same image")
677				}
678			}
679		}
680
681		// No existing container found, deploy a new one
682		log.Info().Str("domain", route.Domain).Str("image", route.Image).Msg("Deploying new container for route")
683
684		_, err := m.DeployContainer(ctx, route)
685		if err != nil {
686			log.Error().Err(err).Str("domain", route.Domain).Str("image", route.Image).Msg("Failed to deploy container for route")
687			continue
688		}
689		startedCount++
690	}
691
692	log.Info().Int("started", startedCount).Int("total_routes", len(routes)).Msg("Auto-start completed")
693	return nil
694}
695
696// normalizeImageName normalizes image names for comparison
697func (m *Manager) normalizeImageName(image string) string {
698	// Remove tag if present, keep only repository name
699	parts := strings.Split(image, ":")
700	repo := parts[0]
701
702	// If no registry domain and it's a simple name, it's from Docker Hub
703	if !strings.Contains(repo, "/") {
704		return "docker.io/library/" + repo
705	}
706
707	// If it has one slash and no domain, it's a user repo on Docker Hub
708	if strings.Count(repo, "/") == 1 && !strings.Contains(strings.Split(repo, "/")[0], ".") {
709		return "docker.io/" + repo
710	}
711
712	return repo
713}
714
715// StopAllManagedContainers stops all containers managed by Gordon
716func (m *Manager) StopAllManagedContainers(ctx context.Context) error {
717	m.mu.RLock()
718	containers := make(map[string]*runtime.Container)
719	for domain, container := range m.containers {
720		containers[domain] = container
721	}
722	m.mu.RUnlock()
723
724	if len(containers) == 0 {
725		log.Info().Msg("No managed containers to stop")
726		return nil
727	}
728
729	log.Info().Int("count", len(containers)).Msg("Stopping all managed containers...")
730
731	var errors []error
732	for domain, container := range containers {
733		log.Info().Str("domain", domain).Str("container", container.ID).Msg("Stopping managed container")
734
735		if err := m.runtime.StopContainer(ctx, container.ID); err != nil {
736			log.Error().Err(err).Str("domain", domain).Str("container", container.ID).Msg("Failed to stop managed container")
737			errors = append(errors, fmt.Errorf("failed to stop container %s for domain %s: %w", container.ID, domain, err))
738			continue
739		}
740
741		// Stop log collection for the container
742		m.logManager.StopCollection(container.ID)
743
744		// Remove from tracking
745		m.mu.Lock()
746		delete(m.containers, domain)
747		m.mu.Unlock()
748
749		log.Info().Str("domain", domain).Str("container", container.ID).Msg("Managed container stopped successfully")
750	}
751
752	if len(errors) > 0 {
753		return fmt.Errorf("failed to stop %d containers: %v", len(errors), errors)
754	}
755
756	log.Info().Msg("All managed containers stopped successfully")
757	return nil
758}
759
760// HealthCheck checks the health of all managed containers
761func (m *Manager) HealthCheck(ctx context.Context) map[string]bool {
762	m.mu.RLock()
763	defer m.mu.RUnlock()
764
765	health := make(map[string]bool)
766	for domain, container := range m.containers {
767		running, err := m.runtime.IsContainerRunning(ctx, container.ID)
768		if err != nil {
769			log.Warn().Err(err).Str("domain", domain).Str("container", container.ID).Msg("Failed to check container health")
770			health[domain] = false
771		} else {
772			health[domain] = running
773		}
774	}
775
776	return health
777}
778
779// Runtime returns the underlying runtime interface
780func (m *Manager) Runtime() runtime.Runtime {
781	return m.runtime
782}
783
784// UpdateConfig updates the manager's configuration and creates env files for new routes
785func (m *Manager) UpdateConfig(cfg *config.Config) {
786	m.config = cfg
787
788	// Update env loader config
789	m.envLoader.UpdateConfig(cfg)
790
791	// Create env files for any new routes
792	if err := m.envLoader.CreateEnvFilesForRoutes(); err != nil {
793		log.Warn().Err(err).Msg("Failed to create env files for new routes during config update")
794	}
795}
796
797// Shutdown gracefully shuts down the container manager
798func (m *Manager) Shutdown(ctx context.Context) error {
799	log.Info().Msg("Shutting down container manager...")
800
801	// Stop all managed containers (this also stops log collection for each)
802	if err := m.StopAllManagedContainers(ctx); err != nil {
803		log.Error().Err(err).Msg("Failed to stop all managed containers during shutdown")
804	}
805
806	// Stop all remaining log collection
807	m.logManager.StopAll()
808
809	log.Info().Msg("Container manager shutdown complete")
810	return nil
811}
812
813// cleanupVolumesForDomain removes all volumes associated with a domain
814func (m *Manager) cleanupVolumesForDomain(ctx context.Context, domain string) error {
815	log.Info().Str("domain", domain).Msg("Cleaning up volumes for domain")
816
817	// We need to get the route to know what image was used to determine volume paths
818	var route *config.Route
819	for _, r := range m.config.GetRoutes() {
820		if r.Domain == domain {
821			route = &r
822			break
823		}
824	}
825
826	if route == nil {
827		log.Debug().Str("domain", domain).Msg("No route found for domain, skipping volume cleanup")
828		return nil
829	}
830
831	// Construct the full image reference like we do in DeployContainer
832	imageRef := route.Image
833	if m.config.RegistryAuth.Enabled && m.config.Server.RegistryDomain != "" {
834		if !strings.Contains(strings.Split(imageRef, ":")[0], ".") {
835			imageRef = fmt.Sprintf("%s/%s", m.config.Server.RegistryDomain, route.Image)
836		}
837	}
838
839	// Get volume paths from image VOLUME directives
840	volumePaths, err := m.runtime.InspectImageVolumes(ctx, imageRef)
841	if err != nil {
842		log.Warn().Err(err).Str("image", imageRef).Msg("Failed to inspect image volumes during cleanup")
843		return err
844	}
845
846	if len(volumePaths) == 0 {
847		log.Debug().Str("domain", domain).Msg("No volumes found for cleanup")
848		return nil
849	}
850
851	var errors []error
852	for _, volumePath := range volumePaths {
853		volumeName := generateVolumeName(m.config.Volumes.Prefix, domain, volumePath)
854
855		// Check if volume exists before trying to remove
856		exists, err := m.runtime.VolumeExists(ctx, volumeName)
857		if err != nil {
858			log.Warn().Err(err).Str("volume", volumeName).Msg("Failed to check if volume exists during cleanup")
859			continue
860		}
861
862		if exists {
863			if err := m.runtime.RemoveVolume(ctx, volumeName, true); err != nil {
864				log.Error().Err(err).Str("volume", volumeName).Str("domain", domain).Msg("Failed to remove volume during cleanup")
865				errors = append(errors, err)
866			} else {
867				log.Info().Str("volume", volumeName).Str("domain", domain).Msg("Volume cleaned up successfully")
868			}
869		}
870	}
871
872	if len(errors) > 0 {
873		return fmt.Errorf("failed to cleanup %d volumes for domain %s", len(errors), domain)
874	}
875
876	return nil
877}
878
879// cleanupNetworkIfEmpty removes a network if it has no containers
880func (m *Manager) cleanupNetworkIfEmpty(ctx context.Context, networkName string) error {
881	if networkName == "bridge" || networkName == "default" {
882		return nil // Don't try to remove default networks
883	}
884
885	networks, err := m.runtime.ListNetworks(ctx)
886	if err != nil {
887		return fmt.Errorf("failed to list networks: %w", err)
888	}
889
890	for _, network := range networks {
891		if network.Name == networkName {
892			// Check if network has any containers
893			if len(network.Containers) == 0 {
894				// Network is empty, remove it
895				if err := m.runtime.RemoveNetwork(ctx, networkName); err != nil {
896					log.Warn().Err(err).Str("network", networkName).Msg("Failed to cleanup empty network")
897					return err
898				}
899				log.Info().Str("network", networkName).Msg("Cleaned up empty network")
900			}
901			break
902		}
903	}
904
905	return nil
906}
907
908// DeployAttachedService deploys a service attached to an app or network group
909func (m *Manager) DeployAttachedService(ctx context.Context, identifier, serviceImage string) error {
910	networkName := m.generateNetworkName(identifier)
911	
912	// Extract service name from image for container naming
913	serviceName := strings.Split(serviceImage, ":")[0]
914	if strings.Contains(serviceName, "/") {
915		parts := strings.Split(serviceName, "/")
916		serviceName = parts[len(parts)-1]
917	}
918	
919	// For network groups, we need a different container naming scheme
920	apps := m.GetAppsForNetwork(identifier)
921	var containerName string
922	
923	if len(apps) > 1 {
924		// Shared service for network group
925		containerName = fmt.Sprintf("%s-shared-%s", m.config.NetworkIsolation.NetworkPrefix, serviceName)
926	} else {
927		// App-specific service
928		containerName = fmt.Sprintf("%s-%s", strings.ReplaceAll(identifier, ".", "-"), serviceName)
929	}
930	
931	// Check if service is already running
932	containers, err := m.runtime.ListContainers(ctx, false)
933	if err != nil {
934		return fmt.Errorf("failed to list containers: %w", err)
935	}
936	
937	for _, container := range containers {
938		if container.Name == containerName {
939			log.Info().Str("service", containerName).Msg("Service already running")
940			return nil
941		}
942	}
943	
944	// Get exposed ports from the service image
945	exposedPorts, err := m.runtime.GetImageExposedPorts(ctx, serviceImage)
946	if err != nil {
947		log.Warn().Err(err).Str("image", serviceImage).Msg("Failed to get exposed ports from service image, using empty ports")
948		exposedPorts = []int{} // Services don't need external ports by default
949	}
950	
951	// Load environment for service (inherit from first app in group or specific app)
952	var envVars []string
953	if len(apps) > 0 {
954		userEnvVars, err := m.envLoader.LoadEnvForRoute(apps[0])
955		if err != nil {
956			log.Warn().Err(err).Str("domain", apps[0]).Msg("Failed to load environment for service, using empty env")
957			userEnvVars = []string{}
958		}
959		
960		// Get environment variables from image ENV directives
961		dockerfileEnvVars, err := m.runtime.InspectImageEnv(ctx, serviceImage)
962		if err != nil {
963			log.Warn().Err(err).Str("image", serviceImage).Msg("Failed to inspect service image environment variables")
964			dockerfileEnvVars = []string{}
965		}
966		
967		// Merge environments
968		envVars = mergeEnvironmentVariables(dockerfileEnvVars, userEnvVars)
969	}
970	
971	// Handle volumes for the service
972	volumes := make(map[string]string)
973	if m.config.Volumes.AutoCreate {
974		volumePaths, err := m.runtime.InspectImageVolumes(ctx, serviceImage)
975		if err != nil {
976			log.Warn().Err(err).Str("image", serviceImage).Msg("Failed to inspect service image volumes")
977		} else if len(volumePaths) > 0 {
978			for _, volumePath := range volumePaths {
979				volumeName := fmt.Sprintf("%s-%s-%s", 
980					m.config.Volumes.Prefix,
981					strings.ReplaceAll(identifier, ".", "-"),
982					strings.ReplaceAll(strings.Trim(volumePath, "/"), "/", "-"))
983				
984				// Create volume if it doesn't exist
985				exists, err := m.runtime.VolumeExists(ctx, volumeName)
986				if err != nil {
987					log.Warn().Err(err).Str("volume", volumeName).Msg("Failed to check service volume")
988					continue
989				}
990				
991				if !exists {
992					if err := m.runtime.CreateVolume(ctx, volumeName); err != nil {
993						log.Error().Err(err).Str("volume", volumeName).Msg("Failed to create service volume")
994						continue
995					}
996					log.Info().Str("volume", volumeName).Str("path", volumePath).Msg("Created volume for service")
997				}
998				
999				volumes[volumePath] = volumeName
1000			}
1001		}
1002	}
1003	
1004	// Create service container configuration
1005	serviceConfig := &runtime.ContainerConfig{
1006		Image:       serviceImage,
1007		Name:        containerName,
1008		Ports:       exposedPorts,
1009		Env:         envVars,
1010		Volumes:     volumes,
1011		NetworkMode: networkName,
1012		Hostname:    serviceName,
1013		Labels: map[string]string{
1014			"gordon.managed":    "true",
1015			"gordon.service":    "true",
1016			"gordon.attached":   identifier,
1017			"gordon.image":      serviceImage,
1018		},
1019		AutoRemove: false,
1020	}
1021	
1022	// Create and start the service container
1023	container, err := m.runtime.CreateContainer(ctx, serviceConfig)
1024	if err != nil {
1025		return fmt.Errorf("failed to create service container %s: %w", containerName, err)
1026	}
1027	
1028	if err := m.runtime.StartContainer(ctx, container.ID); err != nil {
1029		m.runtime.RemoveContainer(ctx, container.ID, true)
1030		return fmt.Errorf("failed to start service container %s: %w", containerName, err)
1031	}
1032	
1033	log.Info().
1034		Str("service", containerName).
1035		Str("image", serviceImage).
1036		Str("network", networkName).
1037		Str("attached_to", identifier).
1038		Msg("Service deployed successfully")
1039	
1040	return nil
1041}
1042
1043// GetNetworkForApp determines which network an app should use
1044func (m *Manager) GetNetworkForApp(domain string) string {
1045	if !m.config.NetworkIsolation.Enabled {
1046		return "bridge" // Use default Docker bridge network
1047	}
1048
1049	// Check if domain is part of a network group
1050	for groupName, domains := range m.config.NetworkGroups {
1051		for _, d := range domains {
1052			if d == domain {
1053				return m.generateNetworkName(groupName)
1054			}
1055		}
1056	}
1057	
1058	// Default: app gets its own network
1059	return m.generateNetworkName(domain)
1060}
1061
1062// generateNetworkName creates a network name from an identifier
1063func (m *Manager) generateNetworkName(identifier string) string {
1064	// "myapp.example.com" -> "gordon-myapp-example-com"
1065	// "backend" -> "gordon-backend"
1066	return fmt.Sprintf("%s-%s", 
1067		m.config.NetworkIsolation.NetworkPrefix,
1068		strings.ReplaceAll(identifier, ".", "-"))
1069}
1070
1071// GetAppsForNetwork returns all apps that should have access to a network
1072func (m *Manager) GetAppsForNetwork(identifier string) []string {
1073	// Check if it's a network group
1074	if apps, ok := m.config.NetworkGroups[identifier]; ok {
1075		return apps
1076	}
1077	
1078	// Single app network
1079	return []string{identifier}
1080}
1081
1082// CreateNetworkIfNeeded creates a network if it doesn't exist
1083func (m *Manager) CreateNetworkIfNeeded(ctx context.Context, networkName string) error {
1084	if networkName == "bridge" || networkName == "default" {
1085		return nil // Don't try to create default networks
1086	}
1087
1088	exists, err := m.runtime.NetworkExists(ctx, networkName)
1089	if err != nil {
1090		return fmt.Errorf("failed to check if network exists: %w", err)
1091	}
1092
1093	if !exists {
1094		options := map[string]string{
1095			"driver": "bridge",
1096		}
1097		
1098		if err := m.runtime.CreateNetwork(ctx, networkName, options); err != nil {
1099			return fmt.Errorf("failed to create network %s: %w", networkName, err)
1100		}
1101		
1102		log.Info().Str("network", networkName).Msg("Created network for app isolation")
1103	}
1104
1105	return nil
1106}

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