From ffcd0f985b1fe872abfca057854e600e1d5ea298 Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 8 Aug 2026 10:36:21 +0100 Subject: [PATCH] =?UTF-8?q?feat(orchestrator):=20ephemeral=20session=20pod?= =?UTF-8?q?s=20=E2=80=94=20restart=20policy,=20volumes,=20init=20container?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p --- .taskmaster/tasks/tasks.json | 16 +- .../src/services/docker/container-manager.ts | 81 +++++++++ .../src/services/k8s/manifest-generator.ts | 54 +++++- src/mcpd/src/services/orchestrator.ts | 35 ++++ src/mcpd/tests/docker-ephemeral.test.ts | 170 ++++++++++++++++++ src/mcpd/tests/k8s-manifest.test.ts | 105 +++++++++++ 6 files changed, 452 insertions(+), 9 deletions(-) create mode 100644 src/mcpd/tests/docker-ephemeral.test.ts diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json index 98e1ad2..0ec808b 100644 --- a/.taskmaster/tasks/tasks.json +++ b/.taskmaster/tasks/tasks.json @@ -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" ] diff --git a/src/mcpd/src/services/docker/container-manager.ts b/src/mcpd/src/services/docker/container-manager.ts index 236cc71..2afe4bb 100644 --- a/src/mcpd/src/services/docker/container-manager.ts +++ b/src/mcpd/src/services/docker/container-manager.ts @@ -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): { 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 { + // 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 { const container = this.docker.getContainer(containerId); await container.stop({ t: timeoutSeconds }); diff --git a/src/mcpd/src/services/k8s/manifest-generator.ts b/src/mcpd/src/services/k8s/manifest-generator.ts index 670679d..4d7dce4 100644 --- a/src/mcpd/src/services/k8s/manifest-generator.ts +++ b/src/mcpd/src/services/k8s/manifest-generator.ts @@ -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 }>; restartPolicy: 'Always' | 'Never' | 'OnFailure'; automountServiceAccountToken: boolean; nodeSelector?: Record; @@ -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 { + const extras: Partial = {}; + + 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, }, diff --git a/src/mcpd/src/services/orchestrator.ts b/src/mcpd/src/services/orchestrator.ts index e6767c1..db55b9e 100644 --- a/src/mcpd/src/services/orchestrator.ts +++ b/src/mcpd/src/services/orchestrator.ts @@ -23,6 +23,41 @@ export interface ContainerSpec { labels?: Record; /** 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; + volumeMounts?: Array<{ name: string; mountPath: string }>; } export interface ContainerInfo { diff --git a/src/mcpd/tests/docker-ephemeral.test.ts b/src/mcpd/tests/docker-ephemeral.test.ts new file mode 100644 index 0000000..ea6f7c4 --- /dev/null +++ b/src/mcpd/tests/docker-ephemeral.test.ts @@ -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>, + /** 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) => { + 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 => + state.created[i]?.['HostConfig'] as Record; + + 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)['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)['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'); + }); +}); diff --git a/src/mcpd/tests/k8s-manifest.test.ts b/src/mcpd/tests/k8s-manifest.test.ts index 22d5429..3e84ddc 100644 --- a/src/mcpd/tests/k8s-manifest.test.ts +++ b/src/mcpd/tests/k8s-manifest.test.ts @@ -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: {} }]); + }); +});