v2.11.0 dca6537

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

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