mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 18:05:10 +00:00
feat(security): severity-aware scheduled scan notifications (#654)
Enrich scheduled vulnerability scan completion notifications with per-severity CVE counts so recipients can triage from the message body alone. Expose the scan action in the schedule creation UI, require an explicit node_id, and harden fire-and-forget alert dispatches so a failing webhook cannot crash the scheduler. Notification body now reports scanned/skipped/failed counts plus critical/high/medium totals aggregated across fresh and cached scans, reflecting the current node posture rather than only what was newly scanned on this run.
This commit is contained in:
@@ -47,7 +47,12 @@ const {
|
||||
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 }),
|
||||
mockScanAllNodeImages: vi.fn().mockResolvedValue({
|
||||
scanned: 0,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
severity: { critical: 0, high: 0, medium: 0, low: 0, unknown: 0 },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../services/DatabaseService', () => ({
|
||||
@@ -777,9 +782,33 @@ describe('SchedulerService - scheduled scan notifications', () => {
|
||||
};
|
||||
}
|
||||
|
||||
function scanResult(opts: {
|
||||
scanned?: number;
|
||||
skipped?: number;
|
||||
failed?: number;
|
||||
critical?: number;
|
||||
high?: number;
|
||||
medium?: number;
|
||||
low?: number;
|
||||
unknown?: number;
|
||||
} = {}) {
|
||||
return {
|
||||
scanned: opts.scanned ?? 0,
|
||||
skipped: opts.skipped ?? 0,
|
||||
failed: opts.failed ?? 0,
|
||||
severity: {
|
||||
critical: opts.critical ?? 0,
|
||||
high: opts.high ?? 0,
|
||||
medium: opts.medium ?? 0,
|
||||
low: opts.low ?? 0,
|
||||
unknown: opts.unknown ?? 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it('dispatches info-level notification when scan completes cleanly', async () => {
|
||||
mockGetScheduledTask.mockReturnValue(makeScanTask());
|
||||
mockScanAllNodeImages.mockResolvedValue({ scanned: 3, skipped: 1, failed: 0 });
|
||||
mockScanAllNodeImages.mockResolvedValue(scanResult({ scanned: 3, skipped: 1 }));
|
||||
|
||||
const svc = SchedulerService.getInstance();
|
||||
await svc.triggerTask(200);
|
||||
@@ -798,7 +827,7 @@ describe('SchedulerService - scheduled scan notifications', () => {
|
||||
|
||||
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 });
|
||||
mockScanAllNodeImages.mockResolvedValue(scanResult({ scanned: 5, failed: 2 }));
|
||||
|
||||
const svc = SchedulerService.getInstance();
|
||||
await svc.triggerTask(201);
|
||||
@@ -812,7 +841,7 @@ describe('SchedulerService - scheduled scan notifications', () => {
|
||||
|
||||
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 });
|
||||
mockScanAllNodeImages.mockResolvedValue(scanResult({ scanned: 1 }));
|
||||
|
||||
const svc = SchedulerService.getInstance();
|
||||
await svc.triggerTask(202);
|
||||
@@ -828,9 +857,9 @@ describe('SchedulerService - scheduled scan notifications', () => {
|
||||
mockGetScheduledTask.mockReturnValue(makeScanTask({
|
||||
id: 204,
|
||||
name: 'recovered-scan',
|
||||
last_status: 'failure', // Previous run failed
|
||||
last_status: 'failure',
|
||||
}));
|
||||
mockScanAllNodeImages.mockResolvedValue({ scanned: 2, skipped: 0, failed: 0 });
|
||||
mockScanAllNodeImages.mockResolvedValue(scanResult({ scanned: 2 }));
|
||||
|
||||
const svc = SchedulerService.getInstance();
|
||||
await svc.triggerTask(204);
|
||||
@@ -853,7 +882,7 @@ describe('SchedulerService - scheduled scan notifications', () => {
|
||||
target_id: 'my-stack',
|
||||
node_id: 1,
|
||||
created_by: 'admin',
|
||||
last_status: null, // No previous failure → no recovery notification
|
||||
last_status: null,
|
||||
});
|
||||
mockGetContainersByStack.mockResolvedValue([{ Id: 'c1', Service: 'web' }]);
|
||||
|
||||
@@ -862,6 +891,77 @@ describe('SchedulerService - scheduled scan notifications', () => {
|
||||
|
||||
expect(mockDispatchAlert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('dispatches error-level notification when Trivy is unavailable', async () => {
|
||||
mockGetScheduledTask.mockReturnValue(makeScanTask({ id: 205, target_id: 'payment-stack' }));
|
||||
mockIsTrivyAvailable.mockReturnValueOnce(false);
|
||||
|
||||
const svc = SchedulerService.getInstance();
|
||||
await svc.triggerTask(205);
|
||||
|
||||
expect(mockDispatchAlert).toHaveBeenCalledWith(
|
||||
'error',
|
||||
expect.stringMatching(/failed.*Trivy/i),
|
||||
'payment-stack',
|
||||
);
|
||||
});
|
||||
|
||||
it('includes severity counts in the notification message', async () => {
|
||||
mockGetScheduledTask.mockReturnValue(makeScanTask({ id: 206 }));
|
||||
mockScanAllNodeImages.mockResolvedValue(
|
||||
scanResult({ scanned: 3, skipped: 1, critical: 2, high: 5, medium: 10 }),
|
||||
);
|
||||
|
||||
const svc = SchedulerService.getInstance();
|
||||
await svc.triggerTask(206);
|
||||
|
||||
const message = mockDispatchAlert.mock.calls[0][1] as string;
|
||||
expect(message).toContain('2 critical');
|
||||
expect(message).toContain('5 high');
|
||||
expect(message).toContain('10 medium');
|
||||
});
|
||||
|
||||
it('reports "No images to scan" when the node has nothing to scan', async () => {
|
||||
mockGetScheduledTask.mockReturnValue(makeScanTask({ id: 207 }));
|
||||
mockScanAllNodeImages.mockResolvedValue(scanResult());
|
||||
|
||||
const svc = SchedulerService.getInstance();
|
||||
await svc.triggerTask(207);
|
||||
|
||||
expect(mockDispatchAlert).toHaveBeenCalledWith(
|
||||
'info',
|
||||
expect.stringContaining('No images to scan'),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('reports "All N image(s) already scanned recently" when every image was cached', async () => {
|
||||
mockGetScheduledTask.mockReturnValue(makeScanTask({ id: 208 }));
|
||||
mockScanAllNodeImages.mockResolvedValue(scanResult({ skipped: 12 }));
|
||||
|
||||
const svc = SchedulerService.getInstance();
|
||||
await svc.triggerTask(208);
|
||||
|
||||
expect(mockDispatchAlert).toHaveBeenCalledWith(
|
||||
'info',
|
||||
expect.stringContaining('All 12 image(s) already scanned recently'),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('persists the run as success even when notification dispatch throws', async () => {
|
||||
mockGetScheduledTask.mockReturnValue(makeScanTask({ id: 209 }));
|
||||
mockScanAllNodeImages.mockResolvedValue(scanResult({ scanned: 1 }));
|
||||
mockDispatchAlert.mockRejectedValueOnce(new Error('webhook down'));
|
||||
|
||||
const svc = SchedulerService.getInstance();
|
||||
await expect(svc.triggerTask(209)).resolves.not.toThrow();
|
||||
|
||||
expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith(
|
||||
expect.any(Number),
|
||||
expect.objectContaining({ status: 'success' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Cleanup ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -6249,6 +6249,9 @@ app.post('/api/scheduled-tasks', (req: Request, res: Response): void => {
|
||||
if (action === 'scan' && target_type !== 'system') {
|
||||
res.status(400).json({ error: 'Scan action requires target_type "system".' }); return;
|
||||
}
|
||||
if (action === 'scan' && !node_id) {
|
||||
res.status(400).json({ error: 'Scan action requires node_id.' }); return;
|
||||
}
|
||||
if (target_type === 'stack' && (!target_id || !node_id)) {
|
||||
res.status(400).json({ error: 'Stack operations require target_id and node_id.' }); return;
|
||||
}
|
||||
@@ -6370,6 +6373,12 @@ app.put('/api/scheduled-tasks/:id', (req: Request, res: Response): void => {
|
||||
if (finalAction === 'scan' && finalTargetType !== 'system') {
|
||||
res.status(400).json({ error: 'Scan action requires target_type "system".' }); return;
|
||||
}
|
||||
if (finalAction === 'scan') {
|
||||
const finalNodeId = node_id !== undefined ? node_id : existing.node_id;
|
||||
if (!finalNodeId) {
|
||||
res.status(400).json({ error: 'Scan action requires node_id.' }); return;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate prune targets
|
||||
const validPruneTargets = ['containers', 'images', 'networks', 'volumes'];
|
||||
|
||||
@@ -13,6 +13,7 @@ import { captureLocalNodeFiles, captureRemoteNodeFiles } from '../utils/snapshot
|
||||
import { NodeRegistry } from './NodeRegistry';
|
||||
import { NotificationService } from './NotificationService';
|
||||
import TrivyService from './TrivyService';
|
||||
import type { ScanAllNodeImagesResult } from './TrivyService';
|
||||
import TrivyInstaller from './TrivyInstaller';
|
||||
|
||||
const TRIVY_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
@@ -86,7 +87,7 @@ export class SchedulerService {
|
||||
try {
|
||||
await installer.update();
|
||||
await trivy.detectTrivy();
|
||||
NotificationService.getInstance().dispatchAlert(
|
||||
this.safeDispatch(
|
||||
'info',
|
||||
`Trivy updated from v${previous} to v${check.latest}`,
|
||||
);
|
||||
@@ -97,7 +98,7 @@ export class SchedulerService {
|
||||
} else {
|
||||
const lastNotified = settings.trivy_last_notified_version || '';
|
||||
if (lastNotified === check.latest) return;
|
||||
NotificationService.getInstance().dispatchAlert(
|
||||
this.safeDispatch(
|
||||
'info',
|
||||
`Trivy update available: v${check.latest} (currently v${check.current ?? 'unknown'})`,
|
||||
);
|
||||
@@ -139,6 +140,16 @@ export class SchedulerService {
|
||||
return expr.next().toDate().getTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire a notification without awaiting completion, catching any promise
|
||||
* rejection so the scheduler never crashes on a failed dispatch.
|
||||
*/
|
||||
private safeDispatch(level: 'info' | 'warning' | 'error', message: string, stackName?: string): void {
|
||||
NotificationService.getInstance()
|
||||
.dispatchAlert(level, message, stackName)
|
||||
.catch(err => console.error('[SchedulerService] Notification dispatch failed:', getErrorMessage(err, 'unknown error')));
|
||||
}
|
||||
|
||||
private async tick(): Promise<void> {
|
||||
if (this.isProcessing) {
|
||||
console.warn('[SchedulerService] Tick skipped: previous tick still processing');
|
||||
@@ -280,13 +291,19 @@ export class SchedulerService {
|
||||
});
|
||||
console.log(`[SchedulerService] Task "${task.name}" (id=${task.id}) completed successfully`);
|
||||
if (task.action === 'scan') {
|
||||
NotificationService.getInstance().dispatchAlert(
|
||||
scanFailedCount > 0 ? 'warning' : 'info',
|
||||
const scanLevel: 'info' | 'warning' = scanFailedCount > 0 ? 'warning' : 'info';
|
||||
if (isDebugEnabled()) {
|
||||
console.log(
|
||||
`[SchedulerService:debug] Dispatching scan completion notification (level=${scanLevel}, stackContext=${task.target_id ?? 'none'})`,
|
||||
);
|
||||
}
|
||||
this.safeDispatch(
|
||||
scanLevel,
|
||||
`Scheduled scan "${task.name}" completed: ${output}`,
|
||||
task.target_id ?? undefined
|
||||
);
|
||||
} else if (task.last_status === 'failure') {
|
||||
NotificationService.getInstance().dispatchAlert(
|
||||
this.safeDispatch(
|
||||
'info',
|
||||
`Scheduled task "${task.name}" (${task.action}) recovered successfully`,
|
||||
task.target_id ?? undefined
|
||||
@@ -321,7 +338,7 @@ export class SchedulerService {
|
||||
error: errMsg,
|
||||
});
|
||||
console.error(`[SchedulerService] Task "${task.name}" (id=${task.id}) failed:`, errMsg);
|
||||
NotificationService.getInstance().dispatchAlert(
|
||||
this.safeDispatch(
|
||||
'error',
|
||||
`Scheduled task "${task.name}" (${task.action}) failed: ${errMsg}`,
|
||||
task.target_id ?? undefined
|
||||
@@ -598,7 +615,7 @@ export class SchedulerService {
|
||||
await compose.updateStack(stackName, undefined, true);
|
||||
db.clearStackUpdateStatus(nodeId, stackName);
|
||||
|
||||
NotificationService.getInstance().dispatchAlert(
|
||||
this.safeDispatch(
|
||||
'info',
|
||||
`Auto-update: stack "${stackName}" updated with new images`,
|
||||
stackName
|
||||
@@ -618,11 +635,55 @@ export class SchedulerService {
|
||||
console.log(`[SchedulerService:debug] Scan task ${task.id}: no node_id specified, using default node ${nodeId}`);
|
||||
}
|
||||
|
||||
const scanStart = Date.now();
|
||||
if (isDebugEnabled()) console.log(`[SchedulerService:debug] executeScan start: task=${task.id} node=${nodeId}`);
|
||||
|
||||
const summary = await trivy.scanAllNodeImages(nodeId, 'scheduled');
|
||||
|
||||
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 { output: parts.join('; '), failed: summary.failed };
|
||||
if (isDebugEnabled()) {
|
||||
console.log(
|
||||
`[SchedulerService:debug] executeScan summary: scanned=${summary.scanned} skipped=${summary.skipped} failed=${summary.failed} ` +
|
||||
`critical=${summary.severity.critical} high=${summary.severity.high} medium=${summary.severity.medium} ` +
|
||||
`low=${summary.severity.low} unknown=${summary.severity.unknown} durationMs=${Date.now() - scanStart}`,
|
||||
);
|
||||
}
|
||||
|
||||
const output = formatScanOutput(summary);
|
||||
return { output, failed: summary.failed };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the human-readable completion message from a bulk scan summary.
|
||||
* Exported for unit tests.
|
||||
*/
|
||||
export function formatScanOutput(summary: ScanAllNodeImagesResult): string {
|
||||
const { scanned, skipped, failed, severity } = summary;
|
||||
|
||||
let header: string;
|
||||
if (scanned === 0 && skipped === 0 && failed === 0) {
|
||||
header = 'No images to scan';
|
||||
} else if (scanned === 0 && skipped > 0 && failed === 0) {
|
||||
header = `All ${skipped} image(s) already scanned recently (cache hit)`;
|
||||
} else {
|
||||
const parts: string[] = [`Scanned ${scanned} image(s)`];
|
||||
if (skipped > 0) parts.push(`${skipped} skipped (cached)`);
|
||||
if (failed > 0) parts.push(`${failed} failed`);
|
||||
header = parts.join('; ');
|
||||
}
|
||||
|
||||
const severityTiers: Array<[string, number]> = [
|
||||
['critical', severity.critical],
|
||||
['high', severity.high],
|
||||
['medium', severity.medium],
|
||||
];
|
||||
const nonZero = severityTiers.filter(([, n]) => n > 0);
|
||||
if (nonZero.length === 0) {
|
||||
if (scanned === 0 && skipped === 0 && failed === 0) {
|
||||
return header + '.';
|
||||
}
|
||||
return `${header}. No critical, high, or medium findings.`;
|
||||
}
|
||||
const findings = nonZero.map(([label, n]) => `${n} ${label}`).join(', ');
|
||||
return `${header}. Found ${findings}.`;
|
||||
}
|
||||
|
||||
@@ -77,6 +77,21 @@ interface TrivyRawOutput {
|
||||
Results?: TrivyRawResult[];
|
||||
}
|
||||
|
||||
export interface ScanAllNodeImagesSeverityTotals {
|
||||
critical: number;
|
||||
high: number;
|
||||
medium: number;
|
||||
low: number;
|
||||
unknown: number;
|
||||
}
|
||||
|
||||
export interface ScanAllNodeImagesResult {
|
||||
scanned: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
severity: ScanAllNodeImagesSeverityTotals;
|
||||
}
|
||||
|
||||
export interface TrivyVulnerability {
|
||||
vulnerabilityId: string;
|
||||
pkgName: string;
|
||||
@@ -874,7 +889,7 @@ class TrivyService {
|
||||
async scanAllNodeImages(
|
||||
nodeId: number,
|
||||
triggeredBy: VulnScanTrigger = 'scheduled',
|
||||
): Promise<{ scanned: number; skipped: number; failed: number }> {
|
||||
): Promise<ScanAllNodeImagesResult> {
|
||||
if (this.source === 'none') {
|
||||
throw new Error('Trivy is not available on this host');
|
||||
}
|
||||
@@ -889,26 +904,43 @@ class TrivyService {
|
||||
let scanned = 0;
|
||||
let skipped = 0;
|
||||
let failed = 0;
|
||||
const severity = { critical: 0, high: 0, medium: 0, low: 0, unknown: 0 };
|
||||
const countedDigests = new Set<string>();
|
||||
|
||||
const addSeverity = (row: VulnerabilityScan | null): void => {
|
||||
if (!row) return;
|
||||
severity.critical += row.critical_count;
|
||||
severity.high += row.high_count;
|
||||
severity.medium += row.medium_count;
|
||||
severity.low += row.low_count;
|
||||
severity.unknown += row.unknown_count;
|
||||
};
|
||||
|
||||
for (const ref of imageRefs) {
|
||||
try {
|
||||
const digest = await this.getImageDigest(ref, nodeId);
|
||||
if (digest) {
|
||||
if (countedDigests.has(digest)) continue;
|
||||
const cached =
|
||||
DatabaseService.getInstance().getLatestScanByDigest(digest, 'vuln');
|
||||
if (cached && Date.now() - cached.scanned_at < DIGEST_CACHE_TTL_MS) {
|
||||
skipped++;
|
||||
addSeverity(cached);
|
||||
countedDigests.add(digest);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
await this.runScanAndPersist(ref, nodeId, triggeredBy, null);
|
||||
const fresh = await this.runScanAndPersist(ref, nodeId, triggeredBy, null);
|
||||
addSeverity(fresh);
|
||||
scanned++;
|
||||
if (digest) countedDigests.add(digest);
|
||||
} catch (err) {
|
||||
failed++;
|
||||
console.warn(`[Trivy] Failed to scan ${ref}:`, getErrorMessage(err, 'unknown error'));
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
}
|
||||
return { scanned, skipped, failed };
|
||||
return { scanned, skipped, failed, severity };
|
||||
}
|
||||
|
||||
async generateSBOM(imageRef: string, format: SbomFormat): Promise<string> {
|
||||
|
||||
@@ -166,8 +166,21 @@ Recurring [vulnerability scans](/features/vulnerability-scanning) dispatch a not
|
||||
|
||||
- **Info** when every image scanned successfully.
|
||||
- **Warning** when one or more images failed to scan during the run.
|
||||
- **Error** when the run itself could not start (for example, Trivy is not installed on the target node).
|
||||
|
||||
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.
|
||||
The message includes the scheduled task name, a summary of how many images were scanned, cached, and failed, and a breakdown of findings by severity. A typical clean-run message looks like:
|
||||
|
||||
```
|
||||
Scheduled scan "nightly-scan" completed: Scanned 12 image(s); 3 skipped (cached). Found 2 critical, 5 high, 10 medium.
|
||||
```
|
||||
|
||||
If no critical, high, or medium findings are present, the message ends with `No critical, high, or medium findings.` so the outcome is still explicit. When the target node has nothing to scan, the message reads `No images to scan.`, and when every image was already covered by a recent cached scan it reads `All N image(s) already scanned recently (cache hit).` In both cases the notification still fires so you know the run executed.
|
||||
|
||||
<Note>
|
||||
Severity counts reflect the current security posture of the node, aggregated across both freshly scanned images and cached scan results. They are not a delta of what changed on this run.
|
||||
</Note>
|
||||
|
||||
Failures are typically transient (registry timeouts, missing credentials) and do not stop the rest of the run from completing.
|
||||
|
||||
## Container crash detection
|
||||
|
||||
|
||||
@@ -31,8 +31,8 @@ Scheduled Operations lets you automate recurring maintenance tasks across your i
|
||||
2. Click **New Schedule**.
|
||||
3. Fill in the form:
|
||||
- **Name**: A descriptive label (e.g. "Nightly staging restart").
|
||||
- **Action**: Choose Restart Stack, Fleet Snapshot, or System Prune. The form fields below change based on your selection.
|
||||
- **Node**: (Restart Stack only) Select the node where the target stack runs.
|
||||
- **Action**: Choose Restart Stack, Fleet Snapshot, System Prune, or Vulnerability Scan. The form fields below change based on your selection.
|
||||
- **Node**: (Restart Stack and Vulnerability Scan) Select the node to run against. For Restart Stack it determines where the target stack lives; for Vulnerability Scan it determines which node's images are scanned.
|
||||
- **Stack**: (Restart Stack only) Select the stack to restart. Becomes available after choosing a node.
|
||||
- **Services**: (Restart Stack only) Optionally select specific services within the stack to restart. Leave empty to restart all services.
|
||||
- **Prune Targets**: (System Prune only) Select which resources to prune: containers, images, networks, volumes. All are selected by default.
|
||||
@@ -52,7 +52,7 @@ The task list is displayed as a table with the following columns:
|
||||
| Column | Description |
|
||||
|--------|-------------|
|
||||
| **Name** | The task name |
|
||||
| **Action** | Task type badge: Restart Stack, Fleet Snapshot, or System Prune |
|
||||
| **Action** | Task type badge: Restart Stack, Fleet Snapshot, System Prune, or Vulnerability Scan |
|
||||
| **Target** | Stack name (with selected services, if any), or the target type for non-stack actions |
|
||||
| **Schedule** | Human-readable description with the raw cron expression below |
|
||||
| **Status** | Last run result: **Success** (green), **Failed** (red), or "Never run" |
|
||||
@@ -70,6 +70,12 @@ When creating a Restart Stack schedule, you can target individual services inste
|
||||
<img src="/images/scheduled-operations/per-service-restart.png" alt="Service checkboxes displayed when creating a per-service restart schedule" />
|
||||
</Frame>
|
||||
|
||||
### Scheduled Vulnerability Scans
|
||||
|
||||
A Vulnerability Scan task runs Trivy against every image on the selected node and persists the results. The scan uses the same digest-based 24-hour cache as manual scans, so unchanged images are not rescanned on every run. See [Vulnerability Scanning](/features/vulnerability-scanning) for how results are surfaced in the UI and [Installing Trivy](/operations/trivy-setup) for setup on each node.
|
||||
|
||||
When a scheduled scan finishes, Sencho dispatches a completion notification with a summary of what was scanned and a breakdown of findings by severity. The full message format is documented in [Alerts & Notifications → Scheduled scan completion](/features/alerts-notifications#scheduled-scan-completion).
|
||||
|
||||
### Prune Label Filter
|
||||
|
||||
When creating a System Prune schedule, you can scope the prune to resources matching a specific Docker label. This lets you target resources from a particular stack or project without affecting unrelated containers, images, or volumes.
|
||||
@@ -130,6 +136,8 @@ When a scheduled task fails, Sencho automatically dispatches an **error-level al
|
||||
|
||||
When a previously failing task succeeds again, Sencho sends an **info-level recovery notification** to confirm the issue is resolved. This recovery-only approach avoids notification noise from tasks that succeed on every run.
|
||||
|
||||
Vulnerability Scan tasks always send a completion notification, even on a clean run, because the message carries severity counts you may want to react to. See [Alerts & Notifications → Scheduled scan completion](/features/alerts-notifications#scheduled-scan-completion) for the full message format.
|
||||
|
||||
To configure notification channels, go to **Settings > Notifications**.
|
||||
|
||||
<Frame>
|
||||
@@ -183,3 +191,16 @@ If a task's cron expression becomes invalid after creation (for example, due to
|
||||
### Run shows "Server restarted during execution"
|
||||
|
||||
This means Sencho was restarted (or crashed) while this task was mid-execution. The run was marked as failed automatically on startup. The task itself is still enabled and will run at its next scheduled time. If you want to re-run it immediately, use the **Run Now** button.
|
||||
|
||||
### Scheduled scan completed but no notification arrived
|
||||
|
||||
The scan notification shares the same delivery path as every other Sencho alert. Check, in order:
|
||||
|
||||
1. At least one channel is enabled in **Settings > Notifications** and the **Test** button succeeds for it.
|
||||
2. If the stack the scan is associated with has notification routes defined, make sure at least one matching route is enabled; the routing layer takes priority over the global channels.
|
||||
3. Open the notification bell. The in-app bell receives every notification regardless of channel configuration, and a failed external delivery is logged there as an error entry you can inspect.
|
||||
4. On a remote node, notification channels must be configured on the remote instance itself; channel settings are per-node.
|
||||
|
||||
### Scan task fails with "Trivy binary is not available"
|
||||
|
||||
Trivy must be installed on the node that runs the scan, not just on the primary instance. Follow [Installing Trivy](/operations/trivy-setup) on the target node, then trigger **Run Now** from the schedule to confirm the scan succeeds before waiting for the next cron tick.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
@@ -22,6 +22,7 @@ const ACTION_OPTIONS = [
|
||||
{ value: 'restart', label: 'Restart Stack', targetType: 'stack' as const },
|
||||
{ value: 'snapshot', label: 'Fleet Snapshot', targetType: 'fleet' as const },
|
||||
{ value: 'prune', label: 'System Prune', targetType: 'system' as const },
|
||||
{ value: 'scan', label: 'Vulnerability Scan', targetType: 'system' as const },
|
||||
];
|
||||
|
||||
interface ScheduledOperationsViewProps {
|
||||
@@ -194,6 +195,9 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter }:
|
||||
body.target_id = formTargetId;
|
||||
body.node_id = formNodeId ? parseInt(formNodeId, 10) : null;
|
||||
}
|
||||
if (formAction === 'scan') {
|
||||
body.node_id = formNodeId ? parseInt(formNodeId, 10) : null;
|
||||
}
|
||||
if (formAction === 'prune' && formPruneTargets.length > 0) {
|
||||
body.prune_targets = formPruneTargets;
|
||||
}
|
||||
@@ -480,6 +484,19 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter }:
|
||||
</>
|
||||
)}
|
||||
|
||||
{formAction === 'scan' && (
|
||||
<div className="space-y-2">
|
||||
<Label>Node</Label>
|
||||
<Combobox
|
||||
options={nodes.map(n => ({ value: String(n.id), label: n.name }))}
|
||||
value={formNodeId}
|
||||
onValueChange={setFormNodeId}
|
||||
placeholder="Select node..."
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Every image on the selected node will be scanned.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formAction === 'prune' && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
@@ -531,7 +548,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter }:
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleSave} disabled={saving || !formName || !formCron || (targetType === 'stack' && (!formTargetId || !formNodeId)) || (formAction === 'prune' && formPruneTargets.length === 0)}>
|
||||
<Button onClick={handleSave} disabled={saving || !formName || !formCron || (targetType === 'stack' && (!formTargetId || !formNodeId)) || (formAction === 'scan' && !formNodeId) || (formAction === 'prune' && formPruneTargets.length === 0)}>
|
||||
{saving ? 'Saving...' : editingTask ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -4,7 +4,7 @@ export interface ScheduledTask {
|
||||
target_type: 'stack' | 'fleet' | 'system';
|
||||
target_id: string | null;
|
||||
node_id: number | null;
|
||||
action: 'restart' | 'snapshot' | 'prune' | 'update';
|
||||
action: 'restart' | 'snapshot' | 'prune' | 'update' | 'scan';
|
||||
cron_expression: string;
|
||||
enabled: number;
|
||||
created_by: string;
|
||||
|
||||
Reference in New Issue
Block a user