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