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 };
}
}
+9
View File
@@ -160,6 +160,15 @@ When the periodic image check (every 6 hours) detects that a stack has new upstr
Both notification types use the same channel routing as alerts: if notification routes are configured for a stack, those channels receive the message; otherwise, global notification channels are used as a fallback.
## Scheduled scan completion
Recurring [vulnerability scans](/features/vulnerability-scanning) dispatch a notification whenever a run finishes so you do not have to check the task history manually.
- **Info** when every image scanned successfully.
- **Warning** when one or more images failed to scan during the run.
The message includes the scheduled task name and a summary of how many images were scanned, cached, and failed. Failures are typically transient (registry timeouts, missing credentials) and do not stop the rest of the run from completing.
## Container crash detection
Sencho watches every container on each of your nodes in real time and notifies you when something exits unexpectedly. Detection is causal: Sencho distinguishes crashes from intentional stops, so stopping a stack, restarting it, or running `docker compose down` will not produce a false crash alert.
+9
View File
@@ -103,6 +103,15 @@ You can run recurring scans of every image on a node through the standard [Sched
Use scheduled scans to keep CVE badges fresh even for images that are rarely redeployed: nightly (`0 3 * * *`) is a good default for most fleets.
### Completion notifications
When a scheduled scan finishes, Sencho dispatches an alert through your configured [notification channels](/features/alerts-notifications). The alert includes the task name and a summary of scanned, cached, and failed images:
- **Info** when every image scanned successfully.
- **Warning** when one or more images in the run failed to scan.
Failures are typically transient (registry timeouts, missing credentials) and do not stop the rest of the run from completing. Check the task's run history for the detailed output.
## Scan policies
<Note>