1package cli
2
3import (
4 "context"
5 "fmt"
6 "os"
7 "strings"
8
9 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
10 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
11 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
12 "github.com/bnema/gordon/internal/domain"
13
14 "github.com/spf13/cobra"
15)
16
17// configPath for local operations. If empty, config is auto-discovered
18// from standard locations (/etc/gordon/gordon.toml, ~/.config/gordon/gordon.toml, ./gordon.toml).
19var configPath string
20
21// truncateImage shortens long image references for display.
22// For digests (image@sha256:...), shows first 12 chars of digest.
23// For tags, truncates to maxLen with ellipsis if needed.
24func truncateImage(image string, maxLen int) string {
25 // Handle digest references: image@sha256:abc123...
26 if maxLen <= 0 {
27 return ""
28 }
29
30 if idx := strings.Index(image, "@sha256:"); idx != -1 {
31 name := image[:idx]
32 digest := image[idx+8:] // Skip "@sha256:"
33 if len(digest) > 12 {
34 digest = digest[:12]
35 }
36 short := fmt.Sprintf("%s@sha256:%s", name, digest)
37 if len(short) <= maxLen {
38 return short
39 }
40 // Truncate from the end to maintain valid reference format
41 if maxLen <= 3 {
42 return short[:maxLen]
43 }
44 return short[:maxLen-3] + "..."
45 }
46
47 // Regular tag: truncate if needed
48 if len(image) <= maxLen {
49 return image
50 }
51 if maxLen <= 3 {
52 return image[:maxLen]
53 }
54 return image[:maxLen-3] + "..."
55}
56
57func truncateNetwork(network string, maxLen int) string {
58 if network == "" || network == "-" {
59 return "-"
60 }
61 if maxLen <= 0 {
62 return ""
63 }
64 if len(network) <= maxLen {
65 return network
66 }
67 if maxLen <= 3 {
68 return network[:maxLen]
69 }
70 return network[:maxLen-3] + "..."
71}
72
73// newRoutesCmd creates the routes command group.
74func newRoutesCmd() *cobra.Command {
75 cmd := &cobra.Command{
76 Use: "routes",
77 Short: "Manage routes",
78 Long: `Manage Gordon routes. Routes map domains to container images.
79
80When targeting a remote Gordon instance (via --remote flag or GORDON_REMOTE env var),
81these commands operate on the remote server. Otherwise, they require access to
82the local Gordon configuration.`,
83 }
84
85 cmd.AddCommand(newRoutesListCmd())
86 cmd.AddCommand(newRoutesAddCmd())
87 cmd.AddCommand(newRoutesRemoveCmd())
88
89 return cmd
90}
91
92// formatHTTPStatus formats the HTTP status for display.
93func formatHTTPStatus(health *remote.RouteHealth) string {
94 if health == nil {
95 return styles.Theme.Muted.Render("-")
96 }
97 if health.HTTPStatus == 0 {
98 if health.Error != "" {
99 return styles.Theme.BadgeError.Render("err")
100 }
101 return styles.Theme.Muted.Render("-")
102 }
103 status := fmt.Sprintf("%d", health.HTTPStatus)
104 if health.ResponseTimeMs > 0 {
105 status = fmt.Sprintf("%d (%dms)", health.HTTPStatus, health.ResponseTimeMs)
106 }
107 if health.HTTPStatus >= 200 && health.HTTPStatus < 400 {
108 return styles.Theme.BadgeSuccess.Render(status)
109 }
110 return styles.Theme.BadgeError.Render(status)
111}
112
113// newRoutesListCmd creates the routes list command.
114func newRoutesListCmd() *cobra.Command {
115 return &cobra.Command{
116 Use: "list",
117 Short: "List all routes",
118 RunE: func(cmd *cobra.Command, args []string) error {
119 ctx := context.Background()
120
121 client, isRemote := GetRemoteClient()
122 if isRemote {
123 return runRoutesListRemote(ctx, client)
124 }
125 return runRoutesListLocal(ctx, configPath)
126 },
127 }
128}
129
130// runRoutesListRemote lists routes from a remote Gordon instance.
131func runRoutesListRemote(ctx context.Context, client *remote.Client) error {
132 routes, err := client.ListRoutesWithDetails(ctx)
133 if err != nil {
134 return fmt.Errorf("failed to list routes: %w", err)
135 }
136
137 if len(routes) == 0 {
138 fmt.Println(styles.Theme.Muted.Render("No routes configured"))
139 return nil
140 }
141
142 // Get health status for each route (includes container status and HTTP probe)
143 health, _ := client.GetHealth(ctx)
144 if health == nil {
145 health = make(map[string]*remote.RouteHealth)
146 }
147
148 const imageColWidth = 35
149 const networkColWidth = 22
150 rows := make([][]string, 0, len(routes))
151 for _, route := range routes {
152 routeHealth := health[route.Domain]
153
154 // Container status column
155 containerStatus := route.ContainerStatus
156 if containerStatus == "" {
157 if routeHealth != nil {
158 containerStatus = routeHealth.ContainerStatus
159 } else {
160 containerStatus = "unknown"
161 }
162 }
163 containerBadge := components.ContainerStatusBadge(containerStatus)
164
165 // HTTP status column
166 httpStatus := formatHTTPStatus(routeHealth)
167
168 displayImage := truncateImage(route.Image, imageColWidth)
169 displayNetwork := truncateNetwork(route.Network, networkColWidth)
170 rows = append(rows, []string{route.Domain, displayImage, displayNetwork, containerBadge, httpStatus})
171
172 for i, attachment := range route.Attachments {
173 // Unicode box-drawing tree structure for nested attachments
174 prefix := styles.IconTreeBranch + styles.IconTreeLine
175 if i == len(route.Attachments)-1 {
176 prefix = styles.IconTreeLast + styles.IconTreeLine
177 }
178 attachmentName := prefix + " " + attachment.Name
179 runes := []rune(attachmentName)
180 if len(runes) > 25 {
181 attachmentName = string(runes[:22]) + "..."
182 }
183 attachmentStatus := components.ContainerStatusBadge(attachment.Status)
184 attachmentImage := truncateImage(attachment.Image, imageColWidth)
185 // Display the attachment's actual network in the routes table
186 attachmentNetwork := truncateNetwork(attachment.Network, networkColWidth)
187 rows = append(rows, []string{attachmentName, attachmentImage, attachmentNetwork, attachmentStatus, "-"})
188 }
189 }
190
191 // Render table
192 table := components.NewTable(
193 components.WithColumns([]components.TableColumn{
194 {Title: "Domain", Width: 25},
195 {Title: "Image", Width: imageColWidth},
196 {Title: "Network", Width: networkColWidth},
197 {Title: "Container", Width: 12},
198 {Title: "HTTP", Width: 14},
199 }),
200 components.WithRows(rows),
201 )
202
203 fmt.Println(styles.Theme.Title.Render("Routes"))
204 fmt.Println()
205 fmt.Println(table.View())
206
207 return nil
208}
209
210// runRoutesListLocal lists routes from local configuration.
211func runRoutesListLocal(ctx context.Context, cfgPath string) error {
212 local, err := GetLocalServices(cfgPath)
213 if err != nil {
214 return fmt.Errorf("failed to initialize local services: %w", err)
215 }
216
217 routes := local.GetConfigService().GetRoutes(ctx)
218
219 if len(routes) == 0 {
220 fmt.Println(styles.Theme.Muted.Render("No routes configured"))
221 return nil
222 }
223
224 // Build table rows (no health info in local mode)
225 const imageColWidth = 45
226 rows := make([][]string, len(routes))
227 for i, route := range routes {
228 displayImage := truncateImage(route.Image, imageColWidth)
229 rows[i] = []string{route.Domain, displayImage}
230 }
231
232 // Render table
233 table := components.NewTable(
234 components.WithColumns([]components.TableColumn{
235 {Title: "Domain", Width: 30},
236 {Title: "Image", Width: imageColWidth},
237 }),
238 components.WithRows(rows),
239 )
240
241 fmt.Println(styles.Theme.Title.Render("Routes (local)"))
242 fmt.Println()
243 fmt.Println(table.View())
244
245 return nil
246}
247
248// newRoutesAddCmd creates the routes add command.
249func newRoutesAddCmd() *cobra.Command {
250 var image string
251
252 cmd := &cobra.Command{
253 Use: "add <domain> <image>",
254 Short: "Add a new route",
255 Long: `Add a new route mapping a domain to a container image.
256
257Examples:
258 gordon routes add app.mydomain.com myapp:latest
259 gordon --remote https://gordon.mydomain.com routes add api.mydomain.com api:v2`,
260 Args: cobra.RangeArgs(1, 2),
261 RunE: func(cmd *cobra.Command, args []string) error {
262 ctx := context.Background()
263 routeDomain := args[0]
264
265 // Resolve image from positional argument or flag
266 hasPositionalImage := len(args) > 1
267 hasFlagImage := image != ""
268
269 // Check for conflicts: both positional and flag provided
270 if hasPositionalImage && hasFlagImage {
271 return fmt.Errorf("error: cannot use both positional image argument and --image flag")
272 }
273
274 // Check that image is provided via either method
275 if !hasPositionalImage && !hasFlagImage {
276 return fmt.Errorf("error: image is required (use positional argument or --image flag)")
277 }
278
279 if hasPositionalImage {
280 image = args[1]
281 }
282
283 route := domain.Route{
284 Domain: routeDomain,
285 Image: image,
286 }
287
288 client, isRemote := GetRemoteClient()
289 if isRemote {
290 if err := client.AddRoute(ctx, route); err != nil {
291 return fmt.Errorf("failed to add route: %w", err)
292 }
293 } else {
294 local, err := GetLocalServices(configPath)
295 if err != nil {
296 return fmt.Errorf("failed to initialize local services: %w", err)
297 }
298 if err := local.GetConfigService().AddRoute(ctx, route); err != nil {
299 return fmt.Errorf("failed to add route: %w", err)
300 }
301 }
302
303 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route added: %s -> %s", routeDomain, image)))
304 return nil
305 },
306 }
307
308 cmd.Flags().StringVarP(&image, "image", "i", "", "Container image")
309
310 return cmd
311}
312
313// newRoutesRemoveCmd creates the routes remove command.
314func newRoutesRemoveCmd() *cobra.Command {
315 var force bool
316
317 cmd := &cobra.Command{
318 Use: "remove <domain>",
319 Short: "Remove a route",
320 Long: `Remove a route by its domain name.
321
322Examples:
323 gordon routes remove app.mydomain.com
324 gordon routes remove app.mydomain.com --force`,
325 Args: cobra.ExactArgs(1),
326 RunE: func(cmd *cobra.Command, args []string) error {
327 ctx := context.Background()
328 routeDomain := args[0]
329
330 // Confirm unless --force
331 if !force {
332 confirmed, err := components.RunConfirm(
333 fmt.Sprintf("Remove route '%s'?", routeDomain),
334 components.WithDescription("This will stop and remove the associated container."),
335 )
336 if err != nil {
337 return err
338 }
339 if !confirmed {
340 fmt.Println(styles.Theme.Muted.Render("Cancelled"))
341 return nil
342 }
343 }
344
345 client, isRemote := GetRemoteClient()
346 if isRemote {
347 if err := client.RemoveRoute(ctx, routeDomain); err != nil {
348 return fmt.Errorf("failed to remove route: %w", err)
349 }
350 } else {
351 local, err := GetLocalServices(configPath)
352 if err != nil {
353 return fmt.Errorf("failed to initialize local services: %w", err)
354 }
355 if err := local.GetConfigService().RemoveRoute(ctx, routeDomain); err != nil {
356 return fmt.Errorf("failed to remove route: %w", err)
357 }
358 }
359
360 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route removed: %s", routeDomain)))
361 return nil
362 },
363 }
364
365 cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation")
366
367 return cmd
368}
369
370// newStatusCmd creates the status command.
371func newStatusCmd() *cobra.Command {
372 return &cobra.Command{
373 Use: "status",
374 Short: "Show Gordon server status",
375 RunE: func(cmd *cobra.Command, args []string) error {
376 ctx := context.Background()
377
378 client, isRemote := GetRemoteClient()
379 if !isRemote {
380 fmt.Println(styles.RenderError("status command requires --remote flag or GORDON_REMOTE env var"))
381 os.Exit(1)
382 }
383
384 status, err := client.GetStatus(ctx)
385 if err != nil {
386 return fmt.Errorf("failed to get status: %w", err)
387 }
388
389 // Display status
390 fmt.Println(styles.Theme.Title.Render("Gordon Status"))
391 fmt.Println()
392
393 fmt.Printf("%s %s\n", styles.Theme.Bold.Render("Domain:"), status.RegistryDomain)
394 fmt.Printf("%s %d\n", styles.Theme.Bold.Render("Registry Port:"), status.RegistryPort)
395 fmt.Printf("%s %d\n", styles.Theme.Bold.Render("Server Port:"), status.ServerPort)
396 fmt.Printf("%s %d\n", styles.Theme.Bold.Render("Routes:"), status.Routes)
397 fmt.Printf("%s %v\n", styles.Theme.Bold.Render("Auto-Route:"), status.AutoRoute)
398 fmt.Printf("%s %v\n", styles.Theme.Bold.Render("Network Isolation:"), status.NetworkIsolation)
399
400 if len(status.ContainerStatus) > 0 {
401 fmt.Println()
402 fmt.Println(styles.Theme.Bold.Render("Container Status:"))
403 for domain, containerStatus := range status.ContainerStatus {
404 badge := components.ContainerStatusBadge(containerStatus)
405 fmt.Printf(" %s: %s\n", domain, badge)
406 }
407 }
408
409 return nil
410 },
411 }
412}