mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +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 ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
VALID_ACTIONS,
|
||||
INVALID_ACTION_MESSAGE,
|
||||
validateActionTarget,
|
||||
getScheduledActionDefinition,
|
||||
type TargetType,
|
||||
type BackendScheduledAction,
|
||||
} from '../services/scheduledActionRegistry';
|
||||
@@ -35,6 +36,31 @@ function broadcastScheduledTasksChanged(): void {
|
||||
const VALID_PRUNE_TARGETS = ['containers', 'images', 'networks', 'volumes'] as const;
|
||||
const ERR_FLEET_NODE_REQUIRED = 'Fleet update requires node_id.';
|
||||
|
||||
function parsePositiveNodeId(nodeId: unknown): number | null {
|
||||
if (typeof nodeId !== 'number' && typeof nodeId !== 'string') return null;
|
||||
if (typeof nodeId === 'string' && nodeId.trim().length === 0) return null;
|
||||
const parsedNodeId = Number(nodeId);
|
||||
return Number.isInteger(parsedNodeId) && parsedNodeId > 0 ? parsedNodeId : null;
|
||||
}
|
||||
|
||||
function actionRequiresNode(action: BackendScheduledAction, targetType: TargetType): boolean {
|
||||
if (targetType === 'stack') return true;
|
||||
return getScheduledActionDefinition(action)?.requiresNode === true;
|
||||
}
|
||||
|
||||
function nodeRequirementLabel(action: BackendScheduledAction, targetType: TargetType): string {
|
||||
if (action === 'scan') return 'Scan';
|
||||
if (action === 'prune') return 'Prune';
|
||||
if (action === 'update' && targetType === 'fleet') return 'Fleet update';
|
||||
return action;
|
||||
}
|
||||
|
||||
function localNodeRequirementLabel(action: BackendScheduledAction): string {
|
||||
if (action === 'scan') return 'Scheduled vulnerability scans';
|
||||
if (action === 'prune') return 'Scheduled prunes';
|
||||
return `${action} tasks`;
|
||||
}
|
||||
|
||||
function validateStackTarget(targetType: TargetType, targetId: unknown, nodeId: unknown): string | null {
|
||||
if (targetType !== 'stack') return null;
|
||||
|
||||
@@ -46,23 +72,38 @@ function validateStackTarget(targetType: TargetType, targetId: unknown, nodeId:
|
||||
return 'Stack target_id must be a valid stack name.';
|
||||
}
|
||||
|
||||
const parsedNodeId = Number(nodeId);
|
||||
if (!Number.isInteger(parsedNodeId) || parsedNodeId <= 0) {
|
||||
if (parsePositiveNodeId(nodeId) === null) {
|
||||
return 'Stack operations require a valid node_id.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateScanNode(nodeId: unknown): string | null {
|
||||
if (nodeId == null) return 'Scan action requires node_id.';
|
||||
const parsedNodeId = Number(nodeId);
|
||||
if (!Number.isFinite(parsedNodeId)) return 'Scan action requires a valid node_id.';
|
||||
const node = DatabaseService.getInstance().getNode(parsedNodeId);
|
||||
if (!node) return 'Scheduled vulnerability scans require an existing local node.';
|
||||
if (node?.type === 'remote') {
|
||||
return 'Scheduled vulnerability scans currently require a local node.';
|
||||
/**
|
||||
* Shared guard for non-stack actions that require a node. Stack actions use
|
||||
* validateStackTarget because they also require target_id.
|
||||
*/
|
||||
function validateActionNode(action: BackendScheduledAction, targetType: TargetType, nodeId: unknown): string | null {
|
||||
if (targetType === 'stack') return null;
|
||||
const def = getScheduledActionDefinition(action);
|
||||
if (!def?.requiresNode) return null;
|
||||
|
||||
const labelSingular = nodeRequirementLabel(action, targetType);
|
||||
const labelPlural = localNodeRequirementLabel(action);
|
||||
|
||||
if (nodeId == null) {
|
||||
return action === 'update' && targetType === 'fleet'
|
||||
? ERR_FLEET_NODE_REQUIRED
|
||||
: `${labelSingular} action requires node_id.`;
|
||||
}
|
||||
|
||||
const parsedNodeId = parsePositiveNodeId(nodeId);
|
||||
if (parsedNodeId === null) return `${labelSingular} action requires a valid node_id.`;
|
||||
if (def.nodeScope !== 'local') return null;
|
||||
|
||||
const node = DatabaseService.getInstance().getNode(parsedNodeId);
|
||||
if (!node) return `${labelPlural} require an existing local node.`;
|
||||
if (node.type === 'remote') return `${labelPlural} currently require a local node.`;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -153,16 +194,8 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => {
|
||||
const targetErr = validateActionTarget(action, target_type);
|
||||
if (targetErr) { res.status(400).json({ error: targetErr }); return; }
|
||||
|
||||
if (action === 'scan' && !node_id) {
|
||||
res.status(400).json({ error: 'Scan action requires node_id.' }); return;
|
||||
}
|
||||
if (action === 'scan') {
|
||||
const nodeErr = validateScanNode(node_id);
|
||||
if (nodeErr) { res.status(400).json({ error: nodeErr }); return; }
|
||||
}
|
||||
if (action === 'update' && target_type === 'fleet' && !node_id) {
|
||||
res.status(400).json({ error: ERR_FLEET_NODE_REQUIRED }); return;
|
||||
}
|
||||
const nodeErr = validateActionNode(action, target_type, node_id);
|
||||
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; }
|
||||
|
||||
@@ -177,12 +210,14 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => {
|
||||
const scheduler = SchedulerService.getInstance();
|
||||
const now = Date.now();
|
||||
const nextRun = (enabled !== false) ? scheduler.calculateNextRun(cron_expression) : null;
|
||||
const normalizedTargetId = target_type === 'stack' ? target_id : null;
|
||||
const normalizedNodeId = actionRequiresNode(action, target_type) ? parsePositiveNodeId(node_id) : null;
|
||||
|
||||
const id = DatabaseService.getInstance().createScheduledTask({
|
||||
name: name.trim(),
|
||||
target_type,
|
||||
target_id: target_id || null,
|
||||
node_id: node_id != null ? Number(node_id) : null,
|
||||
target_id: normalizedTargetId,
|
||||
node_id: normalizedNodeId,
|
||||
action,
|
||||
cron_expression,
|
||||
enabled: enabled !== false ? 1 : 0,
|
||||
@@ -193,9 +228,9 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => {
|
||||
next_run_at: nextRun,
|
||||
last_status: null,
|
||||
last_error: null,
|
||||
prune_targets: prune_targets ? JSON.stringify(prune_targets) : null,
|
||||
target_services: target_services ? JSON.stringify(target_services) : null,
|
||||
prune_label_filter: prune_label_filter ? prune_label_filter.trim() : null,
|
||||
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,
|
||||
delete_after_run: delete_after_run ? 1 : 0,
|
||||
});
|
||||
|
||||
@@ -244,20 +279,17 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => {
|
||||
|
||||
const finalAction = (action ?? existing.action) as BackendScheduledAction;
|
||||
const finalTargetType = (target_type ?? existing.target_type) as TargetType;
|
||||
const finalTargetId = target_id !== undefined ? target_id : existing.target_id;
|
||||
const finalNodeId = node_id !== undefined ? node_id : existing.node_id;
|
||||
const finalTargetId = finalTargetType === 'stack'
|
||||
? (target_id !== undefined ? target_id : existing.target_id)
|
||||
: null;
|
||||
const finalNodeId = actionRequiresNode(finalAction, finalTargetType)
|
||||
? (node_id !== undefined ? node_id : existing.node_id)
|
||||
: null;
|
||||
const targetErr = validateActionTarget(finalAction, finalTargetType);
|
||||
if (targetErr) { res.status(400).json({ error: targetErr }); return; }
|
||||
|
||||
if (finalAction === 'scan') {
|
||||
const nodeErr = validateScanNode(finalNodeId);
|
||||
if (nodeErr) { res.status(400).json({ error: nodeErr }); return; }
|
||||
}
|
||||
if (finalAction === 'update' && finalTargetType === 'fleet') {
|
||||
if (!finalNodeId) {
|
||||
res.status(400).json({ error: ERR_FLEET_NODE_REQUIRED }); return;
|
||||
}
|
||||
}
|
||||
const nodeErr = validateActionNode(finalAction, finalTargetType, finalNodeId);
|
||||
if (nodeErr) { res.status(400).json({ error: nodeErr }); return; }
|
||||
|
||||
const stackTargetErr = validateStackTarget(finalTargetType, finalTargetId, finalNodeId);
|
||||
if (stackTargetErr) { res.status(400).json({ error: stackTargetErr }); return; }
|
||||
@@ -274,15 +306,31 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => {
|
||||
|
||||
const updates: Record<string, unknown> = { updated_at: Date.now() };
|
||||
if (name !== undefined) updates.name = typeof name === 'string' ? name.trim() : name;
|
||||
if (target_type !== undefined) updates.target_type = target_type;
|
||||
if (target_id !== undefined) updates.target_id = target_id || null;
|
||||
if (node_id !== undefined) updates.node_id = node_id != null ? Number(node_id) : null;
|
||||
if (action !== undefined) updates.action = action;
|
||||
if (target_type !== undefined) updates.target_type = finalTargetType;
|
||||
if (target_id !== undefined || finalTargetType !== 'stack') updates.target_id = finalTargetId || null;
|
||||
if (node_id !== undefined || !actionRequiresNode(finalAction, finalTargetType)) {
|
||||
updates.node_id = finalNodeId != null ? parsePositiveNodeId(finalNodeId) : null;
|
||||
}
|
||||
if (action !== undefined) updates.action = finalAction;
|
||||
if (cron_expression !== undefined) updates.cron_expression = cron_expression;
|
||||
if (enabled !== undefined) updates.enabled = enabled ? 1 : 0;
|
||||
if (prune_targets !== undefined) updates.prune_targets = prune_targets ? JSON.stringify(prune_targets) : null;
|
||||
if (target_services !== undefined) updates.target_services = target_services ? JSON.stringify(target_services) : null;
|
||||
if (prune_label_filter !== undefined) updates.prune_label_filter = prune_label_filter ? prune_label_filter.trim() : null;
|
||||
if (prune_targets !== undefined) {
|
||||
updates.prune_targets = finalAction === 'prune' && prune_targets ? JSON.stringify(prune_targets) : null;
|
||||
} else if (finalAction !== 'prune') {
|
||||
updates.prune_targets = null;
|
||||
}
|
||||
if (target_services !== undefined) {
|
||||
updates.target_services = finalAction === 'restart' && finalTargetType === 'stack' && target_services
|
||||
? JSON.stringify(target_services)
|
||||
: null;
|
||||
} else if (finalAction !== 'restart' || finalTargetType !== 'stack') {
|
||||
updates.target_services = null;
|
||||
}
|
||||
if (prune_label_filter !== undefined) {
|
||||
updates.prune_label_filter = finalAction === 'prune' && prune_label_filter ? prune_label_filter.trim() : null;
|
||||
} else if (finalAction !== 'prune') {
|
||||
updates.prune_label_filter = null;
|
||||
}
|
||||
if (delete_after_run !== undefined) updates.delete_after_run = delete_after_run ? 1 : 0;
|
||||
|
||||
const finalCron = cron_expression || existing.cron_expression;
|
||||
|
||||
@@ -986,22 +986,39 @@ export class FileSystemService {
|
||||
// credit the barrier, which it does not through the helper.
|
||||
const baseResolved = path.resolve(this.baseDir);
|
||||
const checksums: Record<string, string> = {};
|
||||
const writeManagedBackupFile = async (file: string, src: string): Promise<void> => {
|
||||
let buf: Buffer;
|
||||
try {
|
||||
buf = await fsPromises.readFile(src);
|
||||
} catch (e: unknown) {
|
||||
const code = (e as NodeJS.ErrnoException)?.code;
|
||||
if (code !== 'ENOENT') {
|
||||
console.warn(`[FileSystemService] Could not read ${file} for backup:`, (e as Error).message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const dest = path.join(backupDir, file);
|
||||
try {
|
||||
await fsPromises.writeFile(dest, buf);
|
||||
} catch (e: unknown) {
|
||||
try {
|
||||
await fsPromises.unlink(dest);
|
||||
} catch {
|
||||
// Best-effort cleanup only. The write failure below is the actionable error.
|
||||
}
|
||||
throw new Error(`Could not write backup ${file}: ${(e as Error).message}`, { cause: e });
|
||||
}
|
||||
checksums[file] = sha256HexBuffer(buf);
|
||||
};
|
||||
|
||||
const composeFiles = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'];
|
||||
for (const file of composeFiles) {
|
||||
const src = path.resolve(baseResolved, path.join(stackDir, file));
|
||||
if (!src.startsWith(baseResolved + path.sep)) {
|
||||
throw Object.assign(new Error('Path escapes compose directory'), { code: 'INVALID_PATH' });
|
||||
}
|
||||
try {
|
||||
const buf = await fsPromises.readFile(src);
|
||||
await fsPromises.writeFile(path.join(backupDir, file), buf);
|
||||
checksums[file] = sha256HexBuffer(buf);
|
||||
} catch (e: unknown) {
|
||||
const code = (e as NodeJS.ErrnoException)?.code;
|
||||
if (code !== 'ENOENT') {
|
||||
console.warn(`[FileSystemService] Could not back up ${file}:`, (e as Error).message);
|
||||
}
|
||||
}
|
||||
await writeManagedBackupFile(file, src);
|
||||
}
|
||||
|
||||
// Copy .env if it exists (same inline containment barrier as above).
|
||||
@@ -1009,16 +1026,7 @@ export class FileSystemService {
|
||||
if (!envSrc.startsWith(baseResolved + path.sep)) {
|
||||
throw Object.assign(new Error('Path escapes compose directory'), { code: 'INVALID_PATH' });
|
||||
}
|
||||
try {
|
||||
const buf = await fsPromises.readFile(envSrc);
|
||||
await fsPromises.writeFile(path.join(backupDir, '.env'), buf);
|
||||
checksums['.env'] = sha256HexBuffer(buf);
|
||||
} catch (e: unknown) {
|
||||
const code = (e as NodeJS.ErrnoException)?.code;
|
||||
if (code !== 'ENOENT') {
|
||||
console.warn('[FileSystemService] Could not back up .env:', (e as Error).message);
|
||||
}
|
||||
}
|
||||
await writeManagedBackupFile('.env', envSrc);
|
||||
|
||||
// Write the integrity manifest before the timestamp marker, so a crash
|
||||
// between the two leaves the checksums present (a backup that restore can
|
||||
|
||||
@@ -662,6 +662,9 @@ export class SchedulerService {
|
||||
if (task.node_id == null && isDebugEnabled()) {
|
||||
console.log(`[SchedulerService:debug] Prune task ${task.id}: no node_id specified, using default node ${nodeId}`);
|
||||
}
|
||||
if (this.isRemoteNode(nodeId)) {
|
||||
throw new Error('Scheduled prunes currently require a local node.');
|
||||
}
|
||||
const docker = DockerController.getInstance(nodeId);
|
||||
const allTargets = ['containers', 'images', 'networks', 'volumes'] as const;
|
||||
type PruneTarget = typeof allTargets[number];
|
||||
|
||||
@@ -13,10 +13,12 @@
|
||||
export const VALID_TARGET_TYPES = ['stack', 'fleet', 'system'] as const;
|
||||
export type TargetType = typeof VALID_TARGET_TYPES[number];
|
||||
|
||||
interface BackendScheduledActionDefinition {
|
||||
export interface BackendScheduledActionDefinition {
|
||||
readonly id: string;
|
||||
/** Target types this action accepts. `update` is the only multi-target action. */
|
||||
readonly targetTypes: readonly TargetType[];
|
||||
readonly requiresNode: boolean;
|
||||
readonly nodeScope?: 'local';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -24,15 +26,15 @@ interface BackendScheduledActionDefinition {
|
||||
* in `routes/scheduledTasks.ts` ("Must be restart, snapshot, prune, ...").
|
||||
*/
|
||||
export const BACKEND_SCHEDULED_ACTIONS = [
|
||||
{ id: 'restart', targetTypes: ['stack'] },
|
||||
{ id: 'snapshot', targetTypes: ['fleet'] },
|
||||
{ id: 'prune', targetTypes: ['system'] },
|
||||
{ id: 'update', targetTypes: ['stack', 'fleet'] },
|
||||
{ id: 'scan', targetTypes: ['system'] },
|
||||
{ id: 'auto_backup', targetTypes: ['stack'] },
|
||||
{ id: 'auto_stop', targetTypes: ['stack'] },
|
||||
{ id: 'auto_down', targetTypes: ['stack'] },
|
||||
{ id: 'auto_start', targetTypes: ['stack'] },
|
||||
{ id: 'restart', targetTypes: ['stack'], requiresNode: true },
|
||||
{ id: 'snapshot', targetTypes: ['fleet'], requiresNode: false },
|
||||
{ id: 'prune', targetTypes: ['system'], requiresNode: true, nodeScope: 'local' },
|
||||
{ id: 'update', targetTypes: ['stack', 'fleet'], requiresNode: true },
|
||||
{ id: 'scan', targetTypes: ['system'], requiresNode: true, nodeScope: 'local' },
|
||||
{ id: 'auto_backup', targetTypes: ['stack'], requiresNode: true },
|
||||
{ id: 'auto_stop', targetTypes: ['stack'], requiresNode: true },
|
||||
{ id: 'auto_down', targetTypes: ['stack'], requiresNode: true },
|
||||
{ id: 'auto_start', targetTypes: ['stack'], requiresNode: true },
|
||||
] as const satisfies readonly BackendScheduledActionDefinition[];
|
||||
|
||||
export type BackendScheduledAction = typeof BACKEND_SCHEDULED_ACTIONS[number]['id'];
|
||||
@@ -77,3 +79,7 @@ export function validateActionTarget(action: BackendScheduledAction, targetType:
|
||||
if (!def) return null;
|
||||
return def.targetTypes.includes(targetType) ? null : TARGET_MISMATCH_MESSAGE[action];
|
||||
}
|
||||
|
||||
export function getScheduledActionDefinition(action: BackendScheduledAction): BackendScheduledActionDefinition | undefined {
|
||||
return ACTION_BY_ID.get(action);
|
||||
}
|
||||
|
||||
@@ -62,8 +62,8 @@ The All tasks toggle swaps the lane track for a sortable table.
|
||||
| **Auto-update Stack** | A specific stack on a specific node | Checks each image in the stack for a newer tag and recreates the stack if any image has an update. See [Auto-Update Policies](/features/auto-update-policies) for the companion review board. |
|
||||
| **Auto-update All Stacks** | A specific node | Runs the auto-update check across every stack on the node. Pair with **Auto-update Stack** rows when you want different cadences for specific stacks. |
|
||||
| **Fleet Snapshot** | The whole fleet | Creates a versioned, fleet-wide snapshot of every node's compose files and `.env` files. See [Fleet Backups](/features/fleet-backups). |
|
||||
| **System Prune** | The selected node | Prunes containers, images, networks, and volumes (any subset), optionally filtered by a Docker label. |
|
||||
| **Vulnerability Scan** | A specific node | Runs Trivy against every image on the node and persists the results. Requires Trivy to be installed on the target node ([Installing Trivy](/operations/trivy-setup)). |
|
||||
| **System Prune** | A local node | Prunes containers, images, networks, and volumes (any subset), optionally filtered by a Docker label. Runs on local nodes only. |
|
||||
| **Vulnerability Scan** | A local node | Runs Trivy against every image on the node and persists the results. Requires Trivy to be installed on the target node ([Installing Trivy](/operations/trivy-setup)). Runs on local nodes only. |
|
||||
| **Backup Stack Files** | A specific stack on a specific node | Copies the stack's compose file and `.env` to `<DATA_DIR>/backups/<stack>/`. One slot per stack; each run overwrites the previous backup. For a versioned archive use a Fleet Snapshot instead. |
|
||||
| **Stop Stack** | A specific stack on a specific node | Runs `docker compose stop`. Containers are stopped but preserved. Use for off-hours power saving when you want a fast restart later. |
|
||||
| **Take Stack Down** | A specific stack on a specific node | Runs `docker compose down`. Containers are removed. Use to fully release resources when the stack is not needed for an extended period. |
|
||||
@@ -87,10 +87,11 @@ Common fields:
|
||||
|
||||
Conditional fields per action:
|
||||
|
||||
- **Stack actions** (Restart Stack, Auto-update Stack, Backup Stack Files, Stop / Take Down / Start Stack) add a **Node** combobox and a **Stack** combobox. Restart Stack additionally renders a **Services** checkbox grid sourced from the stack's compose services, so you can scope the restart to a subset instead of restarting the entire stack.
|
||||
- **Stack actions** (Restart Stack, Auto-update Stack, Backup Stack Files, Stop / Take Down / Start Stack) add a **Node** combobox and a **Stack** combobox. Restart Stack additionally renders a **Services** checkbox grid sourced from the stack's compose services on the selected node, so you can scope the restart to a subset instead of restarting the entire stack.
|
||||
- **Auto-update All Stacks** adds a **Node** combobox with the helper text "Every stack on the selected node will be checked and updated when new images are available."
|
||||
- **Vulnerability Scan** adds a **Node** combobox with the helper text "Every image on the selected node will be scanned."
|
||||
- **System Prune** adds a **Prune Targets** group (Containers, Images, Networks, Volumes; all selected by default) and a **Label Filter** input for scoping the prune to resources matching a Docker label.
|
||||
- **Vulnerability Scan** adds a **Node** combobox listing local nodes only, with the helper text "Every image on the selected node will be scanned. Scans run on local nodes only."
|
||||
- **System Prune** adds a **Node** combobox listing local nodes only, then a **Prune Targets** group (Containers, Images, Networks, Volumes; all selected by default) and a **Label Filter** input for scoping the prune to resources matching a Docker label.
|
||||
- **Fleet Snapshot** shows a read-only **Scope: Entire fleet** summary instead of a Node or Stack picker, because it captures every node.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/scheduled-operations/create-restart.png" alt="The New scheduled task modal configured for a Restart Stack task. Name reads 'Nightly staging restart'. Action is Restart Stack. Node is Local. Stack is 'audit-mesh-prod'. A Services group with helper text '(leave empty for all)' shows two unchecked checkboxes labelled 'echo' and 'prober'. Cron Expression is '0 3 * * *' with the preview 'At 03:00 AM'. The Enabled toggle reads ON. The Delete after successful run checkbox is unchecked. Cancel and Create buttons sit at the bottom right." />
|
||||
|
||||
@@ -127,7 +127,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
const res = await apiFetch('/nodes', { localOnly: true });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setNodes(data.map((n: { id: number; name: string }) => ({ id: n.id, name: n.name })));
|
||||
setNodes(data.map((n: { id: number; name: string; type: 'local' | 'remote' }) => ({ id: n.id, name: n.name, type: n.type })));
|
||||
}
|
||||
} catch {
|
||||
// Non-critical
|
||||
@@ -160,7 +160,12 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
let cancelled = false;
|
||||
const fetchServices = async () => {
|
||||
try {
|
||||
const res = await apiFetch(`/stacks/${encodeURIComponent(formTargetId)}/services`);
|
||||
// Load services from the selected node so remote-node restart schedules
|
||||
// discover the right services instead of the hub's.
|
||||
const endpoint = `/stacks/${encodeURIComponent(formTargetId)}/services`;
|
||||
const res = formNodeId
|
||||
? await fetchForNode(endpoint, parseInt(formNodeId, 10))
|
||||
: await apiFetch(endpoint);
|
||||
if (res.ok && !cancelled) {
|
||||
setAvailableServices(await res.json());
|
||||
}
|
||||
@@ -170,7 +175,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
};
|
||||
fetchServices();
|
||||
return () => { cancelled = true; };
|
||||
}, [formAction, formTargetId]);
|
||||
}, [formAction, formTargetId, formNodeId]);
|
||||
|
||||
// Re-fetch stacks when node changes
|
||||
useEffect(() => {
|
||||
@@ -221,7 +226,10 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
|
||||
const handleSave = async () => {
|
||||
const actionDef = getActionById(formAction);
|
||||
if (!actionDef) return;
|
||||
if (!actionDef) {
|
||||
toast.error('This scheduled action is no longer supported.');
|
||||
return;
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
name: formName,
|
||||
@@ -230,24 +238,13 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
cron_expression: formCron,
|
||||
enabled: formEnabled,
|
||||
delete_after_run: formDeleteAfterRun,
|
||||
target_id: actionDef.requiresStack ? formTargetId : null,
|
||||
node_id: actionDef.requiresNode && formNodeId ? parseInt(formNodeId, 10) : null,
|
||||
prune_targets: formAction === 'prune' && formPruneTargets.length > 0 ? formPruneTargets : null,
|
||||
target_services: actionDef.supportsServiceSelection && formTargetServices.length > 0 ? formTargetServices : null,
|
||||
prune_label_filter: formAction === 'prune' && formPruneLabelFilter.trim() ? formPruneLabelFilter.trim() : null,
|
||||
};
|
||||
|
||||
if (actionDef.requiresStack) {
|
||||
body.target_id = formTargetId;
|
||||
}
|
||||
if (actionDef.requiresNode) {
|
||||
body.node_id = formNodeId ? parseInt(formNodeId, 10) : null;
|
||||
}
|
||||
if (formAction === 'prune' && formPruneTargets.length > 0) {
|
||||
body.prune_targets = formPruneTargets;
|
||||
}
|
||||
if (formAction === 'restart' && formTargetServices.length > 0) {
|
||||
body.target_services = formTargetServices;
|
||||
}
|
||||
if (formAction === 'prune' && formPruneLabelFilter.trim()) {
|
||||
body.prune_label_filter = formPruneLabelFilter.trim();
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = editingTask
|
||||
@@ -342,8 +339,14 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
const currentAction = getActionById(formAction);
|
||||
const cronDescription = getCronDescription(formCron);
|
||||
const nodeOptions = useMemo(() => nodes.map(n => ({ value: String(n.id), label: n.name })), [nodes]);
|
||||
// Scan and prune run on the hub-local Docker daemon only; remote nodes are excluded from their pickers.
|
||||
const localNodeOptions = useMemo(
|
||||
() => nodes.filter(n => n.type === 'local').map(n => ({ value: String(n.id), label: n.name })),
|
||||
[nodes],
|
||||
);
|
||||
const currentNodeOptions = currentAction?.nodeScope === 'local' ? localNodeOptions : nodeOptions;
|
||||
const isSaveDisabled =
|
||||
saving || !formName || !formCron
|
||||
saving || !currentAction || !formName || !formCron
|
||||
|| (!!currentAction?.requiresStack && (!formTargetId || !formNodeId))
|
||||
|| (!!currentAction?.requiresNode && !currentAction.requiresStack && !formNodeId)
|
||||
|| (formAction === 'prune' && formPruneTargets.length === 0);
|
||||
@@ -715,11 +718,21 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
</>
|
||||
)}
|
||||
|
||||
{formAction === 'snapshot' && (
|
||||
<div className="space-y-2">
|
||||
<Label>Scope</Label>
|
||||
<div className="flex h-9 w-full items-center rounded-md border border-glass-border bg-input px-3 text-sm text-muted-foreground">
|
||||
Entire fleet
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Captures every node's compose and .env files. No node or stack to choose.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentAction?.requiresNode && !currentAction.requiresStack && (
|
||||
<div className="space-y-2">
|
||||
<Label>Node</Label>
|
||||
<Combobox
|
||||
options={nodeOptions}
|
||||
options={currentNodeOptions}
|
||||
value={formNodeId}
|
||||
onValueChange={setFormNodeId}
|
||||
placeholder="Select node..."
|
||||
|
||||
@@ -51,16 +51,17 @@ function makeTask(overrides: Partial<ScheduledTask> = {}): ScheduledTask {
|
||||
}
|
||||
|
||||
let tasksFixture: ScheduledTask[];
|
||||
let nodesFixture: { id: number; name: string }[];
|
||||
let nodesFixture: { id: number; name: string; type: 'local' | 'remote' }[];
|
||||
|
||||
beforeEach(() => {
|
||||
tasksFixture = [];
|
||||
nodesFixture = [{ id: 1, name: 'hub' }, { id: 2, name: 'edge' }];
|
||||
nodesFixture = [{ id: 1, name: 'hub', type: 'local' }, { id: 2, name: 'edge', type: 'remote' }];
|
||||
mockedFetch.mockReset();
|
||||
mockedFetchForNode.mockReset();
|
||||
|
||||
mockedFetch.mockImplementation(async (url: string, opts?: { method?: string }) => {
|
||||
if (url === '/scheduled-tasks' && opts?.method === 'POST') return jsonResponse({ id: 99 }, { status: 201 });
|
||||
if (/^\/scheduled-tasks\/\d+$/.test(url) && opts?.method === 'PUT') return jsonResponse({ id: Number(url.split('/').pop()) });
|
||||
if (url === '/scheduled-tasks') return jsonResponse(tasksFixture);
|
||||
if (url === '/nodes') return jsonResponse(nodesFixture);
|
||||
if (url === '/stacks') return jsonResponse([]);
|
||||
@@ -152,6 +153,10 @@ describe('ScheduledOperationsView', () => {
|
||||
await userEvent.click(screen.getAllByRole('combobox')[0]);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'System Prune' }));
|
||||
|
||||
// Prune is now node-scoped: pick the local node from its Node combobox.
|
||||
await userEvent.click(screen.getAllByRole('combobox')[1]);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'hub' }));
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Create' }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -164,6 +169,7 @@ describe('ScheduledOperationsView', () => {
|
||||
name: 'cleanup',
|
||||
target_type: 'system',
|
||||
action: 'prune',
|
||||
node_id: 1,
|
||||
cron_expression: '0 3 * * *',
|
||||
prune_targets: ['containers', 'images', 'networks', 'volumes'],
|
||||
});
|
||||
@@ -171,6 +177,106 @@ describe('ScheduledOperationsView', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps Create disabled for a prune until a node is selected', async () => {
|
||||
render(<ScheduledOperationsView />);
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: /New Schedule/ }));
|
||||
await userEvent.type(await screen.findByPlaceholderText('e.g. Nightly stack restart'), 'cleanup');
|
||||
await userEvent.click(screen.getAllByRole('combobox')[0]);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'System Prune' }));
|
||||
|
||||
// Prune targets default to all four, but with no node the gate must hold.
|
||||
expect(screen.getByRole('button', { name: 'Create' })).toBeDisabled();
|
||||
|
||||
await userEvent.click(screen.getAllByRole('combobox')[1]);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'hub' }));
|
||||
expect(screen.getByRole('button', { name: 'Create' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('excludes remote nodes from the System Prune node picker', async () => {
|
||||
render(<ScheduledOperationsView />);
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: /New Schedule/ }));
|
||||
await userEvent.click(screen.getAllByRole('combobox')[0]);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'System Prune' }));
|
||||
|
||||
// Open the Node combobox; only the local node should be listed.
|
||||
await userEvent.click(screen.getAllByRole('combobox')[1]);
|
||||
expect(await screen.findByRole('button', { name: 'hub' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'edge' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('excludes remote nodes from the Vulnerability Scan node picker', async () => {
|
||||
render(<ScheduledOperationsView />);
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: /New Schedule/ }));
|
||||
await userEvent.click(screen.getAllByRole('combobox')[0]);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Vulnerability Scan' }));
|
||||
|
||||
await userEvent.click(screen.getAllByRole('combobox')[1]);
|
||||
expect(await screen.findByRole('button', { name: 'hub' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'edge' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('submits a vulnerability scan create with the selected local node', async () => {
|
||||
render(<ScheduledOperationsView />);
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: /New Schedule/ }));
|
||||
await userEvent.type(await screen.findByPlaceholderText('e.g. Nightly stack restart'), 'scan-local');
|
||||
await userEvent.click(screen.getAllByRole('combobox')[0]);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Vulnerability Scan' }));
|
||||
await userEvent.click(screen.getAllByRole('combobox')[1]);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'hub' }));
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Create' }));
|
||||
|
||||
await waitFor(() => {
|
||||
const postCall = mockedFetch.mock.calls.find(
|
||||
([url, opts]) => url === '/scheduled-tasks' && opts?.method === 'POST',
|
||||
);
|
||||
expect(postCall).toBeTruthy();
|
||||
const body = JSON.parse(postCall![1].body);
|
||||
expect(body).toMatchObject({
|
||||
name: 'scan-local',
|
||||
target_type: 'system',
|
||||
action: 'scan',
|
||||
node_id: 1,
|
||||
target_id: null,
|
||||
prune_targets: null,
|
||||
target_services: null,
|
||||
prune_label_filter: null,
|
||||
});
|
||||
expect(postCall![1].localOnly).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a read-only "Entire fleet" scope for Fleet Snapshot', async () => {
|
||||
render(<ScheduledOperationsView />);
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: /New Schedule/ }));
|
||||
await userEvent.click(screen.getAllByRole('combobox')[0]);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Fleet Snapshot' }));
|
||||
|
||||
expect(await screen.findByText('Entire fleet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('loads Restart Stack services from the selected node', async () => {
|
||||
render(<ScheduledOperationsView />);
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: /New Schedule/ }));
|
||||
// Default action is Restart Stack. Pick the remote node, then a stack on it.
|
||||
await userEvent.click(screen.getAllByRole('combobox')[1]);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'edge' }));
|
||||
// The Stack combobox unlocks once a node is chosen; pick a stack to drive discovery.
|
||||
await userEvent.click(screen.getAllByRole('combobox')[2]);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'web' }));
|
||||
|
||||
// Service discovery must target the selected node, not the hub-local default.
|
||||
await waitFor(() =>
|
||||
expect(mockedFetchForNode).toHaveBeenCalledWith('/stacks/web/services', 2),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the five registry category lanes in the timeline view', async () => {
|
||||
render(<ScheduledOperationsView />);
|
||||
// Timeline is the default view; the lane track always renders.
|
||||
@@ -217,10 +323,10 @@ describe('ScheduledOperationsView', () => {
|
||||
expect(screen.queryByText('Node')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Stack')).not.toBeInTheDocument();
|
||||
|
||||
// Prune: Prune Targets shown, no Node.
|
||||
// Prune: local-only Node plus Prune Targets.
|
||||
await selectAction('System Prune');
|
||||
expect(screen.getByText('Prune Targets')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Node')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Node')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('emits node_id and target_id for a stack update save', async () => {
|
||||
@@ -286,4 +392,40 @@ describe('ScheduledOperationsView', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('clears stack-only fields when editing a restart task into a fleet snapshot', async () => {
|
||||
tasksFixture = [makeTask({
|
||||
id: 42,
|
||||
name: 'restart-web',
|
||||
target_type: 'stack',
|
||||
target_id: 'web',
|
||||
node_id: 1,
|
||||
action: 'restart',
|
||||
target_services: JSON.stringify(['web']),
|
||||
})];
|
||||
render(<ScheduledOperationsView />);
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: /All tasks/ }));
|
||||
await userEvent.click(await screen.findByTitle('Edit'));
|
||||
await userEvent.click(screen.getAllByRole('combobox')[0]);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Fleet Snapshot' }));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Update' }));
|
||||
|
||||
await waitFor(() => {
|
||||
const putCall = mockedFetch.mock.calls.find(
|
||||
([url, opts]) => url === '/scheduled-tasks/42' && opts?.method === 'PUT',
|
||||
);
|
||||
expect(putCall).toBeTruthy();
|
||||
const body = JSON.parse(putCall![1].body);
|
||||
expect(body).toMatchObject({
|
||||
target_type: 'fleet',
|
||||
action: 'snapshot',
|
||||
target_id: null,
|
||||
node_id: null,
|
||||
target_services: null,
|
||||
prune_targets: null,
|
||||
prune_label_filter: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,6 +38,11 @@ describe('scheduledActions registry', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('marks scan and prune as local-node actions', () => {
|
||||
expect(getActionById('scan')).toMatchObject({ requiresNode: true, nodeScope: 'local' });
|
||||
expect(getActionById('prune')).toMatchObject({ requiresNode: true, nodeScope: 'local' });
|
||||
});
|
||||
|
||||
it('getActionById resolves a known id and returns undefined otherwise', () => {
|
||||
expect(getActionById('restart')?.label).toBe('Restart Stack');
|
||||
expect(getActionById('nope')).toBeUndefined();
|
||||
|
||||
@@ -37,6 +37,7 @@ export interface ScheduledActionDefinition {
|
||||
requiresNode: boolean;
|
||||
requiresStack: boolean;
|
||||
supportsServiceSelection: boolean;
|
||||
nodeScope?: 'local';
|
||||
helperText?: string;
|
||||
}
|
||||
|
||||
@@ -46,8 +47,8 @@ export const SCHEDULED_ACTIONS: ScheduledActionDefinition[] = [
|
||||
{ id: 'update', backendAction: 'update', label: 'Auto-update Stack', shortLabel: 'update', category: 'updates', targetType: 'stack', tone: 'success', requiresNode: true, requiresStack: true, supportsServiceSelection: false },
|
||||
{ id: 'update-fleet', backendAction: 'update', label: 'Auto-update All Stacks', shortLabel: 'update', category: 'updates', targetType: 'fleet', tone: 'success', requiresNode: true, requiresStack: false, supportsServiceSelection: false, helperText: 'Every stack on the selected node will be checked and updated when new images are available.' },
|
||||
{ id: 'snapshot', backendAction: 'snapshot', label: 'Fleet Snapshot', shortLabel: 'snapshot', category: 'backups', targetType: 'fleet', tone: 'warning', requiresNode: false, requiresStack: false, supportsServiceSelection: false },
|
||||
{ id: 'prune', backendAction: 'prune', label: 'System Prune', shortLabel: 'prune', category: 'maintenance', targetType: 'system', tone: 'warning', requiresNode: false, requiresStack: false, supportsServiceSelection: false },
|
||||
{ id: 'scan', backendAction: 'scan', label: 'Vulnerability Scan', shortLabel: 'scan', category: 'security', targetType: 'system', tone: 'success', requiresNode: true, requiresStack: false, supportsServiceSelection: false, helperText: 'Every image on the selected node will be scanned.' },
|
||||
{ id: 'prune', backendAction: 'prune', label: 'System Prune', shortLabel: 'prune', category: 'maintenance', targetType: 'system', tone: 'warning', requiresNode: true, requiresStack: false, supportsServiceSelection: false, nodeScope: 'local', helperText: 'Resources are pruned on the selected node. Prunes run on local nodes only.' },
|
||||
{ id: 'scan', backendAction: 'scan', label: 'Vulnerability Scan', shortLabel: 'scan', category: 'security', targetType: 'system', tone: 'success', requiresNode: true, requiresStack: false, supportsServiceSelection: false, nodeScope: 'local', helperText: 'Every image on the selected node will be scanned. Scans run on local nodes only.' },
|
||||
{ id: 'auto_backup', backendAction: 'auto_backup', label: 'Backup Stack Files', shortLabel: 'backup', category: 'lifecycle', targetType: 'stack', tone: 'brand', requiresNode: true, requiresStack: true, supportsServiceSelection: false },
|
||||
{ id: 'auto_stop', backendAction: 'auto_stop', label: 'Stop Stack (keep containers)', shortLabel: 'stop', category: 'lifecycle', targetType: 'stack', tone: 'warning', requiresNode: true, requiresStack: true, supportsServiceSelection: false },
|
||||
{ id: 'auto_down', backendAction: 'auto_down', label: 'Take Stack Down (remove containers)', shortLabel: 'down', category: 'lifecycle', targetType: 'stack', tone: 'destructive', requiresNode: true, requiresStack: true, supportsServiceSelection: false },
|
||||
|
||||
@@ -35,4 +35,5 @@ export interface TaskRun {
|
||||
export interface NodeOption {
|
||||
id: number;
|
||||
name: string;
|
||||
type: 'local' | 'remote';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user