From e660d2a658a9a0775cd5e448dd2c2933491a33d3 Mon Sep 17 00:00:00 2001 From: Anso Date: Thu, 16 Apr 2026 22:55:30 -0400 Subject: [PATCH] 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. --- .../src/__tests__/scheduler-service.test.ts | 120 ++++++++++++++++++ backend/src/services/SchedulerService.ts | 20 ++- docs/features/alerts-notifications.mdx | 9 ++ docs/features/vulnerability-scanning.mdx | 9 ++ 4 files changed, 153 insertions(+), 5 deletions(-) diff --git a/backend/src/__tests__/scheduler-service.test.ts b/backend/src/__tests__/scheduler-service.test.ts index 8eef1065..8ceed6c0 100644 --- a/backend/src/__tests__/scheduler-service.test.ts +++ b/backend/src/__tests__/scheduler-service.test.ts @@ -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 = {}) { + 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', () => { diff --git a/backend/src/services/SchedulerService.ts b/backend/src/services/SchedulerService.ts index 84e79aee..29a038ee 100644 --- a/backend/src/services/SchedulerService.ts +++ b/backend/src/services/SchedulerService.ts @@ -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 { + 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 }; } } diff --git a/docs/features/alerts-notifications.mdx b/docs/features/alerts-notifications.mdx index aaae03ca..9c35a4a4 100644 --- a/docs/features/alerts-notifications.mdx +++ b/docs/features/alerts-notifications.mdx @@ -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. diff --git a/docs/features/vulnerability-scanning.mdx b/docs/features/vulnerability-scanning.mdx index db22a317..3c595b6f 100644 --- a/docs/features/vulnerability-scanning.mdx +++ b/docs/features/vulnerability-scanning.mdx @@ -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