feat(scheduler): consistent action targeting in Scheduled Operations (#1431)

Give every scheduled action an explicit, predictable target model
(Action then Node then Stack then Options then Schedule):

- System Prune now exposes a Node picker and requires a node, so it can
  no longer run silently on the default node.
- Vulnerability Scan and System Prune list local nodes only; both run on
  the hub-local Docker daemon and reject remote nodes on the backend.
- Restart Stack service discovery loads services from the selected node
  via fetchForNode instead of the active or local node.
- Fleet Snapshot shows a read-only "Scope: Entire fleet" summary.

Backend gains a shared local-node guard and prune node validation on
create and update, plus an executor-level remote-node guard, so the
frontend and backend validation now agree for every action.
This commit is contained in:
Anso
2026-06-24 21:02:15 -04:00
committed by GitHub
parent 0af7ad1df2
commit bc8c051962
14 changed files with 542 additions and 106 deletions
+91 -43
View File
@@ -6,6 +6,7 @@ import {
VALID_ACTIONS,
INVALID_ACTION_MESSAGE,
validateActionTarget,
getScheduledActionDefinition,
type TargetType,
type BackendScheduledAction,
} from '../services/scheduledActionRegistry';
@@ -35,6 +36,31 @@ function broadcastScheduledTasksChanged(): void {
const VALID_PRUNE_TARGETS = ['containers', 'images', 'networks', 'volumes'] as const;
const ERR_FLEET_NODE_REQUIRED = 'Fleet update requires node_id.';
function parsePositiveNodeId(nodeId: unknown): number | null {
if (typeof nodeId !== 'number' && typeof nodeId !== 'string') return null;
if (typeof nodeId === 'string' && nodeId.trim().length === 0) return null;
const parsedNodeId = Number(nodeId);
return Number.isInteger(parsedNodeId) && parsedNodeId > 0 ? parsedNodeId : null;
}
function actionRequiresNode(action: BackendScheduledAction, targetType: TargetType): boolean {
if (targetType === 'stack') return true;
return getScheduledActionDefinition(action)?.requiresNode === true;
}
function nodeRequirementLabel(action: BackendScheduledAction, targetType: TargetType): string {
if (action === 'scan') return 'Scan';
if (action === 'prune') return 'Prune';
if (action === 'update' && targetType === 'fleet') return 'Fleet update';
return action;
}
function localNodeRequirementLabel(action: BackendScheduledAction): string {
if (action === 'scan') return 'Scheduled vulnerability scans';
if (action === 'prune') return 'Scheduled prunes';
return `${action} tasks`;
}
function validateStackTarget(targetType: TargetType, targetId: unknown, nodeId: unknown): string | null {
if (targetType !== 'stack') return null;
@@ -46,23 +72,38 @@ function validateStackTarget(targetType: TargetType, targetId: unknown, nodeId:
return 'Stack target_id must be a valid stack name.';
}
const parsedNodeId = Number(nodeId);
if (!Number.isInteger(parsedNodeId) || parsedNodeId <= 0) {
if (parsePositiveNodeId(nodeId) === null) {
return 'Stack operations require a valid node_id.';
}
return null;
}
function validateScanNode(nodeId: unknown): string | null {
if (nodeId == null) return 'Scan action requires node_id.';
const parsedNodeId = Number(nodeId);
if (!Number.isFinite(parsedNodeId)) return 'Scan action requires a valid node_id.';
const node = DatabaseService.getInstance().getNode(parsedNodeId);
if (!node) return 'Scheduled vulnerability scans require an existing local node.';
if (node?.type === 'remote') {
return 'Scheduled vulnerability scans currently require a local node.';
/**
* Shared guard for non-stack actions that require a node. Stack actions use
* validateStackTarget because they also require target_id.
*/
function validateActionNode(action: BackendScheduledAction, targetType: TargetType, nodeId: unknown): string | null {
if (targetType === 'stack') return null;
const def = getScheduledActionDefinition(action);
if (!def?.requiresNode) return null;
const labelSingular = nodeRequirementLabel(action, targetType);
const labelPlural = localNodeRequirementLabel(action);
if (nodeId == null) {
return action === 'update' && targetType === 'fleet'
? ERR_FLEET_NODE_REQUIRED
: `${labelSingular} action requires node_id.`;
}
const parsedNodeId = parsePositiveNodeId(nodeId);
if (parsedNodeId === null) return `${labelSingular} action requires a valid node_id.`;
if (def.nodeScope !== 'local') return null;
const node = DatabaseService.getInstance().getNode(parsedNodeId);
if (!node) return `${labelPlural} require an existing local node.`;
if (node.type === 'remote') return `${labelPlural} currently require a local node.`;
return null;
}
@@ -153,16 +194,8 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => {
const targetErr = validateActionTarget(action, target_type);
if (targetErr) { res.status(400).json({ error: targetErr }); return; }
if (action === 'scan' && !node_id) {
res.status(400).json({ error: 'Scan action requires node_id.' }); return;
}
if (action === 'scan') {
const nodeErr = validateScanNode(node_id);
if (nodeErr) { res.status(400).json({ error: nodeErr }); return; }
}
if (action === 'update' && target_type === 'fleet' && !node_id) {
res.status(400).json({ error: ERR_FLEET_NODE_REQUIRED }); return;
}
const nodeErr = validateActionNode(action, target_type, node_id);
if (nodeErr) { res.status(400).json({ error: nodeErr }); return; }
const stackTargetErr = validateStackTarget(target_type, target_id, node_id);
if (stackTargetErr) { res.status(400).json({ error: stackTargetErr }); return; }
@@ -177,12 +210,14 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => {
const scheduler = SchedulerService.getInstance();
const now = Date.now();
const nextRun = (enabled !== false) ? scheduler.calculateNextRun(cron_expression) : null;
const normalizedTargetId = target_type === 'stack' ? target_id : null;
const normalizedNodeId = actionRequiresNode(action, target_type) ? parsePositiveNodeId(node_id) : null;
const id = DatabaseService.getInstance().createScheduledTask({
name: name.trim(),
target_type,
target_id: target_id || null,
node_id: node_id != null ? Number(node_id) : null,
target_id: normalizedTargetId,
node_id: normalizedNodeId,
action,
cron_expression,
enabled: enabled !== false ? 1 : 0,
@@ -193,9 +228,9 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => {
next_run_at: nextRun,
last_status: null,
last_error: null,
prune_targets: prune_targets ? JSON.stringify(prune_targets) : null,
target_services: target_services ? JSON.stringify(target_services) : null,
prune_label_filter: prune_label_filter ? prune_label_filter.trim() : null,
prune_targets: action === 'prune' && prune_targets ? JSON.stringify(prune_targets) : null,
target_services: action === 'restart' && target_type === 'stack' && target_services ? JSON.stringify(target_services) : null,
prune_label_filter: action === 'prune' && prune_label_filter ? prune_label_filter.trim() : null,
delete_after_run: delete_after_run ? 1 : 0,
});
@@ -244,20 +279,17 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => {
const finalAction = (action ?? existing.action) as BackendScheduledAction;
const finalTargetType = (target_type ?? existing.target_type) as TargetType;
const finalTargetId = target_id !== undefined ? target_id : existing.target_id;
const finalNodeId = node_id !== undefined ? node_id : existing.node_id;
const finalTargetId = finalTargetType === 'stack'
? (target_id !== undefined ? target_id : existing.target_id)
: null;
const finalNodeId = actionRequiresNode(finalAction, finalTargetType)
? (node_id !== undefined ? node_id : existing.node_id)
: null;
const targetErr = validateActionTarget(finalAction, finalTargetType);
if (targetErr) { res.status(400).json({ error: targetErr }); return; }
if (finalAction === 'scan') {
const nodeErr = validateScanNode(finalNodeId);
if (nodeErr) { res.status(400).json({ error: nodeErr }); return; }
}
if (finalAction === 'update' && finalTargetType === 'fleet') {
if (!finalNodeId) {
res.status(400).json({ error: ERR_FLEET_NODE_REQUIRED }); return;
}
}
const nodeErr = validateActionNode(finalAction, finalTargetType, finalNodeId);
if (nodeErr) { res.status(400).json({ error: nodeErr }); return; }
const stackTargetErr = validateStackTarget(finalTargetType, finalTargetId, finalNodeId);
if (stackTargetErr) { res.status(400).json({ error: stackTargetErr }); return; }
@@ -274,15 +306,31 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => {
const updates: Record<string, unknown> = { updated_at: Date.now() };
if (name !== undefined) updates.name = typeof name === 'string' ? name.trim() : name;
if (target_type !== undefined) updates.target_type = target_type;
if (target_id !== undefined) updates.target_id = target_id || null;
if (node_id !== undefined) updates.node_id = node_id != null ? Number(node_id) : null;
if (action !== undefined) updates.action = action;
if (target_type !== undefined) updates.target_type = finalTargetType;
if (target_id !== undefined || finalTargetType !== 'stack') updates.target_id = finalTargetId || null;
if (node_id !== undefined || !actionRequiresNode(finalAction, finalTargetType)) {
updates.node_id = finalNodeId != null ? parsePositiveNodeId(finalNodeId) : null;
}
if (action !== undefined) updates.action = finalAction;
if (cron_expression !== undefined) updates.cron_expression = cron_expression;
if (enabled !== undefined) updates.enabled = enabled ? 1 : 0;
if (prune_targets !== undefined) updates.prune_targets = prune_targets ? JSON.stringify(prune_targets) : null;
if (target_services !== undefined) updates.target_services = target_services ? JSON.stringify(target_services) : null;
if (prune_label_filter !== undefined) updates.prune_label_filter = prune_label_filter ? prune_label_filter.trim() : null;
if (prune_targets !== undefined) {
updates.prune_targets = finalAction === 'prune' && prune_targets ? JSON.stringify(prune_targets) : null;
} else if (finalAction !== 'prune') {
updates.prune_targets = null;
}
if (target_services !== undefined) {
updates.target_services = finalAction === 'restart' && finalTargetType === 'stack' && target_services
? JSON.stringify(target_services)
: null;
} else if (finalAction !== 'restart' || finalTargetType !== 'stack') {
updates.target_services = null;
}
if (prune_label_filter !== undefined) {
updates.prune_label_filter = finalAction === 'prune' && prune_label_filter ? prune_label_filter.trim() : null;
} else if (finalAction !== 'prune') {
updates.prune_label_filter = null;
}
if (delete_after_run !== undefined) updates.delete_after_run = delete_after_run ? 1 : 0;
const finalCron = cron_expression || existing.cron_expression;