Skip to content

Commit 1e36b2b

Browse files
matzewclaude
andauthored
feat: add createNamespace and deleteGeneratedNamespaces operations (#23)
Add namespace generation, tracking, and cleanup operations to support dynamic namespace names in eval tasks. createNamespace generates a namespace with a random hex suffix (e.g. vm-test-a1b2c3) and tracks it internally. deleteGeneratedNamespaces cleans up all tracked namespaces, silently ignoring already-deleted ones. Ref: mcpchecker/mcpchecker#213 Signed-off-by: Matthias Wessendorf <mwessend@redhat.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 02a1712 commit 1e36b2b

4 files changed

Lines changed: 394 additions & 0 deletions

File tree

pkg/extension/extension.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"os"
77
"path/filepath"
88
"strings"
9+
"sync"
910

1011
"github.com/mcpchecker/mcpchecker/pkg/extension/sdk"
1112
"k8s.io/client-go/dynamic"
@@ -17,6 +18,9 @@ import (
1718
type Extension struct {
1819
*sdk.Extension
1920
client ResourceClient
21+
22+
mu sync.Mutex
23+
generatedNamespaces []string
2024
}
2125

2226
// New creates a new Kubernetes extension

pkg/extension/namespace.go

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
package extension
2+
3+
import (
4+
"context"
5+
"crypto/rand"
6+
"fmt"
7+
"strings"
8+
9+
"github.com/mcpchecker/mcpchecker/pkg/extension/sdk"
10+
apierrors "k8s.io/apimachinery/pkg/api/errors"
11+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
12+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
13+
"k8s.io/apimachinery/pkg/runtime/schema"
14+
)
15+
16+
var namespaceGVR = schema.GroupVersionResource{Group: "", Version: "v1", Resource: "namespaces"}
17+
18+
const alphanumeric = "abcdefghijklmnopqrstuvwxyz0123456789"
19+
20+
// generateID returns a random lowercase alphanumeric string of the given length.
21+
// This matches the {random.id} spec from mcpchecker/mcpchecker#102.
22+
func generateID(length int) (string, error) {
23+
b := make([]byte, length)
24+
if _, err := rand.Read(b); err != nil {
25+
return "", fmt.Errorf("failed to generate random id: %w", err)
26+
}
27+
for i := range b {
28+
b[i] = alphanumeric[int(b[i])%len(alphanumeric)]
29+
}
30+
return string(b), nil
31+
}
32+
33+
func (e *Extension) handleCreateNamespace(ctx context.Context, req *sdk.OperationRequest) (*sdk.OperationResult, error) {
34+
if e.client == nil {
35+
return sdk.Failure(fmt.Errorf("kubernetes client not initialized")), nil
36+
}
37+
38+
args, ok := req.Args.(map[string]any)
39+
if !ok {
40+
return sdk.Failure(fmt.Errorf("args must be an object")), nil
41+
}
42+
43+
prefix, _ := args["prefix"].(string)
44+
if prefix == "" {
45+
return sdk.Failure(fmt.Errorf("prefix is required")), nil
46+
}
47+
48+
id, err := generateID(8)
49+
if err != nil {
50+
return sdk.Failure(err), nil
51+
}
52+
53+
name := fmt.Sprintf("%s-%s", prefix, id)
54+
55+
obj := &unstructured.Unstructured{
56+
Object: map[string]any{
57+
"apiVersion": "v1",
58+
"kind": "Namespace",
59+
"metadata": map[string]any{
60+
"name": name,
61+
},
62+
},
63+
}
64+
65+
e.LogInfo(ctx, "Creating namespace", map[string]any{
66+
"name": name,
67+
})
68+
69+
result, err := e.client.Create(ctx, namespaceGVR, obj, "")
70+
if err != nil {
71+
e.LogError(ctx, "Failed to create namespace", map[string]any{
72+
"name": name,
73+
"error": err.Error(),
74+
})
75+
return sdk.Failure(fmt.Errorf("failed to create namespace: %w", err)), nil
76+
}
77+
78+
e.mu.Lock()
79+
e.generatedNamespaces = append(e.generatedNamespaces, result.GetName())
80+
e.mu.Unlock()
81+
82+
e.LogInfo(ctx, "Namespace created successfully", map[string]any{
83+
"name": result.GetName(),
84+
})
85+
86+
return sdk.SuccessWithOutputs(
87+
fmt.Sprintf("Created namespace %s", result.GetName()),
88+
map[string]string{
89+
"namespace": result.GetName(),
90+
},
91+
), nil
92+
}
93+
94+
func (e *Extension) handleDeleteGeneratedNamespaces(ctx context.Context, req *sdk.OperationRequest) (*sdk.OperationResult, error) {
95+
if e.client == nil {
96+
return sdk.Failure(fmt.Errorf("kubernetes client not initialized")), nil
97+
}
98+
99+
e.mu.Lock()
100+
namespaces := make([]string, len(e.generatedNamespaces))
101+
copy(namespaces, e.generatedNamespaces)
102+
e.generatedNamespaces = nil
103+
e.mu.Unlock()
104+
105+
if len(namespaces) == 0 {
106+
return sdk.Success("No generated namespaces to delete"), nil
107+
}
108+
109+
e.LogInfo(ctx, "Deleting generated namespaces", map[string]any{
110+
"count": len(namespaces),
111+
"namespaces": namespaces,
112+
})
113+
114+
propagation := metav1.DeletePropagationForeground
115+
deleteOpts := metav1.DeleteOptions{
116+
PropagationPolicy: &propagation,
117+
}
118+
119+
var errs []string
120+
for _, ns := range namespaces {
121+
err := e.client.Delete(ctx, namespaceGVR, ns, "", deleteOpts)
122+
if err != nil {
123+
if apierrors.IsNotFound(err) {
124+
e.LogInfo(ctx, "Namespace already deleted (ignored)", map[string]any{
125+
"name": ns,
126+
})
127+
continue
128+
}
129+
e.LogError(ctx, "Failed to delete namespace", map[string]any{
130+
"name": ns,
131+
"error": err.Error(),
132+
})
133+
errs = append(errs, fmt.Sprintf("%s: %s", ns, err.Error()))
134+
}
135+
}
136+
137+
if len(errs) > 0 {
138+
return sdk.Failure(fmt.Errorf("failed to delete namespaces: %s", strings.Join(errs, "; "))), nil
139+
}
140+
141+
return sdk.Success(fmt.Sprintf("Deleted %d generated namespace(s)", len(namespaces))), nil
142+
}

pkg/extension/namespace_test.go

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
package extension
2+
3+
import (
4+
"context"
5+
"errors"
6+
"strings"
7+
"testing"
8+
9+
"github.com/mcpchecker/mcpchecker/pkg/extension/sdk"
10+
apierrors "k8s.io/apimachinery/pkg/api/errors"
11+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
12+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
13+
"k8s.io/apimachinery/pkg/runtime/schema"
14+
)
15+
16+
func TestHandleCreateNamespace(t *testing.T) {
17+
tests := []struct {
18+
name string
19+
args any
20+
client *mockClient
21+
wantSuccess bool
22+
checkOutputs func(t *testing.T, result *sdk.OperationResult, ext *Extension)
23+
}{
24+
{
25+
name: "successful create",
26+
args: map[string]any{
27+
"prefix": "vm-test",
28+
},
29+
client: &mockClient{
30+
createFn: func(ctx context.Context, gvr schema.GroupVersionResource, obj *unstructured.Unstructured, namespace string) (*unstructured.Unstructured, error) {
31+
return obj.DeepCopy(), nil
32+
},
33+
},
34+
wantSuccess: true,
35+
checkOutputs: func(t *testing.T, result *sdk.OperationResult, ext *Extension) {
36+
t.Helper()
37+
ns, ok := result.Outputs["namespace"]
38+
if !ok {
39+
t.Fatal("expected namespace output key")
40+
}
41+
if !strings.HasPrefix(ns, "vm-test-") {
42+
t.Errorf("namespace %q does not have prefix vm-test-", ns)
43+
}
44+
// ID should be 8 lowercase alphanumeric chars per mcpchecker#102
45+
id := strings.TrimPrefix(ns, "vm-test-")
46+
if len(id) != 8 {
47+
t.Errorf("expected 8-char id, got %q (len=%d)", id, len(id))
48+
}
49+
50+
ext.mu.Lock()
51+
defer ext.mu.Unlock()
52+
if len(ext.generatedNamespaces) != 1 {
53+
t.Fatalf("expected 1 tracked namespace, got %d", len(ext.generatedNamespaces))
54+
}
55+
if ext.generatedNamespaces[0] != ns {
56+
t.Errorf("tracked namespace %q != output namespace %q", ext.generatedNamespaces[0], ns)
57+
}
58+
},
59+
},
60+
{
61+
name: "invalid args type",
62+
args: "not a map",
63+
client: &mockClient{},
64+
wantSuccess: false,
65+
},
66+
{
67+
name: "missing prefix",
68+
args: map[string]any{
69+
"other": "value",
70+
},
71+
client: &mockClient{},
72+
wantSuccess: false,
73+
},
74+
{
75+
name: "client error",
76+
args: map[string]any{
77+
"prefix": "vm-test",
78+
},
79+
client: &mockClient{
80+
createFn: func(ctx context.Context, gvr schema.GroupVersionResource, obj *unstructured.Unstructured, namespace string) (*unstructured.Unstructured, error) {
81+
return nil, errors.New("connection refused")
82+
},
83+
},
84+
wantSuccess: false,
85+
checkOutputs: func(t *testing.T, result *sdk.OperationResult, ext *Extension) {
86+
t.Helper()
87+
ext.mu.Lock()
88+
defer ext.mu.Unlock()
89+
if len(ext.generatedNamespaces) != 0 {
90+
t.Errorf("expected no tracked namespaces on error, got %d", len(ext.generatedNamespaces))
91+
}
92+
},
93+
},
94+
}
95+
96+
for _, tt := range tests {
97+
t.Run(tt.name, func(t *testing.T) {
98+
ext := &Extension{
99+
Extension: sdk.NewExtension(sdk.ExtensionInfo{Name: "test"}),
100+
client: tt.client,
101+
}
102+
103+
req := &sdk.OperationRequest{Args: tt.args}
104+
result, err := ext.handleCreateNamespace(context.Background(), req)
105+
106+
if err != nil {
107+
t.Fatalf("handleCreateNamespace() returned error: %v", err)
108+
}
109+
if result.Success != tt.wantSuccess {
110+
t.Errorf("handleCreateNamespace() success = %v, want %v (error: %s)", result.Success, tt.wantSuccess, result.Error)
111+
}
112+
if tt.checkOutputs != nil {
113+
tt.checkOutputs(t, result, ext)
114+
}
115+
})
116+
}
117+
}
118+
119+
func TestHandleDeleteGeneratedNamespaces(t *testing.T) {
120+
tests := []struct {
121+
name string
122+
tracked []string
123+
client *mockClient
124+
wantSuccess bool
125+
checkTracked func(t *testing.T, ext *Extension)
126+
}{
127+
{
128+
name: "no tracked namespaces",
129+
tracked: nil,
130+
client: &mockClient{},
131+
wantSuccess: true,
132+
},
133+
{
134+
name: "successful deletion of two namespaces",
135+
tracked: []string{"vm-test-abc123", "vm-test-def456"},
136+
client: &mockClient{
137+
deleteFn: func(ctx context.Context, gvr schema.GroupVersionResource, name, namespace string, opts metav1.DeleteOptions) error {
138+
return nil
139+
},
140+
},
141+
wantSuccess: true,
142+
checkTracked: func(t *testing.T, ext *Extension) {
143+
t.Helper()
144+
ext.mu.Lock()
145+
defer ext.mu.Unlock()
146+
if len(ext.generatedNamespaces) != 0 {
147+
t.Errorf("expected tracking cleared, got %d namespaces", len(ext.generatedNamespaces))
148+
}
149+
},
150+
},
151+
{
152+
name: "not-found error silently ignored",
153+
tracked: []string{"vm-test-gone"},
154+
client: &mockClient{
155+
deleteFn: func(ctx context.Context, gvr schema.GroupVersionResource, name, namespace string, opts metav1.DeleteOptions) error {
156+
return apierrors.NewNotFound(schema.GroupResource{Resource: "namespaces"}, name)
157+
},
158+
},
159+
wantSuccess: true,
160+
},
161+
{
162+
name: "non-not-found error causes failure",
163+
tracked: []string{"vm-test-err"},
164+
client: &mockClient{
165+
deleteFn: func(ctx context.Context, gvr schema.GroupVersionResource, name, namespace string, opts metav1.DeleteOptions) error {
166+
return errors.New("permission denied")
167+
},
168+
},
169+
wantSuccess: false,
170+
},
171+
}
172+
173+
for _, tt := range tests {
174+
t.Run(tt.name, func(t *testing.T) {
175+
ext := &Extension{
176+
Extension: sdk.NewExtension(sdk.ExtensionInfo{Name: "test"}),
177+
client: tt.client,
178+
generatedNamespaces: tt.tracked,
179+
}
180+
181+
req := &sdk.OperationRequest{Args: map[string]any{}}
182+
result, err := ext.handleDeleteGeneratedNamespaces(context.Background(), req)
183+
184+
if err != nil {
185+
t.Fatalf("handleDeleteGeneratedNamespaces() returned error: %v", err)
186+
}
187+
if result.Success != tt.wantSuccess {
188+
t.Errorf("handleDeleteGeneratedNamespaces() success = %v, want %v (error: %s)", result.Success, tt.wantSuccess, result.Error)
189+
}
190+
if tt.checkTracked != nil {
191+
tt.checkTracked(t, ext)
192+
}
193+
})
194+
}
195+
}
196+
197+
func TestGenerateID(t *testing.T) {
198+
id, err := generateID(8)
199+
if err != nil {
200+
t.Fatalf("generateID() error: %v", err)
201+
}
202+
if len(id) != 8 {
203+
t.Errorf("expected 8-char id, got %q (len=%d)", id, len(id))
204+
}
205+
for _, c := range id {
206+
if !strings.ContainsRune(alphanumeric, c) {
207+
t.Errorf("id contains invalid character %q", string(c))
208+
}
209+
}
210+
211+
// Verify uniqueness (two calls should produce different results)
212+
id2, err := generateID(8)
213+
if err != nil {
214+
t.Fatalf("generateID() error: %v", err)
215+
}
216+
if id == id2 {
217+
t.Errorf("expected unique ids, got %q twice", id)
218+
}
219+
}

0 commit comments

Comments
 (0)