v2.31.0-dev19 35467d7

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		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	// Client-only commands (no server needed)
158	remotesCmd := newRemotesCmd()
159	remotesCmd.GroupID = groupClient
160	rootCmd.AddCommand(remotesCmd)
161
162	versionCmd := newVersionCmd()
163	versionCmd.GroupID = groupClient
164	rootCmd.AddCommand(versionCmd)
165
166	// Put help and completion in the client group
167	rootCmd.SetHelpCommandGroupID(groupClient)
168	rootCmd.SetCompletionCommandGroupID(groupClient)
169
170	return rootCmd
171}
172
173// GetRemoteClient returns a remote client if targeting a remote instance,
174// or nil if running locally.
175func GetRemoteClient() (*remote.Client, bool) {
176	resolved, isRemote := remote.Resolve(remoteFlag, tokenFlag, insecureTLSFlag)
177	if !isRemote {
178		return nil, false
179	}
180
181	opts := remoteClientOptions(resolved.Token, resolved.InsecureTLS)
182	client := remote.NewClient(resolved.URL, opts...)
183	return client, true
184}
185
186func remoteClientOptions(token string, insecureTLS bool) []remote.ClientOption {
187	opts := make([]remote.ClientOption, 0, 2)
188	if token != "" {
189		opts = append(opts, remote.WithToken(token))
190	}
191	if insecureTLS {
192		opts = append(opts, remote.WithInsecureTLS(true))
193	}
194	return opts
195}
196
197// IsRemoteMode returns true if CLI is targeting a remote Gordon instance.
198func IsRemoteMode() bool {
199	_, isRemote := remote.Resolve(remoteFlag, tokenFlag, insecureTLSFlag)
200	return isRemote
201}
202
203// newReloadCmd creates the reload command.
204func newReloadCmd() *cobra.Command {
205	return &cobra.Command{
206		Use:   "reload",
207		Short: "Start containers for configured routes",
208		Long: `Starts containers for routes defined in config.toml that don't have
209a running container. Running containers are never restarted to ensure 100% uptime.
210
211Use this command after editing config.toml to add new routes, or after pushing
212images to the registry when the route was not yet configured.`,
213		RunE: func(cmd *cobra.Command, args []string) error {
214			client, isRemote := GetRemoteClient()
215			if isRemote {
216				return runReloadRemote(cmd.Context(), client)
217			}
218			return runReload()
219		},
220	}
221}
222
223// newVersionCmd creates the version command.
224func newVersionCmd() *cobra.Command {
225	return &cobra.Command{
226		Use:   "version",
227		Short: "Print version information",
228		RunE: func(cmd *cobra.Command, args []string) error {
229			out := cmd.OutOrStdout()
230			if err := cliWriteLine(out, cliRenderTitle("Gordon "+Version)); err != nil {
231				return err
232			}
233			if err := cliWriteLine(out, cliRenderMeta("Commit:", Commit)); err != nil {
234				return err
235			}
236			if err := cliWriteLine(out, cliRenderMeta("Build Date:", BuildDate)); err != nil {
237				return err
238			}
239			return nil
240		},
241	}
242}
243
244// runReload sends SIGUSR1 to the running Gordon process.
245func runReload() error {
246	return app.SendReloadSignal()
247}
248
249// runReloadRemote triggers a reload on a remote Gordon instance.
250func runReloadRemote(ctx context.Context, client *remote.Client) error {
251	if err := client.Reload(ctx); err != nil {
252		if shouldFallbackToLocal(err) {
253			localErr := runReload()
254			if localErr == nil {
255				if writeErr := cliWriteLine(os.Stdout, cliRenderWarning(fmt.Sprintf("Remote reload failed (%v), used local signal fallback", err))); writeErr != nil {
256					return writeErr
257				}
258				if writeErr := cliWriteLine(os.Stdout, cliRenderSuccess("Configuration reloaded successfully")); writeErr != nil {
259					return writeErr
260				}
261				return nil
262			}
263			return fmt.Errorf("remote reload failed: %w; local fallback failed: %v", err, localErr)
264		}
265		return fmt.Errorf("failed to reload: %w", err)
266	}
267	if err := cliWriteLine(os.Stdout, cliRenderSuccess("Configuration reloaded successfully")); err != nil {
268		return err
269	}
270	return nil
271}
272
273// newLogsCmd creates the logs command.
274func newLogsCmd() *cobra.Command {
275	var follow bool
276	var lines int
277	var logsConfigPath string
278
279	cmd := &cobra.Command{
280		Use:   "logs [domain]",
281		Short: "Show logs (Gordon process or container)",
282		Long: `Shows logs from the Gordon process or a specific container.
283
284Without a domain argument, shows Gordon process logs.
285With a domain argument, shows container logs for that domain.
286
287Examples:
288  gordon logs                    # Gordon process logs
289  gordon logs -f                 # Follow process logs
290  gordon logs myapp.local        # Container logs for myapp.local
291  gordon logs myapp.local -f     # Follow container logs
292
293Remote mode:
294  gordon logs --remote https://gordon.mydomain.com --token $TOKEN
295  gordon logs myapp.local --remote https://gordon.mydomain.com --token $TOKEN`,
296		Args: cobra.MaximumNArgs(1),
297		RunE: func(cmd *cobra.Command, args []string) error {
298			logDomain := ""
299			if len(args) > 0 {
300				logDomain = args[0]
301			}
302			return runLogs(cmd.Context(), logsConfigPath, logDomain, follow, lines, cmd.OutOrStdout())
303		},
304	}
305
306	cmd.Flags().StringVarP(&logsConfigPath, "config", "c", "", "Path to config file")
307	cmd.Flags().BoolVarP(&follow, "follow", "f", false, "Follow log output")
308	cmd.Flags().IntVarP(&lines, "lines", "n", 50, "Number of lines to show")
309
310	return cmd
311}
312
313// runLogs handles the logs command logic.
314func runLogs(ctx context.Context, logsConfigPath, logDomain string, follow bool, lines int, out io.Writer) error {
315	if logDomain != "" {
316		handle, err := resolveControlPlaneForRouteDomain(ctx, logDomain)
317		if err != nil {
318			return err
319		}
320		defer handle.close()
321		return runContainerLogs(ctx, handle.plane, logDomain, follow, lines, out)
322	}
323
324	client, isRemote := GetRemoteClient()
325	if isRemote {
326		return runLogsRemote(ctx, client, logDomain, follow, lines, out)
327	}
328	return runLogsLocal(logsConfigPath, logDomain, follow, lines, out)
329}
330
331// runLogsRemote fetches logs from a remote Gordon instance.
332func runLogsRemote(ctx context.Context, client *remote.Client, logDomain string, follow bool, lines int, out io.Writer) error {
333	ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
334	defer cancel()
335
336	if follow {
337		return streamLogsRemote(ctx, client, logDomain, lines, out)
338	}
339
340	if logDomain == "" {
341		// Process logs
342		logLines, err := client.GetProcessLogs(ctx, lines)
343		if err != nil {
344			return fmt.Errorf("failed to get process logs: %w", err)
345		}
346		for _, line := range logLines {
347			if err := cliWriteLine(out, line); err != nil {
348				return err
349			}
350		}
351	} else {
352		// Container logs
353		logLines, err := client.GetContainerLogs(ctx, logDomain, lines)
354		if err != nil {
355			return fmt.Errorf("failed to get container logs: %w", err)
356		}
357		for _, line := range logLines {
358			if err := cliWriteLine(out, line); err != nil {
359				return err
360			}
361		}
362	}
363	return nil
364}
365
366// streamLogsRemote streams logs from a remote Gordon instance.
367func streamLogsRemote(ctx context.Context, client *remote.Client, logDomain string, lines int, out io.Writer) error {
368	var ch <-chan string
369	var err error
370
371	if logDomain == "" {
372		ch, err = client.StreamProcessLogs(ctx, lines)
373	} else {
374		ch, err = client.StreamContainerLogs(ctx, logDomain, lines)
375	}
376	if err != nil {
377		return fmt.Errorf("failed to stream logs: %w", err)
378	}
379
380	for line := range ch {
381		if err := cliWriteLine(out, line); err != nil {
382			return err
383		}
384	}
385	return nil
386}
387
388// runLogsLocal shows logs from a local Gordon instance.
389func runLogsLocal(logsConfigPath, logDomain string, follow bool, lines int, out io.Writer) error {
390	if logDomain == "" {
391		// Process logs - use existing app.ShowLogs
392		return app.ShowLogs(logsConfigPath, follow, lines)
393	}
394
395	return showContainerLogsLocal(out, logsConfigPath, logDomain, follow, lines)
396}
397
398func runContainerLogs(ctx context.Context, cp ControlPlane, logDomain string, follow bool, lines int, out io.Writer) error {
399	ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
400	defer cancel()
401
402	if follow {
403		ch, err := cp.StreamContainerLogs(ctx, logDomain, lines)
404		if err != nil {
405			return fmt.Errorf("failed to stream container logs: %w", err)
406		}
407		for line := range ch {
408			if err := cliWriteLine(out, line); err != nil {
409				return err
410			}
411		}
412		return nil
413	}
414
415	logLines, err := cp.GetContainerLogs(ctx, logDomain, lines)
416	if err != nil {
417		return fmt.Errorf("failed to get container logs: %w", err)
418	}
419	for _, line := range logLines {
420		if err := cliWriteLine(out, line); err != nil {
421			return err
422		}
423	}
424	return nil
425}
426
427// showContainerLogsLocal is retained for UI adoption coverage and fallback messaging.
428func showContainerLogsLocal(out io.Writer, _ string, logDomain string, follow bool, lines int) error {
429	if err := cliWriteLine(out, cliRenderTitle(fmt.Sprintf("Container logs for %s", logDomain))); err != nil {
430		return err
431	}
432	if err := cliWriteLine(out, cliRenderInfo("To view container logs locally, use:")); err != nil {
433		return err
434	}
435	if err := cliWritef(out, "  docker logs --tail %d %s\n", lines, logDomain); err != nil {
436		return err
437	}
438	if follow {
439		if err := cliWritef(out, "  docker logs -f --tail %d %s\n", lines, logDomain); err != nil {
440			return err
441		}
442	}
443	if err := cliWriteLine(out, ""); err != nil {
444		return err
445	}
446	if err := cliWriteLine(out, cliRenderMuted("Or use remote mode to access logs via the admin API.")); err != nil {
447		return err
448	}
449	return nil
450}
451
452// SetVersionInfo sets the version information for the CLI.
453func SetVersionInfo(version, commit, date string) {
454	Version = version
455	Commit = commit
456	BuildDate = date
457}

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