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.`, | |||
| 32 | 32 | // Add subcommands | |
| 33 | 33 | rootCmd.AddCommand(newStartCmd()) | |
| 34 | 34 | rootCmd.AddCommand(newReloadCmd()) | |
| 35 | + | rootCmd.AddCommand(newLogsCmd()) | |
| 35 | 36 | rootCmd.AddCommand(newVersionCmd()) | |
| 36 | 37 | rootCmd.AddCommand(newAuthCmd()) | |
| 37 | 38 | ||
| @@ -61,9 +62,12 @@ func newStartCmd() *cobra.Command { | |||
| 61 | 62 | func newReloadCmd() *cobra.Command { | |
| 62 | 63 | return &cobra.Command{ | |
| 63 | 64 | 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.`, | |
| 67 | 71 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 68 | 72 | return runReload() | |
| 69 | 73 | }, | |
| @@ -88,6 +92,30 @@ func runReload() error { | |||
| 88 | 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 | 119 | // SetVersionInfo sets the version information for the CLI. | |
| 92 | 120 | func SetVersionInfo(version, commit, date string) { | |
| 93 | 121 | Version = version | |
| @@ -32,6 +32,7 @@ configuration rules, making it ideal for single-server deployments.`, | |||||
| 32 | // Add subcommands | 32 | // 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 environment | 66 | + | 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 = version | 121 | 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 { | |||
| 163 | 163 | registryHandler, proxyHandler := createHTTPHandlers(svc, cfg, log) | |
| 164 | 164 | ||
| 165 | 165 | // 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 | 169 | // initConfig loads configuration from file. | |
| @@ -601,6 +601,11 @@ func registerEventHandlers(ctx context.Context, svc *services) error { | |||
| 601 | 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 | 609 | // Proxy cache invalidation on container deployment (for zero-downtime) | |
| 605 | 610 | containerDeployedHandler := proxy.NewContainerDeployedHandler(ctx, svc.proxySvc) | |
| 606 | 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 | 634 | RegistryDomain: v.GetString("server.registry_domain"), | |
| 630 | 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 | 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 | 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 | 705 | ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) | |
| 696 | 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 | 713 | errChan := make(chan error, 2) | |
| 699 | 714 | ||
| 700 | 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 | 720 | Int("registry_port", cfg.Server.RegistryPort). | |
| 706 | 721 | Msg("Gordon is running") | |
| 707 | 722 | ||
| 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 | + | } | |
| 713 | 736 | } | |
| 714 | 737 | ||
| 738 | + | shutdown: | |
| 715 | 739 | log.Info().Msg("shutting down Gordon...") | |
| 716 | 740 | ||
| 717 | 741 | 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 shutdown | 165 | // 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 err | 725 | + | 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 | |||
| 210 | 210 | } | |
| 211 | 211 | ||
| 212 | 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 | 215 | func (h *ManualReloadHandler) Handle(event domain.Event) error { | |
| 214 | 216 | ctx := zerowrap.CtxWithFields(h.ctx, map[string]any{ | |
| 215 | 217 | zerowrap.FieldLayer: "usecase", | |
| @@ -219,43 +221,47 @@ func (h *ManualReloadHandler) Handle(event domain.Event) error { | |||
| 219 | 221 | }) | |
| 220 | 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 | 226 | routes := h.configSvc.GetRoutes(ctx) | |
| 225 | - | redeployedCount := 0 | |
| 227 | + | startedCount := 0 | |
| 228 | + | skippedCount := 0 | |
| 226 | 229 | errorCount := 0 | |
| 227 | 230 | ||
| 228 | 231 | 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++ | |
| 231 | 235 | continue | |
| 232 | 236 | } | |
| 233 | 237 | ||
| 234 | 238 | log.Info(). | |
| 235 | 239 | Str("domain", route.Domain). | |
| 236 | 240 | Str("image", route.Image). | |
| 237 | - | Msg("redeploying container with updated environment") | |
| 241 | + | Msg("starting container for route") | |
| 238 | 242 | ||
| 239 | 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 | 245 | errorCount++ | |
| 242 | 246 | continue | |
| 243 | 247 | } | |
| 244 | 248 | ||
| 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") | |
| 247 | 251 | } | |
| 248 | 252 | ||
| 249 | 253 | if errorCount > 0 { | |
| 250 | 254 | log.Warn(). | |
| 251 | - | Int("redeployed", redeployedCount). | |
| 255 | + | Int("started", startedCount). | |
| 256 | + | Int("skipped", skippedCount). | |
| 252 | 257 | Int("errors", errorCount). | |
| 253 | 258 | Msg("manual reload completed with some errors") | |
| 254 | 259 | return fmt.Errorf("manual reload completed with %d errors", errorCount) | |
| 255 | 260 | } | |
| 256 | 261 | ||
| 257 | 262 | log.Info(). | |
| 258 | - | Int("redeployed", redeployedCount). | |
| 263 | + | Int("started", startedCount). | |
| 264 | + | Int("skipped", skippedCount). | |
| 259 | 265 | Int("total", len(routes)). | |
| 260 | 266 | Msg("manual reload completed successfully") | |
| 261 | 267 | 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 := 0 | 227 | + | startedCount := 0 |
| 228 | + | skippedCount := 0 | |||
| 226 | errorCount := 0 | 229 | 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 | continue | 235 | 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 | continue | 246 | 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 nil | 267 | return nil | ||
M internal/usecase/container/events_test.go +42 -11
| @@ -355,7 +355,7 @@ func TestManualReloadHandler_CanHandle(t *testing.T) { | |||
| 355 | 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 | 359 | containerSvc := inmocks.NewMockContainerService(t) | |
| 360 | 360 | configSvc := inmocks.NewMockConfigService(t) | |
| 361 | 361 | ||
| @@ -367,15 +367,15 @@ func TestManualReloadHandler_Handle_RedeploysExistingContainers(t *testing.T) { | |||
| 367 | 367 | {Domain: "app2.example.com", Image: "app2:latest"}, | |
| 368 | 368 | }) | |
| 369 | 369 | ||
| 370 | - | // Only app1 has a running container | |
| 370 | + | // app1 has a running container, app2 does not | |
| 371 | 371 | containerSvc.EXPECT().Get(mock.Anything, "app1.example.com").Return(&domain.Container{ID: "container-1"}, true) | |
| 372 | 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 | 375 | 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) | |
| 379 | 379 | ||
| 380 | 380 | event := domain.Event{ | |
| 381 | 381 | ID: "event-123", | |
| @@ -387,7 +387,7 @@ func TestManualReloadHandler_Handle_RedeploysExistingContainers(t *testing.T) { | |||
| 387 | 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 | 391 | containerSvc := inmocks.NewMockContainerService(t) | |
| 392 | 392 | configSvc := inmocks.NewMockConfigService(t) | |
| 393 | 393 | ||
| @@ -399,7 +399,11 @@ func TestManualReloadHandler_Handle_NoContainersRunning(t *testing.T) { | |||
| 399 | 399 | ||
| 400 | 400 | containerSvc.EXPECT().Get(mock.Anything, "app.example.com").Return(nil, false) | |
| 401 | 401 | ||
| 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) | |
| 403 | 407 | ||
| 404 | 408 | event := domain.Event{ | |
| 405 | 409 | ID: "event-123", | |
| @@ -422,9 +426,11 @@ func TestManualReloadHandler_Handle_DeployErrors(t *testing.T) { | |||
| 422 | 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) | |
| 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) | |
| 427 | 432 | ||
| 433 | + | // app1 fails to deploy, app2 succeeds | |
| 428 | 434 | containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{ | |
| 429 | 435 | Domain: "app1.example.com", | |
| 430 | 436 | Image: "app1:latest", | |
| @@ -433,7 +439,7 @@ func TestManualReloadHandler_Handle_DeployErrors(t *testing.T) { | |||
| 433 | 439 | containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{ | |
| 434 | 440 | Domain: "app2.example.com", | |
| 435 | 441 | Image: "app2:latest", | |
| 436 | - | }).Return(&domain.Container{ID: "container-2-new"}, nil) | |
| 442 | + | }).Return(&domain.Container{ID: "container-2"}, nil) | |
| 437 | 443 | ||
| 438 | 444 | event := domain.Event{ | |
| 439 | 445 | ID: "event-123", | |
| @@ -446,3 +452,28 @@ func TestManualReloadHandler_Handle_DeployErrors(t *testing.T) { | |||
| 446 | 452 | assert.Error(t, err) | |
| 447 | 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 | + | } | |
| @@ -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 container | 370 | + | // 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 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) | |||
| 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 | + | } | |||