Skip to content

Commit fd47262

Browse files
authored
Add CRD-based group storage for Kubernetes (#2463)
* Add CRD-based group storage for Kubernetes environments * bumped the chart version * fix helm linting * generate crd docs * remove clients from group grom k8s * fix linting error * fix crd docs generation * revert version bump * revert version bump
1 parent dbeef08 commit fd47262

File tree

5 files changed

+1011
-190
lines changed

5 files changed

+1011
-190
lines changed

pkg/groups/cli_manager.go

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
// Package groups provides functionality for managing logical groupings of MCP servers.
2+
// This file contains the CLI/filesystem-based implementation for local environments.
3+
package groups
4+
5+
import (
6+
"context"
7+
"encoding/json"
8+
"fmt"
9+
"sort"
10+
"strings"
11+
12+
thverrors "github.com/stacklok/toolhive/pkg/errors"
13+
"github.com/stacklok/toolhive/pkg/logger"
14+
"github.com/stacklok/toolhive/pkg/state"
15+
"github.com/stacklok/toolhive/pkg/validation"
16+
)
17+
18+
// cliManager implements the Manager interface using filesystem-based state storage
19+
type cliManager struct {
20+
groupStore state.Store
21+
}
22+
23+
// NewCLIManager creates a new CLI-based group manager that uses filesystem storage
24+
func NewCLIManager() (Manager, error) {
25+
store, err := state.NewGroupConfigStore("toolhive")
26+
if err != nil {
27+
return nil, fmt.Errorf("failed to create group state store: %w", err)
28+
}
29+
30+
return &cliManager{groupStore: store}, nil
31+
}
32+
33+
// Create creates a new group with the given name
34+
func (m *cliManager) Create(ctx context.Context, name string) error {
35+
// Validate group name
36+
if err := validation.ValidateGroupName(name); err != nil {
37+
return thverrors.NewInvalidArgumentError(err.Error(), err)
38+
}
39+
// Check if group already exists
40+
exists, err := m.groupStore.Exists(ctx, name)
41+
if err != nil {
42+
return fmt.Errorf("failed to check if group exists: %w", err)
43+
}
44+
if exists {
45+
return thverrors.NewGroupAlreadyExistsError(fmt.Sprintf("group '%s' already exists", name), nil)
46+
}
47+
48+
group := &Group{
49+
Name: name,
50+
RegisteredClients: []string{},
51+
}
52+
return m.saveGroup(ctx, group)
53+
}
54+
55+
// Get retrieves a group by name
56+
func (m *cliManager) Get(ctx context.Context, name string) (*Group, error) {
57+
reader, err := m.groupStore.GetReader(ctx, name)
58+
if err != nil {
59+
return nil, fmt.Errorf("failed to get reader for group: %w", err)
60+
}
61+
defer reader.Close()
62+
63+
var group Group
64+
if err := json.NewDecoder(reader).Decode(&group); err != nil {
65+
return nil, fmt.Errorf("failed to decode group: %w", err)
66+
}
67+
68+
return &group, nil
69+
}
70+
71+
// List returns all groups
72+
func (m *cliManager) List(ctx context.Context) ([]*Group, error) {
73+
names, err := m.groupStore.List(ctx)
74+
if err != nil {
75+
return nil, fmt.Errorf("failed to list groups: %w", err)
76+
}
77+
78+
groups := make([]*Group, 0, len(names))
79+
for _, name := range names {
80+
group, err := m.Get(ctx, name)
81+
if err != nil {
82+
return nil, fmt.Errorf("failed to get group %s: %w", name, err)
83+
}
84+
groups = append(groups, group)
85+
}
86+
87+
// Sort groups alphanumerically by name (handles mixed characters, numbers, etc.)
88+
sort.Slice(groups, func(i, j int) bool {
89+
return strings.Compare(groups[i].Name, groups[j].Name) < 0
90+
})
91+
92+
return groups, nil
93+
}
94+
95+
// Delete removes a group by name
96+
func (m *cliManager) Delete(ctx context.Context, name string) error {
97+
return m.groupStore.Delete(ctx, name)
98+
}
99+
100+
// Exists checks if a group exists
101+
func (m *cliManager) Exists(ctx context.Context, name string) (bool, error) {
102+
return m.groupStore.Exists(ctx, name)
103+
}
104+
105+
// RegisterClients registers multiple clients with multiple groups
106+
func (m *cliManager) RegisterClients(ctx context.Context, groupNames []string, clientNames []string) error {
107+
for _, groupName := range groupNames {
108+
// Get the existing group
109+
group, err := m.Get(ctx, groupName)
110+
if err != nil {
111+
return fmt.Errorf("failed to get group %s: %w", groupName, err)
112+
}
113+
114+
groupModified := false
115+
for _, clientName := range clientNames {
116+
// Check if client is already registered
117+
alreadyRegistered := false
118+
for _, existingClient := range group.RegisteredClients {
119+
if existingClient == clientName {
120+
alreadyRegistered = true
121+
break
122+
}
123+
}
124+
125+
if alreadyRegistered {
126+
logger.Infof("Client %s is already registered with group %s, skipping", clientName, groupName)
127+
continue
128+
}
129+
130+
// Add the client to the group
131+
group.RegisteredClients = append(group.RegisteredClients, clientName)
132+
groupModified = true
133+
logger.Infof("Successfully registered client %s with group %s", clientName, groupName)
134+
}
135+
136+
// Only save if the group was actually modified
137+
if groupModified {
138+
err = m.saveGroup(ctx, group)
139+
if err != nil {
140+
return fmt.Errorf("failed to save group %s: %w", groupName, err)
141+
}
142+
}
143+
}
144+
145+
return nil
146+
}
147+
148+
// UnregisterClients removes multiple clients from multiple groups
149+
func (m *cliManager) UnregisterClients(ctx context.Context, groupNames []string, clientNames []string) error {
150+
for _, groupName := range groupNames {
151+
// Get the existing group
152+
group, err := m.Get(ctx, groupName)
153+
if err != nil {
154+
return fmt.Errorf("failed to get group %s: %w", groupName, err)
155+
}
156+
157+
groupModified := false
158+
for _, clientName := range clientNames {
159+
// Find and remove the client from the group
160+
for i, existingClient := range group.RegisteredClients {
161+
if existingClient == clientName {
162+
// Remove client from slice
163+
group.RegisteredClients = append(group.RegisteredClients[:i], group.RegisteredClients[i+1:]...)
164+
groupModified = true
165+
logger.Infof("Successfully unregistered client %s from group %s", clientName, groupName)
166+
break
167+
}
168+
}
169+
}
170+
171+
// Only save if the group was actually modified
172+
if groupModified {
173+
err = m.saveGroup(ctx, group)
174+
if err != nil {
175+
return fmt.Errorf("failed to save group %s: %w", groupName, err)
176+
}
177+
}
178+
}
179+
180+
return nil
181+
}
182+
183+
// saveGroup saves the group to the group state store
184+
func (m *cliManager) saveGroup(ctx context.Context, group *Group) error {
185+
writer, err := m.groupStore.GetWriter(ctx, group.Name)
186+
if err != nil {
187+
return fmt.Errorf("failed to get writer for group: %w", err)
188+
}
189+
defer writer.Close()
190+
191+
encoder := json.NewEncoder(writer)
192+
encoder.SetIndent("", " ")
193+
if err := encoder.Encode(group); err != nil {
194+
return fmt.Errorf("failed to write group: %w", err)
195+
}
196+
197+
// Ensure the writer is flushed
198+
if closer, ok := writer.(interface{ Sync() error }); ok {
199+
if err := closer.Sync(); err != nil {
200+
return fmt.Errorf("failed to sync group file: %w", err)
201+
}
202+
}
203+
204+
return nil
205+
}

pkg/groups/group_test.go renamed to pkg/groups/cli_manager_test.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ func TestManager_Create(t *testing.T) {
106106
defer ctrl.Finish()
107107

108108
mockStore := mocks.NewMockStore(ctrl)
109-
manager := &manager{groupStore: mockStore}
109+
manager := &cliManager{groupStore: mockStore}
110110

111111
// Set up mock expectations
112112
tt.setupMock(mockStore)
@@ -181,7 +181,7 @@ func TestManager_Get(t *testing.T) {
181181
defer ctrl.Finish()
182182

183183
mockStore := mocks.NewMockStore(ctrl)
184-
manager := &manager{groupStore: mockStore}
184+
manager := &cliManager{groupStore: mockStore}
185185

186186
// Set up mock expectations
187187
tt.setupMock(mockStore)
@@ -288,7 +288,7 @@ func TestManager_List(t *testing.T) {
288288
defer ctrl.Finish()
289289

290290
mockStore := mocks.NewMockStore(ctrl)
291-
manager := &manager{groupStore: mockStore}
291+
manager := &cliManager{groupStore: mockStore}
292292

293293
// Set up mock expectations
294294
tt.setupMock(mockStore)
@@ -374,7 +374,7 @@ func TestManager_Delete(t *testing.T) {
374374
defer ctrl.Finish()
375375

376376
mockStore := mocks.NewMockStore(ctrl)
377-
manager := &manager{groupStore: mockStore}
377+
manager := &cliManager{groupStore: mockStore}
378378

379379
// Set up mock expectations
380380
tt.setupMock(mockStore)
@@ -448,7 +448,7 @@ func TestManager_Exists(t *testing.T) {
448448
defer ctrl.Finish()
449449

450450
mockStore := mocks.NewMockStore(ctrl)
451-
manager := &manager{groupStore: mockStore}
451+
manager := &cliManager{groupStore: mockStore}
452452

453453
// Set up mock expectations
454454
tt.setupMock(mockStore)
@@ -533,7 +533,7 @@ func TestManager_RegisterClients(t *testing.T) {
533533
defer ctrl.Finish()
534534

535535
mockStore := mocks.NewMockStore(ctrl)
536-
manager := &manager{groupStore: mockStore}
536+
manager := &cliManager{groupStore: mockStore}
537537

538538
// Set up mock expectations
539539
tt.setupMock(mockStore)
@@ -617,7 +617,7 @@ func TestManager_UnregisterClients(t *testing.T) {
617617
defer ctrl.Finish()
618618

619619
mockStore := mocks.NewMockStore(ctrl)
620-
manager := &manager{groupStore: mockStore}
620+
manager := &cliManager{groupStore: mockStore}
621621

622622
// Set up mock expectations
623623
tt.setupMock(mockStore)

0 commit comments

Comments
 (0)