feat: add Kubernetes orchestrator for MCP server deployment
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions

KubernetesOrchestrator implements McpOrchestrator interface with K8s API
client, manifest generation (Pod/Deployment), namespace management,
resource limits, and security contexts. 39 new tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michal
2026-02-21 05:30:49 +00:00
parent d0a224e839
commit 9e660140b3
7 changed files with 1102 additions and 0 deletions

View File

@@ -0,0 +1,166 @@
import { describe, it, expect } from 'vitest';
import {
generatePodSpec,
generateDeploymentSpec,
generateNamespaceSpec,
formatMemory,
formatCpu,
sanitizeName,
} from '../src/services/k8s/manifest-generator.js';
import type { ContainerSpec } from '../src/services/orchestrator.js';
const baseSpec: ContainerSpec = {
image: 'mcpctl/test-server:latest',
name: 'test-server',
};
describe('formatMemory', () => {
it('formats bytes to Gi', () => {
expect(formatMemory(1024 * 1024 * 1024)).toBe('1Gi');
expect(formatMemory(2 * 1024 * 1024 * 1024)).toBe('2Gi');
});
it('formats bytes to Mi', () => {
expect(formatMemory(512 * 1024 * 1024)).toBe('512Mi');
expect(formatMemory(256 * 1024 * 1024)).toBe('256Mi');
});
it('formats bytes to Ki', () => {
expect(formatMemory(64 * 1024)).toBe('64Ki');
});
it('formats small values as plain bytes', () => {
expect(formatMemory(500)).toBe('500');
});
});
describe('formatCpu', () => {
it('converts nanoCPUs to millicores', () => {
expect(formatCpu(500_000_000)).toBe('500m');
expect(formatCpu(1_000_000_000)).toBe('1000m');
expect(formatCpu(250_000_000)).toBe('250m');
});
});
describe('sanitizeName', () => {
it('lowercases and replaces invalid chars', () => {
expect(sanitizeName('My Server')).toBe('my-server');
expect(sanitizeName('test_server.v2')).toBe('test-server-v2');
});
it('strips leading/trailing hyphens', () => {
expect(sanitizeName('-hello-')).toBe('hello');
});
it('truncates to 63 chars', () => {
const long = 'a'.repeat(100);
expect(sanitizeName(long).length).toBeLessThanOrEqual(63);
});
});
describe('generatePodSpec', () => {
it('generates valid pod manifest', () => {
const pod = generatePodSpec(baseSpec, 'default');
expect(pod.apiVersion).toBe('v1');
expect(pod.kind).toBe('Pod');
expect(pod.metadata.name).toBe('test-server');
expect(pod.metadata.namespace).toBe('default');
expect(pod.metadata.labels['mcpctl.managed']).toBe('true');
expect(pod.spec.containers).toHaveLength(1);
expect(pod.spec.containers[0]!.image).toBe('mcpctl/test-server:latest');
expect(pod.spec.restartPolicy).toBe('Always');
});
it('applies default resource limits', () => {
const pod = generatePodSpec(baseSpec, 'default');
const container = pod.spec.containers[0]!;
expect(container.resources.limits.memory).toBe('512Mi');
expect(container.resources.limits.cpu).toBe('500m');
});
it('applies custom resource limits', () => {
const spec: ContainerSpec = {
...baseSpec,
memoryLimit: 1024 * 1024 * 1024,
nanoCpus: 1_000_000_000,
};
const pod = generatePodSpec(spec, 'default');
const container = pod.spec.containers[0]!;
expect(container.resources.limits.memory).toBe('1Gi');
expect(container.resources.limits.cpu).toBe('1000m');
});
it('includes env vars when specified', () => {
const spec: ContainerSpec = {
...baseSpec,
env: { API_KEY: 'secret', PORT: '3000' },
};
const pod = generatePodSpec(spec, 'test-ns');
const container = pod.spec.containers[0]!;
expect(container.env).toEqual([
{ name: 'API_KEY', value: 'secret' },
{ name: 'PORT', value: '3000' },
]);
});
it('includes port when specified', () => {
const spec: ContainerSpec = { ...baseSpec, containerPort: 8080 };
const pod = generatePodSpec(spec, 'default');
const container = pod.spec.containers[0]!;
expect(container.ports).toEqual([{ containerPort: 8080 }]);
});
it('omits env and ports when not specified', () => {
const pod = generatePodSpec(baseSpec, 'default');
const container = pod.spec.containers[0]!;
expect(container.env).toBeUndefined();
expect(container.ports).toBeUndefined();
});
it('sets security context', () => {
const pod = generatePodSpec(baseSpec, 'default');
const sc = pod.spec.containers[0]!.securityContext;
expect(sc.runAsNonRoot).toBe(true);
expect(sc.readOnlyRootFilesystem).toBe(true);
expect(sc.allowPrivilegeEscalation).toBe(false);
});
it('propagates custom labels', () => {
const spec: ContainerSpec = {
...baseSpec,
labels: { team: 'infra', version: 'v1' },
};
const pod = generatePodSpec(spec, 'default');
expect(pod.metadata.labels['team']).toBe('infra');
expect(pod.metadata.labels['version']).toBe('v1');
expect(pod.metadata.labels['mcpctl.managed']).toBe('true');
});
});
describe('generateDeploymentSpec', () => {
it('generates valid deployment manifest', () => {
const dep = generateDeploymentSpec(baseSpec, 'prod', 3);
expect(dep.apiVersion).toBe('apps/v1');
expect(dep.kind).toBe('Deployment');
expect(dep.metadata.namespace).toBe('prod');
expect(dep.spec.replicas).toBe(3);
expect(dep.spec.selector.matchLabels['mcpctl.managed']).toBe('true');
expect(dep.spec.template.spec.containers).toHaveLength(1);
});
it('defaults to 1 replica', () => {
const dep = generateDeploymentSpec(baseSpec, 'default');
expect(dep.spec.replicas).toBe(1);
});
});
describe('generateNamespaceSpec', () => {
it('generates namespace manifest', () => {
const ns = generateNamespaceSpec('mcpctl-prod');
expect(ns.apiVersion).toBe('v1');
expect(ns.kind).toBe('Namespace');
expect(ns.metadata.name).toBe('mcpctl-prod');
});
});

