Skip to content

Commit 29c8786

Browse files
committed
add clusters
1 parent 038dac7 commit 29c8786

18 files changed

Lines changed: 1095 additions & 4 deletions

cmd/command/tui/tui.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88

99
"github.com/pluralsh/plural-cli/pkg/bridge"
1010
accessbridge "github.com/pluralsh/plural-cli/pkg/bridge/access"
11+
clustersbridge "github.com/pluralsh/plural-cli/pkg/bridge/clusters"
1112
servicesbridge "github.com/pluralsh/plural-cli/pkg/bridge/services"
1213
welcomebridge "github.com/pluralsh/plural-cli/pkg/bridge/welcome"
1314
"github.com/pluralsh/plural-cli/pkg/common"
@@ -21,10 +22,12 @@ func Command() cli.Command {
2122
auth := bridge.NewAuthService(bridge.PluralAuthFactory{}, 0)
2223
access := accessbridge.NewLocalManager("", auth, nil)
2324
services := servicesbridge.NewService(access)
25+
clusters := clustersbridge.NewService(access)
2426
return tuiapp.Run(ctx, os.Stdin, os.Stdout, tuiapp.Dependencies{
2527
Welcome: welcome,
2628
Access: access,
2729
Services: services,
30+
Clusters: clusters,
2831
})
2932
})
3033
}

