diff --git a/README.md b/README.md index b42e8dfc2..4eaddbbca 100644 --- a/README.md +++ b/README.md @@ -262,6 +262,7 @@ The following sets of tools are available (toolsets marked with ✓ in the Defau | Toolset | Description | Default | |----------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------| +| code | Execute JavaScript code with access to Kubernetes clients for advanced operations and data transformation (opt-in, security-sensitive) | | | config | View and manage the current local Kubernetes configuration (kubeconfig) | ✓ | | core | Most common tools for Kubernetes management (Pods, Generic Resources, Events, etc.) | ✓ | | helm | Tools for managing Helm charts and releases | | @@ -279,6 +280,116 @@ In case multi-cluster support is enabled (default) and you have access to multip
+code + +- **evaluate_script** - Execute a JavaScript script with access to Kubernetes clients. Use this tool for complex operations that require multiple API calls, data transformation, filtering, or aggregation that would be inefficient with individual tool calls. The script runs in a sandboxed environment with access only to Kubernetes clients - no file system or network access. + + +## JavaScript SDK + +**Note:** Full ES5.1 syntax support, partial ES6. Synchronous execution only (no async/await or Promises). + +### Globals +- **k8s** - Kubernetes client (case-insensitive: coreV1, CoreV1, COREV1 all work) +- **ctx** - Request context for cancellation +- **namespace** - Default namespace + +### k8s API Clients +- k8s.coreV1() - pods, services, configMaps, secrets, namespaces, nodes, etc. +- k8s.appsV1() - deployments, statefulSets, daemonSets, replicaSets +- k8s.batchV1() - jobs, cronJobs +- k8s.networkingV1() - ingresses, networkPolicies +- k8s.rbacV1() - roles, roleBindings, clusterRoles, clusterRoleBindings +- k8s.metricsV1beta1Client() - pod and node metrics (CPU/memory usage) +- k8s.dynamicClient() - any resource by GVR +- k8s.discoveryClient() - API discovery + +### Examples + +#### Combine multiple API calls with JavaScript +```javascript +// Get all deployments and their pod counts across namespaces +const deps = k8s.appsV1().deployments("").list(ctx, {}); +const result = deps.items.flatMap(d => { + const pods = k8s.coreV1().pods(d.metadata.namespace).list(ctx, { + labelSelector: Object.entries(d.spec.selector.matchLabels || {}) + .map(([k,v]) => k+"="+v).join(",") + }); + return [{ + deployment: d.metadata.name, + namespace: d.metadata.namespace, + replicas: d.status.readyReplicas + "/" + d.status.replicas, + pods: pods.items.map(p => p.metadata.name) + }]; +}); +JSON.stringify(result); +``` + +#### Filter and aggregate +```javascript +const pods = k8s.coreV1().pods("").list(ctx, {}); +const unhealthy = pods.items.filter(p => + p.status.containerStatuses?.some(c => c.restartCount > 5) +).map(p => ({ + name: p.metadata.name, + ns: p.metadata.namespace, + restarts: p.status.containerStatuses.reduce((s,c) => s + c.restartCount, 0) +})); +JSON.stringify(unhealthy); +``` + +#### Create resources (using standard Kubernetes YAML/JSON structure) +```javascript +const pod = { + apiVersion: "v1", kind: "Pod", + metadata: { name: "my-pod", namespace: namespace }, + spec: { containers: [{ name: "nginx", image: "nginx:latest" }] } +}; +k8s.coreV1().pods(namespace).create(ctx, pod, {}).metadata.name; +``` + +#### API introspection +```javascript +// Discover available resources on coreV1 +const resources = []; for (const k in k8s.coreV1()) if (typeof k8s.coreV1()[k]==='function') resources.push(k); +// resources: ["configMaps","namespaces","pods","secrets","services",...] + +// Discover available operations on pods +const ops = []; for (const k in k8s.coreV1().pods(namespace)) if (typeof k8s.coreV1().pods(namespace)[k]==='function') ops.push(k); +// ops: ["create","delete","get","list","update","watch",...] +``` + +#### Get pod metrics with resource quantities +```javascript +const metrics = k8s.metricsV1beta1Client(); +const podMetrics = metrics.podMetricses("").list(ctx, {}); +const result = podMetrics.items.map(function(pm) { + return { + name: pm.metadata.name, + cpu: pm.containers[0].usage.cpu, // "100m" + memory: pm.containers[0].usage.memory // "128Mi" + }; +}); +JSON.stringify(result); +``` + +#### Get pod logs +```javascript +const logBytes = k8s.coreV1().pods(namespace).getLogs("my-pod", {container: "main", tailLines: 100}).doRaw(ctx); +var logs = ""; for (var i = 0; i < logBytes.length; i++) logs += String.fromCharCode(logBytes[i]); +logs; +``` + +### Return Value +Last expression is returned. Use JSON.stringify() for objects. + + - `script` (`string`) **(required)** - JavaScript code to execute. The last expression is returned as the result. + - `timeout` (`integer`) - Execution timeout in milliseconds (default: 30000, max: 300000) + +
+ +
+ config - **configuration_contexts_list** - List all available context names and associated server urls from the kubeconfig file diff --git a/docs/configuration.md b/docs/configuration.md index 326489767..c9f5b0791 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -261,6 +261,7 @@ Toolsets group related tools together. Enable only the toolsets you need to redu | Toolset | Description | Default | |----------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------| +| code | Execute JavaScript code with access to Kubernetes clients for advanced operations and data transformation (opt-in, security-sensitive) | | | config | View and manage the current local Kubernetes configuration (kubeconfig) | ✓ | | core | Most common tools for Kubernetes management (Pods, Generic Resources, Events, etc.) | ✓ | | helm | Tools for managing Helm charts and releases | | diff --git a/go.mod b/go.mod index b3f2f7b8d..481c8a7cc 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.6 require ( github.com/BurntSushi/toml v1.6.0 github.com/coreos/go-oidc/v3 v3.17.0 + github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 github.com/fsnotify/fsnotify v1.9.0 github.com/go-jose/go-jose/v4 v4.1.3 github.com/go-logr/logr v1.4.3 @@ -66,6 +67,7 @@ require ( github.com/containerd/platforms v0.2.1 // indirect github.com/cyphar/filepath-securejoin v0.6.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dlclark/regexp2 v1.11.4 // indirect github.com/emicklei/go-restful/v3 v3.12.2 // indirect github.com/evanphx/json-patch v5.9.11+incompatible // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect @@ -78,9 +80,11 @@ require ( github.com/go-openapi/jsonpointer v0.21.1 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/swag v0.23.1 // indirect + github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/go-cmp v0.7.0 // indirect + github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect github.com/gosuri/uitable v0.0.4 // indirect diff --git a/go.sum b/go.sum index 3dd1011b4..d9b528d2b 100644 --- a/go.sum +++ b/go.sum @@ -65,14 +65,16 @@ github.com/distribution/distribution/v3 v3.0.0 h1:q4R8wemdRQDClzoNNStftB2ZAfqOiN github.com/distribution/distribution/v3 v3.0.0/go.mod h1:tRNuFoZsUdyRVegq8xGNeds4KLjwLCRin/tTo6i1DhU= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= -github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= +github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/docker/docker-credential-helpers v0.8.2 h1:bX3YxiGzFP5sOXWc3bTPEXdEaZSeVMrFgOr3T+zrFAo= github.com/docker/docker-credential-helpers v0.8.2/go.mod h1:P3ci7E3lwkZg6XiHdRKft1KckHiO9a2rNtyFbZ/ry9M= github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c h1:+pKlWGMw7gf6bQ+oDZB4KHQFypsfjYlq/C4rfL7D3g8= github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA= github.com/docker/go-metrics v0.0.1 h1:AgB/0SvBxihN0X8OR4SjsblXkbMvalQ8cjmtKQ2rQV8= github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw= +github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM= +github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/evanphx/json-patch v5.9.11+incompatible h1:ixHHqfcGvxhWkniF1tWxBHA0yb4Z+d1UQi45df52xW8= @@ -112,6 +114,8 @@ github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= +github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU= +github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= diff --git a/internal/tools/update-readme/main.go b/internal/tools/update-readme/main.go index 200727fd5..e7cc5608b 100644 --- a/internal/tools/update-readme/main.go +++ b/internal/tools/update-readme/main.go @@ -13,6 +13,7 @@ import ( "github.com/containers/kubernetes-mcp-server/pkg/config" "github.com/containers/kubernetes-mcp-server/pkg/toolsets" + _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/code" _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/config" _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/core" _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/helm" diff --git a/pkg/code/evaluator.go b/pkg/code/evaluator.go new file mode 100644 index 000000000..8cacd2788 --- /dev/null +++ b/pkg/code/evaluator.go @@ -0,0 +1,268 @@ +package code + +import ( + "context" + _ "embed" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/dop251/goja" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/containers/kubernetes-mcp-server/pkg/api" +) + +//go:embed k8s_proxy.js +var k8sProxyScript string + +// safeContext wraps a context.Context to expose only cancellation functionality. +// This prevents JavaScript code from accessing sensitive values that may be stored +// in the context (like authentication tokens, user credentials, or request metadata). +type safeContext struct { + ctx context.Context +} + +// Deadline returns the time when work done on behalf of this context should be canceled. +func (s *safeContext) Deadline() (deadline time.Time, ok bool) { + return s.ctx.Deadline() +} + +// Done returns a channel that's closed when work done on behalf of this context should be canceled. +func (s *safeContext) Done() <-chan struct{} { + return s.ctx.Done() +} + +// Err returns nil if Done is not yet closed, or a non-nil error explaining why. +func (s *safeContext) Err() error { + return s.ctx.Err() +} + +// Value always returns nil to prevent access to context values from JavaScript. +// This is intentional for security - context values may contain sensitive data. +func (s *safeContext) Value(_ any) any { + return nil +} + +// newSafeContext creates a context wrapper that only exposes cancellation functionality. +func newSafeContext(ctx context.Context) context.Context { + return &safeContext{ctx: ctx} +} + +// Resource limits for script execution. +// +// Note on memory limits: Goja does not provide built-in memory limiting. +// Memory usage is bounded indirectly by: +// - MaxCallStackSize preventing deep recursion +// - Timeout preventing long-running allocations +// - No file/network I/O limiting external data ingestion +// +// For production deployments with untrusted scripts, consider running +// the MCP server in a container with memory limits (e.g., --memory flag). +const ( + // DefaultTimeout is the default execution timeout for scripts + DefaultTimeout = 30 * time.Second + // MaxTimeout is the maximum allowed execution timeout + MaxTimeout = 5 * time.Minute + // MaxCallStackSize limits function call depth to prevent stack overflow from infinite recursion. + // This is a defense against DoS attacks using deeply recursive code. + MaxCallStackSize = 1024 +) + +// Evaluator executes JavaScript code with access to Kubernetes clients. +// The evaluator is configured once and can be used for a single evaluation. +type Evaluator struct { + vm *goja.Runtime + namespace string +} + +// NewEvaluator creates a new JavaScript evaluator with access to the provided Kubernetes client. +// The kubernetes client is exposed as 'k8s' global, the context as 'ctx', and the default +// namespace as 'namespace'. +func NewEvaluator(ctx context.Context, kubernetes api.KubernetesClient) (*Evaluator, error) { + vm := goja.New() + namespace := kubernetes.NamespaceOrDefault("") + + vm.SetMaxCallStackSize(MaxCallStackSize) + + // Configure field name mapping to use JSON struct tags. + // This allows JavaScript code to access Kubernetes objects using standard JSON field names + // (e.g., pod.metadata.name, pod.spec.containers) instead of Go field names + // (e.g., pod.ObjectMeta.Name, pod.Spec.Containers). + vm.SetFieldNameMapper(goja.TagFieldNameMapper("json", true)) + + // Set up Kubernetes client proxy for transparent resource handling. + // The proxy script (k8s_proxy.js) sets up transparent conversion of standard Kubernetes + // YAML/JSON structure to the format expected by Go's Kubernetes client structs. + // It uses JavaScript Proxy on empty objects (not native Go objects) to avoid + // invariant violations while still intercepting method calls and converting + // objects with 'metadata' fields to the flattened Go struct format. + if _, err := vm.RunString(k8sProxyScript); err != nil { + return nil, fmt.Errorf("failed to set up k8s proxy: %w", err) + } + + // Set up __goToJSON helper function for converting Go objects to pure JavaScript objects. + // This is used internally by the k8s proxy (__toJS function) to convert Go objects + // with custom types (like Quantity) that don't serialize correctly with JavaScript's + // JSON.stringify. This helper uses Go's json.Marshal which handles MarshalJSON + // implementations correctly. It is intentionally not exposed to user scripts. + if err := vm.Set("__goToJSON", func(obj interface{}) (string, error) { + jsonBytes, err := json.Marshal(obj) + if err != nil { + return "", fmt.Errorf("JSON marshal failed: %w", err) + } + return string(jsonBytes), nil + }); err != nil { + return nil, fmt.Errorf("failed to set __goToJSON helper: %w", err) + } + + // Set up __gvr helper function for creating GroupVersionResource objects. + // This is needed because schema.GroupVersionResource doesn't have json tags, + // so the TagFieldNameMapper cannot convert JavaScript objects to it. + // The proxy uses this to properly pass GVR to dynamicClient().resource(). + if err := vm.Set("__gvr", func(group, version, resource string) schema.GroupVersionResource { + return schema.GroupVersionResource{ + Group: group, + Version: version, + Resource: resource, + } + }); err != nil { + return nil, fmt.Errorf("failed to set __gvr helper: %w", err) + } + + // Set up __toUnstructured helper for converting JS objects to *unstructured.Unstructured. + // This is needed for the dynamic client which expects *unstructured.Unstructured objects. + // We use JSON marshaling to properly handle the conversion since it preserves the + // nested structure (metadata.name, etc.) that the dynamic client expects. + if err := vm.Set("__toUnstructured", func(obj interface{}) (*unstructured.Unstructured, error) { + // Convert to JSON and back to get a clean map[string]interface{} + jsonBytes, err := json.Marshal(obj) + if err != nil { + return nil, fmt.Errorf("failed to marshal object: %w", err) + } + var data map[string]interface{} + if err := json.Unmarshal(jsonBytes, &data); err != nil { + return nil, fmt.Errorf("failed to unmarshal object: %w", err) + } + return &unstructured.Unstructured{Object: data}, nil + }); err != nil { + return nil, fmt.Errorf("failed to set __toUnstructured helper: %w", err) + } + + // Set up SDK globals once during construction + // First set the raw client, then wrap it with the proxy for auto-conversion + if err := vm.Set("__rawK8s", kubernetes); err != nil { + return nil, fmt.Errorf("failed to set k8s client: %w", err) + } + if _, err := vm.RunString("const k8s = __wrapK8sClient(__rawK8s);"); err != nil { + return nil, fmt.Errorf("failed to wrap k8s client: %w", err) + } + // Wrap context to expose only cancellation functionality, not context values. + // Context values may contain sensitive data (auth tokens, user info, etc.) + // that should not be accessible from JavaScript. + if err := vm.Set("ctx", newSafeContext(ctx)); err != nil { + return nil, fmt.Errorf("failed to set context: %w", err) + } + if err := vm.Set("namespace", namespace); err != nil { + return nil, fmt.Errorf("failed to set namespace: %w", err) + } + + return &Evaluator{ + vm: vm, + namespace: namespace, + }, nil +} + +// Evaluate executes the provided JavaScript script and returns the result. +// The script has access to: +// - k8s: The Kubernetes client for API operations +// - ctx: The request context +// - namespace: The default namespace +// +// The timeout parameter controls how long the script is allowed to run. +// If timeout is <= 0, DefaultTimeout is used. +// If timeout > MaxTimeout, MaxTimeout is used. +func (e *Evaluator) Evaluate(script string, timeout time.Duration) (string, error) { + if script == "" { + return "", fmt.Errorf("script cannot be empty") + } + + // Normalize timeout + timeout = normalizeTimeout(timeout) + + // Set up timeout interruption + timer := time.AfterFunc(timeout, func() { + e.vm.Interrupt("execution timeout exceeded") + }) + defer timer.Stop() + + // Execute the script + value, err := e.vm.RunString(script) + if err != nil { + return "", wrapError(err) + } + + // Convert result to string + return valueToString(value) +} + +// wrapError converts Goja errors into user-friendly errors. +// Error messages are sanitized to avoid exposing internal implementation details +// while still providing useful debugging information for script authors. +func wrapError(err error) error { + // Check if it was an interrupt (timeout) + var interrupted *goja.InterruptedError + if errors.As(err, &interrupted) { + return fmt.Errorf("script interrupted: %v", interrupted.Value()) + } + // Check if it was a JavaScript exception + var exception *goja.Exception + if errors.As(err, &exception) { + // Extract just the error message without full stack trace + // The exception.Value() contains the error object, exception.String() includes stack + if exception.Value() != nil { + return fmt.Errorf("script error: %s", exception.Value().String()) + } + return fmt.Errorf("script error: %s", exception.Error()) + } + // For other errors, provide a generic message without exposing internal details + return fmt.Errorf("script execution failed: %v", err) +} + +// normalizeTimeout ensures the timeout is within valid bounds. +func normalizeTimeout(timeout time.Duration) time.Duration { + if timeout <= 0 { + return DefaultTimeout + } + if timeout > MaxTimeout { + return MaxTimeout + } + return timeout +} + +// valueToString converts a Goja value to a string representation. +func valueToString(value goja.Value) (string, error) { + if value == nil || goja.IsUndefined(value) || goja.IsNull(value) { + return "", nil + } + + // Export to Go value + exported := value.Export() + + // Handle different types + switch v := exported.(type) { + case string: + return v, nil + case bool, int, int64, float64: + return fmt.Sprintf("%v", v), nil + default: + // For complex types, marshal to compact JSON (no indentation to minimize token usage) + jsonBytes, err := json.Marshal(exported) + if err != nil { + return fmt.Sprintf("%v", exported), nil + } + return string(jsonBytes), nil + } +} diff --git a/pkg/code/evaluator_concurrency_test.go b/pkg/code/evaluator_concurrency_test.go new file mode 100644 index 000000000..7c9a36f52 --- /dev/null +++ b/pkg/code/evaluator_concurrency_test.go @@ -0,0 +1,186 @@ +package code + +import ( + "context" + "strconv" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/suite" +) + +// EvaluatorConcurrencySuite tests thread-safety of the evaluator. +type EvaluatorConcurrencySuite struct { + BaseEvaluatorSuite +} + +func (s *EvaluatorConcurrencySuite) TestConcurrentEvaluators() { + s.Run("multiple evaluators can run concurrently without interference", func() { + const numGoroutines = 10 + var wg sync.WaitGroup + errors := make(chan error, numGoroutines) + results := make(chan string, numGoroutines) + + for i := 0; i < numGoroutines; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + + evaluator, err := NewEvaluator(context.Background(), s.kubernetes) + if err != nil { + errors <- err + return + } + + // Each goroutine runs a different script + script := `"result-" + ` + strconv.Itoa(id) + result, err := evaluator.Evaluate(script, time.Second) + if err != nil { + errors <- err + return + } + results <- result + }(i) + } + + wg.Wait() + close(errors) + close(results) + + for err := range errors { + s.NoError(err) + } + + count := 0 + for range results { + count++ + } + s.Equal(numGoroutines, count, "all goroutines should complete successfully") + }) + + s.Run("evaluators sharing kubernetes client work correctly", func() { + const numGoroutines = 20 + var wg sync.WaitGroup + errors := make(chan error, numGoroutines) + + evaluators := make([]*Evaluator, numGoroutines) + for i := 0; i < numGoroutines; i++ { + var err error + evaluators[i], err = NewEvaluator(context.Background(), s.kubernetes) + s.Require().NoError(err) + } + + for i := 0; i < numGoroutines; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + _, err := evaluators[idx].Evaluate(`namespace`, time.Second) + if err != nil { + errors <- err + } + }(i) + } + + wg.Wait() + close(errors) + + for err := range errors { + s.NoError(err) + } + }) +} + +func (s *EvaluatorConcurrencySuite) TestConcurrentTimeouts() { + s.Run("multiple timeouts are handled independently", func() { + const numGoroutines = 5 + var wg sync.WaitGroup + timeoutErrors := make(chan bool, numGoroutines) + + for i := 0; i < numGoroutines; i++ { + wg.Add(1) + go func() { + defer wg.Done() + + evaluator, err := NewEvaluator(context.Background(), s.kubernetes) + if err != nil { + return + } + + _, err = evaluator.Evaluate(`while(true) {}`, 50*time.Millisecond) + if err != nil { + timeoutErrors <- true + } + }() + } + + wg.Wait() + close(timeoutErrors) + + count := 0 + for range timeoutErrors { + count++ + } + s.Equal(numGoroutines, count, "all concurrent scripts should timeout independently") + }) +} + +func (s *EvaluatorConcurrencySuite) TestDataRaceSafety() { + s.Run("repeated namespace access has no data race", func() { + const numGoroutines = 10 + var wg sync.WaitGroup + + for i := 0; i < numGoroutines; i++ { + wg.Add(1) + go func() { + defer wg.Done() + + evaluator, err := NewEvaluator(context.Background(), s.kubernetes) + if err != nil { + return + } + + for j := 0; j < 10; j++ { + _, _ = evaluator.Evaluate(`namespace`, time.Second) + } + }() + } + + wg.Wait() + }) + + s.Run("concurrent k8s client access is safe", func() { + const numGoroutines = 10 + var wg sync.WaitGroup + errors := make(chan error, numGoroutines) + + for i := 0; i < numGoroutines; i++ { + wg.Add(1) + go func() { + defer wg.Done() + + evaluator, err := NewEvaluator(context.Background(), s.kubernetes) + if err != nil { + errors <- err + return + } + + _, err = evaluator.Evaluate(`typeof k8s.coreV1`, time.Second) + if err != nil { + errors <- err + } + }() + } + + wg.Wait() + close(errors) + + for err := range errors { + s.NoError(err) + } + }) +} + +func TestEvaluatorConcurrency(t *testing.T) { + suite.Run(t, new(EvaluatorConcurrencySuite)) +} diff --git a/pkg/code/evaluator_context_test.go b/pkg/code/evaluator_context_test.go new file mode 100644 index 000000000..ac603e68f --- /dev/null +++ b/pkg/code/evaluator_context_test.go @@ -0,0 +1,149 @@ +package code + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/suite" +) + +// EvaluatorContextSuite tests security aspects of context handling in the JavaScript evaluator. +type EvaluatorContextSuite struct { + BaseEvaluatorSuite +} + +func (s *EvaluatorContextSuite) TestContextValuesNotAccessibleFromJavaScript() { + type contextKey string + const ( + authTokenKey contextKey = "auth_token" + userInfoKey contextKey = "user_info" + ) + + ctx := context.WithValue(context.Background(), authTokenKey, "super-secret-token") + ctx = context.WithValue(ctx, userInfoKey, map[string]string{ + "username": "admin", + "role": "cluster-admin", + }) + + evaluator, err := NewEvaluator(ctx, s.kubernetes) + s.Require().NoError(err) + + result, err := evaluator.Evaluate(` + const results = { + valueResult: ctx.value("auth_token"), + valueResult2: ctx.value("user_info"), + ctxKeys: Object.keys(ctx || {}), + ctxType: typeof ctx + }; + JSON.stringify(results); + `, time.Second) + s.NoError(err) + s.T().Logf("Context access result: %s", result) + + s.NotContains(result, "super-secret-token") + s.NotContains(result, "admin") + s.NotContains(result, "cluster-admin") + s.Contains(result, `"valueResult":null`) + s.Contains(result, `"valueResult2":null`) +} + +func (s *EvaluatorContextSuite) TestContextCancellationMethodsAreExposed() { + cancelCtx, cancel := context.WithCancel(context.Background()) + defer cancel() + + evaluator, err := NewEvaluator(cancelCtx, s.kubernetes) + s.Require().NoError(err) + + result, err := evaluator.Evaluate(` + const done = ctx.done(); + done !== null && done !== undefined ? "has done" : "no done"; + `, time.Second) + s.NoError(err) + s.Equal("has done", result) + + result, err = evaluator.Evaluate(` + typeof ctx.err === 'function' ? "has err method" : "no err method"; + `, time.Second) + s.NoError(err) + s.Equal("has err method", result) + + result, err = evaluator.Evaluate(` + typeof ctx.deadline === 'function' ? "has deadline method" : "no deadline method"; + `, time.Second) + s.NoError(err) + s.Equal("has deadline method", result) +} + +func (s *EvaluatorContextSuite) TestContextDeadlineIsAccessible() { + deadline := time.Now().Add(5 * time.Minute) + deadlineCtx, cancelFunc := context.WithDeadline(context.Background(), deadline) + defer cancelFunc() + + evaluator, err := NewEvaluator(deadlineCtx, s.kubernetes) + s.Require().NoError(err) + + result, err := evaluator.Evaluate(` + const [dl, ok] = ctx.deadline(); + ok ? "has deadline" : "no deadline"; + `, time.Second) + s.NoError(err) + s.Equal("has deadline", result) +} + +func (s *EvaluatorContextSuite) TestSafeContextValueAlwaysReturnsNil() { + type contextKey string + const secretKey contextKey = "secret" + + originalCtx := context.WithValue(context.Background(), secretKey, "sensitive-data") + safeCtx := newSafeContext(originalCtx) + + s.Equal("sensitive-data", originalCtx.Value(secretKey)) + s.Nil(safeCtx.Value(secretKey)) + s.Nil(safeCtx.Value("any-key")) + s.Nil(safeCtx.Value(123)) +} + +func (s *EvaluatorContextSuite) TestSafeContextDoneChannelIsPreserved() { + cancelCtx, cancel := context.WithCancel(context.Background()) + safeCtx := newSafeContext(cancelCtx) + + select { + case <-safeCtx.Done(): + s.Fail("Done should not be closed yet") + default: + } + + cancel() + + select { + case <-safeCtx.Done(): + case <-time.After(100 * time.Millisecond): + s.Fail("Done should be closed after cancel") + } +} + +func (s *EvaluatorContextSuite) TestSafeContextErrIsPreserved() { + cancelCtx, cancel := context.WithCancel(context.Background()) + safeCtx := newSafeContext(cancelCtx) + + s.Nil(safeCtx.Err()) + cancel() + s.Equal(context.Canceled, safeCtx.Err()) +} + +func (s *EvaluatorContextSuite) TestSafeContextDeadlineIsPreserved() { + deadline := time.Now().Add(time.Hour) + deadlineCtx, cancel := context.WithDeadline(context.Background(), deadline) + defer cancel() + + safeCtx := newSafeContext(deadlineCtx) + + gotDeadline, ok := safeCtx.Deadline() + s.True(ok) + s.Equal(deadline, gotDeadline) +} + +func TestEvaluatorContext(t *testing.T) { + suite.Run(t, new(EvaluatorContextSuite)) +} diff --git a/pkg/code/evaluator_output_test.go b/pkg/code/evaluator_output_test.go new file mode 100644 index 000000000..150f8782b --- /dev/null +++ b/pkg/code/evaluator_output_test.go @@ -0,0 +1,183 @@ +package code + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/suite" +) + +// EvaluatorOutputSuite tests output formatting and the valueToString function. +type EvaluatorOutputSuite struct { + BaseEvaluatorSuite + evaluator *Evaluator +} + +func (s *EvaluatorOutputSuite) SetupTest() { + s.BaseEvaluatorSuite.SetupTest() + var err error + s.evaluator, err = NewEvaluator(context.Background(), s.kubernetes) + s.Require().NoError(err) +} + +func (s *EvaluatorOutputSuite) TestStringOutput() { + s.Run("simple string is returned as-is", func() { + result, err := s.evaluator.Evaluate(`"hello world"`, time.Second) + s.NoError(err) + s.Equal("hello world", result) + }) + + s.Run("empty string returns empty", func() { + result, err := s.evaluator.Evaluate(`""`, time.Second) + s.NoError(err) + s.Equal("", result) + }) + + s.Run("string with special characters is preserved", func() { + result, err := s.evaluator.Evaluate(`"hello\nworld\ttab"`, time.Second) + s.NoError(err) + s.Equal("hello\nworld\ttab", result) + }) + + s.Run("unicode string is preserved", func() { + result, err := s.evaluator.Evaluate(`"日本語 🎉"`, time.Second) + s.NoError(err) + s.Equal("日本語 🎉", result) + }) +} + +func (s *EvaluatorOutputSuite) TestNumericOutput() { + s.Run("positive integer", func() { + result, err := s.evaluator.Evaluate(`42`, time.Second) + s.NoError(err) + s.Equal("42", result) + }) + + s.Run("negative integer", func() { + result, err := s.evaluator.Evaluate(`-123`, time.Second) + s.NoError(err) + s.Equal("-123", result) + }) + + s.Run("float with decimals", func() { + result, err := s.evaluator.Evaluate(`3.14159`, time.Second) + s.NoError(err) + s.Equal("3.14159", result) + }) + + s.Run("large number (MAX_SAFE_INTEGER)", func() { + result, err := s.evaluator.Evaluate(`9007199254740991`, time.Second) + s.NoError(err) + s.Equal("9007199254740991", result) + }) + + s.Run("arithmetic expression result", func() { + result, err := s.evaluator.Evaluate(`2 + 2`, time.Second) + s.NoError(err) + s.Equal("4", result) + }) +} + +func (s *EvaluatorOutputSuite) TestBooleanOutput() { + s.Run("true is returned as string", func() { + result, err := s.evaluator.Evaluate(`true`, time.Second) + s.NoError(err) + s.Equal("true", result) + }) + + s.Run("false is returned as string", func() { + result, err := s.evaluator.Evaluate(`false`, time.Second) + s.NoError(err) + s.Equal("false", result) + }) +} + +func (s *EvaluatorOutputSuite) TestNullAndUndefinedOutput() { + s.Run("null returns empty string", func() { + result, err := s.evaluator.Evaluate(`null`, time.Second) + s.NoError(err) + s.Equal("", result) + }) + + s.Run("undefined returns empty string", func() { + result, err := s.evaluator.Evaluate(`undefined`, time.Second) + s.NoError(err) + s.Equal("", result) + }) +} + +func (s *EvaluatorOutputSuite) TestJSONOutput() { + s.Run("output is compact without indentation", func() { + result, err := s.evaluator.Evaluate(`({a: 1, b: "two"})`, time.Second) + s.NoError(err) + s.Equal(`{"a":1,"b":"two"}`, result) + s.NotContains(result, "\n", "JSON should have no newlines") + s.NotContains(result, " ", "JSON should have no indentation") + }) + + s.Run("nested objects are compact", func() { + result, err := s.evaluator.Evaluate(`({outer: {inner: {deep: "value"}}})`, time.Second) + s.NoError(err) + s.Equal(`{"outer":{"inner":{"deep":"value"}}}`, result) + s.NotContains(result, "\n") + }) + + s.Run("arrays are compact", func() { + result, err := s.evaluator.Evaluate(`[1, 2, 3]`, time.Second) + s.NoError(err) + s.Equal(`[1,2,3]`, result) + }) + + s.Run("mixed array with different types", func() { + result, err := s.evaluator.Evaluate(`[1, "two", true, null]`, time.Second) + s.NoError(err) + s.Equal(`[1,"two",true,null]`, result) + }) + + s.Run("array of objects", func() { + result, err := s.evaluator.Evaluate(`[{a: 1}, {b: 2}]`, time.Second) + s.NoError(err) + s.Equal(`[{"a":1},{"b":2}]`, result) + }) + + s.Run("empty object", func() { + result, err := s.evaluator.Evaluate(`({})`, time.Second) + s.NoError(err) + s.Equal(`{}`, result) + }) + + s.Run("empty array", func() { + result, err := s.evaluator.Evaluate(`[]`, time.Second) + s.NoError(err) + s.Equal(`[]`, result) + }) +} + +func (s *EvaluatorOutputSuite) TestExpressionAndFunctionOutput() { + s.Run("function return value is captured", func() { + result, err := s.evaluator.Evaluate(`(function() { return "from function"; })()`, time.Second) + s.NoError(err) + s.Equal("from function", result) + }) + + s.Run("last expression in script is returned", func() { + result, err := s.evaluator.Evaluate(` + const x = 1; + const y = 2; + x + y; + `, time.Second) + s.NoError(err) + s.Equal("3", result) + }) + + s.Run("Date toISOString returns string", func() { + result, err := s.evaluator.Evaluate(`new Date(0).toISOString()`, time.Second) + s.NoError(err) + s.Equal("1970-01-01T00:00:00.000Z", result) + }) +} + +func TestEvaluatorOutput(t *testing.T) { + suite.Run(t, new(EvaluatorOutputSuite)) +} diff --git a/pkg/code/evaluator_parsing_test.go b/pkg/code/evaluator_parsing_test.go new file mode 100644 index 000000000..f5e2614a7 --- /dev/null +++ b/pkg/code/evaluator_parsing_test.go @@ -0,0 +1,381 @@ +package code + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + "sync" + "testing" + "time" + + "github.com/containers/kubernetes-mcp-server/internal/test" + "github.com/stretchr/testify/suite" +) + +// EvaluatorParsingSuite tests the full JavaScript → Go client → HTTP chain. +// It uses MockServer to capture actual HTTP request bodies and verify that +// JavaScript objects are correctly converted when sent to the Kubernetes API. +type EvaluatorParsingSuite struct { + BaseEvaluatorSuite + *Evaluator + mu sync.Mutex + capturedBody map[string]interface{} +} + +func (s *EvaluatorParsingSuite) SetupTest() { + s.BaseEvaluatorSuite.SetupTest() + s.capturedBody = nil + + // Reset handlers + s.mockServer.ResetHandlers() + + // Add capture handler that intercepts mutation requests to resource paths + s.mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Only capture POST/PUT/PATCH to resource paths + if r.Method != http.MethodPost && r.Method != http.MethodPut && r.Method != http.MethodPatch { + return + } + + // Check if this is a resource path (pods, configmaps, etc.) + isResourcePath := strings.Contains(r.URL.Path, "/pods") || + strings.Contains(r.URL.Path, "/configmaps") || + strings.Contains(r.URL.Path, "/services") || + strings.Contains(r.URL.Path, "/deployments") || + strings.Contains(r.URL.Path, "/secrets") + + if !isResourcePath { + return + } + + body, err := io.ReadAll(r.Body) + if err == nil && len(body) > 0 { + s.mu.Lock() + _ = json.Unmarshal(body, &s.capturedBody) + s.mu.Unlock() + } + + // Return success response with the body echoed back + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write(body) + })) + + // Add discovery handler + s.mockServer.Handle(test.NewDiscoveryClientHandler()) + + var err error + s.Evaluator, err = NewEvaluator(context.Background(), s.kubernetes) + s.Require().NoError(err) +} + +// getCapturedBody returns the captured body in a thread-safe way +func (s *EvaluatorParsingSuite) getCapturedBody() map[string]interface{} { + s.mu.Lock() + defer s.mu.Unlock() + return s.capturedBody +} + +// resetCapturedBody clears the captured body before each subtest +func (s *EvaluatorParsingSuite) resetCapturedBody() { + s.mu.Lock() + defer s.mu.Unlock() + s.capturedBody = nil +} + +func (s *EvaluatorParsingSuite) TestMetadataStructure() { + s.Run("simple pod creation preserves metadata structure", func() { + s.resetCapturedBody() + _, err := s.Evaluate(` + k8s.coreV1().pods("default").create(ctx, { + apiVersion: "v1", + kind: "Pod", + metadata: { + name: "test-pod" + }, + spec: { + containers: [{name: "nginx", image: "nginx"}] + } + }, {}); + `, time.Second) + + // The call may fail due to mock limitations, but the body should be captured + _ = err + body := s.getCapturedBody() + s.Require().NotNil(body, "HTTP request body should be captured") + + // Metadata should be preserved in standard Kubernetes structure + metadata, ok := body["metadata"].(map[string]interface{}) + s.True(ok, "metadata should be a map") + s.Equal("test-pod", metadata["name"]) + }) + + s.Run("metadata with labels is preserved correctly", func() { + s.resetCapturedBody() + _, _ = s.Evaluate(` + k8s.coreV1().pods("default").create(ctx, { + metadata: { + name: "labeled-pod", + labels: { + "app": "nginx", + "tier": "frontend" + } + }, + spec: { + containers: [{name: "nginx", image: "nginx"}] + } + }, {}); + `, time.Second) + + body := s.getCapturedBody() + s.Require().NotNil(body) + + metadata, ok := body["metadata"].(map[string]interface{}) + s.True(ok, "metadata should be a map") + s.Equal("labeled-pod", metadata["name"]) + + labels, ok := metadata["labels"].(map[string]interface{}) + s.True(ok, "labels should be a map") + s.Equal("nginx", labels["app"]) + s.Equal("frontend", labels["tier"]) + }) + + s.Run("metadata with annotations is preserved", func() { + s.resetCapturedBody() + _, _ = s.Evaluate(` + k8s.coreV1().pods("default").create(ctx, { + metadata: { + name: "annotated-pod", + annotations: { + "description": "Test pod", + "owner": "team-a" + } + }, + spec: { + containers: [{name: "nginx", image: "nginx"}] + } + }, {}); + `, time.Second) + + body := s.getCapturedBody() + s.Require().NotNil(body) + + metadata, ok := body["metadata"].(map[string]interface{}) + s.True(ok, "metadata should be a map") + + annotations, ok := metadata["annotations"].(map[string]interface{}) + s.True(ok) + s.Equal("Test pod", annotations["description"]) + }) +} + +func (s *EvaluatorParsingSuite) TestComplexStructures() { + s.Run("complex spec is preserved", func() { + s.resetCapturedBody() + _, err := s.Evaluate(` + k8s.coreV1().pods("default").create(ctx, { + metadata: { + name: "complex-pod" + }, + spec: { + containers: [{ + name: "app", + image: "myapp:latest", + ports: [{containerPort: 8080}], + env: [ + {name: "DB_HOST", value: "localhost"}, + {name: "DB_PORT", value: "5432"} + ] + }], + volumes: [{ + name: "config", + configMap: {name: "app-config"} + }] + } + }, {}); + `, time.Second) + body := s.getCapturedBody() + if body == nil { + s.T().Logf("Script evaluation error (if any): %v", err) + } + s.Require().NotNil(body, "HTTP request body should be captured") + + // Metadata should be preserved + metadata, ok := body["metadata"].(map[string]interface{}) + s.True(ok, "metadata should exist") + s.Equal("complex-pod", metadata["name"]) + + // Spec should be preserved as-is + spec, ok := body["spec"].(map[string]interface{}) + s.True(ok, "spec should exist") + + containers, ok := spec["containers"].([]interface{}) + s.True(ok) + s.Len(containers, 1) + + container := containers[0].(map[string]interface{}) + s.Equal("app", container["name"]) + s.Equal("myapp:latest", container["image"]) + + // Check env vars are preserved + env := container["env"].([]interface{}) + s.Len(env, 2) + envVar := env[0].(map[string]interface{}) + s.Equal("DB_HOST", envVar["name"]) + s.Equal("localhost", envVar["value"]) + }) + + s.Run("multiple containers are preserved", func() { + s.resetCapturedBody() + _, _ = s.Evaluate(` + k8s.coreV1().pods("default").create(ctx, { + metadata: {name: "multi-container"}, + spec: { + containers: [ + {name: "main", image: "main:v1"}, + {name: "sidecar", image: "sidecar:v1"} + ] + } + }, {}); + `, time.Second) + + body := s.getCapturedBody() + s.Require().NotNil(body) + + spec := body["spec"].(map[string]interface{}) + containers := spec["containers"].([]interface{}) + s.Len(containers, 2) + }) +} + +func (s *EvaluatorParsingSuite) TestTypePreservation() { + s.Run("numeric values are preserved", func() { + s.resetCapturedBody() + _, _ = s.Evaluate(` + k8s.coreV1().pods("default").create(ctx, { + metadata: {name: "numeric-pod"}, + spec: { + containers: [{ + name: "app", + image: "app", + ports: [{containerPort: 8080, hostPort: 80}] + }], + terminationGracePeriodSeconds: 30 + } + }, {}); + `, time.Second) + + body := s.getCapturedBody() + s.Require().NotNil(body) + + spec := body["spec"].(map[string]interface{}) + // JSON numbers are float64 in Go + s.Equal(float64(30), spec["terminationGracePeriodSeconds"]) + + containers := spec["containers"].([]interface{}) + container := containers[0].(map[string]interface{}) + ports := container["ports"].([]interface{}) + port := ports[0].(map[string]interface{}) + s.Equal(float64(8080), port["containerPort"]) + }) + + s.Run("boolean values are preserved", func() { + s.resetCapturedBody() + _, _ = s.Evaluate(` + k8s.coreV1().pods("default").create(ctx, { + metadata: {name: "bool-pod"}, + spec: { + containers: [{ + name: "app", + image: "app", + stdin: true, + tty: true + }], + hostNetwork: true + } + }, {}); + `, time.Second) + + body := s.getCapturedBody() + s.Require().NotNil(body) + + spec := body["spec"].(map[string]interface{}) + s.Equal(true, spec["hostNetwork"]) + + containers := spec["containers"].([]interface{}) + container := containers[0].(map[string]interface{}) + s.Equal(true, container["stdin"]) + s.Equal(true, container["tty"]) + }) + + s.Run("special characters in strings are preserved", func() { + s.resetCapturedBody() + _, _ = s.Evaluate(` + k8s.coreV1().pods("default").create(ctx, { + metadata: { + name: "special-chars", + labels: { + "app.kubernetes.io/name": "my-app" + }, + annotations: { + "note": "Contains special chars: !@#$%^&*()" + } + }, + spec: { + containers: [{name: "app", image: "app"}] + } + }, {}); + `, time.Second) + + body := s.getCapturedBody() + s.Require().NotNil(body) + + metadata := body["metadata"].(map[string]interface{}) + labels := metadata["labels"].(map[string]interface{}) + s.Equal("my-app", labels["app.kubernetes.io/name"]) + + annotations := metadata["annotations"].(map[string]interface{}) + s.Contains(annotations["note"], "!@#$%^&*()") + }) +} + +func (s *EvaluatorParsingSuite) TestMethodCaseInsensitivity() { + s.Run("uppercase CoreV1 and Create work", func() { + s.resetCapturedBody() + _, _ = s.Evaluate(` + k8s.CoreV1().Pods("default").Create(ctx, { + metadata: {name: "uppercase-methods"}, + spec: {containers: [{name: "app", image: "app"}]} + }, {}); + `, time.Second) + + body := s.getCapturedBody() + s.Require().NotNil(body) + + metadata, ok := body["metadata"].(map[string]interface{}) + s.True(ok, "metadata should be a map") + s.Equal("uppercase-methods", metadata["name"]) + }) + + s.Run("mixed case methods work", func() { + s.resetCapturedBody() + _, _ = s.Evaluate(` + k8s.coreV1().Pods("default").create(ctx, { + metadata: {name: "mixed-case"}, + spec: {containers: [{name: "app", image: "app"}]} + }, {}); + `, time.Second) + + body := s.getCapturedBody() + s.Require().NotNil(body) + + metadata, ok := body["metadata"].(map[string]interface{}) + s.True(ok, "metadata should be a map") + s.Equal("mixed-case", metadata["name"]) + }) +} + +func TestEvaluatorParsing(t *testing.T) { + suite.Run(t, new(EvaluatorParsingSuite)) +} diff --git a/pkg/code/evaluator_security_test.go b/pkg/code/evaluator_security_test.go new file mode 100644 index 000000000..84f136f54 --- /dev/null +++ b/pkg/code/evaluator_security_test.go @@ -0,0 +1,258 @@ +package code + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/suite" +) + +type EvaluatorSecuritySuite struct { + BaseEvaluatorSuite + *Evaluator +} + +func (s *EvaluatorSecuritySuite) SetupTest() { + s.BaseEvaluatorSuite.SetupTest() + var err error + s.Evaluator, err = NewEvaluator(context.Background(), s.kubernetes) + s.Require().NoError(err) +} + +func (s *EvaluatorSecuritySuite) TestNoNetworkAccess() { + s.Run("fetch is not defined", func() { + _, err := s.Evaluate("fetch('http://example.com')", time.Second) + s.Error(err) + s.Contains(err.Error(), "not defined") + }) + + s.Run("XMLHttpRequest is not defined", func() { + _, err := s.Evaluate("new XMLHttpRequest()", time.Second) + s.Error(err) + s.Contains(err.Error(), "not defined") + }) + + s.Run("WebSocket is not defined", func() { + _, err := s.Evaluate("new WebSocket('ws://example.com')", time.Second) + s.Error(err) + s.Contains(err.Error(), "not defined") + }) +} + +func (s *EvaluatorSecuritySuite) TestNoFileSystemAccess() { + s.Run("require is not defined", func() { + _, err := s.Evaluate("require('fs')", time.Second) + s.Error(err) + s.Contains(err.Error(), "not defined") + }) + + s.Run("import is not available for modules", func() { + // Dynamic import should fail + _, err := s.Evaluate("import('fs')", time.Second) + s.Error(err) + s.Contains(err.Error(), "Unexpected reserved word") + }) +} + +func (s *EvaluatorSecuritySuite) TestNoProcessAccess() { + s.Run("process is not defined", func() { + _, err := s.Evaluate("process.env", time.Second) + s.Error(err) + s.Contains(err.Error(), "not defined") + }) + + s.Run("process.exit is not available", func() { + _, err := s.Evaluate("process.exit(1)", time.Second) + s.Error(err) + s.Contains(err.Error(), "not defined") + }) + + s.Run("process.cwd is not available", func() { + _, err := s.Evaluate("process.cwd()", time.Second) + s.Error(err) + s.Contains(err.Error(), "not defined") + }) +} + +func (s *EvaluatorSecuritySuite) TestNoGlobalObjectAccess() { + s.Run("global is not defined", func() { + _, err := s.Evaluate("global.process", time.Second) + s.Error(err) + s.Contains(err.Error(), "not defined") + }) + + s.Run("globalThis has no process", func() { + result, err := s.Evaluate("typeof globalThis.process", time.Second) + s.NoError(err) + s.Equal("undefined", result) + }) + + s.Run("window is not defined", func() { + _, err := s.Evaluate("window.location", time.Second) + s.Error(err) + s.Contains(err.Error(), "not defined") + }) +} + +func (s *EvaluatorSecuritySuite) TestNoTimerAbuse() { + s.Run("setTimeout is not defined", func() { + _, err := s.Evaluate("setTimeout(() => {}, 1000)", time.Second) + s.Error(err) + s.Contains(err.Error(), "not defined") + }) + + s.Run("setInterval is not defined", func() { + _, err := s.Evaluate("setInterval(() => {}, 1000)", time.Second) + s.Error(err) + s.Contains(err.Error(), "not defined") + }) + + s.Run("setImmediate is not defined", func() { + _, err := s.Evaluate("setImmediate(() => {})", time.Second) + s.Error(err) + s.Contains(err.Error(), "not defined") + }) +} + +func (s *EvaluatorSecuritySuite) TestNoEvalAbuse() { + s.Run("eval with process access fails", func() { + _, err := s.Evaluate("eval('process.env')", time.Second) + s.Error(err) + }) + + s.Run("Function constructor with process access fails", func() { + _, err := s.Evaluate("new Function('return process.env')()", time.Second) + s.Error(err) + }) +} + +func (s *EvaluatorSecuritySuite) TestTimeoutEnforcement() { + s.Run("infinite loop is interrupted", func() { + _, err := s.Evaluate("while(true) {}", 100*time.Millisecond) + s.Error(err) + s.Contains(err.Error(), "timeout") + }) + + s.Run("long computation is interrupted", func() { + script := ` + let x = 0; + for (let i = 0; i < 1e12; i++) { + x += i; + } + x; + ` + _, err := s.Evaluate(script, 100*time.Millisecond) + s.Error(err) + s.Contains(err.Error(), "timeout") + }) + + s.Run("default timeout is applied for zero value", func() { + // This test verifies the timeout normalization + result, err := s.Evaluate("'quick'", 0) + s.NoError(err) + s.Equal("quick", result) + }) + + s.Run("max timeout is enforced", func() { + // Verify that timeout > MaxTimeout gets capped + timeout := normalizeTimeout(10 * time.Hour) + s.Equal(MaxTimeout, timeout) + }) +} + +func (s *EvaluatorSecuritySuite) TestStackOverflowProtection() { + s.Run("infinite recursion is prevented", func() { + script := ` + function recurse() { + return recurse(); + } + recurse(); + ` + _, err := s.Evaluate(script, time.Second) + s.Require().Error(err, "infinite recursion should cause an error") + // The error message contains the function name where overflow occurred + s.Contains(err.Error(), "recurse") + }) + + s.Run("deep but finite recursion within limit works", func() { + script := ` + function countdown(n) { + if (n <= 0) return 0; + return countdown(n - 1); + } + countdown(100); + ` + result, err := s.Evaluate(script, time.Second) + s.NoError(err) + s.Equal("0", result) + }) +} + +func (s *EvaluatorSecuritySuite) TestSafeBuiltinsAvailable() { + s.Run("JSON is available", func() { + result, err := s.Evaluate("JSON.stringify({a: 1})", time.Second) + s.NoError(err) + s.Equal(`{"a":1}`, result) + }) + + s.Run("Math is available", func() { + result, err := s.Evaluate("Math.max(1, 2, 3)", time.Second) + s.NoError(err) + s.Equal("3", result) + }) + + s.Run("Array methods are available", func() { + result, err := s.Evaluate("[1,2,3].map(x => x * 2).join(',')", time.Second) + s.NoError(err) + s.Equal("2,4,6", result) + }) + + s.Run("Object methods are available", func() { + result, err := s.Evaluate("Object.keys({a:1, b:2}).join(',')", time.Second) + s.NoError(err) + s.Equal("a,b", result) + }) + + s.Run("String methods are available", func() { + result, err := s.Evaluate("'hello'.toUpperCase()", time.Second) + s.NoError(err) + s.Equal("HELLO", result) + }) + + s.Run("Date is available", func() { + result, err := s.Evaluate("typeof Date", time.Second) + s.NoError(err) + s.Equal("function", result) + }) + + s.Run("RegExp is available", func() { + result, err := s.Evaluate("/test/.test('testing')", time.Second) + s.NoError(err) + s.Equal("true", result) + }) +} + +func (s *EvaluatorSecuritySuite) TestErrorMessages() { + s.Run("syntax errors are descriptive", func() { + _, err := s.Evaluate("function(", time.Second) + s.Error(err) + s.Contains(err.Error(), "Unexpected token") + }) + + s.Run("runtime errors are descriptive", func() { + _, err := s.Evaluate("throw new Error('custom error')", time.Second) + s.Error(err) + s.Contains(err.Error(), "custom error") + }) + + s.Run("reference errors are descriptive", func() { + _, err := s.Evaluate("undefinedVariable", time.Second) + s.Error(err) + s.Contains(err.Error(), "undefinedVariable is not defined") + }) +} + +func TestEvaluatorSecurity(t *testing.T) { + suite.Run(t, new(EvaluatorSecuritySuite)) +} diff --git a/pkg/code/evaluator_test.go b/pkg/code/evaluator_test.go new file mode 100644 index 000000000..efdddbedd --- /dev/null +++ b/pkg/code/evaluator_test.go @@ -0,0 +1,39 @@ +package code + +import ( + "github.com/containers/kubernetes-mcp-server/internal/test" + "github.com/containers/kubernetes-mcp-server/pkg/config" + "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" + "github.com/stretchr/testify/suite" + "k8s.io/client-go/tools/clientcmd" +) + +// BaseEvaluatorSuite provides common setup for evaluator tests. +// It uses MockServer for lightweight Kubernetes API mocking. +type BaseEvaluatorSuite struct { + suite.Suite + mockServer *test.MockServer + kubernetes *kubernetes.Kubernetes +} + +func (s *BaseEvaluatorSuite) SetupTest() { + s.mockServer = test.NewMockServer() + s.mockServer.Handle(test.NewDiscoveryClientHandler()) + clientConfig := clientcmd.NewDefaultClientConfig( + *s.mockServer.Kubeconfig(), + &clientcmd.ConfigOverrides{}, + ) + var err error + s.kubernetes, err = kubernetes.NewKubernetes( + config.Default(), + clientConfig, + s.mockServer.Config(), + ) + s.Require().NoError(err, "Expected no error creating kubernetes client") +} + +func (s *BaseEvaluatorSuite) TearDownTest() { + if s.mockServer != nil { + s.mockServer.Close() + } +} diff --git a/pkg/code/goja_conversion_test.go b/pkg/code/goja_conversion_test.go new file mode 100644 index 000000000..e02a686a6 --- /dev/null +++ b/pkg/code/goja_conversion_test.go @@ -0,0 +1,573 @@ +package code + +import ( + "encoding/json" + "testing" + + "github.com/dop251/goja" + "github.com/stretchr/testify/suite" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// GojaConversionSuite investigates how Goja converts JavaScript objects to Go types. +// This helps understand the root cause of metadata handling issues. +type GojaConversionSuite struct { + suite.Suite + vm *goja.Runtime +} + +func (s *GojaConversionSuite) SetupTest() { + s.vm = goja.New() + s.vm.SetFieldNameMapper(goja.TagFieldNameMapper("json", true)) +} + +// Test basic struct conversion with simple fields +func (s *GojaConversionSuite) TestBasicStructConversion() { + // A simple struct with json tags + type SimpleStruct struct { + Name string `json:"name"` + Value int `json:"value"` + } + + var received SimpleStruct + _ = s.vm.Set("captureStruct", func(obj SimpleStruct) { + received = obj + }) + + s.Run("simple object converts correctly", func() { + _, err := s.vm.RunString(`captureStruct({name: "test", value: 42})`) + s.NoError(err) + s.Equal("test", received.Name) + s.Equal(42, received.Value) + }) +} + +// Test nested struct conversion +func (s *GojaConversionSuite) TestNestedStructConversion() { + type Inner struct { + Name string `json:"name"` + } + type Outer struct { + Inner Inner `json:"inner"` + Value int `json:"value"` + } + + var received Outer + _ = s.vm.Set("captureNested", func(obj Outer) { + received = obj + }) + + s.Run("nested object converts correctly", func() { + _, err := s.vm.RunString(`captureNested({inner: {name: "nested"}, value: 99})`) + s.NoError(err) + s.T().Logf("Received: %+v", received) + s.Equal("nested", received.Inner.Name) + s.Equal(99, received.Value) + }) +} + +// Test embedded struct conversion (like ObjectMeta in Pod) +func (s *GojaConversionSuite) TestEmbeddedStructConversion() { + type EmbeddedMeta struct { + Name string `json:"name"` + Labels map[string]string `json:"labels,omitempty"` + } + type ResourceWithMeta struct { + EmbeddedMeta `json:"metadata"` + Spec string `json:"spec"` + } + + var received ResourceWithMeta + _ = s.vm.Set("captureEmbedded", func(obj ResourceWithMeta) { + received = obj + }) + + // BUG DOCUMENTATION: This test documents a Goja bug where embedded structs + // with json tags (like `json:"metadata"`) don't map nested JS objects correctly. + // Goja's ExportTo checks field.Anonymous and passes the entire object, ignoring + // the mapped field name from FieldNameMapper. This means {metadata: {name: "x"}} + // doesn't work - the metadata property is never looked up. + s.Run("BUG: embedded struct with json tag fails to map nested object", func() { + _, err := s.vm.RunString(`captureEmbedded({metadata: {name: "test-name", labels: {app: "nginx"}}, spec: "test-spec"})`) + s.NoError(err) + s.T().Logf("Received: %+v", received) + // BUG: Name should be "test-name" but is empty because Goja doesn't look up "metadata" + s.Equal("", received.Name, "Goja bug: embedded struct with json tag doesn't map nested object") + s.Equal("test-spec", received.Spec) + // BUG: Labels should have "nginx" but is empty + s.Empty(received.Labels, "Goja bug: labels not populated from nested metadata") + }) + + s.Run("flat fields also work for embedded struct", func() { + received = ResourceWithMeta{} // reset + _, err := s.vm.RunString(`captureEmbedded({name: "flat-name", spec: "flat-spec"})`) + s.NoError(err) + s.T().Logf("Received with flat fields: %+v", received) + s.Equal("flat-name", received.Name) + s.Equal("flat-spec", received.Spec) + }) +} + +// Test actual Kubernetes Pod struct - documents the Goja bug and workaround +func (s *GojaConversionSuite) TestKubernetesPodConversion() { + var received *v1.Pod + _ = s.vm.Set("capturePod", func(pod *v1.Pod) { + received = pod + }) + + // BUG DOCUMENTATION: This test documents the Goja bug with Kubernetes Pods. + // v1.Pod embeds metav1.ObjectMeta with `json:"metadata"`, which triggers the bug. + s.Run("BUG: pod with nested metadata fails to populate ObjectMeta", func() { + received = nil + _, err := s.vm.RunString(` + capturePod({ + apiVersion: "v1", + kind: "Pod", + metadata: { + name: "test-pod", + namespace: "default", + labels: {"app": "nginx"} + }, + spec: { + containers: [{name: "nginx", image: "nginx:latest"}] + } + }) + `) + s.NoError(err) + s.T().Logf("Pod with nested metadata: %+v", received) + if received != nil { + s.T().Logf(" ObjectMeta: %+v", received.ObjectMeta) + s.T().Logf(" Spec: %+v", received.Spec) + } + s.Require().NotNil(received) + // BUG: Name and Namespace should be populated but are empty + s.Equal("", received.Name, "Goja bug: metadata.name not mapped to ObjectMeta.Name") + s.Equal("", received.Namespace, "Goja bug: metadata.namespace not mapped") + }) + + // WORKAROUND: Flat fields work because Goja maps them to embedded struct fields + s.Run("WORKAROUND: pod with flat metadata fields works correctly", func() { + received = nil + _, err := s.vm.RunString(` + capturePod({ + apiVersion: "v1", + kind: "Pod", + name: "flat-pod", + namespace: "flat-ns", + spec: { + containers: [{name: "nginx", image: "nginx:latest"}] + } + }) + `) + s.NoError(err) + s.T().Logf("Pod with flat metadata: %+v", received) + if received != nil { + s.T().Logf(" ObjectMeta: %+v", received.ObjectMeta) + } + s.Require().NotNil(received) + // This works because flat fields are mapped to the embedded ObjectMeta + s.Equal("flat-pod", received.Name) + s.Equal("flat-ns", received.Namespace) + }) +} + +// Test ObjectMeta directly +func (s *GojaConversionSuite) TestObjectMetaConversion() { + var received metav1.ObjectMeta + _ = s.vm.Set("captureObjectMeta", func(meta metav1.ObjectMeta) { + received = meta + }) + + s.Run("ObjectMeta with direct fields", func() { + _, err := s.vm.RunString(` + captureObjectMeta({ + name: "test-name", + namespace: "test-ns", + labels: {"app": "test"} + }) + `) + s.NoError(err) + s.T().Logf("ObjectMeta received: %+v", received) + s.Equal("test-name", received.Name) + s.Equal("test-ns", received.Namespace) + s.Equal("test", received.Labels["app"]) + }) +} + +// Test what happens when we pass map[string]interface{} vs JS object +func (s *GojaConversionSuite) TestMapVsJSObject() { + var receivedPod *v1.Pod + _ = s.vm.Set("capturePodFromMap", func(pod *v1.Pod) { + receivedPod = pod + }) + + // Create a Go map and pass it through JSON round-trip + _ = s.vm.Set("createFromGoMap", func() map[string]interface{} { + return map[string]interface{}{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": map[string]interface{}{ + "name": "map-pod", + "namespace": "map-ns", + }, + "spec": map[string]interface{}{ + "containers": []map[string]interface{}{ + {"name": "nginx", "image": "nginx"}, + }, + }, + } + }) + + s.Run("Go map converted to JS then to Pod", func() { + receivedPod = nil + _, err := s.vm.RunString(` + const goMap = createFromGoMap(); + capturePodFromMap(goMap); + `) + s.NoError(err) + s.T().Logf("Pod from Go map: %+v", receivedPod) + if receivedPod != nil { + s.T().Logf(" Name: %s, Namespace: %s", receivedPod.Name, receivedPod.Namespace) + } + }) + + s.Run("JSON parse/stringify then to Pod", func() { + receivedPod = nil + _, err := s.vm.RunString(` + const obj = { + apiVersion: "v1", + kind: "Pod", + metadata: {name: "json-pod", namespace: "json-ns"}, + spec: {containers: [{name: "nginx", image: "nginx"}]} + }; + const jsonCopy = JSON.parse(JSON.stringify(obj)); + capturePodFromMap(jsonCopy); + `) + s.NoError(err) + s.T().Logf("Pod from JSON round-trip: %+v", receivedPod) + if receivedPod != nil { + s.T().Logf(" Name: %s, Namespace: %s", receivedPod.Name, receivedPod.Namespace) + } + }) +} + +// Test what the actual client interface looks like - documents why wrapping is needed +func (s *GojaConversionSuite) TestClientInterfaceSimulation() { + // Simulate what CoreV1().Pods().Create() does + type MockCreateOptions struct { + DryRun []string `json:"dryRun,omitempty"` + } + + createdPod := &v1.Pod{} + + mockCreate := func(pod *v1.Pod, opts MockCreateOptions) *v1.Pod { + createdPod = pod + // Serialize to JSON to see what we actually receive + jsonBytes, _ := json.MarshalIndent(pod, "", " ") + s.T().Logf("Create received pod JSON:\n%s", string(jsonBytes)) + return pod + } + + _ = s.vm.Set("mockCreate", mockCreate) + + // BUG DOCUMENTATION: This is why we need wrapper functions that flatten metadata + s.Run("BUG: direct call fails to populate metadata", func() { + _, err := s.vm.RunString(` + mockCreate({ + apiVersion: "v1", + kind: "Pod", + metadata: { + name: "direct-pod", + namespace: "direct-ns", + labels: {"app": "test"} + }, + spec: { + containers: [{name: "nginx", image: "nginx"}] + } + }, {}); + `) + s.NoError(err) + s.T().Logf("Created pod Name: %s, Namespace: %s", createdPod.Name, createdPod.Namespace) + // BUG: Name should be "direct-pod" but is empty due to the embedded struct bug + s.Equal("", createdPod.Name, "Goja bug: metadata.name not mapped through embedded ObjectMeta") + }) +} + +// Test the difference between embedded and named fields +func (s *GojaConversionSuite) TestEmbeddedVsNamed() { + type Meta struct { + Name string `json:"name"` + Labels map[string]string `json:"labels,omitempty"` + } + + // Named field (not embedded) + type WithNamedMeta struct { + Meta Meta `json:"metadata"` + Spec string `json:"spec"` + } + + // Embedded field + type WithEmbeddedMeta struct { + Meta `json:"metadata"` + Spec string `json:"spec"` + } + + // Embedded without json tag (inline) + type WithInlineEmbedded struct { + Meta `json:",inline"` + Spec string `json:"spec"` + } + + s.Run("named field converts nested structure correctly", func() { + var received WithNamedMeta + _ = s.vm.Set("captureNamed", func(obj WithNamedMeta) { + received = obj + }) + + _, err := s.vm.RunString(`captureNamed({metadata: {name: "named-test"}, spec: "s"})`) + s.NoError(err) + s.T().Logf("Named: %+v", received) + s.Equal("named-test", received.Meta.Name) + }) + + s.Run("embedded field does NOT convert nested structure", func() { + var received WithEmbeddedMeta + _ = s.vm.Set("captureEmbedded", func(obj WithEmbeddedMeta) { + received = obj + }) + + _, err := s.vm.RunString(`captureEmbedded({metadata: {name: "embedded-test"}, spec: "s"})`) + s.NoError(err) + s.T().Logf("Embedded with json tag: %+v", received) + // This will likely fail - showing the bug + s.T().Logf("Name is empty: %v", received.Name == "") + }) + + s.Run("embedded with inline converts flat fields", func() { + var received WithInlineEmbedded + _ = s.vm.Set("captureInline", func(obj WithInlineEmbedded) { + received = obj + }) + + _, err := s.vm.RunString(`captureInline({name: "inline-test", spec: "s"})`) + s.NoError(err) + s.T().Logf("Inline embedded: %+v", received) + s.Equal("inline-test", received.Name) + }) +} + +// Test if we can work around by using a wrapper +func (s *GojaConversionSuite) TestWorkaroundWithWrapper() { + type Meta struct { + Name string `json:"name"` + Labels map[string]string `json:"labels,omitempty"` + } + + type PodLike struct { + Meta `json:"metadata"` + Spec string `json:"spec"` + } + + // Wrapper that can receive the JS object as a map and manually convert + _ = s.vm.Set("manualConvert", func(obj map[string]interface{}) PodLike { + result := PodLike{} + if spec, ok := obj["spec"].(string); ok { + result.Spec = spec + } + if metadata, ok := obj["metadata"].(map[string]interface{}); ok { + if name, ok := metadata["name"].(string); ok { + result.Name = name + } + if labels, ok := metadata["labels"].(map[string]interface{}); ok { + result.Labels = make(map[string]string) + for k, v := range labels { + if s, ok := v.(string); ok { + result.Labels[k] = s + } + } + } + } + return result + }) + + s.Run("manual conversion from map works", func() { + result, err := s.vm.RunString(` + const pod = manualConvert({ + metadata: {name: "manual-pod", labels: {app: "test"}}, + spec: "test-spec" + }); + JSON.stringify(pod); + `) + s.NoError(err) + s.T().Logf("Manual conversion result: %s", result.String()) + }) +} + +// Test if we can use JSON as intermediary +func (s *GojaConversionSuite) TestJSONIntermediary() { + type Meta struct { + Name string `json:"name"` + Labels map[string]string `json:"labels,omitempty"` + } + + type PodLike struct { + Meta `json:"metadata"` + Spec string `json:"spec"` + } + + // Receive as string (JSON), unmarshal in Go + _ = s.vm.Set("createFromJSON", func(jsonStr string) (*PodLike, error) { + var result PodLike + err := json.Unmarshal([]byte(jsonStr), &result) + if err != nil { + return nil, err + } + s.T().Logf("Created from JSON: %+v", result) + return &result, nil + }) + + s.Run("JSON string to struct works", func() { + result, err := s.vm.RunString(` + const pod = { + metadata: {name: "json-pod", labels: {app: "test"}}, + spec: "test-spec" + }; + createFromJSON(JSON.stringify(pod)); + `) + s.NoError(err) + // The result should be a *PodLike with correct values + if exported := result.Export(); exported != nil { + if pod, ok := exported.(*PodLike); ok { + s.Equal("json-pod", pod.Name) + s.Equal("test", pod.Labels["app"]) + } + } + }) +} + +// Test the proposed solution: wrap typed functions to use JSON intermediary +func (s *GojaConversionSuite) TestJSONWrapperSolution() { + var createdPod *v1.Pod + + // Original function that expects *v1.Pod + originalCreate := func(pod *v1.Pod) *v1.Pod { + createdPod = pod + return pod + } + + // Wrapper that receives JSON string and unmarshals + wrappedCreate := func(jsonStr string) (*v1.Pod, error) { + var pod v1.Pod + if err := json.Unmarshal([]byte(jsonStr), &pod); err != nil { + return nil, err + } + return originalCreate(&pod), nil + } + + _ = s.vm.Set("createPod", wrappedCreate) + + s.Run("JSON wrapper correctly populates Pod", func() { + createdPod = nil + _, err := s.vm.RunString(` + const pod = { + apiVersion: "v1", + kind: "Pod", + metadata: { + name: "test-pod", + namespace: "test-ns", + labels: {"app": "nginx", "tier": "frontend"} + }, + spec: { + containers: [{ + name: "nginx", + image: "nginx:latest", + ports: [{containerPort: 80}] + }] + } + }; + createPod(JSON.stringify(pod)); + `) + s.NoError(err) + s.Require().NotNil(createdPod) + s.Equal("test-pod", createdPod.Name) + s.Equal("test-ns", createdPod.Namespace) + s.Equal("nginx", createdPod.Labels["app"]) + s.Equal("frontend", createdPod.Labels["tier"]) + s.Len(createdPod.Spec.Containers, 1) + s.Equal("nginx", createdPod.Spec.Containers[0].Name) + s.Equal("nginx:latest", createdPod.Spec.Containers[0].Image) + + // Verify JSON output + jsonBytes, _ := json.MarshalIndent(createdPod, "", " ") + s.T().Logf("Created Pod JSON:\n%s", string(jsonBytes)) + }) +} + +// Test how this could be integrated with the k8s client proxy +func (s *GojaConversionSuite) TestProxyWithJSONSolution() { + var capturedJSON string + + // Simulate what the real client would do + mockPodsCreate := func(jsonStr string) (string, error) { + capturedJSON = jsonStr + // In real implementation, unmarshal and call actual client + var pod v1.Pod + if err := json.Unmarshal([]byte(jsonStr), &pod); err != nil { + return "", err + } + // Return the created resource + return jsonStr, nil + } + + _ = s.vm.Set("__podsCreate", mockPodsCreate) + + // Set up a JavaScript wrapper that looks like the typed API + _, err := s.vm.RunString(` + const k8s = { + coreV1: function() { + return { + pods: function(namespace) { + return { + create: function(ctx, pod, opts) { + // Convert to JSON and call Go function + return JSON.parse(__podsCreate(JSON.stringify(pod))); + } + }; + } + }; + } + }; + `) + s.Require().NoError(err) + + s.Run("proxy with JSON maintains API compatibility", func() { + result, err := s.vm.RunString(` + const pod = k8s.coreV1().pods("default").create(null, { + apiVersion: "v1", + kind: "Pod", + metadata: { + name: "proxy-pod", + labels: {"app": "test"} + }, + spec: { + containers: [{name: "nginx", image: "nginx"}] + } + }, {}); + pod.metadata.name; + `) + s.NoError(err) + s.Equal("proxy-pod", result.Export()) + + // Verify the JSON was correct + var pod v1.Pod + err = json.Unmarshal([]byte(capturedJSON), &pod) + s.NoError(err) + s.Equal("proxy-pod", pod.Name) + s.Equal("test", pod.Labels["app"]) + }) +} + +func TestGojaConversion(t *testing.T) { + suite.Run(t, new(GojaConversionSuite)) +} diff --git a/pkg/code/k8s_proxy.js b/pkg/code/k8s_proxy.js new file mode 100644 index 000000000..a131cf253 --- /dev/null +++ b/pkg/code/k8s_proxy.js @@ -0,0 +1,312 @@ +// Workaround for Goja bug: embedded structs with json tags don't map correctly. +// +// Background: Goja's ExportTo handles anonymous (embedded) fields by passing the +// entire JS object recursively. This works for truly anonymous fields (no json tag +// or json:",inline"). However, when an embedded field has a json tag like +// `json:"metadata"`, Goja should look up that property but doesn't - it still +// passes the entire object due to checking field.Anonymous before considering +// the mapped field name. +// +// Kubernetes Impact: All Kubernetes resources embed ObjectMeta with `json:"metadata"`. +// When JS passes {metadata: {name: "x"}}, Goja fails to extract the "metadata" +// property and tries to map the entire object to ObjectMeta, resulting in empty +// metadata. +// +// Solution: Flatten metadata fields to the top level before passing to Go. +// Since ObjectMeta fields (name, namespace, labels, annotations, etc.) are at +// the top level, Goja correctly populates the embedded struct. Go's JSON +// serialization then restores the nested structure when sending to the API. +// +// Coverage: This workaround covers ALL Kubernetes resources including CRDs because: +// - TypeMeta uses `json:",inline"` which works correctly (no mapping needed) +// - ObjectMeta uses `json:"metadata"` which we handle here +// - All other fields (Spec, Status) are named fields, not embedded + +// Methods that accept Kubernetes resources and need conversion +const __mutationMethods = ['create', 'update', 'patch', 'apply', 'Create', 'Update', 'Patch', 'Apply']; + +// Methods that return Kubernetes objects and need conversion to pure JS +const __readMethods = ['get', 'list', 'watch', 'Get', 'List', 'Watch']; + +// Methods that return another resource interface (for dynamic client chaining) +const __chainingMethods = ['namespace', 'Namespace']; + +function __flattenK8sObject(obj) { + if (!obj || typeof obj !== 'object') { + return obj; + } + if (Array.isArray(obj)) { + return obj.map(__flattenK8sObject); + } + + const result = {}; + for (const key in obj) { + if (key === 'metadata' && typeof obj[key] === 'object') { + // Flatten metadata fields to top level + const metadata = obj[key]; + for (const metaKey in metadata) { + result[metaKey] = __flattenK8sObject(metadata[metaKey]); + } + } else { + result[key] = __flattenK8sObject(obj[key]); + } + } + return result; +} + +// Get own enumerable property names from an object +function __getKeys(obj) { + const keys = []; + for (const key in obj) { + keys.push(key); + } + return keys; +} + +// Try to resolve a property with case-insensitive fallback. +// This allows models to use CoreV1(), coreV1(), COREV1(), Pods(), pods(), PODS(), etc. +function __resolveProperty(obj, prop) { + // Try exact match first + if (prop in obj) { + return obj[prop]; + } + // Try lowercase version (e.g., CoreV1 -> coreV1) + const lowerFirst = prop.charAt(0).toLowerCase() + prop.slice(1); + if (lowerFirst in obj) { + return obj[lowerFirst]; + } + // Try uppercase version (e.g., coreV1 -> CoreV1) + const upperFirst = prop.charAt(0).toUpperCase() + prop.slice(1); + if (upperFirst in obj) { + return obj[upperFirst]; + } + // Try fully lowercase (e.g., List -> list, COREV1 -> corev1) + const lower = prop.toLowerCase(); + if (lower in obj) { + return obj[lower]; + } + // Try case-insensitive search through all keys (handles COREV1 -> coreV1) + const lowerProp = prop.toLowerCase(); + for (const key in obj) { + if (key.toLowerCase() === lowerProp) { + return obj[key]; + } + } + return undefined; +} + +// Convert Go objects to pure JavaScript objects. +// This is necessary because Go objects with custom types (like Quantity) don't +// work correctly with JavaScript operations. Uses Go's JSON marshaling which +// correctly handles MarshalJSON implementations. +function __toJS(obj) { + if (obj === null || obj === undefined) { + return obj; + } + // Use the Go-provided JSON conversion function + // The Go function returns (string, error), and Goja throws on error + if (typeof __goToJSON === 'function') { + const jsonStr = __goToJSON(obj); + const result = JSON.parse(jsonStr); + // Fix nil slices: Go marshals nil []T as null, but we need [] for JavaScript + // Common Kubernetes list fields that should be arrays + if (result && typeof result === 'object') { + if (result.items === null) { + result.items = []; + } + } + return result; + } + return obj; +} + +// Wrap a resource interface (e.g., pods(namespace)) to intercept methods. +// - Mutation methods: flatten metadata on input +// - Read methods: convert Go objects to pure JS on output +// - Chaining methods (namespace): wrap returned interface recursively +// Uses Proxy on an empty object to avoid Go native object invariant issues. +function __wrapResource(resource) { + return new Proxy({}, { + get(_, prop) { + const value = __resolveProperty(resource, prop); + if (typeof value === 'function') { + const lowerProp = prop.toLowerCase(); + // Intercept mutation methods to flatten metadata + if (__mutationMethods.map(function(m) { return m.toLowerCase(); }).indexOf(lowerProp) !== -1) { + return function() { + const args = []; + for (let i = 0; i < arguments.length; i++) { + const arg = arguments[i]; + // Skip ctx (first arg), flatten resources with metadata + if (i > 0 && arg && typeof arg === 'object' && arg.metadata) { + args.push(__flattenK8sObject(arg)); + } else { + args.push(arg); + } + } + const result = value.apply(resource, args); + // Also convert mutation results to pure JS + return __toJS(result); + }; + } + // Intercept read methods to convert output to pure JS + if (__readMethods.map(function(m) { return m.toLowerCase(); }).indexOf(lowerProp) !== -1) { + return function() { + const result = value.apply(resource, arguments); + return __toJS(result); + }; + } + // Intercept chaining methods (namespace) to wrap returned interface + // This is needed for dynamic client: resource(gvr).namespace(ns).list(...) + if (__chainingMethods.map(function(m) { return m.toLowerCase(); }).indexOf(lowerProp) !== -1) { + return function() { + const result = value.apply(resource, arguments); + return __wrapResource(result); + }; + } + // Pass through other methods unchanged + return function() { + return value.apply(resource, arguments); + }; + } + return value; + }, + ownKeys(_) { + return __getKeys(resource); + }, + getOwnPropertyDescriptor(_, prop) { + if (__resolveProperty(resource, prop) !== undefined) { + return { configurable: true, enumerable: true, value: __resolveProperty(resource, prop) }; + } + return undefined; + } + }); +} + +// Wrap a dynamic resource interface (from dynamicClient().resource(gvr)). +// Unlike typed resources, dynamic resources use *unstructured.Unstructured which preserves +// the nested metadata structure. We use __toUnstructured instead of __flattenK8sObject. +function __wrapDynamicResource(resource) { + return new Proxy({}, { + get(_, prop) { + const value = __resolveProperty(resource, prop); + if (typeof value === 'function') { + const lowerProp = prop.toLowerCase(); + // Intercept mutation methods to convert to unstructured + if (__mutationMethods.map(function(m) { return m.toLowerCase(); }).indexOf(lowerProp) !== -1) { + return function() { + const args = []; + for (let i = 0; i < arguments.length; i++) { + const arg = arguments[i]; + // Skip ctx (first arg), convert resources with metadata to unstructured + if (i > 0 && arg && typeof arg === 'object' && arg.metadata && typeof __toUnstructured === 'function') { + args.push(__toUnstructured(arg)); + } else { + args.push(arg); + } + } + const result = value.apply(resource, args); + return __toJS(result); + }; + } + // Intercept read methods to convert output to pure JS + if (__readMethods.map(function(m) { return m.toLowerCase(); }).indexOf(lowerProp) !== -1) { + return function() { + const result = value.apply(resource, arguments); + return __toJS(result); + }; + } + // Intercept chaining methods (namespace) to wrap returned interface + if (__chainingMethods.map(function(m) { return m.toLowerCase(); }).indexOf(lowerProp) !== -1) { + return function() { + const result = value.apply(resource, arguments); + return __wrapDynamicResource(result); + }; + } + // Pass through other methods unchanged + return function() { + return value.apply(resource, arguments); + }; + } + return value; + }, + ownKeys(_) { + return __getKeys(resource); + }, + getOwnPropertyDescriptor(_, prop) { + if (__resolveProperty(resource, prop) !== undefined) { + return { configurable: true, enumerable: true, value: __resolveProperty(resource, prop) }; + } + return undefined; + } + }); +} + +// Wrap a client interface (e.g., coreV1()) to wrap resource accessors. +// Also handles dynamicClient's resource(gvr) method specially since GVR needs conversion. +function __wrapClientInterface(client) { + return new Proxy({}, { + get(_, prop) { + const value = __resolveProperty(client, prop); + if (typeof value === 'function') { + const lowerProp = prop.toLowerCase(); + // Special handling for dynamicClient().resource(gvr) + // The GVR object needs to be converted using __gvr because + // schema.GroupVersionResource has no json tags. + // Use __wrapDynamicResource for proper unstructured handling. + if (lowerProp === 'resource' && typeof __gvr === 'function') { + return function(gvrObj) { + // Convert JS object {group, version, resource} to Go GVR struct + const group = gvrObj.group || gvrObj.Group || ''; + const version = gvrObj.version || gvrObj.Version || ''; + const resource = gvrObj.resource || gvrObj.Resource || ''; + const gvr = __gvr(group, version, resource); + const result = value.call(client, gvr); + return __wrapDynamicResource(result); + }; + } + // Resource accessor methods (pods, deployments, etc.) + return function(...args) { + const resource = value.apply(client, args); + return __wrapResource(resource); + }; + } + return value; + }, + ownKeys(_) { + return __getKeys(client); + }, + getOwnPropertyDescriptor(_, prop) { + if (__resolveProperty(client, prop) !== undefined) { + return { configurable: true, enumerable: true, value: __resolveProperty(client, prop) }; + } + return undefined; + } + }); +} + +// Wrap the main k8s client to wrap all API group accessors. +function __wrapK8sClient(rawK8s) { + return new Proxy({}, { + get(_, prop) { + const value = __resolveProperty(rawK8s, prop); + if (typeof value === 'function') { + // API group accessor methods (coreV1, appsV1, etc.) + return function(...args) { + const client = value.apply(rawK8s, args); + return __wrapClientInterface(client); + }; + } + return value; + }, + ownKeys(_) { + return __getKeys(rawK8s); + }, + getOwnPropertyDescriptor(_, prop) { + if (__resolveProperty(rawK8s, prop) !== undefined) { + return { configurable: true, enumerable: true, value: __resolveProperty(rawK8s, prop) }; + } + return undefined; + } + }); +} diff --git a/pkg/mcp/code_logs_test.go b/pkg/mcp/code_logs_test.go new file mode 100644 index 000000000..113e8ffeb --- /dev/null +++ b/pkg/mcp/code_logs_test.go @@ -0,0 +1,74 @@ +package mcp + +import ( + "net/http" + "testing" + + "github.com/containers/kubernetes-mcp-server/internal/test" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/suite" +) + +type CodeLogsSuite struct { + BaseMcpSuite + mockServer *test.MockServer + discoveryHandler *test.DiscoveryClientHandler +} + +func (s *CodeLogsSuite) SetupTest() { + s.BaseMcpSuite.SetupTest() + s.Cfg.Toolsets = []string{"code"} + s.mockServer = test.NewMockServer() + s.Cfg.KubeConfig = s.mockServer.KubeconfigFile(s.T()) + + s.discoveryHandler = test.NewDiscoveryClientHandler() + s.mockServer.Handle(s.discoveryHandler) + + // Mock the pod logs endpoint + s.mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if req.URL.Path == "/api/v1/namespaces/default/pods/test-pod/log" { + w.Header().Set("Content-Type", "text/plain") + _, _ = w.Write([]byte("2024-01-15T10:00:00Z Hello from test-pod\n2024-01-15T10:00:01Z Log line 2\n")) + return + } + })) +} + +func (s *CodeLogsSuite) TearDownTest() { + s.BaseMcpSuite.TearDownTest() + if s.mockServer != nil { + s.mockServer.Close() + } +} + +func (s *CodeLogsSuite) TestCodeEvaluatePodLogs() { + s.InitMcpClient() + + s.Run("evaluate_script retrieves pod logs", func() { + toolResult, err := s.CallTool("evaluate_script", map[string]interface{}{ + "script": ` + const pods = k8s.coreV1().pods("default"); + const logBytes = pods.getLogs("test-pod", {}).doRaw(ctx); + // Convert byte array to string + var logs = ""; + for (var i = 0; i < logBytes.length; i++) { + logs += String.fromCharCode(logBytes[i]); + } + logs; + `, + }) + s.Run("no error", func() { + s.Nilf(err, "call tool failed %v", err) + s.Falsef(toolResult.IsError, "call tool failed: %v", toolResult.Content) + }) + s.Run("returns log content", func() { + text := toolResult.Content[0].(*mcp.TextContent).Text + s.Contains(text, "Hello from test-pod") + s.Contains(text, "Log line 2") + }) + }) +} + +func TestCodeLogs(t *testing.T) { + suite.Run(t, new(CodeLogsSuite)) +} diff --git a/pkg/mcp/code_test.go b/pkg/mcp/code_test.go new file mode 100644 index 000000000..8dc848e46 --- /dev/null +++ b/pkg/mcp/code_test.go @@ -0,0 +1,815 @@ +package mcp + +import ( + "encoding/json" + "testing" + + "github.com/BurntSushi/toml" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/suite" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" +) + +type CodeSuite struct { + BaseMcpSuite +} + +func (s *CodeSuite) SetupTest() { + s.BaseMcpSuite.SetupTest() + // Enable the code toolset for these tests + s.Cfg.Toolsets = []string{"code"} +} + +func (s *CodeSuite) TestCodeEvaluateSDKGlobals() { + s.InitMcpClient() + + s.Run("evaluate_script(script=namespace)", func() { + toolResult, err := s.CallTool("evaluate_script", map[string]interface{}{ + "script": "namespace", + }) + s.Run("no error", func() { + s.Nilf(err, "call tool failed %v", err) + s.Falsef(toolResult.IsError, "call tool failed") + }) + s.Run("returns default namespace", func() { + s.Equal("default", toolResult.Content[0].(*mcp.TextContent).Text) + }) + }) + + s.Run("evaluate_script(script=typeof ctx)", func() { + toolResult, err := s.CallTool("evaluate_script", map[string]interface{}{ + "script": "typeof ctx", + }) + s.Run("no error", func() { + s.Nilf(err, "call tool failed %v", err) + s.Falsef(toolResult.IsError, "call tool failed") + }) + s.Run("ctx is an object", func() { + s.Equal("object", toolResult.Content[0].(*mcp.TextContent).Text) + }) + }) + + s.Run("evaluate_script(script=typeof k8s)", func() { + toolResult, err := s.CallTool("evaluate_script", map[string]interface{}{ + "script": "typeof k8s", + }) + s.Run("no error", func() { + s.Nilf(err, "call tool failed %v", err) + s.Falsef(toolResult.IsError, "call tool failed") + }) + s.Run("k8s is an object", func() { + s.Equal("object", toolResult.Content[0].(*mcp.TextContent).Text) + }) + }) + testCases := []string{"CoreV1", "coreV1", "COREV1", "AppsV1", "APPSV1", "DiscoveryClient", "DynamicClient", "MetricsV1beta1Client"} + for _, methodName := range testCases { + s.Run("evaluate_script(script=typeof k8s."+methodName+")", func() { + toolResult, err := s.CallTool("evaluate_script", map[string]interface{}{ + "script": "typeof k8s." + methodName, + }) + s.Run("no error", func() { + s.Nilf(err, "call tool failed %v", err) + s.Falsef(toolResult.IsError, "call tool failed") + }) + s.Run("coreV1 is a function", func() { + s.Equal("function", toolResult.Content[0].(*mcp.TextContent).Text) + }) + }) + } +} + +func (s *CodeSuite) TestCodeEvaluateSDKIntrospection() { + s.InitMcpClient() + + s.Run("evaluate_script (discover k8s client methods)", func() { + // Demonstrates how LLMs can discover available API clients on the k8s object + toolResult, err := s.CallTool("evaluate_script", map[string]interface{}{ + "script": ` + const methods = []; + for (const key in k8s) { + if (typeof k8s[key] === 'function') { + methods.push(key); + } + } + JSON.stringify(methods.sort()); + `, + }) + s.Run("no error", func() { + s.Nilf(err, "call tool failed %v", err) + s.Falsef(toolResult.IsError, "call tool failed: %v", toolResult.Content) + }) + s.Run("returns available clients", func() { + var methods []string + text := toolResult.Content[0].(*mcp.TextContent).Text + err := json.Unmarshal([]byte(text), &methods) + s.NoError(err, "result should be valid JSON array") + // Method names are lowercase due to JSON tag field mapping + s.Contains(methods, "coreV1", "should include coreV1") + s.Contains(methods, "appsV1", "should include appsV1") + s.Contains(methods, "discoveryClient", "should include discoveryClient") + s.Contains(methods, "dynamicClient", "should include dynamicClient") + s.Contains(methods, "metricsV1beta1Client", "should include metricsV1beta1Client") + }) + }) + + s.Run("evaluate_script (discover CoreV1 resources)", func() { + // Demonstrates how LLMs can discover available resources on a client + toolResult, err := s.CallTool("evaluate_script", map[string]interface{}{ + "script": ` + const coreV1 = k8s.coreV1(); + const resources = []; + for (const key in coreV1) { + if (typeof coreV1[key] === 'function') { + resources.push(key); + } + } + JSON.stringify(resources.sort()); + `, + }) + s.Run("no error", func() { + s.Nilf(err, "call tool failed %v", err) + s.Falsef(toolResult.IsError, "call tool failed: %v", toolResult.Content) + }) + s.Run("returns available resources", func() { + var resources []string + text := toolResult.Content[0].(*mcp.TextContent).Text + err := json.Unmarshal([]byte(text), &resources) + s.NoError(err, "result should be valid JSON array") + // Method names are lowercase due to JSON tag field mapping + s.Contains(resources, "pods", "should include pods") + s.Contains(resources, "services", "should include services") + s.Contains(resources, "namespaces", "should include namespaces") + s.Contains(resources, "configMaps", "should include configMaps") + s.Contains(resources, "secrets", "should include secrets") + }) + }) + + s.Run("evaluate_script (discover Pod operations)", func() { + // Demonstrates how LLMs can discover available operations on a resource + toolResult, err := s.CallTool("evaluate_script", map[string]interface{}{ + "script": ` + const pods = k8s.coreV1().pods(namespace); + const operations = []; + for (const key in pods) { + if (typeof pods[key] === 'function') { + operations.push(key); + } + } + JSON.stringify(operations.sort()); + `, + }) + s.Run("no error", func() { + s.Nilf(err, "call tool failed %v", err) + s.Falsef(toolResult.IsError, "call tool failed: %v", toolResult.Content) + }) + s.Run("returns available operations", func() { + var operations []string + text := toolResult.Content[0].(*mcp.TextContent).Text + err := json.Unmarshal([]byte(text), &operations) + s.NoError(err, "result should be valid JSON array") + // Method names are lowercase due to JSON tag field mapping + s.Contains(operations, "list", "should include list") + s.Contains(operations, "get", "should include get") + s.Contains(operations, "create", "should include create") + s.Contains(operations, "delete", "should include delete") + }) + }) +} + +// TestCodeEvaluateAPICompliance verifies that the JavaScript k8s object exposes all methods +// from the api.KubernetesClient interface. This ensures backward compatibility: scripts +// written for a previous version of kubernetes-mcp-server will work in future versions. +func (s *CodeSuite) TestCodeEvaluateAPICompliance() { + s.InitMcpClient() + + // These are the methods from api.KubernetesClient interface that should be exposed to JavaScript. + // If this test fails after adding new methods to KubernetesClient, add them here. + // If this test fails after removing methods, scripts using those methods will break. + requiredMethods := []string{ + // From kubernetes.Interface (typed clients) + "coreV1", + "appsV1", + "batchV1", + "networkingV1", + "rbacV1", + // From api.KubernetesClient + "namespaceOrDefault", + "discoveryClient", + "dynamicClient", + "metricsV1beta1Client", + } + + s.Run("evaluate_script (API compliance check)", func() { + toolResult, err := s.CallTool("evaluate_script", map[string]interface{}{ + "script": ` + const methods = []; + for (const key in k8s) { + if (typeof k8s[key] === 'function') { + methods.push(key); + } + } + JSON.stringify(methods); + `, + }) + s.Run("no error", func() { + s.Nilf(err, "call tool failed %v", err) + s.Falsef(toolResult.IsError, "call tool failed: %v", toolResult.Content) + }) + s.Run("exposes all required api.KubernetesClient methods", func() { + var methods []string + text := toolResult.Content[0].(*mcp.TextContent).Text + err := json.Unmarshal([]byte(text), &methods) + s.NoError(err, "result should be valid JSON array") + + for _, required := range requiredMethods { + s.Contains(methods, required, + "JavaScript k8s object must expose '%s' for API compliance", required) + } + }) + }) +} + +func (s *CodeSuite) TestCodeEvaluateErrors() { + s.InitMcpClient() + + s.Run("evaluate_script (missing script parameter)", func() { + toolResult, _ := s.CallTool("evaluate_script", map[string]interface{}{}) + s.Run("returns error", func() { + s.Truef(toolResult.IsError, "call tool should fail") + s.Contains(toolResult.Content[0].(*mcp.TextContent).Text, "script parameter required") + }) + }) + + s.Run("evaluate_script(script=)", func() { + toolResult, _ := s.CallTool("evaluate_script", map[string]interface{}{ + "script": "", + }) + s.Run("returns error", func() { + s.Truef(toolResult.IsError, "call tool should fail") + s.Contains(toolResult.Content[0].(*mcp.TextContent).Text, "script cannot be empty") + }) + }) + + s.Run("evaluate_script (syntax error)", func() { + toolResult, _ := s.CallTool("evaluate_script", map[string]interface{}{ + "script": "function(", + }) + s.Run("returns error", func() { + s.Truef(toolResult.IsError, "call tool should fail") + s.Contains(toolResult.Content[0].(*mcp.TextContent).Text, "SyntaxError") + }) + }) + + s.Run("evaluate_script (runtime error)", func() { + toolResult, _ := s.CallTool("evaluate_script", map[string]interface{}{ + "script": "throw new Error('test error message')", + }) + s.Run("returns error", func() { + s.Truef(toolResult.IsError, "call tool should fail") + s.Contains(toolResult.Content[0].(*mcp.TextContent).Text, "script error") + s.Contains(toolResult.Content[0].(*mcp.TextContent).Text, "test error message") + }) + }) + + s.Run("evaluate_script (undefined variable)", func() { + toolResult, _ := s.CallTool("evaluate_script", map[string]interface{}{ + "script": "undefinedVariable.property", + }) + s.Run("returns error", func() { + s.Truef(toolResult.IsError, "call tool should fail") + }) + }) +} + +func (s *CodeSuite) TestCodeEvaluateNullAndUndefined() { + s.InitMcpClient() + + s.Run("evaluate_script(script=undefined)", func() { + toolResult, err := s.CallTool("evaluate_script", map[string]interface{}{ + "script": "undefined", + }) + s.Run("no error", func() { + s.Nilf(err, "call tool failed %v", err) + s.Falsef(toolResult.IsError, "call tool failed") + }) + s.Run("returns empty string", func() { + s.Equal("", toolResult.Content[0].(*mcp.TextContent).Text) + }) + }) + + s.Run("evaluate_script(script=null)", func() { + toolResult, err := s.CallTool("evaluate_script", map[string]interface{}{ + "script": "null", + }) + s.Run("no error", func() { + s.Nilf(err, "call tool failed %v", err) + s.Falsef(toolResult.IsError, "call tool failed") + }) + s.Run("returns empty string", func() { + s.Equal("", toolResult.Content[0].(*mcp.TextContent).Text) + }) + }) +} + +func (s *CodeSuite) TestCodeEvaluateKubernetesIntegration() { + s.InitMcpClient() + + s.Run("evaluate_script (list namespaces via k8s client)", func() { + toolResult, err := s.CallTool("evaluate_script", map[string]interface{}{ + "script": ` + const namespaces = k8s.coreV1().namespaces().list(ctx, {}); + JSON.stringify(namespaces.items.map(ns => ns.metadata.name)); + `, + }) + s.Run("no error", func() { + s.Nilf(err, "call tool failed %v", err) + s.Falsef(toolResult.IsError, "call tool failed: %v", toolResult.Content) + }) + s.Run("returns namespace names", func() { + var namespaces []string + text := toolResult.Content[0].(*mcp.TextContent).Text + err := json.Unmarshal([]byte(text), &namespaces) + s.NoError(err, "result should be valid JSON array") + s.Contains(namespaces, "default", "should include default namespace") + }) + }) + + s.Run("evaluate_script (list pods and transform output)", func() { + toolResult, err := s.CallTool("evaluate_script", map[string]interface{}{ + "script": ` + const pods = k8s.coreV1().pods("").list(ctx, {}); + const result = pods.items.map(p => ({ + name: p.metadata.name, + namespace: p.metadata.namespace + })); + JSON.stringify(result); + `, + }) + s.Run("no error", func() { + s.Nilf(err, "call tool failed %v", err) + s.Falsef(toolResult.IsError, "call tool failed: %v", toolResult.Content) + }) + s.Run("returns transformed pod data", func() { + var pods []map[string]interface{} + text := toolResult.Content[0].(*mcp.TextContent).Text + err := json.Unmarshal([]byte(text), &pods) + s.NoError(err, "result should be valid JSON array") + s.GreaterOrEqual(len(pods), 1, "should have at least one pod") + if len(pods) > 0 { + s.Contains(pods[0], "name", "should have name field") + s.Contains(pods[0], "namespace", "should have namespace field") + } + }) + }) + + s.Run("evaluate_script (filter and aggregate pods)", func() { + // Demonstrates the value of code evaluation: complex filtering and aggregation + // that would be inefficient with individual tool calls + toolResult, err := s.CallTool("evaluate_script", map[string]interface{}{ + "script": ` + const pods = k8s.coreV1().pods("").list(ctx, {}); + const summary = { + total: pods.items.length, + byNamespace: {} + }; + pods.items.forEach(p => { + const ns = p.metadata.namespace; + summary.byNamespace[ns] = (summary.byNamespace[ns] || 0) + 1; + }); + JSON.stringify(summary); + `, + }) + s.Run("no error", func() { + s.Nilf(err, "call tool failed %v", err) + s.Falsef(toolResult.IsError, "call tool failed: %v", toolResult.Content) + }) + s.Run("returns aggregated summary", func() { + var summary map[string]interface{} + text := toolResult.Content[0].(*mcp.TextContent).Text + err := json.Unmarshal([]byte(text), &summary) + s.NoError(err, "result should be valid JSON object") + s.Contains(summary, "total", "should have total field") + s.Contains(summary, "byNamespace", "should have byNamespace field") + }) + }) + + s.Run("evaluate_script (uppercase method names)", func() { + // Models typically use uppercase method names like CoreV1(), Pods(), List() + // because that's what they know from Kubernetes client documentation. + // This test verifies the case-insensitive method resolution works. + toolResult, err := s.CallTool("evaluate_script", map[string]interface{}{ + "script": ` + const namespaces = k8s.CoreV1().Namespaces().List(ctx, {}); + JSON.stringify(namespaces.items.map(ns => ns.metadata.name)); + `, + }) + s.Run("no error", func() { + s.Nilf(err, "call tool failed %v", err) + s.Falsef(toolResult.IsError, "call tool failed: %v", toolResult.Content) + }) + s.Run("returns namespace names", func() { + var namespaces []string + text := toolResult.Content[0].(*mcp.TextContent).Text + err := json.Unmarshal([]byte(text), &namespaces) + s.NoError(err, "result should be valid JSON array") + s.Contains(namespaces, "default", "should include default namespace") + }) + }) + + s.Run("evaluate_script (combine multiple API calls with JavaScript)", func() { + // This test demonstrates the power of code evaluation: combining data from + // multiple Kubernetes API calls using JavaScript array methods (flatMap, filter, map). + // This would be inefficient or impossible with individual tool calls. + toolResult, err := s.CallTool("evaluate_script", map[string]interface{}{ + "script": ` + // Get all namespaces and for each, get its pods and configmaps count + const namespaces = k8s.coreV1().namespaces().list(ctx, {}); + const result = namespaces.items.flatMap(ns => { + const nsName = ns.metadata.name; + const pods = k8s.coreV1().pods(nsName).list(ctx, {}); + const configMaps = k8s.coreV1().configMaps(nsName).list(ctx, {}); + return [{ + namespace: nsName, + podCount: pods.items.length, + configMapCount: configMaps.items.length, + podNames: pods.items.map(p => p.metadata.name) + }]; + }); + JSON.stringify(result); + `, + }) + s.Run("no error", func() { + s.Nilf(err, "call tool failed %v", err) + s.Falsef(toolResult.IsError, "call tool failed: %v", toolResult.Content) + }) + s.Run("returns combined namespace data", func() { + var result []map[string]interface{} + text := toolResult.Content[0].(*mcp.TextContent).Text + err := json.Unmarshal([]byte(text), &result) + s.NoError(err, "result should be valid JSON array") + s.GreaterOrEqual(len(result), 1, "should have at least one namespace") + // Find the default namespace in results + var defaultNs map[string]interface{} + for _, ns := range result { + if ns["namespace"] == "default" { + defaultNs = ns + break + } + } + s.NotNil(defaultNs, "should include default namespace") + s.Contains(defaultNs, "podCount", "should have podCount") + s.Contains(defaultNs, "configMapCount", "should have configMapCount") + s.Contains(defaultNs, "podNames", "should have podNames array") + }) + }) +} + +func (s *CodeSuite) TestCodeEvaluateResourceCreation() { + s.InitMcpClient() + kc := kubernetes.NewForConfigOrDie(envTestRestConfig) + + s.Run("evaluate_script (create pod)", func() { + podName := "code-evaluate-code-created-pod" + // Create a Pod using JavaScript with standard Kubernetes YAML/JSON structure. + // The JavaScript Proxy transparently converts the standard structure (with metadata wrapper) + // to the format expected by Go's Kubernetes client structs - no helper function needed. + toolResult, err := s.CallTool("evaluate_script", map[string]interface{}{ + "script": ` + const pod = { + apiVersion: "v1", + kind: "Pod", + metadata: { + name: "code-evaluate-code-created-pod", + namespace: namespace, + labels: { + "app": "test", + "created-by": "code-evaluate" + } + }, + spec: { + containers: [{ + name: "nginx", + image: "nginx:latest" + }] + } + }; + const created = k8s.coreV1().pods(namespace).create(ctx, pod, {}); + JSON.stringify({ + name: created.metadata.name, + namespace: created.metadata.namespace, + labels: created.metadata.labels + }); + `, + }) + s.T().Cleanup(func() { _ = kc.CoreV1().Pods("default").Delete(s.T().Context(), podName, metav1.DeleteOptions{}) }) + s.Run("no error", func() { + s.Nilf(err, "call tool failed %v", err) + s.Falsef(toolResult.IsError, "call tool failed: %v", toolResult.Content) + }) + s.Run("returns created pod info", func() { + var result map[string]interface{} + text := toolResult.Content[0].(*mcp.TextContent).Text + err := json.Unmarshal([]byte(text), &result) + s.NoError(err, "result should be valid JSON object") + s.Equal(podName, result["name"], "should return created pod name") + s.Equal("default", result["namespace"], "should return pod namespace") + }) + s.Run("pod exists in cluster", func() { + // Verify the pod was actually created in the cluster + pod, err := kc.CoreV1().Pods("default").Get(s.T().Context(), podName, metav1.GetOptions{}) + s.NoError(err, "pod should exist in cluster") + s.Equal(podName, pod.Name, "pod name should match") + s.Equal("test", pod.Labels["app"], "pod should have app label") + s.Equal("code-evaluate", pod.Labels["created-by"], "pod should have created-by label") + s.Len(pod.Spec.Containers, 1, "pod should have one container") + s.Equal("nginx", pod.Spec.Containers[0].Name, "container name should be nginx") + s.Equal("nginx:latest", pod.Spec.Containers[0].Image, "container image should be nginx:latest") + }) + }) +} + +func (s *CodeSuite) TestCodeEvaluateDynamicClient() { + s.InitMcpClient() + kc := kubernetes.NewForConfigOrDie(envTestRestConfig) + + s.Run("evaluate_script (list pods with dynamic client)", func() { + // Use dynamic client to list pods in the default namespace + // GVR fields can be lowercase or uppercase - the proxy handles both + toolResult, err := s.CallTool("evaluate_script", map[string]interface{}{ + "script": ` + const gvr = {group: "", version: "v1", resource: "pods"}; + const pods = k8s.dynamicClient().resource(gvr).namespace("default").list(ctx, {}); + JSON.stringify(pods.items.map(p => p.metadata.name)); + `, + }) + s.Run("no error", func() { + s.Nilf(err, "call tool failed %v", err) + s.Falsef(toolResult.IsError, "call tool failed: %v", toolResult.Content) + }) + s.Run("returns pod names array", func() { + var names []string + text := toolResult.Content[0].(*mcp.TextContent).Text + err := json.Unmarshal([]byte(text), &names) + s.NoError(err, "result should be valid JSON array") + }) + }) + + s.Run("evaluate_script (create configmap with dynamic client)", func() { + configMapName := "code-dynamic-client-test-cm" + toolResult, err := s.CallTool("evaluate_script", map[string]interface{}{ + "script": ` + const gvr = {group: "", version: "v1", resource: "configmaps"}; + const cm = { + apiVersion: "v1", + kind: "ConfigMap", + metadata: { + name: "code-dynamic-client-test-cm", + namespace: "default" + }, + data: {"key": "value"} + }; + const created = k8s.dynamicClient().resource(gvr).namespace("default").create(ctx, cm, {}); + created.metadata.name; + `, + }) + s.T().Cleanup(func() { + _ = kc.CoreV1().ConfigMaps("default").Delete(s.T().Context(), configMapName, metav1.DeleteOptions{}) + }) + s.Run("no error", func() { + s.Nilf(err, "call tool failed %v", err) + s.Falsef(toolResult.IsError, "call tool failed: %v", toolResult.Content) + }) + s.Run("returns created configmap name", func() { + text := toolResult.Content[0].(*mcp.TextContent).Text + s.Equal(configMapName, text) + }) + s.Run("configmap exists in cluster", func() { + cm, err := kc.CoreV1().ConfigMaps("default").Get(s.T().Context(), configMapName, metav1.GetOptions{}) + s.NoError(err, "configmap should exist") + s.Equal("value", cm.Data["key"]) + }) + }) +} + +func (s *CodeSuite) TestCodeEvaluatePatch() { + s.InitMcpClient() + kc := kubernetes.NewForConfigOrDie(envTestRestConfig) + + configMapName := "code-patch-test-cm" + + // Create a configmap first + _, err := kc.CoreV1().ConfigMaps("default").Create(s.T().Context(), &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: configMapName, + Namespace: "default", + }, + Data: map[string]string{"original": "value"}, + }, metav1.CreateOptions{}) + s.Require().NoError(err, "failed to create test configmap") + s.T().Cleanup(func() { + _ = kc.CoreV1().ConfigMaps("default").Delete(s.T().Context(), configMapName, metav1.DeleteOptions{}) + }) + + s.Run("evaluate_script (patch configmap with merge patch)", func() { + toolResult, err := s.CallTool("evaluate_script", map[string]interface{}{ + "script": ` + const patchData = JSON.stringify({data: {patched: "new-value"}}); + // Convert string to byte array for patch + const bytes = []; + for (var i = 0; i < patchData.length; i++) { + bytes.push(patchData.charCodeAt(i)); + } + const patched = k8s.coreV1().configMaps("default").patch( + ctx, + "code-patch-test-cm", + "application/merge-patch+json", + bytes, + {} + ); + JSON.stringify({ + name: patched.metadata.name, + data: patched.data + }); + `, + }) + s.Run("no error", func() { + s.Nilf(err, "call tool failed %v", err) + if toolResult.IsError { + s.T().Logf("Patch error: %v", toolResult.Content) + } + s.Falsef(toolResult.IsError, "call tool failed: %v", toolResult.Content) + }) + s.Run("returns patched configmap", func() { + text := toolResult.Content[0].(*mcp.TextContent).Text + s.Contains(text, "patched") + s.Contains(text, "new-value") + }) + s.Run("configmap was updated in cluster", func() { + cm, err := kc.CoreV1().ConfigMaps("default").Get(s.T().Context(), configMapName, metav1.GetOptions{}) + s.NoError(err) + s.Equal("new-value", cm.Data["patched"]) + }) + }) +} + +func (s *CodeSuite) TestCodeEvaluateDiscoveryClient() { + s.InitMcpClient() + + s.Run("evaluate_script (list server groups)", func() { + // Use discovery client to list available API groups + toolResult, err := s.CallTool("evaluate_script", map[string]interface{}{ + "script": ` + const groups = k8s.discoveryClient().serverGroups(); + JSON.stringify(groups.groups.map(g => g.name)); + `, + }) + s.Run("no error", func() { + s.Nilf(err, "call tool failed %v", err) + s.Falsef(toolResult.IsError, "call tool failed: %v", toolResult.Content) + }) + s.Run("returns API group names", func() { + var groups []string + text := toolResult.Content[0].(*mcp.TextContent).Text + err := json.Unmarshal([]byte(text), &groups) + s.NoError(err, "result should be valid JSON array") + // Core API group is represented as empty string + s.Contains(groups, "", "should include core API group (empty string)") + }) + }) + + s.Run("evaluate_script (get server resources for core API)", func() { + // Use discovery client to list resources in the core API group + toolResult, err := s.CallTool("evaluate_script", map[string]interface{}{ + "script": ` + const resources = k8s.discoveryClient().serverResourcesForGroupVersion("v1"); + JSON.stringify(resources.resources.map(r => r.name).sort()); + `, + }) + s.Run("no error", func() { + s.Nilf(err, "call tool failed %v", err) + s.Falsef(toolResult.IsError, "call tool failed: %v", toolResult.Content) + }) + s.Run("returns core API resources", func() { + var resources []string + text := toolResult.Content[0].(*mcp.TextContent).Text + err := json.Unmarshal([]byte(text), &resources) + s.NoError(err, "result should be valid JSON array") + s.Contains(resources, "pods", "should include pods") + s.Contains(resources, "services", "should include services") + s.Contains(resources, "configmaps", "should include configmaps") + s.Contains(resources, "namespaces", "should include namespaces") + }) + }) + + s.Run("evaluate_script (get server version)", func() { + // Use discovery client to get the server version + toolResult, err := s.CallTool("evaluate_script", map[string]interface{}{ + "script": ` + const version = k8s.discoveryClient().serverVersion(); + JSON.stringify({ + major: version.major, + minor: version.minor, + platform: version.platform + }); + `, + }) + s.Run("no error", func() { + s.Nilf(err, "call tool failed %v", err) + s.Falsef(toolResult.IsError, "call tool failed: %v", toolResult.Content) + }) + s.Run("returns server version info", func() { + var version map[string]interface{} + text := toolResult.Content[0].(*mcp.TextContent).Text + err := json.Unmarshal([]byte(text), &version) + s.NoError(err, "result should be valid JSON object") + s.Contains(version, "major", "should have major version") + s.Contains(version, "minor", "should have minor version") + }) + }) + + s.Run("evaluate_script (introspect discovery client)", func() { + // Demonstrates how LLMs can discover available methods on the discovery client + toolResult, err := s.CallTool("evaluate_script", map[string]interface{}{ + "script": ` + const dc = k8s.discoveryClient(); + const methods = []; + for (const key in dc) { + if (typeof dc[key] === 'function') { + methods.push(key); + } + } + JSON.stringify(methods.sort()); + `, + }) + s.Run("no error", func() { + s.Nilf(err, "call tool failed %v", err) + s.Falsef(toolResult.IsError, "call tool failed: %v", toolResult.Content) + }) + s.Run("returns available methods", func() { + var methods []string + text := toolResult.Content[0].(*mcp.TextContent).Text + err := json.Unmarshal([]byte(text), &methods) + s.NoError(err, "result should be valid JSON array") + s.Contains(methods, "serverGroups", "should include serverGroups") + s.Contains(methods, "serverVersion", "should include serverVersion") + }) + }) +} + +func (s *CodeSuite) TestCodeEvaluateDenied() { + s.Require().NoError(toml.Unmarshal([]byte(` + denied_resources = [ { version = "v1", kind = "Namespace" } ] + `), s.Cfg), "Expected to parse denied resources config") + s.InitMcpClient() + + s.Run("evaluate_script (denied namespace access)", func() { + toolResult, err := s.CallTool("evaluate_script", map[string]interface{}{ + "script": ` + const namespaces = k8s.coreV1().namespaces().list(ctx, {}); + JSON.stringify(namespaces.items.map(ns => ns.metadata.name)); + `, + }) + s.Run("has error", func() { + s.Truef(toolResult.IsError, "call tool should fail") + s.Nilf(err, "call tool should not return error object") + }) + s.Run("describes denial", func() { + msg := toolResult.Content[0].(*mcp.TextContent).Text + s.Contains(msg, "resource not allowed:") + expectedMessage := "(.+:)? resource not allowed: /v1, Kind=Namespace" + s.Regexpf(expectedMessage, msg, + "expected descriptive error '%s', got %v", expectedMessage, msg) + }) + }) +} + +func (s *CodeSuite) TestCodeEvaluateForbidden() { + s.InitMcpClient() + s.T().Cleanup(func() { restoreAuth(s.T().Context()) }) + client := kubernetes.NewForConfigOrDie(envTestRestConfig) + // Remove all permissions - user will have forbidden access + s.Require().NoError(client.RbacV1().ClusterRoles().Delete(s.T().Context(), "allow-all", metav1.DeleteOptions{})) + + s.Run("evaluate_script (forbidden namespace access)", func() { + toolResult, err := s.CallTool("evaluate_script", map[string]interface{}{ + "script": ` + const namespaces = k8s.coreV1().namespaces().list(ctx, {}); + JSON.stringify(namespaces.items.map(ns => ns.metadata.name)); + `, + }) + s.Run("has error", func() { + s.Truef(toolResult.IsError, "call tool should fail") + s.Nilf(err, "call tool should not return error object") + }) + s.Run("describes forbidden", func() { + s.Contains(toolResult.Content[0].(*mcp.TextContent).Text, "namespaces is forbidden", + "error message should indicate forbidden") + }) + }) +} + +func TestCode(t *testing.T) { + suite.Run(t, new(CodeSuite)) +} diff --git a/pkg/mcp/code_top_test.go b/pkg/mcp/code_top_test.go new file mode 100644 index 000000000..9fa2c6366 --- /dev/null +++ b/pkg/mcp/code_top_test.go @@ -0,0 +1,331 @@ +package mcp + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/containers/kubernetes-mcp-server/internal/test" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/suite" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// CodeTopSuite tests the code evaluation tool's ability to retrieve metrics from the metrics server. +// This is a separate suite because it requires mocking the metrics API which is not available in envtest. +type CodeTopSuite struct { + BaseMcpSuite + mockServer *test.MockServer + discoveryHandler *test.DiscoveryClientHandler +} + +func (s *CodeTopSuite) SetupTest() { + s.BaseMcpSuite.SetupTest() + // Enable the code toolset for these tests + s.Cfg.Toolsets = []string{"code"} + s.mockServer = test.NewMockServer() + s.Cfg.KubeConfig = s.mockServer.KubeconfigFile(s.T()) + + s.discoveryHandler = test.NewDiscoveryClientHandler() + s.mockServer.Handle(s.discoveryHandler) +} + +func (s *CodeTopSuite) TearDownTest() { + s.BaseMcpSuite.TearDownTest() + if s.mockServer != nil { + s.mockServer.Close() + } +} + +func (s *CodeTopSuite) TestCodeEvaluateMetricsUnavailable() { + s.InitMcpClient() + + s.Run("evaluate_script with metrics API not available", func() { + toolResult, err := s.CallTool("evaluate_script", map[string]interface{}{ + "script": ` + const metrics = k8s.metricsV1beta1Client(); + const podMetrics = metrics.podMetricses("").list(ctx, {}); + JSON.stringify(podMetrics.items); + `, + }) + s.NoError(err, "call tool failed %v", err) + s.Require().NotNil(toolResult) + s.True(toolResult.IsError, "call tool should have returned an error") + errorText := toolResult.Content[0].(*mcp.TextContent).Text + s.Contains(errorText, "Resource pods.metrics.k8s.io does not exist in the cluster", "error should indicate metrics API is not available") + }) +} + +func (s *CodeTopSuite) TestCodeEvaluateMetricsAvailable() { + // Register the metrics API + s.discoveryHandler.AddAPIResourceList(metav1.APIResourceList{ + GroupVersion: "metrics.k8s.io/v1beta1", + APIResources: []metav1.APIResource{ + {Name: "pods", Kind: "PodMetrics", Namespaced: true, Verbs: metav1.Verbs{"get", "list"}}, + {Name: "nodes", Kind: "NodeMetrics", Namespaced: false, Verbs: metav1.Verbs{"get", "list"}}, + }, + }) + + // Mock the metrics endpoints + s.mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + + // Pod Metrics from all namespaces + if req.URL.Path == "/apis/metrics.k8s.io/v1beta1/pods" { + _, _ = w.Write([]byte(`{ + "kind": "PodMetricsList", + "apiVersion": "metrics.k8s.io/v1beta1", + "items": [ + { + "metadata": {"name": "nginx-pod", "namespace": "default"}, + "timestamp": "2024-01-15T10:00:00Z", + "window": "30s", + "containers": [ + {"name": "nginx", "usage": {"cpu": "100m", "memory": "128Mi"}} + ] + }, + { + "metadata": {"name": "redis-pod", "namespace": "cache"}, + "timestamp": "2024-01-15T10:00:00Z", + "window": "30s", + "containers": [ + {"name": "redis", "usage": {"cpu": "50m", "memory": "64Mi"}} + ] + } + ] + }`)) + return + } + + // Pod Metrics from specific namespace + if req.URL.Path == "/apis/metrics.k8s.io/v1beta1/namespaces/default/pods" { + _, _ = w.Write([]byte(`{ + "kind": "PodMetricsList", + "apiVersion": "metrics.k8s.io/v1beta1", + "items": [ + { + "metadata": {"name": "nginx-pod", "namespace": "default"}, + "timestamp": "2024-01-15T10:00:00Z", + "window": "30s", + "containers": [ + {"name": "nginx", "usage": {"cpu": "100m", "memory": "128Mi"}} + ] + } + ] + }`)) + return + } + + // Node Metrics + if req.URL.Path == "/apis/metrics.k8s.io/v1beta1/nodes" { + _, _ = w.Write([]byte(`{ + "kind": "NodeMetricsList", + "apiVersion": "metrics.k8s.io/v1beta1", + "items": [ + { + "metadata": {"name": "node-1"}, + "timestamp": "2024-01-15T10:00:00Z", + "window": "30s", + "usage": {"cpu": "500m", "memory": "2Gi"} + }, + { + "metadata": {"name": "node-2"}, + "timestamp": "2024-01-15T10:00:00Z", + "window": "30s", + "usage": {"cpu": "750m", "memory": "3Gi"} + } + ] + }`)) + return + } + })) + + s.InitMcpClient() + + s.Run("lists pod metrics", func() { + s.Run("evaluate_script from all namespaces", func() { + toolResult, err := s.CallTool("evaluate_script", map[string]interface{}{ + "script": ` + var metrics = k8s.metricsV1beta1Client(); + var podMetrics = metrics.podMetricses("").list(ctx, {}); + var result = podMetrics.items.map(function(pm) { + return { + name: pm.metadata.name, + namespace: pm.metadata.namespace, + containerCount: pm.containers.length, + firstContainer: pm.containers.length > 0 ? pm.containers[0].name : null + }; + }); + JSON.stringify(result); + `, + }) + s.Run("no error", func() { + s.NoError(err) + s.Require().NotNil(toolResult) + s.False(toolResult.IsError, "call tool failed: %v", toolResult.Content) + }) + s.Run("returns structured output", func() { + var result []map[string]interface{} + text := toolResult.Content[0].(*mcp.TextContent).Text + s.Require().NoError(json.Unmarshal([]byte(text), &result)) + + s.Run("with expected pod count", func() { + s.Len(result, 2) + }) + s.Run("with nginx-pod metadata", func() { + var nginxPod map[string]interface{} + for _, pod := range result { + if pod["name"] == "nginx-pod" { + nginxPod = pod + break + } + } + s.Require().NotNil(nginxPod, "should include nginx-pod") + s.Equal("default", nginxPod["namespace"]) + s.Equal(float64(1), nginxPod["containerCount"]) + s.Equal("nginx", nginxPod["firstContainer"]) + }) + }) + }) + + s.Run("evaluate_script from specific namespace", func() { + toolResult, err := s.CallTool("evaluate_script", map[string]interface{}{ + "script": ` + const metrics = k8s.metricsV1beta1Client(); + const podMetrics = metrics.podMetricses("default").list(ctx, {}); + JSON.stringify(podMetrics.items.map(pm => pm.metadata.name)); + `, + }) + s.Run("no error", func() { + s.NoError(err) + s.Require().NotNil(toolResult) + s.False(toolResult.IsError, "call tool failed: %v", toolResult.Content) + }) + s.Run("returns structured output", func() { + var names []string + text := toolResult.Content[0].(*mcp.TextContent).Text + s.Require().NoError(json.Unmarshal([]byte(text), &names)) + + s.Run("with only default namespace pods", func() { + s.Len(names, 1) + s.Equal("nginx-pod", names[0]) + }) + }) + }) + }) + + s.Run("evaluate_script lists node metrics", func() { + toolResult, err := s.CallTool("evaluate_script", map[string]interface{}{ + "script": ` + var metrics = k8s.metricsV1beta1Client(); + var nodeMetrics = metrics.nodeMetricses().list(ctx, {}); + var result = nodeMetrics.items.map(function(nm) { + return { + name: nm.metadata.name, + hasUsage: nm.usage !== undefined && nm.usage !== null + }; + }); + JSON.stringify(result); + `, + }) + s.Run("no error", func() { + s.NoError(err) + s.Require().NotNil(toolResult) + s.False(toolResult.IsError, "call tool failed: %v", toolResult.Content) + }) + s.Run("returns structured output", func() { + var result []map[string]interface{} + text := toolResult.Content[0].(*mcp.TextContent).Text + s.Require().NoError(json.Unmarshal([]byte(text), &result)) + + s.Run("with expected node count", func() { + s.Len(result, 2) + }) + s.Run("with node-1 usage data", func() { + var node1 map[string]interface{} + for _, node := range result { + if node["name"] == "node-1" { + node1 = node + break + } + } + s.Require().NotNil(node1, "should include node-1") + s.True(node1["hasUsage"].(bool)) + }) + }) + }) + + s.Run("evaluate_script aggregates metrics with JavaScript", func() { + toolResult, err := s.CallTool("evaluate_script", map[string]interface{}{ + "script": ` + var metrics = k8s.metricsV1beta1Client(); + var podMetrics = metrics.podMetricses("").list(ctx, {}); + + var totalContainers = 0; + podMetrics.items.forEach(function(pm) { + totalContainers += pm.containers.length; + }); + + JSON.stringify({ + podCount: podMetrics.items.length, + totalContainers: totalContainers + }); + `, + }) + s.Run("no error", func() { + s.NoError(err) + s.Require().NotNil(toolResult) + s.False(toolResult.IsError, "call tool failed: %v", toolResult.Content) + }) + s.Run("returns structured output", func() { + var result map[string]interface{} + text := toolResult.Content[0].(*mcp.TextContent).Text + s.Require().NoError(json.Unmarshal([]byte(text), &result)) + + s.Run("with correct pod count", func() { + s.Equal(float64(2), result["podCount"]) + }) + s.Run("with correct container count", func() { + s.Equal(float64(2), result["totalContainers"]) + }) + }) + }) + + s.Run("evaluate_script accesses Quantity values transparently", func() { + // Kubernetes Quantities serialize to strings; the k8s proxy converts API responses + // to pure JavaScript objects, so Quantity values are accessible as strings directly. + toolResult, err := s.CallTool("evaluate_script", map[string]interface{}{ + "script": ` + var metrics = k8s.metricsV1beta1Client(); + var podMetrics = metrics.podMetricses("").list(ctx, {}); + var container = podMetrics.items[0].containers[0]; + + JSON.stringify({ + cpu: container.usage.cpu, + memory: container.usage.memory + }); + `, + }) + s.Run("no error", func() { + s.NoError(err) + s.Require().NotNil(toolResult) + s.False(toolResult.IsError, "call tool failed: %v", toolResult.Content) + }) + s.Run("returns structured output", func() { + var result map[string]interface{} + text := toolResult.Content[0].(*mcp.TextContent).Text + s.Require().NoError(json.Unmarshal([]byte(text), &result)) + + s.Run("with CPU value as string", func() { + s.Equal("100m", result["cpu"]) + }) + s.Run("with memory value as string", func() { + s.Equal("128Mi", result["memory"]) + }) + }) + }) +} + +func TestCodeTop(t *testing.T) { + suite.Run(t, new(CodeTopSuite)) +} diff --git a/pkg/mcp/modules.go b/pkg/mcp/modules.go index 7ef68341c..4bf570dbc 100644 --- a/pkg/mcp/modules.go +++ b/pkg/mcp/modules.go @@ -1,6 +1,7 @@ package mcp import ( + _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/code" _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/config" _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/core" _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/helm" diff --git a/pkg/mcp/testdata/toolsets-code-tools.json b/pkg/mcp/testdata/toolsets-code-tools.json new file mode 100644 index 000000000..766e170a2 --- /dev/null +++ b/pkg/mcp/testdata/toolsets-code-tools.json @@ -0,0 +1,28 @@ +[ + { + "annotations": { + "title": "Code: Evaluate Script", + "destructiveHint": true, + "openWorldHint": true + }, + "description": "Execute a JavaScript script with access to Kubernetes clients. Use this tool for complex operations that require multiple API calls, data transformation, filtering, or aggregation that would be inefficient with individual tool calls. The script runs in a sandboxed environment with access only to Kubernetes clients - no file system or network access.\n\n\n## JavaScript SDK\n\n**Note:** Full ES5.1 syntax support, partial ES6. Synchronous execution only (no async/await or Promises).\n\n### Globals\n- **k8s** - Kubernetes client (case-insensitive: coreV1, CoreV1, COREV1 all work)\n- **ctx** - Request context for cancellation\n- **namespace** - Default namespace\n\n### k8s API Clients\n- k8s.coreV1() - pods, services, configMaps, secrets, namespaces, nodes, etc.\n- k8s.appsV1() - deployments, statefulSets, daemonSets, replicaSets\n- k8s.batchV1() - jobs, cronJobs\n- k8s.networkingV1() - ingresses, networkPolicies\n- k8s.rbacV1() - roles, roleBindings, clusterRoles, clusterRoleBindings\n- k8s.metricsV1beta1Client() - pod and node metrics (CPU/memory usage)\n- k8s.dynamicClient() - any resource by GVR\n- k8s.discoveryClient() - API discovery\n\n### Examples\n\n#### Combine multiple API calls with JavaScript\n```javascript\n// Get all deployments and their pod counts across namespaces\nconst deps = k8s.appsV1().deployments(\"\").list(ctx, {});\nconst result = deps.items.flatMap(d =\u003e {\n const pods = k8s.coreV1().pods(d.metadata.namespace).list(ctx, {\n labelSelector: Object.entries(d.spec.selector.matchLabels || {})\n .map(([k,v]) =\u003e k+\"=\"+v).join(\",\")\n });\n return [{\n deployment: d.metadata.name,\n namespace: d.metadata.namespace,\n replicas: d.status.readyReplicas + \"/\" + d.status.replicas,\n pods: pods.items.map(p =\u003e p.metadata.name)\n }];\n});\nJSON.stringify(result);\n```\n\n#### Filter and aggregate\n```javascript\nconst pods = k8s.coreV1().pods(\"\").list(ctx, {});\nconst unhealthy = pods.items.filter(p =\u003e\n p.status.containerStatuses?.some(c =\u003e c.restartCount \u003e 5)\n).map(p =\u003e ({\n name: p.metadata.name,\n ns: p.metadata.namespace,\n restarts: p.status.containerStatuses.reduce((s,c) =\u003e s + c.restartCount, 0)\n}));\nJSON.stringify(unhealthy);\n```\n\n#### Create resources (using standard Kubernetes YAML/JSON structure)\n```javascript\nconst pod = {\n apiVersion: \"v1\", kind: \"Pod\",\n metadata: { name: \"my-pod\", namespace: namespace },\n spec: { containers: [{ name: \"nginx\", image: \"nginx:latest\" }] }\n};\nk8s.coreV1().pods(namespace).create(ctx, pod, {}).metadata.name;\n```\n\n#### API introspection\n```javascript\n// Discover available resources on coreV1\nconst resources = []; for (const k in k8s.coreV1()) if (typeof k8s.coreV1()[k]==='function') resources.push(k);\n// resources: [\"configMaps\",\"namespaces\",\"pods\",\"secrets\",\"services\",...]\n\n// Discover available operations on pods\nconst ops = []; for (const k in k8s.coreV1().pods(namespace)) if (typeof k8s.coreV1().pods(namespace)[k]==='function') ops.push(k);\n// ops: [\"create\",\"delete\",\"get\",\"list\",\"update\",\"watch\",...]\n```\n\n#### Get pod metrics with resource quantities\n```javascript\nconst metrics = k8s.metricsV1beta1Client();\nconst podMetrics = metrics.podMetricses(\"\").list(ctx, {});\nconst result = podMetrics.items.map(function(pm) {\n return {\n name: pm.metadata.name,\n cpu: pm.containers[0].usage.cpu, // \"100m\"\n memory: pm.containers[0].usage.memory // \"128Mi\"\n };\n});\nJSON.stringify(result);\n```\n\n#### Get pod logs\n```javascript\nconst logBytes = k8s.coreV1().pods(namespace).getLogs(\"my-pod\", {container: \"main\", tailLines: 100}).doRaw(ctx);\nvar logs = \"\"; for (var i = 0; i \u003c logBytes.length; i++) logs += String.fromCharCode(logBytes[i]);\nlogs;\n```\n\n### Return Value\nLast expression is returned. Use JSON.stringify() for objects.\n", + "inputSchema": { + "type": "object", + "properties": { + "script": { + "description": "JavaScript code to execute. The last expression is returned as the result.", + "type": "string" + }, + "timeout": { + "description": "Execution timeout in milliseconds (default: 30000, max: 300000)", + "type": "integer" + } + }, + "required": [ + "script" + ] + }, + "name": "evaluate_script", + "title": "Code: Evaluate Script" + } +] diff --git a/pkg/mcp/toolsets_test.go b/pkg/mcp/toolsets_test.go index faa92fbcb..fba052acb 100644 --- a/pkg/mcp/toolsets_test.go +++ b/pkg/mcp/toolsets_test.go @@ -13,6 +13,7 @@ import ( configuration "github.com/containers/kubernetes-mcp-server/pkg/config" "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" "github.com/containers/kubernetes-mcp-server/pkg/toolsets" + "github.com/containers/kubernetes-mcp-server/pkg/toolsets/code" "github.com/containers/kubernetes-mcp-server/pkg/toolsets/config" "github.com/containers/kubernetes-mcp-server/pkg/toolsets/core" "github.com/containers/kubernetes-mcp-server/pkg/toolsets/helm" @@ -144,6 +145,7 @@ func (s *ToolsetsSuite) TestDefaultToolsetsToolsInMultiClusterEnum() { func (s *ToolsetsSuite) TestGranularToolsetsTools() { testCases := []api.Toolset{ + &code.Toolset{}, &core.Toolset{}, &config.Toolset{}, &helm.Toolset{}, diff --git a/pkg/toolsets/code/evaluate.go b/pkg/toolsets/code/evaluate.go new file mode 100644 index 000000000..013e23082 --- /dev/null +++ b/pkg/toolsets/code/evaluate.go @@ -0,0 +1,186 @@ +package code + +import ( + "fmt" + "time" + + "github.com/google/jsonschema-go/jsonschema" + "k8s.io/utils/ptr" + + "github.com/containers/kubernetes-mcp-server/pkg/api" + "github.com/containers/kubernetes-mcp-server/pkg/code" +) + +// sdkDocumentation provides documentation for LLMs about the JavaScript SDK available in scripts. +// This is included in the tool description to help LLMs understand how to use the API. +const sdkDocumentation = ` +## JavaScript SDK + +**Note:** Full ES5.1 syntax support, partial ES6. Synchronous execution only (no async/await or Promises). + +### Globals +- **k8s** - Kubernetes client (case-insensitive: coreV1, CoreV1, COREV1 all work) +- **ctx** - Request context for cancellation +- **namespace** - Default namespace + +### k8s API Clients +- k8s.coreV1() - pods, services, configMaps, secrets, namespaces, nodes, etc. +- k8s.appsV1() - deployments, statefulSets, daemonSets, replicaSets +- k8s.batchV1() - jobs, cronJobs +- k8s.networkingV1() - ingresses, networkPolicies +- k8s.rbacV1() - roles, roleBindings, clusterRoles, clusterRoleBindings +- k8s.metricsV1beta1Client() - pod and node metrics (CPU/memory usage) +- k8s.dynamicClient() - any resource by GVR +- k8s.discoveryClient() - API discovery + +### Examples + +#### Combine multiple API calls with JavaScript +` + "```javascript" + ` +// Get all deployments and their pod counts across namespaces +const deps = k8s.appsV1().deployments("").list(ctx, {}); +const result = deps.items.flatMap(d => { + const pods = k8s.coreV1().pods(d.metadata.namespace).list(ctx, { + labelSelector: Object.entries(d.spec.selector.matchLabels || {}) + .map(([k,v]) => k+"="+v).join(",") + }); + return [{ + deployment: d.metadata.name, + namespace: d.metadata.namespace, + replicas: d.status.readyReplicas + "/" + d.status.replicas, + pods: pods.items.map(p => p.metadata.name) + }]; +}); +JSON.stringify(result); +` + "```" + ` + +#### Filter and aggregate +` + "```javascript" + ` +const pods = k8s.coreV1().pods("").list(ctx, {}); +const unhealthy = pods.items.filter(p => + p.status.containerStatuses?.some(c => c.restartCount > 5) +).map(p => ({ + name: p.metadata.name, + ns: p.metadata.namespace, + restarts: p.status.containerStatuses.reduce((s,c) => s + c.restartCount, 0) +})); +JSON.stringify(unhealthy); +` + "```" + ` + +#### Create resources (using standard Kubernetes YAML/JSON structure) +` + "```javascript" + ` +const pod = { + apiVersion: "v1", kind: "Pod", + metadata: { name: "my-pod", namespace: namespace }, + spec: { containers: [{ name: "nginx", image: "nginx:latest" }] } +}; +k8s.coreV1().pods(namespace).create(ctx, pod, {}).metadata.name; +` + "```" + ` + +#### API introspection +` + "```javascript" + ` +// Discover available resources on coreV1 +const resources = []; for (const k in k8s.coreV1()) if (typeof k8s.coreV1()[k]==='function') resources.push(k); +// resources: ["configMaps","namespaces","pods","secrets","services",...] + +// Discover available operations on pods +const ops = []; for (const k in k8s.coreV1().pods(namespace)) if (typeof k8s.coreV1().pods(namespace)[k]==='function') ops.push(k); +// ops: ["create","delete","get","list","update","watch",...] +` + "```" + ` + +#### Get pod metrics with resource quantities +` + "```javascript" + ` +const metrics = k8s.metricsV1beta1Client(); +const podMetrics = metrics.podMetricses("").list(ctx, {}); +const result = podMetrics.items.map(function(pm) { + return { + name: pm.metadata.name, + cpu: pm.containers[0].usage.cpu, // "100m" + memory: pm.containers[0].usage.memory // "128Mi" + }; +}); +JSON.stringify(result); +` + "```" + ` + +#### Get pod logs +` + "```javascript" + ` +const logBytes = k8s.coreV1().pods(namespace).getLogs("my-pod", {container: "main", tailLines: 100}).doRaw(ctx); +var logs = ""; for (var i = 0; i < logBytes.length; i++) logs += String.fromCharCode(logBytes[i]); +logs; +` + "```" + ` + +### Return Value +Last expression is returned. Use JSON.stringify() for objects. +` + +func initEvaluate() []api.ServerTool { + tools := []api.ServerTool{ + { + Tool: api.Tool{ + Name: "evaluate_script", + Description: "Execute a JavaScript script with access to Kubernetes clients. " + + "Use this tool for complex operations that require multiple API calls, " + + "data transformation, filtering, or aggregation that would be inefficient " + + "with individual tool calls. The script runs in a sandboxed environment " + + "with access only to Kubernetes clients - no file system or network access." + + "\n\n" + sdkDocumentation, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "script": { + Type: "string", + Description: "JavaScript code to execute. The last expression is returned as the result.", + }, + "timeout": { + Type: "integer", + Description: "Execution timeout in milliseconds (default: 30000, max: 300000)", + }, + }, + Required: []string{"script"}, + }, + Annotations: api.ToolAnnotations{ + Title: "Code: Evaluate Script", + ReadOnlyHint: ptr.To(false), // Scripts can modify resources + DestructiveHint: ptr.To(true), // Scripts can be destructive + IdempotentHint: ptr.To(false), // Scripts may not be idempotent + OpenWorldHint: ptr.To(true), // Interacts with Kubernetes cluster + }, + }, + Handler: evaluateScript, + }, + } + return tools +} + +func evaluateScript(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + // Extract required script parameter + script, err := api.RequiredString(params, "script") + if err != nil { + return api.NewToolCallResult("", err), nil + } + + // Extract optional timeout parameter + args := params.GetArguments() + timeout := code.DefaultTimeout + if timeoutVal, ok := args["timeout"]; ok { + timeoutMs, err := api.ParseInt64(timeoutVal) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to parse timeout: %w", err)), nil + } + timeout = time.Duration(timeoutMs) * time.Millisecond + } + + // Create evaluator with SDK already configured + evaluator, err := code.NewEvaluator(params.Context, params.KubernetesClient) + if err != nil { + return api.NewToolCallResult("", err), nil + } + + // Execute the script + result, err := evaluator.Evaluate(script, timeout) + if err != nil { + return api.NewToolCallResult("", err), nil + } + + return api.NewToolCallResult(result, nil), nil +} diff --git a/pkg/toolsets/code/toolset.go b/pkg/toolsets/code/toolset.go new file mode 100644 index 000000000..adbc2abea --- /dev/null +++ b/pkg/toolsets/code/toolset.go @@ -0,0 +1,34 @@ +package code + +import ( + "slices" + + "github.com/containers/kubernetes-mcp-server/pkg/api" + "github.com/containers/kubernetes-mcp-server/pkg/toolsets" +) + +type Toolset struct{} + +var _ api.Toolset = (*Toolset)(nil) + +func (t *Toolset) GetName() string { + return "code" +} + +func (t *Toolset) GetDescription() string { + return "Execute JavaScript code with access to Kubernetes clients for advanced operations and data transformation (opt-in, security-sensitive)" +} + +func (t *Toolset) GetTools(_ api.Openshift) []api.ServerTool { + return slices.Concat( + initEvaluate(), + ) +} + +func (t *Toolset) GetPrompts() []api.ServerPrompt { + return nil +} + +func init() { + toolsets.Register(&Toolset{}) +}