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}