detached 3820de3

feat(container): Implement auto-start and shutdown for managed containers

- Add AutoStartContainers method to automatically start containers matching configured routes.
- Enhance runStart function to initiate auto-starting of containers and handle shutdown of managed containers.
- Improve logging for container management actions, including starting and stopping containers.
commit
3820de39e7c0e566a1bc7d7726baca43a540cf8e
author
bamen · · 2025-06-07 12:47 UTC
parent
45beea7
signature
unsigned
2 files · +206 -0
M cmd/start.go +15 -0
@@ -104,6 +104,12 @@ func runStart(cmd *cobra.Command, args []string) {
104104 log.Warn().Err(err).Msg("Failed to sync existing containers")
105105 }
106106
107+ // Auto-start containers that match config routes
108+ log.Info().Msg("Auto-starting containers for configured routes...")
109+ if err := manager.AutoStartContainers(ctx); err != nil {
110+ log.Warn().Err(err).Msg("Failed to auto-start containers")
111+ }
112+
107113 var wg sync.WaitGroup
108114
109115 // Start registry server
@@ -137,6 +143,15 @@ func runStart(cmd *cobra.Command, args []string) {
137143 log.Info().Msg("Shutting down servers...")
138144 cancel()
139145
146+ // Stop all managed containers with a separate context for shutdown
147+ shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
148+ defer shutdownCancel()
149+
150+ log.Info().Msg("Stopping all managed containers...")
151+ if err := manager.StopAllManagedContainers(shutdownCtx); err != nil {
152+ log.Error().Err(err).Msg("Failed to stop managed containers")
153+ }
154+
140155 // Stop event bus
141156 if err := eventBus.Stop(); err != nil {
142157 log.Error().Err(err).Msg("Failed to stop event bus")
@@ -104,6 +104,12 @@ func runStart(cmd *cobra.Command, args []string) {
104 log.Warn().Err(err).Msg("Failed to sync existing containers")104 log.Warn().Err(err).Msg("Failed to sync existing containers")
105 }105 }
106 106
107+ // Auto-start containers that match config routes
108+ log.Info().Msg("Auto-starting containers for configured routes...")
109+ if err := manager.AutoStartContainers(ctx); err != nil {
110+ log.Warn().Err(err).Msg("Failed to auto-start containers")
111+ }
112+
107 var wg sync.WaitGroup113 var wg sync.WaitGroup
108 114
109 // Start registry server115 // Start registry server
@@ -137,6 +143,15 @@ func runStart(cmd *cobra.Command, args []string) {
137 log.Info().Msg("Shutting down servers...")143 log.Info().Msg("Shutting down servers...")
138 cancel()144 cancel()
139 145
146+ // Stop all managed containers with a separate context for shutdown
147+ shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
148+ defer shutdownCancel()
149+
150+ log.Info().Msg("Stopping all managed containers...")
151+ if err := manager.StopAllManagedContainers(shutdownCtx); err != nil {
152+ log.Error().Err(err).Msg("Failed to stop managed containers")
153+ }
154+
140 // Stop event bus155 // Stop event bus
141 if err := eventBus.Stop(); err != nil {156 if err := eventBus.Stop(); err != nil {
142 log.Error().Err(err).Msg("Failed to stop event bus")157 log.Error().Err(err).Msg("Failed to stop event bus")
M internal/container/manager.go +191 -0
@@ -292,6 +292,197 @@ func (m *Manager) SyncContainers(ctx context.Context) error {
292292 return nil
293293 }
294294
295+// AutoStartContainers automatically starts containers that match configured routes
296+func (m *Manager) AutoStartContainers(ctx context.Context) error {
297+ routes := m.config.GetRoutes()
298+ if len(routes) == 0 {
299+ log.Info().Msg("No routes configured, skipping auto-start")
300+ return nil
301+ }
302+
303+ // Get all containers (including stopped ones) to check for matches
304+ allContainers, err := m.runtime.ListContainers(ctx, true)
305+ if err != nil {
306+ return fmt.Errorf("failed to list all containers: %w", err)
307+ }
308+
309+ // Create maps for quick lookup
310+ gordonManagedContainers := make(map[string]*runtime.Container) // domain -> container
311+ containersByImage := make(map[string][]*runtime.Container) // image -> containers
312+
313+ for _, container := range allContainers {
314+ // Track Gordon-managed containers by domain
315+ if container.Labels != nil && container.Labels["gordon.managed"] == "true" {
316+ if domain, exists := container.Labels["gordon.domain"]; exists {
317+ gordonManagedContainers[domain] = container
318+ }
319+ }
320+
321+ // Track all containers by image (normalize image name)
322+ normalizedImage := m.normalizeImageName(container.Image)
323+ containersByImage[normalizedImage] = append(containersByImage[normalizedImage], container)
324+ }
325+
326+ startedCount := 0
327+ for _, route := range routes {
328+ // Build the expected image reference
329+ expectedImage := route.Image
330+ if m.config.RegistryAuth.Enabled && m.config.Server.RegistryDomain != "" {
331+ if !strings.Contains(strings.Split(expectedImage, ":")[0], ".") {
332+ expectedImage = fmt.Sprintf("%s/%s", m.config.Server.RegistryDomain, route.Image)
333+ }
334+ }
335+
336+ // First, check if we already have a Gordon-managed container for this domain
337+ if existingContainer, exists := gordonManagedContainers[route.Domain]; exists {
338+ isRunning, err := m.runtime.IsContainerRunning(ctx, existingContainer.ID)
339+ if err != nil {
340+ log.Warn().Err(err).Str("domain", route.Domain).Str("container", existingContainer.ID).Msg("Failed to check container status")
341+ continue
342+ }
343+
344+ if isRunning {
345+ log.Info().Str("domain", route.Domain).Str("container", existingContainer.ID).Msg("Gordon-managed container already running")
346+ m.mu.Lock()
347+ m.containers[route.Domain] = existingContainer
348+ m.mu.Unlock()
349+ continue
350+ }
351+
352+ // Container exists but is stopped, start it
353+ log.Info().Str("domain", route.Domain).Str("container", existingContainer.ID).Msg("Starting existing Gordon-managed container")
354+ if err := m.runtime.StartContainer(ctx, existingContainer.ID); err != nil {
355+ log.Error().Err(err).Str("domain", route.Domain).Str("container", existingContainer.ID).Msg("Failed to start existing container")
356+ continue
357+ }
358+
359+ // Re-inspect to get updated information
360+ container, err := m.runtime.InspectContainer(ctx, existingContainer.ID)
361+ if err != nil {
362+ log.Error().Err(err).Str("container", existingContainer.ID).Msg("Failed to inspect started container")
363+ continue
364+ }
365+
366+ m.mu.Lock()
367+ m.containers[route.Domain] = container
368+ m.mu.Unlock()
369+ startedCount++
370+
371+ log.Info().Str("domain", route.Domain).Str("container", existingContainer.ID).Msg("Existing Gordon-managed container started successfully")
372+ continue
373+ }
374+
375+ // Second, check if there are existing containers with the same image and clean them up
376+ normalizedExpectedImage := m.normalizeImageName(expectedImage)
377+ if containers, exists := containersByImage[normalizedExpectedImage]; exists {
378+ // Stop and remove existing containers with the same image to avoid conflicts
379+ for _, container := range containers {
380+ // Skip Gordon-managed containers (already handled above)
381+ if container.Labels != nil && container.Labels["gordon.managed"] == "true" {
382+ continue
383+ }
384+
385+ log.Info().Str("domain", route.Domain).Str("container", container.ID).Str("image", container.Image).Msg("Stopping existing container with same image to avoid conflicts")
386+
387+ // Stop the container if it's running
388+ isRunning, err := m.runtime.IsContainerRunning(ctx, container.ID)
389+ if err != nil {
390+ log.Warn().Err(err).Str("container", container.ID).Msg("Failed to check container status")
391+ continue
392+ }
393+
394+ if isRunning {
395+ if err := m.runtime.StopContainer(ctx, container.ID); err != nil {
396+ log.Warn().Err(err).Str("container", container.ID).Msg("Failed to stop existing container")
397+ continue
398+ }
399+ }
400+
401+ // Remove the container to clean up
402+ if err := m.runtime.RemoveContainer(ctx, container.ID, true); err != nil {
403+ log.Warn().Err(err).Str("container", container.ID).Msg("Failed to remove existing container")
404+ } else {
405+ log.Info().Str("container", container.ID).Msg("Removed existing container with same image")
406+ }
407+ }
408+ }
409+
410+ // No existing container found, deploy a new one
411+ log.Info().Str("domain", route.Domain).Str("image", route.Image).Msg("Deploying new container for route")
412+
413+ _, err := m.DeployContainer(ctx, route)
414+ if err != nil {
415+ log.Error().Err(err).Str("domain", route.Domain).Str("image", route.Image).Msg("Failed to deploy container for route")
416+ continue
417+ }
418+ startedCount++
419+ }
420+
421+ log.Info().Int("started", startedCount).Int("total_routes", len(routes)).Msg("Auto-start completed")
422+ return nil
423+}
424+
425+// normalizeImageName normalizes image names for comparison
426+func (m *Manager) normalizeImageName(image string) string {
427+ // Remove tag if present, keep only repository name
428+ parts := strings.Split(image, ":")
429+ repo := parts[0]
430+
431+ // If no registry domain and it's a simple name, it's from Docker Hub
432+ if !strings.Contains(repo, "/") {
433+ return "docker.io/library/" + repo
434+ }
435+
436+ // If it has one slash and no domain, it's a user repo on Docker Hub
437+ if strings.Count(repo, "/") == 1 && !strings.Contains(strings.Split(repo, "/")[0], ".") {
438+ return "docker.io/" + repo
439+ }
440+
441+ return repo
442+}
443+
444+// StopAllManagedContainers stops all containers managed by Gordon
445+func (m *Manager) StopAllManagedContainers(ctx context.Context) error {
446+ m.mu.RLock()
447+ containers := make(map[string]*runtime.Container)
448+ for domain, container := range m.containers {
449+ containers[domain] = container
450+ }
451+ m.mu.RUnlock()
452+
453+ if len(containers) == 0 {
454+ log.Info().Msg("No managed containers to stop")
455+ return nil
456+ }
457+
458+ log.Info().Int("count", len(containers)).Msg("Stopping all managed containers...")
459+
460+ var errors []error
461+ for domain, container := range containers {
462+ log.Info().Str("domain", domain).Str("container", container.ID).Msg("Stopping managed container")
463+
464+ if err := m.runtime.StopContainer(ctx, container.ID); err != nil {
465+ log.Error().Err(err).Str("domain", domain).Str("container", container.ID).Msg("Failed to stop managed container")
466+ errors = append(errors, fmt.Errorf("failed to stop container %s for domain %s: %w", container.ID, domain, err))
467+ continue
468+ }
469+
470+ // Remove from tracking
471+ m.mu.Lock()
472+ delete(m.containers, domain)
473+ m.mu.Unlock()
474+
475+ log.Info().Str("domain", domain).Str("container", container.ID).Msg("Managed container stopped successfully")
476+ }
477+
478+ if len(errors) > 0 {
479+ return fmt.Errorf("failed to stop %d containers: %v", len(errors), errors)
480+ }
481+
482+ log.Info().Msg("All managed containers stopped successfully")
483+ return nil
484+}
485+
295486 // HealthCheck checks the health of all managed containers
296487 func (m *Manager) HealthCheck(ctx context.Context) map[string]bool {
297488 m.mu.RLock()
@@ -292,6 +292,197 @@ func (m *Manager) SyncContainers(ctx context.Context) error {
292 return nil292 return nil
293 }293 }
294 294
295+// AutoStartContainers automatically starts containers that match configured routes
296+func (m *Manager) AutoStartContainers(ctx context.Context) error {
297+ routes := m.config.GetRoutes()
298+ if len(routes) == 0 {
299+ log.Info().Msg("No routes configured, skipping auto-start")
300+ return nil
301+ }
302+
303+ // Get all containers (including stopped ones) to check for matches
304+ allContainers, err := m.runtime.ListContainers(ctx, true)
305+ if err != nil {
306+ return fmt.Errorf("failed to list all containers: %w", err)
307+ }
308+
309+ // Create maps for quick lookup
310+ gordonManagedContainers := make(map[string]*runtime.Container) // domain -> container
311+ containersByImage := make(map[string][]*runtime.Container) // image -> containers
312+
313+ for _, container := range allContainers {
314+ // Track Gordon-managed containers by domain
315+ if container.Labels != nil && container.Labels["gordon.managed"] == "true" {
316+ if domain, exists := container.Labels["gordon.domain"]; exists {
317+ gordonManagedContainers[domain] = container
318+ }
319+ }
320+
321+ // Track all containers by image (normalize image name)
322+ normalizedImage := m.normalizeImageName(container.Image)
323+ containersByImage[normalizedImage] = append(containersByImage[normalizedImage], container)
324+ }
325+
326+ startedCount := 0
327+ for _, route := range routes {
328+ // Build the expected image reference
329+ expectedImage := route.Image
330+ if m.config.RegistryAuth.Enabled && m.config.Server.RegistryDomain != "" {
331+ if !strings.Contains(strings.Split(expectedImage, ":")[0], ".") {
332+ expectedImage = fmt.Sprintf("%s/%s", m.config.Server.RegistryDomain, route.Image)
333+ }
334+ }
335+
336+ // First, check if we already have a Gordon-managed container for this domain
337+ if existingContainer, exists := gordonManagedContainers[route.Domain]; exists {
338+ isRunning, err := m.runtime.IsContainerRunning(ctx, existingContainer.ID)
339+ if err != nil {
340+ log.Warn().Err(err).Str("domain", route.Domain).Str("container", existingContainer.ID).Msg("Failed to check container status")
341+ continue
342+ }
343+
344+ if isRunning {
345+ log.Info().Str("domain", route.Domain).Str("container", existingContainer.ID).Msg("Gordon-managed container already running")
346+ m.mu.Lock()
347+ m.containers[route.Domain] = existingContainer
348+ m.mu.Unlock()
349+ continue
350+ }
351+
352+ // Container exists but is stopped, start it
353+ log.Info().Str("domain", route.Domain).Str("container", existingContainer.ID).Msg("Starting existing Gordon-managed container")
354+ if err := m.runtime.StartContainer(ctx, existingContainer.ID); err != nil {
355+ log.Error().Err(err).Str("domain", route.Domain).Str("container", existingContainer.ID).Msg("Failed to start existing container")
356+ continue
357+ }
358+
359+ // Re-inspect to get updated information
360+ container, err := m.runtime.InspectContainer(ctx, existingContainer.ID)
361+ if err != nil {
362+ log.Error().Err(err).Str("container", existingContainer.ID).Msg("Failed to inspect started container")
363+ continue
364+ }
365+
366+ m.mu.Lock()
367+ m.containers[route.Domain] = container
368+ m.mu.Unlock()
369+ startedCount++
370+
371+ log.Info().Str("domain", route.Domain).Str("container", existingContainer.ID).Msg("Existing Gordon-managed container started successfully")
372+ continue
373+ }
374+
375+ // Second, check if there are existing containers with the same image and clean them up
376+ normalizedExpectedImage := m.normalizeImageName(expectedImage)
377+ if containers, exists := containersByImage[normalizedExpectedImage]; exists {
378+ // Stop and remove existing containers with the same image to avoid conflicts
379+ for _, container := range containers {
380+ // Skip Gordon-managed containers (already handled above)
381+ if container.Labels != nil && container.Labels["gordon.managed"] == "true" {
382+ continue
383+ }
384+
385+ log.Info().Str("domain", route.Domain).Str("container", container.ID).Str("image", container.Image).Msg("Stopping existing container with same image to avoid conflicts")
386+
387+ // Stop the container if it's running
388+ isRunning, err := m.runtime.IsContainerRunning(ctx, container.ID)
389+ if err != nil {
390+ log.Warn().Err(err).Str("container", container.ID).Msg("Failed to check container status")
391+ continue
392+ }
393+
394+ if isRunning {
395+ if err := m.runtime.StopContainer(ctx, container.ID); err != nil {
396+ log.Warn().Err(err).Str("container", container.ID).Msg("Failed to stop existing container")
397+ continue
398+ }
399+ }
400+
401+ // Remove the container to clean up
402+ if err := m.runtime.RemoveContainer(ctx, container.ID, true); err != nil {
403+ log.Warn().Err(err).Str("container", container.ID).Msg("Failed to remove existing container")
404+ } else {
405+ log.Info().Str("container", container.ID).Msg("Removed existing container with same image")
406+ }
407+ }
408+ }
409+
410+ // No existing container found, deploy a new one
411+ log.Info().Str("domain", route.Domain).Str("image", route.Image).Msg("Deploying new container for route")
412+
413+ _, err := m.DeployContainer(ctx, route)
414+ if err != nil {
415+ log.Error().Err(err).Str("domain", route.Domain).Str("image", route.Image).Msg("Failed to deploy container for route")
416+ continue
417+ }
418+ startedCount++
419+ }
420+
421+ log.Info().Int("started", startedCount).Int("total_routes", len(routes)).Msg("Auto-start completed")
422+ return nil
423+}
424+
425+// normalizeImageName normalizes image names for comparison
426+func (m *Manager) normalizeImageName(image string) string {
427+ // Remove tag if present, keep only repository name
428+ parts := strings.Split(image, ":")
429+ repo := parts[0]
430+
431+ // If no registry domain and it's a simple name, it's from Docker Hub
432+ if !strings.Contains(repo, "/") {
433+ return "docker.io/library/" + repo
434+ }
435+
436+ // If it has one slash and no domain, it's a user repo on Docker Hub
437+ if strings.Count(repo, "/") == 1 && !strings.Contains(strings.Split(repo, "/")[0], ".") {
438+ return "docker.io/" + repo
439+ }
440+
441+ return repo
442+}
443+
444+// StopAllManagedContainers stops all containers managed by Gordon
445+func (m *Manager) StopAllManagedContainers(ctx context.Context) error {
446+ m.mu.RLock()
447+ containers := make(map[string]*runtime.Container)
448+ for domain, container := range m.containers {
449+ containers[domain] = container
450+ }
451+ m.mu.RUnlock()
452+
453+ if len(containers) == 0 {
454+ log.Info().Msg("No managed containers to stop")
455+ return nil
456+ }
457+
458+ log.Info().Int("count", len(containers)).Msg("Stopping all managed containers...")
459+
460+ var errors []error
461+ for domain, container := range containers {
462+ log.Info().Str("domain", domain).Str("container", container.ID).Msg("Stopping managed container")
463+
464+ if err := m.runtime.StopContainer(ctx, container.ID); err != nil {
465+ log.Error().Err(err).Str("domain", domain).Str("container", container.ID).Msg("Failed to stop managed container")
466+ errors = append(errors, fmt.Errorf("failed to stop container %s for domain %s: %w", container.ID, domain, err))
467+ continue
468+ }
469+
470+ // Remove from tracking
471+ m.mu.Lock()
472+ delete(m.containers, domain)
473+ m.mu.Unlock()
474+
475+ log.Info().Str("domain", domain).Str("container", container.ID).Msg("Managed container stopped successfully")
476+ }
477+
478+ if len(errors) > 0 {
479+ return fmt.Errorf("failed to stop %d containers: %v", len(errors), errors)
480+ }
481+
482+ log.Info().Msg("All managed containers stopped successfully")
483+ return nil
484+}
485+
295 // HealthCheck checks the health of all managed containers486 // HealthCheck checks the health of all managed containers
296 func (m *Manager) HealthCheck(ctx context.Context) map[string]bool {487 func (m *Manager) HealthCheck(ctx context.Context) map[string]bool {
297 m.mu.RLock()488 m.mu.RLock()

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