diff --git a/pkg/extension/extension.go b/pkg/extension/extension.go index 9ff323a..64cd068 100644 --- a/pkg/extension/extension.go +++ b/pkg/extension/extension.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "strings" + "sync" "github.com/mcpchecker/mcpchecker/pkg/extension/sdk" "k8s.io/client-go/dynamic" @@ -17,6 +18,9 @@ import ( type Extension struct { *sdk.Extension client ResourceClient + + mu sync.Mutex + generatedNamespaces []string } // New creates a new Kubernetes extension diff --git a/pkg/extension/namespace.go b/pkg/extension/namespace.go new file mode 100644 index 0000000..e5d662d --- /dev/null +++ b/pkg/extension/namespace.go @@ -0,0 +1,142 @@ +package extension + +import ( + "context" + "crypto/rand" + "fmt" + "strings" + + "github.com/mcpchecker/mcpchecker/pkg/extension/sdk" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +var namespaceGVR = schema.GroupVersionResource{Group: "", Version: "v1", Resource: "namespaces"} + +const alphanumeric = "abcdefghijklmnopqrstuvwxyz0123456789" + +// generateID returns a random lowercase alphanumeric string of the given length. +// This matches the {random.id} spec from mcpchecker/mcpchecker#102. +func generateID(length int) (string, error) { + b := make([]byte, length) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("failed to generate random id: %w", err) + } + for i := range b { + b[i] = alphanumeric[int(b[i])%len(alphanumeric)] + } + return string(b), nil +} + +func (e *Extension) handleCreateNamespace(ctx context.Context, req *sdk.OperationRequest) (*sdk.OperationResult, error) { + if e.client == nil { + return sdk.Failure(fmt.Errorf("kubernetes client not initialized")), nil + } + + args, ok := req.Args.(map[string]any) + if !ok { + return sdk.Failure(fmt.Errorf("args must be an object")), nil + } + + prefix, _ := args["prefix"].(string) + if prefix == "" { + return sdk.Failure(fmt.Errorf("prefix is required")), nil + } + + id, err := generateID(8) + if err != nil { + return sdk.Failure(err), nil + } + + name := fmt.Sprintf("%s-%s", prefix, id) + + obj := &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "v1", + "kind": "Namespace", + "metadata": map[string]any{ + "name": name, + }, + }, + } + + e.LogInfo(ctx, "Creating namespace", map[string]any{ + "name": name, + }) + + result, err := e.client.Create(ctx, namespaceGVR, obj, "") + if err != nil { + e.LogError(ctx, "Failed to create namespace", map[string]any{ + "name": name, + "error": err.Error(), + }) + return sdk.Failure(fmt.Errorf("failed to create namespace: %w", err)), nil + } + + e.mu.Lock() + e.generatedNamespaces = append(e.generatedNamespaces, result.GetName()) + e.mu.Unlock() + + e.LogInfo(ctx, "Namespace created successfully", map[string]any{ + "name": result.GetName(), + }) + + return sdk.SuccessWithOutputs( + fmt.Sprintf("Created namespace %s", result.GetName()), + map[string]string{ + "namespace": result.GetName(), + }, + ), nil +} + +func (e *Extension) handleDeleteGeneratedNamespaces(ctx context.Context, req *sdk.OperationRequest) (*sdk.OperationResult, error) { + if e.client == nil { + return sdk.Failure(fmt.Errorf("kubernetes client not initialized")), nil + } + + e.mu.Lock() + namespaces := make([]string, len(e.generatedNamespaces)) + copy(namespaces, e.generatedNamespaces) + e.generatedNamespaces = nil + e.mu.Unlock() + + if len(namespaces) == 0 { + return sdk.Success("No generated namespaces to delete"), nil + } + + e.LogInfo(ctx, "Deleting generated namespaces", map[string]any{ + "count": len(namespaces), + "namespaces": namespaces, + }) + + propagation := metav1.DeletePropagationForeground + deleteOpts := metav1.DeleteOptions{ + PropagationPolicy: &propagation, + } + + var errs []string + for _, ns := range namespaces { + err := e.client.Delete(ctx, namespaceGVR, ns, "", deleteOpts) + if err != nil { + if apierrors.IsNotFound(err) { + e.LogInfo(ctx, "Namespace already deleted (ignored)", map[string]any{ + "name": ns, + }) + continue + } + e.LogError(ctx, "Failed to delete namespace", map[string]any{ + "name": ns, + "error": err.Error(), + }) + errs = append(errs, fmt.Sprintf("%s: %s", ns, err.Error())) + } + } + + if len(errs) > 0 { + return sdk.Failure(fmt.Errorf("failed to delete namespaces: %s", strings.Join(errs, "; "))), nil + } + + return sdk.Success(fmt.Sprintf("Deleted %d generated namespace(s)", len(namespaces))), nil +} diff --git a/pkg/extension/namespace_test.go b/pkg/extension/namespace_test.go new file mode 100644 index 0000000..f59ae8e --- /dev/null +++ b/pkg/extension/namespace_test.go @@ -0,0 +1,219 @@ +package extension + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/mcpchecker/mcpchecker/pkg/extension/sdk" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +func TestHandleCreateNamespace(t *testing.T) { + tests := []struct { + name string + args any + client *mockClient + wantSuccess bool + checkOutputs func(t *testing.T, result *sdk.OperationResult, ext *Extension) + }{ + { + name: "successful create", + args: map[string]any{ + "prefix": "vm-test", + }, + client: &mockClient{ + createFn: func(ctx context.Context, gvr schema.GroupVersionResource, obj *unstructured.Unstructured, namespace string) (*unstructured.Unstructured, error) { + return obj.DeepCopy(), nil + }, + }, + wantSuccess: true, + checkOutputs: func(t *testing.T, result *sdk.OperationResult, ext *Extension) { + t.Helper() + ns, ok := result.Outputs["namespace"] + if !ok { + t.Fatal("expected namespace output key") + } + if !strings.HasPrefix(ns, "vm-test-") { + t.Errorf("namespace %q does not have prefix vm-test-", ns) + } + // ID should be 8 lowercase alphanumeric chars per mcpchecker#102 + id := strings.TrimPrefix(ns, "vm-test-") + if len(id) != 8 { + t.Errorf("expected 8-char id, got %q (len=%d)", id, len(id)) + } + + ext.mu.Lock() + defer ext.mu.Unlock() + if len(ext.generatedNamespaces) != 1 { + t.Fatalf("expected 1 tracked namespace, got %d", len(ext.generatedNamespaces)) + } + if ext.generatedNamespaces[0] != ns { + t.Errorf("tracked namespace %q != output namespace %q", ext.generatedNamespaces[0], ns) + } + }, + }, + { + name: "invalid args type", + args: "not a map", + client: &mockClient{}, + wantSuccess: false, + }, + { + name: "missing prefix", + args: map[string]any{ + "other": "value", + }, + client: &mockClient{}, + wantSuccess: false, + }, + { + name: "client error", + args: map[string]any{ + "prefix": "vm-test", + }, + client: &mockClient{ + createFn: func(ctx context.Context, gvr schema.GroupVersionResource, obj *unstructured.Unstructured, namespace string) (*unstructured.Unstructured, error) { + return nil, errors.New("connection refused") + }, + }, + wantSuccess: false, + checkOutputs: func(t *testing.T, result *sdk.OperationResult, ext *Extension) { + t.Helper() + ext.mu.Lock() + defer ext.mu.Unlock() + if len(ext.generatedNamespaces) != 0 { + t.Errorf("expected no tracked namespaces on error, got %d", len(ext.generatedNamespaces)) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ext := &Extension{ + Extension: sdk.NewExtension(sdk.ExtensionInfo{Name: "test"}), + client: tt.client, + } + + req := &sdk.OperationRequest{Args: tt.args} + result, err := ext.handleCreateNamespace(context.Background(), req) + + if err != nil { + t.Fatalf("handleCreateNamespace() returned error: %v", err) + } + if result.Success != tt.wantSuccess { + t.Errorf("handleCreateNamespace() success = %v, want %v (error: %s)", result.Success, tt.wantSuccess, result.Error) + } + if tt.checkOutputs != nil { + tt.checkOutputs(t, result, ext) + } + }) + } +} + +func TestHandleDeleteGeneratedNamespaces(t *testing.T) { + tests := []struct { + name string + tracked []string + client *mockClient + wantSuccess bool + checkTracked func(t *testing.T, ext *Extension) + }{ + { + name: "no tracked namespaces", + tracked: nil, + client: &mockClient{}, + wantSuccess: true, + }, + { + name: "successful deletion of two namespaces", + tracked: []string{"vm-test-abc123", "vm-test-def456"}, + client: &mockClient{ + deleteFn: func(ctx context.Context, gvr schema.GroupVersionResource, name, namespace string, opts metav1.DeleteOptions) error { + return nil + }, + }, + wantSuccess: true, + checkTracked: func(t *testing.T, ext *Extension) { + t.Helper() + ext.mu.Lock() + defer ext.mu.Unlock() + if len(ext.generatedNamespaces) != 0 { + t.Errorf("expected tracking cleared, got %d namespaces", len(ext.generatedNamespaces)) + } + }, + }, + { + name: "not-found error silently ignored", + tracked: []string{"vm-test-gone"}, + client: &mockClient{ + deleteFn: func(ctx context.Context, gvr schema.GroupVersionResource, name, namespace string, opts metav1.DeleteOptions) error { + return apierrors.NewNotFound(schema.GroupResource{Resource: "namespaces"}, name) + }, + }, + wantSuccess: true, + }, + { + name: "non-not-found error causes failure", + tracked: []string{"vm-test-err"}, + client: &mockClient{ + deleteFn: func(ctx context.Context, gvr schema.GroupVersionResource, name, namespace string, opts metav1.DeleteOptions) error { + return errors.New("permission denied") + }, + }, + wantSuccess: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ext := &Extension{ + Extension: sdk.NewExtension(sdk.ExtensionInfo{Name: "test"}), + client: tt.client, + generatedNamespaces: tt.tracked, + } + + req := &sdk.OperationRequest{Args: map[string]any{}} + result, err := ext.handleDeleteGeneratedNamespaces(context.Background(), req) + + if err != nil { + t.Fatalf("handleDeleteGeneratedNamespaces() returned error: %v", err) + } + if result.Success != tt.wantSuccess { + t.Errorf("handleDeleteGeneratedNamespaces() success = %v, want %v (error: %s)", result.Success, tt.wantSuccess, result.Error) + } + if tt.checkTracked != nil { + tt.checkTracked(t, ext) + } + }) + } +} + +func TestGenerateID(t *testing.T) { + id, err := generateID(8) + if err != nil { + t.Fatalf("generateID() error: %v", err) + } + if len(id) != 8 { + t.Errorf("expected 8-char id, got %q (len=%d)", id, len(id)) + } + for _, c := range id { + if !strings.ContainsRune(alphanumeric, c) { + t.Errorf("id contains invalid character %q", string(c)) + } + } + + // Verify uniqueness (two calls should produce different results) + id2, err := generateID(8) + if err != nil { + t.Fatalf("generateID() error: %v", err) + } + if id == id2 { + t.Errorf("expected unique ids, got %q twice", id) + } +} diff --git a/pkg/extension/operations.go b/pkg/extension/operations.go index d4749a5..bbcad29 100644 --- a/pkg/extension/operations.go +++ b/pkg/extension/operations.go @@ -192,4 +192,33 @@ func (e *Extension) registerOperations() { ), e.handleViewConfig, ) + + e.AddOperation( + sdk.NewOperation("createNamespace", + sdk.WithDescription("Create a Kubernetes namespace with a generated suffix"), + sdk.WithParams(jsonschema.Schema{ + Type: "object", + Description: "Namespace creation parameters", + Properties: map[string]*jsonschema.Schema{ + "prefix": { + Type: "string", + Description: "Prefix for the namespace name (e.g., vm-test produces vm-test-a1b2c3)", + }, + }, + Required: []string{"prefix"}, + }), + ), + e.handleCreateNamespace, + ) + + e.AddOperation( + sdk.NewOperation("deleteGeneratedNamespaces", + sdk.WithDescription("Delete all namespaces previously created by createNamespace"), + sdk.WithParams(jsonschema.Schema{ + Type: "object", + Description: "No parameters required", + }), + ), + e.handleDeleteGeneratedNamespaces, + ) }