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
This commit is contained in:
@@ -33,6 +33,7 @@ export class McpServerRepository implements IMcpServerRepository {
|
||||
replicas: data.replicas,
|
||||
env: data.env,
|
||||
healthCheck: (data.healthCheck ?? Prisma.JsonNull) as Prisma.InputJsonValue,
|
||||
volumes: data.volumes,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -51,6 +52,7 @@ export class McpServerRepository implements IMcpServerRepository {
|
||||
if (data.replicas !== undefined) updateData['replicas'] = data.replicas;
|
||||
if (data.env !== undefined) updateData['env'] = data.env;
|
||||
if (data.healthCheck !== undefined) updateData['healthCheck'] = (data.healthCheck ?? Prisma.JsonNull) as Prisma.InputJsonValue;
|
||||
if (data.volumes !== undefined) updateData['volumes'] = data.volumes;
|
||||
|
||||
return this.prisma.mcpServer.update({ where: { id }, data: updateData });
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ export class TemplateRepository implements ITemplateRepository {
|
||||
replicas: data.replicas,
|
||||
env: (data.env ?? []) as unknown as Prisma.InputJsonValue,
|
||||
healthCheck: (data.healthCheck ?? Prisma.JsonNull) as Prisma.InputJsonValue,
|
||||
volumes: (data.volumes ?? []) as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -71,6 +72,7 @@ export class TemplateRepository implements ITemplateRepository {
|
||||
if (data.replicas !== undefined) updateData.replicas = data.replicas;
|
||||
if (data.env !== undefined) updateData.env = (data.env ?? []) as Prisma.InputJsonValue;
|
||||
if (data.healthCheck !== undefined) updateData.healthCheck = (data.healthCheck ?? Prisma.JsonNull) as Prisma.InputJsonValue;
|
||||
if (data.volumes !== undefined) updateData.volumes = (data.volumes ?? []) as Prisma.InputJsonValue;
|
||||
|
||||
return this.prisma.mcpTemplate.update({
|
||||
where: { id },
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface BackupServer {
|
||||
replicas: number;
|
||||
env: unknown;
|
||||
healthCheck: unknown;
|
||||
volumes: unknown;
|
||||
}
|
||||
|
||||
export interface BackupSecret {
|
||||
@@ -140,6 +141,7 @@ export class BackupService {
|
||||
replicas: s.replicas,
|
||||
env: s.env,
|
||||
healthCheck: s.healthCheck,
|
||||
volumes: s.volumes,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -172,6 +172,11 @@ export class RestoreService {
|
||||
transport: server.transport as 'STDIO' | 'SSE' | 'STREAMABLE_HTTP',
|
||||
replicas: server.replicas ?? 1,
|
||||
env: (server.env ?? []) as Array<{ name: string; value?: string; valueFrom?: { secretRef: { name: string; key: string } } }>,
|
||||
// Restores the volume *declaration* only. The backing PVC / named
|
||||
// volume is not part of the backup — a restored server re-attaches to
|
||||
// a claim of the same name if one survived, and otherwise starts on a
|
||||
// fresh empty volume.
|
||||
volumes: (server.volumes ?? []) as Parameters<IMcpServerRepository['create']>[0]['volumes'],
|
||||
};
|
||||
if (server.packageName) createData.packageName = server.packageName;
|
||||
if (server.runtime) createData.runtime = server.runtime;
|
||||
|
||||
@@ -91,6 +91,13 @@ export class DockerContainerManager implements McpOrchestrator {
|
||||
Memory: memoryLimit,
|
||||
...(nanoCpus ? { NanoCpus: nanoCpus } : {}),
|
||||
NetworkMode: spec.network ?? 'bridge',
|
||||
// Named volumes, created on demand by the engine. Named (not
|
||||
// anonymous) matters: `removeContainer` passes `v: true`, which reaps
|
||||
// anonymous volumes but leaves named ones — which is what lets the
|
||||
// data outlive the instance.
|
||||
...(spec.volumes && spec.volumes.length > 0
|
||||
? { Binds: spec.volumes.map((v) => `${v.claimName}:${v.mountPath}`) }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
if (spec.command) {
|
||||
|
||||
@@ -372,6 +372,26 @@ export class InstanceService {
|
||||
if (server.transport === 'SSE' || server.transport === 'STREAMABLE_HTTP') {
|
||||
spec.containerPort = server.containerPort ?? 3000;
|
||||
}
|
||||
|
||||
// Volumes are keyed on the server, not this instance: `spec.name` carries
|
||||
// the instance id and a claim named after it would be torn down on the
|
||||
// next server edit — exactly when the data has to survive.
|
||||
const volumes = (server.volumes ?? []) as Array<{
|
||||
name: string;
|
||||
mountPath: string;
|
||||
sizeGb?: number;
|
||||
storageClass?: string;
|
||||
}>;
|
||||
if (volumes.length > 0) {
|
||||
spec.volumes = volumes.map((v) => ({
|
||||
claimName: `mcpctl-${server.name}-${v.name}`,
|
||||
mountPath: v.mountPath,
|
||||
sizeGb: v.sizeGb ?? 10,
|
||||
...(v.storageClass !== undefined && v.storageClass !== ''
|
||||
? { storageClass: v.storageClass }
|
||||
: {}),
|
||||
}));
|
||||
}
|
||||
// Package-based servers: command = [packageName, ...args] (entrypoint handles execution)
|
||||
// Docker-image servers: use explicit command if provided
|
||||
if (pkgCommand) {
|
||||
|
||||
@@ -4,13 +4,14 @@ import type {
|
||||
ContainerSpec,
|
||||
ContainerInfo,
|
||||
ContainerLogs,
|
||||
ContainerVolume,
|
||||
ExecResult,
|
||||
InteractiveExec,
|
||||
} from '../orchestrator.js';
|
||||
import { K8sOfficialClient } from './k8s-client-official.js';
|
||||
import type { K8sOfficialClientConfig } from './k8s-client-official.js';
|
||||
import { generatePodSpec } from './manifest-generator.js';
|
||||
import type { V1Pod } from '@kubernetes/client-node';
|
||||
import { generatePodSpec, generatePvcSpec, sanitizeName } from './manifest-generator.js';
|
||||
import type { V1Pod, V1PersistentVolumeClaim } from '@kubernetes/client-node';
|
||||
|
||||
function mapPodState(pod: V1Pod): ContainerInfo['state'] {
|
||||
const cs = pod.status?.containerStatuses?.[0];
|
||||
@@ -82,6 +83,12 @@ export class KubernetesOrchestrator implements McpOrchestrator {
|
||||
async createContainer(spec: ContainerSpec): Promise<ContainerInfo> {
|
||||
await this.ensureNamespace(this.namespace);
|
||||
|
||||
// PVCs must exist before the pod references them, or the pod stays Pending
|
||||
// on an unbound claim.
|
||||
for (const volume of spec.volumes ?? []) {
|
||||
await this.ensurePvc(volume, spec.labels);
|
||||
}
|
||||
|
||||
const manifest = generatePodSpec(spec, this.namespace);
|
||||
const pod = await this.client.core.createNamespacedPod({
|
||||
namespace: this.namespace,
|
||||
@@ -323,6 +330,43 @@ export class KubernetesOrchestrator implements McpOrchestrator {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the PVC backing a server volume if it is not already there.
|
||||
*
|
||||
* Deliberately never updates an existing claim: most PVC spec fields are
|
||||
* immutable after binding, and silently resizing someone's storage is not a
|
||||
* side effect a container start should have. Growing a volume is an explicit
|
||||
* `kubectl edit pvc` (the class must allow expansion).
|
||||
*/
|
||||
private async ensurePvc(
|
||||
volume: ContainerVolume,
|
||||
labels?: Record<string, string>,
|
||||
): Promise<void> {
|
||||
const name = sanitizeName(volume.claimName);
|
||||
try {
|
||||
await this.client.core.readNamespacedPersistentVolumeClaim({
|
||||
name,
|
||||
namespace: this.namespace,
|
||||
});
|
||||
return; // Already there — reuse it, data and all.
|
||||
} catch (err: unknown) {
|
||||
const status = (err as { statusCode?: number }).statusCode
|
||||
?? (err as { response?: { statusCode?: number } }).response?.statusCode;
|
||||
if (status !== 404) throw err;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.client.core.createNamespacedPersistentVolumeClaim({
|
||||
namespace: this.namespace,
|
||||
body: generatePvcSpec(volume, this.namespace, labels) as V1PersistentVolumeClaim,
|
||||
});
|
||||
} catch (createErr: unknown) {
|
||||
const status = (createErr as { statusCode?: number }).statusCode
|
||||
?? (createErr as { response?: { statusCode?: number } }).response?.statusCode;
|
||||
if (status !== 409) throw createErr; // Lost a create race — fine.
|
||||
}
|
||||
}
|
||||
|
||||
getNamespace(): string {
|
||||
return this.namespace;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ContainerSpec } from '../orchestrator.js';
|
||||
import type { ContainerSpec, ContainerVolume } from '../orchestrator.js';
|
||||
import { DEFAULT_MEMORY_LIMIT, DEFAULT_NANO_CPUS } from '../orchestrator.js';
|
||||
|
||||
const MCPCTL_LABEL = 'mcpctl.managed';
|
||||
@@ -20,6 +20,7 @@ export interface K8sPodManifest {
|
||||
env?: Array<{ name: string; value: string }>;
|
||||
ports?: Array<{ containerPort: number }>;
|
||||
stdin?: boolean;
|
||||
volumeMounts?: Array<{ name: string; mountPath: string }>;
|
||||
resources: {
|
||||
limits: { memory: string; cpu: string };
|
||||
requests: { memory: string; cpu: string };
|
||||
@@ -35,6 +36,22 @@ export interface K8sPodManifest {
|
||||
restartPolicy: 'Always' | 'Never' | 'OnFailure';
|
||||
automountServiceAccountToken: boolean;
|
||||
nodeSelector?: Record<string, string>;
|
||||
volumes?: Array<{ name: string; persistentVolumeClaim: { claimName: string } }>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface K8sPvcManifest {
|
||||
apiVersion: 'v1';
|
||||
kind: 'PersistentVolumeClaim';
|
||||
metadata: {
|
||||
name: string;
|
||||
namespace: string;
|
||||
labels: Record<string, string>;
|
||||
};
|
||||
spec: {
|
||||
accessModes: string[];
|
||||
resources: { requests: { storage: string } };
|
||||
storageClassName?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -78,6 +95,16 @@ function sanitizeName(name: string): string {
|
||||
return name.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/^-+|-+$/g, '').slice(0, 63);
|
||||
}
|
||||
|
||||
function buildPodVolumes(spec: ContainerSpec): Pick<K8sPodManifest['spec'], 'volumes'> {
|
||||
if (!spec.volumes || spec.volumes.length === 0) return {};
|
||||
return {
|
||||
volumes: spec.volumes.map((v) => ({
|
||||
name: sanitizeName(v.claimName),
|
||||
persistentVolumeClaim: { claimName: sanitizeName(v.claimName) },
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function buildLabels(spec: ContainerSpec): Record<string, string> {
|
||||
return {
|
||||
[MCPCTL_LABEL]: 'true',
|
||||
@@ -128,9 +155,55 @@ function buildContainerSpec(spec: ContainerSpec) {
|
||||
container.ports = [{ containerPort: spec.containerPort }];
|
||||
}
|
||||
|
||||
if (spec.volumes && spec.volumes.length > 0) {
|
||||
container.volumeMounts = spec.volumes.map((v) => ({
|
||||
name: sanitizeName(v.claimName),
|
||||
mountPath: v.mountPath,
|
||||
}));
|
||||
}
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* PVC for a server volume. Created once per server and left in place when
|
||||
* instances come and go — that outliving is the entire point of the resource.
|
||||
*
|
||||
* ReadWriteOnce because the backing class is typically a block volume
|
||||
* (Longhorn, EBS). A server asking for >1 replica plus a volume cannot have
|
||||
* both; `instance.service` rejects that combination rather than silently
|
||||
* leaving replicas unschedulable.
|
||||
*/
|
||||
export function generatePvcSpec(
|
||||
volume: ContainerVolume,
|
||||
namespace: string,
|
||||
labels: Record<string, string> = {},
|
||||
): K8sPvcManifest {
|
||||
const storageClass = volume.storageClass ?? process.env['MCPD_VOLUME_STORAGE_CLASS'];
|
||||
return {
|
||||
apiVersion: 'v1',
|
||||
kind: 'PersistentVolumeClaim',
|
||||
metadata: {
|
||||
name: sanitizeName(volume.claimName),
|
||||
namespace,
|
||||
labels: {
|
||||
[MCPCTL_LABEL]: 'true',
|
||||
'app.kubernetes.io/managed-by': 'mcpctl',
|
||||
...labels,
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
accessModes: ['ReadWriteOnce'],
|
||||
resources: { requests: { storage: `${volume.sizeGb}Gi` } },
|
||||
// Only set when known: an empty string means "no class" to Kubernetes,
|
||||
// which is not the same as omitting the field (use the default class).
|
||||
...(storageClass !== undefined && storageClass !== ''
|
||||
? { storageClassName: storageClass }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function generatePodSpec(spec: ContainerSpec, namespace: string): K8sPodManifest {
|
||||
const labels = buildLabels(spec);
|
||||
return {
|
||||
@@ -146,6 +219,7 @@ export function generatePodSpec(spec: ContainerSpec, namespace: string): K8sPodM
|
||||
restartPolicy: 'Always',
|
||||
// MCP server pods don't need k8s API access
|
||||
automountServiceAccountToken: false,
|
||||
...buildPodVolumes(spec),
|
||||
// On mixed-arch clusters, constrain to the same arch as mcpd
|
||||
// (runner images are typically single-arch)
|
||||
...(process.env['MCPD_NODE_SELECTOR']
|
||||
@@ -179,6 +253,7 @@ export function generateDeploymentSpec(spec: ContainerSpec, namespace: string, r
|
||||
containers: [buildContainerSpec(spec)],
|
||||
restartPolicy: 'Always',
|
||||
automountServiceAccountToken: false,
|
||||
...buildPodVolumes(spec),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2,11 +2,37 @@
|
||||
* Container orchestrator abstraction. Implementations can back onto Docker, Podman, or Kubernetes.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A persistent volume mounted into an MCP server container.
|
||||
*
|
||||
* `claimName` is deliberately caller-supplied and derived from the *server*,
|
||||
* not the instance: instances are immutable and get recreated on any server
|
||||
* edit, so an instance-scoped volume would be destroyed exactly when the data
|
||||
* needs to survive. Backed by a Docker named volume or a Kubernetes PVC, and
|
||||
* never removed when an instance goes away.
|
||||
*/
|
||||
export interface ContainerVolume {
|
||||
/** Stable backing-volume name, shared by every instance of a server. */
|
||||
claimName: string;
|
||||
/** Absolute path to mount at inside the container. */
|
||||
mountPath: string;
|
||||
/** Requested size in GiB. Kubernetes only — Docker named volumes are unbounded. */
|
||||
sizeGb: number;
|
||||
/**
|
||||
* StorageClass for the PVC, defaulting to MCPD_VOLUME_STORAGE_CLASS.
|
||||
* Worth setting explicitly on clusters with more than one default class,
|
||||
* where an omitted class binds nondeterministically.
|
||||
*/
|
||||
storageClass?: string;
|
||||
}
|
||||
|
||||
export interface ContainerSpec {
|
||||
/** Docker/OCI image reference */
|
||||
image: string;
|
||||
/** Human-readable name (used as container name prefix) */
|
||||
name: string;
|
||||
/** Persistent volumes to mount (survive instance recreation) */
|
||||
volumes?: ContainerVolume[];
|
||||
/** Custom command to run (overrides image CMD) */
|
||||
command?: string[];
|
||||
/** Environment variables */
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import { HealthCheckSchema } from './template.schema.js';
|
||||
import { HealthCheckSchema, VolumeSpecSchema } from './template.schema.js';
|
||||
|
||||
const SecretRefSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
@@ -33,7 +33,16 @@ export const CreateMcpServerSchema = z.object({
|
||||
replicas: z.number().int().min(0).max(10).default(1),
|
||||
env: z.array(ServerEnvEntrySchema).default([]),
|
||||
healthCheck: HealthCheckSchema.optional(),
|
||||
});
|
||||
volumes: z.array(VolumeSpecSchema).default([]),
|
||||
}).refine(
|
||||
(s) => s.volumes.length === 0 || s.replicas <= 1,
|
||||
{
|
||||
message:
|
||||
'A server with volumes cannot have replicas > 1: the backing claim is ReadWriteOnce, '
|
||||
+ 'so the extra replicas would sit unschedulable on a volume they cannot mount.',
|
||||
path: ['replicas'],
|
||||
},
|
||||
);
|
||||
|
||||
export const UpdateMcpServerSchema = z.object({
|
||||
description: z.string().max(1000).optional(),
|
||||
@@ -48,6 +57,7 @@ export const UpdateMcpServerSchema = z.object({
|
||||
replicas: z.number().int().min(0).max(10).optional(),
|
||||
env: z.array(ServerEnvEntrySchema).optional(),
|
||||
healthCheck: HealthCheckSchema.nullable().optional(),
|
||||
volumes: z.array(VolumeSpecSchema).optional(),
|
||||
});
|
||||
|
||||
export type CreateMcpServerInput = z.infer<typeof CreateMcpServerSchema>;
|
||||
|
||||
@@ -7,6 +7,21 @@ const TemplateEnvEntrySchema = z.object({
|
||||
defaultValue: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* A persistent volume declared on a server (or template).
|
||||
*
|
||||
* `name` is scoped to the server; the backing Docker volume / Kubernetes PVC is
|
||||
* named `mcpctl-<server>-<name>` so it is stable across instance recreation.
|
||||
*/
|
||||
export const VolumeSpecSchema = z.object({
|
||||
name: z.string().min(1).max(50).regex(/^[a-z0-9-]+$/, 'Volume name must be lowercase alphanumeric with hyphens'),
|
||||
mountPath: z.string().min(1).max(200).regex(/^\//, 'mountPath must be absolute'),
|
||||
sizeGb: z.number().int().min(1).max(1024).default(10),
|
||||
storageClass: z.string().max(100).optional(),
|
||||
});
|
||||
|
||||
export type VolumeSpecInput = z.infer<typeof VolumeSpecSchema>;
|
||||
|
||||
export const HealthCheckSchema = z.object({
|
||||
tool: z.string().min(1),
|
||||
arguments: z.record(z.unknown()).default({}),
|
||||
@@ -32,6 +47,7 @@ export const CreateTemplateSchema = z.object({
|
||||
replicas: z.number().int().min(0).max(10).default(1),
|
||||
env: z.array(TemplateEnvEntrySchema).default([]),
|
||||
healthCheck: HealthCheckSchema.optional(),
|
||||
volumes: z.array(VolumeSpecSchema).default([]),
|
||||
});
|
||||
|
||||
export const UpdateTemplateSchema = CreateTemplateSchema.partial().omit({ name: true });
|
||||
|
||||
@@ -70,10 +70,11 @@ function mockOrchestrator(): McpOrchestrator {
|
||||
};
|
||||
}
|
||||
|
||||
function makeServer(overrides: Partial<{ id: string; name: string; replicas: number; dockerImage: string | null; externalUrl: string | null; transport: string; command: unknown; containerPort: number | null }> = {}) {
|
||||
function makeServer(overrides: Partial<{ id: string; name: string; replicas: number; dockerImage: string | null; externalUrl: string | null; transport: string; command: unknown; containerPort: number | null; volumes: unknown }> = {}) {
|
||||
return {
|
||||
id: overrides.id ?? 'srv-1',
|
||||
name: overrides.name ?? 'slack',
|
||||
volumes: overrides.volumes ?? [],
|
||||
dockerImage: overrides.dockerImage ?? 'ghcr.io/slack-mcp:latest',
|
||||
packageName: null,
|
||||
transport: overrides.transport ?? 'STDIO',
|
||||
@@ -143,6 +144,53 @@ describe('InstanceService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('volumes', () => {
|
||||
it('names the claim after the server, not the instance', async () => {
|
||||
// The whole point of the feature: instances are recreated on every server
|
||||
// edit, so an instance-scoped claim would be destroyed exactly when the
|
||||
// data needs to survive.
|
||||
vi.mocked(serverRepo.findById).mockResolvedValue(makeServer({
|
||||
name: 'docs',
|
||||
volumes: [{ name: 'data', mountPath: '/data', sizeGb: 20, storageClass: 'longhorn' }],
|
||||
}));
|
||||
vi.mocked(instanceRepo.findAll).mockResolvedValue([]);
|
||||
|
||||
await service.reconcile('srv-1');
|
||||
|
||||
const spec = vi.mocked(orchestrator.createContainer).mock.calls[0]![0];
|
||||
expect(spec.volumes).toEqual([
|
||||
{ claimName: 'mcpctl-docs-data', mountPath: '/data', sizeGb: 20, storageClass: 'longhorn' },
|
||||
]);
|
||||
// Container name carries the instance id; the claim must not.
|
||||
expect(spec.name).toContain('inst-1');
|
||||
expect(spec.volumes![0]!.claimName).not.toContain('inst-1');
|
||||
});
|
||||
|
||||
it('defaults size and leaves storageClass unset when unspecified', async () => {
|
||||
vi.mocked(serverRepo.findById).mockResolvedValue(makeServer({
|
||||
name: 'docs',
|
||||
volumes: [{ name: 'data', mountPath: '/data' }],
|
||||
}));
|
||||
vi.mocked(instanceRepo.findAll).mockResolvedValue([]);
|
||||
|
||||
await service.reconcile('srv-1');
|
||||
|
||||
const spec = vi.mocked(orchestrator.createContainer).mock.calls[0]![0];
|
||||
expect(spec.volumes![0]!.sizeGb).toBe(10);
|
||||
expect(spec.volumes![0]).not.toHaveProperty('storageClass');
|
||||
});
|
||||
|
||||
it('leaves the spec free of volumes when the server declares none', async () => {
|
||||
vi.mocked(serverRepo.findById).mockResolvedValue(makeServer({}));
|
||||
vi.mocked(instanceRepo.findAll).mockResolvedValue([]);
|
||||
|
||||
await service.reconcile('srv-1');
|
||||
|
||||
const spec = vi.mocked(orchestrator.createContainer).mock.calls[0]![0];
|
||||
expect(spec.volumes).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reconcile', () => {
|
||||
it('starts instances when below desired replicas', async () => {
|
||||
vi.mocked(serverRepo.findById).mockResolvedValue(makeServer({ replicas: 2 }));
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
generatePodSpec,
|
||||
generateDeploymentSpec,
|
||||
generateNamespaceSpec,
|
||||
generatePvcSpec,
|
||||
formatMemory,
|
||||
formatCpu,
|
||||
sanitizeName,
|
||||
@@ -156,6 +157,70 @@ describe('generateDeploymentSpec', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
Reference in New Issue
Block a user