feat(scheduler): notify on scheduled scan completion (#646)

Scheduled scan tasks now dispatch a completion alert through the
existing notification system: info when every image scanned cleanly,
warning when one or more images failed. The alert includes the task
name and the run's scanned/cached/failed summary so operators do not
need to open the task history.
This commit is contained in:
Anso
2026-04-16 22:55:30 -04:00
committed by GitHub
parent 3c7539d3b4
commit e660d2a658
4 changed files with 153 additions and 5 deletions
@@ -18,6 +18,8 @@ const {
mockCheckImage,
mockDispatchAlert,
mockGetProxyTarget,
mockIsTrivyAvailable,
mockScanAllNodeImages,
} = vi.hoisted(() => ({
mockGetDueScheduledTasks: vi.fn().mockReturnValue([]),
mockCreateScheduledTaskRun: vi.fn().mockReturnValue(1),
@@ -44,6 +46,8 @@ const {
mockCheckImage: vi.fn().mockResolvedValue({ hasUpdate: false }),
mockDispatchAlert: vi.fn().mockResolvedValue(undefined),
mockGetProxyTarget: vi.fn().mockReturnValue(null),
mockIsTrivyAvailable: vi.fn().mockReturnValue(true),
mockScanAllNodeImages: vi.fn().mockResolvedValue({ scanned: 0, skipped: 0, failed: 0 }),
}));
vi.mock('../services/DatabaseService', () => ({
@@ -129,6 +133,17 @@ vi.mock('../services/NodeRegistry', () => ({
},
}));
vi.mock('../services/TrivyService', () => ({
default: {
getInstance: () => ({
isTrivyAvailable: mockIsTrivyAvailable,
scanAllNodeImages: mockScanAllNodeImages,
getSource: () => 'managed',
detectTrivy: vi.fn().mockResolvedValue(undefined),
}),
},
}));
import { SchedulerService } from '../services/SchedulerService';
beforeEach(() => {
@@ -744,6 +759,111 @@ describe('SchedulerService - error handling', () => {
});
});
// ── Scheduled scan completion notifications ───────────────────────────
describe('SchedulerService - scheduled scan notifications', () => {
function makeScanTask(overrides: Partial<any> = {}) {
return {
id: 200,
name: 'nightly-scan',
action: 'scan',
cron_expression: '0 2 * * *',
enabled: true,
target_id: null,
node_id: 1,
created_by: 'admin',
last_status: null,
...overrides,
};
}
it('dispatches info-level notification when scan completes cleanly', async () => {
mockGetScheduledTask.mockReturnValue(makeScanTask());
mockScanAllNodeImages.mockResolvedValue({ scanned: 3, skipped: 1, failed: 0 });
const svc = SchedulerService.getInstance();
await svc.triggerTask(200);
expect(mockDispatchAlert).toHaveBeenCalledWith(
'info',
expect.stringContaining('nightly-scan'),
undefined,
);
expect(mockDispatchAlert).toHaveBeenCalledWith(
'info',
expect.stringContaining('Scanned 3 image(s)'),
undefined,
);
});
it('dispatches warning-level notification when scan has failures', async () => {
mockGetScheduledTask.mockReturnValue(makeScanTask({ id: 201, name: 'flaky-scan' }));
mockScanAllNodeImages.mockResolvedValue({ scanned: 5, skipped: 0, failed: 2 });
const svc = SchedulerService.getInstance();
await svc.triggerTask(201);
expect(mockDispatchAlert).toHaveBeenCalledWith(
'warning',
expect.stringContaining('2 failed'),
undefined,
);
});
it('passes target_id to dispatchAlert when set', async () => {
mockGetScheduledTask.mockReturnValue(makeScanTask({ id: 202, target_id: 'web-stack' }));
mockScanAllNodeImages.mockResolvedValue({ scanned: 1, skipped: 0, failed: 0 });
const svc = SchedulerService.getInstance();
await svc.triggerTask(202);
expect(mockDispatchAlert).toHaveBeenCalledWith(
'info',
expect.stringContaining('completed'),
'web-stack',
);
});
it('fires only the scan notification when previous run was failure (no duplicate recovery alert)', async () => {
mockGetScheduledTask.mockReturnValue(makeScanTask({
id: 204,
name: 'recovered-scan',
last_status: 'failure', // Previous run failed
}));
mockScanAllNodeImages.mockResolvedValue({ scanned: 2, skipped: 0, failed: 0 });
const svc = SchedulerService.getInstance();
await svc.triggerTask(204);
expect(mockDispatchAlert).toHaveBeenCalledTimes(1);
expect(mockDispatchAlert).toHaveBeenCalledWith(
'info',
expect.stringContaining('recovered-scan'),
undefined,
);
});
it('does not dispatch a scan notification for non-scan actions', async () => {
mockGetScheduledTask.mockReturnValue({
id: 203,
name: 'restart-task',
action: 'restart',
cron_expression: '*/5 * * * *',
enabled: true,
target_id: 'my-stack',
node_id: 1,
created_by: 'admin',
last_status: null, // No previous failure → no recovery notification
});
mockGetContainersByStack.mockResolvedValue([{ Id: 'c1', Service: 'web' }]);
const svc = SchedulerService.getInstance();
await svc.triggerTask(203);
expect(mockDispatchAlert).not.toHaveBeenCalled();
});
});
// ── Cleanup ────────────────────────────────────────────────────────────
describe('SchedulerService - cleanup', () => {
+15 -5
View File
@@ -241,6 +241,7 @@ export class SchedulerService {
if (isDebugEnabled()) console.log(`[SchedulerService:debug] Task ${task.id} pre-checks passed, executing ${task.action}`);
const actionStart = Date.now();
let output = '';
let scanFailedCount = 0;
switch (task.action) {
case 'restart':
output = await this.executeRestart(task);
@@ -254,9 +255,12 @@ export class SchedulerService {
case 'update':
output = await this.executeUpdate(task);
break;
case 'scan':
output = await this.executeScan(task);
case 'scan': {
const result = await this.executeScan(task);
output = result.output;
scanFailedCount = result.failed;
break;
}
}
if (isDebugEnabled()) console.log(`[SchedulerService:debug] Task ${task.id} action completed in ${Date.now() - actionStart}ms`);
@@ -275,7 +279,13 @@ export class SchedulerService {
output,
});
console.log(`[SchedulerService] Task "${task.name}" (id=${task.id}) completed successfully`);
if (task.last_status === 'failure') {
if (task.action === 'scan') {
NotificationService.getInstance().dispatchAlert(
scanFailedCount > 0 ? 'warning' : 'info',
`Scheduled scan "${task.name}" completed: ${output}`,
task.target_id ?? undefined
);
} else if (task.last_status === 'failure') {
NotificationService.getInstance().dispatchAlert(
'info',
`Scheduled task "${task.name}" (${task.action}) recovered successfully`,
@@ -597,7 +607,7 @@ export class SchedulerService {
return `Stack "${stackName}": updated (${updatedImages.join(', ')}).`;
}
private async executeScan(task: ScheduledTask): Promise<string> {
private async executeScan(task: ScheduledTask): Promise<{ output: string; failed: number }> {
const trivy = TrivyService.getInstance();
if (!trivy.isTrivyAvailable()) {
throw new Error('Trivy binary is not available on this node');
@@ -613,6 +623,6 @@ export class SchedulerService {
const parts: string[] = [`Scanned ${summary.scanned} image(s)`];
if (summary.skipped > 0) parts.push(`${summary.skipped} skipped (cached)`);
if (summary.failed > 0) parts.push(`${summary.failed} failed`);
return parts.join('; ');
return { output: parts.join('; '), failed: summary.failed };
}
}