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/dto"
17 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
18 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
19 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
20 "github.com/bnema/gordon/internal/domain"
21)
22
23// configPath for local operations. If empty, config is auto-discovered
24// from standard locations (/etc/gordon/gordon.toml, ~/.config/gordon/gordon.toml, ./gordon.toml).
25var configPath string
26
27// truncateImage shortens long image references for display.
28// For digests (image@sha256:...), shows first 12 chars of digest.
29// For tags, truncates to maxLen with ellipsis if needed.
30func truncateImage(image string, maxLen int) string {
31 // Handle digest references: image@sha256:abc123...
32 if maxLen <= 0 {
33 return ""
34 }
35
36 if name, digest, ok := strings.Cut(image, "@sha256:"); ok {
37 if len(digest) > 12 {
38 digest = digest[:12]
39 }
40 short := fmt.Sprintf("%s@sha256:%s", name, digest)
41 if len(short) <= maxLen {
42 return short
43 }
44 // Truncate from the end to maintain valid reference format
45 if maxLen <= 3 {
46 return short[:maxLen]
47 }
48 return short[:maxLen-3] + "..."
49 }
50
51 // Regular tag: truncate if needed
52 if len(image) <= maxLen {
53 return image
54 }
55 if maxLen <= 3 {
56 return image[:maxLen]
57 }
58 return image[:maxLen-3] + "..."
59}
60
61const networkPrefix = "gordon-"
62
63// httpHealthToStatus maps an HTTP health probe result to a components.Status.
64func httpHealthToStatus(health *remote.RouteHealth) components.Status {
65 if health == nil {
66 return components.StatusUnknown
67 }
68 if health.HTTPStatus == 0 {
69 if health.Error != "" {
70 return components.StatusError
71 }
72 return components.StatusUnknown
73 }
74 if health.HTTPStatus >= 200 && health.HTTPStatus < 400 {
75 return components.StatusSuccess
76 }
77 return components.StatusError
78}
79
80// stripNetworkPrefix removes the "gordon-" prefix from a network name for display.
81func stripNetworkPrefix(network string) string {
82 return strings.TrimPrefix(network, networkPrefix)
83}
84
85// networkGroup holds routes that share a network.
86type networkGroup struct {
87 name string
88 routes []remote.RouteInfo
89}
90
91type routeListItem struct {
92 Domain string `json:"domain"`
93 Image string `json:"image"`
94}
95
96type routeListSection struct {
97 Kind string `json:"kind"`
98 Name string `json:"name"`
99 URL string `json:"url,omitempty"`
100 Error string `json:"error,omitempty"`
101 Routes []routeListItem `json:"routes,omitempty"`
102}
103
104type routeStatusAttachment struct {
105 Name string `json:"name"`
106 Image string `json:"image"`
107 Status string `json:"status"`
108}
109
110type routeStatusItem struct {
111 Domain string `json:"domain"`
112 Image string `json:"image"`
113 ContainerID string `json:"container_id,omitempty"`
114 ContainerStatus string `json:"container_status"`
115 HTTPStatus int `json:"http_status"`
116 HealthError string `json:"health_error,omitempty"`
117 Network string `json:"network"`
118 Attachments []routeStatusAttachment `json:"attachments,omitempty"`
119}
120
121type routeStatusSection struct {
122 Kind string `json:"kind"`
123 Name string `json:"name"`
124 URL string `json:"url,omitempty"`
125 Error string `json:"error,omitempty"`
126 Routes []routeStatusItem `json:"routes,omitempty"`
127}
128
129type routeDiagnosis struct {
130 Domain string `json:"domain"`
131 Configured bool `json:"configured"`
132 Route *domain.Route `json:"route,omitempty"`
133 Runtime *remote.RouteInfo `json:"runtime,omitempty"`
134 Health *remote.RouteHealth `json:"health,omitempty"`
135 Volumes []dto.Volume `json:"volumes,omitempty"`
136 OrphanedAttachments []domain.CleanupAttachment `json:"orphaned_attachments,omitempty"`
137 Warnings []string `json:"warnings,omitempty"`
138 Hints []string `json:"hints,omitempty"`
139}
140
141type routesListDeps struct {
142 explicitRemote func() (*remote.ResolvedRemote, bool, error)
143 loadLocal func(context.Context, string) (routeListSection, error)
144 listRemotes func() (map[string]remote.RemoteEntry, string, error)
145 loadRemote func(context.Context, string, remote.RemoteEntry) (routeListSection, error)
146}
147
148type routesStatusDeps struct {
149 explicitRemote func() (*remote.ResolvedRemote, bool, error)
150 loadLocal func(context.Context, string) (routeStatusSection, error)
151 listRemotes func() (map[string]remote.RemoteEntry, string, error)
152 loadRemote func(context.Context, string, remote.RemoteEntry) (routeStatusSection, error)
153}
154
155// groupRoutesByNetwork separates routes into network groups (2+ routes sharing a network)
156// and solo routes. Both are sorted alphabetically by domain.
157func groupRoutesByNetwork(routes []remote.RouteInfo) ([]networkGroup, []remote.RouteInfo) {
158 byNetwork := make(map[string][]remote.RouteInfo)
159 var networkOrder []string
160 for _, route := range routes {
161 if _, seen := byNetwork[route.Network]; !seen {
162 networkOrder = append(networkOrder, route.Network)
163 }
164 byNetwork[route.Network] = append(byNetwork[route.Network], route)
165 }
166
167 var groups []networkGroup
168 var solo []remote.RouteInfo
169
170 for _, net := range networkOrder {
171 members := byNetwork[net]
172 if len(members) >= 2 {
173 sort.Slice(members, func(i, j int) bool {
174 return members[i].Domain < members[j].Domain
175 })
176 groups = append(groups, networkGroup{
177 name: stripNetworkPrefix(net),
178 routes: members,
179 })
180 } else {
181 solo = append(solo, members[0])
182 }
183 }
184
185 sort.Slice(solo, func(i, j int) bool {
186 return solo[i].Domain < solo[j].Domain
187 })
188
189 return groups, solo
190}
191
192// newRoutesCmd creates the routes command group.
193func newRoutesCmd() *cobra.Command {
194 cmd := &cobra.Command{
195 Use: "routes",
196 Short: "Manage routes",
197 Long: `Manage Gordon routes. Routes map domains to container images.
198
199When targeting a remote Gordon instance (via --remote flag or GORDON_REMOTE env var),
200these commands operate on the remote server. Otherwise, they require access to
201the local Gordon configuration.`,
202 }
203
204 cmd.AddCommand(newRoutesListCmd())
205 cmd.AddCommand(newRoutesStatusCmd())
206
207 cmd.AddCommand(newRoutesShowCmd())
208 cmd.AddCommand(newRoutesDiagnoseCmd())
209 cmd.AddCommand(newRoutesPurgeCmd())
210 cmd.AddCommand(newRoutesAddCmd())
211 cmd.AddCommand(newRoutesRemoveCmd())
212
213 return cmd
214}
215
216// newRoutesListCmd creates the routes list command.
217func newRoutesListCmd() *cobra.Command {
218 var jsonOut bool
219
220 cmd := &cobra.Command{
221 Use: "list",
222 Short: "List all routes",
223 RunE: func(cmd *cobra.Command, args []string) error {
224 sections, err := collectRoutesListSections(cmd.Context(), configPath, routesListDeps{})
225 if err != nil {
226 return err
227 }
228
229 if jsonOut {
230 return writeJSON(cmd.OutOrStdout(), sections)
231 }
232
233 return renderRoutesListSections(cmd.OutOrStdout(), sections)
234 },
235 }
236
237 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
238
239 return cmd
240}
241
242func newRoutesStatusCmd() *cobra.Command {
243 var jsonOut bool
244
245 cmd := &cobra.Command{
246 Use: "status",
247 Short: "Show detailed route status",
248 RunE: func(cmd *cobra.Command, args []string) error {
249 sections, err := collectRoutesStatusSections(cmd.Context(), configPath, routesStatusDeps{})
250 if err != nil {
251 return err
252 }
253
254 if jsonOut {
255 return writeJSON(cmd.OutOrStdout(), sections)
256 }
257
258 return renderRoutesStatusSections(cmd.OutOrStdout(), sections)
259 },
260 }
261
262 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
263
264 return cmd
265}
266
267func collectRoutesListSections(ctx context.Context, cfgPath string, deps routesListDeps) ([]routeListSection, error) {
268 if deps.explicitRemote == nil {
269 deps.explicitRemote = resolveRoutesExplicitRemote
270 }
271 if deps.loadLocal == nil {
272 deps.loadLocal = loadRoutesListLocalSection
273 }
274 if deps.listRemotes == nil {
275 deps.listRemotes = remote.ListRemotes
276 }
277 if deps.loadRemote == nil {
278 deps.loadRemote = loadRoutesListRemoteSection
279 }
280
281 if resolved, ok, err := deps.explicitRemote(); err != nil {
282 return nil, err
283 } else if ok {
284 section := loadRoutesListExplicitRemoteSection(ctx, deps, resolved)
285 return []routeListSection{section}, nil
286 }
287 if target, ok := resolveRoutesExplicitTarget(); ok {
288 return []routeListSection{{
289 Kind: "remote",
290 Name: target,
291 Error: unresolvedRoutesTargetError(target),
292 }}, nil
293 }
294
295 return collectRoutesListAggregateSections(ctx, cfgPath, deps)
296}
297
298func loadRoutesListExplicitRemoteSection(ctx context.Context, deps routesListDeps, resolved *remote.ResolvedRemote) routeListSection {
299 section, err := deps.loadRemote(ctx, resolved.DisplayName(), remote.RemoteEntry{URL: resolved.URL, Token: resolved.Token, InsecureTLS: resolved.InsecureTLS})
300 if err != nil && section.Error == "" {
301 section.Error = err.Error()
302 }
303 return normalizeRouteListRemoteSection(section, resolved.DisplayName(), remote.RemoteEntry{URL: resolved.URL, Token: resolved.Token, InsecureTLS: resolved.InsecureTLS})
304}
305
306func collectRoutesListAggregateSections(ctx context.Context, cfgPath string, deps routesListDeps) ([]routeListSection, error) {
307
308 type localResult struct {
309 section routeListSection
310 err error
311 }
312 type remotesResult struct {
313 entries map[string]remote.RemoteEntry
314 err error
315 }
316
317 localCh := make(chan localResult, 1)
318 remotesCh := make(chan remotesResult, 1)
319
320 go func() {
321 section, err := deps.loadLocal(ctx, cfgPath)
322 localCh <- localResult{section: section, err: err}
323 }()
324 go func() {
325 entries, _, err := deps.listRemotes()
326 remotesCh <- remotesResult{entries: entries, err: err}
327 }()
328
329 localRes := <-localCh
330 remotes := <-remotesCh
331 local := localRes.section
332 if err := localRes.err; err != nil {
333 if local.Error == "" {
334 local.Error = err.Error()
335 }
336 }
337
338 sections := make([]routeListSection, 0, 1)
339 sections = append(sections, normalizeRouteListLocalSection(local))
340
341 if remotes.err != nil {
342 sections = append(sections, routeListSection{Kind: "remote", Name: "remotes", Error: remotes.err.Error()})
343 return sections, nil
344 }
345
346 names := sortedRemoteNames(remotes.entries)
347 remoteSections := make([]routeListSection, len(names))
348 var wg sync.WaitGroup
349 for i, name := range names {
350 entry := remotes.entries[name]
351 wg.Add(1)
352 go func(idx int, remoteName string, remoteEntry remote.RemoteEntry) {
353 defer wg.Done()
354 section, err := deps.loadRemote(ctx, remoteName, remoteEntry)
355 if err != nil && section.Error == "" {
356 section.Error = err.Error()
357 }
358 remoteSections[idx] = normalizeRouteListRemoteSection(section, remoteName, remoteEntry)
359 }(i, name, entry)
360 }
361 wg.Wait()
362
363 sections = append(sections, remoteSections...)
364 return sections, nil
365}
366
367func normalizeRouteListLocalSection(section routeListSection) routeListSection {
368 if section.Kind == "" {
369 section.Kind = "local"
370 }
371 if section.Name == "" {
372 section.Name = "local"
373 }
374 return section
375}
376
377func normalizeRouteListRemoteSection(section routeListSection, name string, entry remote.RemoteEntry) routeListSection {
378 if section.Kind == "" {
379 section.Kind = "remote"
380 }
381 if section.Name == "" {
382 section.Name = name
383 }
384 if section.URL == "" {
385 section.URL = entry.URL
386 }
387 return section
388}
389
390func collectRoutesStatusSections(ctx context.Context, cfgPath string, deps routesStatusDeps) ([]routeStatusSection, error) {
391 if deps.explicitRemote == nil {
392 deps.explicitRemote = resolveRoutesExplicitRemote
393 }
394 if deps.loadLocal == nil {
395 deps.loadLocal = loadRoutesStatusLocalSection
396 }
397 if deps.listRemotes == nil {
398 deps.listRemotes = remote.ListRemotes
399 }
400 if deps.loadRemote == nil {
401 deps.loadRemote = loadRoutesStatusRemoteSection
402 }
403
404 if resolved, ok, err := deps.explicitRemote(); err != nil {
405 return nil, err
406 } else if ok {
407 section := loadRoutesStatusExplicitRemoteSection(ctx, deps, resolved)
408 return []routeStatusSection{section}, nil
409 }
410 if target, ok := resolveRoutesExplicitTarget(); ok {
411 return []routeStatusSection{{
412 Kind: "remote",
413 Name: target,
414 Error: unresolvedRoutesTargetError(target),
415 }}, nil
416 }
417
418 return collectRoutesStatusAggregateSections(ctx, cfgPath, deps)
419}
420
421func loadRoutesStatusExplicitRemoteSection(ctx context.Context, deps routesStatusDeps, resolved *remote.ResolvedRemote) routeStatusSection {
422 section, err := deps.loadRemote(ctx, resolved.DisplayName(), remote.RemoteEntry{URL: resolved.URL, Token: resolved.Token, InsecureTLS: resolved.InsecureTLS})
423 if err != nil && section.Error == "" {
424 section.Error = err.Error()
425 }
426 return normalizeRouteStatusRemoteSection(section, resolved.DisplayName(), remote.RemoteEntry{URL: resolved.URL, Token: resolved.Token, InsecureTLS: resolved.InsecureTLS})
427}
428
429func collectRoutesStatusAggregateSections(ctx context.Context, cfgPath string, deps routesStatusDeps) ([]routeStatusSection, error) {
430
431 type localResult struct {
432 section routeStatusSection
433 err error
434 }
435 type remotesResult struct {
436 entries map[string]remote.RemoteEntry
437 err error
438 }
439
440 localCh := make(chan localResult, 1)
441 remotesCh := make(chan remotesResult, 1)
442
443 go func() {
444 section, err := deps.loadLocal(ctx, cfgPath)
445 localCh <- localResult{section: section, err: err}
446 }()
447 go func() {
448 entries, _, err := deps.listRemotes()
449 remotesCh <- remotesResult{entries: entries, err: err}
450 }()
451
452 localRes := <-localCh
453 remotes := <-remotesCh
454 local := localRes.section
455 if err := localRes.err; err != nil {
456 if local.Error == "" {
457 local.Error = err.Error()
458 }
459 }
460
461 sections := make([]routeStatusSection, 0, 1)
462 sections = append(sections, normalizeRouteStatusLocalSection(local))
463
464 if remotes.err != nil {
465 sections = append(sections, routeStatusSection{Kind: "remote", Name: "remotes", Error: remotes.err.Error()})
466 return sections, nil
467 }
468
469 names := sortedRemoteNames(remotes.entries)
470 remoteSections := make([]routeStatusSection, len(names))
471 var wg sync.WaitGroup
472 for i, name := range names {
473 entry := remotes.entries[name]
474 wg.Add(1)
475 go func(idx int, remoteName string, remoteEntry remote.RemoteEntry) {
476 defer wg.Done()
477 section, err := deps.loadRemote(ctx, remoteName, remoteEntry)
478 if err != nil && section.Error == "" {
479 section.Error = err.Error()
480 }
481 remoteSections[idx] = normalizeRouteStatusRemoteSection(section, remoteName, remoteEntry)
482 }(i, name, entry)
483 }
484 wg.Wait()
485
486 sections = append(sections, remoteSections...)
487 return sections, nil
488}
489
490func normalizeRouteStatusLocalSection(section routeStatusSection) routeStatusSection {
491 if section.Kind == "" {
492 section.Kind = "local"
493 }
494 if section.Name == "" {
495 section.Name = "local"
496 }
497 return section
498}
499
500func normalizeRouteStatusRemoteSection(section routeStatusSection, name string, entry remote.RemoteEntry) routeStatusSection {
501 if section.Kind == "" {
502 section.Kind = "remote"
503 }
504 if section.Name == "" {
505 section.Name = name
506 }
507 if section.URL == "" {
508 section.URL = entry.URL
509 }
510 return section
511}
512
513func renderRoutesListSections(out io.Writer, sections []routeListSection) error {
514 sections = renderedRouteListSections(sections)
515
516 if err := cliWriteLine(out, cliRenderTitle("Routes")); err != nil {
517 return err
518 }
519 if err := cliWriteLine(out, ""); err != nil {
520 return err
521 }
522
523 for i, section := range sections {
524 if i > 0 {
525 if err := cliWriteLine(out, ""); err != nil {
526 return err
527 }
528 }
529 if err := renderRoutesListSection(out, section); err != nil {
530 return err
531 }
532 }
533
534 return nil
535}
536
537func renderRoutesListSection(out io.Writer, section routeListSection) error {
538 if err := cliWriteLine(out, routeSectionHeading(section.Kind, section.Name)); err != nil {
539 return err
540 }
541
542 if len(section.Routes) > 0 {
543 const imageColWidth = 45
544 rows := make([][]string, 0, len(section.Routes))
545 for _, route := range section.Routes {
546 rows = append(rows, []string{route.Domain, truncateImage(route.Image, imageColWidth)})
547 }
548
549 table := components.NewTable(
550 components.WithColumns([]components.TableColumn{
551 {Title: "Domain", Width: 30},
552 {Title: "Image", Width: imageColWidth},
553 }),
554 components.WithRows(rows),
555 )
556
557 if err := cliWriteLine(out, table.View()); err != nil {
558 return err
559 }
560 }
561
562 if section.Error != "" {
563 return cliWriteLine(out, cliRenderWarning(section.Error))
564 }
565
566 if len(section.Routes) == 0 {
567 return cliWriteLine(out, cliRenderMuted("No routes configured"))
568 }
569
570 return nil
571}
572
573func renderRoutesStatusSections(out io.Writer, sections []routeStatusSection) error {
574 sections = renderedRouteStatusSections(sections)
575
576 if err := cliWriteLine(out, cliRenderTitle("Route Status")); err != nil {
577 return err
578 }
579 if err := cliWriteLine(out, ""); err != nil {
580 return err
581 }
582
583 for i, section := range sections {
584 if i > 0 {
585 if err := cliWriteLine(out, ""); err != nil {
586 return err
587 }
588 }
589 if err := renderRoutesStatusSection(out, section); err != nil {
590 return err
591 }
592 }
593
594 return nil
595}
596
597func renderedRouteListSections(sections []routeListSection) []routeListSection {
598 if !shouldHideLocalRouteListSection(sections) {
599 return sections
600 }
601 return sections[1:]
602}
603
604func shouldHideLocalRouteListSection(sections []routeListSection) bool {
605 if len(sections) < 2 {
606 return false
607 }
608 local := sections[0]
609 if local.Kind != "local" || local.Error == "" {
610 return false
611 }
612 for _, section := range sections[1:] {
613 if section.Kind == "remote" && section.Error == "" {
614 return true
615 }
616 }
617 return false
618}
619
620func renderedRouteStatusSections(sections []routeStatusSection) []routeStatusSection {
621 if !shouldHideLocalRouteStatusSection(sections) {
622 return sections
623 }
624 return sections[1:]
625}
626
627func shouldHideLocalRouteStatusSection(sections []routeStatusSection) bool {
628 if len(sections) < 2 {
629 return false
630 }
631 local := sections[0]
632 if local.Kind != "local" || local.Error == "" {
633 return false
634 }
635 for _, section := range sections[1:] {
636 if section.Kind == "remote" && section.Error == "" {
637 return true
638 }
639 }
640 return false
641}
642
643func renderRoutesStatusSection(out io.Writer, section routeStatusSection) error {
644 if err := cliWriteLine(out, routeSectionHeading(section.Kind, section.Name)); err != nil {
645 return err
646 }
647
648 if len(section.Routes) > 0 {
649 tree := buildRouteStatusTree(section.Routes)
650 if err := cliWriteLine(out, strings.TrimSuffix(tree.Render(), "\n")); err != nil {
651 return err
652 }
653 }
654
655 if section.Error != "" {
656 return cliWriteLine(out, cliRenderWarning(section.Error))
657 }
658
659 if len(section.Routes) == 0 {
660 return cliWriteLine(out, cliRenderMuted("No routes configured"))
661 }
662
663 return nil
664}
665
666func routeSectionHeading(kind, name string) string {
667 switch kind {
668 case "local":
669 return "Local"
670 case "remote":
671 if name != "" {
672 return "Remote: " + name
673 }
674 return "Remote"
675 default:
676 if name != "" {
677 return capitalizeKind(kind) + ": " + name
678 }
679 return capitalizeKind(kind)
680 }
681}
682
683func capitalizeKind(kind string) string {
684 if kind == "" {
685 return ""
686 }
687 if len(kind) == 1 {
688 return strings.ToUpper(kind)
689 }
690 return strings.ToUpper(kind[:1]) + kind[1:]
691}
692
693func unresolvedRoutesTargetError(target string) string {
694 return fmt.Sprintf("could not resolve remote target %q", target)
695}
696
697func resolveRoutesExplicitRemote() (*remote.ResolvedRemote, bool, error) {
698 target, ok := resolveRoutesExplicitTarget()
699 if !ok {
700 return nil, false, nil
701 }
702
703 if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") {
704 return &remote.ResolvedRemote{
705 URL: target,
706 Token: resolveRoutesTokenForTarget("", remote.RemoteEntry{}),
707 InsecureTLS: resolveRoutesInsecureForTarget("", remote.RemoteEntry{}),
708 }, true, nil
709 }
710
711 remotes, err := remote.LoadRemotes("")
712 if err != nil {
713 return nil, false, err
714 }
715
716 if remotes != nil {
717 if entry, found := remotes.Remotes[target]; found {
718 return &remote.ResolvedRemote{
719 Name: target,
720 URL: entry.URL,
721 Token: resolveRoutesTokenForTarget(target, entry),
722 InsecureTLS: resolveRoutesInsecureForTarget(target, entry),
723 }, true, nil
724 }
725 }
726
727 return nil, false, nil
728}
729
730func resolveRoutesExplicitTarget() (string, bool) {
731 if target := strings.TrimSpace(remoteFlag); target != "" {
732 return target, true
733 }
734 if target := strings.TrimSpace(os.Getenv("GORDON_REMOTE")); target != "" {
735 return target, true
736 }
737 return "", false
738}
739
740func resolveRoutesTokenForTarget(name string, entry remote.RemoteEntry) string {
741 if token := strings.TrimSpace(tokenFlag); token != "" {
742 return token
743 }
744 if token := strings.TrimSpace(os.Getenv("GORDON_TOKEN")); token != "" {
745 return token
746 }
747 if name != "" {
748 return remote.ResolveTokenForRemote(name, entry)
749 }
750 return ""
751}
752
753func resolveRoutesInsecureForTarget(name string, entry remote.RemoteEntry) bool {
754 if insecureTLSFlag {
755 return true
756 }
757 if env := strings.TrimSpace(os.Getenv("GORDON_INSECURE")); env != "" {
758 if value, err := strconv.ParseBool(env); err == nil {
759 return value
760 }
761 }
762 if name != "" {
763 return entry.InsecureTLS
764 }
765 return false
766}
767
768func newRoutesTargetClient(name string, entry remote.RemoteEntry) *remote.Client {
769 return remote.NewClient(entry.URL, remoteClientOptions(resolveRoutesTokenForTarget(name, entry), resolveRoutesInsecureForTarget(name, entry))...)
770}
771
772func loadRoutesListLocalSection(ctx context.Context, cfgPath string) (routeListSection, error) {
773 local, err := GetLocalServices(cfgPath)
774 if err != nil {
775 return routeListSection{Kind: "local", Name: "local", Error: err.Error()}, nil
776 }
777
778 routes := local.GetConfigService().GetRoutes(ctx)
779 section := routeListSection{Kind: "local", Name: "local", Routes: make([]routeListItem, 0, len(routes))}
780 for _, route := range routes {
781 section.Routes = append(section.Routes, routeListItem{Domain: route.Domain, Image: route.Image})
782 }
783
784 return section, nil
785}
786
787func loadRoutesListRemoteSection(ctx context.Context, name string, entry remote.RemoteEntry) (routeListSection, error) {
788 section := routeListSection{Kind: "remote", Name: name, URL: entry.URL}
789 client := newRoutesTargetClient(name, entry)
790 routes, err := client.ListRoutesWithDetails(ctx)
791 if err != nil {
792 section.Error = err.Error()
793 return section, nil
794 }
795
796 section.Routes = routeListItemsFromInfos(routes)
797 return section, nil
798}
799
800func loadRoutesStatusLocalSection(ctx context.Context, cfgPath string) (routeStatusSection, error) {
801 section := routeStatusSection{Kind: "local", Name: "local"}
802 handle, err := resolveLocalControlPlane(cfgPath)
803 if err != nil {
804 section.Error = err.Error()
805 return section, nil
806 }
807 defer handle.close()
808
809 routes, err := handle.plane.ListRoutesWithDetails(ctx)
810 if err != nil {
811 section.Error = err.Error()
812 return section, nil
813 }
814
815 health, err := handle.plane.GetHealth(ctx)
816 if err != nil {
817 section.Error = err.Error()
818 health = nil
819 }
820
821 section.Routes = routeStatusItemsFromInfos(routes, health)
822 return section, nil
823}
824
825func loadRoutesStatusRemoteSection(ctx context.Context, name string, entry remote.RemoteEntry) (routeStatusSection, error) {
826 section := routeStatusSection{Kind: "remote", Name: name, URL: entry.URL}
827 client := newRoutesTargetClient(name, entry)
828 cp := NewRemoteControlPlane(client)
829
830 routes, err := cp.ListRoutesWithDetails(ctx)
831 if err != nil {
832 section.Error = err.Error()
833 return section, nil
834 }
835
836 health, err := cp.GetHealth(ctx)
837 if err != nil {
838 section.Error = err.Error()
839 health = nil
840 }
841
842 section.Routes = routeStatusItemsFromInfos(routes, health)
843 return section, nil
844}
845
846func routeStatusItemsFromInfos(routes []remote.RouteInfo, health map[string]*remote.RouteHealth) []routeStatusItem {
847 items := make([]routeStatusItem, 0, len(routes))
848 for _, route := range routes {
849 items = append(items, routeStatusItemFromInfo(route, health[route.Domain]))
850 }
851 return items
852}
853
854func routeStatusItemFromInfo(route remote.RouteInfo, routeHealth *remote.RouteHealth) routeStatusItem {
855 item := routeStatusItem{
856 Domain: route.Domain,
857 Image: route.Image,
858 ContainerID: route.ContainerID,
859 ContainerStatus: route.ContainerStatus,
860 Network: route.Network,
861 }
862 if item.ContainerStatus == "" && routeHealth != nil {
863 item.ContainerStatus = routeHealth.ContainerStatus
864 item.HTTPStatus = routeHealth.HTTPStatus
865 item.HealthError = routeHealth.Error
866 }
867 if item.ContainerStatus == "" {
868 item.ContainerStatus = "unknown"
869 }
870 if item.HTTPStatus == 0 && routeHealth != nil {
871 item.HTTPStatus = routeHealth.HTTPStatus
872 item.HealthError = routeHealth.Error
873 }
874 if item.HealthError == "" && routeHealth != nil {
875 item.HealthError = routeHealth.Error
876 }
877 item.Attachments = make([]routeStatusAttachment, 0, len(route.Attachments))
878 for _, attachment := range route.Attachments {
879 status := attachment.Status
880 if status == "" {
881 status = "unknown"
882 }
883 item.Attachments = append(item.Attachments, routeStatusAttachment{Name: attachment.Name, Image: attachment.Image, Status: status})
884 }
885 return item
886}
887
888func routeListItemsFromInfos(routes []remote.RouteInfo) []routeListItem {
889 items := make([]routeListItem, 0, len(routes))
890 for _, route := range routes {
891 items = append(items, routeListItem{Domain: route.Domain, Image: route.Image})
892 }
893 return items
894}
895
896func buildRouteStatusTree(routes []routeStatusItem) *components.Tree {
897 sortedRoutes := make([]routeStatusItem, len(routes))
898 copy(sortedRoutes, routes)
899 sort.SliceStable(sortedRoutes, func(i, j int) bool {
900 if sortedRoutes[i].Network != sortedRoutes[j].Network {
901 return sortedRoutes[i].Network < sortedRoutes[j].Network
902 }
903 if sortedRoutes[i].Domain != sortedRoutes[j].Domain {
904 return sortedRoutes[i].Domain < sortedRoutes[j].Domain
905 }
906 if sortedRoutes[i].Image != sortedRoutes[j].Image {
907 return sortedRoutes[i].Image < sortedRoutes[j].Image
908 }
909 if sortedRoutes[i].ContainerID != sortedRoutes[j].ContainerID {
910 return sortedRoutes[i].ContainerID < sortedRoutes[j].ContainerID
911 }
912 if sortedRoutes[i].ContainerStatus != sortedRoutes[j].ContainerStatus {
913 return sortedRoutes[i].ContainerStatus < sortedRoutes[j].ContainerStatus
914 }
915 return sortedRoutes[i].HTTPStatus < sortedRoutes[j].HTTPStatus
916 })
917
918 infos := make([]remote.RouteInfo, 0, len(routes))
919 itemsByDomain := make(map[string]routeStatusItem, len(routes))
920 for _, route := range sortedRoutes {
921 infos = append(infos, remote.RouteInfo{
922 Domain: route.Domain,
923 Image: route.Image,
924 ContainerID: route.ContainerID,
925 ContainerStatus: route.ContainerStatus,
926 Network: route.Network,
927 Attachments: routeStatusAttachmentsToRemote(route.Attachments),
928 })
929 itemsByDomain[route.Domain] = route
930 }
931
932 groups, solo := groupRoutesByNetwork(infos)
933 tree := components.NewTree()
934
935 for _, group := range groups {
936 g := tree.AddGroup(group.name)
937 for _, route := range group.routes {
938 item := itemsByDomain[route.Domain]
939 node := g.AddNode(routeStatusTitle(item), item.Image)
940 addRouteStatusAttachmentChildren(node, item)
941 }
942 }
943
944 for _, route := range solo {
945 item := itemsByDomain[route.Domain]
946 node := tree.AddNode(routeStatusTitle(item), item.Image)
947 addRouteStatusAttachmentChildren(node, item)
948 }
949
950 return tree
951}
952
953func routeStatusTitle(route routeStatusItem) string {
954 containerStatus := route.ContainerStatus
955 if containerStatus == "" {
956 containerStatus = "unknown"
957 }
958
959 httpIcon := components.StatusIcon(styles.IconHTTPStatus, httpHealthToStatus(&remote.RouteHealth{HTTPStatus: route.HTTPStatus, Error: route.HealthError}))
960 containerIcon := components.StatusIcon(styles.IconContainerStatus, components.ParseStatus(containerStatus))
961
962 return httpIcon + " " + containerIcon + " " + route.Domain
963}
964
965func addRouteStatusAttachmentChildren(node *components.Node, route routeStatusItem) {
966 for _, att := range route.Attachments {
967 status := att.Status
968 if status == "" {
969 status = "unknown"
970 }
971 attIcon := components.StatusIcon(styles.IconContainerStatus, components.ParseStatus(status))
972 node.AddChild(attIcon+" "+att.Name, att.Image)
973 }
974}
975
976func routeStatusAttachmentsToRemote(attachments []routeStatusAttachment) []remote.Attachment {
977 result := make([]remote.Attachment, 0, len(attachments))
978 for _, att := range attachments {
979 result = append(result, remote.Attachment{Name: att.Name, Image: att.Image, Status: att.Status})
980 }
981 return result
982}
983
984func newRoutesShowCmd() *cobra.Command {
985 var jsonOut bool
986
987 cmd := &cobra.Command{
988 Use: "show <domain>",
989 Short: "Show details for a single route",
990 Long: `Display detailed information about a specific route including its image,
991container status, and health.
992
993Examples:
994 gordon routes show app.mydomain.com
995 gordon routes show app.mydomain.com --json
996 gordon routes show app.mydomain.com --remote https://gordon.mydomain.com`,
997 Args: cobra.ExactArgs(1),
998 RunE: func(cmd *cobra.Command, args []string) error {
999 ctx := cmd.Context()
1000 handle, err := resolveControlPlaneForRouteDomain(ctx, args[0])
1001 if err != nil {
1002 return err
1003 }
1004 defer handle.close()
1005 return runRoutesShow(ctx, handle.plane, cmd.OutOrStdout(), args[0], jsonOut)
1006 },
1007 }
1008
1009 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
1010
1011 return cmd
1012}
1013
1014func runRoutesShow(ctx context.Context, cp ControlPlane, out io.Writer, routeDomain string, jsonOut bool) error {
1015 route, err := cp.GetRoute(ctx, routeDomain)
1016 if err != nil {
1017 if errors.Is(err, domain.ErrRouteNotFound) {
1018 return fmt.Errorf("route %q not found", routeDomain)
1019 }
1020 return fmt.Errorf("failed to get route: %w", err)
1021 }
1022
1023 health, err := cp.GetHealth(ctx)
1024 healthErr := ""
1025 if err != nil {
1026 healthErr = err.Error()
1027 health = nil
1028 }
1029 var routeHealth *remote.RouteHealth
1030 if health != nil {
1031 routeHealth = health[routeDomain]
1032 }
1033
1034 containerStatus := "unknown"
1035 httpStatus := 0
1036 if routeHealth != nil {
1037 if routeHealth.ContainerStatus != "" {
1038 containerStatus = routeHealth.ContainerStatus
1039 }
1040 httpStatus = routeHealth.HTTPStatus
1041 if healthErr == "" && routeHealth.Error != "" {
1042 healthErr = routeHealth.Error
1043 }
1044 }
1045
1046 if jsonOut {
1047 payload := map[string]any{
1048 "domain": route.Domain,
1049 "image": route.Image,
1050 "container_status": containerStatus,
1051 "http_status": httpStatus,
1052 }
1053 if healthErr != "" {
1054 payload["health_error"] = healthErr
1055 }
1056 return writeJSON(out, payload)
1057 }
1058
1059 return writeRouteShowText(out, route.Domain, route.Image, containerStatus, httpStatus, healthErr)
1060}
1061
1062func writeRouteShowText(out io.Writer, domainName, image, containerStatus string, httpStatus int, healthErr string) error {
1063 if err := cliWriteLine(out, cliRenderTitle("Route: "+domainName)); err != nil {
1064 return err
1065 }
1066 if err := cliWriteLine(out, ""); err != nil {
1067 return err
1068 }
1069 if err := cliWriteLine(out, cliRenderMeta("Domain:", domainName)); err != nil {
1070 return err
1071 }
1072 if err := cliWriteLine(out, cliRenderMeta("Image:", image)); err != nil {
1073 return err
1074 }
1075 if err := cliWriteLine(out, cliRenderMeta("Container:", containerStatus)); err != nil {
1076 return err
1077 }
1078 if httpStatus > 0 {
1079 if err := cliWriteLine(out, cliRenderMeta("HTTP Status:", fmt.Sprintf("%d", httpStatus))); err != nil {
1080 return err
1081 }
1082 }
1083 if healthErr != "" {
1084 if err := cliWriteLine(out, cliRenderWarning(healthErr)); err != nil {
1085 return err
1086 }
1087 }
1088
1089 return nil
1090}
1091
1092// newRoutesDiagnoseCmd creates the routes diagnose command.
1093func newRoutesDiagnoseCmd() *cobra.Command {
1094 var jsonOut bool
1095 cmd := &cobra.Command{
1096 Use: "diagnose <domain>",
1097 Short: "Diagnose route configuration, runtime state, and cleanup leftovers",
1098 Args: cobra.ExactArgs(1),
1099 RunE: func(cmd *cobra.Command, args []string) error {
1100 return runRouteDiagnosis(cmd.Context(), args[0], cmd.OutOrStdout(), jsonOut)
1101 },
1102 }
1103 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
1104 return cmd
1105}
1106
1107func runRouteDiagnosis(ctx context.Context, domainName string, out io.Writer, jsonOut bool) error {
1108 handle, err := resolveControlPlaneForRouteDomain(ctx, domainName)
1109 if err != nil {
1110 return err
1111 }
1112 defer handle.close()
1113 diag, err := buildRouteDiagnosis(ctx, handle.plane, domainName)
1114 if err != nil {
1115 return err
1116 }
1117 if jsonOut {
1118 return writeJSON(out, diag)
1119 }
1120 return writeRouteDiagnosisText(out, diag)
1121}
1122
1123func buildRouteDiagnosis(ctx context.Context, cp ControlPlane, domainName string) (*routeDiagnosis, error) {
1124 diag := &routeDiagnosis{Domain: domainName}
1125 loadRouteDiagnosisConfig(ctx, cp, domainName, diag)
1126 loadRouteDiagnosisRuntime(ctx, cp, domainName, diag)
1127 loadRouteDiagnosisHealth(ctx, cp, domainName, diag)
1128 loadRouteDiagnosisOrphanedAttachments(ctx, cp, domainName, diag)
1129 loadRouteDiagnosisVolumes(ctx, cp, domainName, diag)
1130 finalizeRouteDiagnosis(diag)
1131 return diag, nil
1132}
1133
1134func loadRouteDiagnosisConfig(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) {
1135 route, err := cp.GetRoute(ctx, domainName)
1136 if err == nil && route != nil {
1137 diag.Configured = true
1138 diag.Route = route
1139 return
1140 }
1141 if err != nil && !errors.Is(err, domain.ErrRouteNotFound) {
1142 diag.Warnings = append(diag.Warnings, "failed to load route config: "+err.Error())
1143 }
1144}
1145
1146func loadRouteDiagnosisRuntime(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) {
1147 routes, err := cp.ListRoutesWithDetails(ctx)
1148 if err != nil {
1149 diag.Warnings = append(diag.Warnings, "failed to load route runtime details: "+err.Error())
1150 return
1151 }
1152 for _, info := range routes {
1153 if strings.EqualFold(info.Domain, domainName) {
1154 copy := info
1155 diag.Runtime = ©
1156 return
1157 }
1158 }
1159}
1160
1161func loadRouteDiagnosisHealth(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) {
1162 if health, err := cp.GetHealth(ctx); err == nil && health != nil {
1163 diag.Health = health[domainName]
1164 }
1165}
1166
1167func loadRouteDiagnosisVolumes(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) {
1168 if volumes, err := cp.ListVolumes(ctx); err == nil {
1169 if len(volumes) == 0 {
1170 return
1171 }
1172 scope := newRouteResourceScope(domainName, routeDiagnosisVolumePrefix(ctx, cp), diag.Runtime, diag.OrphanedAttachments)
1173 diag.Volumes = volumesForRouteDiagnosis(volumes, scope)
1174 }
1175}
1176
1177func routeDiagnosisVolumePrefix(ctx context.Context, cp ControlPlane) string {
1178 const defaultPrefix = "gordon"
1179 cfg, err := cp.GetConfig(ctx)
1180 if err != nil || cfg == nil || cfg.Volumes.Prefix == "" {
1181 return defaultPrefix
1182 }
1183 return cfg.Volumes.Prefix
1184}
1185
1186func loadRouteDiagnosisOrphanedAttachments(ctx context.Context, cp ControlPlane, domainName string, diag *routeDiagnosis) {
1187 lister, ok := cp.(interface {
1188 ListOrphanedAttachments(context.Context) ([]domain.CleanupAttachment, error)
1189 })
1190 if !ok {
1191 return
1192 }
1193 if attachments, err := lister.ListOrphanedAttachments(ctx); err == nil {
1194 diag.OrphanedAttachments = attachmentsForRouteDiagnosis(attachments, domainName)
1195 }
1196}
1197
1198func finalizeRouteDiagnosis(diag *routeDiagnosis) {
1199 if !diag.Configured && diag.Runtime != nil {
1200 diag.Warnings = append(diag.Warnings, "route is not configured but runtime state still exists")
1201 }
1202 if len(diag.Volumes) > 0 {
1203 diag.Hints = append(diag.Hints, "persistent volumes are preserved by default")
1204 }
1205 if len(diag.OrphanedAttachments) > 0 {
1206 diag.Hints = append(diag.Hints, fmt.Sprintf("run 'gordon routes purge %s --attachments' to review orphaned attachment cleanup for this route", diag.Domain))
1207 }
1208}
1209
1210type routeResourceScope struct {
1211 containerNames map[string]struct{}
1212 volumePrefixes []string
1213}
1214
1215func newRouteResourceScope(domainName, volumePrefix string, runtime *remote.RouteInfo, orphaned []domain.CleanupAttachment) routeResourceScope {
1216 if volumePrefix == "" {
1217 volumePrefix = "gordon"
1218 }
1219 scope := routeResourceScope{
1220 containerNames: make(map[string]struct{}),
1221 volumePrefixes: []string{volumePrefix + "-" + strings.ReplaceAll(domainName, ".", "-") + "-"},
1222 }
1223 for _, name := range []string{
1224 fmt.Sprintf("gordon-%s", domainName),
1225 fmt.Sprintf("gordon-%s-new", domainName),
1226 fmt.Sprintf("gordon-%s-next", domainName),
1227 } {
1228 scope.containerNames[name] = struct{}{}
1229 }
1230 if runtime != nil {
1231 for _, attachment := range runtime.Attachments {
1232 for _, name := range routeAttachmentContainerNameCandidates(domainName, attachment.Name) {
1233 scope.containerNames[name] = struct{}{}
1234 scope.volumePrefixes = append(scope.volumePrefixes, volumePrefix+"-"+name+"-")
1235 }
1236 }
1237 }
1238 for _, attachment := range orphaned {
1239 if attachment.Name == "" {
1240 continue
1241 }
1242 scope.containerNames[attachment.Name] = struct{}{}
1243 scope.volumePrefixes = append(scope.volumePrefixes, volumePrefix+"-"+attachment.Name+"-")
1244 }
1245 return scope
1246}
1247
1248func routeAttachmentContainerNameCandidates(domainName, serviceName string) []string {
1249 if serviceName == "" {
1250 return nil
1251 }
1252 return []string{
1253 fmt.Sprintf("gordon-%s-%s", domain.SanitizeDomainForContainer(domainName), serviceName),
1254 fmt.Sprintf("gordon-%s-%s", domain.SanitizeDomainForContainerLegacy(domainName), serviceName),
1255 }
1256}
1257
1258func volumesForRouteDiagnosis(volumes []dto.Volume, scope routeResourceScope) []dto.Volume {
1259 var out []dto.Volume
1260 for _, volume := range volumes {
1261 if scope.matchesVolume(volume) {
1262 out = append(out, volume)
1263 }
1264 }
1265 return out
1266}
1267
1268func (s routeResourceScope) matchesVolume(volume dto.Volume) bool {
1269 for _, containerName := range volume.Containers {
1270 if _, ok := s.containerNames[containerName]; ok {
1271 return true
1272 }
1273 }
1274 for _, prefix := range s.volumePrefixes {
1275 if strings.HasPrefix(volume.Name, prefix) {
1276 return true
1277 }
1278 }
1279 return false
1280}
1281
1282func attachmentsForRouteDiagnosis(attachments []domain.CleanupAttachment, domainName string) []domain.CleanupAttachment {
1283 var out []domain.CleanupAttachment
1284 for _, attachment := range attachments {
1285 if strings.EqualFold(attachment.Owner, domainName) {
1286 out = append(out, attachment)
1287 }
1288 }
1289 return out
1290}
1291
1292func writeRouteDiagnosisText(out io.Writer, diag *routeDiagnosis) error {
1293 if err := cliWriteLine(out, cliRenderTitle("Route diagnosis: "+diag.Domain)); err != nil {
1294 return err
1295 }
1296 if err := writeRouteDiagnosisSummary(out, diag); err != nil {
1297 return err
1298 }
1299 if err := writeRouteDiagnosisLeftovers(out, diag); err != nil {
1300 return err
1301 }
1302 return writeCleanupMessages(out, diag.Warnings, diag.Hints)
1303}
1304
1305func writeRouteDiagnosisSummary(out io.Writer, diag *routeDiagnosis) error {
1306 if diag.Configured && diag.Route != nil {
1307 if err := cliWriteLine(out, cliRenderMeta("Image:", diag.Route.Image)); err != nil {
1308 return err
1309 }
1310 } else if err := cliWriteLine(out, cliRenderWarning("Route is not configured")); err != nil {
1311 return err
1312 }
1313 if diag.Runtime == nil {
1314 return nil
1315 }
1316 if err := cliWriteLine(out, cliRenderMeta("Container:", diag.Runtime.ContainerStatus+" "+shortContainerID(diag.Runtime.ContainerID))); err != nil {
1317 return err
1318 }
1319 if len(diag.Runtime.Attachments) == 0 {
1320 return nil
1321 }
1322 if err := cliWriteLine(out, cliRenderMeta("Active attachments:", "")); err != nil {
1323 return err
1324 }
1325 for _, attachment := range diag.Runtime.Attachments {
1326 label := attachment.Name
1327 if label == "" {
1328 label = attachment.ContainerID
1329 }
1330 if attachment.Status != "" {
1331 label = fmt.Sprintf("%s (%s)", label, attachment.Status)
1332 }
1333 if err := cliWriteLine(out, " "+label); err != nil {
1334 return err
1335 }
1336 }
1337 return nil
1338}
1339
1340func writeRouteDiagnosisLeftovers(out io.Writer, diag *routeDiagnosis) error {
1341 volumeLabel := "Volume:"
1342 if !diag.Configured {
1343 volumeLabel = "Preserved volume:"
1344 }
1345 for _, volume := range diag.Volumes {
1346 if err := cliWriteLine(out, cliRenderMeta(volumeLabel, volume.Name)); err != nil {
1347 return err
1348 }
1349 }
1350 for _, attachment := range diag.OrphanedAttachments {
1351 if err := cliWriteLine(out, cliRenderWarning("Orphaned attachment: "+attachment.Name)); err != nil {
1352 return err
1353 }
1354 }
1355 return nil
1356}
1357
1358func newRoutesPurgeCmd() *cobra.Command {
1359 var jsonOut bool
1360 var force bool
1361 var includeAttachments bool
1362 var includeVolumes bool
1363 cmd := &cobra.Command{
1364 Use: "purge <domain>",
1365 Short: "Review or explicitly purge retained route resources",
1366 Args: cobra.ExactArgs(1),
1367 RunE: func(cmd *cobra.Command, args []string) error {
1368 return runRoutePurgeCommand(cmd.Context(), args[0], cmd.OutOrStdout(), routePurgeOptions{Force: force, Attachments: includeAttachments, Volumes: includeVolumes}, jsonOut)
1369 },
1370 }
1371 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
1372 cmd.Flags().BoolVar(&force, "force", false, "Execute purge actions; default is dry-run")
1373 cmd.Flags().BoolVar(&includeAttachments, "attachments", false, "Include orphaned attachment containers")
1374 cmd.Flags().BoolVar(&includeVolumes, "volumes", false, "Include preserved volumes in the purge plan")
1375 return cmd
1376}
1377
1378type routePurgeOptions struct {
1379 Force bool
1380 Attachments bool
1381 Volumes bool
1382}
1383
1384func runRoutePurgeCommand(ctx context.Context, domainName string, out io.Writer, opts routePurgeOptions, jsonOut bool) error {
1385 handle, err := resolveControlPlaneForRouteDomain(ctx, domainName)
1386 if err != nil {
1387 return err
1388 }
1389 defer handle.close()
1390 report, err := runRoutePurge(ctx, handle.plane, domainName, opts)
1391 if err != nil {
1392 return err
1393 }
1394 if jsonOut {
1395 return writeJSON(out, report)
1396 }
1397 return writeRoutePurgeText(out, report, opts.Force)
1398}
1399
1400func runRoutePurge(ctx context.Context, cp ControlPlane, domainName string, opts routePurgeOptions) (*domain.CleanupReport, error) {
1401 diag, err := buildRouteDiagnosis(ctx, cp, domainName)
1402 if err != nil {
1403 return nil, err
1404 }
1405 report := &domain.CleanupReport{Domain: domainName}
1406 if diag.Runtime != nil && diag.Runtime.ContainerID != "" {
1407 report.OrphanedEntities = append(report.OrphanedEntities, domain.CleanupOrphanedEntity{Kind: "route_container", ID: diag.Runtime.ContainerID, Status: diag.Runtime.ContainerStatus, Reason: "route runtime state matched purge target"})
1408 }
1409 if opts.Volumes {
1410 for _, volume := range diag.Volumes {
1411 report.PreservedVolumes = append(report.PreservedVolumes, domain.CleanupVolume{Name: volume.Name, Reason: "volume purge requires explicit --volumes and runtime support"})
1412 }
1413 }
1414 if !opts.Attachments || !opts.Force {
1415 report.PreservedAttachments = append(report.PreservedAttachments, diag.OrphanedAttachments...)
1416 }
1417 if opts.Attachments && opts.Force {
1418 cleaner, ok := cp.(interface {
1419 CleanupOrphanedAttachments(context.Context, string, bool) (*domain.CleanupReport, error)
1420 })
1421 if !ok {
1422 return nil, fmt.Errorf("attachment purge is unavailable")
1423 }
1424 attachmentReport, err := cleaner.CleanupOrphanedAttachments(ctx, domainName, true)
1425 if err != nil {
1426 return nil, err
1427 }
1428 report.RemovedContainers = append(report.RemovedContainers, attachmentReport.RemovedContainers...)
1429 report.PreservedVolumes = append(report.PreservedVolumes, attachmentReport.PreservedVolumes...)
1430 report.PreservedAttachments = append(report.PreservedAttachments, attachmentReport.PreservedAttachments...)
1431 report.OrphanedEntities = append(report.OrphanedEntities, attachmentReport.OrphanedEntities...)
1432 report.Warnings = append(report.Warnings, attachmentReport.Warnings...)
1433 report.Hints = append(report.Hints, attachmentReport.Hints...)
1434 report.PartialFailures = append(report.PartialFailures, attachmentReport.PartialFailures...)
1435 }
1436 if opts.Volumes {
1437 report.Warnings = append(report.Warnings, "volume purge is not executed yet; preserved volumes are reported for manual review")
1438 }
1439 if !opts.Force {
1440 report.Hints = append(report.Hints, "dry-run only; add --force with explicit category flags to execute supported purge actions")
1441 }
1442 return report, nil
1443}
1444
1445func writeRoutePurgeText(out io.Writer, report *domain.CleanupReport, force bool) error {
1446 mode := "Purge dry-run"
1447 if force {
1448 mode = "Purge completed"
1449 }
1450 if err := cliWriteLine(out, cliRenderTitle(mode+": "+report.Domain)); err != nil {
1451 return err
1452 }
1453 if err := writePreservedAttachments(out, report.PreservedAttachments); err != nil {
1454 return err
1455 }
1456 if err := writeRemovedContainers(out, report.RemovedContainers); err != nil {
1457 return err
1458 }
1459 for _, entity := range report.OrphanedEntities {
1460 if err := cliWriteLine(out, cliRenderWarning("Purge candidate: "+entity.Kind+" "+entity.ID)); err != nil {
1461 return err
1462 }
1463 }
1464 for _, volume := range report.PreservedVolumes {
1465 if err := cliWriteLine(out, cliRenderMeta("Preserved volume:", volume.Name)); err != nil {
1466 return err
1467 }
1468 }
1469 return writeCleanupMessages(out, report.Warnings, report.Hints)
1470}
1471
1472func newRoutesAddCmd() *cobra.Command {
1473 var image string
1474
1475 cmd := &cobra.Command{
1476 Use: "add <domain> <image>",
1477 Short: "Create or update a route",
1478 Long: `Create or update a route mapping a domain to a container image.
1479
1480If the route already exists with the same image, this is a no-op.
1481If it exists with a different image, the image is updated.
1482
1483Examples:
1484 gordon routes add app.mydomain.com myapp:latest
1485 gordon --remote https://gordon.mydomain.com routes add api.mydomain.com api:v2`,
1486 Args: cobra.RangeArgs(1, 2),
1487 RunE: func(cmd *cobra.Command, args []string) error {
1488 ctx := context.Background()
1489 routeDomain := args[0]
1490
1491 // Resolve image from positional argument or flag
1492 hasPositionalImage := len(args) > 1
1493 hasFlagImage := image != ""
1494
1495 // Check for conflicts: both positional and flag provided
1496 if hasPositionalImage && hasFlagImage {
1497 return fmt.Errorf("error: cannot use both positional image argument and --image flag")
1498 }
1499
1500 // Check that image is provided via either method
1501 if !hasPositionalImage && !hasFlagImage {
1502 return fmt.Errorf("error: image is required (use positional argument or --image flag)")
1503 }
1504
1505 if hasPositionalImage {
1506 image = args[1]
1507 }
1508
1509 route := domain.Route{
1510 Domain: routeDomain,
1511 Image: image,
1512 }
1513
1514 client, isRemote := GetRemoteClient()
1515 if isRemote {
1516 if err := client.AddRoute(ctx, route); err != nil {
1517 return fmt.Errorf("failed to add route: %w", err)
1518 }
1519 } else {
1520 local, err := GetLocalServices(configPath)
1521 if err != nil {
1522 return fmt.Errorf("failed to initialize local services: %w", err)
1523 }
1524 if err := local.GetConfigService().AddRoute(ctx, route); err != nil {
1525 return fmt.Errorf("failed to add route: %w", err)
1526 }
1527 }
1528
1529 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route configured: %s -> %s", routeDomain, image)))
1530 return nil
1531 },
1532 }
1533
1534 cmd.Flags().StringVarP(&image, "image", "i", "", "Container image")
1535
1536 return cmd
1537}
1538
1539// newRoutesRemoveCmd creates the routes remove command.
1540func newRoutesRemoveCmd() *cobra.Command {
1541 var force bool
1542
1543 cmd := &cobra.Command{
1544 Use: "remove <domain>",
1545 Short: "Remove a route",
1546 Long: `Remove a route by its domain name.
1547
1548Examples:
1549 gordon routes remove app.mydomain.com
1550 gordon routes remove app.mydomain.com --force`,
1551 Args: cobra.ExactArgs(1),
1552 RunE: func(cmd *cobra.Command, args []string) error {
1553 ctx := cmd.Context()
1554 routeDomain := args[0]
1555
1556 // Confirm unless --force
1557 if !force {
1558 confirmed, err := components.RunConfirm(
1559 fmt.Sprintf("Remove route '%s'?", routeDomain),
1560 components.WithDescription("This will stop and remove the associated container."),
1561 )
1562 if err != nil {
1563 return err
1564 }
1565 if !confirmed {
1566 fmt.Println(styles.Theme.Muted.Render("Cancelled"))
1567 return nil
1568 }
1569 }
1570
1571 handle, err := resolveControlPlaneForRouteDomain(ctx, routeDomain)
1572 if err != nil {
1573 return err
1574 }
1575 defer handle.close()
1576 var cleanup *dto.CleanupReport
1577 if remover, ok := handle.plane.(interface {
1578 RemoveRouteWithCleanup(context.Context, string) (*dto.RouteDeleteResponse, error)
1579 }); ok {
1580 resp, err := remover.RemoveRouteWithCleanup(ctx, routeDomain)
1581 if err != nil {
1582 return fmt.Errorf("failed to remove route: %w", err)
1583 }
1584 if resp != nil {
1585 cleanup = resp.Cleanup
1586 }
1587 } else if err := handle.plane.RemoveRoute(ctx, routeDomain); err != nil {
1588 return fmt.Errorf("failed to remove route: %w", err)
1589 }
1590
1591 return writeRouteRemoveText(cmd.OutOrStdout(), routeDomain, cleanup)
1592 },
1593 }
1594
1595 cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation")
1596
1597 return cmd
1598}
1599
1600func writeRouteRemoveText(out io.Writer, routeDomain string, cleanup *dto.CleanupReport) error {
1601 if err := cliWriteLine(out, styles.RenderSuccess(fmt.Sprintf("Route removed: %s", routeDomain))); err != nil {
1602 return err
1603 }
1604 if cleanup == nil {
1605 return nil
1606 }
1607 cleanupReport := cleanupReportDTOToDomain(cleanup)
1608 if err := writeRemovedContainers(out, cleanupReport.RemovedContainers); err != nil {
1609 return err
1610 }
1611 if err := writePreservedAttachments(out, cleanupReport.PreservedAttachments); err != nil {
1612 return err
1613 }
1614 return writeCleanupMessages(out, cleanupReport.Warnings, cleanupReport.Hints)
1615}
1616
1617func cleanupReportDTOToDomain(cleanup *dto.CleanupReport) *domain.CleanupReport {
1618 if cleanup == nil {
1619 return nil
1620 }
1621 out := &domain.CleanupReport{
1622 Domain: cleanup.Domain,
1623 Warnings: append([]string(nil), cleanup.Warnings...),
1624 Hints: append([]string(nil), cleanup.Hints...),
1625 }
1626 for _, item := range cleanup.RemovedContainers {
1627 out.RemovedContainers = append(out.RemovedContainers, domain.CleanupContainer(item))
1628 }
1629 for _, item := range cleanup.PreservedAttachments {
1630 out.PreservedAttachments = append(out.PreservedAttachments, domain.CleanupAttachment(item))
1631 }
1632 return out
1633}
1634
1635func writeRemovedContainers(out io.Writer, containers []domain.CleanupContainer) error {
1636 if len(containers) == 0 {
1637 return nil
1638 }
1639 if err := cliWriteLine(out, ""); err != nil {
1640 return err
1641 }
1642 if err := cliWriteLine(out, styles.Theme.Bold.Render("Removed containers:")); err != nil {
1643 return err
1644 }
1645 for _, c := range containers {
1646 label := c.Name
1647 if label == "" {
1648 label = c.ID
1649 }
1650 if id := shortContainerID(c.ID); id != "" && id != label {
1651 label = fmt.Sprintf("%s (%s)", label, id)
1652 }
1653 if err := cliWriteLine(out, " "+label); err != nil {
1654 return err
1655 }
1656 }
1657 return nil
1658}
1659
1660func writePreservedAttachments(out io.Writer, attachments []domain.CleanupAttachment) error {
1661 if len(attachments) == 0 {
1662 return nil
1663 }
1664 if err := cliWriteLine(out, ""); err != nil {
1665 return err
1666 }
1667 if err := cliWriteLine(out, styles.Theme.Bold.Render("Preserved attachments:")); err != nil {
1668 return err
1669 }
1670 for _, a := range attachments {
1671 label := a.Name
1672 if label == "" {
1673 label = a.ContainerID
1674 }
1675 if a.Status != "" {
1676 label = fmt.Sprintf("%s (%s)", label, a.Status)
1677 }
1678 if err := cliWriteLine(out, " "+label); err != nil {
1679 return err
1680 }
1681 }
1682 return nil
1683}
1684
1685func writeCleanupMessages(out io.Writer, warnings, hints []string) error {
1686 for _, warning := range warnings {
1687 if err := cliWriteLine(out, cliRenderWarning(warning)); err != nil {
1688 return err
1689 }
1690 }
1691 for _, hint := range hints {
1692 if err := cliWriteLine(out, cliRenderMuted("Hint: "+hint)); err != nil {
1693 return err
1694 }
1695 }
1696 return nil
1697}
1698
1699// newStatusCmd creates the status command.
1700func newStatusCmd() *cobra.Command {
1701 return &cobra.Command{
1702 Use: "status",
1703 Short: "Show Gordon server status",
1704 RunE: func(cmd *cobra.Command, args []string) error {
1705 ctx := context.Background()
1706
1707 handle, err := resolveControlPlane(configPath)
1708 if err != nil {
1709 return err
1710 }
1711 defer handle.close()
1712
1713 status, err := handle.plane.GetStatus(ctx)
1714 if err != nil {
1715 return fmt.Errorf("failed to get status: %w", err)
1716 }
1717
1718 // Display status
1719 fmt.Println(styles.Theme.Title.Render("Gordon Status"))
1720 fmt.Println()
1721
1722 fmt.Printf("%s %s\n", styles.Theme.Bold.Render("Domain:"), status.RegistryDomain)
1723 fmt.Printf("%s %d\n", styles.Theme.Bold.Render("Registry Port:"), status.RegistryPort)
1724 fmt.Printf("%s %d\n", styles.Theme.Bold.Render("Server Port:"), status.ServerPort)
1725 fmt.Printf("%s %d\n", styles.Theme.Bold.Render("Routes:"), status.Routes)
1726 fmt.Printf("%s %v\n", styles.Theme.Bold.Render("Auto-Route:"), status.AutoRoute)
1727 fmt.Printf("%s %v\n", styles.Theme.Bold.Render("Network Isolation:"), status.NetworkIsolation)
1728
1729 if len(status.ContainerStatus) > 0 {
1730 fmt.Println()
1731 fmt.Println(styles.Theme.Bold.Render("Container Status:"))
1732 for domain, containerStatus := range status.ContainerStatus {
1733 badge := components.ContainerStatusBadge(containerStatus)
1734 fmt.Printf(" %s: %s\n", domain, badge)
1735 }
1736 }
1737
1738 return nil
1739 },
1740 }
1741}