mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-04 07:57:54 +00:00
feat(scheduler): support fleet-wide auto-update schedules per node (#773)
Allow a scheduled task with action='update' and target_type='fleet' to update every eligible stack on a node in a single schedule entry. The executor respects each stack's per-stack auto-update policy via a single batch query, skipping stacks that have opted out. For remote nodes the request proxies to the remote Sencho instance, which already enforces the same policy in its /api/auto-update/execute endpoint. Backend route validation now accepts update+fleet as a valid combo (previously only update+stack was allowed) and requires node_id. Frontend adds an "Auto-update All Stacks" option to the scheduled-task creation form with a node selector and descriptive helper text.
This commit is contained in:
@@ -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 ─────────────────────────────────────
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -482,41 +482,56 @@ export class SchedulerService {
|
||||
}
|
||||
|
||||
private async executeUpdate(task: ScheduledTask): Promise<string> {
|
||||
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));
|
||||
|
||||
@@ -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<string, unknown> = {
|
||||
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
|
||||
<TableCell className="font-medium">{task.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">
|
||||
{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}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
@@ -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}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm">{getCronDescription(task.cron_expression)}</div>
|
||||
@@ -660,7 +680,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
<div className="space-y-2">
|
||||
<Label>Node</Label>
|
||||
<Combobox
|
||||
options={nodes.map(n => ({ 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 && (
|
||||
<div className="space-y-2">
|
||||
<Label>Node</Label>
|
||||
<Combobox
|
||||
options={nodeOptions}
|
||||
value={formNodeId}
|
||||
onValueChange={setFormNodeId}
|
||||
placeholder="Select node..."
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Only stacks with auto-updates enabled on this node will be updated.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formAction === 'scan' && (
|
||||
<div className="space-y-2">
|
||||
<Label>Node</Label>
|
||||
<Combobox
|
||||
options={nodes.map(n => ({ 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
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleSave} disabled={saving || !formName || !formCron || (targetType === 'stack' && (!formTargetId || !formNodeId)) || (formAction === 'scan' && !formNodeId) || (formAction === 'prune' && formPruneTargets.length === 0)}>
|
||||
<Button onClick={handleSave} disabled={isSaveDisabled}>
|
||||
{saving ? 'Saving...' : editingTask ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
Reference in New Issue
Block a user