diff --git a/backend/src/__tests__/scheduler-service.test.ts b/backend/src/__tests__/scheduler-service.test.ts index daf824ea..ba5cf897 100644 --- a/backend/src/__tests__/scheduler-service.test.ts +++ b/backend/src/__tests__/scheduler-service.test.ts @@ -20,6 +20,7 @@ const { mockGetProxyTarget, mockIsTrivyAvailable, mockScanAllNodeImages, + mockGetStackAutoUpdateSettingsForNode, } = vi.hoisted(() => ({ mockGetDueScheduledTasks: vi.fn().mockReturnValue([]), mockCreateScheduledTaskRun: vi.fn().mockReturnValue(1), @@ -54,6 +55,7 @@ const { severity: { critical: 0, high: 0, medium: 0, low: 0, unknown: 0 }, violations: [], }), + mockGetStackAutoUpdateSettingsForNode: vi.fn().mockReturnValue({}), })); vi.mock('../services/DatabaseService', () => ({ @@ -72,6 +74,7 @@ vi.mock('../services/DatabaseService', () => ({ clearStackUpdateStatus: mockClearStackUpdateStatus, markStaleRunsAsFailed: mockMarkStaleRunsAsFailed, deleteOldScans: mockDeleteOldScans, + getStackAutoUpdateSettingsForNode: mockGetStackAutoUpdateSettingsForNode, }), }, })); @@ -737,6 +740,93 @@ describe('SchedulerService - executeUpdate', () => { const svc = SchedulerService.getInstance(); expect(svc.isTaskRunning(999)).toBe(false); }); + + it('fleet target updates all stacks whose policy allows it', async () => { + mockGetScheduledTask.mockReturnValue({ + id: 87, + name: 'fleet-update', + action: 'update', + target_type: 'fleet', + cron_expression: '0 4 * * *', + enabled: true, + target_id: null, + node_id: 1, + created_by: 'admin', + last_status: null, + }); + mockGetStacks.mockResolvedValue(['app1', 'app2', 'app3']); + // app2 explicitly disabled; app1 and app3 default to enabled + mockGetStackAutoUpdateSettingsForNode.mockReturnValue({ app2: false }); + mockGetContainersByStack.mockResolvedValue([{ Id: 'c1', Image: 'nginx:latest' }]); + mockCheckImage.mockResolvedValue({ hasUpdate: true }); + + const svc = SchedulerService.getInstance(); + await svc.triggerTask(87); + + // Only app1 and app3 should be updated + expect(mockUpdateStack).toHaveBeenCalledTimes(2); + expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith( + 1, + expect.objectContaining({ + status: 'success', + output: expect.stringContaining('auto-updates disabled; skipped'), + }) + ); + }); + + it('fleet target with zero eligible stacks records success', async () => { + mockGetScheduledTask.mockReturnValue({ + id: 88, + name: 'fleet-update-all-off', + action: 'update', + target_type: 'fleet', + cron_expression: '0 4 * * *', + enabled: true, + target_id: null, + node_id: 1, + created_by: 'admin', + last_status: null, + }); + mockGetStacks.mockResolvedValue(['app1', 'app2']); + mockGetStackAutoUpdateSettingsForNode.mockReturnValue({ app1: false, app2: false }); + + const svc = SchedulerService.getInstance(); + await svc.triggerTask(88); + + expect(mockUpdateStack).not.toHaveBeenCalled(); + expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith( + 1, + expect.objectContaining({ status: 'success' }) + ); + }); + + it('fleet target on empty node returns early with skipped message', async () => { + mockGetScheduledTask.mockReturnValue({ + id: 89, + name: 'fleet-update-empty-node', + action: 'update', + target_type: 'fleet', + cron_expression: '0 4 * * *', + enabled: true, + target_id: null, + node_id: 1, + created_by: 'admin', + last_status: null, + }); + mockGetStacks.mockResolvedValue([]); + + const svc = SchedulerService.getInstance(); + await svc.triggerTask(89); + + expect(mockUpdateStack).not.toHaveBeenCalled(); + expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith( + 1, + expect.objectContaining({ + status: 'success', + output: expect.stringContaining('No stacks found'), + }) + ); + }); }); // ── Error handling & notifications ───────────────────────────────────── diff --git a/backend/src/routes/scheduledTasks.ts b/backend/src/routes/scheduledTasks.ts index 910c975b..4b262682 100644 --- a/backend/src/routes/scheduledTasks.ts +++ b/backend/src/routes/scheduledTasks.ts @@ -10,6 +10,7 @@ import { getErrorMessage } from '../utils/errors'; const VALID_TARGET_TYPES = ['stack', 'fleet', 'system'] as const; const VALID_ACTIONS = ['restart', 'snapshot', 'prune', 'update', 'scan'] as const; const VALID_PRUNE_TARGETS = ['containers', 'images', 'networks', 'volumes'] as const; +const ERR_FLEET_NODE_REQUIRED = 'Fleet update requires node_id.'; type TargetType = typeof VALID_TARGET_TYPES[number]; type ScheduledAction = typeof VALID_ACTIONS[number]; @@ -30,7 +31,7 @@ function parseTaskId(req: Request, res: Response): number | null { */ function validateActionTarget(action: ScheduledAction, targetType: TargetType): string | null { if (action === 'restart' && targetType !== 'stack') return 'Restart action requires target_type "stack".'; - if (action === 'update' && targetType !== 'stack') return 'Update action requires target_type "stack".'; + if (action === 'update' && targetType !== 'stack' && targetType !== 'fleet') return 'Update action requires target_type "stack" or "fleet".'; if (action === 'snapshot' && targetType !== 'fleet') return 'Snapshot action requires target_type "fleet".'; if (action === 'prune' && targetType !== 'system') return 'Prune action requires target_type "system".'; if (action === 'scan' && targetType !== 'system') return 'Scan action requires target_type "system".'; @@ -131,6 +132,9 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => { if (action === 'scan' && !node_id) { res.status(400).json({ error: 'Scan action requires node_id.' }); return; } + if (action === 'update' && target_type === 'fleet' && !node_id) { + res.status(400).json({ error: ERR_FLEET_NODE_REQUIRED }); return; + } if (target_type === 'stack' && (!target_id || !node_id)) { res.status(400).json({ error: 'Stack operations require target_id and node_id.' }); return; } @@ -224,6 +228,12 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => { res.status(400).json({ error: 'Scan action requires node_id.' }); return; } } + if (finalAction === 'update' && finalTargetType === 'fleet') { + const finalNodeId = node_id !== undefined ? node_id : existing.node_id; + if (!finalNodeId) { + res.status(400).json({ error: ERR_FLEET_NODE_REQUIRED }); return; + } + } const optionalErr = validateOptionalFields(finalAction, finalTargetType, prune_targets, target_services, prune_label_filter); if (optionalErr) { res.status(400).json({ error: optionalErr }); return; } diff --git a/backend/src/services/SchedulerService.ts b/backend/src/services/SchedulerService.ts index 6d26de88..7213f7f6 100644 --- a/backend/src/services/SchedulerService.ts +++ b/backend/src/services/SchedulerService.ts @@ -482,41 +482,56 @@ export class SchedulerService { } private async executeUpdate(task: ScheduledTask): Promise { - if (!task.target_id || task.node_id == null) { - throw new Error('Auto-update requires target_id (stack name or "*") and node_id'); + if (task.node_id == null) { + throw new Error('Auto-update requires node_id'); } - // For remote nodes, proxy the entire execution to the remote Sencho instance + const isFleet = task.target_type === 'fleet'; + + if (!isFleet && !task.target_id) { + throw new Error('Auto-update requires target_id (stack name or "*")'); + } + + // For remote nodes, proxy the entire execution to the remote Sencho instance. + // The remote /api/auto-update/execute endpoint already handles per-stack + // auto-update policy, so passing '*' for fleet is sufficient. const node = NodeRegistry.getInstance().getNode(task.node_id); if (node?.type === 'remote') { - return this.executeUpdateRemote(task.node_id, task.target_id); + return this.executeUpdateRemote(task.node_id, isFleet ? '*' : task.target_id!); } // Local node: execute directly const isWildcard = task.target_id === '*'; let stackNames: string[]; - if (isWildcard) { + if (isFleet || isWildcard) { stackNames = await FileSystemService.getInstance(task.node_id).getStacks(); if (stackNames.length === 0) { return 'No stacks found on node; skipped.'; } } else { - stackNames = [task.target_id]; + stackNames = [task.target_id!]; } if (isDebugEnabled()) { - console.log(`[SchedulerService] executeUpdate: ${stackNames.length} stack(s) to check, wildcard=${isWildcard}`); + console.log(`[SchedulerService] executeUpdate: ${stackNames.length} stack(s) to check, fleet=${isFleet}, wildcard=${isWildcard}`); } + const db = DatabaseService.getInstance(); const docker = DockerController.getInstance(task.node_id); const imageUpdateService = ImageUpdateService.getInstance(); const compose = ComposeService.getInstance(task.node_id); - const db = DatabaseService.getInstance(); const results: string[] = []; + // Single batch query for fleet mode; per-stack default is enabled (true) when no explicit row exists. + const policyMap = isFleet ? db.getStackAutoUpdateSettingsForNode(task.node_id) : null; + for (const stackName of stackNames) { try { - const output = await this.executeUpdateForStack(stackName, task.node_id ?? 0, docker, imageUpdateService, compose, db, isWildcard); + if (isFleet && (policyMap![stackName] ?? true) === false) { + results.push(`Stack "${stackName}": auto-updates disabled; skipped.`); + continue; + } + const output = await this.executeUpdateForStack(stackName, task.node_id, docker, imageUpdateService, compose, db, isFleet || isWildcard); results.push(output); } catch (e) { const msg = getErrorMessage(e, String(e)); diff --git a/frontend/src/components/ScheduledOperationsView.tsx b/frontend/src/components/ScheduledOperationsView.tsx index a2431681..abe2886c 100644 --- a/frontend/src/components/ScheduledOperationsView.tsx +++ b/frontend/src/components/ScheduledOperationsView.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -18,12 +18,20 @@ import { Combobox } from '@/components/ui/combobox'; import type { ScheduledTask, TaskRun, NodeOption } from '@/types/scheduling'; import { getCronDescription, formatTimestamp } from '@/lib/scheduling'; -const ACTION_OPTIONS = [ - { value: 'restart', label: 'Restart Stack', targetType: 'stack' as const }, - { value: 'update', label: 'Auto-update 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 }, +const UPDATE_FLEET_ACTION = 'update-fleet' as const; + +const ACTION_OPTIONS: Array<{ + value: string; + label: string; + targetType: 'stack' | 'fleet' | 'system'; + backendAction?: 'restart' | 'snapshot' | 'prune' | 'update' | 'scan'; +}> = [ + { value: 'restart', label: 'Restart Stack', targetType: 'stack' }, + { value: 'update', label: 'Auto-update Stack', targetType: 'stack' }, + { value: UPDATE_FLEET_ACTION, label: 'Auto-update All Stacks', targetType: 'fleet', backendAction: 'update' }, + { value: 'snapshot', label: 'Fleet Snapshot', targetType: 'fleet' }, + { value: 'prune', label: 'System Prune', targetType: 'system' }, + { value: 'scan', label: 'Vulnerability Scan', targetType: 'system' }, ]; const TIMELINE_LANES: { key: ScheduledTask['action']; label: string; color: string; bg: string; actions: ScheduledTask['action'][] }[] = [ @@ -212,7 +220,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p const openEdit = (task: ScheduledTask) => { setEditingTask(task); setFormName(task.name); - setFormAction(task.action); + setFormAction(task.action === 'update' && task.target_type === 'fleet' ? UPDATE_FLEET_ACTION : task.action); setFormTargetId(task.target_id || ''); setFormNodeId(task.node_id != null ? String(task.node_id) : ''); setFormCron(task.cron_expression); @@ -234,7 +242,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p const body: Record = { name: formName, target_type: actionOption.targetType, - action: formAction, + action: actionOption.backendAction ?? formAction, cron_expression: formCron, enabled: formEnabled, }; @@ -243,7 +251,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p body.target_id = formTargetId; body.node_id = formNodeId ? parseInt(formNodeId, 10) : null; } - if (formAction === 'scan') { + if (formAction === 'scan' || formAction === UPDATE_FLEET_ACTION) { body.node_id = formNodeId ? parseInt(formNodeId, 10) : null; } if (formAction === 'prune' && formPruneTargets.length > 0) { @@ -348,6 +356,13 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p const targetType = ACTION_OPTIONS.find(a => a.value === formAction)?.targetType; const cronDescription = getCronDescription(formCron); + const nodeOptions = useMemo(() => nodes.map(n => ({ value: String(n.id), label: n.name })), [nodes]); + const isSaveDisabled = + saving || !formName || !formCron + || (targetType === 'stack' && (!formTargetId || !formNodeId)) + || (formAction === 'scan' && !formNodeId) + || (formAction === UPDATE_FLEET_ACTION && !formNodeId) + || (formAction === 'prune' && formPruneTargets.length === 0); const windowEnd = now + TIMELINE_WINDOW_MS; const timelinePills = filteredTasks @@ -576,7 +591,10 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p {task.name} - {ACTION_OPTIONS.find(a => a.value === task.action)?.label || task.action} + {(task.action === 'update' && task.target_type === 'fleet' + ? ACTION_OPTIONS.find(a => a.value === UPDATE_FLEET_ACTION) + : ACTION_OPTIONS.find(a => a.value === task.action) + )?.label || task.action} @@ -584,7 +602,9 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p ? task.target_services ? `${task.target_id} (${(JSON.parse(task.target_services) as string[]).join(', ')})` : task.target_id - : task.target_type} + : task.action === 'update' + ? 'All eligible stacks' + : task.target_type}
{getCronDescription(task.cron_expression)}
@@ -660,7 +680,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
({ value: String(n.id), label: n.name }))} + options={nodeOptions} value={formNodeId} onValueChange={setFormNodeId} placeholder="Select node..." @@ -699,11 +719,24 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p )} + {formAction === UPDATE_FLEET_ACTION && ( +
+ + +

Only stacks with auto-updates enabled on this node will be updated.

+
+ )} + {formAction === 'scan' && (
({ value: String(n.id), label: n.name }))} + options={nodeOptions} value={formNodeId} onValueChange={setFormNodeId} placeholder="Select node..." @@ -763,7 +796,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
-