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