mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 20:27:22 +00:00
3f1f15a6f4
* fix: keep running containers until stack pull/build succeeds Acquire images before reconcile, capture a recovery generation for compensation, and only remove classified orphans after handoff. * fix: address recovery audit blockers for safe stack updates Retire abandoned and expired recovery artifacts, probe compensated runtimes before reporting rollback success, preserve local Docker when deleting a node, validate the exact Compose invocation before capture, and repair updateStack return-contract fixtures. * fix: resolve ESLint errors blocking CI on this branch Unused-import and unused-variable errors left over from the stack deletion refactor: MeshService in stacks.ts (its opt-out cascade moved into DeployedStackDeletionService), a redundant pruneVolumes destructure in deleteDeployedStack (the real one is re-derived from the same input object inside runDeletionBody), and an unused beforeAll import in a Docker-integration test stub. Also scopes the webhook pull-action case body in a block to satisfy no-case-declarations; purely syntactic, no behavior change. * fix: harden recovery probe, cleanup retry, and failed-pull Docker test Reject absent or unhealthy expected replicas before reporting rollback success, keep cleanup records until artifacts are actually removed, fail closed when a mesh override cannot be generated, and assert a real failed pull leaves the original container running. * fix: verify recovery probe image identity and stack-scoped override paths Reject recovered runtimes that use the wrong image or leave scale-zero services running, and confine tombstone override deletion to the intent stack directory so forged cross-stack paths cannot be swept. * test: batch notification cap fixtures in a SQLite transaction Unbatched 1200-row inserts were timing out at the default 30s under CI load even though the same assertions pass in under 2s when green.
1695 lines
63 KiB
TypeScript
1695 lines
63 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,
|
|
mockResolveMissingExternalNetworks,
|
|
mockCreateNetwork,
|
|
mockAddNotificationHistory,
|
|
mockIsMeshStackEnabled,
|
|
mockClassifyLegacyOrphansForUpdate,
|
|
mockCaptureCandidate,
|
|
mockMarkAcquired,
|
|
mockHandoff,
|
|
mockMarkReconciling,
|
|
mockMarkImmediateVerified,
|
|
mockAbandon,
|
|
mockCompensateWithCandidate,
|
|
mockBuildUnifiedHeldImagePredicate,
|
|
mockGetRecovery,
|
|
} = 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([]),
|
|
mockResolveMissingExternalNetworks: vi.fn().mockResolvedValue({
|
|
status: 'ok',
|
|
autoCreateEnabled: false,
|
|
stackName: 'my-stack',
|
|
networks: [],
|
|
declaredExternalCount: 0,
|
|
}),
|
|
mockCreateNetwork: vi.fn().mockResolvedValue({ id: 'net-1' }),
|
|
mockAddNotificationHistory: vi.fn(),
|
|
mockIsMeshStackEnabled: vi.fn().mockReturnValue(false),
|
|
mockClassifyLegacyOrphansForUpdate: vi.fn().mockResolvedValue({ status: 'none' }),
|
|
mockCaptureCandidate: vi.fn().mockResolvedValue({
|
|
id: 'recovery-1',
|
|
node_id: 1,
|
|
stack_name: 'my-stack',
|
|
status: 'candidate',
|
|
phase: 'captured',
|
|
override_path: '/test/compose/my-stack/.sencho-recovery-abc.yml',
|
|
}),
|
|
mockMarkAcquired: vi.fn().mockReturnValue(true),
|
|
mockHandoff: vi.fn().mockReturnValue(true),
|
|
mockMarkReconciling: vi.fn().mockReturnValue(true),
|
|
mockMarkImmediateVerified: vi.fn().mockReturnValue(true),
|
|
mockAbandon: vi.fn().mockResolvedValue(true),
|
|
mockCompensateWithCandidate: vi.fn().mockResolvedValue(true),
|
|
mockBuildUnifiedHeldImagePredicate: vi.fn().mockReturnValue(() => false),
|
|
mockGetRecovery: vi.fn().mockReturnValue(undefined),
|
|
}));
|
|
|
|
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,
|
|
classifyLegacyOrphansForUpdate: mockClassifyLegacyOrphansForUpdate,
|
|
removeContainers: mockRemoveContainers,
|
|
pruneDanglingImages: mockPruneDanglingImages,
|
|
createNetwork: mockCreateNetwork,
|
|
getDocker: () => ({
|
|
listContainers: mockListContainers,
|
|
getContainer: () => ({
|
|
inspect: mockContainerInspect,
|
|
logs: mockContainerLogs,
|
|
}),
|
|
}),
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../services/DatabaseService', () => ({
|
|
DatabaseService: {
|
|
getInstance: () => ({
|
|
getRegistries: mockGetRegistries,
|
|
getGlobalSettings: mockGetGlobalSettings,
|
|
getGitSource: () => undefined,
|
|
getStackProjectEnvFiles: () => [],
|
|
addNotificationHistory: mockAddNotificationHistory,
|
|
isMeshStackEnabled: (...args: unknown[]) => mockIsMeshStackEnabled(...args),
|
|
}),
|
|
},
|
|
}));
|
|
|
|
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/StackUpdateRecoveryService', () => ({
|
|
StackUpdateRecoveryService: {
|
|
getInstance: () => ({
|
|
captureCandidate: mockCaptureCandidate,
|
|
markAcquired: mockMarkAcquired,
|
|
handoff: mockHandoff,
|
|
markReconciling: mockMarkReconciling,
|
|
markImmediateVerified: mockMarkImmediateVerified,
|
|
abandon: mockAbandon,
|
|
compensateWithCandidate: mockCompensateWithCandidate,
|
|
buildUnifiedHeldImagePredicate: mockBuildUnifiedHeldImagePredicate,
|
|
get: mockGetRecovery,
|
|
linkGateOrRetain: vi.fn(),
|
|
}),
|
|
},
|
|
}));
|
|
|
|
|
|
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),
|
|
}));
|
|
|
|
vi.mock('../services/network/resolveMissingExternalNetworks', () => ({
|
|
resolveMissingExternalNetworks: (...args: unknown[]) => mockResolveMissingExternalNetworks(...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([]);
|
|
mockResolveMissingExternalNetworks.mockResolvedValue({
|
|
status: 'ok',
|
|
autoCreateEnabled: false,
|
|
stackName: 'my-stack',
|
|
networks: [],
|
|
declaredExternalCount: 0,
|
|
});
|
|
mockCreateNetwork.mockResolvedValue({ id: 'net-1' });
|
|
mockClassifyLegacyOrphansForUpdate.mockResolvedValue({ status: 'none' });
|
|
mockCaptureCandidate.mockResolvedValue({
|
|
id: 'recovery-1',
|
|
node_id: 1,
|
|
stack_name: 'my-stack',
|
|
status: 'candidate',
|
|
phase: 'captured',
|
|
override_path: '/test/compose/my-stack/.sencho-recovery-abc.yml',
|
|
});
|
|
mockMarkAcquired.mockReturnValue(true);
|
|
mockHandoff.mockReturnValue(true);
|
|
mockMarkReconciling.mockReturnValue(true);
|
|
mockMarkImmediateVerified.mockReturnValue(true);
|
|
mockAbandon.mockResolvedValue(true);
|
|
mockIsMeshStackEnabled.mockReturnValue(false);
|
|
mockCompensateWithCandidate.mockResolvedValue(true);
|
|
mockBuildUnifiedHeldImagePredicate.mockReturnValue(() => false);
|
|
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 ────────────────────────────────────────────────────────
|
|
|
|
|
|
it('fails closed when a mesh-enabled stack cannot generate its override', async () => {
|
|
mockIsMeshStackEnabled.mockReturnValue(true);
|
|
mockEnsureStackOverride.mockRejectedValue(new Error('mesh override write failed'));
|
|
|
|
const svc = ComposeService.getInstance(1);
|
|
await expect(svc.validateExactComposeInvocation('my-stack')).rejects.toThrow(/mesh override write failed/i);
|
|
expect(mockSpawn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
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('blocks a pilot service restore with relative binds before Compose up', 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.recreateServiceFromLocal('my-stack', 'db').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(mockSpawn).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
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('blocks deploy before backup when missing external networks need a prompt', async () => {
|
|
const { MissingExternalNetworksError } = await import('../services/network/missingExternalNetworksError');
|
|
mockResolveMissingExternalNetworks.mockResolvedValue({
|
|
status: 'ok',
|
|
autoCreateEnabled: false,
|
|
stackName: 'my-stack',
|
|
networks: [{
|
|
name: 'arr-net',
|
|
keys: ['arr'],
|
|
declarations: [{ key: 'arr', driverKind: 'bridge', unsupportedFeatures: [] }],
|
|
safe: true,
|
|
unsupportedFeatures: [],
|
|
creationSpec: { driver: 'bridge', options: 'default' },
|
|
}],
|
|
declaredExternalCount: 1,
|
|
});
|
|
|
|
const svc = ComposeService.getInstance(1);
|
|
await expect(svc.deployStack('my-stack', undefined, true)).rejects.toBeInstanceOf(MissingExternalNetworksError);
|
|
expect(mockBackupStackFiles).not.toHaveBeenCalled();
|
|
expect(mockSpawn).not.toHaveBeenCalled();
|
|
expect(mockAddNotificationHistory).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('auto-creates safe missing networks and records history-only activity', async () => {
|
|
const missing = [{
|
|
name: 'arr-net',
|
|
keys: ['arr'],
|
|
declarations: [{ key: 'arr', driverKind: 'bridge' as const, unsupportedFeatures: [] as const }],
|
|
safe: true,
|
|
unsupportedFeatures: [] as const,
|
|
creationSpec: { driver: 'bridge' as const, options: 'default' as const },
|
|
}];
|
|
mockResolveMissingExternalNetworks
|
|
.mockResolvedValueOnce({
|
|
status: 'ok',
|
|
autoCreateEnabled: true,
|
|
stackName: 'my-stack',
|
|
networks: missing,
|
|
declaredExternalCount: 1,
|
|
})
|
|
.mockResolvedValueOnce({
|
|
status: 'ok',
|
|
autoCreateEnabled: true,
|
|
stackName: 'my-stack',
|
|
networks: [],
|
|
declaredExternalCount: 1,
|
|
});
|
|
setupAutoCloseSpawn();
|
|
mockListContainers.mockResolvedValue([]);
|
|
|
|
const svc = ComposeService.getInstance(1);
|
|
const promise = svc.deployStack(
|
|
'my-stack',
|
|
undefined,
|
|
true,
|
|
{ source: 'scheduler', actor: 'system:scheduler' },
|
|
);
|
|
await vi.advanceTimersByTimeAsync(3100);
|
|
await promise;
|
|
|
|
expect(mockCreateNetwork).toHaveBeenCalledWith({ Name: 'arr-net', Driver: 'bridge' });
|
|
expect(mockAddNotificationHistory).toHaveBeenCalledWith(
|
|
1,
|
|
expect.objectContaining({
|
|
category: 'network_auto_created',
|
|
level: 'info',
|
|
actor_username: 'system:scheduler',
|
|
stack_name: 'my-stack',
|
|
}),
|
|
);
|
|
expect(mockBackupStackFiles).toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not wrap a missing-external prompt in ComposeRollbackError when atomic', async () => {
|
|
mockResolveMissingExternalNetworks.mockResolvedValue({
|
|
status: 'ok',
|
|
autoCreateEnabled: false,
|
|
stackName: 'my-stack',
|
|
networks: [{
|
|
name: 'arr-net',
|
|
keys: ['arr'],
|
|
declarations: [{ key: 'arr', driverKind: 'bridge', unsupportedFeatures: [] }],
|
|
safe: true,
|
|
unsupportedFeatures: [],
|
|
creationSpec: { driver: 'bridge', options: 'default' },
|
|
}],
|
|
declaredExternalCount: 1,
|
|
});
|
|
|
|
const svc = ComposeService.getInstance(1);
|
|
const error = await svc.deployStack('my-stack', undefined, true).then(() => null, (e: Error) => e);
|
|
expect(getComposeRollbackInfo(error)).toBeNull();
|
|
expect(error?.name).toBe('MissingExternalNetworksError');
|
|
});
|
|
|
|
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('abandons recovery and leaves runtime untouched when acquire fails before handoff', 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(mockAbandon).toHaveBeenCalledWith('recovery-1');
|
|
expect(mockHandoff).not.toHaveBeenCalled();
|
|
expect(mockRemoveContainers).not.toHaveBeenCalled();
|
|
expect(mockCompensateWithCandidate).not.toHaveBeenCalled();
|
|
const spawnArgs = mockSpawn.mock.calls.map(c => c[1] as string[]);
|
|
expect(spawnArgs.some(args => args.includes('up'))).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ── 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.toEqual({ recoveryId: 'recovery-1' });
|
|
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.toEqual({ recoveryId: 'recovery-1' });
|
|
});
|
|
});
|
|
|
|
// ── 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 - runDown', () => {
|
|
it('runs plain docker compose down by default', async () => {
|
|
setupAutoCloseSpawn();
|
|
|
|
const svc = ComposeService.getInstance(1);
|
|
await svc.runDown('my-stack');
|
|
|
|
expect(mockSpawn).toHaveBeenCalledWith(
|
|
'docker',
|
|
['compose', 'down'],
|
|
expect.any(Object)
|
|
);
|
|
});
|
|
|
|
it('runs docker compose down --volumes when removeVolumes is true', async () => {
|
|
setupAutoCloseSpawn();
|
|
|
|
const svc = ComposeService.getInstance(1);
|
|
await svc.runDown('my-stack', { removeVolumes: true });
|
|
|
|
expect(mockSpawn).toHaveBeenCalledWith(
|
|
'docker',
|
|
['compose', 'down', '--volumes'],
|
|
expect.any(Object)
|
|
);
|
|
});
|
|
|
|
it('does not pass --volumes when removeVolumes is false', async () => {
|
|
setupAutoCloseSpawn();
|
|
|
|
const svc = ComposeService.getInstance(1);
|
|
await svc.runDown('my-stack', { removeVolumes: false });
|
|
|
|
expect(mockSpawn).toHaveBeenCalledWith(
|
|
'docker',
|
|
['compose', 'down'],
|
|
expect.any(Object)
|
|
);
|
|
});
|
|
});
|
|
|
|
// ── 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('surfaces STACK_STALLED_OUTPUT on acquire stall without runtime compensation', async () => {
|
|
process.env.SENCHO_COMPOSE_STALL_TIMEOUT_MS = '1000';
|
|
const pullProc = createMockProcess();
|
|
mockSpawn.mockImplementation(() => pullProc);
|
|
|
|
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');
|
|
// Acquire failure is pre-handoff: abandon candidate, do not wrap as ComposeRollbackError.
|
|
expect(getComposeRollbackInfo(error)).toBeNull();
|
|
expect(mockAbandon).toHaveBeenCalledWith('recovery-1');
|
|
expect(mockCompensateWithCandidate).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// ── 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(),
|
|
);
|
|
});
|
|
});
|
|
|
|
|
|
describe('ComposeService - updateStack safe ordering', () => {
|
|
it('never broad-removes Compose-managed containers before up', async () => {
|
|
setupAutoCloseSpawn();
|
|
mockListContainers.mockResolvedValue([]);
|
|
mockGetContainersByStack.mockResolvedValue([{ Id: 'should-not-remove' }]);
|
|
|
|
const svc = ComposeService.getInstance(1);
|
|
const promise = svc.updateStack('my-stack');
|
|
await vi.advanceTimersByTimeAsync(3100);
|
|
await promise;
|
|
|
|
expect(mockGetContainersByStack).not.toHaveBeenCalled();
|
|
expect(mockRemoveContainers).not.toHaveBeenCalled();
|
|
expect(mockCaptureCandidate).toHaveBeenCalled();
|
|
expect(mockClassifyLegacyOrphansForUpdate).toHaveBeenCalledWith('my-stack');
|
|
expect(mockHandoff).toHaveBeenCalled();
|
|
});
|
|
|
|
it('removes only classified orphans after handoff', async () => {
|
|
setupAutoCloseSpawn();
|
|
mockListContainers.mockResolvedValue([]);
|
|
mockClassifyLegacyOrphansForUpdate.mockResolvedValueOnce({
|
|
status: 'orphans',
|
|
ids: ['orphan-1'],
|
|
});
|
|
mockRemoveContainers.mockResolvedValueOnce([{ id: 'orphan-1', success: true }]);
|
|
|
|
const svc = ComposeService.getInstance(1);
|
|
const promise = svc.updateStack('my-stack');
|
|
await vi.advanceTimersByTimeAsync(3100);
|
|
await promise;
|
|
|
|
expect(mockHandoff).toHaveBeenCalled();
|
|
expect(mockRemoveContainers).toHaveBeenCalledWith(['orphan-1']);
|
|
});
|
|
});
|