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
@@ -113,7 +113,7 @@ describe('GET /api/dashboard/configuration', () => {
last_error: null,
prune_targets: null,
target_services: null,
prune_label_filter: null,
prune_label_filter: null, selector_type: null, selector_value: null,
};
const idA = db.createScheduledTask({
...baseTask,
@@ -41,6 +41,8 @@ const SCHEMA = `
prune_targets TEXT,
target_services TEXT,
prune_label_filter TEXT,
selector_type TEXT,
selector_value TEXT,
FOREIGN KEY(node_id) REFERENCES nodes(id)
);
@@ -412,6 +412,35 @@ describe('POST /api/auto-update/execute', () => {
expect(res.body.error).toMatch(/Missing "target"/);
});
it('rejects an empty targets array with 400', async () => {
const res = await request(app)
.post('/api/auto-update/execute')
.set('Cookie', adminCookie)
.send({ targets: [] });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/non-empty array/);
});
it('rejects invalid names in targets with 400', async () => {
const res = await request(app)
.post('/api/auto-update/execute')
.set('Cookie', adminCookie)
.send({ targets: ['ok-stack', '../bad'] });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/Invalid stack name/);
});
it('accepts targets[] and returns a per-stack summary string', async () => {
const res = await request(app)
.post('/api/auto-update/execute')
.set('Cookie', adminCookie)
.send({ targets: ['missing-a', 'missing-b'] });
expect(res.status).toBe(200);
expect(typeof res.body.result).toBe('string');
expect(res.body.result).toMatch(/missing-a/);
expect(res.body.result).toMatch(/missing-b/);
});
it('rejects invalid stack name with 400', async () => {
const res = await request(app)
.post('/api/auto-update/execute')
+2
View File
@@ -66,6 +66,8 @@ const SCHEMA = `
prune_targets TEXT,
target_services TEXT,
prune_label_filter TEXT,
selector_type TEXT,
selector_value TEXT,
FOREIGN KEY(node_id) REFERENCES nodes(id)
);
@@ -78,7 +78,7 @@ describe('GET /api/scheduled-tasks', () => {
last_error: null,
prune_targets: null,
target_services: null,
prune_label_filter: null,
prune_label_filter: null, selector_type: null, selector_value: null,
});
const res = await request(app).get('/api/scheduled-tasks?window_hours=48').set('Cookie', adminCookie);
@@ -108,7 +108,7 @@ describe('GET /api/scheduled-tasks', () => {
last_error: null,
prune_targets: null,
target_services: null,
prune_label_filter: null,
prune_label_filter: null, selector_type: null, selector_value: null,
});
db.createScheduledTask({
name: 'daily-snapshot',
@@ -127,7 +127,7 @@ describe('GET /api/scheduled-tasks', () => {
last_error: null,
prune_targets: null,
target_services: null,
prune_label_filter: null,
prune_label_filter: null, selector_type: null, selector_value: null,
});
db.createScheduledTask({
name: 'system-prune',
@@ -146,7 +146,7 @@ describe('GET /api/scheduled-tasks', () => {
last_error: null,
prune_targets: JSON.stringify(['images']),
target_services: null,
prune_label_filter: null,
prune_label_filter: null, selector_type: null, selector_value: null,
});
const res = await request(app).get('/api/scheduled-tasks').set('Cookie', adminCookie);
@@ -440,7 +440,7 @@ describe('GET /api/scheduled-tasks/:id', () => {
name: 't', target_type: 'stack', target_id: 's', node_id: 1, action: 'update',
cron_expression: '0 3 * * *', enabled: 1, created_by: 'admin', created_at: now, updated_at: now,
last_run_at: null, next_run_at: null, last_status: null, last_error: null,
prune_targets: null, target_services: null, prune_label_filter: null,
prune_targets: null, target_services: null, prune_label_filter: null, selector_type: null, selector_value: null,
});
});
@@ -468,7 +468,7 @@ describe('PATCH /api/scheduled-tasks/:id/toggle', () => {
name: 't', target_type: 'stack', target_id: 's', node_id: 1, action: 'update',
cron_expression: '0 3 * * *', enabled: 1, created_by: 'admin', created_at: now, updated_at: now,
last_run_at: null, next_run_at: now + 1000, last_status: null, last_error: null,
prune_targets: null, target_services: null, prune_label_filter: null,
prune_targets: null, target_services: null, prune_label_filter: null, selector_type: null, selector_value: null,
});
const off = await request(app).patch(`/api/scheduled-tasks/${id}/toggle`).set('Cookie', adminCookie);
@@ -490,7 +490,7 @@ describe('PATCH /api/scheduled-tasks/:id/toggle', () => {
name: 'once', target_type: 'stack', target_id: 's', node_id: 1, action: 'auto_backup',
cron_expression: '0 23 1 7 *', enabled: 1, created_by: 'admin', created_at: now, updated_at: now,
last_run_at: null, next_run_at: pinned, last_status: null, last_error: null,
prune_targets: null, target_services: null, prune_label_filter: null, delete_after_run: 1, run_at: pinned,
prune_targets: null, target_services: null, prune_label_filter: null, selector_type: null, selector_value: null, delete_after_run: 1, run_at: pinned,
});
const off = await request(app).patch(`/api/scheduled-tasks/${id}/toggle`).set('Cookie', adminCookie);
@@ -515,7 +515,7 @@ describe('DELETE /api/scheduled-tasks/:id', () => {
name: 't', target_type: 'stack', target_id: 's', node_id: 1, action: 'update',
cron_expression: '0 3 * * *', enabled: 1, created_by: 'admin', created_at: now, updated_at: now,
last_run_at: null, next_run_at: null, last_status: null, last_error: null,
prune_targets: null, target_services: null, prune_label_filter: null,
prune_targets: null, target_services: null, prune_label_filter: null, selector_type: null, selector_value: null,
});
const del = await request(app).delete(`/api/scheduled-tasks/${id}`).set('Cookie', adminCookie);
@@ -534,7 +534,7 @@ describe('GET /api/scheduled-tasks/:id/runs', () => {
name: 't', target_type: 'stack', target_id: 's', node_id: 1, action: 'update',
cron_expression: '0 3 * * *', enabled: 1, created_by: 'admin', created_at: now, updated_at: now,
last_run_at: null, next_run_at: null, last_status: null, last_error: null,
prune_targets: null, target_services: null, prune_label_filter: null,
prune_targets: null, target_services: null, prune_label_filter: null, selector_type: null, selector_value: null,
});
const res = await request(app).get(`/api/scheduled-tasks/${id}/runs`).set('Cookie', adminCookie);
@@ -712,7 +712,7 @@ describe('PUT /api/scheduled-tasks/:id - delete_after_run', () => {
name: 't', target_type: 'stack', target_id: 's', node_id: 1, action: 'auto_backup',
cron_expression: '0 3 * * *', enabled: 1, created_by: 'admin', created_at: now, updated_at: now,
last_run_at: null, next_run_at: null, last_status: null, last_error: null,
prune_targets: null, target_services: null, prune_label_filter: null, delete_after_run: 0,
prune_targets: null, target_services: null, prune_label_filter: null, selector_type: null, selector_value: null, delete_after_run: 0,
});
const res = await request(app)
@@ -736,7 +736,7 @@ describe('PUT /api/scheduled-tasks/:id - delete_after_run', () => {
name: 'once', target_type: 'stack', target_id: 's', node_id: 1, action: 'auto_backup',
cron_expression: '0 23 1 7 *', enabled: 1, created_by: 'admin', created_at: now, updated_at: now,
last_run_at: null, next_run_at: now + 1000, last_status: null, last_error: null,
prune_targets: null, target_services: null, prune_label_filter: null, delete_after_run: 1,
prune_targets: null, target_services: null, prune_label_filter: null, selector_type: null, selector_value: null, delete_after_run: 1,
});
const runAt = new Date(new Date().getFullYear() + 1, 6, 1, 23, 0, 0, 0).getTime();
@@ -757,7 +757,7 @@ describe('PUT /api/scheduled-tasks/:id - delete_after_run', () => {
name: 'once', target_type: 'stack', target_id: 's', node_id: 1, action: 'auto_backup',
cron_expression: '0 23 1 7 *', enabled: 1, created_by: 'admin', created_at: now, updated_at: now,
last_run_at: null, next_run_at: pinned, last_status: null, last_error: null,
prune_targets: null, target_services: null, prune_label_filter: null, delete_after_run: 1, run_at: pinned,
prune_targets: null, target_services: null, prune_label_filter: null, selector_type: null, selector_value: null, delete_after_run: 1, run_at: pinned,
});
// Editing to a recurring daily schedule sends run_at: null (the frontend
@@ -780,7 +780,7 @@ describe('PUT /api/scheduled-tasks/:id - delete_after_run', () => {
name: 'once', target_type: 'stack', target_id: 's', node_id: 1, action: 'auto_backup',
cron_expression: '0 23 1 7 *', enabled: 1, created_by: 'admin', created_at: now, updated_at: now,
last_run_at: null, next_run_at: pinned, last_status: null, last_error: null,
prune_targets: null, target_services: null, prune_label_filter: null, delete_after_run: 1, run_at: pinned,
prune_targets: null, target_services: null, prune_label_filter: null, selector_type: null, selector_value: null, delete_after_run: 1, run_at: pinned,
});
// Disabling via Save: next_run_at clears, but the pinned instant is retained
@@ -800,7 +800,7 @@ describe('PUT /api/scheduled-tasks/:id - delete_after_run', () => {
name: 'once', target_type: 'stack', target_id: 's', node_id: 1, action: 'auto_backup',
cron_expression: '0 23 1 7 *', enabled: 1, created_by: 'admin', created_at: now, updated_at: now,
last_run_at: null, next_run_at: now + 1000, last_status: null, last_error: null,
prune_targets: null, target_services: null, prune_label_filter: null, delete_after_run: 1,
prune_targets: null, target_services: null, prune_label_filter: null, selector_type: null, selector_value: null, delete_after_run: 1,
});
const res = await request(app)
@@ -821,7 +821,7 @@ describe('PUT /api/scheduled-tasks/:id - stack target validation', () => {
name: 't', target_type: 'stack', target_id: 's', node_id: 1, action: 'update',
cron_expression: '0 3 * * *', enabled: 1, created_by: 'admin', created_at: now, updated_at: now,
last_run_at: null, next_run_at: null, last_status: null, last_error: null,
prune_targets: null, target_services: null, prune_label_filter: null, delete_after_run: 0,
prune_targets: null, target_services: null, prune_label_filter: null, selector_type: null, selector_value: null, delete_after_run: 0,
});
});
@@ -862,7 +862,7 @@ describe('PUT /api/scheduled-tasks/:id - stack target validation', () => {
name: 'prune', target_type: 'system', target_id: null, node_id: 1, action: 'prune',
cron_expression: '0 4 * * *', enabled: 1, created_by: 'admin', created_at: now, updated_at: now,
last_run_at: null, next_run_at: null, last_status: null, last_error: null,
prune_targets: null, target_services: null, prune_label_filter: null,
prune_targets: null, target_services: null, prune_label_filter: null, selector_type: null, selector_value: null,
});
const res = await request(app)
@@ -881,7 +881,7 @@ describe('PUT /api/scheduled-tasks/:id - stack target validation', () => {
name: 'prune', target_type: 'system', target_id: null, node_id: 1, action: 'prune',
cron_expression: '0 4 * * *', enabled: 1, created_by: 'admin', created_at: now, updated_at: now,
last_run_at: null, next_run_at: null, last_status: null, last_error: null,
prune_targets: null, target_services: null, prune_label_filter: null,
prune_targets: null, target_services: null, prune_label_filter: null, selector_type: null, selector_value: null,
});
const remoteNodeId = db.addNode({
name: 'remote-prune-update-node', type: 'remote', api_url: 'http://remote.local:1852',
@@ -937,7 +937,7 @@ describe('PUT /api/scheduled-tasks/:id - stack target validation', () => {
last_error: null,
prune_targets: null,
target_services: JSON.stringify(['api']),
prune_label_filter: null,
prune_label_filter: null, selector_type: null, selector_value: null,
delete_after_run: 0,
});
@@ -966,7 +966,7 @@ describe('PUT /api/scheduled-tasks/:id - cron validation', () => {
name: 't', target_type: 'stack', target_id: 's', node_id: 1, action: 'update',
cron_expression: '0 3 * * *', enabled: 1, created_by: 'admin', created_at: now, updated_at: now,
last_run_at: null, next_run_at: null, last_status: null, last_error: null,
prune_targets: null, target_services: null, prune_label_filter: null, delete_after_run: 0,
prune_targets: null, target_services: null, prune_label_filter: null, selector_type: null, selector_value: null, delete_after_run: 0,
});
});
@@ -1087,3 +1087,83 @@ describe('scheduled-tasks state-invalidate broadcast', () => {
}
});
});
describe('POST/PUT /api/scheduled-tasks - stack-label selector', () => {
const labelPayload = {
name: 'label-update',
target_type: 'fleet',
target_id: null,
node_id: null,
action: 'update',
cron_expression: '0 3 * * *',
enabled: true,
selector_type: 'stack-label',
selector_value: 'production',
};
it('creates a fleet-wide label update with node_id null', async () => {
const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send(labelPayload);
expect(res.status).toBe(201);
expect(res.body.selector_type).toBe('stack-label');
expect(res.body.selector_value).toBe('production');
expect(res.body.node_id).toBeNull();
expect(res.body.action).toBe('update');
expect(res.body.target_type).toBe('fleet');
});
it('persists selector_value changes through PUT (column map)', async () => {
const create = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send(labelPayload);
expect(create.status).toBe(201);
const id = create.body.id as number;
const put = await request(app)
.put(`/api/scheduled-tasks/${id}`)
.set('Cookie', adminCookie)
.send({ selector_type: 'stack-label', selector_value: 'staging' });
expect(put.status).toBe(200);
expect(put.body.selector_value).toBe('staging');
const get = await request(app).get(`/api/scheduled-tasks/${id}`).set('Cookie', adminCookie);
expect(get.status).toBe(200);
expect(get.body.selector_value).toBe('staging');
});
it('still requires node_id for non-selector fleet updates', async () => {
const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({
name: 'fleet-no-node',
target_type: 'fleet',
action: 'update',
cron_expression: '0 3 * * *',
node_id: null,
});
expect(res.status).toBe(400);
expect(res.body.error).toBe('Fleet update requires node_id.');
});
it('rejects selector fields on unsupported actions', async () => {
const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({
name: 'restart-with-selector',
target_type: 'stack',
target_id: 'my-stack',
node_id: 1,
action: 'restart',
cron_expression: '0 3 * * *',
selector_type: 'stack-label',
selector_value: 'prod',
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/selector fields can only be used with update action on fleet target/);
});
it('creates a node-scoped label update when node_id is set', async () => {
const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({
...labelPayload,
name: 'label-update-node',
node_id: 1,
selector_value: 'Databases',
});
expect(res.status).toBe(201);
expect(res.body.node_id).toBe(1);
expect(res.body.selector_value).toBe('Databases');
});
});
@@ -1875,7 +1875,7 @@ function makeLifecycleTask(action: ScheduledTask['action'], overrides: Partial<S
last_error: null,
prune_targets: null,
target_services: null,
prune_label_filter: null,
prune_label_filter: null, selector_type: null, selector_value: null,
delete_after_run: 0,
...overrides,
};
+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
+11 -2
View File
@@ -714,6 +714,10 @@ export interface ScheduledTask {
prune_targets: string | null;
target_services: string | null;
prune_label_filter: string | null;
/** Optional dynamic target selector; currently only 'stack-label'. */
selector_type: string | null;
/** Selector payload (e.g. Stack Label name when selector_type is stack-label). */
selector_value: string | null;
delete_after_run?: number;
// Absolute epoch-ms fire time for a one-time ('once') schedule. A 5-field
// cron has no year field, so the chosen instant (including year) is persisted
@@ -1936,6 +1940,8 @@ export class DatabaseService {
maybeAddCol('scheduled_tasks', 'prune_targets', 'TEXT DEFAULT NULL');
maybeAddCol('scheduled_tasks', 'target_services', 'TEXT DEFAULT NULL');
maybeAddCol('scheduled_tasks', 'prune_label_filter', 'TEXT DEFAULT NULL');
maybeAddCol('scheduled_tasks', 'selector_type', 'TEXT DEFAULT NULL');
maybeAddCol('scheduled_tasks', 'selector_value', 'TEXT DEFAULT NULL');
maybeAddCol('scheduled_tasks', 'delete_after_run', 'INTEGER DEFAULT 0');
maybeAddCol('scheduled_tasks', 'run_at', 'INTEGER DEFAULT NULL');
@@ -6041,13 +6047,14 @@ export class DatabaseService {
public createScheduledTask(task: Omit<ScheduledTask, 'id'>): number {
const result = this.db.prepare(
'INSERT INTO scheduled_tasks (name, target_type, target_id, node_id, action, cron_expression, enabled, created_by, created_at, updated_at, last_run_at, next_run_at, last_status, last_error, prune_targets, target_services, prune_label_filter, delete_after_run, run_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
'INSERT INTO scheduled_tasks (name, target_type, target_id, node_id, action, cron_expression, enabled, created_by, created_at, updated_at, last_run_at, next_run_at, last_status, last_error, prune_targets, target_services, prune_label_filter, selector_type, selector_value, delete_after_run, run_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
).run(
task.name, task.target_type, task.target_id, task.node_id,
task.action, task.cron_expression, task.enabled, task.created_by,
task.created_at, task.updated_at, task.last_run_at, task.next_run_at,
task.last_status, task.last_error, task.prune_targets, task.target_services,
task.prune_label_filter, task.delete_after_run ?? 0, task.run_at ?? null
task.prune_label_filter, task.selector_type ?? null, task.selector_value ?? null,
task.delete_after_run ?? 0, task.run_at ?? null
);
return result.lastInsertRowid as number;
}
@@ -6064,6 +6071,8 @@ export class DatabaseService {
last_status: updates.last_status, last_error: updates.last_error,
prune_targets: updates.prune_targets, target_services: updates.target_services,
prune_label_filter: updates.prune_label_filter,
selector_type: updates.selector_type,
selector_value: updates.selector_value,
delete_after_run: updates.delete_after_run,
run_at: updates.run_at,
};
+187
View File
@@ -739,6 +739,10 @@ export class SchedulerService {
}
private async executeUpdate(task: ScheduledTask): Promise<string> {
if (task.selector_type === 'stack-label') {
return this.executeUpdateByStackLabel(task);
}
if (task.node_id == null) {
throw new Error('Auto-update requires node_id');
}
@@ -791,6 +795,140 @@ export class SchedulerService {
return results.join('\n');
}
/**
* Resolve live stack-label membership (fleet-wide or one node) and run the
* existing per-stack auto-update path. Remotes receive an explicit stack
* list; they do not evaluate the selector themselves.
*/
private async executeUpdateByStackLabel(task: ScheduledTask): Promise<string> {
const labelName = (task.selector_value ?? '').trim();
if (!labelName) {
throw new Error('Label-targeted auto-update requires selector_value');
}
const { collectFleetLabelSummaries } = await import('../helpers/fleetLabelSummary');
let summaries = await collectFleetLabelSummaries();
if (task.node_id != null) {
summaries = summaries.filter(s => s.nodeId === task.node_id);
if (summaries.length === 0) {
throw new Error(`Target node (id=${task.node_id}) no longer exists`);
}
}
type NodePlan = {
nodeId: number;
nodeName: string;
reachable: boolean;
stacks: string[];
error?: string;
};
const plans: NodePlan[] = [];
const seen = new Set<string>();
for (const summary of summaries) {
if (!summary.reachable) {
plans.push({
nodeId: summary.nodeId,
nodeName: summary.nodeName,
reachable: false,
stacks: [],
error: summary.error ?? 'unreachable',
});
continue;
}
const match = summary.labels.find(l => l.name === labelName);
const stacks: string[] = [];
if (match) {
for (const stackName of match.stackNames) {
const key = `${summary.nodeId}\0${stackName}`;
if (seen.has(key)) continue;
seen.add(key);
stacks.push(stackName);
}
}
plans.push({
nodeId: summary.nodeId,
nodeName: summary.nodeName,
reachable: true,
stacks,
});
}
const lines: string[] = [
`Selector: stack-label="${labelName}" · scope=${task.node_id == null ? 'entire fleet' : `node ${task.node_id}`}`,
];
for (const plan of plans) {
if (!plan.reachable) {
lines.push(`Node "${plan.nodeName}" (id=${plan.nodeId}): unreachable (${plan.error})`);
} else if (plan.stacks.length === 0) {
lines.push(`Node "${plan.nodeName}" (id=${plan.nodeId}): no stacks with label "${labelName}"`);
} else {
lines.push(`Node "${plan.nodeName}" (id=${plan.nodeId}): ${plan.stacks.length} stack(s) → ${plan.stacks.join(', ')}`);
}
}
const matchedStacks = plans.reduce((n, p) => n + p.stacks.length, 0);
const unreachableCount = plans.filter(p => !p.reachable).length;
if (matchedStacks === 0 && unreachableCount === 0) {
lines.push(`No stacks currently match label "${labelName}"; skipped.`);
return lines.join('\n');
}
let materialFailure = unreachableCount > 0;
const work = plans.filter(p => p.reachable && p.stacks.length > 0);
const NODE_CONCURRENCY = 3;
// Label-targeted runs fail closed on material stack failures or
// unreachable scoped nodes (unlike plain node fleet update, which
// historically returns failure lines as a successful run output).
const looksLikeStackFailure = (text: string): boolean =>
/^Stack ".+" failed:/m.test(text);
const runNode = async (plan: NodePlan): Promise<void> => {
const node = NodeRegistry.getInstance().getNode(plan.nodeId);
try {
if (node?.type === 'remote') {
const remoteOut = await this.executeUpdateRemoteTargets(plan.nodeId, plan.stacks);
lines.push(`Node "${plan.nodeName}" (id=${plan.nodeId}) results:\n${remoteOut}`);
if (looksLikeStackFailure(remoteOut)) materialFailure = true;
} else {
const docker = DockerController.getInstance(plan.nodeId);
const imageUpdateService = ImageUpdateService.getInstance();
const stackLines: string[] = [];
for (const stackName of plan.stacks) {
try {
const out = await this.executeUpdateForStack(
stackName, plan.nodeId, docker, imageUpdateService, true,
);
stackLines.push(out);
} catch (e) {
materialFailure = true;
const msg = getErrorMessage(e, String(e));
stackLines.push(`Stack "${stackName}" failed: ${msg}`);
console.error(`[SchedulerService] Label auto-update failed for stack "${stackName}" on node ${plan.nodeId}:`, e);
}
}
lines.push(`Node "${plan.nodeName}" (id=${plan.nodeId}) results:\n${stackLines.join('\n')}`);
}
} catch (e) {
materialFailure = true;
const msg = getErrorMessage(e, String(e));
lines.push(`Node "${plan.nodeName}" (id=${plan.nodeId}): failed (${msg})`);
console.error(`[SchedulerService] Label auto-update failed for node ${plan.nodeId}:`, e);
}
};
for (let i = 0; i < work.length; i += NODE_CONCURRENCY) {
const batch = work.slice(i, i + NODE_CONCURRENCY);
await Promise.all(batch.map(runNode));
}
if (materialFailure) {
throw new Error(lines.join('\n'));
}
return lines.join('\n');
}
/**
* Proxy auto-update execution to a remote Sencho instance.
* The remote node runs the image checks and compose update locally.
@@ -829,6 +967,55 @@ export class SchedulerService {
}
}
/** Proxy auto-update for an explicit stack list on a remote node (label selector). */
private async executeUpdateRemoteTargets(nodeId: number, targets: string[]): Promise<string> {
const proxyTarget = this.requireRemoteProxyTarget(nodeId);
const baseUrl = proxyTarget.apiUrl.replace(/\/$/, '');
const proxyHeaders = LicenseService.getInstance().getProxyHeaders();
if (isDebugEnabled()) {
console.log(`[SchedulerService] executeUpdateRemoteTargets: node=${nodeId} count=${targets.length}`);
}
const startTime = Date.now();
try {
const response = await fetch(`${baseUrl}/api/auto-update/execute`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${proxyTarget.apiToken}`,
[PROXY_TIER_HEADER]: proxyHeaders.tier,
},
body: JSON.stringify({ targets }),
signal: AbortSignal.timeout(300_000),
});
// Older remotes only accept { target }. Fall back to one call per
// stack so mixed-version fleets still complete the label schedule.
if (response.status === 400) {
const detail = await this.remoteResponseDetail(response);
if (/target/i.test(detail)) {
const parts: string[] = [];
for (const stackName of targets) {
parts.push(await this.executeUpdateRemote(nodeId, stackName));
}
return parts.join('\n');
}
throw new Error(this.remoteProxyFailureMessage(nodeId, detail));
}
if (!response.ok) {
throw new Error(this.remoteProxyFailureMessage(nodeId, await this.remoteResponseDetail(response)));
}
const body = await response.json() as { result?: string };
if (isDebugEnabled()) {
console.log(`[SchedulerService] executeUpdateRemoteTargets: completed in ${Date.now() - startTime}ms`);
}
return body.result || 'Remote auto-update completed (no details returned).';
} catch (err) {
this.rethrowRemoteProxyError(nodeId, err);
}
}
/**
* Proxy a stack lifecycle action to a remote Sencho instance. ComposeService,
* DockerController, and FileSystemService are local-only, so for a remote node