pkg/bridge/clusters/clusters.go

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
// Package clusters exposes read-only Console cluster list/get use cases to
2+
// presentation layers without importing TUI code.
3+
package clusters
4+
5+
import (
6+
"context"
7+
"errors"
8+
"strings"
9+
10+
gqlclient "github.com/pluralsh/console/go/client"
11+
12+
"github.com/pluralsh/plural-cli/pkg/bridge"
13+
"github.com/pluralsh/plural-cli/pkg/console"
14+
)
15+
16+
var (
17+
errNoConsole = errors.New("connect a Console profile before browsing Console resources")
18+
errMissingID = errors.New("cluster id is required")
19+
errMissingCluster = errors.New("cluster was not found")
20+
)
21+
22+
// Summary is a credential-free list row for a Console cluster.
23+
type Summary struct {
24+
ID string
25+
Name string
26+
Handle string
27+
Version string
28+
Distro string
29+
}
30+
31+
// Tag is a credential-free cluster tag.
32+
type Tag struct {
33+
Name string
34+
Value string
35+
}
36+
37+
// Detail is the credential-free detail payload for a Console cluster.
38+
type Detail struct {
39+
Summary
40+
Self bool
41+
PingedAt string
42+
Protect bool
43+
DeletedAt string
44+
Project string
45+
Provider string
46+
Tags []Tag
47+
NodePools int
48+
}
49+
50+
// Loader is the narrow contract consumed by the Clusters screen.
51+
type Loader interface {
52+
List(ctx context.Context, query string) ([]Summary, error)
53+
Get(ctx context.Context, id string) (Detail, error)
54+
}
55+
56+
// ConsoleResolver supplies the active Console URL and token.
57+
type ConsoleResolver interface {
58+
ActiveConsole(ctx context.Context) (url, token string, err error)
59+
}
60+
61+
// API is the Console surface required by this package.
62+
type API interface {
63+
ListClusters() (*gqlclient.ListClusters, error)
64+
GetCluster(clusterId, clusterName *string) (*gqlclient.ClusterFragment, error)
65+
}
66+
67+
// ClientFactory builds a Console API for an authenticated endpoint.
68+
type ClientFactory func(token, url string) (API, error)
69+
70+
// Service implements Loader against Console GraphQL.
71+
type Service struct {
72+
resolve ConsoleResolver
73+
newClient ClientFactory
74+
}
75+
76+
// NewService wires production Console credentials and client construction.
77+
func NewService(resolve ConsoleResolver) *Service {
78+
return &Service{
79+
resolve: resolve,
80+
newClient: func(token, url string) (API, error) {
81+
return console.NewConsoleClient(token, url)
82+
},
83+
}
84+
}
85+
86+
func (s *Service) client(ctx context.Context) (API, error) {
87+
if s.resolve == nil {
88+
return nil, &bridge.Error{Code: bridge.ErrorUnauthenticated, Err: errNoConsole}
89+
}
90+
url, token, err := s.resolve.ActiveConsole(ctx)
91+
if err != nil {
92+
return nil, err
93+
}
94+
factory := s.newClient
95+
if factory == nil {
96+
factory = func(token, url string) (API, error) {
97+
return console.NewConsoleClient(token, url)
98+
}
99+
}
100+
return factory(token, url)
101+
}
102+
103+
func (s *Service) List(ctx context.Context, query string) ([]Summary, error) {
104+
if err := ctx.Err(); err != nil {
105+
return nil, err
106+
}
107+
client, err := s.client(ctx)
108+
if err != nil {
109+
return nil, err
110+
}
111+
result, err := client.ListClusters()
112+
if err != nil {
113+
return nil, err
114+
}
115+
if result == nil || result.Clusters == nil {
116+
return nil, nil
117+
}
118+
items := make([]Summary, 0, len(result.Clusters.Edges))
119+
for _, edge := range result.Clusters.Edges {
120+
if edge == nil || edge.Node == nil {
121+
continue
122+
}
123+
summary := summaryFromFragment(edge.Node)
124+
if !matchesQuery(summary, query) {
125+
continue
126+
}
127+
items = append(items, summary)
128+
}
129+
return items, nil
130+
}
131+
132+
func (s *Service) Get(ctx context.Context, id string) (Detail, error) {
133+
if err := ctx.Err(); err != nil {
134+
return Detail{}, err
135+
}
136+
id = strings.TrimSpace(id)
137+
if id == "" {
138+
return Detail{}, &bridge.Error{Code: bridge.ErrorInvalid, Err: errMissingID}
139+
}
140+
client, err := s.client(ctx)
141+
if err != nil {
142+
return Detail{}, err
143+
}
144+
cluster, err := client.GetCluster(&id, nil)
145+
if err != nil {
146+
return Detail{}, err
147+
}
148+
if cluster == nil {
149+
return Detail{}, &bridge.Error{Code: bridge.ErrorUnavailable, Err: errMissingCluster}
150+
}
151+
return detailFromFragment(cluster), nil
152+
}
153+
154+
func summaryFromFragment(node *gqlclient.ClusterFragment) Summary {
155+
summary := Summary{ID: node.ID, Name: node.Name}
156+
if node.Handle != nil {
157+
summary.Handle = *node.Handle
158+
}
159+
if node.CurrentVersion != nil {
160+
summary.Version = *node.CurrentVersion
161+
}
162+
if node.Distro != nil {
163+
summary.Distro = string(*node.Distro)
164+
}
165+
return summary
166+
}
167+
168+
func detailFromFragment(cluster *gqlclient.ClusterFragment) Detail {
169+
detail := Detail{Summary: summaryFromFragment(cluster)}
170+
if cluster.Self != nil {
171+
detail.Self = *cluster.Self
172+
}
173+
if cluster.PingedAt != nil {
174+
detail.PingedAt = *cluster.PingedAt
175+
}
176+
if cluster.Protect != nil {
177+
detail.Protect = *cluster.Protect
178+
}
179+
if cluster.DeletedAt != nil {
180+
detail.DeletedAt = *cluster.DeletedAt
181+
}
182+
if cluster.Project != nil {
183+
detail.Project = cluster.Project.Name
184+
}
185+
if cluster.Provider != nil {
186+
detail.Provider = cluster.Provider.Name
187+
if cluster.Provider.Cloud != "" {
188+
detail.Provider = strings.TrimSpace(detail.Provider + " · " + cluster.Provider.Cloud)
189+
}
190+
}
191+
for _, tag := range cluster.Tags {
192+
if tag == nil {
193+
continue
194+
}
195+
detail.Tags = append(detail.Tags, Tag{Name: tag.Name, Value: tag.Value})
196+
}
197+
detail.NodePools = len(cluster.NodePools)
198+
return detail
199+
}
200+
201+
func matchesQuery(summary Summary, query string) bool {
202+
query = strings.TrimSpace(strings.ToLower(query))
203+
if query == "" {
204+
return true
205+
}
206+
haystack := strings.ToLower(strings.Join([]string{summary.Name, summary.Handle, summary.ID, summary.Version, summary.Distro}, " "))
207+
return strings.Contains(haystack, query)
208+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package clusters
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
gqlclient "github.com/pluralsh/console/go/client"
8+
"github.com/samber/lo"
9+
10+
"github.com/pluralsh/plural-cli/pkg/bridge"
11+
)
12+
13+
type fakeResolver struct {
14+
url, token string
15+
err error
16+
}
17+
18+
func (f fakeResolver) ActiveConsole(context.Context) (string, string, error) {
19+
return f.url, f.token, f.err
20+
}
21+
22+
type fakeAPI struct {
23+
clusters *gqlclient.ListClusters
24+
listErr error
25+
detail *gqlclient.ClusterFragment
26+
getErr error
27+
}
28+
29+
func (f *fakeAPI) ListClusters() (*gqlclient.ListClusters, error) { return f.clusters, f.listErr }
30+
func (f *fakeAPI) GetCluster(*string, *string) (*gqlclient.ClusterFragment, error) {
31+
return f.detail, f.getErr
32+
}
33+
34+
func TestListAndGet(t *testing.T) {
35+
handle := "prod-eu"
36+
version := "1.30.2"
37+
distro := gqlclient.ClusterDistroEks
38+
pinged := "2026-07-29T10:00:00Z"
39+
api := &fakeAPI{
40+
clusters: &gqlclient.ListClusters{Clusters: &gqlclient.ListClusters_Clusters{Edges: []*gqlclient.ClusterEdgeFragment{
41+
{Node: &gqlclient.ClusterFragment{
42+
ID: "c1", Name: "production", Handle: &handle,
43+
CurrentVersion: &version, Distro: &distro,
44+
}},
45+
{Node: &gqlclient.ClusterFragment{ID: "c2", Name: "staging"}},
46+
}}},
47+
detail: &gqlclient.ClusterFragment{
48+
ID: "c1", Name: "production", Handle: &handle,
49+
CurrentVersion: &version, Distro: &distro,
50+
Self: lo.ToPtr(true), PingedAt: &pinged, Protect: lo.ToPtr(false),
51+
Project: &gqlclient.TinyProjectFragment{Name: "acme"},
52+
Tags: []*gqlclient.ClusterTags{{Name: "env", Value: "prod"}},
53+
NodePools: []*gqlclient.NodePoolFragment{{}, {}},
54+
},
55+
}
56+
service := &Service{
57+
resolve: fakeResolver{url: "https://console.example.com", token: "token"},
58+
newClient: func(string, string) (API, error) { return api, nil },
59+
}
60+
61+
items, err := service.List(t.Context(), "prod")
62+
if err != nil || len(items) != 1 || items[0].Handle != "prod-eu" || items[0].Version != "1.30.2" {
63+
t.Fatalf("List() = %#v, %v", items, err)
64+
}
65+
66+
detail, err := service.Get(t.Context(), "c1")
67+
if err != nil {
68+
t.Fatalf("Get() error = %v", err)
69+
}
70+
if !detail.Self || detail.Project != "acme" || detail.NodePools != 2 || len(detail.Tags) != 1 {
71+
t.Fatalf("detail = %#v", detail)
72+
}
73+
}
74+
75+
func TestGetRequiresID(t *testing.T) {
76+
service := &Service{
77+
resolve: fakeResolver{url: "https://console.example.com", token: "token"},
78+
newClient: func(string, string) (API, error) { return &fakeAPI{}, nil },
79+
}
80+
_, err := service.Get(t.Context(), "")
81+
if !bridge.IsCode(err, bridge.ErrorInvalid) {
82+
t.Fatalf("Get() error = %v", err)
83+
}
84+
}

