mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 10:21:03 +00:00
b3bc223c45
* feat: move core Blueprint orchestration to Community tier Blueprints CRUD, reconciliation, and drift modes are now available on Community. Pin remains Admiral-only via Federation placement controls. * test: update NodeCard cordon tests for Admiral-only tier gate Cordon now requires both isPaid and node:manage permission, matching the backend requirePaid + requirePermission guard. Three tests still used isPaid:false but expected the menu to be visible.
1386 lines
52 KiB
TypeScript
1386 lines
52 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 path from 'path';
|
|
import type WebSocket from 'ws';
|
|
|
|
// ── Hoisted mocks ──────────────────────────────────────────────────────
|
|
|
|
const {
|
|
mockSpawn,
|
|
mockGetContainersByStack, mockGetLegacyOrphanContainersByStack, mockRemoveContainers, mockListContainers,
|
|
mockContainerInspect, mockContainerLogs,
|
|
mockGetRegistries, mockResolveDockerConfig,
|
|
mockBackupStackFiles, mockRestoreStackFiles,
|
|
mockGetComposeFilename, mockGetOverrideFilename, mockEnsureStackOverride,
|
|
mockMkdtempSync, mockWriteFileSync, mockUnlinkSync, mockRmdirSync,
|
|
mockGetGlobalSettings, mockPruneDanglingImages, mockGetBindMounts,
|
|
mockGetStackContent, mockGetEnvContent,
|
|
mockLoadStackBuildServices,
|
|
} = vi.hoisted(() => ({
|
|
mockSpawn: vi.fn(),
|
|
mockGetContainersByStack: vi.fn().mockResolvedValue([]),
|
|
mockGetLegacyOrphanContainersByStack: 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),
|
|
mockGetComposeFilename: vi.fn().mockResolvedValue('compose.yaml'),
|
|
mockGetOverrideFilename: vi.fn().mockResolvedValue(null),
|
|
mockEnsureStackOverride: vi.fn().mockResolvedValue(null),
|
|
mockMkdtempSync: vi.fn().mockReturnValue('/tmp/sencho-docker-test'),
|
|
mockWriteFileSync: vi.fn(),
|
|
mockUnlinkSync: vi.fn(),
|
|
mockRmdirSync: vi.fn(),
|
|
mockGetGlobalSettings: vi.fn().mockReturnValue({}),
|
|
mockPruneDanglingImages: vi.fn().mockResolvedValue({ reclaimedBytes: 0 }),
|
|
mockGetBindMounts: vi.fn().mockResolvedValue(null),
|
|
mockGetStackContent: vi.fn().mockResolvedValue(''),
|
|
mockGetEnvContent: vi.fn().mockResolvedValue(''),
|
|
mockLoadStackBuildServices: vi.fn().mockResolvedValue([]),
|
|
}));
|
|
|
|
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,
|
|
getLegacyOrphanContainersByStack: mockGetLegacyOrphanContainersByStack,
|
|
removeContainers: mockRemoveContainers,
|
|
pruneDanglingImages: mockPruneDanglingImages,
|
|
getDocker: () => ({
|
|
listContainers: mockListContainers,
|
|
getContainer: () => ({
|
|
inspect: mockContainerInspect,
|
|
logs: mockContainerLogs,
|
|
}),
|
|
}),
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../services/DatabaseService', () => ({
|
|
DatabaseService: {
|
|
getInstance: () => ({
|
|
getRegistries: mockGetRegistries,
|
|
getGlobalSettings: mockGetGlobalSettings,
|
|
getGitSource: () => undefined,
|
|
getStackProjectEnvFiles: () => [],
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../services/RegistryService', () => ({
|
|
RegistryService: {
|
|
getInstance: () => ({
|
|
resolveDockerConfig: mockResolveDockerConfig,
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../services/FileSystemService', () => ({
|
|
FileSystemService: {
|
|
getInstance: () => ({
|
|
backupStackFiles: mockBackupStackFiles,
|
|
restoreStackFiles: mockRestoreStackFiles,
|
|
getComposeFilename: mockGetComposeFilename,
|
|
getOverrideFilename: mockGetOverrideFilename,
|
|
getStackContent: mockGetStackContent,
|
|
getEnvContent: mockGetEnvContent,
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../services/LogFormatter', () => ({
|
|
LogFormatter: { process: (line: string) => line },
|
|
}));
|
|
|
|
// runCommand and the deploy/update paths route through authoredComposeArgs, which
|
|
// resolves the (optional) mesh override. The hoisted mock defaults to "no override"
|
|
// so a single-file stack yields plain `docker compose <action>` args deterministically;
|
|
// individual tests set a path to exercise the mesh-injection branch.
|
|
vi.mock('../services/MeshService', () => ({
|
|
MeshService: {
|
|
getInstance: () => ({ ensureStackOverride: mockEnsureStackOverride }),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../services/SelfIdentityService', () => ({
|
|
default: {
|
|
getInstance: () => ({ getBindMounts: mockGetBindMounts }),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../services/ImageUpdateService', async (importOriginal) => ({
|
|
...(await importOriginal<typeof import('../services/ImageUpdateService')>()),
|
|
loadStackBuildServices: (...args: unknown[]) => mockLoadStackBuildServices(...args),
|
|
}));
|
|
|
|
import { ComposeService, getComposeRollbackInfo } from '../services/ComposeService';
|
|
import { DriftLedgerService } from '../services/DriftLedgerService';
|
|
|
|
const originalComposeTimeout = process.env.SENCHO_COMPOSE_COMMAND_TIMEOUT_MS;
|
|
const originalStallTimeout = process.env.SENCHO_COMPOSE_STALL_TIMEOUT_MS;
|
|
const originalSenchoMode = process.env.SENCHO_MODE;
|
|
|
|
/** 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;
|
|
}
|
|
|
|
/**
|
|
* runCommand now awaits the authored compose args (mesh override resolution) before
|
|
* spawning, so the child is created one microtask after the call. Tests that drive
|
|
* the mock child's events must wait for the spawn first, or the event fires before
|
|
* any listener is attached.
|
|
*/
|
|
async function waitForSpawn() {
|
|
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalled());
|
|
}
|
|
|
|
/** 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();
|
|
// clearAllMocks() clears call records but not implementations, so a mockResolvedValue
|
|
// set by one test persists into the next. Re-assert the safe "no mesh override, no user
|
|
// override, base = compose.yaml" baseline here so a stray override from an earlier test
|
|
// cannot leak forward and add phantom -f flags.
|
|
mockGetComposeFilename.mockResolvedValue('compose.yaml');
|
|
mockGetOverrideFilename.mockResolvedValue(null);
|
|
mockEnsureStackOverride.mockResolvedValue(null);
|
|
mockGetBindMounts.mockResolvedValue(null);
|
|
mockLoadStackBuildServices.mockResolvedValue([]);
|
|
delete process.env.SENCHO_MODE;
|
|
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;
|
|
}
|
|
if (originalStallTimeout === undefined) {
|
|
delete process.env.SENCHO_COMPOSE_STALL_TIMEOUT_MS;
|
|
} else {
|
|
process.env.SENCHO_COMPOSE_STALL_TIMEOUT_MS = originalStallTimeout;
|
|
}
|
|
if (originalSenchoMode === undefined) {
|
|
delete process.env.SENCHO_MODE;
|
|
} else {
|
|
process.env.SENCHO_MODE = originalSenchoMode;
|
|
}
|
|
});
|
|
|
|
// ── 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');
|
|
await waitForSpawn();
|
|
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');
|
|
await waitForSpawn();
|
|
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');
|
|
await waitForSpawn();
|
|
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');
|
|
await waitForSpawn();
|
|
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);
|
|
await waitForSpawn();
|
|
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('keeps the command running when the WebSocket disconnects (progress socket is output-only)', async () => {
|
|
const proc = createMockProcess();
|
|
mockSpawn.mockReturnValue(proc);
|
|
const ws = createMockWs();
|
|
|
|
const svc = ComposeService.getInstance(1);
|
|
const promise = svc.runCommand('my-stack', 'restart', ws);
|
|
await waitForSpawn();
|
|
// The deploy is owned by its HTTP request; closing the progress socket
|
|
// (panel minimized, navigated away, connection blip) must not abort it.
|
|
ws.emit('close');
|
|
expect(proc.kill).not.toHaveBeenCalled();
|
|
|
|
// The command still completes on its own exit, not the socket close.
|
|
proc.emit('close', 0);
|
|
await expect(promise).resolves.toBeUndefined();
|
|
});
|
|
|
|
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);
|
|
await waitForSpawn();
|
|
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);
|
|
await waitForSpawn();
|
|
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');
|
|
await waitForSpawn();
|
|
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();
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── authoredComposeArgs: mesh + user override ──────────────────────────
|
|
|
|
describe('ComposeService - authoredComposeArgs mesh override', () => {
|
|
const MESH_OVERRIDE = '/app/data/mesh/overrides/1/my-stack.override.yml';
|
|
|
|
it('preserves a user compose.override.yml between the base and the mesh override', async () => {
|
|
// Single-file stack opted into mesh, with a hand-authored override on disk.
|
|
mockEnsureStackOverride.mockResolvedValue(MESH_OVERRIDE);
|
|
mockGetComposeFilename.mockResolvedValue('compose.yaml');
|
|
mockGetOverrideFilename.mockResolvedValue('compose.override.yml');
|
|
|
|
const proc = createMockProcess();
|
|
mockSpawn.mockReturnValue(proc);
|
|
|
|
const svc = ComposeService.getInstance(1);
|
|
const promise = svc.runCommand('my-stack', 'restart');
|
|
await waitForSpawn();
|
|
proc.emit('close', 0);
|
|
await promise;
|
|
|
|
// The user override sits between the base and the mesh override as a bare basename
|
|
// (resolved against the stack-dir cwd); only the mesh override is an absolute path.
|
|
expect(mockSpawn).toHaveBeenCalledWith(
|
|
'docker',
|
|
['compose', '-f', 'compose.yaml', '-f', 'compose.override.yml', '-f', MESH_OVERRIDE, 'restart'],
|
|
expect.objectContaining({ cwd: expect.stringContaining('my-stack') })
|
|
);
|
|
});
|
|
|
|
it('emits base + mesh override only when no user override exists', async () => {
|
|
mockEnsureStackOverride.mockResolvedValue(MESH_OVERRIDE);
|
|
mockGetComposeFilename.mockResolvedValue('compose.yaml');
|
|
mockGetOverrideFilename.mockResolvedValue(null);
|
|
|
|
const proc = createMockProcess();
|
|
mockSpawn.mockReturnValue(proc);
|
|
|
|
const svc = ComposeService.getInstance(1);
|
|
const promise = svc.runCommand('my-stack', 'restart');
|
|
await waitForSpawn();
|
|
proc.emit('close', 0);
|
|
await promise;
|
|
|
|
expect(mockSpawn).toHaveBeenCalledWith(
|
|
'docker',
|
|
['compose', '-f', 'compose.yaml', '-f', MESH_OVERRIDE, 'restart'],
|
|
expect.objectContaining({ cwd: expect.stringContaining('my-stack') })
|
|
);
|
|
});
|
|
|
|
it('does not look up or emit a user override when mesh is disabled', async () => {
|
|
// A user override on disk must not introduce -f flags for a non-mesh stack;
|
|
// implicit compose discovery already resolves it when no -f is passed.
|
|
mockEnsureStackOverride.mockResolvedValue(null);
|
|
mockGetOverrideFilename.mockResolvedValue('compose.override.yml');
|
|
|
|
const proc = createMockProcess();
|
|
mockSpawn.mockReturnValue(proc);
|
|
|
|
const svc = ComposeService.getInstance(1);
|
|
const promise = svc.runCommand('my-stack', 'restart');
|
|
await waitForSpawn();
|
|
proc.emit('close', 0);
|
|
await promise;
|
|
|
|
expect(mockSpawn).toHaveBeenCalledWith(
|
|
'docker',
|
|
['compose', 'restart'],
|
|
expect.objectContaining({ cwd: expect.stringContaining('my-stack') })
|
|
);
|
|
// The override lookup is gated inside the mesh branch.
|
|
expect(mockGetOverrideFilename).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('drops the user override and still deploys when the lookup throws', async () => {
|
|
// A present override that cannot be resolved (e.g. EACCES) must not crash the
|
|
// deploy: the mesh override still applies and the deploy proceeds without the
|
|
// user override, with a warning logged.
|
|
mockEnsureStackOverride.mockResolvedValue(MESH_OVERRIDE);
|
|
mockGetComposeFilename.mockResolvedValue('compose.yaml');
|
|
mockGetOverrideFilename.mockRejectedValue(Object.assign(new Error('EACCES'), { code: 'EACCES' }));
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
|
|
const proc = createMockProcess();
|
|
mockSpawn.mockReturnValue(proc);
|
|
|
|
const svc = ComposeService.getInstance(1);
|
|
const promise = svc.runCommand('my-stack', 'restart');
|
|
await waitForSpawn();
|
|
proc.emit('close', 0);
|
|
await promise;
|
|
|
|
expect(mockSpawn).toHaveBeenCalledWith(
|
|
'docker',
|
|
['compose', '-f', 'compose.yaml', '-f', MESH_OVERRIDE, 'restart'],
|
|
expect.objectContaining({ cwd: expect.stringContaining('my-stack') })
|
|
);
|
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('could not resolve user compose override'),
|
|
expect.anything()
|
|
);
|
|
warnSpy.mockRestore();
|
|
});
|
|
|
|
it('aborts the deploy when the override lookup hits a containment-guard rejection', async () => {
|
|
// A symlink-escape (or invalid-name) rejection from the override lookup is a hard
|
|
// error: it must propagate and abort the deploy, never degrade to "no override".
|
|
mockEnsureStackOverride.mockResolvedValue(MESH_OVERRIDE);
|
|
mockGetComposeFilename.mockResolvedValue('compose.yaml');
|
|
mockGetOverrideFilename.mockRejectedValue(Object.assign(new Error('symlink escape'), { code: 'SYMLINK_ESCAPE' }));
|
|
|
|
const svc = ComposeService.getInstance(1);
|
|
await expect(svc.runCommand('my-stack', 'restart')).rejects.toMatchObject({ code: 'SYMLINK_ESCAPE' });
|
|
// The error is thrown while building the args, before docker is ever spawned.
|
|
expect(mockSpawn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('leak sanity: a default single-file stack still emits no -f flags', async () => {
|
|
// Proves the override-setting tests above do not leak through the shared mocks.
|
|
const proc = createMockProcess();
|
|
mockSpawn.mockReturnValue(proc);
|
|
|
|
const svc = ComposeService.getInstance(1);
|
|
const promise = svc.runCommand('my-stack', 'restart');
|
|
await waitForSpawn();
|
|
proc.emit('close', 0);
|
|
await promise;
|
|
|
|
expect(mockSpawn).toHaveBeenCalledWith(
|
|
'docker',
|
|
['compose', 'restart'],
|
|
expect.objectContaining({ cwd: expect.stringContaining('my-stack') })
|
|
);
|
|
});
|
|
});
|
|
|
|
// ── deployStack ────────────────────────────────────────────────────────
|
|
|
|
describe('ComposeService - deployStack', () => {
|
|
it('blocks a pilot deploy with relative binds before replacing containers when path mapping differs', async () => {
|
|
process.env.SENCHO_MODE = 'pilot';
|
|
const composeDir = path.resolve('/test/compose');
|
|
mockGetBindMounts.mockResolvedValue([
|
|
{ source: '/opt/docker/sencho', destination: composeDir },
|
|
]);
|
|
const configProc = createMockProcess();
|
|
mockSpawn.mockReturnValue(configProc);
|
|
|
|
const svc = ComposeService.getInstance(1);
|
|
const result = svc.deployStack('my-stack').then(() => null, (error: Error) => error);
|
|
await waitForSpawn();
|
|
configProc.stdout.emit('data', Buffer.from(JSON.stringify({
|
|
name: 'my-stack',
|
|
services: {
|
|
db: {
|
|
image: 'postgres:17',
|
|
volumes: [{
|
|
type: 'bind',
|
|
source: path.join(composeDir, 'my-stack', 'data'),
|
|
target: '/var/lib/postgresql/data',
|
|
}],
|
|
},
|
|
},
|
|
})));
|
|
configProc.emit('close', 0);
|
|
|
|
const error = await result;
|
|
expect(error?.message).toContain('1:1');
|
|
expect(mockGetLegacyOrphanContainersByStack).not.toHaveBeenCalled();
|
|
expect(mockRemoveContainers).not.toHaveBeenCalled();
|
|
expect(mockSpawn).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('allows a pilot deploy that uses only absolute external bind sources', async () => {
|
|
process.env.SENCHO_MODE = 'pilot';
|
|
const composeDir = path.resolve('/test/compose');
|
|
mockGetBindMounts.mockResolvedValue([
|
|
{ source: '/opt/docker/sencho', destination: composeDir },
|
|
]);
|
|
let spawnCount = 0;
|
|
mockSpawn.mockImplementation(() => {
|
|
spawnCount += 1;
|
|
const proc = createMockProcess();
|
|
Promise.resolve().then(() => {
|
|
if (spawnCount === 1) {
|
|
proc.stdout.emit('data', Buffer.from(JSON.stringify({
|
|
name: 'my-stack',
|
|
services: {
|
|
db: {
|
|
image: 'postgres:17',
|
|
volumes: [{
|
|
type: 'bind',
|
|
source: '/srv/postgres/data',
|
|
target: '/var/lib/postgresql/data',
|
|
}],
|
|
},
|
|
},
|
|
})));
|
|
}
|
|
proc.emit('close', 0);
|
|
});
|
|
return proc;
|
|
});
|
|
mockListContainers.mockResolvedValue([]);
|
|
|
|
const promise = ComposeService.getInstance(1).deployStack('my-stack');
|
|
await vi.advanceTimersByTimeAsync(3100);
|
|
|
|
await expect(promise).resolves.toBeUndefined();
|
|
expect(mockGetLegacyOrphanContainersByStack).toHaveBeenCalledWith('my-stack');
|
|
});
|
|
|
|
it('does not remove compose-managed containers before deploy (#1565)', async () => {
|
|
setupAutoCloseSpawn();
|
|
mockListContainers.mockResolvedValue([]);
|
|
mockGetLegacyOrphanContainersByStack.mockResolvedValue([]);
|
|
|
|
const svc = ComposeService.getInstance(1);
|
|
const promise = svc.deployStack('my-stack');
|
|
await vi.advanceTimersByTimeAsync(3100);
|
|
await promise;
|
|
|
|
expect(mockGetLegacyOrphanContainersByStack).toHaveBeenCalledWith('my-stack');
|
|
expect(mockRemoveContainers).not.toHaveBeenCalled();
|
|
expect(mockSpawn).toHaveBeenCalledWith(
|
|
'docker',
|
|
['compose', 'up', '-d', '--remove-orphans'],
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
it('removes legacy orphan containers when compose ps is empty', async () => {
|
|
setupAutoCloseSpawn();
|
|
mockListContainers.mockResolvedValue([]);
|
|
mockGetLegacyOrphanContainersByStack.mockResolvedValue([
|
|
{ Id: 'legacy-c1' },
|
|
{ Id: 'legacy-c2' },
|
|
]);
|
|
|
|
const svc = ComposeService.getInstance(1);
|
|
const promise = svc.deployStack('my-stack');
|
|
await vi.advanceTimersByTimeAsync(3100);
|
|
await promise;
|
|
|
|
expect(mockRemoveContainers).toHaveBeenCalledWith(['legacy-c1', 'legacy-c2']);
|
|
expect(mockSpawn).toHaveBeenCalledWith(
|
|
'docker',
|
|
['compose', 'up', '-d', '--remove-orphans'],
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
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(mockGetLegacyOrphanContainersByStack).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();
|
|
});
|
|
});
|
|
|
|
// ── updateStack: build-aware ───────────────────────────────────────────
|
|
|
|
describe('ComposeService - updateStack build-aware', () => {
|
|
it('runs build --pull, pull --ignore-buildable, and up when build services exist', async () => {
|
|
mockLoadStackBuildServices.mockResolvedValueOnce(['app']);
|
|
setupAutoCloseSpawn();
|
|
mockListContainers.mockResolvedValue([]);
|
|
|
|
const svc = ComposeService.getInstance(1);
|
|
const promise = svc.updateStack('my-stack');
|
|
await vi.advanceTimersByTimeAsync(3100);
|
|
await promise;
|
|
|
|
const spawnArgs = mockSpawn.mock.calls.map(c => c[1] as string[]);
|
|
expect(spawnArgs.some(args => args.includes('build') && args.includes('--pull'))).toBe(true);
|
|
expect(spawnArgs.some(args => args.includes('pull') && args.includes('--ignore-buildable'))).toBe(true);
|
|
expect(spawnArgs.some(args => args.includes('up') && args.includes('-d'))).toBe(true);
|
|
});
|
|
|
|
it('runs plain pull + up when no build services exist', async () => {
|
|
mockLoadStackBuildServices.mockResolvedValueOnce([]);
|
|
setupAutoCloseSpawn();
|
|
mockListContainers.mockResolvedValue([]);
|
|
|
|
const svc = ComposeService.getInstance(1);
|
|
const promise = svc.updateStack('my-stack');
|
|
await vi.advanceTimersByTimeAsync(3100);
|
|
await promise;
|
|
|
|
const spawnArgs = mockSpawn.mock.calls.map(c => c[1] as string[]);
|
|
expect(spawnArgs.some(args => args.includes('build'))).toBe(false);
|
|
expect(spawnArgs.some(args => args.includes('pull') && !args.includes('--ignore-buildable'))).toBe(true);
|
|
});
|
|
|
|
it('rolls back compose files when a build step fails during atomic update', async () => {
|
|
mockLoadStackBuildServices.mockResolvedValueOnce(['app']);
|
|
let spawnCount = 0;
|
|
mockSpawn.mockImplementation(() => {
|
|
spawnCount += 1;
|
|
const proc = createMockProcess();
|
|
Promise.resolve().then(() => proc.emit('close', spawnCount === 1 ? 1 : 0));
|
|
return proc;
|
|
});
|
|
mockListContainers.mockResolvedValue([]);
|
|
|
|
const svc = ComposeService.getInstance(1);
|
|
const result = svc.updateStack('my-stack', undefined, true).then(() => null, (e: Error) => e);
|
|
await vi.advanceTimersByTimeAsync(3100);
|
|
const error = await result;
|
|
|
|
expect(error).not.toBeNull();
|
|
expect(mockRestoreStackFiles).toHaveBeenCalled();
|
|
expect(getComposeRollbackInfo(error)?.attempted).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ── updateStack: prune-on-update ───────────────────────────────────────
|
|
|
|
describe('ComposeService - updateStack prune-on-update', () => {
|
|
it('prunes dangling images after a successful update when prune_on_update=1', async () => {
|
|
setupAutoCloseSpawn();
|
|
mockListContainers.mockResolvedValue([]);
|
|
mockGetGlobalSettings.mockReturnValue({ prune_on_update: '1' });
|
|
mockPruneDanglingImages.mockResolvedValue({ reclaimedBytes: 2_097_152 });
|
|
|
|
const svc = ComposeService.getInstance(1);
|
|
const promise = svc.updateStack('my-stack');
|
|
await vi.advanceTimersByTimeAsync(3100);
|
|
await promise;
|
|
|
|
expect(mockPruneDanglingImages).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('streams a reclaim figure only when the daemon reports bytes', async () => {
|
|
mockListContainers.mockResolvedValue([]);
|
|
mockGetGlobalSettings.mockReturnValue({ prune_on_update: '1' });
|
|
const ws = createMockWs();
|
|
|
|
// The containerd image store reports SpaceReclaimed=0 even when it removes
|
|
// images, so a zero figure must be omitted rather than shown as "0.0 MB".
|
|
setupAutoCloseSpawn();
|
|
mockPruneDanglingImages.mockResolvedValueOnce({ reclaimedBytes: 0 });
|
|
let p = ComposeService.getInstance(1).updateStack('my-stack', ws);
|
|
await vi.advanceTimersByTimeAsync(3100);
|
|
await p;
|
|
expect(ws.send).toHaveBeenCalledWith('=== Pruned dangling images ===\n');
|
|
|
|
ws.send.mockClear();
|
|
setupAutoCloseSpawn();
|
|
mockPruneDanglingImages.mockResolvedValueOnce({ reclaimedBytes: 5 * 1024 * 1024 });
|
|
p = ComposeService.getInstance(1).updateStack('my-stack', ws);
|
|
await vi.advanceTimersByTimeAsync(3100);
|
|
await p;
|
|
expect(ws.send).toHaveBeenCalledWith('=== Pruned dangling images · reclaimed 5.0 MB ===\n');
|
|
});
|
|
|
|
it('does not prune when prune_on_update=0', async () => {
|
|
setupAutoCloseSpawn();
|
|
mockListContainers.mockResolvedValue([]);
|
|
mockGetGlobalSettings.mockReturnValue({ prune_on_update: '0' });
|
|
|
|
const svc = ComposeService.getInstance(1);
|
|
const promise = svc.updateStack('my-stack');
|
|
await vi.advanceTimersByTimeAsync(3100);
|
|
await promise;
|
|
|
|
expect(mockPruneDanglingImages).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not prune when the setting key is absent (fail-safe for un-backfilled DBs)', async () => {
|
|
setupAutoCloseSpawn();
|
|
mockListContainers.mockResolvedValue([]);
|
|
mockGetGlobalSettings.mockReturnValue({});
|
|
|
|
const svc = ComposeService.getInstance(1);
|
|
const promise = svc.updateStack('my-stack');
|
|
await vi.advanceTimersByTimeAsync(3100);
|
|
await promise;
|
|
|
|
expect(mockPruneDanglingImages).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not prune when the update itself fails (prune is success-only)', async () => {
|
|
setupAutoCloseSpawn();
|
|
// A container that exited non-zero makes the post-update health probe throw,
|
|
// so control never reaches the prune block that follows it.
|
|
mockListContainers.mockResolvedValue([{ Id: 'c1', State: 'exited' }]);
|
|
mockContainerInspect.mockResolvedValue({ State: { ExitCode: 1 } });
|
|
mockGetGlobalSettings.mockReturnValue({ prune_on_update: '1' });
|
|
|
|
const svc = ComposeService.getInstance(1);
|
|
const promise = svc.updateStack('my-stack');
|
|
// Attach the rejection expectation before advancing timers so the throw
|
|
// (which fires mid-advance) is never momentarily unhandled.
|
|
const rejection = expect(promise).rejects.toThrow();
|
|
await vi.advanceTimersByTimeAsync(3100);
|
|
await rejection;
|
|
|
|
expect(mockPruneDanglingImages).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not roll back an atomic update when the post-update prune throws', async () => {
|
|
setupAutoCloseSpawn();
|
|
mockListContainers.mockResolvedValue([]);
|
|
mockGetGlobalSettings.mockReturnValue({ prune_on_update: '1' });
|
|
mockPruneDanglingImages.mockRejectedValueOnce(new Error('docker busy'));
|
|
|
|
const svc = ComposeService.getInstance(1);
|
|
const promise = svc.updateStack('my-stack', undefined, true); // atomic
|
|
await vi.advanceTimersByTimeAsync(3100);
|
|
|
|
// The update already succeeded before the prune ran, so a prune failure
|
|
// must neither reject nor trigger the atomic restore.
|
|
await expect(promise).resolves.toBeUndefined();
|
|
expect(mockRestoreStackFiles).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not fail the update when the prune throws', async () => {
|
|
setupAutoCloseSpawn();
|
|
mockListContainers.mockResolvedValue([]);
|
|
mockGetGlobalSettings.mockReturnValue({ prune_on_update: '1' });
|
|
mockPruneDanglingImages.mockRejectedValueOnce(new Error('docker busy'));
|
|
|
|
const svc = ComposeService.getInstance(1);
|
|
const promise = svc.updateStack('my-stack');
|
|
await vi.advanceTimersByTimeAsync(3100);
|
|
|
|
await expect(promise).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ── withRegistryAuth ───────────────────────────────────────────────────
|
|
|
|
describe('ComposeService - drift reconcile hook', () => {
|
|
it('reconciles the drift ledger after a successful update', async () => {
|
|
setupAutoCloseSpawn();
|
|
mockListContainers.mockResolvedValue([]);
|
|
mockGetGlobalSettings.mockReturnValue({});
|
|
const spy = vi.spyOn(DriftLedgerService.getInstance(), 'reconcileStack').mockResolvedValue({ detected: 0, resolved: 0 });
|
|
|
|
const promise = ComposeService.getInstance(1).updateStack('my-stack');
|
|
await vi.advanceTimersByTimeAsync(3100);
|
|
await promise;
|
|
|
|
expect(spy).toHaveBeenCalledWith(1, 'my-stack');
|
|
spy.mockRestore();
|
|
});
|
|
|
|
it('reconciles the drift ledger after a successful deploy', async () => {
|
|
setupAutoCloseSpawn();
|
|
mockListContainers.mockResolvedValue([]);
|
|
mockGetGlobalSettings.mockReturnValue({});
|
|
const spy = vi.spyOn(DriftLedgerService.getInstance(), 'reconcileStack').mockResolvedValue({ detected: 0, resolved: 0 });
|
|
|
|
const promise = ComposeService.getInstance(1).deployStack('my-stack');
|
|
await vi.advanceTimersByTimeAsync(3100);
|
|
await promise;
|
|
|
|
expect(spy).toHaveBeenCalledWith(1, 'my-stack');
|
|
spy.mockRestore();
|
|
});
|
|
|
|
it('does not reconcile the ledger when a deploy fails', async () => {
|
|
setupAutoCloseSpawn(1); // non-zero exit => the deploy rejects before the post-success hook
|
|
mockListContainers.mockResolvedValue([]);
|
|
mockGetGlobalSettings.mockReturnValue({});
|
|
const spy = vi.spyOn(DriftLedgerService.getInstance(), 'reconcileStack').mockResolvedValue({ detected: 0, resolved: 0 });
|
|
|
|
const result = await ComposeService.getInstance(1).deployStack('my-stack').then(() => null, (e: Error) => e);
|
|
expect(result).toBeInstanceOf(Error);
|
|
expect(spy).not.toHaveBeenCalled();
|
|
spy.mockRestore();
|
|
});
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|
|
|
|
// ── stall (idle-output) backstop ───────────────────────────────────────
|
|
|
|
describe('ComposeService - idle-output stall backstop', () => {
|
|
it('terminates a silent update step and rejects with STACK_STALLED_OUTPUT', async () => {
|
|
process.env.SENCHO_COMPOSE_STALL_TIMEOUT_MS = '1000';
|
|
const proc = createMockProcess();
|
|
mockSpawn.mockReturnValue(proc);
|
|
|
|
const svc = ComposeService.getInstance(1);
|
|
// The pull emits nothing; the idle backstop should fire after 1s.
|
|
const result = svc.updateStack('my-stack').then(() => null, (e: Error) => e);
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
|
|
expect(proc.kill).toHaveBeenCalledWith('SIGTERM');
|
|
proc.emit('close', null);
|
|
const error = await result;
|
|
expect(error).not.toBeNull();
|
|
expect(error!.message).toContain('STACK_STALLED_OUTPUT');
|
|
});
|
|
|
|
it('sends a stalled marker to the WebSocket before terminating', async () => {
|
|
process.env.SENCHO_COMPOSE_STALL_TIMEOUT_MS = '1000';
|
|
const proc = createMockProcess();
|
|
mockSpawn.mockReturnValue(proc);
|
|
const ws = createMockWs();
|
|
|
|
const svc = ComposeService.getInstance(1);
|
|
const result = svc.updateStack('my-stack', ws).then(() => null, (e: Error) => e);
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
proc.emit('close', null);
|
|
await result;
|
|
|
|
const sendCalls = ws.send.mock.calls.map(c => c[0] as string);
|
|
expect(sendCalls.some(msg => msg.includes('appears stalled and was stopped'))).toBe(true);
|
|
});
|
|
|
|
it('does not stall when output keeps arriving within the idle window', async () => {
|
|
process.env.SENCHO_COMPOSE_STALL_TIMEOUT_MS = '1000';
|
|
mockListContainers.mockResolvedValue([]);
|
|
const proc = createMockProcess();
|
|
// deployStack now spawns a second child (exposure refresh via renderConfig)
|
|
// after the up command closes. Return the controlled proc for the up spawn,
|
|
// and a fresh auto-closing proc for the config spawn so the test does not
|
|
// hang on the already-closed proc.
|
|
let spawnCount = 0;
|
|
mockSpawn.mockImplementation(() => {
|
|
spawnCount += 1;
|
|
if (spawnCount === 1) return proc;
|
|
const configProc = createMockProcess();
|
|
Promise.resolve().then(() => configProc.emit('close', 0));
|
|
return configProc;
|
|
});
|
|
|
|
const svc = ComposeService.getInstance(1);
|
|
// deployStack spawns a single `up`; emit output every 600ms (< 1s window)
|
|
// so the idle timer resets and never fires.
|
|
const promise = svc.deployStack('my-stack');
|
|
await vi.advanceTimersByTimeAsync(600);
|
|
proc.stdout.emit('data', Buffer.from('pulling layer a...'));
|
|
await vi.advanceTimersByTimeAsync(600);
|
|
proc.stdout.emit('data', Buffer.from('pulling layer b...'));
|
|
await vi.advanceTimersByTimeAsync(600);
|
|
expect(proc.kill).not.toHaveBeenCalled();
|
|
|
|
proc.emit('close', 0);
|
|
await vi.advanceTimersByTimeAsync(3100); // health probe
|
|
await promise;
|
|
expect(proc.kill).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not arm the idle backstop for runCommand (down/restart/stop stay silent-safe)', async () => {
|
|
process.env.SENCHO_COMPOSE_STALL_TIMEOUT_MS = '1000';
|
|
const proc = createMockProcess();
|
|
mockSpawn.mockReturnValue(proc);
|
|
|
|
const svc = ComposeService.getInstance(1);
|
|
const promise = svc.runCommand('my-stack', 'restart');
|
|
// A silent restart longer than the stall window must not be killed: the
|
|
// idle backstop is only armed for deploy/update compose steps.
|
|
await vi.advanceTimersByTimeAsync(1500);
|
|
expect(proc.kill).not.toHaveBeenCalled();
|
|
|
|
proc.emit('close', 0);
|
|
await promise;
|
|
});
|
|
|
|
it('falls back to the default stall window when the env value is invalid', async () => {
|
|
process.env.SENCHO_COMPOSE_STALL_TIMEOUT_MS = '0'; // invalid → default (10min)
|
|
mockListContainers.mockResolvedValue([]);
|
|
const proc = createMockProcess();
|
|
// Same pattern as the stall test above: the second spawn (exposure refresh)
|
|
// needs its own auto-closing proc so deployStack can resolve after close.
|
|
let spawnCount = 0;
|
|
mockSpawn.mockImplementation(() => {
|
|
spawnCount += 1;
|
|
if (spawnCount === 1) return proc;
|
|
const configProc = createMockProcess();
|
|
Promise.resolve().then(() => configProc.emit('close', 0));
|
|
return configProc;
|
|
});
|
|
|
|
const svc = ComposeService.getInstance(1);
|
|
const promise = svc.deployStack('my-stack');
|
|
// Far below the 10-minute default: a '0' that leaked through would fire at 0ms.
|
|
await vi.advanceTimersByTimeAsync(2000);
|
|
expect(proc.kill).not.toHaveBeenCalled();
|
|
|
|
proc.emit('close', 0);
|
|
await vi.advanceTimersByTimeAsync(3100); // health probe
|
|
await promise;
|
|
});
|
|
|
|
it('preserves STACK_STALLED_OUTPUT through the atomic rollback wrapper', async () => {
|
|
process.env.SENCHO_COMPOSE_STALL_TIMEOUT_MS = '1000';
|
|
const pullProc = createMockProcess();
|
|
let call = 0;
|
|
// First spawn is the stalling pull; later spawns (the rollback restore's
|
|
// `up`) close cleanly, proving the restore is not idle-timeout armed.
|
|
mockSpawn.mockImplementation(() => {
|
|
call += 1;
|
|
if (call === 1) return pullProc;
|
|
const p = createMockProcess();
|
|
Promise.resolve().then(() => p.emit('close', 0));
|
|
return p;
|
|
});
|
|
|
|
const svc = ComposeService.getInstance(1);
|
|
const result = svc.updateStack('my-stack', undefined, true).then(() => null, (e: Error) => e);
|
|
await vi.advanceTimersByTimeAsync(1000); // idle backstop fires on the silent pull
|
|
pullProc.emit('close', null);
|
|
await vi.runAllTimersAsync();
|
|
|
|
const error = await result;
|
|
expect(error).not.toBeNull();
|
|
expect(error!.message).toContain('STACK_STALLED_OUTPUT');
|
|
expect(getComposeRollbackInfo(error)).toMatchObject({ attempted: true });
|
|
});
|
|
});
|
|
|
|
// ── streamLogs ─────────────────────────────────────────────────────────
|
|
|
|
describe('ComposeService - streamLogs', () => {
|
|
it('emits a normalized container name prefix for each container', async () => {
|
|
mockGetContainersByStack.mockResolvedValue([
|
|
{ Names: ['/mystack-redis-1'], State: 'running', Id: 'abc123' },
|
|
{ Names: ['/mystack-api-1'], State: 'running', Id: 'def456' },
|
|
]);
|
|
|
|
const ws = createMockWs();
|
|
const svc = ComposeService.getInstance(1);
|
|
|
|
const proc1 = createMockProcess();
|
|
const proc2 = createMockProcess();
|
|
mockSpawn
|
|
.mockReturnValueOnce(proc1)
|
|
.mockReturnValueOnce(proc2);
|
|
|
|
svc.streamLogs('mystack', ws);
|
|
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(2));
|
|
|
|
// Emit stdout from each container.
|
|
proc1.stdout.emit('data', Buffer.from('2024-01-01T00:00:00Z redis log line\n'));
|
|
proc2.stdout.emit('data', Buffer.from('2024-01-01T00:00:01Z api response\n'));
|
|
|
|
const calls = (ws.send as ReturnType<typeof vi.fn>).mock.calls as string[][];
|
|
const sentLines = calls.flatMap(c => c[0].split('\r\n')).filter(Boolean);
|
|
|
|
expect(sentLines).toContain('redis | 2024-01-01T00:00:00Z redis log line');
|
|
expect(sentLines).toContain('api | 2024-01-01T00:00:01Z api response');
|
|
});
|
|
|
|
it('prefixes flushBuffer trailing line on child close', async () => {
|
|
mockGetContainersByStack.mockResolvedValue([
|
|
{ Names: ['/mystack-web-1'], State: 'running', Id: 'ghi789' },
|
|
]);
|
|
|
|
const ws = createMockWs();
|
|
const svc = ComposeService.getInstance(1);
|
|
|
|
const proc = createMockProcess();
|
|
mockSpawn.mockReturnValue(proc);
|
|
|
|
svc.streamLogs('mystack', ws);
|
|
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalled());
|
|
|
|
// Emit a line without a trailing newline, then close.
|
|
proc.stdout.emit('data', Buffer.from('trailing content'));
|
|
proc.emit('close', 0);
|
|
|
|
const calls = (ws.send as ReturnType<typeof vi.fn>).mock.calls as string[][];
|
|
const sentLines = calls.flatMap(c => c[0].split('\r\n')).filter(Boolean);
|
|
|
|
expect(sentLines).toContain('web | trailing content');
|
|
});
|
|
|
|
it('joins chunk-split lines and prefixes once', async () => {
|
|
mockGetContainersByStack.mockResolvedValue([
|
|
{ Names: ['/mystack-db-1'], State: 'running', Id: 'jkl012' },
|
|
]);
|
|
|
|
const ws = createMockWs();
|
|
const svc = ComposeService.getInstance(1);
|
|
|
|
const proc = createMockProcess();
|
|
mockSpawn.mockReturnValue(proc);
|
|
|
|
svc.streamLogs('mystack', ws);
|
|
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalled());
|
|
|
|
// Split a single line across two data events.
|
|
proc.stdout.emit('data', Buffer.from('2024-01-01T00:00:00Z partial '));
|
|
proc.stdout.emit('data', Buffer.from('end of line\n'));
|
|
|
|
const calls = (ws.send as ReturnType<typeof vi.fn>).mock.calls as string[][];
|
|
const sentLines = calls.flatMap(c => c[0].split('\r\n')).filter(Boolean);
|
|
|
|
expect(sentLines).toContain('db | 2024-01-01T00:00:00Z partial end of line');
|
|
});
|
|
|
|
it('normalizes dotted container names', async () => {
|
|
mockGetContainersByStack.mockResolvedValue([
|
|
{ Names: ['/mystack-api.v1-1'], State: 'running', Id: 'mno345' },
|
|
]);
|
|
|
|
const ws = createMockWs();
|
|
const svc = ComposeService.getInstance(1);
|
|
|
|
const proc = createMockProcess();
|
|
mockSpawn.mockReturnValue(proc);
|
|
|
|
svc.streamLogs('mystack', ws);
|
|
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalled());
|
|
|
|
proc.stdout.emit('data', Buffer.from('2024-01-01T00:00:00Z started\n'));
|
|
|
|
const calls = (ws.send as ReturnType<typeof vi.fn>).mock.calls as string[][];
|
|
const sentLines = calls.flatMap(c => c[0].split('\r\n')).filter(Boolean);
|
|
|
|
// normalizeContainerName strips stack prefix and -1 replica suffix, leaving 'api.v1'.
|
|
expect(sentLines).toContain('api.v1 | 2024-01-01T00:00:00Z started');
|
|
});
|
|
|
|
it('passes raw container name to docker logs, not normalized name', async () => {
|
|
mockGetContainersByStack.mockResolvedValue([
|
|
{ Names: ['/mystack-redis-1'], State: 'running', Id: 'pqr678' },
|
|
]);
|
|
|
|
const ws = createMockWs();
|
|
const svc = ComposeService.getInstance(1);
|
|
|
|
const proc = createMockProcess();
|
|
mockSpawn.mockReturnValue(proc);
|
|
|
|
svc.streamLogs('mystack', ws);
|
|
await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalled());
|
|
|
|
// Verify that docker logs uses the raw name, not the normalized one.
|
|
expect(mockSpawn).toHaveBeenCalledWith(
|
|
'docker',
|
|
['logs', '-f', '-t', '--tail', '100', 'mystack-redis-1'],
|
|
expect.anything(),
|
|
);
|
|
});
|
|
});
|