1// Package cli implements the CLI adapter for Gordon.
2// This package provides Cobra commands that delegate to the app layer.
3package cli
4
5import (
6 "context"
7 "fmt"
8 "os"
9 "os/signal"
10 "syscall"
11
12 "github.com/spf13/cobra"
13
14 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
15 "github.com/bnema/gordon/internal/app"
16)
17
18var (
19 // Version information (set at build time)
20 Version = "dev"
21 Commit = "unknown"
22 BuildDate = "unknown"
23
24 // Global flags for remote targeting
25 remoteFlag string
26 tokenFlag string
27 insecureTLSFlag bool
28)
29
30// Command group IDs
31const (
32 groupServer = "server"
33 groupManage = "manage"
34 groupClient = "client"
35)
36
37// NewRootCmd creates the root command for Gordon CLI.
38func NewRootCmd() *cobra.Command {
39 rootCmd := &cobra.Command{
40 Use: "gordon",
41 Short: "Gordon - A lightweight container deployment platform",
42 Long: `Gordon is a self-contained container deployment platform that combines
43a Docker registry with automatic container deployment capabilities.
44
45It listens for image pushes and automatically deploys containers based on
46configuration rules, making it ideal for single-server deployments.
47
48Commands are organized by where they run:
49 Server-only: Run on the machine hosting Gordon (serve, auth, reload)
50 Management: Work locally or remotely via --remote flag (routes, secrets, etc.)
51 Client-only: CLI utilities that don't require a running Gordon server`,
52 }
53
54 // Define command groups (order matters for help output)
55 rootCmd.AddGroup(
56 &cobra.Group{ID: groupServer, Title: "Server Commands (local only):"},
57 &cobra.Group{ID: groupManage, Title: "Management Commands (local or --remote):"},
58 &cobra.Group{ID: groupClient, Title: "Client Commands:"},
59 )
60
61 // Add persistent flags for remote targeting
62 rootCmd.PersistentFlags().StringVar(&remoteFlag, "remote", "", "Remote Gordon URL (e.g., https://gordon.mydomain.com)")
63 rootCmd.PersistentFlags().StringVar(&tokenFlag, "token", "", "Authentication token for remote")
64 rootCmd.PersistentFlags().BoolVar(&insecureTLSFlag, "insecure", false, "Skip TLS certificate verification for remote HTTPS endpoints")
65
66 // Server-only commands (must run on the Gordon host)
67 serveCmd := newServeCmd()
68 serveCmd.GroupID = groupServer
69 rootCmd.AddCommand(serveCmd)
70
71 startCmd := newStartCmd() // Deprecated alias for serve
72 startCmd.GroupID = groupServer
73 rootCmd.AddCommand(startCmd)
74
75 authCmd := newAuthCmd()
76 authCmd.GroupID = groupServer
77 rootCmd.AddCommand(authCmd)
78
79 // Management commands (work locally or via --remote)
80 routesCmd := newRoutesCmd()
81 routesCmd.GroupID = groupManage
82 rootCmd.AddCommand(routesCmd)
83
84 attachmentsCmd := newAttachmentsCmd()
85 attachmentsCmd.GroupID = groupManage
86 rootCmd.AddCommand(attachmentsCmd)
87
88 secretsCmd := newSecretsCmd()
89 secretsCmd.GroupID = groupManage
90 rootCmd.AddCommand(secretsCmd)
91
92 deployCmd := newDeployCmd()
93 deployCmd.GroupID = groupManage
94 rootCmd.AddCommand(deployCmd)
95
96 restartCmd := newRestartCmd()
97 restartCmd.GroupID = groupManage
98 rootCmd.AddCommand(restartCmd)
99
100 pushCmd := newPushCmd()
101 pushCmd.GroupID = groupManage
102 rootCmd.AddCommand(pushCmd)
103
104 rollbackCmd := newRollbackCmd()
105 rollbackCmd.GroupID = groupManage
106 rootCmd.AddCommand(rollbackCmd)
107
108 reloadCmd := newReloadCmd()
109 reloadCmd.GroupID = groupManage
110 rootCmd.AddCommand(reloadCmd)
111
112 logsCmd := newLogsCmd()
113 logsCmd.GroupID = groupManage
114 rootCmd.AddCommand(logsCmd)
115
116 statusCmd := newStatusCmd()
117 statusCmd.GroupID = groupManage
118 rootCmd.AddCommand(statusCmd)
119
120 backupCmd := newBackupCmd()
121 backupCmd.GroupID = groupManage
122 rootCmd.AddCommand(backupCmd)
123
124 imagesCmd := newImagesCmd()
125 imagesCmd.GroupID = groupManage
126 rootCmd.AddCommand(imagesCmd)
127
128 // Client-only commands (no server needed)
129 remotesCmd := newRemotesCmd()
130 remotesCmd.GroupID = groupClient
131 rootCmd.AddCommand(remotesCmd)
132
133 versionCmd := newVersionCmd()
134 versionCmd.GroupID = groupClient
135 rootCmd.AddCommand(versionCmd)
136
137 // Put help and completion in the client group
138 rootCmd.SetHelpCommandGroupID(groupClient)
139 rootCmd.SetCompletionCommandGroupID(groupClient)
140
141 return rootCmd
142}
143
144// GetRemoteClient returns a remote client if targeting a remote instance,
145// or nil if running locally.
146func GetRemoteClient() (*remote.Client, bool) {
147 url, token, insecureTLS, isRemote := remote.ResolveRemote(remoteFlag, tokenFlag, insecureTLSFlag)
148 if !isRemote {
149 return nil, false
150 }
151
152 client := remote.NewClient(url, remoteClientOptions(token, insecureTLS)...)
153 return client, true
154}
155
156func remoteClientOptions(token string, insecureTLS bool) []remote.ClientOption {
157 opts := make([]remote.ClientOption, 0, 2)
158 if token != "" {
159 opts = append(opts, remote.WithToken(token))
160 }
161 if insecureTLS {
162 opts = append(opts, remote.WithInsecureTLS(true))
163 }
164 return opts
165}
166
167// IsRemoteMode returns true if CLI is targeting a remote Gordon instance.
168func IsRemoteMode() bool {
169 _, _, _, isRemote := remote.ResolveRemote(remoteFlag, tokenFlag, insecureTLSFlag)
170 return isRemote
171}
172
173// newReloadCmd creates the reload command.
174func newReloadCmd() *cobra.Command {
175 return &cobra.Command{
176 Use: "reload",
177 Short: "Start containers for configured routes",
178 Long: `Starts containers for routes defined in config.toml that don't have
179a running container. Running containers are never restarted to ensure 100% uptime.
180
181Use this command after editing config.toml to add new routes, or after pushing
182images to the registry when the route was not yet configured.`,
183 RunE: func(cmd *cobra.Command, args []string) error {
184 client, isRemote := GetRemoteClient()
185 if isRemote {
186 return runReloadRemote(cmd.Context(), client)
187 }
188 return runReload()
189 },
190 }
191}
192
193// newVersionCmd creates the version command.
194func newVersionCmd() *cobra.Command {
195 return &cobra.Command{
196 Use: "version",
197 Short: "Print version information",
198 Run: func(cmd *cobra.Command, args []string) {
199 cmd.Printf("Gordon %s\n", Version)
200 cmd.Printf("Commit: %s\n", Commit)
201 cmd.Printf("Build Date: %s\n", BuildDate)
202 },
203 }
204}
205
206// runReload sends SIGUSR1 to the running Gordon process.
207func runReload() error {
208 return app.SendReloadSignal()
209}
210
211// runReloadRemote triggers a reload on a remote Gordon instance.
212func runReloadRemote(ctx context.Context, client *remote.Client) error {
213 if err := client.Reload(ctx); err != nil {
214 if shouldFallbackToLocal(err) {
215 localErr := runReload()
216 if localErr == nil {
217 fmt.Printf("Remote reload failed (%v), used local signal fallback\n", err)
218 fmt.Println("Configuration reloaded successfully")
219 return nil
220 }
221 return fmt.Errorf("remote reload failed: %w; local fallback failed: %v", err, localErr)
222 }
223 return fmt.Errorf("failed to reload: %w", err)
224 }
225 fmt.Println("Configuration reloaded successfully")
226 return nil
227}
228
229// newLogsCmd creates the logs command.
230func newLogsCmd() *cobra.Command {
231 var follow bool
232 var lines int
233 var logsConfigPath string
234
235 cmd := &cobra.Command{
236 Use: "logs [domain]",
237 Short: "Show logs (Gordon process or container)",
238 Long: `Shows logs from the Gordon process or a specific container.
239
240Without a domain argument, shows Gordon process logs.
241With a domain argument, shows container logs for that domain.
242
243Examples:
244 gordon logs # Gordon process logs
245 gordon logs -f # Follow process logs
246 gordon logs myapp.local # Container logs for myapp.local
247 gordon logs myapp.local -f # Follow container logs
248
249Remote mode:
250 gordon logs --remote https://gordon.mydomain.com --token $TOKEN
251 gordon logs myapp.local --remote https://gordon.mydomain.com --token $TOKEN`,
252 Args: cobra.MaximumNArgs(1),
253 RunE: func(cmd *cobra.Command, args []string) error {
254 logDomain := ""
255 if len(args) > 0 {
256 logDomain = args[0]
257 }
258 return runLogs(logsConfigPath, logDomain, follow, lines)
259 },
260 }
261
262 cmd.Flags().StringVarP(&logsConfigPath, "config", "c", "", "Path to config file")
263 cmd.Flags().BoolVarP(&follow, "follow", "f", false, "Follow log output")
264 cmd.Flags().IntVarP(&lines, "lines", "n", 50, "Number of lines to show")
265
266 return cmd
267}
268
269// runLogs handles the logs command logic.
270func runLogs(logsConfigPath, logDomain string, follow bool, lines int) error {
271 client, isRemote := GetRemoteClient()
272 if isRemote {
273 return runLogsRemote(client, logDomain, follow, lines)
274 }
275 return runLogsLocal(logsConfigPath, logDomain, follow, lines)
276}
277
278// runLogsRemote fetches logs from a remote Gordon instance.
279func runLogsRemote(client *remote.Client, logDomain string, follow bool, lines int) error {
280 ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
281 defer cancel()
282
283 if follow {
284 return streamLogsRemote(ctx, client, logDomain, lines)
285 }
286
287 if logDomain == "" {
288 // Process logs
289 logLines, err := client.GetProcessLogs(ctx, lines)
290 if err != nil {
291 return fmt.Errorf("failed to get process logs: %w", err)
292 }
293 for _, line := range logLines {
294 fmt.Println(line)
295 }
296 } else {
297 // Container logs
298 logLines, err := client.GetContainerLogs(ctx, logDomain, lines)
299 if err != nil {
300 return fmt.Errorf("failed to get container logs: %w", err)
301 }
302 for _, line := range logLines {
303 fmt.Println(line)
304 }
305 }
306 return nil
307}
308
309// streamLogsRemote streams logs from a remote Gordon instance.
310func streamLogsRemote(ctx context.Context, client *remote.Client, logDomain string, lines int) error {
311 var ch <-chan string
312 var err error
313
314 if logDomain == "" {
315 ch, err = client.StreamProcessLogs(ctx, lines)
316 } else {
317 ch, err = client.StreamContainerLogs(ctx, logDomain, lines)
318 }
319 if err != nil {
320 return fmt.Errorf("failed to stream logs: %w", err)
321 }
322
323 for line := range ch {
324 fmt.Println(line)
325 }
326 return nil
327}
328
329// runLogsLocal shows logs from a local Gordon instance.
330func runLogsLocal(logsConfigPath, logDomain string, follow bool, lines int) error {
331 if logDomain == "" {
332 // Process logs - use existing app.ShowLogs
333 return app.ShowLogs(logsConfigPath, follow, lines)
334 }
335
336 // Container logs - use local services
337 return showContainerLogsLocal(logsConfigPath, logDomain, follow, lines)
338}
339
340// showContainerLogsLocal shows container logs using local Docker access.
341func showContainerLogsLocal(logsConfigPath, logDomain string, follow bool, lines int) error {
342 // For local container logs, we need Docker access which requires
343 // the runtime to be initialized. For now, suggest using remote mode
344 // or direct docker logs command.
345 fmt.Printf("Container logs for %s\n", logDomain)
346 fmt.Println("To view container logs locally, use:")
347 fmt.Printf(" docker logs --tail %d %s\n", lines, logDomain)
348 if follow {
349 fmt.Printf(" docker logs -f --tail %d %s\n", lines, logDomain)
350 }
351 fmt.Println("\nOr use remote mode to access logs via the admin API.")
352 return nil
353}
354
355// SetVersionInfo sets the version information for the CLI.
356func SetVersionInfo(version, commit, date string) {
357 Version = version
358 Commit = commit
359 BuildDate = date
360}