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}