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 // Client-only commands (no server needed)
125 remotesCmd := newRemotesCmd()
126 remotesCmd.GroupID = groupClient
127 rootCmd.AddCommand(remotesCmd)
128
129 versionCmd := newVersionCmd()
130 versionCmd.GroupID = groupClient
131 rootCmd.AddCommand(versionCmd)
132
133 // Put help and completion in the client group
134 rootCmd.SetHelpCommandGroupID(groupClient)
135 rootCmd.SetCompletionCommandGroupID(groupClient)
136
137 return rootCmd
138}
139
140// GetRemoteClient returns a remote client if targeting a remote instance,
141// or nil if running locally.
142func GetRemoteClient() (*remote.Client, bool) {
143 url, token, insecureTLS, isRemote := remote.ResolveRemote(remoteFlag, tokenFlag, insecureTLSFlag)
144 if !isRemote {
145 return nil, false
146 }
147
148 client := remote.NewClient(url, remoteClientOptions(token, insecureTLS)...)
149 return client, true
150}
151
152func remoteClientOptions(token string, insecureTLS bool) []remote.ClientOption {
153 opts := make([]remote.ClientOption, 0, 2)
154 if token != "" {
155 opts = append(opts, remote.WithToken(token))
156 }
157 if insecureTLS {
158 opts = append(opts, remote.WithInsecureTLS(true))
159 }
160 return opts
161}
162
163// IsRemoteMode returns true if CLI is targeting a remote Gordon instance.
164func IsRemoteMode() bool {
165 _, _, _, isRemote := remote.ResolveRemote(remoteFlag, tokenFlag, insecureTLSFlag)
166 return isRemote
167}
168
169// newReloadCmd creates the reload command.
170func newReloadCmd() *cobra.Command {
171 return &cobra.Command{
172 Use: "reload",
173 Short: "Start containers for configured routes",
174 Long: `Starts containers for routes defined in config.toml that don't have
175a running container. Running containers are never restarted to ensure 100% uptime.
176
177Use this command after editing config.toml to add new routes, or after pushing
178images to the registry when the route was not yet configured.`,
179 RunE: func(cmd *cobra.Command, args []string) error {
180 client, isRemote := GetRemoteClient()
181 if isRemote {
182 return runReloadRemote(cmd.Context(), client)
183 }
184 return runReload()
185 },
186 }
187}
188
189// newVersionCmd creates the version command.
190func newVersionCmd() *cobra.Command {
191 return &cobra.Command{
192 Use: "version",
193 Short: "Print version information",
194 Run: func(cmd *cobra.Command, args []string) {
195 cmd.Printf("Gordon %s\n", Version)
196 cmd.Printf("Commit: %s\n", Commit)
197 cmd.Printf("Build Date: %s\n", BuildDate)
198 },
199 }
200}
201
202// runReload sends SIGUSR1 to the running Gordon process.
203func runReload() error {
204 return app.SendReloadSignal()
205}
206
207// runReloadRemote triggers a reload on a remote Gordon instance.
208func runReloadRemote(ctx context.Context, client *remote.Client) error {
209 if err := client.Reload(ctx); err != nil {
210 if shouldFallbackToLocal(err) {
211 localErr := runReload()
212 if localErr == nil {
213 fmt.Printf("Remote reload failed (%v), used local signal fallback\n", err)
214 fmt.Println("Configuration reloaded successfully")
215 return nil
216 }
217 return fmt.Errorf("remote reload failed: %w; local fallback failed: %v", err, localErr)
218 }
219 return fmt.Errorf("failed to reload: %w", err)
220 }
221 fmt.Println("Configuration reloaded successfully")
222 return nil
223}
224
225// newLogsCmd creates the logs command.
226func newLogsCmd() *cobra.Command {
227 var follow bool
228 var lines int
229 var logsConfigPath string
230
231 cmd := &cobra.Command{
232 Use: "logs [domain]",
233 Short: "Show logs (Gordon process or container)",
234 Long: `Shows logs from the Gordon process or a specific container.
235
236Without a domain argument, shows Gordon process logs.
237With a domain argument, shows container logs for that domain.
238
239Examples:
240 gordon logs # Gordon process logs
241 gordon logs -f # Follow process logs
242 gordon logs myapp.local # Container logs for myapp.local
243 gordon logs myapp.local -f # Follow container logs
244
245Remote mode:
246 gordon logs --remote https://gordon.mydomain.com --token $TOKEN
247 gordon logs myapp.local --remote https://gordon.mydomain.com --token $TOKEN`,
248 Args: cobra.MaximumNArgs(1),
249 RunE: func(cmd *cobra.Command, args []string) error {
250 logDomain := ""
251 if len(args) > 0 {
252 logDomain = args[0]
253 }
254 return runLogs(logsConfigPath, logDomain, follow, lines)
255 },
256 }
257
258 cmd.Flags().StringVarP(&logsConfigPath, "config", "c", "", "Path to config file")
259 cmd.Flags().BoolVarP(&follow, "follow", "f", false, "Follow log output")
260 cmd.Flags().IntVarP(&lines, "lines", "n", 50, "Number of lines to show")
261
262 return cmd
263}
264
265// runLogs handles the logs command logic.
266func runLogs(logsConfigPath, logDomain string, follow bool, lines int) error {
267 client, isRemote := GetRemoteClient()
268 if isRemote {
269 return runLogsRemote(client, logDomain, follow, lines)
270 }
271 return runLogsLocal(logsConfigPath, logDomain, follow, lines)
272}
273
274// runLogsRemote fetches logs from a remote Gordon instance.
275func runLogsRemote(client *remote.Client, logDomain string, follow bool, lines int) error {
276 ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
277 defer cancel()
278
279 if follow {
280 return streamLogsRemote(ctx, client, logDomain, lines)
281 }
282
283 if logDomain == "" {
284 // Process logs
285 logLines, err := client.GetProcessLogs(ctx, lines)
286 if err != nil {
287 return fmt.Errorf("failed to get process logs: %w", err)
288 }
289 for _, line := range logLines {
290 fmt.Println(line)
291 }
292 } else {
293 // Container logs
294 logLines, err := client.GetContainerLogs(ctx, logDomain, lines)
295 if err != nil {
296 return fmt.Errorf("failed to get container logs: %w", err)
297 }
298 for _, line := range logLines {
299 fmt.Println(line)
300 }
301 }
302 return nil
303}
304
305// streamLogsRemote streams logs from a remote Gordon instance.
306func streamLogsRemote(ctx context.Context, client *remote.Client, logDomain string, lines int) error {
307 var ch <-chan string
308 var err error
309
310 if logDomain == "" {
311 ch, err = client.StreamProcessLogs(ctx, lines)
312 } else {
313 ch, err = client.StreamContainerLogs(ctx, logDomain, lines)
314 }
315 if err != nil {
316 return fmt.Errorf("failed to stream logs: %w", err)
317 }
318
319 for line := range ch {
320 fmt.Println(line)
321 }
322 return nil
323}
324
325// runLogsLocal shows logs from a local Gordon instance.
326func runLogsLocal(logsConfigPath, logDomain string, follow bool, lines int) error {
327 if logDomain == "" {
328 // Process logs - use existing app.ShowLogs
329 return app.ShowLogs(logsConfigPath, follow, lines)
330 }
331
332 // Container logs - use local services
333 return showContainerLogsLocal(logsConfigPath, logDomain, follow, lines)
334}
335
336// showContainerLogsLocal shows container logs using local Docker access.
337func showContainerLogsLocal(logsConfigPath, logDomain string, follow bool, lines int) error {
338 // For local container logs, we need Docker access which requires
339 // the runtime to be initialized. For now, suggest using remote mode
340 // or direct docker logs command.
341 fmt.Printf("Container logs for %s\n", logDomain)
342 fmt.Println("To view container logs locally, use:")
343 fmt.Printf(" docker logs --tail %d %s\n", lines, logDomain)
344 if follow {
345 fmt.Printf(" docker logs -f --tail %d %s\n", lines, logDomain)
346 }
347 fmt.Println("\nOr use remote mode to access logs via the admin API.")
348 return nil
349}
350
351// SetVersionInfo sets the version information for the CLI.
352func SetVersionInfo(version, commit, date string) {
353 Version = version
354 Commit = commit
355 BuildDate = date
356}