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(); 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'); }); }); });