mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 04:06:59 +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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user