mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-24 00:49:45 +00:00
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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user