diff --git a/README.md b/README.md index e620aa7..b125b06 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,9 @@ This extension enables declarative Kubernetes interactions within mcpchecker tas | `kubernetes.authCanI` | Check if a user or service account can perform an action on a resource | | `kubernetes.create` | Create a Kubernetes resource | | `kubernetes.delete` | Delete a Kubernetes resource | +| `kubernetes.getCurrentContext` | Get the current context from kubeconfig | +| `kubernetes.listContexts` | List all contexts from kubeconfig | +| `kubernetes.viewConfig` | View kubeconfig as YAML (optionally minified) | | `kubernetes.wait` | Wait for a condition on a resource (e.g., `Ready`, `Available`) | ## Configuration @@ -132,6 +135,43 @@ Waits for a condition on a resource. Supports configurable timeout and expected timeout: 5m # optional, defaults to 60s ``` +### kubernetes.listContexts + +Lists all contexts from the kubeconfig file, including which one is currently active. + +```yaml +- kubernetes.listContexts: + # No parameters required +``` + +**Outputs:** +- `current`: Name of the current context +- `count`: Number of contexts found + +### kubernetes.getCurrentContext + +Returns the current context name from the kubeconfig. + +```yaml +- kubernetes.getCurrentContext: + # No parameters required +``` + +**Outputs:** +- `context`: Name of the current context + +### kubernetes.viewConfig + +Views the kubeconfig as YAML, optionally minified to show only the current context. + +```yaml +- kubernetes.viewConfig: + minify: false # optional, defaults to false +``` + +**Outputs:** +- `config`: The kubeconfig content as YAML + ## Contributing Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, project structure, and guidelines for adding new operations. diff --git a/pkg/extension/client.go b/pkg/extension/client.go index 1f99f9e..2d7f1c8 100644 --- a/pkg/extension/client.go +++ b/pkg/extension/client.go @@ -2,6 +2,8 @@ package extension import ( "context" + "fmt" + "sort" authorizationv1 "k8s.io/api/authorization/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -9,6 +11,8 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/dynamic" authorizationv1client "k8s.io/client-go/kubernetes/typed/authorization/v1" + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" ) // ResourceClient abstracts Kubernetes resource operations for testability. @@ -25,12 +29,25 @@ type ResourceClient interface { // CheckAccess checks if a user can perform an action on a resource. CheckAccess(ctx context.Context, user, verb, resource, apiGroup, namespace, resourceName string) (bool, string, error) + + // ListContexts returns all contexts from the kubeconfig sorted by name. + // Each context includes its name, cluster, user, namespace, and whether it's the current context. + ListContexts(ctx context.Context) ([]ContextInfo, error) + + // GetCurrentContext returns the current context name from the kubeconfig. + // Returns an error if the kubeconfig cannot be loaded. + GetCurrentContext(ctx context.Context) (string, error) + + // ViewConfig returns the kubeconfig as YAML. + // When minify is true, only the current context and its dependencies are included. + ViewConfig(ctx context.Context, minify bool) (string, error) } // dynamicClientAdapter adapts the Kubernetes dynamic client to the ResourceClient interface. type dynamicClientAdapter struct { - client dynamic.Interface - authzClient authorizationv1client.AuthorizationV1Interface + client dynamic.Interface + authzClient authorizationv1client.AuthorizationV1Interface + kubeconfigPath string } func (a *dynamicClientAdapter) Create(ctx context.Context, gvr schema.GroupVersionResource, obj *unstructured.Unstructured, namespace string) (*unstructured.Unstructured, error) { @@ -75,3 +92,99 @@ func (a *dynamicClientAdapter) CheckAccess(ctx context.Context, user, verb, reso return result.Status.Allowed, result.Status.Reason, nil } + +func (a *dynamicClientAdapter) ListContexts(ctx context.Context) ([]ContextInfo, error) { + config, err := clientcmd.LoadFromFile(a.kubeconfigPath) + if err != nil { + return nil, fmt.Errorf("failed to load kubeconfig: %w", err) + } + + var contexts []ContextInfo + for name, context := range config.Contexts { + contexts = append(contexts, ContextInfo{ + Name: name, + Cluster: context.Cluster, + User: context.AuthInfo, + Namespace: context.Namespace, + IsCurrent: name == config.CurrentContext, + }) + } + + // Sort contexts by name for deterministic output + sort.Slice(contexts, func(i, j int) bool { + return contexts[i].Name < contexts[j].Name + }) + + return contexts, nil +} + +func (a *dynamicClientAdapter) GetCurrentContext(ctx context.Context) (string, error) { + config, err := clientcmd.LoadFromFile(a.kubeconfigPath) + if err != nil { + return "", fmt.Errorf("failed to load kubeconfig: %w", err) + } + + return config.CurrentContext, nil +} + +func (a *dynamicClientAdapter) ViewConfig(ctx context.Context, minify bool) (string, error) { + // Load the full config + rawConfig, err := clientcmd.LoadFromFile(a.kubeconfigPath) + if err != nil { + return "", fmt.Errorf("failed to load kubeconfig: %w", err) + } + + // Apply minification if requested + if minify { + // Get current context + currentContext := rawConfig.CurrentContext + if currentContext == "" { + return "", fmt.Errorf("no current context set in kubeconfig") + } + + // Create minified config with only current context and its dependencies + currentCtx, exists := rawConfig.Contexts[currentContext] + if !exists { + return "", fmt.Errorf("current context %q not found in kubeconfig", currentContext) + } + + minifiedConfig := clientcmdapi.NewConfig() + minifiedConfig.CurrentContext = currentContext + minifiedConfig.Contexts = map[string]*clientcmdapi.Context{ + currentContext: currentCtx, + } + + // Add the cluster referenced by current context + if currentCtx.Cluster == "" { + return "", fmt.Errorf("current context %q has no cluster", currentContext) + } + if cluster, exists := rawConfig.Clusters[currentCtx.Cluster]; exists { + minifiedConfig.Clusters = map[string]*clientcmdapi.Cluster{ + currentCtx.Cluster: cluster, + } + } else { + return "", fmt.Errorf("cluster %q not found in kubeconfig", currentCtx.Cluster) + } + + // Add the user referenced by current context (optional) + if currentCtx.AuthInfo != "" { + authInfo, exists := rawConfig.AuthInfos[currentCtx.AuthInfo] + if !exists { + return "", fmt.Errorf("user %q not found in kubeconfig", currentCtx.AuthInfo) + } + minifiedConfig.AuthInfos = map[string]*clientcmdapi.AuthInfo{ + currentCtx.AuthInfo: authInfo, + } + } + + rawConfig = minifiedConfig + } + + // Convert to YAML + yamlBytes, err := clientcmd.Write(*rawConfig) + if err != nil { + return "", fmt.Errorf("failed to marshal config to YAML: %w", err) + } + + return string(yamlBytes), nil +} diff --git a/pkg/extension/extension.go b/pkg/extension/extension.go index 8d088d6..9ff323a 100644 --- a/pkg/extension/extension.go +++ b/pkg/extension/extension.go @@ -80,7 +80,11 @@ func (e *Extension) handleInitialize(config map[string]any) error { return fmt.Errorf("failed to create authorization client: %w", err) } - e.client = &dynamicClientAdapter{client: client, authzClient: authzClient} + e.client = &dynamicClientAdapter{ + client: client, + authzClient: authzClient, + kubeconfigPath: kubeconfigPath, + } return nil } diff --git a/pkg/extension/kubeconfig.go b/pkg/extension/kubeconfig.go new file mode 100644 index 0000000..7596e99 --- /dev/null +++ b/pkg/extension/kubeconfig.go @@ -0,0 +1,134 @@ +package extension + +import ( + "context" + "fmt" + + "github.com/mcpchecker/mcpchecker/pkg/extension/sdk" +) + +// ContextInfo represents information about a Kubernetes context +type ContextInfo struct { + Name string `json:"name"` + Cluster string `json:"cluster"` + User string `json:"user"` + Namespace string `json:"namespace,omitempty"` + IsCurrent bool `json:"isCurrent"` +} + +// handleListContexts lists all contexts from the kubeconfig file. +// Returns the current context name and total count. +func (e *Extension) handleListContexts(ctx context.Context, req *sdk.OperationRequest) (*sdk.OperationResult, error) { + if e.client == nil { + return sdk.Failure(fmt.Errorf("kubernetes client not initialized")), nil + } + + e.LogInfo(ctx, "Listing kubeconfig contexts", nil) + + contexts, err := e.client.ListContexts(ctx) + if err != nil { + e.LogError(ctx, "Failed to list contexts", map[string]any{ + "error": err.Error(), + }) + return sdk.Failure(fmt.Errorf("failed to list contexts: %w", err)), nil + } + + if len(contexts) == 0 { + return sdk.Failure(fmt.Errorf("no contexts found in kubeconfig")), nil + } + + // Find current context + var currentContext string + for _, c := range contexts { + if c.IsCurrent { + currentContext = c.Name + break + } + } + + e.LogInfo(ctx, "Contexts listed successfully", map[string]any{ + "count": len(contexts), + "current": currentContext, + }) + + return sdk.SuccessWithOutputs( + fmt.Sprintf("Found %d context(s), current: %s", len(contexts), currentContext), + map[string]string{ + "current": currentContext, + "count": fmt.Sprintf("%d", len(contexts)), + }, + ), nil +} + +// handleGetCurrentContext returns the current context name from the kubeconfig. +// Fails if no current context is set. +func (e *Extension) handleGetCurrentContext(ctx context.Context, req *sdk.OperationRequest) (*sdk.OperationResult, error) { + if e.client == nil { + return sdk.Failure(fmt.Errorf("kubernetes client not initialized")), nil + } + + e.LogInfo(ctx, "Getting current kubeconfig context", nil) + + currentContext, err := e.client.GetCurrentContext(ctx) + if err != nil { + e.LogError(ctx, "Failed to get current context", map[string]any{ + "error": err.Error(), + }) + return sdk.Failure(fmt.Errorf("failed to get current context: %w", err)), nil + } + + if currentContext == "" { + return sdk.Failure(fmt.Errorf("no current context set in kubeconfig")), nil + } + + e.LogInfo(ctx, "Current context retrieved", map[string]any{ + "context": currentContext, + }) + + return sdk.SuccessWithOutputs( + fmt.Sprintf("Current context: %s", currentContext), + map[string]string{ + "context": currentContext, + }, + ), nil +} + +// handleViewConfig returns the kubeconfig as YAML. +// When minify is true, returns only the current context and its dependencies. +func (e *Extension) handleViewConfig(ctx context.Context, req *sdk.OperationRequest) (*sdk.OperationResult, error) { + if e.client == nil { + return sdk.Failure(fmt.Errorf("kubernetes client not initialized")), nil + } + + // Parse args + args, ok := req.Args.(map[string]any) + if !ok { + args = make(map[string]any) + } + + minify := false + if m, ok := args["minify"].(bool); ok { + minify = m + } + + e.LogInfo(ctx, "Viewing kubeconfig", map[string]any{ + "minify": minify, + }) + + configYAML, err := e.client.ViewConfig(ctx, minify) + if err != nil { + e.LogError(ctx, "Failed to view config", map[string]any{ + "error": err.Error(), + }) + return sdk.Failure(fmt.Errorf("failed to view config: %w", err)), nil + } + + e.LogInfo(ctx, "Kubeconfig retrieved successfully", nil) + + return sdk.SuccessWithOutputs( + "Kubeconfig retrieved", + map[string]string{ + "config": configYAML, + }, + ), nil +} diff --git a/pkg/extension/kubeconfig_test.go b/pkg/extension/kubeconfig_test.go new file mode 100644 index 0000000..0b695af --- /dev/null +++ b/pkg/extension/kubeconfig_test.go @@ -0,0 +1,277 @@ +package extension + +import ( + "context" + "errors" + "testing" + + "github.com/mcpchecker/mcpchecker/pkg/extension/sdk" +) + +func TestHandleListContexts(t *testing.T) { + tests := []struct { + name string + client *mockClient + wantSuccess bool + wantOutputs bool + }{ + { + name: "successful list with multiple contexts", + client: &mockClient{ + listContextsFn: func(ctx context.Context) ([]ContextInfo, error) { + return []ContextInfo{ + {Name: "dev", Cluster: "dev-cluster", User: "dev-user", IsCurrent: false}, + {Name: "prod", Cluster: "prod-cluster", User: "prod-user", IsCurrent: true}, + }, nil + }, + }, + wantSuccess: true, + wantOutputs: true, + }, + { + name: "successful list with single context", + client: &mockClient{ + listContextsFn: func(ctx context.Context) ([]ContextInfo, error) { + return []ContextInfo{ + {Name: "kind-kind", Cluster: "kind-kind", User: "kind-kind", IsCurrent: true}, + }, nil + }, + }, + wantSuccess: true, + wantOutputs: true, + }, + { + name: "no contexts found", + client: &mockClient{ + listContextsFn: func(ctx context.Context) ([]ContextInfo, error) { + return []ContextInfo{}, nil + }, + }, + wantSuccess: false, + }, + { + name: "client error", + client: &mockClient{ + listContextsFn: func(ctx context.Context) ([]ContextInfo, error) { + return nil, errors.New("failed to load kubeconfig") + }, + }, + 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, + } + + req := &sdk.OperationRequest{Args: map[string]any{}} + result, err := ext.handleListContexts(context.Background(), req) + + if err != nil { + t.Fatalf("handleListContexts() returned error: %v", err) + } + if result.Success != tt.wantSuccess { + t.Errorf("handleListContexts() success = %v, want %v", result.Success, tt.wantSuccess) + } + if tt.wantOutputs && result.Outputs == nil { + t.Errorf("handleListContexts() outputs = nil, want outputs") + } + }) + } +} + +func TestHandleGetCurrentContext(t *testing.T) { + tests := []struct { + name string + client *mockClient + wantSuccess bool + wantContext string + }{ + { + name: "successful get current context", + client: &mockClient{ + getCurrentContextFn: func(ctx context.Context) (string, error) { + return "prod", nil + }, + }, + wantSuccess: true, + wantContext: "prod", + }, + { + name: "empty current context", + client: &mockClient{ + getCurrentContextFn: func(ctx context.Context) (string, error) { + return "", nil + }, + }, + wantSuccess: false, + }, + { + name: "client error", + client: &mockClient{ + getCurrentContextFn: func(ctx context.Context) (string, error) { + return "", errors.New("failed to load kubeconfig") + }, + }, + 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, + } + + req := &sdk.OperationRequest{Args: map[string]any{}} + result, err := ext.handleGetCurrentContext(context.Background(), req) + + if err != nil { + t.Fatalf("handleGetCurrentContext() returned error: %v", err) + } + if result.Success != tt.wantSuccess { + t.Errorf("handleGetCurrentContext() success = %v, want %v", result.Success, tt.wantSuccess) + } + if tt.wantSuccess && result.Outputs != nil { + if ctx := result.Outputs["context"]; ctx != tt.wantContext { + t.Errorf("handleGetCurrentContext() context = %v, want %v", ctx, tt.wantContext) + } + } + }) + } +} + +func TestHandleViewConfig(t *testing.T) { + tests := []struct { + name string + args any + client *mockClient + wantSuccess bool + }{ + { + name: "successful view without minify", + args: map[string]any{ + "minify": false, + }, + client: &mockClient{ + viewConfigFn: func(ctx context.Context, minify bool) (string, error) { + if minify { + t.Error("expected minify=false") + } + return "apiVersion: v1\nkind: Config\nclusters:\n- cluster:\n server: https://example.com\n", nil + }, + }, + wantSuccess: true, + }, + { + name: "successful view with minify", + args: map[string]any{ + "minify": true, + }, + client: &mockClient{ + viewConfigFn: func(ctx context.Context, minify bool) (string, error) { + if !minify { + t.Error("expected minify=true") + } + return "apiVersion: v1\nkind: Config\ncurrent-context: prod\n", nil + }, + }, + wantSuccess: true, + }, + { + name: "default minify to false", + args: map[string]any{}, + client: &mockClient{ + viewConfigFn: func(ctx context.Context, minify bool) (string, error) { + if minify { + t.Error("expected minify=false by default") + } + return "apiVersion: v1\nkind: Config\n", nil + }, + }, + wantSuccess: true, + }, + { + name: "client error", + args: map[string]any{}, + client: &mockClient{ + viewConfigFn: func(ctx context.Context, minify bool) (string, error) { + return "", errors.New("failed to read kubeconfig") + }, + }, + wantSuccess: false, + }, + { + name: "minify with missing cluster reference", + args: map[string]any{ + "minify": true, + }, + client: &mockClient{ + viewConfigFn: func(ctx context.Context, minify bool) (string, error) { + return "", errors.New("cluster \"missing-cluster\" not found in kubeconfig") + }, + }, + wantSuccess: false, + }, + { + name: "minify with missing authInfo reference", + args: map[string]any{ + "minify": true, + }, + client: &mockClient{ + viewConfigFn: func(ctx context.Context, minify bool) (string, error) { + return "", errors.New("user \"missing-user\" not found in kubeconfig") + }, + }, + wantSuccess: false, + }, + { + name: "minify with empty cluster name", + args: map[string]any{ + "minify": true, + }, + client: &mockClient{ + viewConfigFn: func(ctx context.Context, minify bool) (string, error) { + return "", errors.New("current context \"prod\" has no cluster") + }, + }, + wantSuccess: false, + }, + { + name: "minify with empty authInfo (should succeed)", + args: map[string]any{ + "minify": true, + }, + client: &mockClient{ + viewConfigFn: func(ctx context.Context, minify bool) (string, error) { + // AuthInfo is optional, so empty authInfo should succeed + return "apiVersion: v1\nkind: Config\ncurrent-context: prod\n", nil + }, + }, + wantSuccess: true, + }, + } + + 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.handleViewConfig(context.Background(), req) + + if err != nil { + t.Fatalf("handleViewConfig() returned error: %v", err) + } + if result.Success != tt.wantSuccess { + t.Errorf("handleViewConfig() success = %v, want %v", result.Success, tt.wantSuccess) + } + }) + } +} diff --git a/pkg/extension/mock_client_test.go b/pkg/extension/mock_client_test.go index 0c41377..5494347 100644 --- a/pkg/extension/mock_client_test.go +++ b/pkg/extension/mock_client_test.go @@ -9,10 +9,13 @@ import ( ) type mockClient struct { - createFn func(ctx context.Context, gvr schema.GroupVersionResource, obj *unstructured.Unstructured, namespace string) (*unstructured.Unstructured, error) - getFn func(ctx context.Context, gvr schema.GroupVersionResource, name, namespace string) (*unstructured.Unstructured, error) - deleteFn func(ctx context.Context, gvr schema.GroupVersionResource, name, namespace string, opts metav1.DeleteOptions) error - checkAccessFn func(ctx context.Context, user, verb, resource, apiGroup, namespace, resourceName string) (bool, string, error) + createFn func(ctx context.Context, gvr schema.GroupVersionResource, obj *unstructured.Unstructured, namespace string) (*unstructured.Unstructured, error) + getFn func(ctx context.Context, gvr schema.GroupVersionResource, name, namespace string) (*unstructured.Unstructured, error) + deleteFn func(ctx context.Context, gvr schema.GroupVersionResource, name, namespace string, opts metav1.DeleteOptions) error + checkAccessFn func(ctx context.Context, user, verb, resource, apiGroup, namespace, resourceName string) (bool, string, error) + listContextsFn func(ctx context.Context) ([]ContextInfo, error) + getCurrentContextFn func(ctx context.Context) (string, error) + viewConfigFn func(ctx context.Context, minify bool) (string, error) } func (m *mockClient) Create(ctx context.Context, gvr schema.GroupVersionResource, obj *unstructured.Unstructured, namespace string) (*unstructured.Unstructured, error) { @@ -42,3 +45,26 @@ func (m *mockClient) CheckAccess(ctx context.Context, user, verb, resource, apiG } return true, "", nil } + +func (m *mockClient) ListContexts(ctx context.Context) ([]ContextInfo, error) { + if m.listContextsFn != nil { + return m.listContextsFn(ctx) + } + return []ContextInfo{ + {Name: "default", Cluster: "default-cluster", User: "default-user", IsCurrent: true}, + }, nil +} + +func (m *mockClient) GetCurrentContext(ctx context.Context) (string, error) { + if m.getCurrentContextFn != nil { + return m.getCurrentContextFn(ctx) + } + return "default", nil +} + +func (m *mockClient) ViewConfig(ctx context.Context, minify bool) (string, error) { + if m.viewConfigFn != nil { + return m.viewConfigFn(ctx, minify) + } + return "apiVersion: v1\nkind: Config\n", nil +} diff --git a/pkg/extension/operations.go b/pkg/extension/operations.go index f2f14d0..d4749a5 100644 --- a/pkg/extension/operations.go +++ b/pkg/extension/operations.go @@ -153,4 +153,43 @@ func (e *Extension) registerOperations() { ), e.handleAuthCanI, ) + + e.AddOperation( + sdk.NewOperation("listContexts", + sdk.WithDescription("List all contexts from kubeconfig"), + sdk.WithParams(jsonschema.Schema{ + Type: "object", + Description: "No parameters required", + }), + ), + e.handleListContexts, + ) + + e.AddOperation( + sdk.NewOperation("getCurrentContext", + sdk.WithDescription("Get the current context from kubeconfig"), + sdk.WithParams(jsonschema.Schema{ + Type: "object", + Description: "No parameters required", + }), + ), + e.handleGetCurrentContext, + ) + + e.AddOperation( + sdk.NewOperation("viewConfig", + sdk.WithDescription("View kubeconfig as YAML"), + sdk.WithParams(jsonschema.Schema{ + Type: "object", + Description: "Configuration view options", + Properties: map[string]*jsonschema.Schema{ + "minify": { + Type: "boolean", + Description: "If true, only show current context (default: false)", + }, + }, + }), + ), + e.handleViewConfig, + ) }