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:
Michal
2026-08-08 10:36:21 +01:00
parent fceb3cfb95
commit ffcd0f985b
6 changed files with 452 additions and 9 deletions

View File

@@ -2917,9 +2917,9 @@
"testStrategy": "Type tests: verify ContainerSpec accepts all new optional fields, verify existing code still compiles without providing new fields.", "testStrategy": "Type tests: verify ContainerSpec accepts all new optional fields, verify existing code still compiles without providing new fields.",
"priority": "high", "priority": "high",
"dependencies": [], "dependencies": [],
"status": "pending", "status": "done",
"subtasks": [], "subtasks": [],
"updatedAt": "2026-08-08T08:55:58.627Z" "updatedAt": "2026-08-08T09:36:21.123Z"
}, },
{ {
"id": "141", "id": "141",
@@ -2931,9 +2931,9 @@
"dependencies": [ "dependencies": [
"140" "140"
], ],
"status": "pending", "status": "done",
"subtasks": [], "subtasks": [],
"updatedAt": "2026-08-08T08:55:58.627Z" "updatedAt": "2026-08-08T09:36:21.132Z"
}, },
{ {
"id": "142", "id": "142",
@@ -2945,9 +2945,9 @@
"dependencies": [ "dependencies": [
"140" "140"
], ],
"status": "pending", "status": "done",
"subtasks": [], "subtasks": [],
"updatedAt": "2026-08-08T08:55:58.627Z" "updatedAt": "2026-08-08T09:36:21.139Z"
}, },
{ {
"id": "143", "id": "143",
@@ -3251,9 +3251,9 @@
], ],
"metadata": { "metadata": {
"version": "1.0.0", "version": "1.0.0",
"lastModified": "2026-08-08T09:29:43.398Z", "lastModified": "2026-08-08T09:36:21.139Z",
"taskCount": 162, "taskCount": 162,
"completedCount": 104, "completedCount": 107,
"tags": [ "tags": [
"master" "master"
] ]

View File

@@ -6,12 +6,28 @@ import type {
ContainerInfo, ContainerInfo,
ContainerLogs, ContainerLogs,
ExecResult, ExecResult,
InitContainerSpec,
InteractiveExec, InteractiveExec,
} from '../orchestrator.js'; } from '../orchestrator.js';
import { DEFAULT_MEMORY_LIMIT } from '../orchestrator.js'; import { DEFAULT_MEMORY_LIMIT } from '../orchestrator.js';
const MCPCTL_LABEL = 'mcpctl.managed'; 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'] { function mapState(state: string | undefined): ContainerInfo['state'] {
switch (state?.toLowerCase()) { switch (state?.toLowerCase()) {
case 'running': case 'running':
@@ -77,6 +93,15 @@ export class DockerContainerManager implements McpOrchestrator {
? Object.entries(spec.env).map(([k, v]) => `${k}=${v}`) ? Object.entries(spec.env).map(([k, v]) => `${k}=${v}`)
: undefined; : 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 = { const createOpts: Docker.ContainerCreateOptions = {
Image: spec.image, Image: spec.image,
name: spec.name, name: spec.name,
@@ -91,11 +116,24 @@ export class DockerContainerManager implements McpOrchestrator {
Memory: memoryLimit, Memory: memoryLimit,
...(nanoCpus ? { NanoCpus: nanoCpus } : {}), ...(nanoCpus ? { NanoCpus: nanoCpus } : {}),
NetworkMode: spec.network ?? 'bridge', NetworkMode: spec.network ?? 'bridge',
...(mounts.length > 0 ? { Mounts: mounts } : {}),
...(spec.restartPolicy ? { RestartPolicy: toDockerRestartPolicy(spec.restartPolicy) } : {}),
}, },
}; };
if (spec.command) { if (spec.command) {
createOpts.Cmd = 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); const container = await this.docker.createContainer(createOpts);
@@ -104,6 +142,49 @@ export class DockerContainerManager implements McpOrchestrator {
return this.inspectContainer(container.id); 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> { async stopContainer(containerId: string, timeoutSeconds = 10): Promise<void> {
const container = this.docker.getContainer(containerId); const container = this.docker.getContainer(containerId);
await container.stop({ t: timeoutSeconds }); await container.stop({ t: timeoutSeconds });

View File

@@ -20,6 +20,8 @@ export interface K8sPodManifest {
env?: Array<{ name: string; value: string }>; env?: Array<{ name: string; value: string }>;
ports?: Array<{ containerPort: number }>; ports?: Array<{ containerPort: number }>;
stdin?: boolean; stdin?: boolean;
workingDir?: string;
volumeMounts?: Array<{ name: string; mountPath: string }>;
resources: { resources: {
limits: { memory: string; cpu: string }; limits: { memory: string; cpu: string };
requests: { memory: string; cpu: string }; requests: { memory: string; cpu: string };
@@ -32,6 +34,14 @@ export interface K8sPodManifest {
seccompProfile: { type: string }; 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'; restartPolicy: 'Always' | 'Never' | 'OnFailure';
automountServiceAccountToken: boolean; automountServiceAccountToken: boolean;
nodeSelector?: Record<string, string>; nodeSelector?: Record<string, string>;
@@ -128,9 +138,45 @@ function buildContainerSpec(spec: ContainerSpec) {
container.ports = [{ containerPort: spec.containerPort }]; 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; 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 { export function generatePodSpec(spec: ContainerSpec, namespace: string): K8sPodManifest {
const labels = buildLabels(spec); const labels = buildLabels(spec);
return { return {
@@ -143,7 +189,10 @@ export function generatePodSpec(spec: ContainerSpec, namespace: string): K8sPodM
}, },
spec: { spec: {
containers: [buildContainerSpec(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 // MCP server pods don't need k8s API access
automountServiceAccountToken: false, automountServiceAccountToken: false,
// On mixed-arch clusters, constrain to the same arch as mcpd // 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 }, metadata: { labels },
spec: { spec: {
containers: [buildContainerSpec(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', restartPolicy: 'Always',
automountServiceAccountToken: false, automountServiceAccountToken: false,
}, },

View File

@@ -23,6 +23,41 @@ export interface ContainerSpec {
labels?: Record<string, string>; labels?: Record<string, string>;
/** Network name to attach to */ /** Network name to attach to */
network?: string; 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 { export interface ContainerInfo {

View 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');
});
});

View File

@@ -164,3 +164,108 @@ describe('generateNamespaceSpec', () => {
expect(ns.metadata.name).toBe('mcpctl-prod'); 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: {} }]);
});
});