mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-19 23:06:49 +00:00
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:
@@ -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 ──────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user