Skip to content

Commit f09ab4b

Browse files
matzewclaude
andcommitted
feat: add createNamespace and deleteGeneratedNamespaces operations
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> Signed-off-by: Matthias Wessendorf <mwessend@redhat.com>
1 parent 02a1712 commit f09ab4b

4 files changed

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

pkg/extension/namespace_test.go

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
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+
// Suffix should be 6 hex chars
45+
suffix := strings.TrimPrefix(ns, "vm-test-")
46+
if len(suffix) != 6 {
47+
t.Errorf("expected 6-char suffix, got %q (len=%d)", suffix, len(suffix))
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 TestGenerateSuffix(t *testing.T) {
198+
suffix, err := generateSuffix(3)
199+
if err != nil {
200+
t.Fatalf("generateSuffix() error: %v", err)
201+
}
202+
if len(suffix) != 6 {
203+
t.Errorf("expected 6-char suffix, got %q (len=%d)", suffix, len(suffix))
204+
}
205+
206+
// Verify uniqueness (two calls should produce different results)
207+
suffix2, err := generateSuffix(3)
208+
if err != nil {
209+
t.Fatalf("generateSuffix() error: %v", err)
210+
}
211+
if suffix == suffix2 {
212+
t.Errorf("expected unique suffixes, got %q twice", suffix)
213+
}
214+
}

0 commit comments

Comments
 (0)