1package cli
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "io"
8 "os"
9 "sort"
10 "strconv"
11 "strings"
12 "sync"
13
14 "github.com/spf13/cobra"
15
16 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
17 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
18 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
19 "github.com/bnema/gordon/internal/domain"
20)
21
22// configPath for local operations. If empty, config is auto-discovered
23// from standard locations (/etc/gordon/gordon.toml, ~/.config/gordon/gordon.toml, ./gordon.toml).
24var configPath string
25
26// truncateImage shortens long image references for display.
27// For digests (image@sha256:...), shows first 12 chars of digest.
28// For tags, truncates to maxLen with ellipsis if needed.
29func truncateImage(image string, maxLen int) string {
30 // Handle digest references: image@sha256:abc123...
31 if maxLen <= 0 {
32 return ""
33 }
34
35 if idx := strings.Index(image, "@sha256:"); idx != -1 {
36 name := image[:idx]
37 digest := image[idx+8:] // Skip "@sha256:"
38 if len(digest) > 12 {
39 digest = digest[:12]
40 }
41 short := fmt.Sprintf("%s@sha256:%s", name, digest)
42 if len(short) <= maxLen {
43 return short
44 }
45 // Truncate from the end to maintain valid reference format
46 if maxLen <= 3 {
47 return short[:maxLen]
48 }
49 return short[:maxLen-3] + "..."
50 }
51
52 // Regular tag: truncate if needed
53 if len(image) <= maxLen {
54 return image
55 }
56 if maxLen <= 3 {
57 return image[:maxLen]
58 }
59 return image[:maxLen-3] + "..."
60}
61
62const networkPrefix = "gordon-"
63
64// httpHealthToStatus maps an HTTP health probe result to a components.Status.
65func httpHealthToStatus(health *remote.RouteHealth) components.Status {
66 if health == nil {
67 return components.StatusUnknown
68 }
69 if health.HTTPStatus == 0 {
70 if health.Error != "" {
71 return components.StatusError
72 }
73 return components.StatusUnknown
74 }
75 if health.HTTPStatus >= 200 && health.HTTPStatus < 400 {
76 return components.StatusSuccess
77 }
78 return components.StatusError
79}
80
81// stripNetworkPrefix removes the "gordon-" prefix from a network name for display.
82func stripNetworkPrefix(network string) string {
83 return strings.TrimPrefix(network, networkPrefix)
84}
85
86// networkGroup holds routes that share a network.
87type networkGroup struct {
88 name string
89 routes []remote.RouteInfo
90}
91
92type routeListItem struct {
93 Domain string `json:"domain"`
94 Image string `json:"image"`
95}
96
97type routeListSection struct {
98 Kind string `json:"kind"`
99 Name string `json:"name"`
100 URL string `json:"url,omitempty"`
101 Error string `json:"error,omitempty"`
102 Routes []routeListItem `json:"routes,omitempty"`
103}
104
105type routeStatusAttachment struct {
106 Name string `json:"name"`
107 Image string `json:"image"`
108 Status string `json:"status"`
109}
110
111type routeStatusItem struct {
112 Domain string `json:"domain"`
113 Image string `json:"image"`
114 ContainerID string `json:"container_id,omitempty"`
115 ContainerStatus string `json:"container_status"`
116 HTTPStatus int `json:"http_status"`
117 HealthError string `json:"health_error,omitempty"`
118 Network string `json:"network"`
119 Attachments []routeStatusAttachment `json:"attachments,omitempty"`
120}
121
122type routeStatusSection struct {
123 Kind string `json:"kind"`
124 Name string `json:"name"`
125 URL string `json:"url,omitempty"`
126 Error string `json:"error,omitempty"`
127 Routes []routeStatusItem `json:"routes,omitempty"`
128}
129
130type routesListDeps struct {
131 explicitRemote func() (*remote.ResolvedRemote, bool, error)
132 loadLocal func(context.Context, string) (routeListSection, error)
133 listRemotes func() (map[string]remote.RemoteEntry, string, error)
134 loadRemote func(context.Context, string, remote.RemoteEntry) (routeListSection, error)
135}
136
137type routesStatusDeps struct {
138 explicitRemote func() (*remote.ResolvedRemote, bool, error)
139 loadLocal func(context.Context, string) (routeStatusSection, error)
140 listRemotes func() (map[string]remote.RemoteEntry, string, error)
141 loadRemote func(context.Context, string, remote.RemoteEntry) (routeStatusSection, error)
142}
143
144// groupRoutesByNetwork separates routes into network groups (2+ routes sharing a network)
145// and solo routes. Both are sorted alphabetically by domain.
146func groupRoutesByNetwork(routes []remote.RouteInfo) ([]networkGroup, []remote.RouteInfo) {
147 byNetwork := make(map[string][]remote.RouteInfo)
148 var networkOrder []string
149 for _, route := range routes {
150 if _, seen := byNetwork[route.Network]; !seen {
151 networkOrder = append(networkOrder, route.Network)
152 }
153 byNetwork[route.Network] = append(byNetwork[route.Network], route)
154 }
155
156 var groups []networkGroup
157 var solo []remote.RouteInfo
158
159 for _, net := range networkOrder {
160 members := byNetwork[net]
161 if len(members) >= 2 {
162 sort.Slice(members, func(i, j int) bool {
163 return members[i].Domain < members[j].Domain
164 })
165 groups = append(groups, networkGroup{
166 name: stripNetworkPrefix(net),
167 routes: members,
168 })
169 } else {
170 solo = append(solo, members[0])
171 }
172 }
173
174 sort.Slice(solo, func(i, j int) bool {
175 return solo[i].Domain < solo[j].Domain
176 })
177
178 return groups, solo
179}
180
181// newRoutesCmd creates the routes command group.
182func newRoutesCmd() *cobra.Command {
183 cmd := &cobra.Command{
184 Use: "routes",
185 Short: "Manage routes",
186 Long: `Manage Gordon routes. Routes map domains to container images.
187
188When targeting a remote Gordon instance (via --remote flag or GORDON_REMOTE env var),
189these commands operate on the remote server. Otherwise, they require access to
190the local Gordon configuration.`,
191 }
192
193 cmd.AddCommand(newRoutesListCmd())
194 cmd.AddCommand(newRoutesStatusCmd())
195
196 cmd.AddCommand(newRoutesShowCmd())
197 cmd.AddCommand(newRoutesAddCmd())
198 cmd.AddCommand(newRoutesRemoveCmd())
199
200 return cmd
201}
202
203// newRoutesListCmd creates the routes list command.
204func newRoutesListCmd() *cobra.Command {
205 var jsonOut bool
206
207 cmd := &cobra.Command{
208 Use: "list",
209 Short: "List all routes",
210 RunE: func(cmd *cobra.Command, args []string) error {
211 sections, err := collectRoutesListSections(cmd.Context(), configPath, routesListDeps{})
212 if err != nil {
213 return err
214 }
215
216 if jsonOut {
217 return writeJSON(cmd.OutOrStdout(), sections)
218 }
219
220 return renderRoutesListSections(cmd.OutOrStdout(), sections)
221 },
222 }
223
224 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
225
226 return cmd
227}
228
229func newRoutesStatusCmd() *cobra.Command {
230 var jsonOut bool
231
232 cmd := &cobra.Command{
233 Use: "status",
234 Short: "Show detailed route status",
235 RunE: func(cmd *cobra.Command, args []string) error {
236 sections, err := collectRoutesStatusSections(cmd.Context(), configPath, routesStatusDeps{})
237 if err != nil {
238 return err
239 }
240
241 if jsonOut {
242 return writeJSON(cmd.OutOrStdout(), sections)
243 }
244
245 return renderRoutesStatusSections(cmd.OutOrStdout(), sections)
246 },
247 }
248
249 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
250
251 return cmd
252}
253
254func collectRoutesListSections(ctx context.Context, cfgPath string, deps routesListDeps) ([]routeListSection, error) {
255 if deps.explicitRemote == nil {
256 deps.explicitRemote = resolveRoutesExplicitRemote
257 }
258 if deps.loadLocal == nil {
259 deps.loadLocal = loadRoutesListLocalSection
260 }
261 if deps.listRemotes == nil {
262 deps.listRemotes = remote.ListRemotes
263 }
264 if deps.loadRemote == nil {
265 deps.loadRemote = loadRoutesListRemoteSection
266 }
267
268 if resolved, ok, err := deps.explicitRemote(); err != nil {
269 return nil, err
270 } else if ok {
271 section := loadRoutesListExplicitRemoteSection(ctx, deps, resolved)
272 return []routeListSection{section}, nil
273 }
274 if target, ok := resolveRoutesExplicitTarget(); ok {
275 return []routeListSection{{
276 Kind: "remote",
277 Name: target,
278 Error: unresolvedRoutesTargetError(target),
279 }}, nil
280 }
281
282 return collectRoutesListAggregateSections(ctx, cfgPath, deps)
283}
284
285func loadRoutesListExplicitRemoteSection(ctx context.Context, deps routesListDeps, resolved *remote.ResolvedRemote) routeListSection {
286 section, err := deps.loadRemote(ctx, resolved.DisplayName(), remote.RemoteEntry{URL: resolved.URL, Token: resolved.Token, InsecureTLS: resolved.InsecureTLS})
287 if err != nil && section.Error == "" {
288 section.Error = err.Error()
289 }
290 return normalizeRouteListRemoteSection(section, resolved.DisplayName(), remote.RemoteEntry{URL: resolved.URL, Token: resolved.Token, InsecureTLS: resolved.InsecureTLS})
291}
292
293func collectRoutesListAggregateSections(ctx context.Context, cfgPath string, deps routesListDeps) ([]routeListSection, error) {
294
295 type localResult struct {
296 section routeListSection
297 err error
298 }
299 type remotesResult struct {
300 entries map[string]remote.RemoteEntry
301 err error
302 }
303
304 localCh := make(chan localResult, 1)
305 remotesCh := make(chan remotesResult, 1)
306
307 go func() {
308 section, err := deps.loadLocal(ctx, cfgPath)
309 localCh <- localResult{section: section, err: err}
310 }()
311 go func() {
312 entries, _, err := deps.listRemotes()
313 remotesCh <- remotesResult{entries: entries, err: err}
314 }()
315
316 localRes := <-localCh
317 remotes := <-remotesCh
318 local := localRes.section
319 if err := localRes.err; err != nil {
320 if local.Error == "" {
321 local.Error = err.Error()
322 }
323 }
324
325 sections := make([]routeListSection, 0, 1)
326 sections = append(sections, normalizeRouteListLocalSection(local))
327
328 if remotes.err != nil {
329 sections = append(sections, routeListSection{Kind: "remote", Name: "remotes", Error: remotes.err.Error()})
330 return sections, nil
331 }
332
333 names := sortedRemoteNames(remotes.entries)
334 remoteSections := make([]routeListSection, len(names))
335 var wg sync.WaitGroup
336 for i, name := range names {
337 i, name := i, name
338 entry := remotes.entries[name]
339 wg.Add(1)
340 go func() {
341 defer wg.Done()
342 section, err := deps.loadRemote(ctx, name, entry)
343 if err != nil && section.Error == "" {
344 section.Error = err.Error()
345 }
346 remoteSections[i] = normalizeRouteListRemoteSection(section, name, entry)
347 }()
348 }
349 wg.Wait()
350
351 sections = append(sections, remoteSections...)
352 return sections, nil
353}
354
355func normalizeRouteListLocalSection(section routeListSection) routeListSection {
356 if section.Kind == "" {
357 section.Kind = "local"
358 }
359 if section.Name == "" {
360 section.Name = "local"
361 }
362 return section
363}
364
365func normalizeRouteListRemoteSection(section routeListSection, name string, entry remote.RemoteEntry) routeListSection {
366 if section.Kind == "" {
367 section.Kind = "remote"
368 }
369 if section.Name == "" {
370 section.Name = name
371 }
372 if section.URL == "" {
373 section.URL = entry.URL
374 }
375 return section
376}
377
378func collectRoutesStatusSections(ctx context.Context, cfgPath string, deps routesStatusDeps) ([]routeStatusSection, error) {
379 if deps.explicitRemote == nil {
380 deps.explicitRemote = resolveRoutesExplicitRemote
381 }
382 if deps.loadLocal == nil {
383 deps.loadLocal = loadRoutesStatusLocalSection
384 }
385 if deps.listRemotes == nil {
386 deps.listRemotes = remote.ListRemotes
387 }
388 if deps.loadRemote == nil {
389 deps.loadRemote = loadRoutesStatusRemoteSection
390 }
391
392 if resolved, ok, err := deps.explicitRemote(); err != nil {
393 return nil, err
394 } else if ok {
395 section := loadRoutesStatusExplicitRemoteSection(ctx, deps, resolved)
396 return []routeStatusSection{section}, nil
397 }
398 if target, ok := resolveRoutesExplicitTarget(); ok {
399 return []routeStatusSection{{
400 Kind: "remote",
401 Name: target,
402 Error: unresolvedRoutesTargetError(target),
403 }}, nil
404 }
405
406 return collectRoutesStatusAggregateSections(ctx, cfgPath, deps)
407}
408
409func loadRoutesStatusExplicitRemoteSection(ctx context.Context, deps routesStatusDeps, resolved *remote.ResolvedRemote) routeStatusSection {
410 section, err := deps.loadRemote(ctx, resolved.DisplayName(), remote.RemoteEntry{URL: resolved.URL, Token: resolved.Token, InsecureTLS: resolved.InsecureTLS})
411 if err != nil && section.Error == "" {
412 section.Error = err.Error()
413 }
414 return normalizeRouteStatusRemoteSection(section, resolved.DisplayName(), remote.RemoteEntry{URL: resolved.URL, Token: resolved.Token, InsecureTLS: resolved.InsecureTLS})
415}
416
417func collectRoutesStatusAggregateSections(ctx context.Context, cfgPath string, deps routesStatusDeps) ([]routeStatusSection, error) {
418
419 type localResult struct {
420 section routeStatusSection
421 err error
422 }
423 type remotesResult struct {
424 entries map[string]remote.RemoteEntry
425 err error
426 }
427
428 localCh := make(chan localResult, 1)
429 remotesCh := make(chan remotesResult, 1)
430
431 go func() {
432 section, err := deps.loadLocal(ctx, cfgPath)
433 localCh <- localResult{section: section, err: err}
434 }()
435 go func() {
436 entries, _, err := deps.listRemotes()
437 remotesCh <- remotesResult{entries: entries, err: err}
438 }()
439
440 localRes := <-localCh
441 remotes := <-remotesCh
442 local := localRes.section
443 if err := localRes.err; err != nil {
444 if local.Error == "" {
445 local.Error = err.Error()
446 }
447 }
448
449 sections := make([]routeStatusSection, 0, 1)
450 sections = append(sections, normalizeRouteStatusLocalSection(local))
451
452 if remotes.err != nil {
453 sections = append(sections, routeStatusSection{Kind: "remote", Name: "remotes", Error: remotes.err.Error()})
454 return sections, nil
455 }
456
457 names := sortedRemoteNames(remotes.entries)
458 remoteSections := make([]routeStatusSection, len(names))
459 var wg sync.WaitGroup
460 for i, name := range names {
461 i, name := i, name
462 entry := remotes.entries[name]
463 wg.Add(1)
464 go func() {
465 defer wg.Done()
466 section, err := deps.loadRemote(ctx, name, entry)
467 if err != nil && section.Error == "" {
468 section.Error = err.Error()
469 }
470 remoteSections[i] = normalizeRouteStatusRemoteSection(section, name, entry)
471 }()
472 }
473 wg.Wait()
474
475 sections = append(sections, remoteSections...)
476 return sections, nil
477}
478
479func normalizeRouteStatusLocalSection(section routeStatusSection) routeStatusSection {
480 if section.Kind == "" {
481 section.Kind = "local"
482 }
483 if section.Name == "" {
484 section.Name = "local"
485 }
486 return section
487}
488
489func normalizeRouteStatusRemoteSection(section routeStatusSection, name string, entry remote.RemoteEntry) routeStatusSection {
490 if section.Kind == "" {
491 section.Kind = "remote"
492 }
493 if section.Name == "" {
494 section.Name = name
495 }
496 if section.URL == "" {
497 section.URL = entry.URL
498 }
499 return section
500}
501
502func renderRoutesListSections(out io.Writer, sections []routeListSection) error {
503 sections = renderedRouteListSections(sections)
504
505 if err := cliWriteLine(out, cliRenderTitle("Routes")); err != nil {
506 return err
507 }
508 if err := cliWriteLine(out, ""); err != nil {
509 return err
510 }
511
512 for i, section := range sections {
513 if i > 0 {
514 if err := cliWriteLine(out, ""); err != nil {
515 return err
516 }
517 }
518 if err := renderRoutesListSection(out, section); err != nil {
519 return err
520 }
521 }
522
523 return nil
524}
525
526func renderRoutesListSection(out io.Writer, section routeListSection) error {
527 if err := cliWriteLine(out, routeSectionHeading(section.Kind, section.Name)); err != nil {
528 return err
529 }
530
531 if len(section.Routes) > 0 {
532 const imageColWidth = 45
533 rows := make([][]string, 0, len(section.Routes))
534 for _, route := range section.Routes {
535 rows = append(rows, []string{route.Domain, truncateImage(route.Image, imageColWidth)})
536 }
537
538 table := components.NewTable(
539 components.WithColumns([]components.TableColumn{
540 {Title: "Domain", Width: 30},
541 {Title: "Image", Width: imageColWidth},
542 }),
543 components.WithRows(rows),
544 )
545
546 if err := cliWriteLine(out, table.View()); err != nil {
547 return err
548 }
549 }
550
551 if section.Error != "" {
552 return cliWriteLine(out, cliRenderWarning(section.Error))
553 }
554
555 if len(section.Routes) == 0 {
556 return cliWriteLine(out, cliRenderMuted("No routes configured"))
557 }
558
559 return nil
560}
561
562func renderRoutesStatusSections(out io.Writer, sections []routeStatusSection) error {
563 sections = renderedRouteStatusSections(sections)
564
565 if err := cliWriteLine(out, cliRenderTitle("Route Status")); err != nil {
566 return err
567 }
568 if err := cliWriteLine(out, ""); err != nil {
569 return err
570 }
571
572 for i, section := range sections {
573 if i > 0 {
574 if err := cliWriteLine(out, ""); err != nil {
575 return err
576 }
577 }
578 if err := renderRoutesStatusSection(out, section); err != nil {
579 return err
580 }
581 }
582
583 return nil
584}
585
586func renderedRouteListSections(sections []routeListSection) []routeListSection {
587 if !shouldHideLocalRouteListSection(sections) {
588 return sections
589 }
590 return sections[1:]
591}
592
593func shouldHideLocalRouteListSection(sections []routeListSection) bool {
594 if len(sections) < 2 {
595 return false
596 }
597 local := sections[0]
598 if local.Kind != "local" || local.Error == "" {
599 return false
600 }
601 for _, section := range sections[1:] {
602 if section.Kind == "remote" && section.Error == "" {
603 return true
604 }
605 }
606 return false
607}
608
609func renderedRouteStatusSections(sections []routeStatusSection) []routeStatusSection {
610 if !shouldHideLocalRouteStatusSection(sections) {
611 return sections
612 }
613 return sections[1:]
614}
615
616func shouldHideLocalRouteStatusSection(sections []routeStatusSection) bool {
617 if len(sections) < 2 {
618 return false
619 }
620 local := sections[0]
621 if local.Kind != "local" || local.Error == "" {
622 return false
623 }
624 for _, section := range sections[1:] {
625 if section.Kind == "remote" && section.Error == "" {
626 return true
627 }
628 }
629 return false
630}
631
632func renderRoutesStatusSection(out io.Writer, section routeStatusSection) error {
633 if err := cliWriteLine(out, routeSectionHeading(section.Kind, section.Name)); err != nil {
634 return err
635 }
636
637 if len(section.Routes) > 0 {
638 tree := buildRouteStatusTree(section.Routes)
639 if err := cliWriteLine(out, strings.TrimSuffix(tree.Render(), "\n")); err != nil {
640 return err
641 }
642 }
643
644 if section.Error != "" {
645 return cliWriteLine(out, cliRenderWarning(section.Error))
646 }
647
648 if len(section.Routes) == 0 {
649 return cliWriteLine(out, cliRenderMuted("No routes configured"))
650 }
651
652 return nil
653}
654
655func routeSectionHeading(kind, name string) string {
656 switch kind {
657 case "local":
658 return "Local"
659 case "remote":
660 if name != "" {
661 return "Remote: " + name
662 }
663 return "Remote"
664 default:
665 if name != "" {
666 return capitalizeKind(kind) + ": " + name
667 }
668 return capitalizeKind(kind)
669 }
670}
671
672func capitalizeKind(kind string) string {
673 if kind == "" {
674 return ""
675 }
676 if len(kind) == 1 {
677 return strings.ToUpper(kind)
678 }
679 return strings.ToUpper(kind[:1]) + kind[1:]
680}
681
682func unresolvedRoutesTargetError(target string) string {
683 return fmt.Sprintf("could not resolve remote target %q", target)
684}
685
686func resolveRoutesExplicitRemote() (*remote.ResolvedRemote, bool, error) {
687 target, ok := resolveRoutesExplicitTarget()
688 if !ok {
689 return nil, false, nil
690 }
691
692 if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") {
693 return &remote.ResolvedRemote{
694 URL: target,
695 Token: resolveRoutesTokenForTarget("", remote.RemoteEntry{}),
696 InsecureTLS: resolveRoutesInsecureForTarget("", remote.RemoteEntry{}),
697 }, true, nil
698 }
699
700 remotes, err := remote.LoadRemotes("")
701 if err != nil {
702 return nil, false, err
703 }
704
705 if remotes != nil {
706 if entry, found := remotes.Remotes[target]; found {
707 return &remote.ResolvedRemote{
708 Name: target,
709 URL: entry.URL,
710 Token: resolveRoutesTokenForTarget(target, entry),
711 InsecureTLS: resolveRoutesInsecureForTarget(target, entry),
712 }, true, nil
713 }
714 }
715
716 return nil, false, nil
717}
718
719func resolveRoutesExplicitTarget() (string, bool) {
720 if target := strings.TrimSpace(remoteFlag); target != "" {
721 return target, true
722 }
723 if target := strings.TrimSpace(os.Getenv("GORDON_REMOTE")); target != "" {
724 return target, true
725 }
726 return "", false
727}
728
729func resolveRoutesTokenForTarget(name string, entry remote.RemoteEntry) string {
730 if token := strings.TrimSpace(tokenFlag); token != "" {
731 return token
732 }
733 if token := strings.TrimSpace(os.Getenv("GORDON_TOKEN")); token != "" {
734 return token
735 }
736 if name != "" {
737 return remote.ResolveTokenForRemote(name, entry)
738 }
739 return ""
740}
741
742func resolveRoutesInsecureForTarget(name string, entry remote.RemoteEntry) bool {
743 if insecureTLSFlag {
744 return true
745 }
746 if env := strings.TrimSpace(os.Getenv("GORDON_INSECURE")); env != "" {
747 if value, err := strconv.ParseBool(env); err == nil {
748 return value
749 }
750 }
751 if name != "" {
752 return entry.InsecureTLS
753 }
754 return false
755}
756
757func newRoutesTargetClient(name string, entry remote.RemoteEntry) *remote.Client {
758 return remote.NewClient(entry.URL, remoteClientOptions(resolveRoutesTokenForTarget(name, entry), resolveRoutesInsecureForTarget(name, entry))...)
759}
760
761func loadRoutesListLocalSection(ctx context.Context, cfgPath string) (routeListSection, error) {
762 local, err := GetLocalServices(cfgPath)
763 if err != nil {
764 return routeListSection{Kind: "local", Name: "local", Error: err.Error()}, nil
765 }
766
767 routes := local.GetConfigService().GetRoutes(ctx)
768 section := routeListSection{Kind: "local", Name: "local", Routes: make([]routeListItem, 0, len(routes))}
769 for _, route := range routes {
770 section.Routes = append(section.Routes, routeListItem{Domain: route.Domain, Image: route.Image})
771 }
772
773 return section, nil
774}
775
776func loadRoutesListRemoteSection(ctx context.Context, name string, entry remote.RemoteEntry) (routeListSection, error) {
777 section := routeListSection{Kind: "remote", Name: name, URL: entry.URL}
778 client := newRoutesTargetClient(name, entry)
779 routes, err := client.ListRoutesWithDetails(ctx)
780 if err != nil {
781 section.Error = err.Error()
782 return section, nil
783 }
784
785 section.Routes = routeListItemsFromInfos(routes)
786 return section, nil
787}
788
789func loadRoutesStatusLocalSection(ctx context.Context, cfgPath string) (routeStatusSection, error) {
790 section := routeStatusSection{Kind: "local", Name: "local"}
791 handle, err := resolveLocalControlPlane(cfgPath)
792 if err != nil {
793 section.Error = err.Error()
794 return section, nil
795 }
796 defer handle.close()
797
798 routes, err := handle.plane.ListRoutesWithDetails(ctx)
799 if err != nil {
800 section.Error = err.Error()
801 return section, nil
802 }
803
804 health, err := handle.plane.GetHealth(ctx)
805 if err != nil {
806 section.Error = err.Error()
807 health = nil
808 }
809
810 section.Routes = routeStatusItemsFromInfos(routes, health)
811 return section, nil
812}
813
814func loadRoutesStatusRemoteSection(ctx context.Context, name string, entry remote.RemoteEntry) (routeStatusSection, error) {
815 section := routeStatusSection{Kind: "remote", Name: name, URL: entry.URL}
816 client := newRoutesTargetClient(name, entry)
817 cp := NewRemoteControlPlane(client)
818
819 routes, err := cp.ListRoutesWithDetails(ctx)
820 if err != nil {
821 section.Error = err.Error()
822 return section, nil
823 }
824
825 health, err := cp.GetHealth(ctx)
826 if err != nil {
827 section.Error = err.Error()
828 health = nil
829 }
830
831 section.Routes = routeStatusItemsFromInfos(routes, health)
832 return section, nil
833}
834
835func routeStatusItemsFromInfos(routes []remote.RouteInfo, health map[string]*remote.RouteHealth) []routeStatusItem {
836 items := make([]routeStatusItem, 0, len(routes))
837 for _, route := range routes {
838 item := routeStatusItem{
839 Domain: route.Domain,
840 Image: route.Image,
841 ContainerID: route.ContainerID,
842 ContainerStatus: route.ContainerStatus,
843 Network: route.Network,
844 }
845 if item.ContainerStatus == "" {
846 if routeHealth := health[route.Domain]; routeHealth != nil {
847 item.ContainerStatus = routeHealth.ContainerStatus
848 item.HTTPStatus = routeHealth.HTTPStatus
849 item.HealthError = routeHealth.Error
850 }
851 }
852 if item.ContainerStatus == "" {
853 item.ContainerStatus = "unknown"
854 }
855 if item.HTTPStatus == 0 {
856 if routeHealth := health[route.Domain]; routeHealth != nil {
857 item.HTTPStatus = routeHealth.HTTPStatus
858 item.HealthError = routeHealth.Error
859 }
860 }
861 if item.HealthError == "" {
862 if routeHealth := health[route.Domain]; routeHealth != nil {
863 item.HealthError = routeHealth.Error
864 }
865 }
866 item.Attachments = make([]routeStatusAttachment, 0, len(route.Attachments))
867 for _, attachment := range route.Attachments {
868 status := attachment.Status
869 if status == "" {
870 status = "unknown"
871 }
872 item.Attachments = append(item.Attachments, routeStatusAttachment{Name: attachment.Name, Image: attachment.Image, Status: status})
873 }
874 items = append(items, item)
875 }
876 return items
877}
878
879func routeListItemsFromInfos(routes []remote.RouteInfo) []routeListItem {
880 items := make([]routeListItem, 0, len(routes))
881 for _, route := range routes {
882 items = append(items, routeListItem{Domain: route.Domain, Image: route.Image})
883 }
884 return items
885}
886
887func buildRouteStatusTree(routes []routeStatusItem) *components.Tree {
888 sortedRoutes := make([]routeStatusItem, len(routes))
889 copy(sortedRoutes, routes)
890 sort.SliceStable(sortedRoutes, func(i, j int) bool {
891 if sortedRoutes[i].Network != sortedRoutes[j].Network {
892 return sortedRoutes[i].Network < sortedRoutes[j].Network
893 }
894 if sortedRoutes[i].Domain != sortedRoutes[j].Domain {
895 return sortedRoutes[i].Domain < sortedRoutes[j].Domain
896 }
897 if sortedRoutes[i].Image != sortedRoutes[j].Image {
898 return sortedRoutes[i].Image < sortedRoutes[j].Image
899 }
900 if sortedRoutes[i].ContainerID != sortedRoutes[j].ContainerID {
901 return sortedRoutes[i].ContainerID < sortedRoutes[j].ContainerID
902 }
903 if sortedRoutes[i].ContainerStatus != sortedRoutes[j].ContainerStatus {
904 return sortedRoutes[i].ContainerStatus < sortedRoutes[j].ContainerStatus
905 }
906 return sortedRoutes[i].HTTPStatus < sortedRoutes[j].HTTPStatus
907 })
908
909 infos := make([]remote.RouteInfo, 0, len(routes))
910 itemsByDomain := make(map[string]routeStatusItem, len(routes))
911 for _, route := range sortedRoutes {
912 infos = append(infos, remote.RouteInfo{
913 Domain: route.Domain,
914 Image: route.Image,
915 ContainerID: route.ContainerID,
916 ContainerStatus: route.ContainerStatus,
917 Network: route.Network,
918 Attachments: routeStatusAttachmentsToRemote(route.Attachments),
919 })
920 itemsByDomain[route.Domain] = route
921 }
922
923 groups, solo := groupRoutesByNetwork(infos)
924 tree := components.NewTree()
925
926 for _, group := range groups {
927 g := tree.AddGroup(group.name)
928 for _, route := range group.routes {
929 item := itemsByDomain[route.Domain]
930 node := g.AddNode(routeStatusTitle(item), item.Image)
931 addRouteStatusAttachmentChildren(node, item)
932 }
933 }
934
935 for _, route := range solo {
936 item := itemsByDomain[route.Domain]
937 node := tree.AddNode(routeStatusTitle(item), item.Image)
938 addRouteStatusAttachmentChildren(node, item)
939 }
940
941 return tree
942}
943
944func routeStatusTitle(route routeStatusItem) string {
945 containerStatus := route.ContainerStatus
946 if containerStatus == "" {
947 containerStatus = "unknown"
948 }
949
950 httpIcon := components.StatusIcon(styles.IconHTTPStatus, httpHealthToStatus(&remote.RouteHealth{HTTPStatus: route.HTTPStatus, Error: route.HealthError}))
951 containerIcon := components.StatusIcon(styles.IconContainerStatus, components.ParseStatus(containerStatus))
952
953 return httpIcon + " " + containerIcon + " " + route.Domain
954}
955
956func addRouteStatusAttachmentChildren(node *components.Node, route routeStatusItem) {
957 for _, att := range route.Attachments {
958 status := att.Status
959 if status == "" {
960 status = "unknown"
961 }
962 attIcon := components.StatusIcon(styles.IconContainerStatus, components.ParseStatus(status))
963 node.AddChild(attIcon+" "+att.Name, att.Image)
964 }
965}
966
967func routeStatusAttachmentsToRemote(attachments []routeStatusAttachment) []remote.Attachment {
968 result := make([]remote.Attachment, 0, len(attachments))
969 for _, att := range attachments {
970 result = append(result, remote.Attachment{Name: att.Name, Image: att.Image, Status: att.Status})
971 }
972 return result
973}
974
975func newRoutesShowCmd() *cobra.Command {
976 var jsonOut bool
977
978 cmd := &cobra.Command{
979 Use: "show <domain>",
980 Short: "Show details for a single route",
981 Long: `Display detailed information about a specific route including its image,
982container status, and health.
983
984Examples:
985 gordon routes show app.mydomain.com
986 gordon routes show app.mydomain.com --json
987 gordon routes show app.mydomain.com --remote https://gordon.mydomain.com`,
988 Args: cobra.ExactArgs(1),
989 RunE: func(cmd *cobra.Command, args []string) error {
990 ctx := cmd.Context()
991 handle, err := resolveControlPlane(configPath)
992 if err != nil {
993 return err
994 }
995 defer handle.close()
996 return runRoutesShow(ctx, handle.plane, cmd.OutOrStdout(), args[0], jsonOut)
997 },
998 }
999
1000 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
1001
1002 return cmd
1003}
1004
1005func runRoutesShow(ctx context.Context, cp ControlPlane, out io.Writer, routeDomain string, jsonOut bool) error {
1006 route, err := cp.GetRoute(ctx, routeDomain)
1007 if err != nil {
1008 if errors.Is(err, domain.ErrRouteNotFound) {
1009 return fmt.Errorf("route %q not found", routeDomain)
1010 }
1011 return fmt.Errorf("failed to get route: %w", err)
1012 }
1013
1014 health, err := cp.GetHealth(ctx)
1015 healthErr := ""
1016 if err != nil {
1017 healthErr = err.Error()
1018 health = nil
1019 }
1020 var routeHealth *remote.RouteHealth
1021 if health != nil {
1022 routeHealth = health[routeDomain]
1023 }
1024
1025 containerStatus := "unknown"
1026 httpStatus := 0
1027 if routeHealth != nil {
1028 if routeHealth.ContainerStatus != "" {
1029 containerStatus = routeHealth.ContainerStatus
1030 }
1031 httpStatus = routeHealth.HTTPStatus
1032 if healthErr == "" && routeHealth.Error != "" {
1033 healthErr = routeHealth.Error
1034 }
1035 }
1036
1037 if jsonOut {
1038 payload := map[string]any{
1039 "domain": route.Domain,
1040 "image": route.Image,
1041 "container_status": containerStatus,
1042 "http_status": httpStatus,
1043 }
1044 if healthErr != "" {
1045 payload["health_error"] = healthErr
1046 }
1047 return writeJSON(out, payload)
1048 }
1049
1050 return writeRouteShowText(out, route.Domain, route.Image, containerStatus, httpStatus, healthErr)
1051}
1052
1053func writeRouteShowText(out io.Writer, domainName, image, containerStatus string, httpStatus int, healthErr string) error {
1054 if err := cliWriteLine(out, cliRenderTitle("Route: "+domainName)); err != nil {
1055 return err
1056 }
1057 if err := cliWriteLine(out, ""); err != nil {
1058 return err
1059 }
1060 if err := cliWriteLine(out, cliRenderMeta("Domain:", domainName)); err != nil {
1061 return err
1062 }
1063 if err := cliWriteLine(out, cliRenderMeta("Image:", image)); err != nil {
1064 return err
1065 }
1066 if err := cliWriteLine(out, cliRenderMeta("Container:", containerStatus)); err != nil {
1067 return err
1068 }
1069 if httpStatus > 0 {
1070 if err := cliWriteLine(out, cliRenderMeta("HTTP Status:", fmt.Sprintf("%d", httpStatus))); err != nil {
1071 return err
1072 }
1073 }
1074 if healthErr != "" {
1075 if err := cliWriteLine(out, cliRenderWarning(healthErr)); err != nil {
1076 return err
1077 }
1078 }
1079
1080 return nil
1081}
1082
1083// newRoutesAddCmd creates the routes add command.
1084func newRoutesAddCmd() *cobra.Command {
1085 var image string
1086
1087 cmd := &cobra.Command{
1088 Use: "add <domain> <image>",
1089 Short: "Create or update a route",
1090 Long: `Create or update a route mapping a domain to a container image.
1091
1092If the route already exists with the same image, this is a no-op.
1093If it exists with a different image, the image is updated.
1094
1095Examples:
1096 gordon routes add app.mydomain.com myapp:latest
1097 gordon --remote https://gordon.mydomain.com routes add api.mydomain.com api:v2`,
1098 Args: cobra.RangeArgs(1, 2),
1099 RunE: func(cmd *cobra.Command, args []string) error {
1100 ctx := context.Background()
1101 routeDomain := args[0]
1102
1103 // Resolve image from positional argument or flag
1104 hasPositionalImage := len(args) > 1
1105 hasFlagImage := image != ""
1106
1107 // Check for conflicts: both positional and flag provided
1108 if hasPositionalImage && hasFlagImage {
1109 return fmt.Errorf("error: cannot use both positional image argument and --image flag")
1110 }
1111
1112 // Check that image is provided via either method
1113 if !hasPositionalImage && !hasFlagImage {
1114 return fmt.Errorf("error: image is required (use positional argument or --image flag)")
1115 }
1116
1117 if hasPositionalImage {
1118 image = args[1]
1119 }
1120
1121 route := domain.Route{
1122 Domain: routeDomain,
1123 Image: image,
1124 }
1125
1126 client, isRemote := GetRemoteClient()
1127 if isRemote {
1128 if err := client.AddRoute(ctx, route); err != nil {
1129 return fmt.Errorf("failed to add route: %w", err)
1130 }
1131 } else {
1132 local, err := GetLocalServices(configPath)
1133 if err != nil {
1134 return fmt.Errorf("failed to initialize local services: %w", err)
1135 }
1136 if err := local.GetConfigService().AddRoute(ctx, route); err != nil {
1137 return fmt.Errorf("failed to add route: %w", err)
1138 }
1139 }
1140
1141 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route configured: %s -> %s", routeDomain, image)))
1142 return nil
1143 },
1144 }
1145
1146 cmd.Flags().StringVarP(&image, "image", "i", "", "Container image")
1147
1148 return cmd
1149}
1150
1151// newRoutesRemoveCmd creates the routes remove command.
1152func newRoutesRemoveCmd() *cobra.Command {
1153 var force bool
1154
1155 cmd := &cobra.Command{
1156 Use: "remove <domain>",
1157 Short: "Remove a route",
1158 Long: `Remove a route by its domain name.
1159
1160Examples:
1161 gordon routes remove app.mydomain.com
1162 gordon routes remove app.mydomain.com --force`,
1163 Args: cobra.ExactArgs(1),
1164 RunE: func(cmd *cobra.Command, args []string) error {
1165 ctx := context.Background()
1166 routeDomain := args[0]
1167
1168 // Confirm unless --force
1169 if !force {
1170 confirmed, err := components.RunConfirm(
1171 fmt.Sprintf("Remove route '%s'?", routeDomain),
1172 components.WithDescription("This will stop and remove the associated container."),
1173 )
1174 if err != nil {
1175 return err
1176 }
1177 if !confirmed {
1178 fmt.Println(styles.Theme.Muted.Render("Cancelled"))
1179 return nil
1180 }
1181 }
1182
1183 client, isRemote := GetRemoteClient()
1184 if isRemote {
1185 if err := client.RemoveRoute(ctx, routeDomain); err != nil {
1186 return fmt.Errorf("failed to remove route: %w", err)
1187 }
1188 } else {
1189 local, err := GetLocalServices(configPath)
1190 if err != nil {
1191 return fmt.Errorf("failed to initialize local services: %w", err)
1192 }
1193 if err := local.GetConfigService().RemoveRoute(ctx, routeDomain); err != nil {
1194 return fmt.Errorf("failed to remove route: %w", err)
1195 }
1196 }
1197
1198 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route removed: %s", routeDomain)))
1199 return nil
1200 },
1201 }
1202
1203 cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation")
1204
1205 return cmd
1206}
1207
1208// newStatusCmd creates the status command.
1209func newStatusCmd() *cobra.Command {
1210 return &cobra.Command{
1211 Use: "status",
1212 Short: "Show Gordon server status",
1213 RunE: func(cmd *cobra.Command, args []string) error {
1214 ctx := context.Background()
1215
1216 handle, err := resolveControlPlane(configPath)
1217 if err != nil {
1218 return err
1219 }
1220 defer handle.close()
1221
1222 status, err := handle.plane.GetStatus(ctx)
1223 if err != nil {
1224 return fmt.Errorf("failed to get status: %w", err)
1225 }
1226
1227 // Display status
1228 fmt.Println(styles.Theme.Title.Render("Gordon Status"))
1229 fmt.Println()
1230
1231 fmt.Printf("%s %s\n", styles.Theme.Bold.Render("Domain:"), status.RegistryDomain)
1232 fmt.Printf("%s %d\n", styles.Theme.Bold.Render("Registry Port:"), status.RegistryPort)
1233 fmt.Printf("%s %d\n", styles.Theme.Bold.Render("Server Port:"), status.ServerPort)
1234 fmt.Printf("%s %d\n", styles.Theme.Bold.Render("Routes:"), status.Routes)
1235 fmt.Printf("%s %v\n", styles.Theme.Bold.Render("Auto-Route:"), status.AutoRoute)
1236 fmt.Printf("%s %v\n", styles.Theme.Bold.Render("Network Isolation:"), status.NetworkIsolation)
1237
1238 if len(status.ContainerStatus) > 0 {
1239 fmt.Println()
1240 fmt.Println(styles.Theme.Bold.Render("Container Status:"))
1241 for domain, containerStatus := range status.ContainerStatus {
1242 badge := components.ContainerStatusBadge(containerStatus)
1243 fmt.Printf(" %s: %s\n", domain, badge)
1244 }
1245 }
1246
1247 return nil
1248 },
1249 }
1250}