mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 19:57:37 +00:00
d113004359
* feat: add confirmed Take down stack action with optional volume removal Expose Take down in the stack header and sidebar with a confirmation dialog that runs compose down while keeping the stack definition on disk. Optional volume removal is gated by node capability and stack:deploy permission, with remote gateway preflight before proxying removeVolumes requests. Closes #1582 * fix: reset take-down volume checkbox when dialog closes * test: align getStackMenuVisibility assertions with showTakeDown key getStackMenuVisibility now returns a fifth lifecycle flag, showTakeDown, but three exhaustive toEqual assertions still listed only the prior four keys and failed. Add the expected showTakeDown value to each: true for the partial and exited running-stack cases, false for the self stack. * test: cover Take down visibility for running non-self stacks The getStackMenuVisibility assertions exercised the partial and exited branches and the self-stack guard, but not the raw === 'running' literal that drives showTakeDown for a normal running stack. Add a case so a regression dropping 'running' from that check is caught. * fix: drop Take down from header overflow and wire activity shortcut Remove duplicate Take down from More actions. Keep inline button when running, sidebar menu, and Cmd+ArrowDown. Record stack_taken_down in activity on successful POST /down.
243 lines
7.8 KiB
TypeScript
243 lines
7.8 KiB
TypeScript
/**
|
|
* Integration tests for Docker-daemon disconnect handling on stack lifecycle
|
|
* routes. When the engine socket is unreachable, the lifecycle routes must
|
|
* return a structured 503 envelope with `code: docker_unavailable` so the UI
|
|
* can surface a "Docker is down" message instead of raw ECONNREFUSED text.
|
|
*
|
|
* Disconnect is simulated by injecting ECONNREFUSED at the Dockerode test
|
|
* seam (DockerController.getInstance(...).getContainersByStack throws) and at
|
|
* the ComposeService spawn path (deploy/down/update rejection with the same
|
|
* error shape). Both shapes flow through isDockerUnavailableError in the
|
|
* route layer.
|
|
*/
|
|
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
|
import request from 'supertest';
|
|
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
|
|
import { isDockerUnavailableError } from '../routes/stacks';
|
|
|
|
const {
|
|
mockDeployStack,
|
|
mockRunCommand,
|
|
mockRunDown,
|
|
mockUpdateStack,
|
|
mockGetContainersByStack,
|
|
mockRestartContainer,
|
|
mockStopContainer,
|
|
mockStartContainer,
|
|
} = vi.hoisted(() => ({
|
|
mockDeployStack: vi.fn(),
|
|
mockRunCommand: vi.fn(),
|
|
mockRunDown: vi.fn(),
|
|
mockUpdateStack: vi.fn(),
|
|
mockGetContainersByStack: vi.fn(),
|
|
mockRestartContainer: vi.fn(),
|
|
mockStopContainer: vi.fn(),
|
|
mockStartContainer: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../services/ComposeService', async () => {
|
|
const actual = await vi.importActual<typeof import('../services/ComposeService')>(
|
|
'../services/ComposeService',
|
|
);
|
|
return {
|
|
...actual,
|
|
ComposeService: {
|
|
...actual.ComposeService,
|
|
getInstance: () => ({
|
|
deployStack: mockDeployStack,
|
|
runCommand: mockRunCommand,
|
|
runDown: mockRunDown,
|
|
updateStack: mockUpdateStack,
|
|
}),
|
|
},
|
|
};
|
|
});
|
|
|
|
vi.mock('../services/DockerController', async () => {
|
|
const actual = await vi.importActual<typeof import('../services/DockerController')>(
|
|
'../services/DockerController',
|
|
);
|
|
return {
|
|
...actual,
|
|
default: {
|
|
...actual.default,
|
|
getInstance: () => ({
|
|
getContainersByStack: mockGetContainersByStack,
|
|
restartContainer: mockRestartContainer,
|
|
stopContainer: mockStopContainer,
|
|
startContainer: mockStartContainer,
|
|
}),
|
|
},
|
|
};
|
|
});
|
|
|
|
vi.mock('../services/FileSystemService', () => ({
|
|
FileSystemService: {
|
|
getInstance: () => ({
|
|
getBaseDir: () => '/tmp/compose',
|
|
hasComposeFile: vi.fn().mockResolvedValue(true),
|
|
}),
|
|
},
|
|
}));
|
|
|
|
let tmpDir: string;
|
|
let app: import('express').Express;
|
|
let authCookie: string;
|
|
|
|
beforeAll(async () => {
|
|
tmpDir = await setupTestDb();
|
|
({ app } = await import('../index'));
|
|
authCookie = await loginAsTestAdmin(app);
|
|
|
|
const { NotificationService } = await import('../services/NotificationService');
|
|
vi.spyOn(NotificationService.getInstance(), 'dispatchAlert').mockResolvedValue(undefined);
|
|
});
|
|
|
|
afterAll(() => {
|
|
vi.restoreAllMocks();
|
|
cleanupTestDb(tmpDir);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
mockDeployStack.mockReset();
|
|
mockRunCommand.mockReset();
|
|
mockRunDown.mockReset();
|
|
mockUpdateStack.mockReset();
|
|
mockGetContainersByStack.mockReset();
|
|
mockRestartContainer.mockReset();
|
|
mockStopContainer.mockReset();
|
|
mockStartContainer.mockReset();
|
|
});
|
|
|
|
/** Build an Error that mimics the shape Dockerode throws on socket failure. */
|
|
function dockerSocketDownError(): NodeJS.ErrnoException {
|
|
const err = Object.assign(new Error('connect ECONNREFUSED /var/run/docker.sock'), {
|
|
code: 'ECONNREFUSED',
|
|
errno: -111,
|
|
syscall: 'connect',
|
|
address: '/var/run/docker.sock',
|
|
}) as NodeJS.ErrnoException;
|
|
return err;
|
|
}
|
|
|
|
/** Build an Error that mimics docker compose CLI output when dockerd is down. */
|
|
function composeDaemonDownError(): Error {
|
|
return new Error(
|
|
'Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?',
|
|
);
|
|
}
|
|
|
|
describe('isDockerUnavailableError helper', () => {
|
|
it('matches plain ECONNREFUSED node errors', () => {
|
|
expect(isDockerUnavailableError(dockerSocketDownError())).toBe(true);
|
|
});
|
|
|
|
it('matches docker compose CLI "cannot connect to the Docker daemon" output', () => {
|
|
expect(isDockerUnavailableError(composeDaemonDownError())).toBe(true);
|
|
});
|
|
|
|
it('matches docker.sock ENOENT errors', () => {
|
|
const err = Object.assign(new Error('ENOENT: no such file or directory, connect /var/run/docker.sock'), {
|
|
code: 'ENOENT',
|
|
});
|
|
expect(isDockerUnavailableError(err)).toBe(true);
|
|
});
|
|
|
|
it('does not match unrelated errors', () => {
|
|
expect(isDockerUnavailableError(new Error('image pull failed: manifest unknown'))).toBe(false);
|
|
expect(isDockerUnavailableError(new Error('compose YAML parse error'))).toBe(false);
|
|
expect(isDockerUnavailableError(null)).toBe(false);
|
|
expect(isDockerUnavailableError(undefined)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('POST /api/stacks/:name/restart with daemon down', () => {
|
|
it('returns 503 with code: docker_unavailable when Dockerode listContainers refuses', async () => {
|
|
mockGetContainersByStack.mockRejectedValue(dockerSocketDownError());
|
|
|
|
const res = await request(app)
|
|
.post('/api/stacks/web/restart')
|
|
.set('Cookie', authCookie);
|
|
|
|
expect(res.status).toBe(503);
|
|
expect(res.body).toMatchObject({ code: 'docker_unavailable' });
|
|
expect(res.body.error).toMatch(/ECONNREFUSED|docker daemon|unreachable/i);
|
|
});
|
|
|
|
it('still returns 503 on stop when the daemon is down', async () => {
|
|
mockGetContainersByStack.mockRejectedValue(dockerSocketDownError());
|
|
|
|
const res = await request(app)
|
|
.post('/api/stacks/web/stop')
|
|
.set('Cookie', authCookie);
|
|
|
|
expect(res.status).toBe(503);
|
|
expect(res.body.code).toBe('docker_unavailable');
|
|
});
|
|
|
|
it('still returns 503 on start when the daemon is down', async () => {
|
|
mockGetContainersByStack.mockRejectedValue(dockerSocketDownError());
|
|
|
|
const res = await request(app)
|
|
.post('/api/stacks/web/start')
|
|
.set('Cookie', authCookie);
|
|
|
|
expect(res.status).toBe(503);
|
|
expect(res.body.code).toBe('docker_unavailable');
|
|
});
|
|
});
|
|
|
|
describe('POST /api/stacks/:name/deploy with daemon down', () => {
|
|
it('returns 503 with code: docker_unavailable when ComposeService surfaces the daemon error', async () => {
|
|
mockDeployStack.mockRejectedValue(composeDaemonDownError());
|
|
|
|
const res = await request(app)
|
|
.post('/api/stacks/web/deploy')
|
|
.set('Cookie', authCookie)
|
|
.send({ skip_scan: true });
|
|
|
|
expect(res.status).toBe(503);
|
|
expect(res.body.code).toBe('docker_unavailable');
|
|
expect(res.body.error).toMatch(/Cannot connect to the Docker daemon|unreachable/i);
|
|
});
|
|
|
|
it('still returns 500 (not 503) for unrelated deploy failures', async () => {
|
|
mockDeployStack.mockRejectedValue(new Error('compose YAML parse error'));
|
|
|
|
const res = await request(app)
|
|
.post('/api/stacks/web/deploy')
|
|
.set('Cookie', authCookie)
|
|
.send({ skip_scan: true });
|
|
|
|
expect(res.status).toBe(500);
|
|
expect(res.body.code).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('POST /api/stacks/:name/down with daemon down', () => {
|
|
it('returns 503 with code: docker_unavailable when runDown surfaces the daemon error', async () => {
|
|
mockRunDown.mockRejectedValue(composeDaemonDownError());
|
|
|
|
const res = await request(app)
|
|
.post('/api/stacks/web/down')
|
|
.set('Cookie', authCookie);
|
|
|
|
expect(res.status).toBe(503);
|
|
expect(res.body.code).toBe('docker_unavailable');
|
|
});
|
|
});
|
|
|
|
describe('POST /api/stacks/:name/update with daemon down', () => {
|
|
it('returns 503 with code: docker_unavailable when updateStack surfaces the daemon error', async () => {
|
|
mockUpdateStack.mockRejectedValue(composeDaemonDownError());
|
|
|
|
const res = await request(app)
|
|
.post('/api/stacks/web/update')
|
|
.set('Cookie', authCookie)
|
|
.send({ skip_scan: true });
|
|
|
|
expect(res.status).toBe(503);
|
|
expect(res.body.code).toBe('docker_unavailable');
|
|
});
|
|
});
|