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,
};