267 lines
8.8 KiB
TypeScript
267 lines
8.8 KiB
TypeScript
|
|
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');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|