feat(orchestrator): ephemeral session pods — restart policy, volumes, init containers
An MCP server container is long-lived and restarts forever; an agent session pod is the opposite — it does a unit of work and dies. ContainerSpec could not express that: restartPolicy was hardcoded 'Always' in generatePodSpec, and there were no volume, workdir, or init-container fields at all. Adds four optional fields to ContainerSpec (restartPolicy, workdir, volumes, initContainers) and implements them in both backends. All optional, so every existing caller is byte-identical — generatePodSpec still emits 'Always' unless asked otherwise, and omits volumes/initContainers keys entirely when unused. Kubernetes gets this nearly free: initContainers run to completion in declaration order before any app container starts, so "clone the repos, then work them" needs no sequencing logic from us. generateDeploymentSpec deliberately ignores spec.restartPolicy — k8s rejects anything but 'Always' on a Deployment pod template — and says so in a comment. Docker has no init containers, so they are emulated as sequential runs sharing the same volumes. The important property is the failure path: a non-zero init exit aborts BEFORE the main container is created, and the error carries the init container's last 50 log lines. Otherwise a failed git clone would leave an empty workspace that looks exactly like a successful one, and the agent would start work against nothing. Named Docker volumes are namespaced by container name to stand in for emptyDir. Tests: 10 k8s manifest tests (defaults unchanged, each restart policy, volume and mount emission, init ordering, name sanitization, security context still hardened) and 9 Docker tests including the abort-before-main case and log propagation. Local: build clean, workspace 2310 passed (+19), lint unchanged at the repo's pre-existing 869. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
This commit is contained in:
@@ -2917,9 +2917,9 @@
|
||||
"testStrategy": "Type tests: verify ContainerSpec accepts all new optional fields, verify existing code still compiles without providing new fields.",
|
||||
"priority": "high",
|
||||
"dependencies": [],
|
||||
"status": "pending",
|
||||
"status": "done",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-08-08T08:55:58.627Z"
|
||||
"updatedAt": "2026-08-08T09:36:21.123Z"
|
||||
},
|
||||
{
|
||||
"id": "141",
|
||||
@@ -2931,9 +2931,9 @@
|
||||
"dependencies": [
|
||||
"140"
|
||||
],
|
||||
"status": "pending",
|
||||
"status": "done",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-08-08T08:55:58.627Z"
|
||||
"updatedAt": "2026-08-08T09:36:21.132Z"
|
||||
},
|
||||
{
|
||||
"id": "142",
|
||||
@@ -2945,9 +2945,9 @@
|
||||
"dependencies": [
|
||||
"140"
|
||||
],
|
||||
"status": "pending",
|
||||
"status": "done",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-08-08T08:55:58.627Z"
|
||||
"updatedAt": "2026-08-08T09:36:21.139Z"
|
||||
},
|
||||
{
|
||||
"id": "143",
|
||||
@@ -3251,9 +3251,9 @@
|
||||
],
|
||||
"metadata": {
|
||||
"version": "1.0.0",
|
||||
"lastModified": "2026-08-08T09:29:43.398Z",
|
||||
"lastModified": "2026-08-08T09:36:21.139Z",
|
||||
"taskCount": 162,
|
||||
"completedCount": 104,
|
||||
"completedCount": 107,
|
||||
"tags": [
|
||||
"master"
|
||||
]
|
||||
|
||||
@@ -6,12 +6,28 @@ import type {
|
||||
ContainerInfo,
|
||||
ContainerLogs,
|
||||
ExecResult,
|
||||
InitContainerSpec,
|
||||
InteractiveExec,
|
||||
} from '../orchestrator.js';
|
||||
import { DEFAULT_MEMORY_LIMIT } from '../orchestrator.js';
|
||||
|
||||
const MCPCTL_LABEL = 'mcpctl.managed';
|
||||
|
||||
/**
|
||||
* Map the orchestrator's k8s-flavoured restart policy onto Docker's.
|
||||
* 'Never' becomes 'no' — a session container that exits stays exited.
|
||||
*/
|
||||
function toDockerRestartPolicy(policy: NonNullable<ContainerSpec['restartPolicy']>): { Name: string } {
|
||||
switch (policy) {
|
||||
case 'Never':
|
||||
return { Name: 'no' };
|
||||
case 'OnFailure':
|
||||
return { Name: 'on-failure' };
|
||||
default:
|
||||
return { Name: 'always' };
|
||||
}
|
||||
}
|
||||
|
||||
function mapState(state: string | undefined): ContainerInfo['state'] {
|
||||
switch (state?.toLowerCase()) {
|
||||
case 'running':
|
||||
@@ -77,6 +93,15 @@ export class DockerContainerManager implements McpOrchestrator {
|
||||
? Object.entries(spec.env).map(([k, v]) => `${k}=${v}`)
|
||||
: undefined;
|
||||
|
||||
// Named Docker volumes stand in for k8s emptyDir: same lifetime intent
|
||||
// (scratch space shared across the pod), removed by removeContainer's `v`.
|
||||
const volumeName = (specName: string, vol: string): string => `${specName}-${vol}`;
|
||||
const mounts: Docker.MountSettings[] = (spec.volumes ?? []).map((v) => ({
|
||||
Type: 'volume' as const,
|
||||
Source: volumeName(spec.name, v.name),
|
||||
Target: v.mountPath,
|
||||
}));
|
||||
|
||||
const createOpts: Docker.ContainerCreateOptions = {
|
||||
Image: spec.image,
|
||||
name: spec.name,
|
||||
@@ -91,11 +116,24 @@ export class DockerContainerManager implements McpOrchestrator {
|
||||
Memory: memoryLimit,
|
||||
...(nanoCpus ? { NanoCpus: nanoCpus } : {}),
|
||||
NetworkMode: spec.network ?? 'bridge',
|
||||
...(mounts.length > 0 ? { Mounts: mounts } : {}),
|
||||
...(spec.restartPolicy ? { RestartPolicy: toDockerRestartPolicy(spec.restartPolicy) } : {}),
|
||||
},
|
||||
};
|
||||
if (spec.command) {
|
||||
createOpts.Cmd = spec.command;
|
||||
}
|
||||
if (spec.workdir !== undefined && spec.workdir !== '') {
|
||||
createOpts.WorkingDir = spec.workdir;
|
||||
}
|
||||
|
||||
// Docker has no init containers. Run each to completion, in order, sharing
|
||||
// the same volumes — the main container only starts once they all succeed.
|
||||
// A non-zero exit aborts before the main container is ever created, so a
|
||||
// failed clone can't be mistaken for an empty workspace.
|
||||
for (const init of spec.initContainers ?? []) {
|
||||
await this.runInitContainer(spec, init, mounts);
|
||||
}
|
||||
|
||||
const container = await this.docker.createContainer(createOpts);
|
||||
|
||||
@@ -104,6 +142,49 @@ export class DockerContainerManager implements McpOrchestrator {
|
||||
return this.inspectContainer(container.id);
|
||||
}
|
||||
|
||||
/** Run one init container to completion; throw on non-zero exit. */
|
||||
private async runInitContainer(
|
||||
spec: ContainerSpec,
|
||||
init: InitContainerSpec,
|
||||
podMounts: Docker.MountSettings[],
|
||||
): Promise<void> {
|
||||
// Honour the init container's own volumeMounts when given, otherwise give
|
||||
// it the same view as the main container.
|
||||
const mounts: Docker.MountSettings[] = init.volumeMounts
|
||||
? init.volumeMounts.map((m) => ({
|
||||
Type: 'volume' as const,
|
||||
Source: `${spec.name}-${m.name}`,
|
||||
Target: m.mountPath,
|
||||
}))
|
||||
: podMounts;
|
||||
|
||||
const container = await this.docker.createContainer({
|
||||
Image: init.image,
|
||||
name: `${spec.name}-init-${init.name}`,
|
||||
Cmd: init.command,
|
||||
...(init.env ? { Env: Object.entries(init.env).map(([k, v]) => `${k}=${v}`) } : {}),
|
||||
Labels: { [MCPCTL_LABEL]: 'true', ...spec.labels },
|
||||
HostConfig: {
|
||||
...(mounts.length > 0 ? { Mounts: mounts } : {}),
|
||||
NetworkMode: spec.network ?? 'bridge',
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await container.start();
|
||||
const result = (await container.wait()) as { StatusCode?: number };
|
||||
const code = result.StatusCode ?? 0;
|
||||
if (code !== 0) {
|
||||
const logs = await this.getContainerLogs(container.id, { tail: 50 }).catch(() => null);
|
||||
const detail = logs ? `\n${logs.stdout}${logs.stderr}`.trimEnd() : '';
|
||||
throw new Error(`init container '${init.name}' exited with code ${String(code)}${detail}`);
|
||||
}
|
||||
} finally {
|
||||
// Keep the volumes (`v: false`) — that is the whole point of the run.
|
||||
await container.remove({ force: true, v: false }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async stopContainer(containerId: string, timeoutSeconds = 10): Promise<void> {
|
||||
const container = this.docker.getContainer(containerId);
|
||||
await container.stop({ t: timeoutSeconds });
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface K8sPodManifest {
|
||||
env?: Array<{ name: string; value: string }>;
|
||||
ports?: Array<{ containerPort: number }>;
|
||||
stdin?: boolean;
|
||||
workingDir?: string;
|
||||
volumeMounts?: Array<{ name: string; mountPath: string }>;
|
||||
resources: {
|
||||
limits: { memory: string; cpu: string };
|
||||
requests: { memory: string; cpu: string };
|
||||
@@ -32,6 +34,14 @@ export interface K8sPodManifest {
|
||||
seccompProfile: { type: string };
|
||||
};
|
||||
}>;
|
||||
initContainers?: Array<{
|
||||
name: string;
|
||||
image: string;
|
||||
command: string[];
|
||||
env?: Array<{ name: string; value: string }>;
|
||||
volumeMounts?: Array<{ name: string; mountPath: string }>;
|
||||
}>;
|
||||
volumes?: Array<{ name: string; emptyDir: Record<string, never> }>;
|
||||
restartPolicy: 'Always' | 'Never' | 'OnFailure';
|
||||
automountServiceAccountToken: boolean;
|
||||
nodeSelector?: Record<string, string>;
|
||||
@@ -128,9 +138,45 @@ function buildContainerSpec(spec: ContainerSpec) {
|
||||
container.ports = [{ containerPort: spec.containerPort }];
|
||||
}
|
||||
|
||||
if (spec.workdir !== undefined && spec.workdir !== '') {
|
||||
container.workingDir = spec.workdir;
|
||||
}
|
||||
|
||||
// The main container sees every declared volume; init containers pick the
|
||||
// subset they need via their own volumeMounts.
|
||||
if (spec.volumes && spec.volumes.length > 0) {
|
||||
container.volumeMounts = spec.volumes.map((v) => ({ name: v.name, mountPath: v.mountPath }));
|
||||
}
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
/** Translate ContainerSpec volumes/initContainers into pod-spec fragments. */
|
||||
function buildPodExtras(spec: ContainerSpec): Partial<K8sPodManifest['spec']> {
|
||||
const extras: Partial<K8sPodManifest['spec']> = {};
|
||||
|
||||
if (spec.volumes && spec.volumes.length > 0) {
|
||||
extras.volumes = spec.volumes.map((v) => ({ name: v.name, emptyDir: {} }));
|
||||
}
|
||||
|
||||
// Kubernetes runs initContainers to completion, in declaration order, before
|
||||
// any app container starts — so "clone the repos, then work them" needs no
|
||||
// sequencing logic on our side.
|
||||
if (spec.initContainers && spec.initContainers.length > 0) {
|
||||
extras.initContainers = spec.initContainers.map((c) => ({
|
||||
name: sanitizeName(c.name),
|
||||
image: c.image,
|
||||
command: c.command,
|
||||
...(c.env && Object.keys(c.env).length > 0
|
||||
? { env: Object.entries(c.env).map(([name, value]) => ({ name, value })) }
|
||||
: {}),
|
||||
...(c.volumeMounts && c.volumeMounts.length > 0 ? { volumeMounts: c.volumeMounts } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
return extras;
|
||||
}
|
||||
|
||||
export function generatePodSpec(spec: ContainerSpec, namespace: string): K8sPodManifest {
|
||||
const labels = buildLabels(spec);
|
||||
return {
|
||||
@@ -143,7 +189,10 @@ export function generatePodSpec(spec: ContainerSpec, namespace: string): K8sPodM
|
||||
},
|
||||
spec: {
|
||||
containers: [buildContainerSpec(spec)],
|
||||
restartPolicy: 'Always',
|
||||
...buildPodExtras(spec),
|
||||
// Long-lived MCP servers restart forever; ephemeral session pods pass
|
||||
// 'Never' so a finished or crashed run stays finished.
|
||||
restartPolicy: spec.restartPolicy ?? 'Always',
|
||||
// MCP server pods don't need k8s API access
|
||||
automountServiceAccountToken: false,
|
||||
// On mixed-arch clusters, constrain to the same arch as mcpd
|
||||
@@ -177,6 +226,9 @@ export function generateDeploymentSpec(spec: ContainerSpec, namespace: string, r
|
||||
metadata: { labels },
|
||||
spec: {
|
||||
containers: [buildContainerSpec(spec)],
|
||||
...buildPodExtras(spec),
|
||||
// Deployments require 'Always' on their pod template — k8s rejects
|
||||
// anything else — so spec.restartPolicy is deliberately ignored here.
|
||||
restartPolicy: 'Always',
|
||||
automountServiceAccountToken: false,
|
||||
},
|
||||
|
||||
@@ -23,6 +23,41 @@ export interface ContainerSpec {
|
||||
labels?: Record<string, string>;
|
||||
/** Network name to attach to */
|
||||
network?: string;
|
||||
|
||||
// ── Ephemeral session pods (agentic teams) ────────────────────────────────
|
||||
// MCP server containers are long-lived and restart forever. An agent session
|
||||
// pod is the opposite: it does a unit of work and dies. These fields are all
|
||||
// optional, so existing callers are unaffected.
|
||||
|
||||
/**
|
||||
* Restart behaviour. Defaults to 'Always' (long-lived MCP servers).
|
||||
* Session pods use 'Never' so a finished or crashed run stays finished.
|
||||
*/
|
||||
restartPolicy?: 'Always' | 'Never' | 'OnFailure';
|
||||
/** Working directory for the main container. */
|
||||
workdir?: string;
|
||||
/** Scratch volumes, e.g. the shared workspace an init container clones into. */
|
||||
volumes?: VolumeSpec[];
|
||||
/**
|
||||
* Containers that must run to completion, in order, before the main container
|
||||
* starts. Used to clone the project's repositories into the workspace volume.
|
||||
*/
|
||||
initContainers?: InitContainerSpec[];
|
||||
}
|
||||
|
||||
export interface VolumeSpec {
|
||||
name: string;
|
||||
mountPath: string;
|
||||
/** Only ephemeral scratch space for now; the pod's lifetime bounds it. */
|
||||
kind: 'emptyDir';
|
||||
}
|
||||
|
||||
export interface InitContainerSpec {
|
||||
name: string;
|
||||
image: string;
|
||||
command: string[];
|
||||
env?: Record<string, string>;
|
||||
volumeMounts?: Array<{ name: string; mountPath: string }>;
|
||||
}
|
||||
|
||||
export interface ContainerInfo {
|
||||
|
||||
170
src/mcpd/tests/docker-ephemeral.test.ts
Normal file
170
src/mcpd/tests/docker-ephemeral.test.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
import { DockerContainerManager } from '../src/services/docker/container-manager.js';
|
||||
import type { ContainerSpec } from '../src/services/orchestrator.js';
|
||||
|
||||
/**
|
||||
* Docker has no init containers, so the manager emulates them as sequential
|
||||
* runs against the same volumes. These tests pin the translation and — more
|
||||
* importantly — that a failed init aborts before the main container is ever
|
||||
* created, so a failed clone can never be mistaken for an empty workspace.
|
||||
*/
|
||||
const state = vi.hoisted(() => ({
|
||||
/** Every createContainer option object, in call order. */
|
||||
created: [] as Array<Record<string, unknown>>,
|
||||
/** Exit codes handed out by container.wait(), in order. */
|
||||
exitCodes: [] as number[],
|
||||
started: [] as string[],
|
||||
}));
|
||||
|
||||
vi.mock('dockerode', () => {
|
||||
const makeContainer = (name: string) => ({
|
||||
id: `ctr-${name}`,
|
||||
start: vi.fn(async () => { state.started.push(name); }),
|
||||
wait: vi.fn(async () => ({ StatusCode: state.exitCodes.shift() ?? 0 })),
|
||||
stop: vi.fn(async () => {}),
|
||||
remove: vi.fn(async () => {}),
|
||||
inspect: vi.fn(async () => ({
|
||||
Id: `ctr-${name}`,
|
||||
Name: `/${name}`,
|
||||
State: { Status: 'running' },
|
||||
Created: '2024-01-01T00:00:00Z',
|
||||
NetworkSettings: { Ports: {} },
|
||||
})),
|
||||
logs: vi.fn(async () => Buffer.from('clone failed: repository not found')),
|
||||
});
|
||||
|
||||
class MockDocker {
|
||||
modem = {
|
||||
followProgress: vi.fn((_s: unknown, cb: (e: Error | null) => void) => { cb(null); }),
|
||||
};
|
||||
ping = vi.fn(async () => 'OK');
|
||||
pull = vi.fn(async () => ({}));
|
||||
createContainer = vi.fn(async (opts: Record<string, unknown>) => {
|
||||
state.created.push(opts);
|
||||
return makeContainer(String(opts['name'] ?? 'anon'));
|
||||
});
|
||||
getContainer = vi.fn((id: string) => makeContainer(id));
|
||||
}
|
||||
|
||||
return { default: MockDocker };
|
||||
});
|
||||
|
||||
const WORKSPACE = { name: 'workspace', mountPath: '/workspace', kind: 'emptyDir' } as const;
|
||||
|
||||
describe('DockerContainerManager — ephemeral session containers', () => {
|
||||
let manager: DockerContainerManager;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
state.created = [];
|
||||
state.exitCodes = [];
|
||||
state.started = [];
|
||||
manager = new DockerContainerManager();
|
||||
});
|
||||
|
||||
const hostConfig = (i: number): Record<string, unknown> =>
|
||||
state.created[i]?.['HostConfig'] as Record<string, unknown>;
|
||||
|
||||
it('leaves restart policy unset when the caller does not ask — MCP servers keep today\'s behaviour', async () => {
|
||||
await manager.createContainer({ image: 'x:latest', name: 'srv' });
|
||||
expect(hostConfig(0)['RestartPolicy']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('maps Never to Docker\'s "no" so a finished session stays finished', async () => {
|
||||
await manager.createContainer({ image: 'x:latest', name: 'sess', restartPolicy: 'Never' });
|
||||
expect(hostConfig(0)['RestartPolicy']).toEqual({ Name: 'no' });
|
||||
});
|
||||
|
||||
it('maps OnFailure and Always', async () => {
|
||||
await manager.createContainer({ image: 'x:latest', name: 'a', restartPolicy: 'OnFailure' });
|
||||
expect(hostConfig(0)['RestartPolicy']).toEqual({ Name: 'on-failure' });
|
||||
await manager.createContainer({ image: 'x:latest', name: 'b', restartPolicy: 'Always' });
|
||||
expect(hostConfig(1)['RestartPolicy']).toEqual({ Name: 'always' });
|
||||
});
|
||||
|
||||
it('sets the working directory', async () => {
|
||||
await manager.createContainer({ image: 'x:latest', name: 'sess', workdir: '/workspace/api' });
|
||||
expect(state.created[0]?.['WorkingDir']).toBe('/workspace/api');
|
||||
});
|
||||
|
||||
it('translates volumes into namespaced Docker mounts', async () => {
|
||||
await manager.createContainer({ image: 'x:latest', name: 'sess', volumes: [WORKSPACE] });
|
||||
expect(hostConfig(0)['Mounts']).toEqual([
|
||||
{ Type: 'volume', Source: 'sess-workspace', Target: '/workspace' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('runs init containers to completion, in order, before the main container', async () => {
|
||||
const spec: ContainerSpec = {
|
||||
image: 'agent:latest',
|
||||
name: 'sess',
|
||||
volumes: [WORKSPACE],
|
||||
initContainers: [
|
||||
{
|
||||
name: 'clone-api',
|
||||
image: 'alpine/git',
|
||||
command: ['git', 'clone', 'https://git.example/api.git'],
|
||||
env: { GIT_TERMINAL_PROMPT: '0' },
|
||||
volumeMounts: [{ name: 'workspace', mountPath: '/workspace' }],
|
||||
},
|
||||
{ name: 'clone-web', image: 'alpine/git', command: ['git', 'clone', 'https://git.example/web.git'] },
|
||||
],
|
||||
};
|
||||
|
||||
await manager.createContainer(spec);
|
||||
|
||||
expect(state.created.map((c) => c['name'])).toEqual([
|
||||
'sess-init-clone-api',
|
||||
'sess-init-clone-web',
|
||||
'sess',
|
||||
]);
|
||||
expect(state.created[0]?.['Cmd']).toEqual(['git', 'clone', 'https://git.example/api.git']);
|
||||
expect(state.created[0]?.['Env']).toEqual(['GIT_TERMINAL_PROMPT=0']);
|
||||
// Explicit volumeMounts are honoured, and namespaced the same way.
|
||||
expect((state.created[0]?.['HostConfig'] as Record<string, unknown>)['Mounts']).toEqual([
|
||||
{ Type: 'volume', Source: 'sess-workspace', Target: '/workspace' },
|
||||
]);
|
||||
// No volumeMounts declared → inherits the main container's view.
|
||||
expect((state.created[1]?.['HostConfig'] as Record<string, unknown>)['Mounts']).toEqual([
|
||||
{ Type: 'volume', Source: 'sess-workspace', Target: '/workspace' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('aborts before creating the main container when an init container fails', async () => {
|
||||
state.exitCodes = [128]; // git clone: repository not found
|
||||
|
||||
await expect(
|
||||
manager.createContainer({
|
||||
image: 'agent:latest',
|
||||
name: 'sess',
|
||||
volumes: [WORKSPACE],
|
||||
initContainers: [
|
||||
{ name: 'clone', image: 'alpine/git', command: ['git', 'clone', 'bad'] },
|
||||
{ name: 'never-runs', image: 'alpine/git', command: ['true'] },
|
||||
],
|
||||
}),
|
||||
).rejects.toThrow(/init container 'clone' exited with code 128/);
|
||||
|
||||
// The failure must stop the chain — no second init, and crucially no main
|
||||
// container, so an empty workspace can't be mistaken for a successful clone.
|
||||
expect(state.created.map((c) => c['name'])).toEqual(['sess-init-clone']);
|
||||
});
|
||||
|
||||
it('includes init container logs in the failure so the cause is visible', async () => {
|
||||
state.exitCodes = [1];
|
||||
await expect(
|
||||
manager.createContainer({
|
||||
image: 'agent:latest',
|
||||
name: 'sess',
|
||||
initContainers: [{ name: 'clone', image: 'alpine/git', command: ['git', 'clone', 'bad'] }],
|
||||
}),
|
||||
).rejects.toThrow(/repository not found/);
|
||||
});
|
||||
|
||||
it('creates no init containers when none are declared', async () => {
|
||||
await manager.createContainer({ image: 'x:latest', name: 'plain' });
|
||||
expect(state.created).toHaveLength(1);
|
||||
expect(state.created[0]?.['name']).toBe('plain');
|
||||
});
|
||||
});
|
||||
@@ -164,3 +164,108 @@ describe('generateNamespaceSpec', () => {
|
||||
expect(ns.metadata.name).toBe('mcpctl-prod');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generatePodSpec — ephemeral session pods', () => {
|
||||
it('defaults to restartPolicy Always so existing MCP server pods are unchanged', () => {
|
||||
expect(generatePodSpec(baseSpec, 'ns').spec.restartPolicy).toBe('Always');
|
||||
});
|
||||
|
||||
it('honours an explicit restartPolicy', () => {
|
||||
for (const policy of ['Never', 'OnFailure', 'Always'] as const) {
|
||||
const pod = generatePodSpec({ ...baseSpec, restartPolicy: policy }, 'ns');
|
||||
expect(pod.spec.restartPolicy).toBe(policy);
|
||||
}
|
||||
});
|
||||
|
||||
it('omits volumes and initContainers entirely when not requested', () => {
|
||||
const pod = generatePodSpec(baseSpec, 'ns');
|
||||
expect(pod.spec.volumes).toBeUndefined();
|
||||
expect(pod.spec.initContainers).toBeUndefined();
|
||||
expect(pod.spec.containers[0]?.volumeMounts).toBeUndefined();
|
||||
expect(pod.spec.containers[0]?.workingDir).toBeUndefined();
|
||||
});
|
||||
|
||||
it('declares emptyDir volumes and mounts them into the main container', () => {
|
||||
const pod = generatePodSpec(
|
||||
{ ...baseSpec, volumes: [{ name: 'workspace', mountPath: '/workspace', kind: 'emptyDir' }] },
|
||||
'ns',
|
||||
);
|
||||
expect(pod.spec.volumes).toEqual([{ name: 'workspace', emptyDir: {} }]);
|
||||
expect(pod.spec.containers[0]?.volumeMounts).toEqual([
|
||||
{ name: 'workspace', mountPath: '/workspace' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('sets the container working directory', () => {
|
||||
const pod = generatePodSpec({ ...baseSpec, workdir: '/workspace/repo' }, 'ns');
|
||||
expect(pod.spec.containers[0]?.workingDir).toBe('/workspace/repo');
|
||||
});
|
||||
|
||||
it('emits init containers in declaration order — k8s runs them to completion first', () => {
|
||||
const pod = generatePodSpec(
|
||||
{
|
||||
...baseSpec,
|
||||
volumes: [{ name: 'workspace', mountPath: '/workspace', kind: 'emptyDir' }],
|
||||
initContainers: [
|
||||
{
|
||||
name: 'clone-api',
|
||||
image: 'alpine/git:latest',
|
||||
command: ['git', 'clone', 'https://git.example/api.git', '/workspace/api'],
|
||||
env: { GIT_TERMINAL_PROMPT: '0' },
|
||||
volumeMounts: [{ name: 'workspace', mountPath: '/workspace' }],
|
||||
},
|
||||
{
|
||||
name: 'clone-web',
|
||||
image: 'alpine/git:latest',
|
||||
command: ['git', 'clone', 'https://git.example/web.git', '/workspace/web'],
|
||||
},
|
||||
],
|
||||
},
|
||||
'ns',
|
||||
);
|
||||
|
||||
expect(pod.spec.initContainers).toHaveLength(2);
|
||||
expect(pod.spec.initContainers?.map((c) => c.name)).toEqual(['clone-api', 'clone-web']);
|
||||
expect(pod.spec.initContainers?.[0]?.env).toEqual([{ name: 'GIT_TERMINAL_PROMPT', value: '0' }]);
|
||||
expect(pod.spec.initContainers?.[0]?.volumeMounts).toEqual([
|
||||
{ name: 'workspace', mountPath: '/workspace' },
|
||||
]);
|
||||
// No env / no mounts declared → the keys are absent rather than empty.
|
||||
expect(pod.spec.initContainers?.[1]?.env).toBeUndefined();
|
||||
expect(pod.spec.initContainers?.[1]?.volumeMounts).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sanitizes init container names like every other k8s name', () => {
|
||||
const pod = generatePodSpec(
|
||||
{
|
||||
...baseSpec,
|
||||
initContainers: [{ name: 'Clone_My Repo', image: 'alpine/git', command: ['true'] }],
|
||||
},
|
||||
'ns',
|
||||
);
|
||||
expect(pod.spec.initContainers?.[0]?.name).toBe(sanitizeName('Clone_My Repo'));
|
||||
});
|
||||
|
||||
it('keeps a session pod hardened — security context is not relaxed', () => {
|
||||
const pod = generatePodSpec({ ...baseSpec, restartPolicy: 'Never' }, 'ns');
|
||||
const sc = pod.spec.containers[0]?.securityContext;
|
||||
expect(sc?.allowPrivilegeEscalation).toBe(false);
|
||||
expect(sc?.capabilities.drop).toEqual(['ALL']);
|
||||
expect(pod.spec.automountServiceAccountToken).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateDeploymentSpec — restartPolicy', () => {
|
||||
it('always emits Always, since k8s rejects anything else on a Deployment template', () => {
|
||||
const dep = generateDeploymentSpec({ ...baseSpec, restartPolicy: 'Never' }, 'ns');
|
||||
expect(dep.spec.template.spec.restartPolicy).toBe('Always');
|
||||
});
|
||||
|
||||
it('still carries volumes through to the pod template', () => {
|
||||
const dep = generateDeploymentSpec(
|
||||
{ ...baseSpec, volumes: [{ name: 'cache', mountPath: '/cache', kind: 'emptyDir' }] },
|
||||
'ns',
|
||||
);
|
||||
expect(dep.spec.template.spec.volumes).toEqual([{ name: 'cache', emptyDir: {} }]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user