tui/app/model.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@ import (
88
tea "charm.land/bubbletea/v2"
99

1010
accessbridge "github.com/pluralsh/plural-cli/pkg/bridge/access"
11+
clustersbridge "github.com/pluralsh/plural-cli/pkg/bridge/clusters"
1112
servicesbridge "github.com/pluralsh/plural-cli/pkg/bridge/services"
1213
welcomebridge "github.com/pluralsh/plural-cli/pkg/bridge/welcome"
1314
"github.com/pluralsh/plural-cli/tui/navigation"
1415
accessscreen "github.com/pluralsh/plural-cli/tui/screens/access"
16+
clustersscreen "github.com/pluralsh/plural-cli/tui/screens/clusters"
1517
deploymentsscreen "github.com/pluralsh/plural-cli/tui/screens/deployments"
1618
diagnosticsscreen "github.com/pluralsh/plural-cli/tui/screens/diagnostics"
1719
servicesscreen "github.com/pluralsh/plural-cli/tui/screens/services"
@@ -24,6 +26,7 @@ type Dependencies struct {
2426
Welcome welcomebridge.Loader
2527
Access accessbridge.Manager
2628
Services servicesbridge.Loader
29+
Clusters clustersbridge.Loader
2730
}
2831

2932
// Model is the root TUI model. It owns global input and delegates screen state
@@ -40,6 +43,7 @@ type Model struct {
4043
diagnostics diagnosticsscreen.Model
4144
deployments deploymentsscreen.Model
4245
services servicesscreen.Model
46+
clusters clustersscreen.Model
4347
route navigation.Route
4448
}
4549

@@ -52,6 +56,7 @@ func New(ctx context.Context, t theme.Theme, dependencies Dependencies) Model {
5256
diagnostics: diagnosticsscreen.New(ctx, dependencies.Welcome, t),
5357
deployments: deploymentsscreen.New(ctx, t, ""),
5458
services: servicesscreen.New(ctx, dependencies.Services, t),
59+
clusters: clustersscreen.New(ctx, dependencies.Clusters, t),
5560
route: navigation.Welcome,
5661
quit: key.NewBinding(
5762
key.WithKeys("ctrl+c"),
@@ -76,6 +81,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
7681
return m, m.deployments.Init()
7782
case navigation.Services:
7883
return m, m.services.Init()
84+
case navigation.Clusters:
85+
return m, m.clusters.Init()
7986
default:
8087
return m, m.welcome.Init()
8188
}
@@ -103,6 +110,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
103110
m.deployments, cmd = m.deployments.Update(msg)
104111
case navigation.Services:
105112
m.services, cmd = m.services.Update(msg)
113+
case navigation.Clusters:
114+
m.clusters, cmd = m.clusters.Update(msg)
106115
default:
107116
m.welcome, cmd = m.welcome.Update(msg)
108117
}

tui/app/model_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,11 @@ func TestModelRoutesScreensWithoutRebuildingShell(t *testing.T) {
5555
if routed.route != navigation.Services || !strings.Contains(routed.View().Content, "Services") {
5656
t.Fatalf("services route/view = %q\n%s", routed.route, routed.View().Content)
5757
}
58+
updated, _ = routed.Update(navigation.NavigateMsg{Route: navigation.Clusters})
59+
routed = updated.(Model)
60+
if routed.route != navigation.Clusters || !strings.Contains(routed.View().Content, "Clusters") {
61+
t.Fatalf("clusters route/view = %q\n%s", routed.route, routed.View().Content)
62+
}
5863
updated, _ = routed.Update(navigation.NavigateMsg{Route: navigation.Welcome})
5964
if got := updated.(Model).route; got != navigation.Welcome {
6065
t.Fatalf("route = %q", got)

tui/app/view.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ func (m Model) View() tea.View {
1919
content = m.deployments.View(m.width, m.height)
2020
case navigation.Services:
2121
content = m.services.View(m.width, m.height)
22+
case navigation.Clusters:
23+
content = m.clusters.View(m.width, m.height)
2224
}
2325
view := tea.NewView(content)
2426
view.AltScreen = true

tui/navigation/navigation.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const (
1414
Diagnostics Route = "diagnostics"
1515
Deployments Route = "deployments"
1616
Services Route = "services"
17+
Clusters Route = "clusters"
1718
)
1819

1920
// NavigateMsg requests a top-level route change.

0 commit comments

Comments
 (0)