mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 00:47:52 +00:00
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:
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user