mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 02:12: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 };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user