mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 10:49:35 +00:00
fix(scheduled-ops): run stack lifecycle schedules on remote nodes and harden run visibility (#1260)
* fix(scheduled-ops): run stack lifecycle schedules on remote nodes and harden run visibility Stack lifecycle schedules (Restart, Stop, Take Down, Start, Backup Stack Files) now run against whichever node the schedule targets, local or remote. Each remote run proxies to that node's own stack-operation endpoint, so a hub-managed schedule reaches the node that actually holds the stack. Restart with a service subset restarts each selected service and, if one fails, names the services already restarted so run history reflects the stack's partial state. Auto-start on a remote node runs that node's own pre-deploy scan-policy check against the images it holds. Add POST /api/stacks/:name/backup to trigger an on-demand backup of a stack's compose and env files (the same rollback snapshot a deploy takes); it backs the remote backup schedule and is available to operators on its own. A scheduled task that reaches execution on an unpaid licence is now skipped and written to run history as a failed run, so a manual trigger that returned a queued response never silently disappears. Test plan: - Backend unit + integration: scheduler-service (remote proxy per action, per-service fan-out, auto-start policy delegation, remote-failure and no-credentials paths, unpaid-tier skip), stack-backup-route (auth/role/paid/404/400/500), scheduled-tasks-routes. - Frontend component test for the schedules view (list, prefill, node filter, create payload). - tsc and lint clean on both packages. * fix(scheduled-ops): lock the stack-files backup route against concurrent stack ops The stack-files backup writes the same slot the pre-deploy rollback snapshot uses, so running it while a deploy, update, or rollback is in flight on the same stack could overwrite the rollback point. The backup route now takes the per-stack operation lock (as deploy/down/restart do) and returns 409 when the stack is busy, keeping the rollback snapshot intact. Adds the 'backup' action to the stack-op lock type and a busy participle for the 409 message. * fix(scheduled-ops): enforce backup-path containment inline at the filesystem sink The on-demand backup route passes the stack name straight into backupStackFiles, so resolve the backup directory against the backup root and confirm containment with an inline startsWith check before the mkdir/copy/write sinks, matching the barrier restoreStackFiles already uses. The stack name is validated at the route and again by resolveStackDir, so this is defense in depth that also closes a static path-injection finding on the new call path.
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Component coverage for ScheduledOperationsView. Locks the deterministic
|
||||
* wiring that manual and browser testing miss: the task list renders, a prefill
|
||||
* opens the create modal and is consumed once, the node filter narrows the
|
||||
* table, and a create submits the correct action/target payload to the
|
||||
* hub-local endpoint.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { ScheduledTask } from '@/types/scheduling';
|
||||
|
||||
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn(), fetchForNode: vi.fn() }));
|
||||
vi.mock('@/components/ui/toast-store', () => ({
|
||||
toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn() },
|
||||
}));
|
||||
|
||||
import { apiFetch, fetchForNode } from '@/lib/api';
|
||||
import ScheduledOperationsView from '../ScheduledOperationsView';
|
||||
|
||||
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
|
||||
const mockedFetchForNode = fetchForNode as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
function jsonResponse(body: unknown, init: { ok?: boolean; status?: number } = {}): Response {
|
||||
return { ok: init.ok ?? true, status: init.status ?? 200, json: async () => body } as unknown as Response;
|
||||
}
|
||||
|
||||
function makeTask(overrides: Partial<ScheduledTask> = {}): ScheduledTask {
|
||||
return {
|
||||
id: 1,
|
||||
name: 'task-1',
|
||||
target_type: 'system',
|
||||
target_id: null,
|
||||
node_id: 1,
|
||||
action: 'prune',
|
||||
cron_expression: '0 3 * * *',
|
||||
enabled: 1,
|
||||
created_by: 'admin',
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
last_run_at: null,
|
||||
next_run_at: null,
|
||||
last_status: null,
|
||||
last_error: null,
|
||||
prune_targets: null,
|
||||
target_services: null,
|
||||
prune_label_filter: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
let tasksFixture: ScheduledTask[];
|
||||
let nodesFixture: { id: number; name: string }[];
|
||||
|
||||
beforeEach(() => {
|
||||
tasksFixture = [];
|
||||
nodesFixture = [{ id: 1, name: 'hub' }, { id: 2, name: 'edge' }];
|
||||
mockedFetch.mockReset();
|
||||
mockedFetchForNode.mockReset();
|
||||
|
||||
mockedFetch.mockImplementation(async (url: string, opts?: { method?: string }) => {
|
||||
if (url === '/scheduled-tasks' && opts?.method === 'POST') return jsonResponse({ id: 99 }, { status: 201 });
|
||||
if (url === '/scheduled-tasks') return jsonResponse(tasksFixture);
|
||||
if (url === '/nodes') return jsonResponse(nodesFixture);
|
||||
if (url === '/stacks') return jsonResponse([]);
|
||||
return jsonResponse({});
|
||||
});
|
||||
mockedFetchForNode.mockResolvedValue(jsonResponse(['web', 'db']));
|
||||
});
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('ScheduledOperationsView', () => {
|
||||
it('renders existing tasks in the table view', async () => {
|
||||
tasksFixture = [makeTask({ id: 7, name: 'nightly-prune' })];
|
||||
render(<ScheduledOperationsView />);
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: /All tasks/ }));
|
||||
expect(await screen.findByText('nightly-prune')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the create modal from a prefill and consumes it once', async () => {
|
||||
const onPrefillConsumed = vi.fn();
|
||||
render(
|
||||
<ScheduledOperationsView
|
||||
prefill={{ stackName: 'web', nodeId: 1 }}
|
||||
onPrefillConsumed={onPrefillConsumed}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('New scheduled task')).toBeInTheDocument();
|
||||
expect(onPrefillConsumed).toHaveBeenCalledTimes(1);
|
||||
// The prefilled stack drives a node-scoped stack fetch through the proxy.
|
||||
await waitFor(() => expect(mockedFetchForNode).toHaveBeenCalledWith('/stacks', 1));
|
||||
});
|
||||
|
||||
it('filters the table to the selected node and clears the filter', async () => {
|
||||
tasksFixture = [
|
||||
makeTask({ id: 1, name: 'hub-task', node_id: 1 }),
|
||||
makeTask({ id: 2, name: 'edge-task', node_id: 2 }),
|
||||
];
|
||||
const onClearFilter = vi.fn();
|
||||
render(<ScheduledOperationsView filterNodeId={2} onClearFilter={onClearFilter} />);
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: /All tasks/ }));
|
||||
expect(await screen.findByText('edge-task')).toBeInTheDocument();
|
||||
expect(screen.queryByText('hub-task')).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /Clear filter/ }));
|
||||
expect(onClearFilter).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('submits a system-prune create with the correct target_type and payload', async () => {
|
||||
render(<ScheduledOperationsView />);
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: /New Schedule/ }));
|
||||
await userEvent.type(await screen.findByPlaceholderText('e.g. Nightly stack restart'), 'cleanup');
|
||||
|
||||
// The action selector is the first combobox; switch it to System Prune.
|
||||
await userEvent.click(screen.getAllByRole('combobox')[0]);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'System Prune' }));
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Create' }));
|
||||
|
||||
await waitFor(() => {
|
||||
const postCall = mockedFetch.mock.calls.find(
|
||||
([url, opts]) => url === '/scheduled-tasks' && opts?.method === 'POST',
|
||||
);
|
||||
expect(postCall).toBeTruthy();
|
||||
const body = JSON.parse(postCall![1].body);
|
||||
expect(body).toMatchObject({
|
||||
name: 'cleanup',
|
||||
target_type: 'system',
|
||||
action: 'prune',
|
||||
cron_expression: '0 3 * * *',
|
||||
prune_targets: ['containers', 'images', 'networks', 'volumes'],
|
||||
});
|
||||
expect(postCall![1].localOnly).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user