detached 9b04dad

fix(reload): start only missing containers and add logs command

- Fix ManualReloadHandler to start containers for routes without running
  containers instead of restarting existing ones (100% uptime guarantee)
- Register ManualReloadHandler in event bus
- Add SIGUSR1 signal handler to trigger manual reload events
- Fix config hot-reload to publish EventConfigReload event
- Add `gordon logs` command with file and journalctl fallback
- Update reload command description to reflect new behavior
- Update unit tests for new ManualReloadHandler behavior
commit
9b04dadcfe2db91bd561181983e7766e6cf16cbc
author
brice · · 2026-01-13 21:21 UTC
committer
brice · · 2026-01-13 22:32 UTC
parent
0a59794
signature
unsigned
5 files · +300 -31
M internal/adapters/in/cli/root.go +31 -3
@@ -32,6 +32,7 @@ configuration rules, making it ideal for single-server deployments.`,
3232 // Add subcommands
3333 rootCmd.AddCommand(newStartCmd())
3434 rootCmd.AddCommand(newReloadCmd())
35+ rootCmd.AddCommand(newLogsCmd())
3536 rootCmd.AddCommand(newVersionCmd())
3637 rootCmd.AddCommand(newAuthCmd())
3738
@@ -61,9 +62,12 @@ func newStartCmd() *cobra.Command {
6162 func newReloadCmd() *cobra.Command {
6263 return &cobra.Command{
6364 Use: "reload",
64- Short: "Reload containers with updated environment",
65- Long: `Triggers a reload of all managed containers with updated environment
66-variables from their respective .env files.`,
65+ Short: "Start containers for configured routes",
66+ Long: `Starts containers for routes defined in config.toml that don't have
67+a running container. Running containers are never restarted to ensure 100% uptime.
68+
69+Use this command after editing config.toml to add new routes, or after pushing
70+images to the registry when the route was not yet configured.`,
6771 RunE: func(cmd *cobra.Command, args []string) error {
6872 return runReload()
6973 },
@@ -88,6 +92,30 @@ func runReload() error {
8892 return app.SendReloadSignal()
8993 }
9094
95+// newLogsCmd creates the logs command.
96+func newLogsCmd() *cobra.Command {
97+ var follow bool
98+ var lines int
99+ var configPath string
100+
101+ cmd := &cobra.Command{
102+ Use: "logs",
103+ Short: "Show Gordon process logs",
104+ Long: `Shows logs from the Gordon process. By default reads from the log file
105+configured in config.toml. If file logging is not enabled, falls back to
106+journalctl (if running as a systemd service).`,
107+ RunE: func(cmd *cobra.Command, args []string) error {
108+ return app.ShowLogs(configPath, follow, lines)
109+ },
110+ }
111+
112+ cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
113+ cmd.Flags().BoolVarP(&follow, "follow", "f", false, "Follow log output")
114+ cmd.Flags().IntVarP(&lines, "lines", "n", 50, "Number of lines to show")
115+
116+ return cmd
117+}
118+
91119 // SetVersionInfo sets the version information for the CLI.
92120 func SetVersionInfo(version, commit, date string) {
93121 Version = version
@@ -32,6 +32,7 @@ configuration rules, making it ideal for single-server deployments.`,
32 // Add subcommands32 // Add subcommands
33 rootCmd.AddCommand(newStartCmd())33 rootCmd.AddCommand(newStartCmd())
34 rootCmd.AddCommand(newReloadCmd())34 rootCmd.AddCommand(newReloadCmd())
35+ rootCmd.AddCommand(newLogsCmd())
35 rootCmd.AddCommand(newVersionCmd())36 rootCmd.AddCommand(newVersionCmd())
36 rootCmd.AddCommand(newAuthCmd())37 rootCmd.AddCommand(newAuthCmd())
37 38
@@ -61,9 +62,12 @@ func newStartCmd() *cobra.Command {
61 func newReloadCmd() *cobra.Command {62 func newReloadCmd() *cobra.Command {
62 return &cobra.Command{63 return &cobra.Command{
63 Use: "reload",64 Use: "reload",
64- Short: "Reload containers with updated environment",65+ Short: "Start containers for configured routes",
65- Long: `Triggers a reload of all managed containers with updated environment66+ Long: `Starts containers for routes defined in config.toml that don't have
66-variables from their respective .env files.`,67+a running container. Running containers are never restarted to ensure 100% uptime.
68+
69+Use this command after editing config.toml to add new routes, or after pushing
70+images to the registry when the route was not yet configured.`,
67 RunE: func(cmd *cobra.Command, args []string) error {71 RunE: func(cmd *cobra.Command, args []string) error {
68 return runReload()72 return runReload()
69 },73 },
@@ -88,6 +92,30 @@ func runReload() error {
88 return app.SendReloadSignal()92 return app.SendReloadSignal()
89 }93 }
90 94
95+// newLogsCmd creates the logs command.
96+func newLogsCmd() *cobra.Command {
97+ var follow bool
98+ var lines int
99+ var configPath string
100+
101+ cmd := &cobra.Command{
102+ Use: "logs",
103+ Short: "Show Gordon process logs",
104+ Long: `Shows logs from the Gordon process. By default reads from the log file
105+configured in config.toml. If file logging is not enabled, falls back to
106+journalctl (if running as a systemd service).`,
107+ RunE: func(cmd *cobra.Command, args []string) error {
108+ return app.ShowLogs(configPath, follow, lines)
109+ },
110+ }
111+
112+ cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
113+ cmd.Flags().BoolVarP(&follow, "follow", "f", false, "Follow log output")
114+ cmd.Flags().IntVarP(&lines, "lines", "n", 50, "Number of lines to show")
115+
116+ return cmd
117+}
118+
91 // SetVersionInfo sets the version information for the CLI.119 // SetVersionInfo sets the version information for the CLI.
92 func SetVersionInfo(version, commit, date string) {120 func SetVersionInfo(version, commit, date string) {
93 Version = version121 Version = version
A internal/app/logs.go +180 -0
@@ -0,0 +1,180 @@
1+// Package app provides the application initialization and wiring.
2+package app
3+
4+import (
5+ "bufio"
6+ "fmt"
7+ "os"
8+ "os/exec"
9+ "path/filepath"
10+ "time"
11+
12+ "github.com/spf13/viper"
13+)
14+
15+// ShowLogs displays Gordon process logs from file or journalctl.
16+func ShowLogs(configPath string, follow bool, lines int) error {
17+ // Try to find log file from config
18+ logPath, err := findLogFile(configPath)
19+ if err == nil && logPath != "" {
20+ return showFileLog(logPath, follow, lines)
21+ }
22+
23+ // Fallback to journalctl
24+ if hasJournalctl() {
25+ return showJournalctlLog(follow, lines)
26+ }
27+
28+ return fmt.Errorf("no logs available: enable file logging in config.toml or run gordon as a systemd service")
29+}
30+
31+// findLogFile looks for the configured log file path.
32+func findLogFile(configPath string) (string, error) {
33+ v := viper.New()
34+
35+ // Set defaults
36+ v.SetDefault("server.data_dir", DefaultDataDir())
37+ v.SetDefault("logging.file.enabled", false)
38+ v.SetDefault("logging.file.path", "")
39+
40+ // Try to load config if path provided
41+ if configPath != "" {
42+ v.SetConfigFile(configPath)
43+ if err := v.ReadInConfig(); err != nil {
44+ return "", fmt.Errorf("failed to read config: %w", err)
45+ }
46+ } else {
47+ // Try default config locations
48+ v.SetConfigName("config")
49+ v.SetConfigType("toml")
50+ v.AddConfigPath(".")
51+ v.AddConfigPath(DefaultDataDir())
52+ v.AddConfigPath("/etc/gordon")
53+ _ = v.ReadInConfig() // Ignore error, we'll check if logging is enabled
54+ }
55+
56+ if !v.GetBool("logging.file.enabled") {
57+ return "", nil
58+ }
59+
60+ logPath := v.GetString("logging.file.path")
61+ if logPath == "" {
62+ return "", nil
63+ }
64+
65+ // Make path absolute if relative
66+ if !filepath.IsAbs(logPath) {
67+ dataDir := v.GetString("server.data_dir")
68+ logPath = filepath.Join(dataDir, logPath)
69+ }
70+
71+ // Check if file exists
72+ if _, err := os.Stat(logPath); err != nil {
73+ return "", fmt.Errorf("log file not found: %s", logPath)
74+ }
75+
76+ return logPath, nil
77+}
78+
79+// showFileLog displays logs from a file.
80+func showFileLog(logPath string, follow bool, lines int) error {
81+ // First show the last N lines
82+ if err := tailFile(logPath, lines); err != nil {
83+ return err
84+ }
85+
86+ if !follow {
87+ return nil
88+ }
89+
90+ // Follow mode: watch for new lines
91+ return followFile(logPath)
92+}
93+
94+// tailFile reads the last N lines from a file.
95+func tailFile(path string, lines int) error {
96+ file, err := os.Open(path)
97+ if err != nil {
98+ return fmt.Errorf("failed to open log file: %w", err)
99+ }
100+ defer file.Close()
101+
102+ // Read all lines into a buffer
103+ var allLines []string
104+ scanner := bufio.NewScanner(file)
105+ for scanner.Scan() {
106+ allLines = append(allLines, scanner.Text())
107+ }
108+ if err := scanner.Err(); err != nil {
109+ return fmt.Errorf("failed to read log file: %w", err)
110+ }
111+
112+ // Print last N lines
113+ start := 0
114+ if len(allLines) > lines {
115+ start = len(allLines) - lines
116+ }
117+ for _, line := range allLines[start:] {
118+ fmt.Println(line)
119+ }
120+
121+ return nil
122+}
123+
124+// followFile watches a file for new content and prints it.
125+func followFile(path string) error {
126+ file, err := os.Open(path)
127+ if err != nil {
128+ return fmt.Errorf("failed to open log file: %w", err)
129+ }
130+ defer file.Close()
131+
132+ // Seek to end of file
133+ _, err = file.Seek(0, 2)
134+ if err != nil {
135+ return fmt.Errorf("failed to seek to end of file: %w", err)
136+ }
137+
138+ reader := bufio.NewReader(file)
139+
140+ // Poll for new content
141+ for {
142+ line, err := reader.ReadString('\n')
143+ if err != nil {
144+ // No new content, wait and retry
145+ time.Sleep(100 * time.Millisecond)
146+ continue
147+ }
148+ fmt.Print(line)
149+ }
150+}
151+
152+// hasJournalctl checks if journalctl is available.
153+func hasJournalctl() bool {
154+ _, err := exec.LookPath("journalctl")
155+ return err == nil
156+}
157+
158+// showJournalctlLog displays logs from journalctl.
159+func showJournalctlLog(follow bool, lines int) error {
160+ args := []string{"-u", "gordon", "-n", fmt.Sprintf("%d", lines), "--no-pager"}
161+ if follow {
162+ args = append(args, "-f")
163+ }
164+
165+ // #nosec G204 -- args are constructed internally with controlled values
166+ cmd := exec.Command("journalctl", args...)
167+ cmd.Stdout = os.Stdout
168+ cmd.Stderr = os.Stderr
169+
170+ err := cmd.Run()
171+ if err != nil {
172+ // Check if it's because the service doesn't exist
173+ if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() != 0 {
174+ return fmt.Errorf("gordon service not found in journalctl: enable file logging in config.toml or run gordon as a systemd service")
175+ }
176+ return fmt.Errorf("failed to run journalctl: %w", err)
177+ }
178+
179+ return nil
180+}
@@ -0,0 +1,180 @@
1+// Package app provides the application initialization and wiring.
2+package app
3+
4+import (
5+ "bufio"
6+ "fmt"
7+ "os"
8+ "os/exec"
9+ "path/filepath"
10+ "time"
11+
12+ "github.com/spf13/viper"
13+)
14+
15+// ShowLogs displays Gordon process logs from file or journalctl.
16+func ShowLogs(configPath string, follow bool, lines int) error {
17+ // Try to find log file from config
18+ logPath, err := findLogFile(configPath)
19+ if err == nil && logPath != "" {
20+ return showFileLog(logPath, follow, lines)
21+ }
22+
23+ // Fallback to journalctl
24+ if hasJournalctl() {
25+ return showJournalctlLog(follow, lines)
26+ }
27+
28+ return fmt.Errorf("no logs available: enable file logging in config.toml or run gordon as a systemd service")
29+}
30+
31+// findLogFile looks for the configured log file path.
32+func findLogFile(configPath string) (string, error) {
33+ v := viper.New()
34+
35+ // Set defaults
36+ v.SetDefault("server.data_dir", DefaultDataDir())
37+ v.SetDefault("logging.file.enabled", false)
38+ v.SetDefault("logging.file.path", "")
39+
40+ // Try to load config if path provided
41+ if configPath != "" {
42+ v.SetConfigFile(configPath)
43+ if err := v.ReadInConfig(); err != nil {
44+ return "", fmt.Errorf("failed to read config: %w", err)
45+ }
46+ } else {
47+ // Try default config locations
48+ v.SetConfigName("config")
49+ v.SetConfigType("toml")
50+ v.AddConfigPath(".")
51+ v.AddConfigPath(DefaultDataDir())
52+ v.AddConfigPath("/etc/gordon")
53+ _ = v.ReadInConfig() // Ignore error, we'll check if logging is enabled
54+ }
55+
56+ if !v.GetBool("logging.file.enabled") {
57+ return "", nil
58+ }
59+
60+ logPath := v.GetString("logging.file.path")
61+ if logPath == "" {
62+ return "", nil
63+ }
64+
65+ // Make path absolute if relative
66+ if !filepath.IsAbs(logPath) {
67+ dataDir := v.GetString("server.data_dir")
68+ logPath = filepath.Join(dataDir, logPath)
69+ }
70+
71+ // Check if file exists
72+ if _, err := os.Stat(logPath); err != nil {
73+ return "", fmt.Errorf("log file not found: %s", logPath)
74+ }
75+
76+ return logPath, nil
77+}
78+
79+// showFileLog displays logs from a file.
80+func showFileLog(logPath string, follow bool, lines int) error {
81+ // First show the last N lines
82+ if err := tailFile(logPath, lines); err != nil {
83+ return err
84+ }
85+
86+ if !follow {
87+ return nil
88+ }
89+
90+ // Follow mode: watch for new lines
91+ return followFile(logPath)
92+}
93+
94+// tailFile reads the last N lines from a file.
95+func tailFile(path string, lines int) error {
96+ file, err := os.Open(path)
97+ if err != nil {
98+ return fmt.Errorf("failed to open log file: %w", err)
99+ }
100+ defer file.Close()
101+
102+ // Read all lines into a buffer
103+ var allLines []string
104+ scanner := bufio.NewScanner(file)
105+ for scanner.Scan() {
106+ allLines = append(allLines, scanner.Text())
107+ }
108+ if err := scanner.Err(); err != nil {
109+ return fmt.Errorf("failed to read log file: %w", err)
110+ }
111+
112+ // Print last N lines
113+ start := 0
114+ if len(allLines) > lines {
115+ start = len(allLines) - lines
116+ }
117+ for _, line := range allLines[start:] {
118+ fmt.Println(line)
119+ }
120+
121+ return nil
122+}
123+
124+// followFile watches a file for new content and prints it.
125+func followFile(path string) error {
126+ file, err := os.Open(path)
127+ if err != nil {
128+ return fmt.Errorf("failed to open log file: %w", err)
129+ }
130+ defer file.Close()
131+
132+ // Seek to end of file
133+ _, err = file.Seek(0, 2)
134+ if err != nil {
135+ return fmt.Errorf("failed to seek to end of file: %w", err)
136+ }
137+
138+ reader := bufio.NewReader(file)
139+
140+ // Poll for new content
141+ for {
142+ line, err := reader.ReadString('\n')
143+ if err != nil {
144+ // No new content, wait and retry
145+ time.Sleep(100 * time.Millisecond)
146+ continue
147+ }
148+ fmt.Print(line)
149+ }
150+}
151+
152+// hasJournalctl checks if journalctl is available.
153+func hasJournalctl() bool {
154+ _, err := exec.LookPath("journalctl")
155+ return err == nil
156+}
157+
158+// showJournalctlLog displays logs from journalctl.
159+func showJournalctlLog(follow bool, lines int) error {
160+ args := []string{"-u", "gordon", "-n", fmt.Sprintf("%d", lines), "--no-pager"}
161+ if follow {
162+ args = append(args, "-f")
163+ }
164+
165+ // #nosec G204 -- args are constructed internally with controlled values
166+ cmd := exec.Command("journalctl", args...)
167+ cmd.Stdout = os.Stdout
168+ cmd.Stderr = os.Stderr
169+
170+ err := cmd.Run()
171+ if err != nil {
172+ // Check if it's because the service doesn't exist
173+ if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() != 0 {
174+ return fmt.Errorf("gordon service not found in journalctl: enable file logging in config.toml or run gordon as a systemd service")
175+ }
176+ return fmt.Errorf("failed to run journalctl: %w", err)
177+ }
178+
179+ return nil
180+}
M internal/app/run.go +31 -7
@@ -163,7 +163,7 @@ func Run(ctx context.Context, configPath string) error {
163163 registryHandler, proxyHandler := createHTTPHandlers(svc, cfg, log)
164164
165165 // Start servers and wait for shutdown
166- return runServers(ctx, cfg, registryHandler, proxyHandler, svc.containerSvc, log)
166+ return runServers(ctx, cfg, registryHandler, proxyHandler, svc.containerSvc, svc.eventBus, log)
167167 }
168168
169169 // initConfig loads configuration from file.
@@ -601,6 +601,11 @@ func registerEventHandlers(ctx context.Context, svc *services) error {
601601 return fmt.Errorf("failed to subscribe config reload handler: %w", err)
602602 }
603603
604+ manualReloadHandler := container.NewManualReloadHandler(ctx, svc.containerSvc, svc.configSvc)
605+ if err := svc.eventBus.Subscribe(manualReloadHandler); err != nil {
606+ return fmt.Errorf("failed to subscribe manual reload handler: %w", err)
607+ }
608+
604609 // Proxy cache invalidation on container deployment (for zero-downtime)
605610 containerDeployedHandler := proxy.NewContainerDeployedHandler(ctx, svc.proxySvc)
606611 if err := svc.eventBus.Subscribe(containerDeployedHandler); err != nil {
@@ -629,6 +634,11 @@ func setupConfigHotReload(ctx context.Context, v *viper.Viper, svc *services, lo
629634 RegistryDomain: v.GetString("server.registry_domain"),
630635 RegistryPort: v.GetInt("server.registry_port"),
631636 })
637+
638+ // Publish config reload event to trigger container updates
639+ if err := svc.eventBus.Publish(domain.EventConfigReload, nil); err != nil {
640+ log.Error().Err(err).Msg("failed to publish config reload event")
641+ }
632642 })
633643 v.WatchConfig()
634644 }
@@ -691,10 +701,15 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
691701 }
692702
693703 // runServers starts the HTTP servers and waits for shutdown.
694-func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler http.Handler, containerSvc *container.Service, log zerowrap.Logger) error {
704+func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler http.Handler, containerSvc *container.Service, eventBus out.EventBus, log zerowrap.Logger) error {
695705 ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
696706 defer cancel()
697707
708+ // Set up SIGUSR1 for reload
709+ reloadChan := make(chan os.Signal, 1)
710+ signal.Notify(reloadChan, syscall.SIGUSR1)
711+ defer signal.Stop(reloadChan)
712+
698713 errChan := make(chan error, 2)
699714
700715 go startServer(fmt.Sprintf(":%d", cfg.Server.RegistryPort), registryHandler, "registry", errChan, log)
@@ -705,13 +720,22 @@ func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler h
705720 Int("registry_port", cfg.Server.RegistryPort).
706721 Msg("Gordon is running")
707722
708- select {
709- case err := <-errChan:
710- return err
711- case <-ctx.Done():
712- log.Info().Msg("shutdown signal received")
723+ for {
724+ select {
725+ case err := <-errChan:
726+ return err
727+ case <-reloadChan:
728+ log.Info().Msg("reload signal received (SIGUSR1)")
729+ if err := eventBus.Publish(domain.EventManualReload, nil); err != nil {
730+ log.Error().Err(err).Msg("failed to publish manual reload event")
731+ }
732+ case <-ctx.Done():
733+ log.Info().Msg("shutdown signal received")
734+ goto shutdown
735+ }
713736 }
714737
738+shutdown:
715739 log.Info().Msg("shutting down Gordon...")
716740
717741 if err := containerSvc.Shutdown(ctx); err != nil {
@@ -163,7 +163,7 @@ func Run(ctx context.Context, configPath string) error {
163 registryHandler, proxyHandler := createHTTPHandlers(svc, cfg, log)163 registryHandler, proxyHandler := createHTTPHandlers(svc, cfg, log)
164 164
165 // Start servers and wait for shutdown165 // Start servers and wait for shutdown
166- return runServers(ctx, cfg, registryHandler, proxyHandler, svc.containerSvc, log)166+ return runServers(ctx, cfg, registryHandler, proxyHandler, svc.containerSvc, svc.eventBus, log)
167 }167 }
168 168
169 // initConfig loads configuration from file.169 // initConfig loads configuration from file.
@@ -601,6 +601,11 @@ func registerEventHandlers(ctx context.Context, svc *services) error {
601 return fmt.Errorf("failed to subscribe config reload handler: %w", err)601 return fmt.Errorf("failed to subscribe config reload handler: %w", err)
602 }602 }
603 603
604+ manualReloadHandler := container.NewManualReloadHandler(ctx, svc.containerSvc, svc.configSvc)
605+ if err := svc.eventBus.Subscribe(manualReloadHandler); err != nil {
606+ return fmt.Errorf("failed to subscribe manual reload handler: %w", err)
607+ }
608+
604 // Proxy cache invalidation on container deployment (for zero-downtime)609 // Proxy cache invalidation on container deployment (for zero-downtime)
605 containerDeployedHandler := proxy.NewContainerDeployedHandler(ctx, svc.proxySvc)610 containerDeployedHandler := proxy.NewContainerDeployedHandler(ctx, svc.proxySvc)
606 if err := svc.eventBus.Subscribe(containerDeployedHandler); err != nil {611 if err := svc.eventBus.Subscribe(containerDeployedHandler); err != nil {
@@ -629,6 +634,11 @@ func setupConfigHotReload(ctx context.Context, v *viper.Viper, svc *services, lo
629 RegistryDomain: v.GetString("server.registry_domain"),634 RegistryDomain: v.GetString("server.registry_domain"),
630 RegistryPort: v.GetInt("server.registry_port"),635 RegistryPort: v.GetInt("server.registry_port"),
631 })636 })
637+
638+ // Publish config reload event to trigger container updates
639+ if err := svc.eventBus.Publish(domain.EventConfigReload, nil); err != nil {
640+ log.Error().Err(err).Msg("failed to publish config reload event")
641+ }
632 })642 })
633 v.WatchConfig()643 v.WatchConfig()
634 }644 }
@@ -691,10 +701,15 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
691 }701 }
692 702
693 // runServers starts the HTTP servers and waits for shutdown.703 // runServers starts the HTTP servers and waits for shutdown.
694-func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler http.Handler, containerSvc *container.Service, log zerowrap.Logger) error {704+func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler http.Handler, containerSvc *container.Service, eventBus out.EventBus, log zerowrap.Logger) error {
695 ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)705 ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
696 defer cancel()706 defer cancel()
697 707
708+ // Set up SIGUSR1 for reload
709+ reloadChan := make(chan os.Signal, 1)
710+ signal.Notify(reloadChan, syscall.SIGUSR1)
711+ defer signal.Stop(reloadChan)
712+
698 errChan := make(chan error, 2)713 errChan := make(chan error, 2)
699 714
700 go startServer(fmt.Sprintf(":%d", cfg.Server.RegistryPort), registryHandler, "registry", errChan, log)715 go startServer(fmt.Sprintf(":%d", cfg.Server.RegistryPort), registryHandler, "registry", errChan, log)
@@ -705,13 +720,22 @@ func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler h
705 Int("registry_port", cfg.Server.RegistryPort).720 Int("registry_port", cfg.Server.RegistryPort).
706 Msg("Gordon is running")721 Msg("Gordon is running")
707 722
708- select {723+ for {
709- case err := <-errChan:724+ select {
710- return err725+ case err := <-errChan:
711- case <-ctx.Done():726+ return err
712- log.Info().Msg("shutdown signal received")727+ case <-reloadChan:
728+ log.Info().Msg("reload signal received (SIGUSR1)")
729+ if err := eventBus.Publish(domain.EventManualReload, nil); err != nil {
730+ log.Error().Err(err).Msg("failed to publish manual reload event")
731+ }
732+ case <-ctx.Done():
733+ log.Info().Msg("shutdown signal received")
734+ goto shutdown
735+ }
713 }736 }
714 737
738+shutdown:
715 log.Info().Msg("shutting down Gordon...")739 log.Info().Msg("shutting down Gordon...")
716 740
717 if err := containerSvc.Shutdown(ctx); err != nil {741 if err := containerSvc.Shutdown(ctx); err != nil {
M internal/usecase/container/events.go +16 -10
@@ -210,6 +210,8 @@ func NewManualReloadHandler(ctx context.Context, containerSvc in.ContainerServic
210210 }
211211
212212 // Handle handles an event.
213+// It starts containers for configured routes that don't have a running container.
214+// Running containers are NEVER restarted to ensure 100% uptime.
213215 func (h *ManualReloadHandler) Handle(event domain.Event) error {
214216 ctx := zerowrap.CtxWithFields(h.ctx, map[string]any{
215217 zerowrap.FieldLayer: "usecase",
@@ -219,43 +221,47 @@ func (h *ManualReloadHandler) Handle(event domain.Event) error {
219221 })
220222 log := zerowrap.FromCtx(ctx)
221223
222- log.Info().Msg("processing manual reload event - redeploying all containers")
224+ log.Info().Msg("processing manual reload event - starting missing containers")
223225
224226 routes := h.configSvc.GetRoutes(ctx)
225- redeployedCount := 0
227+ startedCount := 0
228+ skippedCount := 0
226229 errorCount := 0
227230
228231 for _, route := range routes {
229- if _, exists := h.containerSvc.Get(ctx, route.Domain); !exists {
230- log.Debug().Str("domain", route.Domain).Msg("no container found for route, skipping")
232+ if _, exists := h.containerSvc.Get(ctx, route.Domain); exists {
233+ log.Debug().Str("domain", route.Domain).Msg("container already running, skipping")
234+ skippedCount++
231235 continue
232236 }
233237
234238 log.Info().
235239 Str("domain", route.Domain).
236240 Str("image", route.Image).
237- Msg("redeploying container with updated environment")
241+ Msg("starting container for route")
238242
239243 if _, err := h.containerSvc.Deploy(ctx, route); err != nil {
240- log.WrapErrWithFields(err, "failed to redeploy container", map[string]any{"domain": route.Domain})
244+ log.WrapErrWithFields(err, "failed to start container", map[string]any{"domain": route.Domain})
241245 errorCount++
242246 continue
243247 }
244248
245- redeployedCount++
246- log.Info().Str("domain", route.Domain).Msg("container redeployed successfully")
249+ startedCount++
250+ log.Info().Str("domain", route.Domain).Msg("container started successfully")
247251 }
248252
249253 if errorCount > 0 {
250254 log.Warn().
251- Int("redeployed", redeployedCount).
255+ Int("started", startedCount).
256+ Int("skipped", skippedCount).
252257 Int("errors", errorCount).
253258 Msg("manual reload completed with some errors")
254259 return fmt.Errorf("manual reload completed with %d errors", errorCount)
255260 }
256261
257262 log.Info().
258- Int("redeployed", redeployedCount).
263+ Int("started", startedCount).
264+ Int("skipped", skippedCount).
259265 Int("total", len(routes)).
260266 Msg("manual reload completed successfully")
261267 return nil
@@ -210,6 +210,8 @@ func NewManualReloadHandler(ctx context.Context, containerSvc in.ContainerServic
210 }210 }
211 211
212 // Handle handles an event.212 // Handle handles an event.
213+// It starts containers for configured routes that don't have a running container.
214+// Running containers are NEVER restarted to ensure 100% uptime.
213 func (h *ManualReloadHandler) Handle(event domain.Event) error {215 func (h *ManualReloadHandler) Handle(event domain.Event) error {
214 ctx := zerowrap.CtxWithFields(h.ctx, map[string]any{216 ctx := zerowrap.CtxWithFields(h.ctx, map[string]any{
215 zerowrap.FieldLayer: "usecase",217 zerowrap.FieldLayer: "usecase",
@@ -219,43 +221,47 @@ func (h *ManualReloadHandler) Handle(event domain.Event) error {
219 })221 })
220 log := zerowrap.FromCtx(ctx)222 log := zerowrap.FromCtx(ctx)
221 223
222- log.Info().Msg("processing manual reload event - redeploying all containers")224+ log.Info().Msg("processing manual reload event - starting missing containers")
223 225
224 routes := h.configSvc.GetRoutes(ctx)226 routes := h.configSvc.GetRoutes(ctx)
225- redeployedCount := 0227+ startedCount := 0
228+ skippedCount := 0
226 errorCount := 0229 errorCount := 0
227 230
228 for _, route := range routes {231 for _, route := range routes {
229- if _, exists := h.containerSvc.Get(ctx, route.Domain); !exists {232+ if _, exists := h.containerSvc.Get(ctx, route.Domain); exists {
230- log.Debug().Str("domain", route.Domain).Msg("no container found for route, skipping")233+ log.Debug().Str("domain", route.Domain).Msg("container already running, skipping")
234+ skippedCount++
231 continue235 continue
232 }236 }
233 237
234 log.Info().238 log.Info().
235 Str("domain", route.Domain).239 Str("domain", route.Domain).
236 Str("image", route.Image).240 Str("image", route.Image).
237- Msg("redeploying container with updated environment")241+ Msg("starting container for route")
238 242
239 if _, err := h.containerSvc.Deploy(ctx, route); err != nil {243 if _, err := h.containerSvc.Deploy(ctx, route); err != nil {
240- log.WrapErrWithFields(err, "failed to redeploy container", map[string]any{"domain": route.Domain})244+ log.WrapErrWithFields(err, "failed to start container", map[string]any{"domain": route.Domain})
241 errorCount++245 errorCount++
242 continue246 continue
243 }247 }
244 248
245- redeployedCount++249+ startedCount++
246- log.Info().Str("domain", route.Domain).Msg("container redeployed successfully")250+ log.Info().Str("domain", route.Domain).Msg("container started successfully")
247 }251 }
248 252
249 if errorCount > 0 {253 if errorCount > 0 {
250 log.Warn().254 log.Warn().
251- Int("redeployed", redeployedCount).255+ Int("started", startedCount).
256+ Int("skipped", skippedCount).
252 Int("errors", errorCount).257 Int("errors", errorCount).
253 Msg("manual reload completed with some errors")258 Msg("manual reload completed with some errors")
254 return fmt.Errorf("manual reload completed with %d errors", errorCount)259 return fmt.Errorf("manual reload completed with %d errors", errorCount)
255 }260 }
256 261
257 log.Info().262 log.Info().
258- Int("redeployed", redeployedCount).263+ Int("started", startedCount).
264+ Int("skipped", skippedCount).
259 Int("total", len(routes)).265 Int("total", len(routes)).
260 Msg("manual reload completed successfully")266 Msg("manual reload completed successfully")
261 return nil267 return nil
M internal/usecase/container/events_test.go +42 -11
@@ -355,7 +355,7 @@ func TestManualReloadHandler_CanHandle(t *testing.T) {
355355 assert.False(t, handler.CanHandle(domain.EventConfigReload))
356356 }
357357
358-func TestManualReloadHandler_Handle_RedeploysExistingContainers(t *testing.T) {
358+func TestManualReloadHandler_Handle_StartsOnlyMissingContainers(t *testing.T) {
359359 containerSvc := inmocks.NewMockContainerService(t)
360360 configSvc := inmocks.NewMockConfigService(t)
361361
@@ -367,15 +367,15 @@ func TestManualReloadHandler_Handle_RedeploysExistingContainers(t *testing.T) {
367367 {Domain: "app2.example.com", Image: "app2:latest"},
368368 })
369369
370- // Only app1 has a running container
370+ // app1 has a running container, app2 does not
371371 containerSvc.EXPECT().Get(mock.Anything, "app1.example.com").Return(&domain.Container{ID: "container-1"}, true)
372372 containerSvc.EXPECT().Get(mock.Anything, "app2.example.com").Return(nil, false)
373373
374- // Only app1 should be redeployed (app2 has no container)
374+ // Only app2 should be deployed (app1 is already running - never restart healthy containers)
375375 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
376- Domain: "app1.example.com",
377- Image: "app1:latest",
378- }).Return(&domain.Container{ID: "container-1-new"}, nil)
376+ Domain: "app2.example.com",
377+ Image: "app2:latest",
378+ }).Return(&domain.Container{ID: "container-2"}, nil)
379379
380380 event := domain.Event{
381381 ID: "event-123",
@@ -387,7 +387,7 @@ func TestManualReloadHandler_Handle_RedeploysExistingContainers(t *testing.T) {
387387 assert.NoError(t, err)
388388 }
389389
390-func TestManualReloadHandler_Handle_NoContainersRunning(t *testing.T) {
390+func TestManualReloadHandler_Handle_StartsMissingContainer(t *testing.T) {
391391 containerSvc := inmocks.NewMockContainerService(t)
392392 configSvc := inmocks.NewMockConfigService(t)
393393
@@ -399,7 +399,11 @@ func TestManualReloadHandler_Handle_NoContainersRunning(t *testing.T) {
399399
400400 containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(nil, false)
401401
402- // No Deploy calls - no container exists to reload
402+ // Deploy should be called to start the missing container
403+ containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
404+ Domain: "app.example.com",
405+ Image: "app:latest",
406+ }).Return(&domain.Container{ID: "container-1"}, nil)
403407
404408 event := domain.Event{
405409 ID: "event-123",
@@ -422,9 +426,11 @@ func TestManualReloadHandler_Handle_DeployErrors(t *testing.T) {
422426 {Domain: "app2.example.com", Image: "app2:latest"},
423427 })
424428
425- containerSvc.EXPECT().Get(mock.Anything, "app1.example.com").Return(&domain.Container{ID: "container-1"}, true)
426- containerSvc.EXPECT().Get(mock.Anything, "app2.example.com").Return(&domain.Container{ID: "container-2"}, true)
429+ // Both containers are missing
430+ containerSvc.EXPECT().Get(mock.Anything, "app1.example.com").Return(nil, false)
431+ containerSvc.EXPECT().Get(mock.Anything, "app2.example.com").Return(nil, false)
427432
433+ // app1 fails to deploy, app2 succeeds
428434 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
429435 Domain: "app1.example.com",
430436 Image: "app1:latest",
@@ -433,7 +439,7 @@ func TestManualReloadHandler_Handle_DeployErrors(t *testing.T) {
433439 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
434440 Domain: "app2.example.com",
435441 Image: "app2:latest",
436- }).Return(&domain.Container{ID: "container-2-new"}, nil)
442+ }).Return(&domain.Container{ID: "container-2"}, nil)
437443
438444 event := domain.Event{
439445 ID: "event-123",
@@ -446,3 +452,28 @@ func TestManualReloadHandler_Handle_DeployErrors(t *testing.T) {
446452 assert.Error(t, err)
447453 assert.Contains(t, err.Error(), "1 errors")
448454 }
455+
456+func TestManualReloadHandler_Handle_DoesNotRestartRunningContainers(t *testing.T) {
457+ containerSvc := inmocks.NewMockContainerService(t)
458+ configSvc := inmocks.NewMockConfigService(t)
459+
460+ handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc)
461+
462+ // Route with running container
463+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
464+ {Domain: "app.example.com", Image: "app:latest"},
465+ })
466+
467+ containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(&domain.Container{ID: "container-1"}, true)
468+
469+ // NO Deploy call expected - container is already running, never restart healthy containers
470+
471+ event := domain.Event{
472+ ID: "event-123",
473+ Type: domain.EventManualReload,
474+ }
475+
476+ err := handler.Handle(event)
477+
478+ assert.NoError(t, err)
479+}
@@ -355,7 +355,7 @@ func TestManualReloadHandler_CanHandle(t *testing.T) {
355 assert.False(t, handler.CanHandle(domain.EventConfigReload))355 assert.False(t, handler.CanHandle(domain.EventConfigReload))
356 }356 }
357 357
358-func TestManualReloadHandler_Handle_RedeploysExistingContainers(t *testing.T) {358+func TestManualReloadHandler_Handle_StartsOnlyMissingContainers(t *testing.T) {
359 containerSvc := inmocks.NewMockContainerService(t)359 containerSvc := inmocks.NewMockContainerService(t)
360 configSvc := inmocks.NewMockConfigService(t)360 configSvc := inmocks.NewMockConfigService(t)
361 361
@@ -367,15 +367,15 @@ func TestManualReloadHandler_Handle_RedeploysExistingContainers(t *testing.T) {
367 {Domain: "app2.example.com", Image: "app2:latest"},367 {Domain: "app2.example.com", Image: "app2:latest"},
368 })368 })
369 369
370- // Only app1 has a running container370+ // app1 has a running container, app2 does not
371 containerSvc.EXPECT().Get(mock.Anything, "app1.example.com").Return(&domain.Container{ID: "container-1"}, true)371 containerSvc.EXPECT().Get(mock.Anything, "app1.example.com").Return(&domain.Container{ID: "container-1"}, true)
372 containerSvc.EXPECT().Get(mock.Anything, "app2.example.com").Return(nil, false)372 containerSvc.EXPECT().Get(mock.Anything, "app2.example.com").Return(nil, false)
373 373
374- // Only app1 should be redeployed (app2 has no container)374+ // Only app2 should be deployed (app1 is already running - never restart healthy containers)
375 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{375 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
376- Domain: "app1.example.com",376+ Domain: "app2.example.com",
377- Image: "app1:latest",377+ Image: "app2:latest",
378- }).Return(&domain.Container{ID: "container-1-new"}, nil)378+ }).Return(&domain.Container{ID: "container-2"}, nil)
379 379
380 event := domain.Event{380 event := domain.Event{
381 ID: "event-123",381 ID: "event-123",
@@ -387,7 +387,7 @@ func TestManualReloadHandler_Handle_RedeploysExistingContainers(t *testing.T) {
387 assert.NoError(t, err)387 assert.NoError(t, err)
388 }388 }
389 389
390-func TestManualReloadHandler_Handle_NoContainersRunning(t *testing.T) {390+func TestManualReloadHandler_Handle_StartsMissingContainer(t *testing.T) {
391 containerSvc := inmocks.NewMockContainerService(t)391 containerSvc := inmocks.NewMockContainerService(t)
392 configSvc := inmocks.NewMockConfigService(t)392 configSvc := inmocks.NewMockConfigService(t)
393 393
@@ -399,7 +399,11 @@ func TestManualReloadHandler_Handle_NoContainersRunning(t *testing.T) {
399 399
400 containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(nil, false)400 containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(nil, false)
401 401
402- // No Deploy calls - no container exists to reload402+ // Deploy should be called to start the missing container
403+ containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
404+ Domain: "app.example.com",
405+ Image: "app:latest",
406+ }).Return(&domain.Container{ID: "container-1"}, nil)
403 407
404 event := domain.Event{408 event := domain.Event{
405 ID: "event-123",409 ID: "event-123",
@@ -422,9 +426,11 @@ func TestManualReloadHandler_Handle_DeployErrors(t *testing.T) {
422 {Domain: "app2.example.com", Image: "app2:latest"},426 {Domain: "app2.example.com", Image: "app2:latest"},
423 })427 })
424 428
425- containerSvc.EXPECT().Get(mock.Anything, "app1.example.com").Return(&domain.Container{ID: "container-1"}, true)429+ // Both containers are missing
426- containerSvc.EXPECT().Get(mock.Anything, "app2.example.com").Return(&domain.Container{ID: "container-2"}, true)430+ containerSvc.EXPECT().Get(mock.Anything, "app1.example.com").Return(nil, false)
431+ containerSvc.EXPECT().Get(mock.Anything, "app2.example.com").Return(nil, false)
427 432
433+ // app1 fails to deploy, app2 succeeds
428 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{434 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
429 Domain: "app1.example.com",435 Domain: "app1.example.com",
430 Image: "app1:latest",436 Image: "app1:latest",
@@ -433,7 +439,7 @@ func TestManualReloadHandler_Handle_DeployErrors(t *testing.T) {
433 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{439 containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
434 Domain: "app2.example.com",440 Domain: "app2.example.com",
435 Image: "app2:latest",441 Image: "app2:latest",
436- }).Return(&domain.Container{ID: "container-2-new"}, nil)442+ }).Return(&domain.Container{ID: "container-2"}, nil)
437 443
438 event := domain.Event{444 event := domain.Event{
439 ID: "event-123",445 ID: "event-123",
@@ -446,3 +452,28 @@ func TestManualReloadHandler_Handle_DeployErrors(t *testing.T) {
446 assert.Error(t, err)452 assert.Error(t, err)
447 assert.Contains(t, err.Error(), "1 errors")453 assert.Contains(t, err.Error(), "1 errors")
448 }454 }
455+
456+func TestManualReloadHandler_Handle_DoesNotRestartRunningContainers(t *testing.T) {
457+ containerSvc := inmocks.NewMockContainerService(t)
458+ configSvc := inmocks.NewMockConfigService(t)
459+
460+ handler := NewManualReloadHandler(testCtx(), containerSvc, configSvc)
461+
462+ // Route with running container
463+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
464+ {Domain: "app.example.com", Image: "app:latest"},
465+ })
466+
467+ containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(&domain.Container{ID: "container-1"}, true)
468+
469+ // NO Deploy call expected - container is already running, never restart healthy containers
470+
471+ event := domain.Event{
472+ ID: "event-123",
473+ Type: domain.EventManualReload,
474+ }
475+
476+ err := handler.Handle(event)
477+
478+ assert.NoError(t, err)
479+}

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