View File

@@ -0,0 +1,266 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { K8sClientConfig } from '../src/services/k8s/k8s-client.js';
// Mock the K8sClient before importing KubernetesOrchestrator
vi.mock('../src/services/k8s/k8s-client.js', () => {
class MockK8sClient {
defaultNamespace: string;
// Store mock handlers so tests can override
_handlers = new Map<string, { statusCode: number; body: unknown }>();
constructor(config: K8sClientConfig) {
this.defaultNamespace = config.namespace ?? 'default';
}
_setResponse(key: string, statusCode: number, body: unknown) {
this._handlers.set(key, { statusCode, body });
}
_getResponse(key: string) {
return this._handlers.get(key) ?? { statusCode: 200, body: {} };
}
async get(path: string) { return this._getResponse(`GET:${path}`); }
async post(path: string, _body: unknown) { return this._getResponse(`POST:${path}`); }
async delete(path: string) { return this._getResponse(`DELETE:${path}`); }
async patch(path: string, _body: unknown) { return this._getResponse(`PATCH:${path}`); }
async getLogs(_ns: string, _pod: string, _opts?: unknown) {
return this._getResponse('LOGS')?.body ?? '';
}
}
return {
K8sClient: MockK8sClient,
loadDefaultConfig: vi.fn(),
parseKubeconfig: vi.fn(),
};
});
import { KubernetesOrchestrator } from '../src/services/k8s/kubernetes-orchestrator.js';
import type { ContainerSpec } from '../src/services/orchestrator.js';
function getClient(orch: KubernetesOrchestrator): {
_setResponse(key: string, statusCode: number, body: unknown): void;
} {
// Access private client for test setup
return (orch as unknown as { client: { _setResponse(k: string, sc: number, b: unknown): void } }).client;
}
const testConfig: K8sClientConfig = {
apiServer: 'https://localhost:6443',
token: 'test-token',
namespace: 'test-ns',
};
const testSpec: ContainerSpec = {
image: 'mcpctl/server:latest',
name: 'my-server',
env: { PORT: '3000' },
containerPort: 3000,
};
const podStatusRunning = {
metadata: {
name: 'my-server',
namespace: 'test-ns',
creationTimestamp: '2026-01-01T00:00:00Z',
labels: { 'mcpctl.managed': 'true' },
},
status: {
phase: 'Running',
containerStatuses: [{
state: { running: { startedAt: '2026-01-01T00:00:00Z' } },
}],
},
spec: {
containers: [{ ports: [{ containerPort: 3000 }] }],
},
};
const podStatusPending = {
metadata: {
name: 'my-server',
namespace: 'test-ns',
creationTimestamp: '2026-01-01T00:00:00Z',
},
status: {
phase: 'Pending',
containerStatuses: [{
state: { waiting: { reason: 'ContainerCreating' } },
}],
},
};
describe('KubernetesOrchestrator', () => {
let orch: KubernetesOrchestrator;
beforeEach(() => {
orch = new KubernetesOrchestrator(testConfig);
});
describe('ping', () => {
it('returns true on successful API call', async () => {
getClient(orch)._setResponse('GET:/api/v1', 200, { kind: 'APIResourceList' });
expect(await orch.ping()).toBe(true);
});
it('returns false on error', async () => {
getClient(orch)._setResponse('GET:/api/v1', 500, { message: 'internal error' });
expect(await orch.ping()).toBe(false);
});
});
describe('pullImage', () => {
it('is a no-op for K8s', async () => {
await expect(orch.pullImage('some-image:latest')).resolves.toBeUndefined();
});
});
describe('createContainer', () => {
it('creates a pod and returns container info', async () => {
const client = getClient(orch);
// ensureNamespace check
client._setResponse('GET:/api/v1/namespaces/test-ns', 200, {});
// create pod
client._setResponse('POST:/api/v1/namespaces/test-ns/pods', 201, podStatusRunning);
// inspect after creation
client._setResponse('GET:/api/v1/namespaces/test-ns/pods/my-server', 200, podStatusRunning);
const info = await orch.createContainer(testSpec);
expect(info.containerId).toBe('my-server');
expect(info.state).toBe('running');
expect(info.port).toBe(3000);
});
it('throws on API error', async () => {
const client = getClient(orch);
client._setResponse('GET:/api/v1/namespaces/test-ns', 200, {});
client._setResponse('POST:/api/v1/namespaces/test-ns/pods', 422, {
message: 'pod already exists',
});
await expect(orch.createContainer(testSpec)).rejects.toThrow('Failed to create pod');
});
});
describe('inspectContainer', () => {
it('returns running container info', async () => {
getClient(orch)._setResponse('GET:/api/v1/namespaces/test-ns/pods/my-server', 200, podStatusRunning);
const info = await orch.inspectContainer('my-server');
expect(info.state).toBe('running');
expect(info.name).toBe('my-server');
});
it('maps pending state correctly', async () => {
getClient(orch)._setResponse('GET:/api/v1/namespaces/test-ns/pods/my-server', 200, podStatusPending);
const info = await orch.inspectContainer('my-server');
expect(info.state).toBe('starting');
});
it('throws on 404', async () => {
getClient(orch)._setResponse('GET:/api/v1/namespaces/test-ns/pods/missing', 404, {
message: 'pods "missing" not found',
});
await expect(orch.inspectContainer('missing')).rejects.toThrow('not found');
});
});
describe('stopContainer', () => {
it('deletes the pod', async () => {
getClient(orch)._setResponse('DELETE:/api/v1/namespaces/test-ns/pods/my-server', 200, {});
await expect(orch.stopContainer('my-server')).resolves.toBeUndefined();
});
});
describe('removeContainer', () => {
it('deletes the pod successfully', async () => {
getClient(orch)._setResponse('DELETE:/api/v1/namespaces/test-ns/pods/my-server', 200, {});
await expect(orch.removeContainer('my-server')).resolves.toBeUndefined();
});
it('ignores 404 (already deleted)', async () => {
getClient(orch)._setResponse('DELETE:/api/v1/namespaces/test-ns/pods/my-server', 404, {});
await expect(orch.removeContainer('my-server')).resolves.toBeUndefined();
});
it('throws on other errors', async () => {
getClient(orch)._setResponse('DELETE:/api/v1/namespaces/test-ns/pods/my-server', 403, {
message: 'forbidden',
});
await expect(orch.removeContainer('my-server')).rejects.toThrow('Failed to delete pod');
});
});
describe('getContainerLogs', () => {
it('returns logs from pod', async () => {
getClient(orch)._setResponse('LOGS', 200, 'log line 1\nlog line 2\n');
const logs = await orch.getContainerLogs('my-server');
expect(logs.stdout).toBe('log line 1\nlog line 2\n');
expect(logs.stderr).toBe('');
});
});
describe('listContainers', () => {
it('lists managed pods', async () => {
getClient(orch)._setResponse(
'GET:/api/v1/namespaces/test-ns/pods?labelSelector=mcpctl.managed%3Dtrue',
200,
{ items: [podStatusRunning] },
);
const containers = await orch.listContainers();
expect(containers).toHaveLength(1);
expect(containers[0]!.containerId).toBe('my-server');
expect(containers[0]!.state).toBe('running');
});
it('returns empty on API error', async () => {
getClient(orch)._setResponse(
'GET:/api/v1/namespaces/test-ns/pods?labelSelector=mcpctl.managed%3Dtrue',
500,
{},
);
const containers = await orch.listContainers();
expect(containers).toEqual([]);
});
});
describe('ensureNamespace', () => {
it('does nothing if namespace exists', async () => {
getClient(orch)._setResponse('GET:/api/v1/namespaces/test-ns', 200, {});
await expect(orch.ensureNamespace('test-ns')).resolves.toBeUndefined();
});
it('creates namespace if not found', async () => {
const client = getClient(orch);
client._setResponse('GET:/api/v1/namespaces/new-ns', 404, {});
client._setResponse('POST:/api/v1/namespaces', 201, {});
await expect(orch.ensureNamespace('new-ns')).resolves.toBeUndefined();
});
it('handles conflict (namespace already created by another process)', async () => {
const client = getClient(orch);
client._setResponse('GET:/api/v1/namespaces/new-ns', 404, {});
client._setResponse('POST:/api/v1/namespaces', 409, { message: 'already exists' });
await expect(orch.ensureNamespace('new-ns')).resolves.toBeUndefined();
});
});
describe('getNamespace', () => {
it('returns configured namespace', () => {
expect(orch.getNamespace()).toBe('test-ns');
});
it('defaults to "default"', () => {
const defaultOrch = new KubernetesOrchestrator({
apiServer: 'https://localhost:6443',
});
expect(defaultOrch.getNamespace()).toBe('default');
});
});
});