feat(scheduler): consistent action targeting in Scheduled Operations (#1431)

Give every scheduled action an explicit, predictable target model
(Action then Node then Stack then Options then Schedule):

- System Prune now exposes a Node picker and requires a node, so it can
  no longer run silently on the default node.
- Vulnerability Scan and System Prune list local nodes only; both run on
  the hub-local Docker daemon and reject remote nodes on the backend.
- Restart Stack service discovery loads services from the selected node
  via fetchForNode instead of the active or local node.
- Fleet Snapshot shows a read-only "Scope: Entire fleet" summary.

Backend gains a shared local-node guard and prune node validation on
create and update, plus an executor-level remote-node guard, so the
frontend and backend validation now agree for every action.
This commit is contained in:
Anso
2026-06-24 21:02:15 -04:00
committed by GitHub
parent 0af7ad1df2
commit bc8c051962
14 changed files with 542 additions and 106 deletions
@@ -24,6 +24,10 @@ vi.mock('../services/NodeRegistry', () => ({
},
}));
vi.mock('../utils/debug', () => ({
isDebugEnabled: () => false,
}));
import { FileSystemService } from '../services/FileSystemService';
describe('FileSystemService backup location', () => {
@@ -85,6 +89,28 @@ describe('FileSystemService backup location', () => {
expect(typeof after.timestamp).toBe('number');
});
it('aborts backup creation when a destination write fails', async () => {
const stackName = 'writefail';
const stackDir = path.join(composeDir, stackName);
const backupCompose = path.join(dataDir, 'backups', '1', stackName, 'compose.yaml');
await fsPromises.mkdir(stackDir, { recursive: true });
await fsPromises.writeFile(path.join(stackDir, 'compose.yaml'), 'services: {}\n', 'utf-8');
const realWriteFile = fsPromises.writeFile.bind(fsPromises);
const writeSpy = vi.spyOn(fsPromises, 'writeFile').mockImplementation(async (file, data, options) => {
if (path.normalize(String(file)) === path.normalize(backupCompose)) {
throw new Error('disk full');
}
return realWriteFile(file, data, options);
});
try {
await expect(FileSystemService.getInstance().backupStackFiles(stackName)).rejects.toThrow(/Could not write backup compose.yaml/);
await expect(fsPromises.access(backupCompose)).rejects.toMatchObject({ code: 'ENOENT' });
} finally {
writeSpy.mockRestore();
}
});
it('scopes backups by node id when stack names overlap', async () => {
const stackName = 'web';
const secondComposeDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'sencho-compose-'));
@@ -8,6 +8,7 @@ import {
VALID_ACTIONS,
BACKEND_SCHEDULED_ACTIONS,
INVALID_ACTION_MESSAGE,
getScheduledActionDefinition,
validateActionTarget,
type BackendScheduledAction,
type TargetType,
@@ -36,6 +37,13 @@ describe('scheduledActionRegistry', () => {
);
});
it('marks node-scoped and local-only actions in backend metadata', () => {
expect(getScheduledActionDefinition('scan')).toMatchObject({ requiresNode: true, nodeScope: 'local' });
expect(getScheduledActionDefinition('prune')).toMatchObject({ requiresNode: true, nodeScope: 'local' });
expect(getScheduledActionDefinition('update')).toMatchObject({ requiresNode: true });
expect(getScheduledActionDefinition('snapshot')).toMatchObject({ requiresNode: false });
});
describe('validateActionTarget', () => {
const validPairs: Record<BackendScheduledAction, TargetType[]> = {
restart: ['stack'],
@@ -215,6 +215,34 @@ describe('POST /api/scheduled-tasks', () => {
expect(res.body.error).toMatch(/Scan action requires node_id/);
});
for (const badNodeId of [true, '1.5', 0]) {
it(`rejects scan with malformed node_id ${JSON.stringify(badNodeId)}`, async () => {
const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({
name: 'bad-scan-node',
target_type: 'system',
node_id: badNodeId,
action: 'scan',
cron_expression: '0 0 * * *',
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/valid node_id|node_id/);
});
}
for (const badNodeId of [true, '1.5', 0]) {
it(`rejects fleet update with malformed node_id ${JSON.stringify(badNodeId)}`, async () => {
const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({
name: 'bad-fleet-update-node',
target_type: 'fleet',
node_id: badNodeId,
action: 'update',
cron_expression: '0 0 * * *',
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/valid node_id|node_id/);
});
}
it('rejects scheduled scans on remote nodes', async () => {
const remoteNodeId = DatabaseService.getInstance().addNode({
name: 'remote-scan-node',
@@ -236,6 +264,45 @@ describe('POST /api/scheduled-tasks', () => {
expect(res.body.error).toMatch(/local node/i);
});
it('rejects prune without node_id', async () => {
const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({
name: 'cleanup', target_type: 'system', action: 'prune', cron_expression: '0 4 * * *',
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/Prune action requires node_id/);
});
it('rejects scheduled prunes on remote nodes', async () => {
const remoteNodeId = DatabaseService.getInstance().addNode({
name: 'remote-prune-node',
type: 'remote',
api_url: 'http://remote.local:1852',
api_token: 'token',
compose_dir: '/srv/compose',
is_default: false,
});
const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({
name: 'remote-prune',
target_type: 'system',
node_id: remoteNodeId,
action: 'prune',
cron_expression: '0 4 * * *',
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/local node/i);
});
it('creates a prune task on a local node', async () => {
const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({
name: 'local-prune', target_type: 'system', node_id: 1, action: 'prune',
cron_expression: '0 4 * * *', enabled: true,
});
expect(res.status).toBe(201);
expect(res.body.action).toBe('prune');
expect(res.body.node_id).toBe(1);
});
it('rejects target_services with wrong action', async () => {
const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({
...basePayload, action: 'update', target_services: ['web'],
@@ -528,6 +595,48 @@ describe('PUT /api/scheduled-tasks/:id - stack target validation', () => {
expect(res.body.error).toMatch(/node_id/);
});
it('rejects updates that clear node_id on a prune task', async () => {
const db = DatabaseService.getInstance();
const now = Date.now();
const pruneId = db.createScheduledTask({
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,
});
const res = await request(app)
.put(`/api/scheduled-tasks/${pruneId}`)
.set('Cookie', adminCookie)
.send({ node_id: null });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/Prune action requires node_id/);
});
it('rejects updates that point a prune task at a remote node', async () => {
const db = DatabaseService.getInstance();
const now = Date.now();
const pruneId = db.createScheduledTask({
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,
});
const remoteNodeId = db.addNode({
name: 'remote-prune-update-node', type: 'remote', api_url: 'http://remote.local:1852',
api_token: 'token', compose_dir: '/srv/compose', is_default: false,
});
const res = await request(app)
.put(`/api/scheduled-tasks/${pruneId}`)
.set('Cookie', adminCookie)
.send({ node_id: remoteNodeId });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/local node/i);
});
it('rejects updates that clear target_type', async () => {
const res = await request(app)
.put(`/api/scheduled-tasks/${taskId}`)
@@ -547,6 +656,45 @@ describe('PUT /api/scheduled-tasks/:id - stack target validation', () => {
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/Invalid action/);
});
it('clears stale stack and service fields when changing a task to a fleet snapshot', async () => {
const db = DatabaseService.getInstance();
const now = Date.now();
const restartId = db.createScheduledTask({
name: 'restart',
target_type: 'stack',
target_id: 'web',
node_id: 1,
action: 'restart',
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: JSON.stringify(['api']),
prune_label_filter: null,
delete_after_run: 0,
});
const res = await request(app)
.put(`/api/scheduled-tasks/${restartId}`)
.set('Cookie', adminCookie)
.send({ action: 'snapshot', target_type: 'fleet' });
expect(res.status).toBe(200);
expect(res.body.action).toBe('snapshot');
expect(res.body.target_type).toBe('fleet');
expect(res.body.target_id).toBeNull();
expect(res.body.node_id).toBeNull();
expect(res.body.target_services).toBeNull();
expect(res.body.prune_targets).toBeNull();
expect(res.body.prune_label_filter).toBeNull();
});
});
describe('scheduled-tasks state-invalidate broadcast', () => {
@@ -594,6 +594,32 @@ describe('SchedulerService - executePrune', () => {
expect(mockPruneSystem).toHaveBeenCalledWith('containers', 'env=staging');
});
it('fails scheduled prune tasks that target remote nodes before pruning', async () => {
mockGetScheduledTask.mockReturnValue({
id: 73,
name: 'remote-prune',
action: 'prune',
cron_expression: '0 3 * * *',
enabled: true,
node_id: 2,
created_by: 'admin',
last_status: null,
});
mockGetNode.mockReturnValue({ id: 2, name: 'remote', type: 'remote', status: 'online' });
const svc = SchedulerService.getInstance();
await svc.triggerTask(73);
expect(mockPruneSystem).not.toHaveBeenCalled();
expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith(
expect.any(Number),
expect.objectContaining({
status: 'failure',
error: expect.stringMatching(/local node/i),
}),
);
});
});
// ── executeUpdate ──────────────────────────────────────────────────────