2026-02-21 05:30:49 +00:00
|
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
2026-04-08 01:55:13 +01:00
|
|
|
import type { ContainerSpec } from '../src/services/orchestrator.js';
|
|
|
|
|
|
|
|
|
|
// Mock @kubernetes/client-node before imports
|
|
|
|
|
vi.mock('@kubernetes/client-node', () => {
|
|
|
|
|
const handlers = new Map<string, { resolve: unknown; reject?: unknown }>();
|
|
|
|
|
|
|
|
|
|
function setHandler(key: string, resolveVal: unknown, rejectVal?: unknown) {
|
|
|
|
|
handlers.set(key, { resolve: resolveVal, reject: rejectVal });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getHandler(key: string) {
|
|
|
|
|
return handlers.get(key);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function clearHandlers() {
|
|
|
|
|
handlers.clear();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const mockCore = {
|
|
|
|
|
listNamespace: vi.fn(async () => {
|
|
|
|
|
const h = getHandler('listNamespace');
|
|
|
|
|
if (h?.reject) throw h.reject;
|
|
|
|
|
return h?.resolve ?? { items: [] };
|
|
|
|
|
}),
|
|
|
|
|
createNamespacedPod: vi.fn(async (params: { namespace: string; body: { metadata: { name: string } } }) => {
|
|
|
|
|
const h = getHandler('createNamespacedPod');
|
|
|
|
|
if (h?.reject) throw h.reject;
|
|
|
|
|
return h?.resolve ?? params.body;
|
|
|
|
|
}),
|
|
|
|
|
readNamespacedPod: vi.fn(async (params: { name: string }) => {
|
|
|
|
|
const h = getHandler(`readNamespacedPod:${params.name}`);
|
|
|
|
|
if (h?.reject) throw h.reject;
|
|
|
|
|
return h?.resolve;
|
|
|
|
|
}),
|
|
|
|
|
deleteNamespacedPod: vi.fn(async (params: { name: string }) => {
|
|
|
|
|
const h = getHandler(`deleteNamespacedPod:${params.name}`);
|
|
|
|
|
if (h?.reject) throw h.reject;
|
|
|
|
|
return h?.resolve ?? {};
|
|
|
|
|
}),
|
|
|
|
|
listNamespacedPod: vi.fn(async () => {
|
|
|
|
|
const h = getHandler('listNamespacedPod');
|
|
|
|
|
if (h?.reject) throw h.reject;
|
|
|
|
|
return h?.resolve ?? { items: [] };
|
|
|
|
|
}),
|
|
|
|
|
readNamespace: vi.fn(async (params: { name: string }) => {
|
|
|
|
|
const h = getHandler(`readNamespace:${params.name}`);
|
|
|
|
|
if (h?.reject) throw h.reject;
|
|
|
|
|
return h?.resolve ?? {};
|
|
|
|
|
}),
|
|
|
|
|
createNamespace: vi.fn(async () => {
|
|
|
|
|
const h = getHandler('createNamespace');
|
|
|
|
|
if (h?.reject) throw h.reject;
|
|
|
|
|
return h?.resolve ?? {};
|
|
|
|
|
}),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
class MockKubeConfig {
|
|
|
|
|
loadFromDefault = vi.fn();
|
|
|
|
|
setCurrentContext = vi.fn();
|
|
|
|
|
getContexts = vi.fn(() => []);
|
|
|
|
|
getCurrentContext = vi.fn(() => 'default');
|
|
|
|
|
makeApiClient = vi.fn(() => mockCore);
|
|
|
|
|
}
|
|
|
|
|
|
fix(mcpd): recover k8s STDIO instances after in-place container restarts
A container restart (OOMKill, crash, transient npx failure) used to strand
the instance in ERROR forever while its pod sat 1/1 Running, because five
gaps lined up (#114):
- the exec/attach websocket died without ending the stdout PassThrough
(client-node installs no onclose), so PersistentStdioClient — whose only
death signal was stdout 'end' — kept believing it was connected and every
request rode the 120s timeout into a dead pipe;
- the stdioClients cache is keyed by pod name, which survives a restart, so
nothing ever evicted the corpse;
- syncStatus never re-inspected ERROR rows and never read restartCount, so
neither the recovery nor the in-place restart was visible;
- its ERROR writes clobbered retry metadata, making the row instantly
dueForRetry, and the retry recreated the pod under the SAME name — an
uncaught 409 that looped ERROR against a healthy pod;
- the stuck row consumed the whole replica budget, blocking a fresh-id
replacement.
The fix, layer by layer:
- ws 'close'/'error' now end stdout in both k8s interactive paths (and the
docker interactive path mirrors its own one-shot handlers), funneling into
an identity-guarded teardown in PersistentStdioClient that also listens
for stream 'close'/'error' — a late event from a previous session cannot
clobber a reconnected one;
- syncStatus re-inspects ERROR rows (pod running again → back to RUNNING
with retry metadata cleared), tracks restartCount in instance metadata to
catch restarts between polls, MERGES retry metadata instead of clobbering
it, and evicts the cached stdio client through a setter-injected hook
whenever the pipe is known-dead;
- createContainer adopts an alive pod on 409 instead of throwing (and only
replaces a genuinely dead one, waiting out the deletion grace period);
- attach mode retries once through a fresh client before surfacing an error,
so the first call after a detected death succeeds;
- the health probe evicts the cached client when failures cross the
threshold, and shutdown finally calls closeAll().
Closes #114
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JaFvfHrQyUKCGv6o3N2Wir
2026-08-15 23:32:23 +01:00
|
|
|
// Track the live Exec/Attach instances so tests can program their vi.fn()s
|
|
|
|
|
// (each K8sOfficialClient constructs fresh ones).
|
|
|
|
|
const instances: { exec?: { exec: ReturnType<typeof vi.fn> }; attach?: { attach: ReturnType<typeof vi.fn> } } = {};
|
|
|
|
|
|
2026-04-08 01:55:13 +01:00
|
|
|
class MockExec {
|
|
|
|
|
exec = vi.fn();
|
fix(mcpd): recover k8s STDIO instances after in-place container restarts
A container restart (OOMKill, crash, transient npx failure) used to strand
the instance in ERROR forever while its pod sat 1/1 Running, because five
gaps lined up (#114):
- the exec/attach websocket died without ending the stdout PassThrough
(client-node installs no onclose), so PersistentStdioClient — whose only
death signal was stdout 'end' — kept believing it was connected and every
request rode the 120s timeout into a dead pipe;
- the stdioClients cache is keyed by pod name, which survives a restart, so
nothing ever evicted the corpse;
- syncStatus never re-inspected ERROR rows and never read restartCount, so
neither the recovery nor the in-place restart was visible;
- its ERROR writes clobbered retry metadata, making the row instantly
dueForRetry, and the retry recreated the pod under the SAME name — an
uncaught 409 that looped ERROR against a healthy pod;
- the stuck row consumed the whole replica budget, blocking a fresh-id
replacement.
The fix, layer by layer:
- ws 'close'/'error' now end stdout in both k8s interactive paths (and the
docker interactive path mirrors its own one-shot handlers), funneling into
an identity-guarded teardown in PersistentStdioClient that also listens
for stream 'close'/'error' — a late event from a previous session cannot
clobber a reconnected one;
- syncStatus re-inspects ERROR rows (pod running again → back to RUNNING
with retry metadata cleared), tracks restartCount in instance metadata to
catch restarts between polls, MERGES retry metadata instead of clobbering
it, and evicts the cached stdio client through a setter-injected hook
whenever the pipe is known-dead;
- createContainer adopts an alive pod on 409 instead of throwing (and only
replaces a genuinely dead one, waiting out the deletion grace period);
- attach mode retries once through a fresh client before surfacing an error,
so the first call after a detected death succeeds;
- the health probe evicts the cached client when failures cross the
threshold, and shutdown finally calls closeAll().
Closes #114
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JaFvfHrQyUKCGv6o3N2Wir
2026-08-15 23:32:23 +01:00
|
|
|
constructor() {
|
|
|
|
|
instances.exec = this;
|
|
|
|
|
}
|
2026-04-08 01:55:13 +01:00
|
|
|
}
|
|
|
|
|
|
2026-04-09 23:21:34 +01:00
|
|
|
class MockAttach {
|
|
|
|
|
attach = vi.fn();
|
fix(mcpd): recover k8s STDIO instances after in-place container restarts
A container restart (OOMKill, crash, transient npx failure) used to strand
the instance in ERROR forever while its pod sat 1/1 Running, because five
gaps lined up (#114):
- the exec/attach websocket died without ending the stdout PassThrough
(client-node installs no onclose), so PersistentStdioClient — whose only
death signal was stdout 'end' — kept believing it was connected and every
request rode the 120s timeout into a dead pipe;
- the stdioClients cache is keyed by pod name, which survives a restart, so
nothing ever evicted the corpse;
- syncStatus never re-inspected ERROR rows and never read restartCount, so
neither the recovery nor the in-place restart was visible;
- its ERROR writes clobbered retry metadata, making the row instantly
dueForRetry, and the retry recreated the pod under the SAME name — an
uncaught 409 that looped ERROR against a healthy pod;
- the stuck row consumed the whole replica budget, blocking a fresh-id
replacement.
The fix, layer by layer:
- ws 'close'/'error' now end stdout in both k8s interactive paths (and the
docker interactive path mirrors its own one-shot handlers), funneling into
an identity-guarded teardown in PersistentStdioClient that also listens
for stream 'close'/'error' — a late event from a previous session cannot
clobber a reconnected one;
- syncStatus re-inspects ERROR rows (pod running again → back to RUNNING
with retry metadata cleared), tracks restartCount in instance metadata to
catch restarts between polls, MERGES retry metadata instead of clobbering
it, and evicts the cached stdio client through a setter-injected hook
whenever the pipe is known-dead;
- createContainer adopts an alive pod on 409 instead of throwing (and only
replaces a genuinely dead one, waiting out the deletion grace period);
- attach mode retries once through a fresh client before surfacing an error,
so the first call after a detected death succeeds;
- the health probe evicts the cached client when failures cross the
threshold, and shutdown finally calls closeAll().
Closes #114
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JaFvfHrQyUKCGv6o3N2Wir
2026-08-15 23:32:23 +01:00
|
|
|
constructor() {
|
|
|
|
|
instances.attach = this;
|
|
|
|
|
}
|
2026-04-09 23:21:34 +01:00
|
|
|
}
|
|
|
|
|
|
2026-04-08 01:55:13 +01:00
|
|
|
class MockLog {
|
|
|
|
|
log = vi.fn();
|
2026-02-21 05:30:49 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
2026-04-08 01:55:13 +01:00
|
|
|
KubeConfig: MockKubeConfig,
|
|
|
|
|
CoreV1Api: class {},
|
|
|
|
|
Exec: MockExec,
|
2026-04-09 23:21:34 +01:00
|
|
|
Attach: MockAttach,
|
2026-04-08 01:55:13 +01:00
|
|
|
Log: MockLog,
|
|
|
|
|
// Export test helpers
|
fix(mcpd): recover k8s STDIO instances after in-place container restarts
A container restart (OOMKill, crash, transient npx failure) used to strand
the instance in ERROR forever while its pod sat 1/1 Running, because five
gaps lined up (#114):
- the exec/attach websocket died without ending the stdout PassThrough
(client-node installs no onclose), so PersistentStdioClient — whose only
death signal was stdout 'end' — kept believing it was connected and every
request rode the 120s timeout into a dead pipe;
- the stdioClients cache is keyed by pod name, which survives a restart, so
nothing ever evicted the corpse;
- syncStatus never re-inspected ERROR rows and never read restartCount, so
neither the recovery nor the in-place restart was visible;
- its ERROR writes clobbered retry metadata, making the row instantly
dueForRetry, and the retry recreated the pod under the SAME name — an
uncaught 409 that looped ERROR against a healthy pod;
- the stuck row consumed the whole replica budget, blocking a fresh-id
replacement.
The fix, layer by layer:
- ws 'close'/'error' now end stdout in both k8s interactive paths (and the
docker interactive path mirrors its own one-shot handlers), funneling into
an identity-guarded teardown in PersistentStdioClient that also listens
for stream 'close'/'error' — a late event from a previous session cannot
clobber a reconnected one;
- syncStatus re-inspects ERROR rows (pod running again → back to RUNNING
with retry metadata cleared), tracks restartCount in instance metadata to
catch restarts between polls, MERGES retry metadata instead of clobbering
it, and evicts the cached stdio client through a setter-injected hook
whenever the pipe is known-dead;
- createContainer adopts an alive pod on 409 instead of throwing (and only
replaces a genuinely dead one, waiting out the deletion grace period);
- attach mode retries once through a fresh client before surfacing an error,
so the first call after a detected death succeeds;
- the health probe evicts the cached client when failures cross the
threshold, and shutdown finally calls closeAll().
Closes #114
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JaFvfHrQyUKCGv6o3N2Wir
2026-08-15 23:32:23 +01:00
|
|
|
__testHelpers: { setHandler, getHandler, clearHandlers, mockCore, instances },
|
2026-02-21 05:30:49 +00:00
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-08 01:55:13 +01:00
|
|
|
// Import after mock
|
2026-08-10 12:20:56 +01:00
|
|
|
import { KubernetesOrchestrator, httpStatusOf } from '../src/services/k8s/kubernetes-orchestrator.js';
|
2026-04-08 01:55:13 +01:00
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
|
|
|
const k8sMock = await import('@kubernetes/client-node') as any;
|
|
|
|
|
const { setHandler, clearHandlers, mockCore } = k8sMock.__testHelpers;
|
2026-02-21 05:30:49 +00:00
|
|
|
|
|
|
|
|
const testSpec: ContainerSpec = {
|
2026-04-08 01:55:13 +01:00
|
|
|
image: 'mysources.co.uk/michal/mcpctl-node-runner:latest',
|
2026-02-21 05:30:49 +00:00
|
|
|
name: 'my-server',
|
|
|
|
|
env: { PORT: '3000' },
|
|
|
|
|
containerPort: 3000,
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-08 01:55:13 +01:00
|
|
|
const podRunning = {
|
2026-02-21 05:30:49 +00:00
|
|
|
metadata: {
|
|
|
|
|
name: 'my-server',
|
2026-04-08 01:55:13 +01:00
|
|
|
namespace: 'mcpctl-servers',
|
2026-02-21 05:30:49 +00:00
|
|
|
creationTimestamp: '2026-01-01T00:00:00Z',
|
|
|
|
|
labels: { 'mcpctl.managed': 'true' },
|
|
|
|
|
},
|
|
|
|
|
status: {
|
|
|
|
|
phase: 'Running',
|
2026-04-08 01:55:13 +01:00
|
|
|
podIP: '10.42.0.15',
|
2026-02-21 05:30:49 +00:00
|
|
|
containerStatuses: [{
|
|
|
|
|
state: { running: { startedAt: '2026-01-01T00:00:00Z' } },
|
|
|
|
|
}],
|
|
|
|
|
},
|
|
|
|
|
spec: {
|
2026-04-08 01:55:13 +01:00
|
|
|
containers: [{ name: 'my-server', ports: [{ containerPort: 3000 }] }],
|
2026-02-21 05:30:49 +00:00
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-08 01:55:13 +01:00
|
|
|
const podPending = {
|
2026-02-21 05:30:49 +00:00
|
|
|
metadata: {
|
|
|
|
|
name: 'my-server',
|
2026-04-08 01:55:13 +01:00
|
|
|
namespace: 'mcpctl-servers',
|
2026-02-21 05:30:49 +00:00
|
|
|
creationTimestamp: '2026-01-01T00:00:00Z',
|
|
|
|
|
},
|
|
|
|
|
status: {
|
|
|
|
|
phase: 'Pending',
|
|
|
|
|
containerStatuses: [{
|
|
|
|
|
state: { waiting: { reason: 'ContainerCreating' } },
|
|
|
|
|
}],
|
|
|
|
|
},
|
2026-04-08 01:55:13 +01:00
|
|
|
spec: {
|
|
|
|
|
containers: [{ name: 'my-server' }],
|
|
|
|
|
},
|
2026-02-21 05:30:49 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
describe('KubernetesOrchestrator', () => {
|
|
|
|
|
let orch: KubernetesOrchestrator;
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
2026-04-08 01:55:13 +01:00
|
|
|
clearHandlers();
|
|
|
|
|
vi.clearAllMocks();
|
|
|
|
|
orch = new KubernetesOrchestrator({ serversNamespace: 'mcpctl-servers' });
|
2026-02-21 05:30:49 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('ping', () => {
|
|
|
|
|
it('returns true on successful API call', async () => {
|
2026-04-08 01:55:13 +01:00
|
|
|
setHandler('listNamespace', { items: [] });
|
2026-02-21 05:30:49 +00:00
|
|
|
expect(await orch.ping()).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns false on error', async () => {
|
2026-04-08 01:55:13 +01:00
|
|
|
setHandler('listNamespace', undefined, new Error('connection refused'));
|
2026-02-21 05:30:49 +00:00
|
|
|
expect(await orch.ping()).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('pullImage', () => {
|
|
|
|
|
it('is a no-op for K8s', async () => {
|
|
|
|
|
await expect(orch.pullImage('some-image:latest')).resolves.toBeUndefined();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('createContainer', () => {
|
|
|
|
|
it('creates a pod and returns container info', async () => {
|
2026-04-08 01:55:13 +01:00
|
|
|
// ensureNamespace
|
|
|
|
|
setHandler('readNamespace:mcpctl-servers', {});
|
|
|
|
|
// createPod returns the pod
|
|
|
|
|
setHandler('createNamespacedPod', podRunning);
|
|
|
|
|
// inspectContainer after create
|
|
|
|
|
setHandler('readNamespacedPod:my-server', podRunning);
|
2026-02-21 05:30:49 +00:00
|
|
|
|
|
|
|
|
const info = await orch.createContainer(testSpec);
|
|
|
|
|
expect(info.containerId).toBe('my-server');
|
|
|
|
|
expect(info.state).toBe('running');
|
|
|
|
|
expect(info.port).toBe(3000);
|
2026-04-08 01:55:13 +01:00
|
|
|
expect(info.ip).toBe('10.42.0.15');
|
2026-02-21 05:30:49 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('throws on API error', async () => {
|
2026-04-08 01:55:13 +01:00
|
|
|
setHandler('readNamespace:mcpctl-servers', {});
|
|
|
|
|
setHandler('createNamespacedPod', undefined, new Error('pod already exists'));
|
2026-02-21 05:30:49 +00:00
|
|
|
|
2026-04-08 01:55:13 +01:00
|
|
|
await expect(orch.createContainer(testSpec)).rejects.toThrow('pod already exists');
|
2026-02-21 05:30:49 +00:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('inspectContainer', () => {
|
2026-04-08 01:55:13 +01:00
|
|
|
it('returns running container info with pod IP', async () => {
|
|
|
|
|
setHandler('readNamespacedPod:my-server', podRunning);
|
2026-02-21 05:30:49 +00:00
|
|
|
|
|
|
|
|
const info = await orch.inspectContainer('my-server');
|
|
|
|
|
expect(info.state).toBe('running');
|
|
|
|
|
expect(info.name).toBe('my-server');
|
2026-04-08 01:55:13 +01:00
|
|
|
expect(info.ip).toBe('10.42.0.15');
|
|
|
|
|
expect(info.port).toBe(3000);
|
2026-02-21 05:30:49 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('maps pending state correctly', async () => {
|
2026-04-08 01:55:13 +01:00
|
|
|
setHandler('readNamespacedPod:my-server', podPending);
|
2026-02-21 05:30:49 +00:00
|
|
|
|
|
|
|
|
const info = await orch.inspectContainer('my-server');
|
|
|
|
|
expect(info.state).toBe('starting');
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-08 01:55:13 +01:00
|
|
|
it('throws when pod not found', async () => {
|
|
|
|
|
setHandler('readNamespacedPod:missing', undefined, { statusCode: 404, message: 'not found' });
|
2026-02-21 05:30:49 +00:00
|
|
|
|
2026-04-08 01:55:13 +01:00
|
|
|
await expect(orch.inspectContainer('missing')).rejects.toBeDefined();
|
2026-02-21 05:30:49 +00:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('stopContainer', () => {
|
|
|
|
|
it('deletes the pod', async () => {
|
2026-04-08 01:55:13 +01:00
|
|
|
setHandler('deleteNamespacedPod:my-server', {});
|
2026-02-21 05:30:49 +00:00
|
|
|
await expect(orch.stopContainer('my-server')).resolves.toBeUndefined();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('removeContainer', () => {
|
|
|
|
|
it('deletes the pod successfully', async () => {
|
2026-04-08 01:55:13 +01:00
|
|
|
setHandler('deleteNamespacedPod:my-server', {});
|
2026-02-21 05:30:49 +00:00
|
|
|
await expect(orch.removeContainer('my-server')).resolves.toBeUndefined();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('ignores 404 (already deleted)', async () => {
|
2026-04-08 01:55:13 +01:00
|
|
|
setHandler('deleteNamespacedPod:my-server', undefined, { statusCode: 404 });
|
2026-02-21 05:30:49 +00:00
|
|
|
await expect(orch.removeContainer('my-server')).resolves.toBeUndefined();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('throws on other errors', async () => {
|
2026-04-08 01:55:13 +01:00
|
|
|
setHandler('deleteNamespacedPod:my-server', undefined, { statusCode: 403, message: 'forbidden' });
|
|
|
|
|
await expect(orch.removeContainer('my-server')).rejects.toBeDefined();
|
2026-02-21 05:30:49 +00:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('listContainers', () => {
|
|
|
|
|
it('lists managed pods', async () => {
|
2026-04-08 01:55:13 +01:00
|
|
|
setHandler('listNamespacedPod', { items: [podRunning] });
|
2026-02-21 05:30:49 +00:00
|
|
|
|
|
|
|
|
const containers = await orch.listContainers();
|
|
|
|
|
expect(containers).toHaveLength(1);
|
|
|
|
|
expect(containers[0]!.containerId).toBe('my-server');
|
|
|
|
|
expect(containers[0]!.state).toBe('running');
|
2026-04-08 01:55:13 +01:00
|
|
|
expect(containers[0]!.ip).toBe('10.42.0.15');
|
2026-02-21 05:30:49 +00:00
|
|
|
|
2026-04-08 01:55:13 +01:00
|
|
|
expect(mockCore.listNamespacedPod).toHaveBeenCalledWith(
|
|
|
|
|
expect.objectContaining({ labelSelector: 'mcpctl.managed=true' }),
|
2026-02-21 05:30:49 +00:00
|
|
|
);
|
2026-04-08 01:55:13 +01:00
|
|
|
});
|
2026-02-21 05:30:49 +00:00
|
|
|
|
2026-04-08 01:55:13 +01:00
|
|
|
it('returns empty when no pods', async () => {
|
|
|
|
|
setHandler('listNamespacedPod', { items: [] });
|
2026-02-21 05:30:49 +00:00
|
|
|
const containers = await orch.listContainers();
|
|
|
|
|
expect(containers).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('ensureNamespace', () => {
|
|
|
|
|
it('does nothing if namespace exists', async () => {
|
2026-04-08 01:55:13 +01:00
|
|
|
setHandler('readNamespace:test-ns', {});
|
2026-02-21 05:30:49 +00:00
|
|
|
await expect(orch.ensureNamespace('test-ns')).resolves.toBeUndefined();
|
2026-04-08 01:55:13 +01:00
|
|
|
expect(mockCore.createNamespace).not.toHaveBeenCalled();
|
2026-02-21 05:30:49 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('creates namespace if not found', async () => {
|
2026-04-08 01:55:13 +01:00
|
|
|
setHandler('readNamespace:new-ns', undefined, { statusCode: 404 });
|
|
|
|
|
setHandler('createNamespace', {});
|
2026-02-21 05:30:49 +00:00
|
|
|
await expect(orch.ensureNamespace('new-ns')).resolves.toBeUndefined();
|
2026-04-08 01:55:13 +01:00
|
|
|
expect(mockCore.createNamespace).toHaveBeenCalled();
|
2026-02-21 05:30:49 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('handles conflict (namespace already created by another process)', async () => {
|
2026-04-08 01:55:13 +01:00
|
|
|
setHandler('readNamespace:new-ns', undefined, { statusCode: 404 });
|
|
|
|
|
setHandler('createNamespace', undefined, { statusCode: 409, message: 'already exists' });
|
2026-02-21 05:30:49 +00:00
|
|
|
await expect(orch.ensureNamespace('new-ns')).resolves.toBeUndefined();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('getNamespace', () => {
|
|
|
|
|
it('returns configured namespace', () => {
|
2026-04-08 01:55:13 +01:00
|
|
|
expect(orch.getNamespace()).toBe('mcpctl-servers');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('defaults to mcpctl-servers', () => {
|
|
|
|
|
const defaultOrch = new KubernetesOrchestrator();
|
|
|
|
|
expect(defaultOrch.getNamespace()).toBe('mcpctl-servers');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('pod IP extraction', () => {
|
|
|
|
|
it('extracts podIP from status', async () => {
|
|
|
|
|
setHandler('readNamespacedPod:my-server', podRunning);
|
|
|
|
|
const info = await orch.inspectContainer('my-server');
|
|
|
|
|
expect(info.ip).toBe('10.42.0.15');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns undefined ip when no podIP', async () => {
|
|
|
|
|
const podWithoutIP = {
|
|
|
|
|
...podRunning,
|
|
|
|
|
status: { ...podRunning.status, podIP: undefined },
|
|
|
|
|
};
|
|
|
|
|
setHandler('readNamespacedPod:my-server', podWithoutIP);
|
|
|
|
|
const info = await orch.inspectContainer('my-server');
|
|
|
|
|
expect(info.ip).toBeUndefined();
|
2026-02-21 05:30:49 +00:00
|
|
|
});
|
2026-04-08 01:55:13 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('manifest security', () => {
|
|
|
|
|
it('creates pods with security hardening', async () => {
|
|
|
|
|
setHandler('readNamespace:mcpctl-servers', {});
|
|
|
|
|
setHandler('createNamespacedPod', podRunning);
|
|
|
|
|
setHandler('readNamespacedPod:my-server', podRunning);
|
|
|
|
|
|
|
|
|
|
await orch.createContainer(testSpec);
|
|
|
|
|
|
|
|
|
|
const createCall = mockCore.createNamespacedPod.mock.calls[0]![0];
|
|
|
|
|
const container = createCall.body.spec.containers[0];
|
|
|
|
|
expect(container.securityContext.runAsNonRoot).toBe(false);
|
|
|
|
|
expect(container.securityContext.readOnlyRootFilesystem).toBe(false);
|
|
|
|
|
expect(container.securityContext.allowPrivilegeEscalation).toBe(false);
|
|
|
|
|
expect(container.securityContext.capabilities.drop).toEqual(['ALL']);
|
|
|
|
|
expect(container.securityContext.seccompProfile.type).toBe('RuntimeDefault');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('creates pods with automountServiceAccountToken disabled', async () => {
|
|
|
|
|
setHandler('readNamespace:mcpctl-servers', {});
|
|
|
|
|
setHandler('createNamespacedPod', podRunning);
|
|
|
|
|
setHandler('readNamespacedPod:my-server', podRunning);
|
|
|
|
|
|
|
|
|
|
await orch.createContainer(testSpec);
|
|
|
|
|
|
|
|
|
|
const createCall = mockCore.createNamespacedPod.mock.calls[0]![0];
|
|
|
|
|
expect(createCall.body.spec.automountServiceAccountToken).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('creates pods with stdin enabled for STDIO servers', async () => {
|
|
|
|
|
setHandler('readNamespace:mcpctl-servers', {});
|
|
|
|
|
setHandler('createNamespacedPod', podRunning);
|
|
|
|
|
setHandler('readNamespacedPod:my-server', podRunning);
|
|
|
|
|
|
|
|
|
|
await orch.createContainer(testSpec);
|
|
|
|
|
|
|
|
|
|
const createCall = mockCore.createNamespacedPod.mock.calls[0]![0];
|
|
|
|
|
expect(createCall.body.spec.containers[0].stdin).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-02-21 05:30:49 +00:00
|
|
|
|
2026-04-08 01:55:13 +01:00
|
|
|
describe('context enforcement', () => {
|
|
|
|
|
it('sets context when configured', () => {
|
|
|
|
|
const _orch = new KubernetesOrchestrator({ context: 'default' });
|
|
|
|
|
// The mock KubeConfig.setCurrentContext should have been called
|
|
|
|
|
// This verifies the safety mechanism works
|
|
|
|
|
expect(_orch.getNamespace()).toBe('mcpctl-servers');
|
2026-02-21 05:30:49 +00:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-08-10 12:20:56 +01:00
|
|
|
|
|
|
|
|
describe('httpStatusOf', () => {
|
|
|
|
|
it('reads .code, which is what client-node v1 ApiException actually sets', () => {
|
|
|
|
|
// The bug this exists for: reading only .statusCode returned undefined, so
|
|
|
|
|
// an expected 404 was rethrown and a first-start PVC create never happened.
|
|
|
|
|
expect(httpStatusOf({ code: 404 })).toBe(404);
|
|
|
|
|
expect(httpStatusOf({ code: 409 })).toBe(409);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('still reads the pre-1.0 HttpError shapes', () => {
|
|
|
|
|
expect(httpStatusOf({ statusCode: 404 })).toBe(404);
|
|
|
|
|
expect(httpStatusOf({ response: { statusCode: 409 } })).toBe(409);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('falls back to parsing the rendered message', () => {
|
|
|
|
|
expect(httpStatusOf(new Error('HTTP-Code: 403\nMessage: Unknown API Status Code!'))).toBe(403);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns undefined when there is no status to find', () => {
|
|
|
|
|
expect(httpStatusOf(new Error('socket hang up'))).toBeUndefined();
|
|
|
|
|
expect(httpStatusOf({ code: 'ECONNREFUSED' })).toBeUndefined();
|
|
|
|
|
});
|
|
|
|
|
});
|
fix(mcpd): recover k8s STDIO instances after in-place container restarts
A container restart (OOMKill, crash, transient npx failure) used to strand
the instance in ERROR forever while its pod sat 1/1 Running, because five
gaps lined up (#114):
- the exec/attach websocket died without ending the stdout PassThrough
(client-node installs no onclose), so PersistentStdioClient — whose only
death signal was stdout 'end' — kept believing it was connected and every
request rode the 120s timeout into a dead pipe;
- the stdioClients cache is keyed by pod name, which survives a restart, so
nothing ever evicted the corpse;
- syncStatus never re-inspected ERROR rows and never read restartCount, so
neither the recovery nor the in-place restart was visible;
- its ERROR writes clobbered retry metadata, making the row instantly
dueForRetry, and the retry recreated the pod under the SAME name — an
uncaught 409 that looped ERROR against a healthy pod;
- the stuck row consumed the whole replica budget, blocking a fresh-id
replacement.
The fix, layer by layer:
- ws 'close'/'error' now end stdout in both k8s interactive paths (and the
docker interactive path mirrors its own one-shot handlers), funneling into
an identity-guarded teardown in PersistentStdioClient that also listens
for stream 'close'/'error' — a late event from a previous session cannot
clobber a reconnected one;
- syncStatus re-inspects ERROR rows (pod running again → back to RUNNING
with retry metadata cleared), tracks restartCount in instance metadata to
catch restarts between polls, MERGES retry metadata instead of clobbering
it, and evicts the cached stdio client through a setter-injected hook
whenever the pipe is known-dead;
- createContainer adopts an alive pod on 409 instead of throwing (and only
replaces a genuinely dead one, waiting out the deletion grace period);
- attach mode retries once through a fresh client before surfacing an error,
so the first call after a detected death succeeds;
- the health probe evicts the cached client when failures cross the
threshold, and shutdown finally calls closeAll().
Closes #114
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JaFvfHrQyUKCGv6o3N2Wir
2026-08-15 23:32:23 +01:00
|
|
|
|
|
|
|
|
// ── mcpctl#114: interactive-session death signals, restartCount, 409-adopt ──
|
|
|
|
|
|
|
|
|
|
import { EventEmitter } from 'node:events';
|
|
|
|
|
|
|
|
|
|
function makeFakeWs(): EventEmitter & { close: ReturnType<typeof vi.fn> } {
|
|
|
|
|
const ws = new EventEmitter() as EventEmitter & { close: ReturnType<typeof vi.fn> };
|
|
|
|
|
ws.close = vi.fn();
|
|
|
|
|
return ws;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const podRestarted = {
|
|
|
|
|
...podRunning,
|
|
|
|
|
status: {
|
|
|
|
|
...podRunning.status,
|
|
|
|
|
containerStatuses: [{
|
|
|
|
|
state: { running: { startedAt: '2026-01-02T00:00:00Z' } },
|
|
|
|
|
restartCount: 3,
|
|
|
|
|
lastState: { terminated: { reason: 'OOMKilled', exitCode: 137 } },
|
|
|
|
|
}],
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const podTerminated = {
|
|
|
|
|
...podRunning,
|
|
|
|
|
status: {
|
|
|
|
|
phase: 'Failed',
|
|
|
|
|
containerStatuses: [{
|
|
|
|
|
state: { terminated: { reason: 'Error', exitCode: 1 } },
|
|
|
|
|
restartCount: 1,
|
|
|
|
|
}],
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
describe('KubernetesOrchestrator interactive sessions (mcpctl#114)', () => {
|
|
|
|
|
let orch: KubernetesOrchestrator;
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
clearHandlers();
|
|
|
|
|
vi.clearAllMocks();
|
|
|
|
|
orch = new KubernetesOrchestrator({ serversNamespace: 'mcpctl-servers' });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("execInteractive: ws 'close' ends stdout so the consumer sees the death", async () => {
|
|
|
|
|
setHandler('readNamespacedPod:my-server', podRunning);
|
|
|
|
|
const ws = makeFakeWs();
|
|
|
|
|
k8sMock.__testHelpers.instances.exec!.exec.mockResolvedValue(ws);
|
|
|
|
|
|
|
|
|
|
const iexec = await orch.execInteractive!('my-server', ['node', 'index.js']);
|
|
|
|
|
iexec.stdout.resume(); // PassThrough only emits 'end' once it is being read
|
|
|
|
|
const ended = new Promise<void>((resolve) => iexec.stdout.on('end', resolve));
|
|
|
|
|
ws.emit('close');
|
|
|
|
|
await expect(ended).resolves.toBeUndefined();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("execInteractive: ws 'error' also ends stdout, and close() closes the ws", async () => {
|
|
|
|
|
setHandler('readNamespacedPod:my-server', podRunning);
|
|
|
|
|
const ws = makeFakeWs();
|
|
|
|
|
k8sMock.__testHelpers.instances.exec!.exec.mockResolvedValue(ws);
|
|
|
|
|
|
|
|
|
|
const iexec = await orch.execInteractive!('my-server', ['node']);
|
|
|
|
|
iexec.stdout.resume();
|
|
|
|
|
const ended = new Promise<void>((resolve) => iexec.stdout.on('end', resolve));
|
|
|
|
|
ws.emit('error', new Error('abnormal closure'));
|
|
|
|
|
await expect(ended).resolves.toBeUndefined();
|
|
|
|
|
|
|
|
|
|
iexec.close();
|
|
|
|
|
expect(ws.close).toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("attachInteractive: ws 'close' ends stdout (first-ever attach coverage)", async () => {
|
|
|
|
|
setHandler('readNamespacedPod:my-server', podRunning);
|
|
|
|
|
const ws = makeFakeWs();
|
|
|
|
|
k8sMock.__testHelpers.instances.attach!.attach.mockResolvedValue(ws);
|
|
|
|
|
|
|
|
|
|
const iexec = await orch.attachInteractive!('my-server');
|
|
|
|
|
iexec.stdout.resume();
|
|
|
|
|
const ended = new Promise<void>((resolve) => iexec.stdout.on('end', resolve));
|
|
|
|
|
ws.emit('close');
|
|
|
|
|
await expect(ended).resolves.toBeUndefined();
|
|
|
|
|
expect(k8sMock.__testHelpers.instances.attach!.attach).toHaveBeenCalledOnce();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('inspectContainer surfaces restartCount and startedAt', async () => {
|
|
|
|
|
setHandler('readNamespacedPod:my-server', podRestarted);
|
|
|
|
|
const info = await orch.inspectContainer('my-server');
|
|
|
|
|
expect(info.state).toBe('running');
|
|
|
|
|
expect(info.restartCount).toBe(3);
|
|
|
|
|
expect(info.startedAt).toEqual(new Date('2026-01-02T00:00:00Z'));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('createContainer ADOPTS an alive pod on 409 instead of throwing', async () => {
|
|
|
|
|
setHandler('readNamespace:mcpctl-servers', {});
|
|
|
|
|
setHandler('createNamespacedPod', undefined, { code: 409 });
|
|
|
|
|
setHandler('readNamespacedPod:my-server', podRestarted);
|
|
|
|
|
|
|
|
|
|
const info = await orch.createContainer(testSpec);
|
|
|
|
|
expect(info.containerId).toBe('my-server');
|
|
|
|
|
expect(info.state).toBe('running');
|
|
|
|
|
expect(mockCore.deleteNamespacedPod).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('createContainer replaces a genuinely dead pod on 409', async () => {
|
|
|
|
|
setHandler('readNamespace:mcpctl-servers', {});
|
|
|
|
|
setHandler('createNamespacedPod', undefined, { code: 409 });
|
|
|
|
|
setHandler('readNamespacedPod:my-server', podTerminated);
|
|
|
|
|
setHandler('deleteNamespacedPod:my-server', {});
|
|
|
|
|
|
|
|
|
|
const promise = orch.createContainer(testSpec);
|
|
|
|
|
// Give it a tick to inspect + delete, then flip the world: pod is gone,
|
|
|
|
|
// create succeeds.
|
|
|
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
|
|
|
// The gone-wait loop polls once per second: serve 404 so it breaks, then
|
|
|
|
|
// restore the pod before the post-create inspect (~500ms later) runs.
|
|
|
|
|
setHandler('readNamespacedPod:my-server', undefined, { code: 404 });
|
|
|
|
|
setHandler('createNamespacedPod', podRunning);
|
|
|
|
|
setTimeout(() => setHandler('readNamespacedPod:my-server', podRunning), 1200);
|
|
|
|
|
|
|
|
|
|
const info = await promise;
|
|
|
|
|
expect(info.state).toBe('running');
|
|
|
|
|
expect(mockCore.deleteNamespacedPod).toHaveBeenCalled();
|
|
|
|
|
expect(mockCore.createNamespacedPod).toHaveBeenCalledTimes(2);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('createContainer still throws on non-409 errors', async () => {
|
|
|
|
|
setHandler('readNamespace:mcpctl-servers', {});
|
|
|
|
|
setHandler('createNamespacedPod', undefined, { code: 500 });
|
|
|
|
|
await expect(orch.createContainer(testSpec)).rejects.toBeTruthy();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|