v2.31.1 f37fe67

root.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close