v2.13.0 7748089

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	// Client-only commands (no server needed)
121	remotesCmd := newRemotesCmd()
122	remotesCmd.GroupID = groupClient
123	rootCmd.AddCommand(remotesCmd)
124
125	versionCmd := newVersionCmd()
126	versionCmd.GroupID = groupClient
127	rootCmd.AddCommand(versionCmd)
128
129	// Put help and completion in the client group
130	rootCmd.SetHelpCommandGroupID(groupClient)
131	rootCmd.SetCompletionCommandGroupID(groupClient)
132
133	return rootCmd
134}
135
136// GetRemoteClient returns a remote client if targeting a remote instance,
137// or nil if running locally.
138func GetRemoteClient() (*remote.Client, bool) {
139	url, token, insecureTLS, isRemote := remote.ResolveRemote(remoteFlag, tokenFlag, insecureTLSFlag)
140	if !isRemote {
141		return nil, false
142	}
143
144	client := remote.NewClient(url, remoteClientOptions(token, insecureTLS)...)
145	return client, true
146}
147
148func remoteClientOptions(token string, insecureTLS bool) []remote.ClientOption {
149	opts := make([]remote.ClientOption, 0, 2)
150	if token != "" {
151		opts = append(opts, remote.WithToken(token))
152	}
153	if insecureTLS {
154		opts = append(opts, remote.WithInsecureTLS(true))
155	}
156	return opts
157}
158
159// IsRemoteMode returns true if CLI is targeting a remote Gordon instance.
160func IsRemoteMode() bool {
161	_, _, _, isRemote := remote.ResolveRemote(remoteFlag, tokenFlag, insecureTLSFlag)
162	return isRemote
163}
164
165// newReloadCmd creates the reload command.
166func newReloadCmd() *cobra.Command {
167	return &cobra.Command{
168		Use:   "reload",
169		Short: "Start containers for configured routes",
170		Long: `Starts containers for routes defined in config.toml that don't have
171a running container. Running containers are never restarted to ensure 100% uptime.
172
173Use this command after editing config.toml to add new routes, or after pushing
174images to the registry when the route was not yet configured.`,
175		RunE: func(cmd *cobra.Command, args []string) error {
176			client, isRemote := GetRemoteClient()
177			if isRemote {
178				return runReloadRemote(cmd.Context(), client)
179			}
180			return runReload()
181		},
182	}
183}
184
185// newVersionCmd creates the version command.
186func newVersionCmd() *cobra.Command {
187	return &cobra.Command{
188		Use:   "version",
189		Short: "Print version information",
190		Run: func(cmd *cobra.Command, args []string) {
191			cmd.Printf("Gordon %s\n", Version)
192			cmd.Printf("Commit: %s\n", Commit)
193			cmd.Printf("Build Date: %s\n", BuildDate)
194		},
195	}
196}
197
198// runReload sends SIGUSR1 to the running Gordon process.
199func runReload() error {
200	return app.SendReloadSignal()
201}
202
203// runReloadRemote triggers a reload on a remote Gordon instance.
204func runReloadRemote(ctx context.Context, client *remote.Client) error {
205	if err := client.Reload(ctx); err != nil {
206		if shouldFallbackToLocal(err) {
207			localErr := runReload()
208			if localErr == nil {
209				fmt.Printf("Remote reload failed (%v), used local signal fallback\n", err)
210				fmt.Println("Configuration reloaded successfully")
211				return nil
212			}
213			return fmt.Errorf("remote reload failed: %w; local fallback failed: %v", err, localErr)
214		}
215		return fmt.Errorf("failed to reload: %w", err)
216	}
217	fmt.Println("Configuration reloaded successfully")
218	return nil
219}
220
221// newLogsCmd creates the logs command.
222func newLogsCmd() *cobra.Command {
223	var follow bool
224	var lines int
225	var logsConfigPath string
226
227	cmd := &cobra.Command{
228		Use:   "logs [domain]",
229		Short: "Show logs (Gordon process or container)",
230		Long: `Shows logs from the Gordon process or a specific container.
231
232Without a domain argument, shows Gordon process logs.
233With a domain argument, shows container logs for that domain.
234
235Examples:
236  gordon logs                    # Gordon process logs
237  gordon logs -f                 # Follow process logs
238  gordon logs myapp.local        # Container logs for myapp.local
239  gordon logs myapp.local -f     # Follow container logs
240
241Remote mode:
242  gordon logs --remote https://gordon.mydomain.com --token $TOKEN
243  gordon logs myapp.local --remote https://gordon.mydomain.com --token $TOKEN`,
244		Args: cobra.MaximumNArgs(1),
245		RunE: func(cmd *cobra.Command, args []string) error {
246			logDomain := ""
247			if len(args) > 0 {
248				logDomain = args[0]
249			}
250			return runLogs(logsConfigPath, logDomain, follow, lines)
251		},
252	}
253
254	cmd.Flags().StringVarP(&logsConfigPath, "config", "c", "", "Path to config file")
255	cmd.Flags().BoolVarP(&follow, "follow", "f", false, "Follow log output")
256	cmd.Flags().IntVarP(&lines, "lines", "n", 50, "Number of lines to show")
257
258	return cmd
259}
260
261// runLogs handles the logs command logic.
262func runLogs(logsConfigPath, logDomain string, follow bool, lines int) error {
263	client, isRemote := GetRemoteClient()
264	if isRemote {
265		return runLogsRemote(client, logDomain, follow, lines)
266	}
267	return runLogsLocal(logsConfigPath, logDomain, follow, lines)
268}
269
270// runLogsRemote fetches logs from a remote Gordon instance.
271func runLogsRemote(client *remote.Client, logDomain string, follow bool, lines int) error {
272	ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
273	defer cancel()
274
275	if follow {
276		return streamLogsRemote(ctx, client, logDomain, lines)
277	}
278
279	if logDomain == "" {
280		// Process logs
281		logLines, err := client.GetProcessLogs(ctx, lines)
282		if err != nil {
283			return fmt.Errorf("failed to get process logs: %w", err)
284		}
285		for _, line := range logLines {
286			fmt.Println(line)
287		}
288	} else {
289		// Container logs
290		logLines, err := client.GetContainerLogs(ctx, logDomain, lines)
291		if err != nil {
292			return fmt.Errorf("failed to get container logs: %w", err)
293		}
294		for _, line := range logLines {
295			fmt.Println(line)
296		}
297	}
298	return nil
299}
300
301// streamLogsRemote streams logs from a remote Gordon instance.
302func streamLogsRemote(ctx context.Context, client *remote.Client, logDomain string, lines int) error {
303	var ch <-chan string
304	var err error
305
306	if logDomain == "" {
307		ch, err = client.StreamProcessLogs(ctx, lines)
308	} else {
309		ch, err = client.StreamContainerLogs(ctx, logDomain, lines)
310	}
311	if err != nil {
312		return fmt.Errorf("failed to stream logs: %w", err)
313	}
314
315	for line := range ch {
316		fmt.Println(line)
317	}
318	return nil
319}
320
321// runLogsLocal shows logs from a local Gordon instance.
322func runLogsLocal(logsConfigPath, logDomain string, follow bool, lines int) error {
323	if logDomain == "" {
324		// Process logs - use existing app.ShowLogs
325		return app.ShowLogs(logsConfigPath, follow, lines)
326	}
327
328	// Container logs - use local services
329	return showContainerLogsLocal(logsConfigPath, logDomain, follow, lines)
330}
331
332// showContainerLogsLocal shows container logs using local Docker access.
333func showContainerLogsLocal(logsConfigPath, logDomain string, follow bool, lines int) error {
334	// For local container logs, we need Docker access which requires
335	// the runtime to be initialized. For now, suggest using remote mode
336	// or direct docker logs command.
337	fmt.Printf("Container logs for %s\n", logDomain)
338	fmt.Println("To view container logs locally, use:")
339	fmt.Printf("  docker logs --tail %d %s\n", lines, logDomain)
340	if follow {
341		fmt.Printf("  docker logs -f --tail %d %s\n", lines, logDomain)
342	}
343	fmt.Println("\nOr use remote mode to access logs via the admin API.")
344	return nil
345}
346
347// SetVersionInfo sets the version information for the CLI.
348func SetVersionInfo(version, commit, date string) {
349	Version = version
350	Commit = commit
351	BuildDate = date
352}

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