import { describe, it, expect } from 'vitest'; import { generatePodSpec, generateDeploymentSpec, generateNamespaceSpec, 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); }); }); 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'); }); });