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:
Anso
2026-04-25 12:17:09 -04:00
committed by GitHub
parent 819d2a63fc
commit a74564fd61
4 changed files with 173 additions and 25 deletions
@@ -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 ─────────────────────────────────────
+11 -1
View File
@@ -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; }
+24 -9
View File
@@ -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));