feat(schedules): auto-update stacks by Stack Label (#1717)

* feat(schedules): auto-update stacks by Stack Label

Add a reusable selector_type/selector_value on scheduled tasks so admins
can schedule image updates against live Stack Label membership across the
fleet or one node, reusing fleet label resolution and the existing
auto-update orchestrator.

* fix(image-updates): sanitize auto-update execute failure logs

Use a static format string and sanitizeForLog so CodeQL no longer
flags user-controlled stack names and error text in the execute catch.

* fix(ui): space Scope label from fleet/node segmented control

Match the Schedule row layout so the inline SegmentedControl no longer
sits flush against the Scope label.

* fix(ui): remove redundant wrapper around Scope segmented control
This commit is contained in:
Anso
2026-07-28 13:00:47 -04:00
committed by GitHub
parent fa503ddf27
commit 72cdbb0eaa
18 changed files with 883 additions and 72 deletions
+44 -16
View File
@@ -359,30 +359,54 @@ export const autoUpdateRouter = Router();
autoUpdateRouter.post('/execute', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
try {
// Honor the node-scoped image-update detection opt-out before any work.
if (!ImageUpdateService.isChecksEnabled()) {
res.json({ result: 'Image update detection is disabled for this node; skipped.' });
return;
}
const { target } = req.body as { target?: string };
console.log(`[AutoUpdate] Execute requested: target="${sanitizeForLog(target || '')}"`);
if (!target || typeof target !== 'string') {
res.status(400).json({ error: 'Missing "target" (stack name or "*" for all)' });
return;
}
const { target, targets } = req.body as { target?: string; targets?: unknown };
let stackNames: string[];
if (target === '*') {
stackNames = await FileSystemService.getInstance(req.nodeId).getStacks();
if (stackNames.length === 0) {
res.json({ result: 'No stacks found on node; skipped.' });
if (Array.isArray(targets)) {
if (targets.length === 0) {
res.status(400).json({ error: '"targets" must be a non-empty array of stack names' });
return;
}
if (targets.length > 500) {
res.status(400).json({ error: '"targets" accepts at most 500 stack names' });
return;
}
if (!targets.every((t): t is string => typeof t === 'string' && isValidStackName(t))) {
res.status(400).json({ error: 'Invalid stack name in targets' });
return;
}
// Deduplicate while preserving order.
const seen = new Set<string>();
stackNames = [];
for (const name of targets) {
if (seen.has(name)) continue;
seen.add(name);
stackNames.push(name);
}
console.log(`[AutoUpdate] Execute requested: targets=${stackNames.length}`);
} else if (typeof target === 'string' && target.length > 0) {
console.log(`[AutoUpdate] Execute requested: target="${sanitizeForLog(target)}"`);
if (target === '*') {
stackNames = await FileSystemService.getInstance(req.nodeId).getStacks();
if (stackNames.length === 0) {
res.json({ result: 'No stacks found on node; skipped.' });
return;
}
} else {
if (!isValidStackName(target)) {
res.status(400).json({ error: 'Invalid stack name' });
return;
}
stackNames = [target];
}
} else {
if (!isValidStackName(target)) {
res.status(400).json({ error: 'Invalid stack name' });
return;
}
stackNames = [target];
res.status(400).json({ error: 'Missing "target" (stack name or "*") or "targets" (stack name array)' });
return;
}
const docker = DockerController.getInstance(req.nodeId);
@@ -510,7 +534,11 @@ autoUpdateRouter.post('/execute', authMiddleware, async (req: Request, res: Resp
} catch (e) {
const msg = getErrorMessage(e, String(e));
results.push(`Stack "${stackName}" failed: ${msg}`);
console.error(`[AutoUpdate] Failed for stack "${stackName}":`, e);
console.error(
'[AutoUpdate] Failed for stack %s: %s',
sanitizeForLog(stackName),
sanitizeForLog(msg),
);
}
}
+109 -13
View File
@@ -35,6 +35,17 @@ function broadcastScheduledTasksChanged(): void {
const VALID_PRUNE_TARGETS = ['containers', 'images', 'networks', 'volumes'] as const;
const ERR_FLEET_NODE_REQUIRED = 'Fleet update requires node_id.';
const STACK_LABEL_SELECTOR = 'stack-label';
const LABEL_NAME_RE = /^[a-zA-Z0-9 -]+$/;
function isStackLabelSelector(selectorType: unknown): boolean {
return selectorType === STACK_LABEL_SELECTOR;
}
/** True when this update+fleet task uses a stack-label selector (node_id may be null). */
function usesStackLabelSelector(action: BackendScheduledAction, targetType: TargetType, selectorType: unknown): boolean {
return action === 'update' && targetType === 'fleet' && isStackLabelSelector(selectorType);
}
function parsePositiveNodeId(nodeId: unknown): number | null {
if (typeof nodeId !== 'number' && typeof nodeId !== 'string') return null;
@@ -98,17 +109,26 @@ function validateContainerTarget(targetType: TargetType, targetId: unknown, node
/**
* Shared guard for non-stack actions that require a node. Stack actions use
* validateStackTarget because they also require target_id.
* validateStackTarget because they also require target_id. Label-targeted
* fleet updates may omit node_id (entire fleet); pass selectorType so that
* path is allowed.
*/
function validateActionNode(action: BackendScheduledAction, targetType: TargetType, nodeId: unknown): string | null {
function validateActionNode(
action: BackendScheduledAction,
targetType: TargetType,
nodeId: unknown,
selectorType?: unknown,
): string | null {
if (targetType === 'stack' || targetType === 'container') return null;
const def = getScheduledActionDefinition(action);
if (!def?.requiresNode) return null;
const labelSingular = nodeRequirementLabel(action, targetType);
const labelPlural = localNodeRequirementLabel(action);
const labelFleetUpdate = usesStackLabelSelector(action, targetType, selectorType);
if (nodeId == null) {
if (labelFleetUpdate) return null;
return action === 'update' && targetType === 'fleet'
? ERR_FLEET_NODE_REQUIRED
: `${labelSingular} action requires node_id.`;
@@ -124,13 +144,15 @@ function validateActionNode(action: BackendScheduledAction, targetType: TargetTy
return null;
}
/** Shared validation for prune_targets, target_services, prune_label_filter. Returns an error string or null. */
/** Shared validation for prune_targets, target_services, prune_label_filter, selector_*. Returns an error string or null. */
function validateOptionalFields(
action: BackendScheduledAction,
targetType: TargetType,
prune_targets: unknown,
target_services: unknown,
prune_label_filter: unknown,
selector_type?: unknown,
selector_value?: unknown,
): string | null {
if (prune_targets !== undefined && prune_targets !== null) {
if (!Array.isArray(prune_targets) || prune_targets.length === 0
@@ -155,9 +177,39 @@ function validateOptionalFields(
return 'prune_label_filter can only be used with prune action';
}
}
const selectorPresent = (selector_type !== undefined && selector_type !== null)
|| (selector_value !== undefined && selector_value !== null);
if (selectorPresent) {
if (action !== 'update' || targetType !== 'fleet') {
return 'selector fields can only be used with update action on fleet target';
}
if (selector_type !== STACK_LABEL_SELECTOR) {
return 'selector_type must be "stack-label"';
}
if (typeof selector_value !== 'string' || selector_value.trim().length === 0 || selector_value.trim().length > 30) {
return 'selector_value is required and must be 1-30 characters';
}
if (!LABEL_NAME_RE.test(selector_value.trim())) {
return 'selector_value may only contain letters, numbers, spaces, and hyphens';
}
}
return null;
}
function normalizeSelectorFields(
action: BackendScheduledAction,
targetType: TargetType,
selector_type: unknown,
selector_value: unknown,
): { selector_type: string | null; selector_value: string | null } {
if (action === 'update' && targetType === 'fleet' && isStackLabelSelector(selector_type)
&& typeof selector_value === 'string' && selector_value.trim()) {
return { selector_type: STACK_LABEL_SELECTOR, selector_value: selector_value.trim() };
}
return { selector_type: null, selector_value: null };
}
/**
* Validate a cron expression for Scheduled Operations. The scheduler ticks once
* per minute, so an expression with a leading seconds field (6 or more fields)
@@ -238,7 +290,11 @@ scheduledTasksRouter.get('/', (req: Request, res: Response): void => {
scheduledTasksRouter.post('/', (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
try {
const { name, target_type, target_id, node_id, action, cron_expression, enabled, prune_targets, target_services, prune_label_filter, delete_after_run, run_at } = req.body;
const {
name, target_type, target_id, node_id, action, cron_expression, enabled,
prune_targets, target_services, prune_label_filter, selector_type, selector_value,
delete_after_run, run_at,
} = req.body;
if (!name || typeof name !== 'string' || !name.trim()) {
res.status(400).json({ error: 'Name is required' }); return;
@@ -253,14 +309,16 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => {
const targetErr = validateActionTarget(action, target_type);
if (targetErr) { res.status(400).json({ error: targetErr }); return; }
const nodeErr = validateActionNode(action, target_type, node_id);
const nodeErr = validateActionNode(action, target_type, node_id, selector_type);
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; }
const containerTargetErr = validateContainerTarget(target_type, target_id, node_id);
if (containerTargetErr) { res.status(400).json({ error: containerTargetErr }); return; }
const optionalErr = validateOptionalFields(action, target_type, prune_targets, target_services, prune_label_filter);
const optionalErr = validateOptionalFields(
action, target_type, prune_targets, target_services, prune_label_filter, selector_type, selector_value,
);
if (optionalErr) { res.status(400).json({ error: optionalErr }); return; }
const cronErr = validateCronExpression(cron_expression);
@@ -282,7 +340,14 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => {
: (pinnedRunAt ?? scheduler.calculateNextRun(cron_expression));
const normalizedTargetId =
target_type === 'stack' || target_type === 'container' ? target_id : null;
const normalizedNodeId = actionRequiresNode(action) ? parsePositiveNodeId(node_id) : null;
const labelSelector = usesStackLabelSelector(action, target_type, selector_type);
const normalizedNodeId = labelSelector
? (node_id == null || node_id === '' ? null : parsePositiveNodeId(node_id))
: (actionRequiresNode(action) ? parsePositiveNodeId(node_id) : null);
if (labelSelector && node_id != null && node_id !== '' && normalizedNodeId === null) {
res.status(400).json({ error: 'Fleet update action requires a valid node_id.' }); return;
}
const selectors = normalizeSelectorFields(action, target_type, selector_type, selector_value);
const id = DatabaseService.getInstance().createScheduledTask({
name: name.trim(),
@@ -302,6 +367,8 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => {
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,
selector_type: selectors.selector_type,
selector_value: selectors.selector_value,
delete_after_run: delete_after_run ? 1 : 0,
run_at: pinnedRunAt,
});
@@ -340,7 +407,11 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => {
const existing = db.getScheduledTask(id);
if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; }
const { name, target_type, target_id, node_id, action, cron_expression, enabled, prune_targets, target_services, prune_label_filter, delete_after_run, run_at } = req.body;
const {
name, target_type, target_id, node_id, action, cron_expression, enabled,
prune_targets, target_services, prune_label_filter, selector_type, selector_value,
delete_after_run, run_at,
} = req.body;
if (target_type !== undefined && !(VALID_TARGET_TYPES as readonly string[]).includes(target_type)) {
res.status(400).json({ error: 'Invalid target_type' }); return;
@@ -354,13 +425,18 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => {
const finalTargetId = finalTargetType === 'stack' || finalTargetType === 'container'
? (target_id !== undefined ? target_id : existing.target_id)
: null;
const finalNodeId = actionRequiresNode(finalAction)
const finalSelectorType = selector_type !== undefined ? selector_type : existing.selector_type;
const finalSelectorValue = selector_value !== undefined ? selector_value : existing.selector_value;
const labelSelector = usesStackLabelSelector(finalAction, finalTargetType, finalSelectorType);
const finalNodeId = labelSelector
? (node_id !== undefined ? node_id : existing.node_id)
: null;
: (actionRequiresNode(finalAction)
? (node_id !== undefined ? node_id : existing.node_id)
: null);
const targetErr = validateActionTarget(finalAction, finalTargetType);
if (targetErr) { res.status(400).json({ error: targetErr }); return; }
const nodeErr = validateActionNode(finalAction, finalTargetType, finalNodeId);
const nodeErr = validateActionNode(finalAction, finalTargetType, finalNodeId, finalSelectorType);
if (nodeErr) { res.status(400).json({ error: nodeErr }); return; }
const stackTargetErr = validateStackTarget(finalTargetType, finalTargetId, finalNodeId);
@@ -369,7 +445,11 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => {
const containerTargetErr = validateContainerTarget(finalTargetType, finalTargetId, finalNodeId);
if (containerTargetErr) { res.status(400).json({ error: containerTargetErr }); return; }
const optionalErr = validateOptionalFields(finalAction, finalTargetType, prune_targets, target_services, prune_label_filter);
const optionalErr = validateOptionalFields(
finalAction, finalTargetType, prune_targets, target_services, prune_label_filter,
selector_type !== undefined ? selector_type : finalSelectorType,
selector_value !== undefined ? selector_value : finalSelectorValue,
);
if (optionalErr) { res.status(400).json({ error: optionalErr }); return; }
if (cron_expression !== undefined) {
@@ -391,7 +471,17 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => {
if (target_id !== undefined || (finalTargetType !== 'stack' && finalTargetType !== 'container')) {
updates.target_id = finalTargetId || null;
}
if (node_id !== undefined || !actionRequiresNode(finalAction)) {
// Label-targeted fleet updates keep node_id when provided (or existing);
// clear only when the caller explicitly sends null/empty for fleet-wide, or
// when the action no longer requires a node and is not a label selector.
if (labelSelector) {
if (node_id !== undefined) {
updates.node_id = node_id == null || node_id === '' ? null : parsePositiveNodeId(node_id);
if (node_id != null && node_id !== '' && updates.node_id === null) {
res.status(400).json({ error: 'Fleet update action requires a valid node_id.' }); return;
}
}
} else if (node_id !== undefined || !actionRequiresNode(finalAction)) {
updates.node_id = finalNodeId != null ? parsePositiveNodeId(finalNodeId) : null;
}
if (action !== undefined) updates.action = finalAction;
@@ -414,6 +504,12 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => {
} else if (finalAction !== 'prune') {
updates.prune_label_filter = null;
}
if (selector_type !== undefined || selector_value !== undefined
|| finalAction !== 'update' || finalTargetType !== 'fleet') {
const selectors = normalizeSelectorFields(finalAction, finalTargetType, finalSelectorType, finalSelectorValue);
updates.selector_type = selectors.selector_type;
updates.selector_value = selectors.selector_value;
}
if (delete_after_run !== undefined) updates.delete_after_run = delete_after_run ? 1 : 0;
// Persist a re-supplied run_at to its column (a number pins a one-shot; null