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