Files
mcpctl/src/mcpd/tests/k8s-manifest.test.ts

232 lines
8.0 KiB
TypeScript
Raw Normal View History

import { describe, it, expect } from 'vitest';
import {
generatePodSpec,
generateDeploymentSpec,
generateNamespaceSpec,
feat(servers): persistent volumes + self-hosted web search and docs templates Instances are immutable and get recreated on any server edit, so anything an MCP server wrote to its container filesystem was lost at exactly that point. That ruled out every stateful MCP server, docs-mcp among them: its index is a SQLite file (better-sqlite3 + sqlite-vec) and it has no external-database mode, so no amount of Postgres helps. A server or template can now declare volumes. The backing store is keyed on the server, not the instance — `mcpctl-<server>-<name>` — which is the whole point: an instance-scoped claim would be destroyed precisely when the data needs to survive. On Kubernetes that is a PVC ensured in the servers namespace before the pod is created and never deleted with it; on Docker, a named volume (named, not anonymous, so `removeContainer`'s `v: true` leaves it alone). Claims are ReadWriteOnce, so volumes and replicas > 1 are mutually exclusive; validation rejects that pair instead of leaving the extra replicas unschedulable. storageClassName is omitted rather than sent empty when no class is configured — to Kubernetes those mean different things. Also fixes a pre-existing bug in the same path: seedTemplates dropped `runtime`, so every PyPI-backed template seeded from YAML silently defaulted to node and would run `npx` against a package that only exists on PyPI. `unifi-network` declares `runtime: python` and had been seeding with runtime unset. Templates added, all self-hosted and none needing an API key: - duckduckgo — no backing service at all - searxng — needs a SearXNG engine (compose profile in stack/) - docs-mcp — open-source Context7/Ref alternative, uses the new volume Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-09 18:17:32 +01:00
generatePvcSpec,
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(false);
expect(sc.readOnlyRootFilesystem).toBe(false);
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);
});
});
feat(servers): persistent volumes + self-hosted web search and docs templates Instances are immutable and get recreated on any server edit, so anything an MCP server wrote to its container filesystem was lost at exactly that point. That ruled out every stateful MCP server, docs-mcp among them: its index is a SQLite file (better-sqlite3 + sqlite-vec) and it has no external-database mode, so no amount of Postgres helps. A server or template can now declare volumes. The backing store is keyed on the server, not the instance — `mcpctl-<server>-<name>` — which is the whole point: an instance-scoped claim would be destroyed precisely when the data needs to survive. On Kubernetes that is a PVC ensured in the servers namespace before the pod is created and never deleted with it; on Docker, a named volume (named, not anonymous, so `removeContainer`'s `v: true` leaves it alone). Claims are ReadWriteOnce, so volumes and replicas > 1 are mutually exclusive; validation rejects that pair instead of leaving the extra replicas unschedulable. storageClassName is omitted rather than sent empty when no class is configured — to Kubernetes those mean different things. Also fixes a pre-existing bug in the same path: seedTemplates dropped `runtime`, so every PyPI-backed template seeded from YAML silently defaulted to node and would run `npx` against a package that only exists on PyPI. `unifi-network` declares `runtime: python` and had been seeding with runtime unset. Templates added, all self-hosted and none needing an API key: - duckduckgo — no backing service at all - searxng — needs a SearXNG engine (compose profile in stack/) - docs-mcp — open-source Context7/Ref alternative, uses the new volume Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-09 18:17:32 +01:00
describe('volumes', () => {
const volumeSpec: ContainerSpec = {
...baseSpec,
volumes: [{ claimName: 'mcpctl-docs-data', mountPath: '/data', sizeGb: 20, storageClass: 'longhorn' }],
};
it('mounts the claim in the pod and declares the volume', () => {
const pod = generatePodSpec(volumeSpec, 'mcpctl-servers');
expect(pod.spec.volumes).toEqual([
{ name: 'mcpctl-docs-data', persistentVolumeClaim: { claimName: 'mcpctl-docs-data' } },
]);
expect(pod.spec.containers[0]!.volumeMounts).toEqual([
{ name: 'mcpctl-docs-data', mountPath: '/data' },
]);
});
it('omits volume fields entirely when none are declared', () => {
const pod = generatePodSpec(baseSpec, 'mcpctl-servers');
expect(pod.spec.volumes).toBeUndefined();
expect(pod.spec.containers[0]!.volumeMounts).toBeUndefined();
});
it('carries volumes into a deployment pod template', () => {
const dep = generateDeploymentSpec(volumeSpec, 'mcpctl-servers', 1);
expect(dep.spec.template.spec.volumes).toHaveLength(1);
expect(dep.spec.template.spec.containers[0]!.volumeMounts).toHaveLength(1);
});
it('builds a PVC with the requested size and class', () => {
const pvc = generatePvcSpec(volumeSpec.volumes![0]!, 'mcpctl-servers');
expect(pvc.kind).toBe('PersistentVolumeClaim');
expect(pvc.metadata.name).toBe('mcpctl-docs-data');
expect(pvc.metadata.namespace).toBe('mcpctl-servers');
expect(pvc.spec.resources.requests.storage).toBe('20Gi');
expect(pvc.spec.accessModes).toEqual(['ReadWriteOnce']);
expect(pvc.spec.storageClassName).toBe('longhorn');
});
it('omits storageClassName rather than sending an empty string', () => {
// An empty string means "no storage class" to Kubernetes, which is NOT the
// same as omitting the field (use the cluster default).
const prev = process.env['MCPD_VOLUME_STORAGE_CLASS'];
delete process.env['MCPD_VOLUME_STORAGE_CLASS'];
try {
const pvc = generatePvcSpec({ claimName: 'c', mountPath: '/d', sizeGb: 1 }, 'ns');
expect('storageClassName' in pvc.spec).toBe(false);
} finally {
if (prev !== undefined) process.env['MCPD_VOLUME_STORAGE_CLASS'] = prev;
}
});
it('falls back to MCPD_VOLUME_STORAGE_CLASS when the volume omits a class', () => {
const prev = process.env['MCPD_VOLUME_STORAGE_CLASS'];
process.env['MCPD_VOLUME_STORAGE_CLASS'] = 'longhorn';
try {
const pvc = generatePvcSpec({ claimName: 'c', mountPath: '/d', sizeGb: 5 }, 'ns');
expect(pvc.spec.storageClassName).toBe('longhorn');
} finally {
if (prev === undefined) delete process.env['MCPD_VOLUME_STORAGE_CLASS'];
else process.env['MCPD_VOLUME_STORAGE_CLASS'] = prev;
}
});
});
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');
});
});