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
+28 -20
View File
@@ -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
+3
View File
@@ -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];
+16 -10
View File
@@ -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);
}