v2.30.0-pre 7341e0e

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

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