Files
sencho/backend/src/__tests__/compose-service.test.ts
T
Anso 9dbce9c3c7 fix(spawn): attribute ENOMEM and ENOENT-under-memory-pressure spawn failures to host OOM (#1111)
Operators previously saw "spawn docker ENOENT" or "spawn /bin/sh ENOENT" when
the host was under memory pressure, which sent them down a missing-binary
debugging path. Linux libuv's posix_spawn can fail to allocate its argv /
path-search arena under low free memory and surface the underlying ENOMEM
as ENOENT.

Centralizes spawn-error mapping in a new utils/spawnErrors.ts helper:
- Explicit ENOMEM is rewritten to "Out of memory while launching <command>
  (host free memory: X MiB of Y MiB)".
- ENOENT under the 128 MiB free-memory floor is rewritten with the same
  wording plus a "reported as ENOENT under memory pressure" hint.
- ENOENT for docker on a healthy host preserves the existing
  "Docker CLI unavailable on this node" mapping.
- Other errors pass through unchanged.

Applied at the four named offenders: ComposeService.execute(),
ComposeService.captureCompose(), DockerController.getContainersByStack(),
and FileSystemService.getStacks() (which gets an ENOMEM-aware log line
for the scandir failure).

Startup also logs host free/total MiB once and warns when free memory is
below the 128 MiB floor, so the diagnostic surfaces before the first
spawn attempt rather than after it fails.

37 tests cover the mapping function directly and the ComposeService /
FileSystemService integration paths.
2026-05-19 07:27:12 -04:00

573 lines
20 KiB
TypeScript

/**
* Unit tests for ComposeService — subprocess handling, deploy/rollback,
* registry auth temp dir management, and WebSocket output.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { EventEmitter } from 'events';
import os from 'os';
import type WebSocket from 'ws';
// ── Hoisted mocks ──────────────────────────────────────────────────────
const {
mockSpawn,
mockGetContainersByStack, mockRemoveContainers, mockListContainers,
mockContainerInspect, mockContainerLogs,
mockGetRegistries, mockResolveDockerConfig,
mockBackupStackFiles, mockRestoreStackFiles,
mockMkdtempSync, mockWriteFileSync, mockUnlinkSync, mockRmdirSync,
} = vi.hoisted(() => ({
mockSpawn: vi.fn(),
mockGetContainersByStack: vi.fn().mockResolvedValue([]),
mockRemoveContainers: vi.fn().mockResolvedValue([]),
mockListContainers: vi.fn().mockResolvedValue([]),
mockContainerInspect: vi.fn().mockResolvedValue({ State: { ExitCode: 0 } }),
mockContainerLogs: vi.fn().mockResolvedValue(Buffer.from('')),
mockGetRegistries: vi.fn().mockReturnValue([]),
mockResolveDockerConfig: vi.fn().mockResolvedValue({ config: { auths: {} }, warnings: [] }),
mockBackupStackFiles: vi.fn().mockResolvedValue(undefined),
mockRestoreStackFiles: vi.fn().mockResolvedValue(undefined),
mockMkdtempSync: vi.fn().mockReturnValue('/tmp/sencho-docker-test'),
mockWriteFileSync: vi.fn(),
mockUnlinkSync: vi.fn(),
mockRmdirSync: vi.fn(),
}));
vi.mock('child_process', () => ({ spawn: mockSpawn, execFile: vi.fn() }));
vi.mock('fs', () => ({
default: {
mkdtempSync: (...args: unknown[]) => mockMkdtempSync(...args),
writeFileSync: (...args: unknown[]) => mockWriteFileSync(...args),
unlinkSync: (...args: unknown[]) => mockUnlinkSync(...args),
rmdirSync: (...args: unknown[]) => mockRmdirSync(...args),
},
mkdtempSync: (...args: unknown[]) => mockMkdtempSync(...args),
writeFileSync: (...args: unknown[]) => mockWriteFileSync(...args),
unlinkSync: (...args: unknown[]) => mockUnlinkSync(...args),
rmdirSync: (...args: unknown[]) => mockRmdirSync(...args),
}));
vi.mock('../services/NodeRegistry', () => ({
NodeRegistry: {
getInstance: () => ({
getDefaultNodeId: () => 1,
getComposeDir: () => '/test/compose',
}),
},
}));
vi.mock('../services/DockerController', () => ({
default: {
getInstance: () => ({
getContainersByStack: mockGetContainersByStack,
removeContainers: mockRemoveContainers,
getDocker: () => ({
listContainers: mockListContainers,
getContainer: () => ({
inspect: mockContainerInspect,
logs: mockContainerLogs,
}),
}),
}),
},
}));
vi.mock('../services/DatabaseService', () => ({
DatabaseService: {
getInstance: () => ({
getRegistries: mockGetRegistries,
}),
},
}));
vi.mock('../services/RegistryService', () => ({
RegistryService: {
getInstance: () => ({
resolveDockerConfig: mockResolveDockerConfig,
}),
},
}));
vi.mock('../services/FileSystemService', () => ({
FileSystemService: {
getInstance: () => ({
backupStackFiles: mockBackupStackFiles,
restoreStackFiles: mockRestoreStackFiles,
}),
},
}));
vi.mock('../services/LogFormatter', () => ({
LogFormatter: { formatLine: (line: string) => line },
}));
import { ComposeService, getComposeRollbackInfo } from '../services/ComposeService';
const originalComposeTimeout = process.env.SENCHO_COMPOSE_COMMAND_TIMEOUT_MS;
/** Creates an EventEmitter that mimics a child_process spawn result */
function createMockProcess() {
const proc = new EventEmitter() as EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
kill: ReturnType<typeof vi.fn>;
killed: boolean;
};
proc.stdout = new EventEmitter();
proc.stderr = new EventEmitter();
proc.killed = false;
proc.kill = vi.fn(() => {
proc.killed = true;
return true;
});
return proc;
}
/** Sets up mockSpawn to auto-close with exit code 0 on next tick */
function setupAutoCloseSpawn(exitCode = 0) {
mockSpawn.mockImplementation(() => {
const proc = createMockProcess();
// Emit close asynchronously (next microtask)
Promise.resolve().then(() => proc.emit('close', exitCode));
return proc;
});
}
type MockWebSocket = EventEmitter & {
readyState: number;
send: ReturnType<typeof vi.fn>;
close: ReturnType<typeof vi.fn>;
OPEN: number;
} & WebSocket;
function createMockWs(): MockWebSocket {
const ws = new EventEmitter() as MockWebSocket;
Object.assign(ws, {
readyState: 1,
send: vi.fn(),
close: vi.fn(),
OPEN: 1,
});
return ws;
}
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
vi.useRealTimers();
if (originalComposeTimeout === undefined) {
delete process.env.SENCHO_COMPOSE_COMMAND_TIMEOUT_MS;
} else {
process.env.SENCHO_COMPOSE_COMMAND_TIMEOUT_MS = originalComposeTimeout;
}
});
// ── runCommand ─────────────────────────────────────────────────────────
describe('ComposeService - runCommand', () => {
it('spawns docker compose with the correct action', async () => {
const proc = createMockProcess();
mockSpawn.mockReturnValue(proc);
const svc = ComposeService.getInstance(1);
const promise = svc.runCommand('my-stack', 'restart');
proc.emit('close', 0);
await promise;
expect(mockSpawn).toHaveBeenCalledWith(
'docker',
['compose', 'restart'],
expect.objectContaining({ cwd: expect.stringContaining('my-stack') })
);
});
it('resolves on exit code 0', async () => {
const proc = createMockProcess();
mockSpawn.mockReturnValue(proc);
const svc = ComposeService.getInstance(1);
const promise = svc.runCommand('my-stack', 'start');
proc.emit('close', 0);
await expect(promise).resolves.toBeUndefined();
});
it('rejects on non-zero exit code', async () => {
const proc = createMockProcess();
mockSpawn.mockReturnValue(proc);
const svc = ComposeService.getInstance(1);
const promise = svc.runCommand('my-stack', 'stop');
proc.stderr.emit('data', Buffer.from('service not found'));
proc.emit('close', 1);
await expect(promise).rejects.toThrow('service not found');
});
it('redacts secrets from command failure errors', async () => {
const proc = createMockProcess();
mockSpawn.mockReturnValue(proc);
const svc = ComposeService.getInstance(1);
const promise = svc.runCommand('my-stack', 'stop');
proc.stderr.emit('data', Buffer.from('token=abc123SECRET password=hunter2 Authorization: Bearer abc.def.ghi'));
proc.emit('close', 1);
await expect(promise).rejects.toThrow('token=[redacted]');
await expect(promise).rejects.toThrow('password=[redacted]');
await expect(promise).rejects.not.toThrow('abc.def.ghi');
});
it('sends output to WebSocket when provided', async () => {
const proc = createMockProcess();
mockSpawn.mockReturnValue(proc);
const ws = createMockWs();
const svc = ComposeService.getInstance(1);
const promise = svc.runCommand('my-stack', 'restart', ws);
proc.stdout.emit('data', Buffer.from('Restarting...'));
proc.emit('close', 0);
await promise;
expect(ws.send).toHaveBeenCalledWith('Restarting...');
});
it('kills and rejects commands that exceed the compose timeout', async () => {
process.env.SENCHO_COMPOSE_COMMAND_TIMEOUT_MS = '1000';
const proc = createMockProcess();
mockSpawn.mockReturnValue(proc);
const svc = ComposeService.getInstance(1);
const promise = svc.runCommand('my-stack', 'restart');
const expectation = expect(promise).rejects.toThrow('Command timed out after 1s');
let settled = false;
promise.finally(() => { settled = true; }).catch(() => undefined);
await vi.advanceTimersByTimeAsync(1000);
expect(proc.kill).toHaveBeenCalledWith('SIGTERM');
expect(settled).toBe(false);
proc.emit('close', null);
await expectation;
});
it('kills and rejects running commands when the WebSocket disconnects', async () => {
const proc = createMockProcess();
mockSpawn.mockReturnValue(proc);
const ws = createMockWs();
const svc = ComposeService.getInstance(1);
const promise = svc.runCommand('my-stack', 'restart', ws);
const expectation = expect(promise).rejects.toThrow('client disconnected');
let settled = false;
promise.finally(() => { settled = true; }).catch(() => undefined);
ws.emit('close');
expect(proc.kill).toHaveBeenCalledWith('SIGTERM');
expect(settled).toBe(false);
proc.emit('close', null);
await expectation;
});
it('rewrites ENOMEM spawn failures as host out-of-memory', async () => {
const freememSpy = vi.spyOn(os, 'freemem').mockReturnValue(32 * 1024 * 1024);
const totalmemSpy = vi.spyOn(os, 'totalmem').mockReturnValue(6612 * 1024 * 1024);
try {
const proc = createMockProcess();
mockSpawn.mockReturnValue(proc);
const ws = createMockWs();
const svc = ComposeService.getInstance(1);
const promise = svc.runCommand('my-stack', 'restart', ws);
const err = Object.assign(new Error('spawn docker ENOMEM'), { code: 'ENOMEM' });
proc.emit('error', err);
await expect(promise).rejects.toThrow(/Out of memory while launching docker/);
const sendCalls = ws.send.mock.calls.map(c => c[0] as string);
expect(sendCalls.some(msg => msg.includes('Out of memory while launching docker'))).toBe(true);
} finally {
freememSpy.mockRestore();
totalmemSpy.mockRestore();
}
});
it('rewrites ENOENT spawn failures as host OOM when free memory is below the floor', async () => {
const freememSpy = vi.spyOn(os, 'freemem').mockReturnValue(32 * 1024 * 1024);
const totalmemSpy = vi.spyOn(os, 'totalmem').mockReturnValue(6612 * 1024 * 1024);
try {
const proc = createMockProcess();
mockSpawn.mockReturnValue(proc);
const ws = createMockWs();
const svc = ComposeService.getInstance(1);
const promise = svc.runCommand('my-stack', 'restart', ws);
const err = Object.assign(new Error('spawn docker ENOENT'), { code: 'ENOENT' });
proc.emit('error', err);
await expect(promise).rejects.toThrow(/Out of memory while launching docker/);
const sendCalls = ws.send.mock.calls.map(c => c[0] as string);
expect(sendCalls.some(msg => msg.includes('reported as ENOENT under memory pressure'))).toBe(true);
} finally {
freememSpy.mockRestore();
totalmemSpy.mockRestore();
}
});
it('preserves "Docker CLI unavailable" wording on healthy-memory ENOENT for docker', async () => {
const freememSpy = vi.spyOn(os, 'freemem').mockReturnValue(2 * 1024 * 1024 * 1024);
const totalmemSpy = vi.spyOn(os, 'totalmem').mockReturnValue(6612 * 1024 * 1024);
try {
const proc = createMockProcess();
mockSpawn.mockReturnValue(proc);
const svc = ComposeService.getInstance(1);
const promise = svc.runCommand('my-stack', 'restart');
const err = Object.assign(new Error('spawn docker ENOENT'), { code: 'ENOENT' });
proc.emit('error', err);
await expect(promise).rejects.toThrow('Docker CLI unavailable on this node');
} finally {
freememSpy.mockRestore();
totalmemSpy.mockRestore();
}
});
});
// ── deployStack ────────────────────────────────────────────────────────
describe('ComposeService - deployStack', () => {
it('runs docker compose up -d --remove-orphans', async () => {
setupAutoCloseSpawn();
mockListContainers.mockResolvedValue([]);
const svc = ComposeService.getInstance(1);
const promise = svc.deployStack('my-stack');
// Advance past the 3s health probe timeout
await vi.advanceTimersByTimeAsync(3100);
await promise;
expect(mockSpawn).toHaveBeenCalledWith(
'docker',
['compose', 'up', '-d', '--remove-orphans'],
expect.any(Object)
);
});
it('creates backup when atomic=true', async () => {
setupAutoCloseSpawn();
mockListContainers.mockResolvedValue([]);
const svc = ComposeService.getInstance(1);
const promise = svc.deployStack('my-stack', undefined, true);
await vi.advanceTimersByTimeAsync(3100);
await promise;
expect(mockBackupStackFiles).toHaveBeenCalledWith('my-stack');
});
it('aborts atomic deploy before docker side effects when backup fails', async () => {
mockBackupStackFiles.mockRejectedValueOnce(new Error('disk full'));
const svc = ComposeService.getInstance(1);
await expect(svc.deployStack('my-stack', undefined, true)).rejects.toThrow(
'Atomic deployment backup failed',
);
expect(mockSpawn).not.toHaveBeenCalled();
expect(mockGetContainersByStack).not.toHaveBeenCalled();
});
it('throws sanitized CONTAINER_CRASHED when exited container has non-zero exit code', async () => {
setupAutoCloseSpawn();
mockListContainers.mockResolvedValue([{
Id: 'crashed-c1',
State: 'exited',
Labels: { 'com.docker.compose.project': 'my-stack' },
}]);
mockContainerInspect.mockResolvedValue({ State: { ExitCode: 1 } });
mockContainerLogs.mockResolvedValue(Buffer.from('SECRET_TOKEN=leaked'));
const svc = ComposeService.getInstance(1);
// Attach catch handler immediately so rejection is never "unhandled"
const result = svc.deployStack('my-stack').then(() => null, (e: Error) => e);
await vi.runAllTimersAsync();
const error = await result;
expect(error).not.toBeNull();
expect(error!.message).toContain('CONTAINER_CRASHED');
expect(error!.message).not.toContain('SECRET_TOKEN');
expect(mockContainerLogs).not.toHaveBeenCalled();
});
it('rolls back on failure when atomic=true', async () => {
setupAutoCloseSpawn();
mockListContainers.mockResolvedValue([{
Id: 'crashed-c1',
State: 'exited',
Labels: { 'com.docker.compose.project': 'my-stack' },
}]);
mockContainerInspect.mockResolvedValue({ State: { ExitCode: 1 } });
mockContainerLogs.mockResolvedValue(Buffer.from('Error'));
const svc = ComposeService.getInstance(1);
const result = svc.deployStack('my-stack', undefined, true).then(() => null, (e: Error) => e);
await vi.runAllTimersAsync();
const error = await result;
expect(error).not.toBeNull();
expect(error!.message).toContain('CONTAINER_CRASHED');
expect(getComposeRollbackInfo(error)).toEqual({ attempted: true, rolledBack: true });
expect(mockRestoreStackFiles).toHaveBeenCalledWith('my-stack');
});
it('reports rollback failure when atomic restore fails', async () => {
setupAutoCloseSpawn();
mockListContainers.mockResolvedValue([{
Id: 'crashed-c1',
State: 'exited',
Labels: { 'com.docker.compose.project': 'my-stack' },
}]);
mockContainerInspect.mockResolvedValue({ State: { ExitCode: 1 } });
mockRestoreStackFiles.mockRejectedValueOnce(new Error('restore denied'));
const svc = ComposeService.getInstance(1);
const result = svc.deployStack('my-stack', undefined, true).then(() => null, (e: Error) => e);
await vi.runAllTimersAsync();
const error = await result;
expect(error).not.toBeNull();
expect(getComposeRollbackInfo(error)).toEqual({ attempted: true, rolledBack: false });
});
it('does not roll back when atomic=false', async () => {
setupAutoCloseSpawn();
mockListContainers.mockResolvedValue([{
Id: 'crashed-c1',
State: 'exited',
}]);
mockContainerInspect.mockResolvedValue({ State: { ExitCode: 1 } });
mockContainerLogs.mockResolvedValue(Buffer.from('Error'));
const svc = ComposeService.getInstance(1);
const result = svc.deployStack('my-stack', undefined, false).then(() => null, (e: Error) => e);
await vi.runAllTimersAsync();
const error = await result;
expect(error).not.toBeNull();
expect(error!.message).toContain('CONTAINER_CRASHED');
expect(mockRestoreStackFiles).not.toHaveBeenCalled();
});
});
// ── withRegistryAuth ───────────────────────────────────────────────────
describe('ComposeService - withRegistryAuth', () => {
it('passes default env when no registries configured', async () => {
mockGetRegistries.mockReturnValue([]);
setupAutoCloseSpawn();
mockListContainers.mockResolvedValue([]);
const svc = ComposeService.getInstance(1);
const promise = svc.deployStack('my-stack');
await vi.advanceTimersByTimeAsync(3100);
await promise;
expect(mockMkdtempSync).not.toHaveBeenCalled();
});
it('creates temp config dir when registries exist', async () => {
mockGetRegistries.mockReturnValue([{ url: 'https://registry.example.com', username: 'user', password: 'pass' }]);
mockResolveDockerConfig.mockResolvedValue({ config: { auths: { 'registry.example.com': { auth: 'dXNlcjpwYXNz' } } }, warnings: [] });
setupAutoCloseSpawn();
mockListContainers.mockResolvedValue([]);
const svc = ComposeService.getInstance(1);
const promise = svc.deployStack('my-stack');
await vi.advanceTimersByTimeAsync(3100);
await promise;
expect(mockMkdtempSync).toHaveBeenCalled();
expect(mockWriteFileSync).toHaveBeenCalled();
expect(mockUnlinkSync).toHaveBeenCalled();
expect(mockRmdirSync).toHaveBeenCalled();
});
it('surfaces resolveDockerConfig warnings to the WebSocket output', async () => {
mockGetRegistries.mockReturnValue([{ url: 'https://registry.example.com' }]);
mockResolveDockerConfig.mockResolvedValue({
config: { auths: {} },
warnings: ['Registry "broken" credentials unavailable: bad key'],
});
setupAutoCloseSpawn();
mockListContainers.mockResolvedValue([]);
const ws = createMockWs();
const svc = ComposeService.getInstance(1);
const promise = svc.deployStack('my-stack', ws);
await vi.advanceTimersByTimeAsync(3100);
await promise;
const sendCalls = ws.send.mock.calls.map(c => c[0]);
expect(sendCalls.some((msg: string) => msg.includes('[Sencho] Warning') && msg.includes('bad key'))).toBe(true);
});
it('cleans up temp dir even on command failure', async () => {
mockGetRegistries.mockReturnValue([{ url: 'https://registry.example.com' }]);
mockResolveDockerConfig.mockResolvedValue({ config: { auths: {} }, warnings: [] });
// Make spawn fail
mockSpawn.mockImplementation(() => {
const proc = createMockProcess();
Promise.resolve().then(() => {
proc.stderr.emit('data', Buffer.from('pull failed'));
proc.emit('close', 1);
});
return proc;
});
const svc = ComposeService.getInstance(1);
const result = svc.deployStack('my-stack').then(() => null, (e: Error) => e);
await vi.runAllTimersAsync();
const error = await result;
expect(error).not.toBeNull();
expect(mockUnlinkSync).toHaveBeenCalled();
});
});
// ── downStack ──────────────────────────────────────────────────────────
describe('ComposeService - downStack', () => {
it('runs docker compose down with volumes and remove-orphans', async () => {
setupAutoCloseSpawn();
const svc = ComposeService.getInstance(1);
await svc.downStack('my-stack');
expect(mockSpawn).toHaveBeenCalledWith(
'docker',
['compose', 'down', '--volumes', '--remove-orphans'],
expect.any(Object)
);
});
it('resolves even when command fails (throwOnError=false)', async () => {
mockSpawn.mockImplementation(() => {
const proc = createMockProcess();
Promise.resolve().then(() => proc.emit('close', 1));
return proc;
});
const svc = ComposeService.getInstance(1);
await expect(svc.downStack('my-stack')).resolves.toBeUndefined();
});
});