mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 04:06:59 +00:00
refactor(scheduler): drive scheduled-action metadata from a shared registry (#1428)
Scheduled-operation action metadata was duplicated across the backend route validator, the DatabaseService action union, the desktop action picker, the Timeline lanes, and the mobile labels/tones. Adding or renaming one action meant editing all of them. Introduce one registry per package as the single source within that package: - backend/src/services/scheduledActionRegistry.ts owns the action list and target-type validation; routes/scheduledTasks.ts and DatabaseService import from it (BackendScheduledAction type, VALID_ACTIONS, validateActionTarget). - frontend/src/lib/scheduledActions.ts owns the UI metadata (labels, short labels, categories, tones, target/node/stack/service flags, helper text) and drives the create-flow picker, the All Tasks label, the Timeline lanes, and the mobile schedule view. Timeline lanes now group by semantic category (Lifecycle, Updates, Security, Maintenance, Backups) sourced from the registry. The update-fleet UI alias is made explicit via a backendAction field. Backend validation stays authoritative; parity tests on each side keep the action sets in lockstep.
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Locks the backend scheduled-action registry: the action list stays in sync
|
||||
* with what the route layer validates, and validateActionTarget reproduces the
|
||||
* exact per-action error messages that are part of the API contract.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
VALID_ACTIONS,
|
||||
BACKEND_SCHEDULED_ACTIONS,
|
||||
INVALID_ACTION_MESSAGE,
|
||||
validateActionTarget,
|
||||
type BackendScheduledAction,
|
||||
type TargetType,
|
||||
} from '../services/scheduledActionRegistry';
|
||||
|
||||
const EXPECTED_ACTIONS: BackendScheduledAction[] = [
|
||||
'restart', 'snapshot', 'prune', 'update', 'scan',
|
||||
'auto_backup', 'auto_stop', 'auto_down', 'auto_start',
|
||||
];
|
||||
|
||||
const ALL_TARGET_TYPES: TargetType[] = ['stack', 'fleet', 'system'];
|
||||
|
||||
describe('scheduledActionRegistry', () => {
|
||||
it('exposes exactly the known backend actions, in order', () => {
|
||||
expect([...VALID_ACTIONS]).toEqual(EXPECTED_ACTIONS);
|
||||
});
|
||||
|
||||
it('every valid action has a registry entry and vice versa', () => {
|
||||
const entryIds = BACKEND_SCHEDULED_ACTIONS.map(a => a.id);
|
||||
expect([...entryIds].sort()).toEqual([...VALID_ACTIONS].sort());
|
||||
});
|
||||
|
||||
it('derives the invalid-action message from the action list', () => {
|
||||
expect(INVALID_ACTION_MESSAGE).toBe(
|
||||
'Invalid action. Must be restart, snapshot, prune, update, scan, auto_backup, auto_stop, auto_down, or auto_start.',
|
||||
);
|
||||
});
|
||||
|
||||
describe('validateActionTarget', () => {
|
||||
const validPairs: Record<BackendScheduledAction, TargetType[]> = {
|
||||
restart: ['stack'],
|
||||
snapshot: ['fleet'],
|
||||
prune: ['system'],
|
||||
update: ['stack', 'fleet'],
|
||||
scan: ['system'],
|
||||
auto_backup: ['stack'],
|
||||
auto_stop: ['stack'],
|
||||
auto_down: ['stack'],
|
||||
auto_start: ['stack'],
|
||||
};
|
||||
|
||||
const mismatchMessage: Record<BackendScheduledAction, string> = {
|
||||
restart: 'Restart action requires target_type "stack".',
|
||||
snapshot: 'Snapshot action requires target_type "fleet".',
|
||||
prune: 'Prune action requires target_type "system".',
|
||||
update: 'Update action requires target_type "stack" or "fleet".',
|
||||
scan: 'Scan action requires target_type "system".',
|
||||
auto_backup: 'auto_backup action requires target_type "stack".',
|
||||
auto_stop: 'auto_stop action requires target_type "stack".',
|
||||
auto_down: 'auto_down action requires target_type "stack".',
|
||||
auto_start: 'auto_start action requires target_type "stack".',
|
||||
};
|
||||
|
||||
for (const action of EXPECTED_ACTIONS) {
|
||||
it(`accepts valid and rejects invalid target types for ${action}`, () => {
|
||||
for (const targetType of ALL_TARGET_TYPES) {
|
||||
const result = validateActionTarget(action, targetType);
|
||||
if (validPairs[action].includes(targetType)) {
|
||||
expect(result).toBeNull();
|
||||
} else {
|
||||
expect(result).toBe(mismatchMessage[action]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,14 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { CronExpressionParser } from 'cron-parser';
|
||||
import { DatabaseService, type ScheduledTask } from '../services/DatabaseService';
|
||||
import {
|
||||
VALID_TARGET_TYPES,
|
||||
VALID_ACTIONS,
|
||||
INVALID_ACTION_MESSAGE,
|
||||
validateActionTarget,
|
||||
type TargetType,
|
||||
type BackendScheduledAction,
|
||||
} from '../services/scheduledActionRegistry';
|
||||
import { SchedulerService } from '../services/SchedulerService';
|
||||
import { NotificationService } from '../services/NotificationService';
|
||||
import { requireAdmin } from '../middleware/tierGates';
|
||||
@@ -24,33 +32,9 @@ function broadcastScheduledTasksChanged(): void {
|
||||
}
|
||||
}
|
||||
|
||||
const VALID_TARGET_TYPES = ['stack', 'fleet', 'system'] as const;
|
||||
const VALID_ACTIONS = ['restart', 'snapshot', 'prune', 'update', 'scan', 'auto_backup', 'auto_stop', 'auto_down', 'auto_start'] as const;
|
||||
const VALID_PRUNE_TARGETS = ['containers', 'images', 'networks', 'volumes'] as const;
|
||||
const ERR_FLEET_NODE_REQUIRED = 'Fleet update requires node_id.';
|
||||
|
||||
type TargetType = typeof VALID_TARGET_TYPES[number];
|
||||
type ScheduledAction = typeof VALID_ACTIONS[number];
|
||||
|
||||
const STACK_ONLY_ACTIONS = new Set<ScheduledAction>(['auto_backup', 'auto_stop', 'auto_down', 'auto_start']);
|
||||
|
||||
/**
|
||||
* Validate that the target_type is compatible with the action. Each action
|
||||
* has exactly one allowed target_type; the helper returns an error message
|
||||
* on mismatch and null otherwise.
|
||||
*/
|
||||
function validateActionTarget(action: ScheduledAction, targetType: TargetType): string | null {
|
||||
if (action === 'restart' && targetType !== 'stack') return 'Restart action requires target_type "stack".';
|
||||
if (action === 'update' && targetType !== 'stack' && targetType !== 'fleet') return 'Update action requires target_type "stack" or "fleet".';
|
||||
if (action === 'snapshot' && targetType !== 'fleet') return 'Snapshot action requires target_type "fleet".';
|
||||
if (action === 'prune' && targetType !== 'system') return 'Prune action requires target_type "system".';
|
||||
if (action === 'scan' && targetType !== 'system') return 'Scan action requires target_type "system".';
|
||||
if (STACK_ONLY_ACTIONS.has(action) && targetType !== 'stack') {
|
||||
return `${action} action requires target_type "stack".`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateStackTarget(targetType: TargetType, targetId: unknown, nodeId: unknown): string | null {
|
||||
if (targetType !== 'stack') return null;
|
||||
|
||||
@@ -84,7 +68,7 @@ function validateScanNode(nodeId: unknown): string | null {
|
||||
|
||||
/** Shared validation for prune_targets, target_services, prune_label_filter. Returns an error string or null. */
|
||||
function validateOptionalFields(
|
||||
action: ScheduledAction,
|
||||
action: BackendScheduledAction,
|
||||
targetType: TargetType,
|
||||
prune_targets: unknown,
|
||||
target_services: unknown,
|
||||
@@ -163,7 +147,7 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => {
|
||||
res.status(400).json({ error: 'Invalid target_type. Must be stack, fleet, or system.' }); return;
|
||||
}
|
||||
if (!(VALID_ACTIONS as readonly string[]).includes(action)) {
|
||||
res.status(400).json({ error: 'Invalid action. Must be restart, snapshot, prune, update, scan, auto_backup, auto_stop, auto_down, or auto_start.' }); return;
|
||||
res.status(400).json({ error: INVALID_ACTION_MESSAGE }); return;
|
||||
}
|
||||
|
||||
const targetErr = validateActionTarget(action, target_type);
|
||||
@@ -258,7 +242,7 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => {
|
||||
res.status(400).json({ error: 'Invalid action' }); return;
|
||||
}
|
||||
|
||||
const finalAction = (action ?? existing.action) as ScheduledAction;
|
||||
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;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { isSeverityAtLeast } from '../utils/severity';
|
||||
import { evaluatePolicyRisk, policyInputs } from '../utils/policy-risk';
|
||||
import type { AuditStatsInput } from './AuditAnomalyService';
|
||||
import { EXPOSURE_INTENTS, type ExposureIntent } from './network/types';
|
||||
import type { BackendScheduledAction } from './scheduledActionRegistry';
|
||||
|
||||
function isPilotMode(): boolean {
|
||||
return process.env.SENCHO_MODE === 'pilot';
|
||||
@@ -505,7 +506,7 @@ export interface ScheduledTask {
|
||||
target_type: 'stack' | 'fleet' | 'system';
|
||||
target_id: string | null;
|
||||
node_id: number | null;
|
||||
action: 'restart' | 'snapshot' | 'prune' | 'update' | 'scan' | 'auto_backup' | 'auto_stop' | 'auto_down' | 'auto_start';
|
||||
action: BackendScheduledAction;
|
||||
cron_expression: string;
|
||||
enabled: number;
|
||||
created_by: string;
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Single source of truth for scheduled-operation action metadata that the
|
||||
* backend needs for validation. The route layer derives its allow-list and
|
||||
* action/target compatibility checks from this table, so adding a new action
|
||||
* means adding one entry here (plus its execution logic in SchedulerService).
|
||||
*
|
||||
* The frontend keeps its own richer registry (labels, categories, tones) in
|
||||
* `frontend/src/lib/scheduledActions.ts`; the two cannot share a module because
|
||||
* the packages build in isolation. The shapes are kept in lockstep by tests on
|
||||
* each side.
|
||||
*/
|
||||
|
||||
export const VALID_TARGET_TYPES = ['stack', 'fleet', 'system'] as const;
|
||||
export type TargetType = typeof VALID_TARGET_TYPES[number];
|
||||
|
||||
interface BackendScheduledActionDefinition {
|
||||
readonly id: string;
|
||||
/** Target types this action accepts. `update` is the only multi-target action. */
|
||||
readonly targetTypes: readonly TargetType[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered so the `VALID_ACTIONS` list matches the human-readable error message
|
||||
* 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'] },
|
||||
] as const satisfies readonly BackendScheduledActionDefinition[];
|
||||
|
||||
export type BackendScheduledAction = typeof BACKEND_SCHEDULED_ACTIONS[number]['id'];
|
||||
|
||||
export const VALID_ACTIONS: readonly BackendScheduledAction[] =
|
||||
BACKEND_SCHEDULED_ACTIONS.map(a => a.id);
|
||||
|
||||
/**
|
||||
* Human-readable allow-list for the route's 400 response. Built from
|
||||
* VALID_ACTIONS so a new action cannot leave this enumeration stale.
|
||||
*/
|
||||
export const INVALID_ACTION_MESSAGE =
|
||||
`Invalid action. Must be ${VALID_ACTIONS.join(', ').replace(/, ([^,]+)$/, ', or $1')}.`;
|
||||
|
||||
const ACTION_BY_ID = new Map<BackendScheduledAction, BackendScheduledActionDefinition>(
|
||||
BACKEND_SCHEDULED_ACTIONS.map(a => [a.id, a]),
|
||||
);
|
||||
|
||||
/**
|
||||
* Per-action mismatch message. The wording differs per action and is part of
|
||||
* the API contract, so it is kept explicit rather than templated.
|
||||
*/
|
||||
const TARGET_MISMATCH_MESSAGE: Record<BackendScheduledAction, string> = {
|
||||
restart: 'Restart action requires target_type "stack".',
|
||||
snapshot: 'Snapshot action requires target_type "fleet".',
|
||||
prune: 'Prune action requires target_type "system".',
|
||||
update: 'Update action requires target_type "stack" or "fleet".',
|
||||
scan: 'Scan action requires target_type "system".',
|
||||
auto_backup: 'auto_backup action requires target_type "stack".',
|
||||
auto_stop: 'auto_stop action requires target_type "stack".',
|
||||
auto_down: 'auto_down action requires target_type "stack".',
|
||||
auto_start: 'auto_start action requires target_type "stack".',
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate that the target_type is compatible with the action. Returns an error
|
||||
* message on mismatch and null otherwise. Callers must already have confirmed
|
||||
* the action is in `VALID_ACTIONS`.
|
||||
*/
|
||||
export function validateActionTarget(action: BackendScheduledAction, targetType: TargetType): string | null {
|
||||
const def = ACTION_BY_ID.get(action);
|
||||
if (!def) return null;
|
||||
return def.targetTypes.includes(targetType) ? null : TARGET_MISMATCH_MESSAGE[action];
|
||||
}
|
||||
Reference in New Issue
Block a user