// Package cli implements the CLI adapter for Gordon. // This package provides Cobra commands that delegate to the app layer. package cli import ( "context" "fmt" "io" "os" "os/signal" "syscall" "github.com/spf13/cobra" "github.com/bnema/gordon/internal/adapters/in/cli/remote" "github.com/bnema/gordon/internal/app" ) var ( // Version information (set at build time) Version = "dev" Commit = "unknown" BuildDate = "unknown" // Global flags for remote targeting remoteFlag string tokenFlag string insecureTLSFlag bool ) // Command group IDs const ( groupServer = "server" groupManage = "manage" groupClient = "client" ) // NewRootCmd creates the root command for Gordon CLI. func NewRootCmd() *cobra.Command { rootCmd := &cobra.Command{ Use: "gordon", Short: "Gordon - A lightweight container deployment platform", Long: `Gordon is a self-contained container deployment platform that combines a Docker registry with automatic container deployment capabilities. It listens for image pushes and automatically deploys containers based on configuration rules, making it ideal for single-server deployments. Commands are organized by where they run: Server-only: Run on the machine hosting Gordon (serve, auth, reload) Management: Work locally or remotely via --remote flag (routes, secrets, etc.) Client-only: CLI utilities that don't require a running Gordon server`, } // Define command groups (order matters for help output) rootCmd.AddGroup( &cobra.Group{ID: groupServer, Title: "Server Commands (local only):"}, &cobra.Group{ID: groupManage, Title: "Management Commands (local or --remote):"}, &cobra.Group{ID: groupClient, Title: "Client Commands:"}, ) // Add persistent flags for remote targeting rootCmd.PersistentFlags().StringVarP(&remoteFlag, "remote", "r", "", "Remote name or URL (e.g., prod, https://gordon.mydomain.com)") rootCmd.PersistentFlags().StringVar(&tokenFlag, "token", "", "Authentication token for remote") rootCmd.PersistentFlags().BoolVar(&insecureTLSFlag, "insecure", false, "Skip TLS certificate verification for remote HTTPS endpoints") // Server-only commands (must run on the Gordon host) serveCmd := newServeCmd() serveCmd.GroupID = groupServer rootCmd.AddCommand(serveCmd) authCmd := newAuthCmd() authCmd.GroupID = groupServer rootCmd.AddCommand(authCmd) caCmd := newCACmd() caCmd.GroupID = groupServer rootCmd.AddCommand(caCmd) // Management commands (work locally or via --remote) routesCmd := newRoutesCmd() routesCmd.GroupID = groupManage rootCmd.AddCommand(routesCmd) attachmentsCmd := newAttachmentsCmd() attachmentsCmd.GroupID = groupManage rootCmd.AddCommand(attachmentsCmd) secretsCmd := newSecretsCmd() secretsCmd.GroupID = groupManage rootCmd.AddCommand(secretsCmd) deployCmd := newDeployCmd() deployCmd.GroupID = groupManage rootCmd.AddCommand(deployCmd) restartCmd := newRestartCmd() restartCmd.GroupID = groupManage rootCmd.AddCommand(restartCmd) pushCmd := newPushCmd() pushCmd.GroupID = groupManage rootCmd.AddCommand(pushCmd) pinCmd := newPinCmd() pinCmd.GroupID = groupManage rootCmd.AddCommand(pinCmd) reloadCmd := newReloadCmd() reloadCmd.GroupID = groupManage rootCmd.AddCommand(reloadCmd) logsCmd := newLogsCmd() logsCmd.GroupID = groupManage rootCmd.AddCommand(logsCmd) statusCmd := newStatusCmd() statusCmd.GroupID = groupManage rootCmd.AddCommand(statusCmd) backupCmd := newBackupCmd() backupCmd.GroupID = groupManage rootCmd.AddCommand(backupCmd) imagesCmd := newImagesCmd() imagesCmd.GroupID = groupManage rootCmd.AddCommand(imagesCmd) bootstrapCmd := newBootstrapCmd() bootstrapCmd.GroupID = groupManage rootCmd.AddCommand(bootstrapCmd) configCmd := newConfigCmd() configCmd.GroupID = groupManage rootCmd.AddCommand(configCmd) networksCmd := newNetworksCmd() networksCmd.GroupID = groupManage rootCmd.AddCommand(networksCmd) volumesCmd := newVolumesCmd() volumesCmd.GroupID = groupManage rootCmd.AddCommand(volumesCmd) autorouteCmd := newAutorouteCmd() autorouteCmd.GroupID = groupManage rootCmd.AddCommand(autorouteCmd) previewCmd := newPreviewCmd() previewCmd.GroupID = groupManage rootCmd.AddCommand(previewCmd) tlsCmd := newTLSCmd() tlsCmd.GroupID = groupManage rootCmd.AddCommand(tlsCmd) trafficCmd := newTrafficCmd() trafficCmd.GroupID = groupManage rootCmd.AddCommand(trafficCmd) // Client-only commands (no server needed) remotesCmd := newRemotesCmd() remotesCmd.GroupID = groupClient rootCmd.AddCommand(remotesCmd) versionCmd := newVersionCmd() versionCmd.GroupID = groupClient rootCmd.AddCommand(versionCmd) // Put help and completion in the client group rootCmd.SetHelpCommandGroupID(groupClient) rootCmd.SetCompletionCommandGroupID(groupClient) return rootCmd } // GetRemoteClient returns a remote client if targeting a remote instance, // or nil if running locally. func GetRemoteClient() (*remote.Client, bool, error) { resolved, isRemote, err := remote.ResolveStrict(remoteFlag, tokenFlag, insecureTLSFlag) if err != nil { return nil, false, err } if !isRemote { return nil, false, nil } opts := remoteClientOptions(resolved.Token, resolved.InsecureTLS) client := remote.NewClient(resolved.URL, opts...) return client, true, nil } func remoteClientOptions(token string, insecureTLS bool) []remote.ClientOption { opts := make([]remote.ClientOption, 0, 2) if token != "" { opts = append(opts, remote.WithToken(token)) } if insecureTLS { opts = append(opts, remote.WithInsecureTLS(true)) } return opts } // IsRemoteMode returns true if CLI is targeting a remote Gordon instance. func IsRemoteMode() bool { _, isRemote, err := remote.ResolveStrict(remoteFlag, tokenFlag, insecureTLSFlag) return err == nil && isRemote } // newReloadCmd creates the reload command. func newReloadCmd() *cobra.Command { return &cobra.Command{ Use: "reload", Short: "Start containers for configured routes", Long: `Starts containers for routes defined in config.toml that don't have a running container. Running containers are never restarted to ensure 100% uptime. Use this command after editing config.toml to add new routes, or after pushing images to the registry when the route was not yet configured.`, RunE: func(cmd *cobra.Command, args []string) error { client, isRemote, err := GetRemoteClient() if err != nil { return err } if isRemote { return runReloadRemote(cmd.Context(), client) } return runReload() }, } } // newVersionCmd creates the version command. func newVersionCmd() *cobra.Command { return &cobra.Command{ Use: "version", Short: "Print version information", RunE: func(cmd *cobra.Command, args []string) error { out := cmd.OutOrStdout() if err := cliWriteLine(out, cliRenderTitle("Gordon "+Version)); err != nil { return err } if err := cliWriteLine(out, cliRenderMeta("Commit:", Commit)); err != nil { return err } if err := cliWriteLine(out, cliRenderMeta("Build Date:", BuildDate)); err != nil { return err } return nil }, } } // runReload sends SIGUSR1 to the running Gordon process. func runReload() error { return app.SendReloadSignal() } // runReloadRemote triggers a reload on a remote Gordon instance. func runReloadRemote(ctx context.Context, client *remote.Client) error { if err := client.Reload(ctx); err != nil { if shouldFallbackToLocal(err) { localErr := runReload() if localErr == nil { if writeErr := cliWriteLine(os.Stdout, cliRenderWarning(fmt.Sprintf("Remote reload failed (%v), used local signal fallback", err))); writeErr != nil { return writeErr } if writeErr := cliWriteLine(os.Stdout, cliRenderSuccess("Configuration reloaded successfully")); writeErr != nil { return writeErr } return nil } return fmt.Errorf("remote reload failed: %w; local fallback failed: %v", err, localErr) } return fmt.Errorf("failed to reload: %w", err) } if err := cliWriteLine(os.Stdout, cliRenderSuccess("Configuration reloaded successfully")); err != nil { return err } return nil } // newLogsCmd creates the logs command. func newLogsCmd() *cobra.Command { var follow bool var lines int var logsConfigPath string cmd := &cobra.Command{ Use: "logs [domain]", Short: "Show logs (Gordon process or container)", Long: `Shows logs from the Gordon process or a specific container. Without a domain argument, shows Gordon process logs. With a domain argument, shows container logs for that domain. Examples: gordon logs # Gordon process logs gordon logs -f # Follow process logs gordon logs myapp.local # Container logs for myapp.local gordon logs myapp.local -f # Follow container logs Remote mode: gordon logs --remote https://gordon.mydomain.com --token $TOKEN gordon logs myapp.local --remote https://gordon.mydomain.com --token $TOKEN`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { logDomain := "" if len(args) > 0 { logDomain = args[0] } return runLogs(cmd.Context(), logsConfigPath, logDomain, follow, lines, cmd.OutOrStdout()) }, } cmd.Flags().StringVarP(&logsConfigPath, "config", "c", "", "Path to config file") cmd.Flags().BoolVarP(&follow, "follow", "f", false, "Follow log output") cmd.Flags().IntVarP(&lines, "lines", "n", 50, "Number of lines to show") return cmd } // runLogs handles the logs command logic. func runLogs(ctx context.Context, logsConfigPath, logDomain string, follow bool, lines int, out io.Writer) error { if logDomain != "" { handle, err := resolveControlPlaneForRouteDomain(ctx, logDomain) if err != nil { return err } defer handle.close() return runContainerLogs(ctx, handle.plane, logDomain, follow, lines, out) } client, isRemote, err := GetRemoteClient() if err != nil { return err } if isRemote { return runLogsRemote(ctx, client, logDomain, follow, lines, out) } return runLogsLocal(logsConfigPath, logDomain, follow, lines, out) } // runLogsRemote fetches logs from a remote Gordon instance. func runLogsRemote(ctx context.Context, client *remote.Client, logDomain string, follow bool, lines int, out io.Writer) error { ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) defer cancel() if follow { return streamLogsRemote(ctx, client, logDomain, lines, out) } if logDomain == "" { // Process logs logLines, err := client.GetProcessLogs(ctx, lines) if err != nil { return fmt.Errorf("failed to get process logs: %w", err) } for _, line := range logLines { if err := cliWriteLine(out, line); err != nil { return err } } } else { // Container logs logLines, err := client.GetContainerLogs(ctx, logDomain, lines) if err != nil { return fmt.Errorf("failed to get container logs: %w", err) } for _, line := range logLines { if err := cliWriteLine(out, line); err != nil { return err } } } return nil } // streamLogsRemote streams logs from a remote Gordon instance. func streamLogsRemote(ctx context.Context, client *remote.Client, logDomain string, lines int, out io.Writer) error { var ch <-chan string var err error if logDomain == "" { ch, err = client.StreamProcessLogs(ctx, lines) } else { ch, err = client.StreamContainerLogs(ctx, logDomain, lines) } if err != nil { return fmt.Errorf("failed to stream logs: %w", err) } for line := range ch { if err := cliWriteLine(out, line); err != nil { return err } } return nil } // runLogsLocal shows logs from a local Gordon instance. func runLogsLocal(logsConfigPath, logDomain string, follow bool, lines int, out io.Writer) error { if logDomain == "" { // Process logs - use existing app.ShowLogs return app.ShowLogs(logsConfigPath, follow, lines) } return showContainerLogsLocal(out, logsConfigPath, logDomain, follow, lines) } func runContainerLogs(ctx context.Context, cp ControlPlane, logDomain string, follow bool, lines int, out io.Writer) error { ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) defer cancel() if follow { ch, err := cp.StreamContainerLogs(ctx, logDomain, lines) if err != nil { return fmt.Errorf("failed to stream container logs: %w", err) } for line := range ch { if err := cliWriteLine(out, line); err != nil { return err } } return nil } logLines, err := cp.GetContainerLogs(ctx, logDomain, lines) if err != nil { return fmt.Errorf("failed to get container logs: %w", err) } for _, line := range logLines { if err := cliWriteLine(out, line); err != nil { return err } } return nil } // showContainerLogsLocal is retained for UI adoption coverage and fallback messaging. func showContainerLogsLocal(out io.Writer, _ string, logDomain string, follow bool, lines int) error { if err := cliWriteLine(out, cliRenderTitle(fmt.Sprintf("Container logs for %s", logDomain))); err != nil { return err } if err := cliWriteLine(out, cliRenderInfo("To view container logs locally, use:")); err != nil { return err } if err := cliWritef(out, " docker logs --tail %d %s\n", lines, logDomain); err != nil { return err } if follow { if err := cliWritef(out, " docker logs -f --tail %d %s\n", lines, logDomain); err != nil { return err } } if err := cliWriteLine(out, ""); err != nil { return err } if err := cliWriteLine(out, cliRenderMuted("Or use remote mode to access logs via the admin API.")); err != nil { return err } return nil } // SetVersionInfo sets the version information for the CLI. func SetVersionInfo(version, commit, date string) { Version = version Commit = commit BuildDate = date }