mcp-server-kubernetes

MIT License
1,446
246
  • Linux
  • Apple
// Import required test frameworks and SDK components import { expect, test, describe, beforeEach, afterEach } from "vitest"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { ListToolsResponseSchema } from "../src/models/tool-models.js"; import { ListPodsResponseSchema, ListNamespacesResponseSchema, ListNodesResponseSchema, CreatePodResponseSchema, DeletePodResponseSchema, CreateDeploymentResponseSchema, DeleteDeploymentResponseSchema, ListDeploymentsResponseSchema, } from "../src/models/response-schemas.js"; import { ScaleDeploymentResponseSchema } from "../src/models/response-schemas.js"; /** * Utility function to create a promise that resolves after specified milliseconds * Useful for waiting between operations or ensuring async operations complete */ async function sleep(ms: number): Promise<void> { return new Promise((resolve) => setTimeout(resolve, ms)); } /** * Generates a random SHA-like string for unique resource naming * Used to avoid naming conflicts when creating test resources */ function generateRandomSHA(): string { return Math.random().toString(36).substring(2, 15); } /** * Test suite for kubernetes server operations * Tests the core functionality of kubernetes operations including: * - Listing available tools * - Namespace and node operations * - Pod lifecycle management (create, monitor, delete) */ describe("kubernetes server operations", () => { let transport: StdioClientTransport; let client: Client; /** * Set up before each test: * - Creates a new StdioClientTransport instance * - Initializes and connects the MCP client * - Waits for connection to be established */ beforeEach(async () => { try { transport = new StdioClientTransport({ command: "bun", args: ["src/index.ts"], stderr: "pipe", }); client = new Client( { name: "test-client", version: "1.0.0", }, { capabilities: {}, } ); await client.connect(transport); // Wait for connection to be fully established await sleep(1000); } catch (e) { console.error("Error in beforeEach:", e); throw e; } }); /** * Clean up after each test: * - Closes the transport connection * - Waits to ensure clean shutdown */ afterEach(async () => { try { await transport.close(); await sleep(1000); } catch (e) { console.error("Error during cleanup:", e); } }); /** * Test case: Verify the availability of kubernetes tools * Ensures that the server exposes the expected kubernetes operations */ test("list available tools", async () => { // List available tools stays the same console.log("Listing available tools..."); const toolsList = await client.request( { method: "tools/list", }, ListToolsResponseSchema ); expect(toolsList.tools).toBeDefined(); expect(toolsList.tools.length).toBeGreaterThan(0); }); /** * Test case: Verify namespace and node listing functionality * Tests both namespace and node listing operations in sequence */ test("list namespaces and nodes", async () => { // List namespaces console.log("Listing namespaces..."); const namespacesResult = await client.request( { method: "tools/call", params: { name: "list_namespaces", arguments: {}, }, }, ListNamespacesResponseSchema ); expect(namespacesResult.content[0].type).toBe("text"); const namespaces = JSON.parse(namespacesResult.content[0].text); expect(namespaces.namespaces).toBeDefined(); // List nodes console.log("Listing nodes..."); const listNodesResult = await client.request( { method: "tools/call", params: { name: "list_nodes", arguments: {}, }, }, ListNodesResponseSchema ); expect(listNodesResult.content[0].type).toBe("text"); const nodes = JSON.parse(listNodesResult.content[0].text); expect(nodes.nodes).toBeDefined(); expect(Array.isArray(nodes.nodes)).toBe(true); }); /** * Test case: Complete pod lifecycle management * Tests the full lifecycle of a pod including: * 1. Cleanup of existing test pods * 2. Creation of new test pod * 3. Monitoring pod until running state * 4. Verification of pod logs * 5. Pod deletion and termination verification * * Note: Test timeout is set to 120 seconds to accommodate all operations via vitest.config.ts */ test( "pod lifecycle management", async () => { const podBaseName = "unit-test"; const podName = `${podBaseName}-${generateRandomSHA()}`; // Step 1: Check if pods with unit-test prefix exist and terminate them if found const existingPods = await client.request( { method: "tools/call", params: { name: "list_pods", arguments: { namespace: "default", }, }, }, ListPodsResponseSchema ); const podsResponse = JSON.parse(existingPods.content[0].text); const existingTestPods = podsResponse.items?.filter((pod: any) => pod.metadata?.name?.startsWith(podBaseName) ) || []; // Terminate existing test pods if found for (const pod of existingTestPods) { await client.request( { method: "tools/call", params: { name: "delete_pod", arguments: { name: pod.metadata.name, namespace: "default", ignoreNotFound: true, }, }, }, DeletePodResponseSchema ); // Wait for pod to be fully terminated let podDeleted = false; const terminationStartTime = Date.now(); while (!podDeleted && Date.now() - terminationStartTime < 10000) { try { await client.request( { method: "tools/call", params: { name: "describe_pod", arguments: { name: pod.metadata.name, namespace: "default", }, }, }, ListPodsResponseSchema ); await sleep(500); } catch (error) { // If we get an error, it might be because the pod is gone (404) podDeleted = true; } } } // Create new pod with random SHA name const createPodResult = await client.request( { method: "tools/call", params: { name: "create_pod", arguments: { name: podName, namespace: "default", template: "busybox", command: [ "/bin/sh", "-c", "echo Pod is running && sleep infinity", ], }, }, }, CreatePodResponseSchema ); expect(createPodResult.content[0].type).toBe("text"); const podResult = JSON.parse(createPodResult.content[0].text); expect(podResult.podName).toBe(podName); // Step 2: Wait for Running state (up to 60 seconds) let podRunning = false; const startTime = Date.now(); while (!podRunning && Date.now() - startTime < 60000) { const podStatus = await client.request( { method: "tools/call", params: { name: "describe_pod", arguments: { name: podName, namespace: "default", }, }, }, ListPodsResponseSchema ); const status = JSON.parse(podStatus.content[0].text); if (status.status?.phase === "Running") { podRunning = true; console.log(`Pod ${podName} is running. Checking logs...`); // Check pod logs once running const logsResult = await client.request( { method: "tools/call", params: { name: "get_logs", arguments: { resourceType: "pod", name: podName, namespace: "default", }, }, }, ListPodsResponseSchema ); expect(logsResult.content[0].type).toBe("text"); const logs = JSON.parse(logsResult.content[0].text); expect(logs.logs[podName]).toContain("Pod is running"); break; } await sleep(1000); } expect(podRunning).toBe(true); // Step 3: Terminate pod and verify termination (wait up to 10 seconds) const deletePodResult = await client.request( { method: "tools/call", params: { name: "delete_pod", arguments: { name: podName, namespace: "default", }, }, }, DeletePodResponseSchema ); expect(deletePodResult.content[0].type).toBe("text"); const deleteResult = JSON.parse(deletePodResult.content[0].text); expect(deleteResult.status).toBe("deleted"); // Try to verify pod termination, but don't fail the test if we can't confirm it try { let podTerminated = false; const terminationStartTime = Date.now(); while (!podTerminated && Date.now() - terminationStartTime < 10000) { try { const podStatus = await client.request( { method: "tools/call", params: { name: "describe_pod", arguments: { name: podName, namespace: "default", }, }, }, ListPodsResponseSchema ); // Pod still exists, check if it's in Terminating state const status = JSON.parse(podStatus.content[0].text); if (status.status?.phase === "Terminating") { podTerminated = true; break; } await sleep(500); } catch (error) { // If we get an error (404), the pod is gone which also means it's terminated podTerminated = true; break; } } // Log termination status but don't fail the test if (podTerminated) { console.log(`Pod ${podName} termination confirmed`); } else { console.log( `Pod ${podName} termination could not be confirmed within timeout, but deletion was initiated` ); } } catch (error) { // Ignore any errors during termination check console.log(`Error checking pod termination status: ${error}`); } }, { timeout: 120000 } ); /** * Test case: Verify custom pod configuration * Tests creating a pod with a custom configuration */ test( "custom pod configuration", async () => { const podName = `custom-test-${generateRandomSHA()}`; const namespace = "default"; // Create a pod with custom configuration const createPodResult = await client.request( { method: "tools/call", params: { name: "create_pod", arguments: { name: podName, namespace: namespace, template: "custom", customConfig: { image: "nginx:latest", ports: [ { containerPort: 80, name: "http", protocol: "TCP", }, ], resources: { limits: { cpu: "200m", memory: "256Mi", }, requests: { cpu: "100m", memory: "128Mi", }, }, env: [ { name: "NODE_ENV", value: "production", }, ], }, }, }, }, CreatePodResponseSchema ); expect(createPodResult.content[0].type).toBe("text"); const podResult = JSON.parse(createPodResult.content[0].text); expect(podResult.podName).toBe(podName); // Wait for pod to be running let podRunning = false; const startTime = Date.now(); while (!podRunning && Date.now() - startTime < 60000) { const podStatus = await client.request( { method: "tools/call", params: { name: "describe_pod", arguments: { name: podName, namespace: namespace, }, }, }, ListPodsResponseSchema ); const status = JSON.parse(podStatus.content[0].text); if (status.status?.phase === "Running") { podRunning = true; break; } await sleep(1000); } expect(podRunning).toBe(true); // Verify pod configuration const podDetails = await client.request( { method: "tools/call", params: { name: "describe_pod", arguments: { name: podName, namespace: namespace, }, }, }, ListPodsResponseSchema ); const details = JSON.parse(podDetails.content[0].text); const container = details.spec.containers[0]; expect(container.image).toBe("nginx:latest"); expect(container.ports[0].containerPort).toBe(80); expect(container.ports[0].name).toBe("http"); expect(container.ports[0].protocol).toBe("TCP"); expect(container.resources.limits.cpu).toBe("200m"); expect(container.resources.limits.memory).toBe("256Mi"); expect(container.resources.requests.cpu).toBe("100m"); expect(container.resources.requests.memory).toBe("128Mi"); // Cleanup await client.request( { method: "tools/call", params: { name: "delete_pod", arguments: { name: podName, namespace: namespace, }, }, }, DeletePodResponseSchema ); }, { timeout: 60000 } ); /** * Test case: Verify custom deployment configuration * Tests creating a deployment with a custom configuration */ test("custom deployment configuration", async () => { const deploymentName = `test-deployment-${generateRandomSHA()}`; let attempts = 0; const maxAttempts = 3; const waitTime = 2000; while (attempts < maxAttempts) { try { const createDeploymentResult = await client.request( { method: "tools/call", params: { name: "create_deployment", arguments: { name: deploymentName, namespace: "default", template: "custom", replicas: 1, customConfig: { image: "nginx:1.14.2", resources: { limits: { cpu: "100m", memory: "128Mi", }, requests: { cpu: "50m", memory: "64Mi", }, }, }, }, }, }, CreateDeploymentResponseSchema ); expect(createDeploymentResult.content[0].type).toBe("text"); const createResponse = JSON.parse( createDeploymentResult.content[0].text ); expect(createResponse.status).toBe("created"); // Wait for deployment to be ready await sleep(5000); // Verify deployment const listDeploymentsResult = await client.request( { method: "tools/call", params: { name: "list_deployments", arguments: { namespace: "default", }, }, }, ListDeploymentsResponseSchema ); const deployments = JSON.parse(listDeploymentsResult.content[0].text); expect( deployments.deployments.some((d: any) => d.name === deploymentName) ).toBe(true); const scaleDeploymentResult = await client.request( { method: "tools/call", params: { name: "scale_deployment", arguments: { name: deploymentName, namespace: "default", replicas: 2, }, }, }, ScaleDeploymentResponseSchema ); expect(scaleDeploymentResult.content[0].success).toBe(true); expect(scaleDeploymentResult.content[0].message).toContain( `Scaled deployment ${deploymentName} to 2 replicas` ); // Cleanup await client.request( { method: "tools/call", params: { name: "delete_deployment", arguments: { name: deploymentName, namespace: "default", }, }, }, DeleteDeploymentResponseSchema ); // Wait for cleanup await sleep(5000); return; } catch (e) { attempts++; if (attempts === maxAttempts) { throw new Error( `Failed after ${maxAttempts} attempts. Last error: ${e.message}` ); } await sleep(waitTime); } } }); });