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
@@ -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-'));