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 "io"
9 "os"
10 "os/signal"
11 "syscall"
12
13 "github.com/spf13/cobra"
14
15 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
16 "github.com/bnema/gordon/internal/app"
17)
18
19var (
20 // Version information (set at build time)
21 Version = "dev"
22 Commit = "unknown"
23 BuildDate = "unknown"
24
25 // Global flags for remote targeting
26 remoteFlag string
27 tokenFlag string
28 insecureTLSFlag bool
29)
30
31// Command group IDs
32const (
33 groupServer = "server"
34 groupManage = "manage"
35 groupClient = "client"
36)
37
38// NewRootCmd creates the root command for Gordon CLI.
39func NewRootCmd() *cobra.Command {
40 rootCmd := &cobra.Command{
41 Use: "gordon",
42 Short: "Gordon - A lightweight container deployment platform",
43 Long: `Gordon is a self-contained container deployment platform that combines
44a Docker registry with automatic container deployment capabilities.
45
46It listens for image pushes and automatically deploys containers based on
47configuration rules, making it ideal for single-server deployments.
48
49Commands are organized by where they run:
50 Server-only: Run on the machine hosting Gordon (serve, auth, reload)
51 Management: Work locally or remotely via --remote flag (routes, secrets, etc.)
52 Client-only: CLI utilities that don't require a running Gordon server`,
53 }
54
55 // Define command groups (order matters for help output)
56 rootCmd.AddGroup(
57 &cobra.Group{ID: groupServer, Title: "Server Commands (local only):"},
58 &cobra.Group{ID: groupManage, Title: "Management Commands (local or --remote):"},
59 &cobra.Group{ID: groupClient, Title: "Client Commands:"},
60 )
61
62 // Add persistent flags for remote targeting
63 rootCmd.PersistentFlags().StringVarP(&remoteFlag, "remote", "r", "", "Remote name or URL (e.g., prod, https://gordon.mydomain.com)")
64 rootCmd.PersistentFlags().StringVar(&tokenFlag, "token", "", "Authentication token for remote")
65 rootCmd.PersistentFlags().BoolVar(&insecureTLSFlag, "insecure", false, "Skip TLS certificate verification for remote HTTPS endpoints")
66
67 // Server-only commands (must run on the Gordon host)
68 serveCmd := newServeCmd()
69 serveCmd.GroupID = groupServer
70 rootCmd.AddCommand(serveCmd)
71
72 authCmd := newAuthCmd()
73 authCmd.GroupID = groupServer
74 rootCmd.AddCommand(authCmd)
75
76 caCmd := newCACmd()
77 caCmd.GroupID = groupServer
78 rootCmd.AddCommand(caCmd)
79
80 // Management commands (work locally or via --remote)
81 routesCmd := newRoutesCmd()
82 routesCmd.GroupID = groupManage
83 rootCmd.AddCommand(routesCmd)
84
85 attachmentsCmd := newAttachmentsCmd()
86 attachmentsCmd.GroupID = groupManage
87 rootCmd.AddCommand(attachmentsCmd)
88
89 secretsCmd := newSecretsCmd()
90 secretsCmd.GroupID = groupManage
91 rootCmd.AddCommand(secretsCmd)
92
93 deployCmd := newDeployCmd()
94 deployCmd.GroupID = groupManage
95 rootCmd.AddCommand(deployCmd)
96
97 restartCmd := newRestartCmd()
98 restartCmd.GroupID = groupManage
99 rootCmd.AddCommand(restartCmd)
100
101 pushCmd := newPushCmd()
102 pushCmd.GroupID = groupManage
103 rootCmd.AddCommand(pushCmd)
104
105 pinCmd := newPinCmd()
106 pinCmd.GroupID = groupManage
107 rootCmd.AddCommand(pinCmd)
108
109 reloadCmd := newReloadCmd()
110 reloadCmd.GroupID = groupManage
111 rootCmd.AddCommand(reloadCmd)
112
113 logsCmd := newLogsCmd()
114 logsCmd.GroupID = groupManage
115 rootCmd.AddCommand(logsCmd)
116
117 statusCmd := newStatusCmd()
118 statusCmd.GroupID = groupManage
119 rootCmd.AddCommand(statusCmd)
120
121 backupCmd := newBackupCmd()
122 backupCmd.GroupID = groupManage
123 rootCmd.AddCommand(backupCmd)
124
125 imagesCmd := newImagesCmd()
126 imagesCmd.GroupID = groupManage
127 rootCmd.AddCommand(imagesCmd)
128
129 bootstrapCmd := newBootstrapCmd()
130 bootstrapCmd.GroupID = groupManage
131 rootCmd.AddCommand(bootstrapCmd)
132
133 configCmd := newConfigCmd()
134 configCmd.GroupID = groupManage
135 rootCmd.AddCommand(configCmd)
136
137 networksCmd := newNetworksCmd()
138 networksCmd.GroupID = groupManage
139 rootCmd.AddCommand(networksCmd)
140
141 volumesCmd := newVolumesCmd()
142 volumesCmd.GroupID = groupManage
143 rootCmd.AddCommand(volumesCmd)
144
145 autorouteCmd := newAutorouteCmd()
146 autorouteCmd.GroupID = groupManage
147 rootCmd.AddCommand(autorouteCmd)
148
149 previewCmd := newPreviewCmd()
150 previewCmd.GroupID = groupManage
151 rootCmd.AddCommand(previewCmd)
152
153 tlsCmd := newTLSCmd()
154 tlsCmd.GroupID = groupManage
155 rootCmd.AddCommand(tlsCmd)
156
157 trafficCmd := newTrafficCmd()
158 trafficCmd.GroupID = groupManage
159 rootCmd.AddCommand(trafficCmd)
160
161 // Client-only commands (no server needed)
162 remotesCmd := newRemotesCmd()
163 remotesCmd.GroupID = groupClient
164 rootCmd.AddCommand(remotesCmd)
165
166 versionCmd := newVersionCmd()
167 versionCmd.GroupID = groupClient
168 rootCmd.AddCommand(versionCmd)
169
170 // Put help and completion in the client group
171 rootCmd.SetHelpCommandGroupID(groupClient)
172 rootCmd.SetCompletionCommandGroupID(groupClient)
173
174 return rootCmd
175}
176
177// GetRemoteClient returns a remote client if targeting a remote instance,
178// or nil if running locally.
179func GetRemoteClient() (*remote.Client, bool, error) {
180 resolved, isRemote, err := remote.ResolveStrict(remoteFlag, tokenFlag, insecureTLSFlag)
181 if err != nil {
182 return nil, false, err
183 }
184 if !isRemote {
185 return nil, false, nil
186 }
187
188 opts := remoteClientOptions(resolved.Token, resolved.InsecureTLS)
189 client := remote.NewClient(resolved.URL, opts...)
190 return client, true, nil
191}
192
193func remoteClientOptions(token string, insecureTLS bool) []remote.ClientOption {
194 opts := make([]remote.ClientOption, 0, 2)
195 if token != "" {
196 opts = append(opts, remote.WithToken(token))
197 }
198 if insecureTLS {
199 opts = append(opts, remote.WithInsecureTLS(true))
200 }
201 return opts
202}
203
204// IsRemoteMode returns true if CLI is targeting a remote Gordon instance.
205func IsRemoteMode() bool {
206 _, isRemote, err := remote.ResolveStrict(remoteFlag, tokenFlag, insecureTLSFlag)
207 return err == nil && isRemote
208}
209
210// newReloadCmd creates the reload command.
211func newReloadCmd() *cobra.Command {
212 return &cobra.Command{
213 Use: "reload",
214 Short: "Start containers for configured routes",
215 Long: `Starts containers for routes defined in config.toml that don't have
216a running container. Running containers are never restarted to ensure 100% uptime.
217
218Use this command after editing config.toml to add new routes, or after pushing
219images to the registry when the route was not yet configured.`,
220 RunE: func(cmd *cobra.Command, args []string) error {
221 client, isRemote, err := GetRemoteClient()
222 if err != nil {
223 return err
224 }
225 if isRemote {
226 return runReloadRemote(cmd.Context(), client)
227 }
228 return runReload()
229 },
230 }
231}
232
233// newVersionCmd creates the version command.
234func newVersionCmd() *cobra.Command {
235 return &cobra.Command{
236 Use: "version",
237 Short: "Print version information",
238 RunE: func(cmd *cobra.Command, args []string) error {
239 out := cmd.OutOrStdout()
240 if err := cliWriteLine(out, cliRenderTitle("Gordon "+Version)); err != nil {
241 return err
242 }
243 if err := cliWriteLine(out, cliRenderMeta("Commit:", Commit)); err != nil {
244 return err
245 }
246 if err := cliWriteLine(out, cliRenderMeta("Build Date:", BuildDate)); err != nil {
247 return err
248 }
249 return nil
250 },
251 }
252}
253
254// runReload sends SIGUSR1 to the running Gordon process.
255func runReload() error {
256 return app.SendReloadSignal()
257}
258
259// runReloadRemote triggers a reload on a remote Gordon instance.
260func runReloadRemote(ctx context.Context, client *remote.Client) error {
261 if err := client.Reload(ctx); err != nil {
262 if shouldFallbackToLocal(err) {
263 localErr := runReload()
264 if localErr == nil {
265 if writeErr := cliWriteLine(os.Stdout, cliRenderWarning(fmt.Sprintf("Remote reload failed (%v), used local signal fallback", err))); writeErr != nil {
266 return writeErr
267 }
268 if writeErr := cliWriteLine(os.Stdout, cliRenderSuccess("Configuration reloaded successfully")); writeErr != nil {
269 return writeErr
270 }
271 return nil
272 }
273 return fmt.Errorf("remote reload failed: %w; local fallback failed: %v", err, localErr)
274 }
275 return fmt.Errorf("failed to reload: %w", err)
276 }
277 if err := cliWriteLine(os.Stdout, cliRenderSuccess("Configuration reloaded successfully")); err != nil {
278 return err
279 }
280 return nil
281}
282
283// newLogsCmd creates the logs command.
284func newLogsCmd() *cobra.Command {
285 var follow bool
286 var lines int
287 var logsConfigPath string
288
289 cmd := &cobra.Command{
290 Use: "logs [domain]",
291 Short: "Show logs (Gordon process or container)",
292 Long: `Shows logs from the Gordon process or a specific container.
293
294Without a domain argument, shows Gordon process logs.
295With a domain argument, shows container logs for that domain.
296
297Examples:
298 gordon logs # Gordon process logs
299 gordon logs -f # Follow process logs
300 gordon logs myapp.local # Container logs for myapp.local
301 gordon logs myapp.local -f # Follow container logs
302
303Remote mode:
304 gordon logs --remote https://gordon.mydomain.com --token $TOKEN
305 gordon logs myapp.local --remote https://gordon.mydomain.com --token $TOKEN`,
306 Args: cobra.MaximumNArgs(1),
307 RunE: func(cmd *cobra.Command, args []string) error {
308 logDomain := ""
309 if len(args) > 0 {
310 logDomain = args[0]
311 }
312 return runLogs(cmd.Context(), logsConfigPath, logDomain, follow, lines, cmd.OutOrStdout())
313 },
314 }
315
316 cmd.Flags().StringVarP(&logsConfigPath, "config", "c", "", "Path to config file")
317 cmd.Flags().BoolVarP(&follow, "follow", "f", false, "Follow log output")
318 cmd.Flags().IntVarP(&lines, "lines", "n", 50, "Number of lines to show")
319
320 return cmd
321}
322
323// runLogs handles the logs command logic.
324func runLogs(ctx context.Context, logsConfigPath, logDomain string, follow bool, lines int, out io.Writer) error {
325 if logDomain != "" {
326 handle, err := resolveControlPlaneForRouteDomain(ctx, logDomain)
327 if err != nil {
328 return err
329 }
330 defer handle.close()
331 return runContainerLogs(ctx, handle.plane, logDomain, follow, lines, out)
332 }
333
334 client, isRemote, err := GetRemoteClient()
335 if err != nil {
336 return err
337 }
338 if isRemote {
339 return runLogsRemote(ctx, client, logDomain, follow, lines, out)
340 }
341 return runLogsLocal(logsConfigPath, logDomain, follow, lines, out)
342}
343
344// runLogsRemote fetches logs from a remote Gordon instance.
345func runLogsRemote(ctx context.Context, client *remote.Client, logDomain string, follow bool, lines int, out io.Writer) error {
346 ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
347 defer cancel()
348
349 if follow {
350 return streamLogsRemote(ctx, client, logDomain, lines, out)
351 }
352
353 if logDomain == "" {
354 // Process logs
355 logLines, err := client.GetProcessLogs(ctx, lines)
356 if err != nil {
357 return fmt.Errorf("failed to get process logs: %w", err)
358 }
359 for _, line := range logLines {
360 if err := cliWriteLine(out, line); err != nil {
361 return err
362 }
363 }
364 } else {
365 // Container logs
366 logLines, err := client.GetContainerLogs(ctx, logDomain, lines)
367 if err != nil {
368 return fmt.Errorf("failed to get container logs: %w", err)
369 }
370 for _, line := range logLines {
371 if err := cliWriteLine(out, line); err != nil {
372 return err
373 }
374 }
375 }
376 return nil
377}
378
379// streamLogsRemote streams logs from a remote Gordon instance.
380func streamLogsRemote(ctx context.Context, client *remote.Client, logDomain string, lines int, out io.Writer) error {
381 var ch <-chan string
382 var err error
383
384 if logDomain == "" {
385 ch, err = client.StreamProcessLogs(ctx, lines)
386 } else {
387 ch, err = client.StreamContainerLogs(ctx, logDomain, lines)
388 }
389 if err != nil {
390 return fmt.Errorf("failed to stream logs: %w", err)
391 }
392
393 for line := range ch {
394 if err := cliWriteLine(out, line); err != nil {
395 return err
396 }
397 }
398 return nil
399}
400
401// runLogsLocal shows logs from a local Gordon instance.
402func runLogsLocal(logsConfigPath, logDomain string, follow bool, lines int, out io.Writer) error {
403 if logDomain == "" {
404 // Process logs - use existing app.ShowLogs
405 return app.ShowLogs(logsConfigPath, follow, lines)
406 }
407
408 return showContainerLogsLocal(out, logsConfigPath, logDomain, follow, lines)
409}
410
411func runContainerLogs(ctx context.Context, cp ControlPlane, logDomain string, follow bool, lines int, out io.Writer) error {
412 ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
413 defer cancel()
414
415 if follow {
416 ch, err := cp.StreamContainerLogs(ctx, logDomain, lines)
417 if err != nil {
418 return fmt.Errorf("failed to stream container logs: %w", err)
419 }
420 for line := range ch {
421 if err := cliWriteLine(out, line); err != nil {
422 return err
423 }
424 }
425 return nil
426 }
427
428 logLines, err := cp.GetContainerLogs(ctx, logDomain, lines)
429 if err != nil {
430 return fmt.Errorf("failed to get container logs: %w", err)
431 }
432 for _, line := range logLines {
433 if err := cliWriteLine(out, line); err != nil {
434 return err
435 }
436 }
437 return nil
438}
439
440// showContainerLogsLocal is retained for UI adoption coverage and fallback messaging.
441func showContainerLogsLocal(out io.Writer, _ string, logDomain string, follow bool, lines int) error {
442 if err := cliWriteLine(out, cliRenderTitle(fmt.Sprintf("Container logs for %s", logDomain))); err != nil {
443 return err
444 }
445 if err := cliWriteLine(out, cliRenderInfo("To view container logs locally, use:")); err != nil {
446 return err
447 }
448 if err := cliWritef(out, " docker logs --tail %d %s\n", lines, logDomain); err != nil {
449 return err
450 }
451 if follow {
452 if err := cliWritef(out, " docker logs -f --tail %d %s\n", lines, logDomain); err != nil {
453 return err
454 }
455 }
456 if err := cliWriteLine(out, ""); err != nil {
457 return err
458 }
459 if err := cliWriteLine(out, cliRenderMuted("Or use remote mode to access logs via the admin API.")); err != nil {
460 return err
461 }
462 return nil
463}
464
465// SetVersionInfo sets the version information for the CLI.
466func SetVersionInfo(version, commit, date string) {
467 Version = version
468 Commit = commit
469 BuildDate = date
470}