mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-28 04:38:59 +00:00
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:
@@ -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', () => {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user