mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 19:26:56 +00:00
85842cc547
* feat: add service-scoped stack alert rules
Stack alerts can target one Compose service or all services. Breach timers
are per container and cooldowns are per service so a healthy sibling no
longer clears another container's timer or silences a different service.
* fix: gate remote scoped alert creates without losing the body
Remote hops skip JSON parsing so the proxy stream stays pipeable, which
left service_name invisible to the capability gate. Buffer POST /alerts
bodies for inspection, fail closed when the remote lacks the capability,
and rewrite the buffered bytes on forward. Restore alert-panel alt text
to match the unchanged screenshot.
* fix: bound remote alert body buffer and reject encoded JSON
Cap proxied POST /alerts buffering at the local 100KB JSON limit with
structured 413 cleanup, reject non-identity Content-Encoding with 415 so
compressed scoped bodies cannot bypass the mixed-version gate, and cover
oversized, chunked, and gzip regressions.
* fix: harden service-scoped alert delete, cooldown, and proxy gates
Reject non-digit alert ids, dual-write last_fired_at for rollback safety,
gate cooldown on persisted notification history, fail-fast oversized proxy
bodies with 413, and clarify Not in compose UI semantics.
* test: expect dispatchAlert persisted result in crash-safety cases
Update notification-routing assertions for the new { persisted } return
shape so CI matches the cooldown-gating contract.
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({ persisted: true });
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|