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