Skip to content

Commit 73baeb2

Browse files
authored
feat: Add group delete and rm --group commands with workload management (#1174)
1 parent 4a24093 commit 73baeb2

File tree

9 files changed

+778
-17
lines changed

9 files changed

+778
-17
lines changed

cmd/thv/app/group.go

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package app
22

33
import (
4+
"bufio"
5+
"context"
46
"fmt"
57
"os"
68
"sort"
@@ -10,6 +12,7 @@ import (
1012
"github.com/spf13/cobra"
1113

1214
"github.com/stacklok/toolhive/pkg/groups"
15+
"github.com/stacklok/toolhive/pkg/workloads"
1316
)
1417

1518
var groupCmd = &cobra.Command{
@@ -33,6 +36,17 @@ var groupListCmd = &cobra.Command{
3336
RunE: groupListCmdFunc,
3437
}
3538

39+
var groupRmCmd = &cobra.Command{
40+
Use: "rm [group-name]",
41+
Short: "Remove a group and remove workloads from it",
42+
Long: "Remove a group and remove all MCP servers from it. By default, this only removes the group " +
43+
"membership from workloads without deleting them. Use --with-workloads to also delete the workloads. ",
44+
Args: cobra.ExactArgs(1),
45+
RunE: groupRmCmdFunc,
46+
}
47+
48+
var withWorkloadsFlag bool
49+
3650
func groupCreateCmdFunc(cmd *cobra.Command, args []string) error {
3751
groupName := args[0]
3852
ctx := cmd.Context()
@@ -90,7 +104,153 @@ func groupListCmdFunc(cmd *cobra.Command, _ []string) error {
90104
return nil
91105
}
92106

107+
func groupRmCmdFunc(cmd *cobra.Command, args []string) error {
108+
groupName := args[0]
109+
ctx := cmd.Context()
110+
111+
if strings.EqualFold(groupName, groups.DefaultGroup) {
112+
return fmt.Errorf("cannot delete the %s group", groups.DefaultGroup)
113+
}
114+
115+
groupManager, err := groups.NewManager()
116+
if err != nil {
117+
return fmt.Errorf("failed to create group manager: %w", err)
118+
}
119+
120+
// Check if group exists
121+
exists, err := groupManager.Exists(ctx, groupName)
122+
if err != nil {
123+
return fmt.Errorf("failed to check if group exists: %w", err)
124+
}
125+
if !exists {
126+
return fmt.Errorf("group '%s' does not exist", groupName)
127+
}
128+
129+
// Get all workloads in the group
130+
groupWorkloads, err := groupManager.ListWorkloadsInGroup(ctx, groupName)
131+
if err != nil {
132+
return fmt.Errorf("failed to list workloads in group: %w", err)
133+
}
134+
135+
// Show warning and get user confirmation
136+
confirmed, err := showWarningAndGetConfirmation(groupName, groupWorkloads)
137+
if err != nil {
138+
return err
139+
}
140+
141+
if !confirmed {
142+
return nil
143+
}
144+
145+
// Handle workloads if any exist
146+
if len(groupWorkloads) > 0 {
147+
if withWorkloadsFlag {
148+
err = deleteWorkloadsInGroup(ctx, groupWorkloads, groupName)
149+
} else {
150+
err = removeWorkloadsMembershipFromGroup(ctx, groupWorkloads, groupName)
151+
}
152+
}
153+
if err != nil {
154+
return err
155+
}
156+
157+
if err = groupManager.Delete(ctx, groupName); err != nil {
158+
return fmt.Errorf("failed to delete group: %w", err)
159+
}
160+
161+
fmt.Printf("Group '%s' deleted successfully\n", groupName)
162+
return nil
163+
}
164+
165+
func showWarningAndGetConfirmation(groupName string, groupWorkloads []string) (bool, error) {
166+
if len(groupWorkloads) == 0 {
167+
return true, nil
168+
}
169+
170+
// Show warning and get user confirmation
171+
if withWorkloadsFlag {
172+
fmt.Printf("⚠️ WARNING: This will delete group '%s' and DELETE all workloads belonging to it.\n", groupName)
173+
} else {
174+
fmt.Printf("⚠️ WARNING: This will delete group '%s' and move all workloads to the 'default' group\n", groupName)
175+
}
176+
177+
fmt.Printf(" The following %d workload(s) will be affected:\n", len(groupWorkloads))
178+
for _, workload := range groupWorkloads {
179+
if withWorkloadsFlag {
180+
fmt.Printf(" - %s (will be DELETED)\n", workload)
181+
} else {
182+
fmt.Printf(" - %s (will be moved to the 'default' group)\n", workload)
183+
}
184+
}
185+
186+
if withWorkloadsFlag {
187+
fmt.Printf("\nThis action cannot be undone. Are you sure you want to continue? [y/N]: ")
188+
} else {
189+
fmt.Printf("\nAre you sure you want to continue? [y/N]: ")
190+
}
191+
192+
// Read user input
193+
reader := bufio.NewReader(os.Stdin)
194+
response, err := reader.ReadString('\n')
195+
if err != nil {
196+
return false, fmt.Errorf("failed to read user input: %w", err)
197+
}
198+
199+
// Check if user confirmed
200+
response = strings.TrimSpace(strings.ToLower(response))
201+
if response != "y" && response != "yes" {
202+
fmt.Println("Group deletion cancelled.")
203+
return false, nil
204+
}
205+
206+
return true, nil
207+
}
208+
209+
func deleteWorkloadsInGroup(ctx context.Context, groupWorkloads []string, groupName string) error {
210+
// Delete workloads
211+
workloadManager, err := workloads.NewManager(ctx)
212+
if err != nil {
213+
return fmt.Errorf("failed to create workload manager: %w", err)
214+
}
215+
216+
// Delete all workloads in the group
217+
group, err := workloadManager.DeleteWorkloads(ctx, groupWorkloads)
218+
if err != nil {
219+
return fmt.Errorf("failed to delete workloads in group: %w", err)
220+
}
221+
222+
// Wait for the deletion to complete
223+
if err := group.Wait(); err != nil {
224+
return fmt.Errorf("failed to delete workloads in group: %w", err)
225+
}
226+
227+
fmt.Printf("Deleted %d workload(s) from group '%s'\n", len(groupWorkloads), groupName)
228+
return nil
229+
}
230+
231+
// removeWorkloadsFromGroup removes the group membership from the workloads
232+
// in the group.
233+
func removeWorkloadsMembershipFromGroup(ctx context.Context, groupWorkloads []string, groupName string) error {
234+
workloadManager, err := workloads.NewManager(ctx)
235+
if err != nil {
236+
return fmt.Errorf("failed to create workload manager: %w", err)
237+
}
238+
239+
// Remove group membership from all workloads
240+
if err := workloadManager.MoveToDefaultGroup(ctx, groupWorkloads, groupName); err != nil {
241+
return fmt.Errorf("failed to move workloads to default group: %w", err)
242+
}
243+
244+
fmt.Printf("Removed %d workload(s) from group '%s'\n", len(groupWorkloads), groupName)
245+
return nil
246+
}
247+
93248
func init() {
94249
groupCmd.AddCommand(groupCreateCmd)
95250
groupCmd.AddCommand(groupListCmd)
251+
groupCmd.AddCommand(groupRmCmd)
252+
253+
// Add --with-workloads flag to group rm command
254+
groupRmCmd.Flags().BoolVar(&withWorkloadsFlag, "with-workloads", false,
255+
"Delete all workloads in the group along with the group")
96256
}

cmd/thv/app/rm.go

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,54 @@
11
package app
22

33
import (
4+
"context"
45
"fmt"
56

67
"github.com/spf13/cobra"
78

9+
"github.com/stacklok/toolhive/pkg/groups"
810
"github.com/stacklok/toolhive/pkg/workloads"
911
)
1012

1113
var rmCmd = &cobra.Command{
1214
Use: "rm [workload-name]",
1315
Short: "Remove an MCP server",
1416
Long: `Remove an MCP server managed by ToolHive.`,
15-
Args: cobra.ExactArgs(1),
17+
Args: cobra.MaximumNArgs(1),
1618
RunE: rmCmdFunc,
1719
ValidArgsFunction: completeMCPServerNames,
1820
}
1921

22+
func init() {
23+
// TODO: Re-enable when group functionality is complete
24+
// rmCmd.Flags().String("group", "", "Delete all workloads in the specified group")
25+
}
26+
2027
//nolint:gocyclo // This function is complex but manageable
2128
func rmCmdFunc(cmd *cobra.Command, args []string) error {
2229
ctx := cmd.Context()
23-
// Get workload name
24-
workloadName := args[0]
30+
31+
// Check if group flag is provided
32+
groupName, _ := cmd.Flags().GetString("group")
33+
34+
workloadName := ""
35+
if len(args) > 0 {
36+
workloadName = args[0]
37+
}
38+
39+
if workloadName != "" && groupName != "" {
40+
return fmt.Errorf("workload name and group name cannot be used together")
41+
}
42+
43+
if groupName != "" {
44+
// Delete all workloads in the specified group
45+
return deleteAllWorkloadsInGroup(ctx, groupName)
46+
}
47+
48+
// Delete specific workload
49+
if workloadName == "" {
50+
return fmt.Errorf("workload name is required when not using --group flag")
51+
}
2552

2653
// Create workload manager.
2754
manager, err := workloads.NewManager(ctx)
@@ -43,3 +70,51 @@ func rmCmdFunc(cmd *cobra.Command, args []string) error {
4370
fmt.Printf("Container %s removed successfully\n", workloadName)
4471
return nil
4572
}
73+
74+
func deleteAllWorkloadsInGroup(ctx context.Context, groupName string) error {
75+
// Create group manager
76+
groupManager, err := groups.NewManager()
77+
if err != nil {
78+
return fmt.Errorf("failed to create group manager: %v", err)
79+
}
80+
81+
// Check if group exists
82+
exists, err := groupManager.Exists(ctx, groupName)
83+
if err != nil {
84+
return fmt.Errorf("failed to check if group exists: %v", err)
85+
}
86+
if !exists {
87+
return fmt.Errorf("group '%s' does not exist", groupName)
88+
}
89+
90+
// Get all workloads in the group
91+
groupWorkloads, err := groupManager.ListWorkloadsInGroup(ctx, groupName)
92+
if err != nil {
93+
return fmt.Errorf("failed to list workloads in group: %v", err)
94+
}
95+
96+
if len(groupWorkloads) == 0 {
97+
fmt.Printf("No workloads found in group '%s'\n", groupName)
98+
return nil
99+
}
100+
101+
// Create workload manager
102+
workloadManager, err := workloads.NewManager(ctx)
103+
if err != nil {
104+
return fmt.Errorf("failed to create workload manager: %v", err)
105+
}
106+
107+
// Delete all workloads in the group
108+
group, err := workloadManager.DeleteWorkloads(ctx, groupWorkloads)
109+
if err != nil {
110+
return fmt.Errorf("failed to delete workloads in group: %v", err)
111+
}
112+
113+
// Wait for the deletion to complete
114+
if err := group.Wait(); err != nil {
115+
return fmt.Errorf("failed to delete workloads in group: %v", err)
116+
}
117+
118+
fmt.Printf("Successfully removed %d workload(s) from group '%s'\n", len(groupWorkloads), groupName)
119+
return nil
120+
}

pkg/groups/group.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ import (
88
"os"
99
)
1010

11+
// DefaultGroup is the name of the default group for workloads
12+
const DefaultGroup = "default"
13+
1114
// Group represents a logical grouping of MCP servers.
1215
type Group struct {
1316
Name string `json:"name"`
@@ -44,4 +47,7 @@ type Manager interface {
4447
// GetWorkloadGroup returns the group that a workload belongs to, if any.
4548
// Returns nil if the workload is not in any group.
4649
GetWorkloadGroup(ctx context.Context, workloadName string) (*Group, error)
50+
51+
// ListWorkloadsInGroup returns all workload names that belong to the specified group.
52+
ListWorkloadsInGroup(ctx context.Context, groupName string) ([]string, error)
4753
}

pkg/groups/group_test.go

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

103103
mockStore := mocks.NewMockStore(ctrl)
104-
manager := &manager{store: mockStore}
104+
manager := &manager{groupStore: mockStore}
105105

106106
// Set up mock expectations
107107
tt.setupMock(mockStore)
@@ -176,7 +176,7 @@ func TestManager_Get(t *testing.T) {
176176
defer ctrl.Finish()
177177

178178
mockStore := mocks.NewMockStore(ctrl)
179-
manager := &manager{store: mockStore}
179+
manager := &manager{groupStore: mockStore}
180180

181181
// Set up mock expectations
182182
tt.setupMock(mockStore)
@@ -282,7 +282,7 @@ func TestManager_List(t *testing.T) {
282282
defer ctrl.Finish()
283283

284284
mockStore := mocks.NewMockStore(ctrl)
285-
manager := &manager{store: mockStore}
285+
manager := &manager{groupStore: mockStore}
286286

287287
// Set up mock expectations
288288
tt.setupMock(mockStore)
@@ -368,7 +368,7 @@ func TestManager_Delete(t *testing.T) {
368368
defer ctrl.Finish()
369369

370370
mockStore := mocks.NewMockStore(ctrl)
371-
manager := &manager{store: mockStore}
371+
manager := &manager{groupStore: mockStore}
372372

373373
// Set up mock expectations
374374
tt.setupMock(mockStore)
@@ -442,7 +442,7 @@ func TestManager_Exists(t *testing.T) {
442442
defer ctrl.Finish()
443443

444444
mockStore := mocks.NewMockStore(ctrl)
445-
manager := &manager{store: mockStore}
445+
manager := &manager{groupStore: mockStore}
446446

447447
// Set up mock expectations
448448
tt.setupMock(mockStore)
@@ -489,7 +489,7 @@ func TestManager_GetWorkloadGroup(t *testing.T) {
489489
defer ctrl.Finish()
490490

491491
mockStore := mocks.NewMockStore(ctrl)
492-
manager := &manager{store: mockStore}
492+
manager := &manager{groupStore: mockStore}
493493

494494
// Call the method
495495
group, err := manager.GetWorkloadGroup(context.Background(), tt.workloadName)

0 commit comments

Comments
 (0)