v2.30.0-pre3 63e7f3a

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

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