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}