fix(notifications): prevent self-container stack routing (#1242)

* fix(notifications): prevent self-container stack routing

* fix(stack-files): stabilize download metrics in CI
This commit is contained in:
Anso
2026-05-28 12:45:10 -04:00
committed by GitHub
parent e57dbb8da7
commit 265fece988
9 changed files with 704 additions and 53 deletions
@@ -25,6 +25,7 @@ const {
mockInspect,
mockGetContainer,
mockGetDocker,
mockIsOwnContainer,
} = vi.hoisted(() => ({
mockDispatchAlert: vi.fn().mockResolvedValue(undefined),
mockBroadcastEvent: vi.fn(),
@@ -34,6 +35,7 @@ const {
mockInspect: vi.fn().mockResolvedValue({}),
mockGetContainer: vi.fn(),
mockGetDocker: vi.fn(),
mockIsOwnContainer: vi.fn().mockReturnValue(false),
}));
vi.mock('../services/NotificationService', () => ({
@@ -57,6 +59,14 @@ vi.mock('../services/NodeRegistry', () => ({
},
}));
vi.mock('../services/SelfIdentityService', () => ({
default: {
getInstance: () => ({
isOwnContainer: mockIsOwnContainer,
}),
},
}));
// ── Fake Docker stream helper ──────────────────────────────────────────
interface FakeStream extends EventEmitter {
@@ -88,6 +98,8 @@ beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
mockGetGlobalSettings.mockReturnValue({ global_crash: '1' });
mockIsOwnContainer.mockReset();
mockIsOwnContainer.mockReturnValue(false);
stream = makeStream();
mockGetEvents.mockImplementation(async () => stream);
@@ -130,6 +142,61 @@ describe('DockerEventService - die classification', () => {
);
});
it('keeps stack and container routing for non-self compose crashes', async () => {
service = new DockerEventService(1, 'local');
await service.start();
stream.push({
Type: 'container',
Action: 'die',
Actor: {
ID: 'app-id',
Attributes: {
exitCode: '1',
name: 'web',
'com.docker.compose.project': 'web-stack',
},
},
});
await vi.advanceTimersByTimeAsync(600);
expect(mockDispatchAlert).toHaveBeenCalledWith(
'error',
'monitor_alert',
expect.stringContaining('Container Crash Detected'),
expect.objectContaining({ stackName: 'web-stack', containerName: 'web' }),
);
});
it('routes self-container crashes as system-only notifications', async () => {
mockIsOwnContainer.mockImplementation((idOrName: string) =>
idOrName === 'self-id' || idOrName === 'sencho',
);
service = new DockerEventService(1, 'local');
await service.start();
stream.push({
Type: 'container',
Action: 'die',
Actor: {
ID: 'self-id',
Attributes: {
exitCode: '1',
name: 'sencho',
'com.docker.compose.project': 'sencho',
},
},
});
await vi.advanceTimersByTimeAsync(600);
expect(mockDispatchAlert).toHaveBeenCalledWith(
'error',
'monitor_alert',
expect.stringContaining('Container Crash Detected'),
{ actor: 'system:docker-events' },
);
});
it('does not emit when die follows a recent kill (intentional)', async () => {
service = new DockerEventService(1, 'local');
await service.start();
@@ -231,6 +298,33 @@ describe('DockerEventService - die classification', () => {
);
});
it('routes self-container unhealthy alerts as system-only notifications', async () => {
mockIsOwnContainer.mockImplementation((idOrName: string) =>
idOrName === 'self-id' || idOrName === 'sencho',
);
service = new DockerEventService(1, 'local');
await service.start();
stream.push({
Type: 'container',
Action: 'health_status: unhealthy',
Actor: {
ID: 'self-id',
Attributes: {
name: 'sencho',
'com.docker.compose.project': 'sencho',
},
},
});
expect(mockDispatchAlert).toHaveBeenCalledWith(
'error',
'monitor_alert',
expect.stringContaining('Healthcheck failed'),
{ actor: 'system:docker-events' },
);
});
it('does not emit when global_crash is disabled', async () => {
mockGetGlobalSettings.mockReturnValue({ global_crash: '0' });
service = new DockerEventService(1, 'local');
@@ -616,6 +710,30 @@ describe('DockerEventService - state-invalidate broadcasts', () => {
}));
});
it('does not broadcast stack state-invalidate for Sencho self-container events', async () => {
mockIsOwnContainer.mockImplementation((idOrName: string) =>
idOrName === 'self-id' || idOrName === 'sencho',
);
service = new DockerEventService(7, 'node-7');
await service.start();
stream.push({
Type: 'container',
Action: 'start',
Actor: {
ID: 'self-id',
Attributes: {
name: 'sencho',
'com.docker.compose.project': 'sencho',
},
},
time: 1,
});
await vi.advanceTimersByTimeAsync(1);
expect(mockBroadcastEvent).not.toHaveBeenCalled();
});
it('broadcasts state-invalidate on health_status:unhealthy', async () => {
service = new DockerEventService(1, 'local');
await service.start();