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