mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-19 14:56:27 +00:00
33b15d6cba
A control instance now retries fleet-sync pushes that hit a transient failure and backfills the security state on a freshly registered remote without waiting for the next policy edit. New service: - FleetSyncRetryService (singleton, start/stop) wakes 30s after boot and ticks every 5min. For each fleet resource, queries getFailedSyncTargets within a 24h window and re-pushes via FleetSyncService.pushResourceToNode through the same per-node mutex, so a normal fanout in flight serializes naturally with a retry. - After STALE_THRESHOLD_MS (1h) of continuous failure for a previously-working node, dispatches a single warning notification per cooldown window. Brand-new nodes that have never succeeded do not alert via this path; misconfigured remotes are caught by the test-connection affordance at registration time. - Wired into bootstrap startup/shutdown next to AutoHealService. Public surface: - FleetSyncService.pushResourceToNode(node, resource): targeted push to one node that re-uses the per-node mutex. Used by the retry service and any future targeted-resync flow. - routes/nodes.ts POST /api/nodes fires pushResourceAsync for both resources after a remote-proxy node row commits. Tuning constants centralized in fleetSyncConstants.ts: - RETRY_MAX_AGE_MS = 24h - STALE_THRESHOLD_MS = 1h Tests: - 8 vitest cases covering replica skip, retry dispatch, missing-node skip, alert-once-per-cooldown across the threshold window, no-alert for recent failures, no-alert for brand-new never-succeeded nodes, no-alert when the retry itself succeeds, start/stop idempotency. - Full backend suite: 1781 pass / 5 skipped.
180 lines
7.5 KiB
TypeScript
180 lines
7.5 KiB
TypeScript
/**
|
|
* Unit tests for FleetSyncRetryService: the background loop that re-pushes
|
|
* fleet sync to nodes whose last attempt failed and emits a stale-target
|
|
* notification when a per-node failure window exceeds the threshold.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
const {
|
|
mockGetFailedSyncTargets,
|
|
mockGetNode,
|
|
mockGetFleetSyncStatuses,
|
|
mockGetSystemState,
|
|
mockPushResourceToNode,
|
|
mockDispatchAlert,
|
|
} = vi.hoisted(() => ({
|
|
mockGetFailedSyncTargets: vi.fn().mockReturnValue([]),
|
|
mockGetNode: vi.fn(),
|
|
mockGetFleetSyncStatuses: vi.fn().mockReturnValue([]),
|
|
mockGetSystemState: vi.fn().mockReturnValue(null),
|
|
mockPushResourceToNode: vi.fn().mockResolvedValue(undefined),
|
|
mockDispatchAlert: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
vi.mock('../services/DatabaseService', () => ({
|
|
DatabaseService: {
|
|
getInstance: () => ({
|
|
getFailedSyncTargets: mockGetFailedSyncTargets,
|
|
getNode: mockGetNode,
|
|
getFleetSyncStatuses: mockGetFleetSyncStatuses,
|
|
getSystemState: mockGetSystemState,
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../services/FleetSyncService', () => ({
|
|
FleetSyncService: {
|
|
getInstance: () => ({ pushResourceToNode: mockPushResourceToNode }),
|
|
getRole: () => (mockGetSystemState('fleet_role') === 'replica' ? 'replica' : 'control'),
|
|
},
|
|
FLEET_RESOURCES: ['scan_policies', 'cve_suppressions'] as const,
|
|
}));
|
|
|
|
vi.mock('../services/NotificationService', () => ({
|
|
NotificationService: {
|
|
getInstance: () => ({ dispatchAlert: mockDispatchAlert }),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../utils/debug', () => ({
|
|
isDebugEnabled: () => false,
|
|
}));
|
|
|
|
import { FleetSyncRetryService } from '../services/FleetSyncRetryService';
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockGetSystemState.mockReturnValue(null);
|
|
// Reset internal alert memo by replacing the singleton.
|
|
(FleetSyncRetryService as unknown as { instance: FleetSyncRetryService | undefined }).instance = undefined;
|
|
});
|
|
|
|
describe('FleetSyncRetryService.evaluate', () => {
|
|
it('skips the tick when this instance is a replica', async () => {
|
|
mockGetSystemState.mockImplementation((k: string) => (k === 'fleet_role' ? 'replica' : null));
|
|
await FleetSyncRetryService.getInstance().evaluate();
|
|
expect(mockGetFailedSyncTargets).not.toHaveBeenCalled();
|
|
expect(mockPushResourceToNode).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('retries each failed (node, resource) target by calling pushResourceToNode', async () => {
|
|
mockGetFailedSyncTargets.mockImplementation((resource: string) => {
|
|
if (resource === 'scan_policies') {
|
|
return [{ node_id: 7, resource, last_success_at: null, last_failure_at: Date.now() - 1000, last_error: 'timeout' }];
|
|
}
|
|
return [];
|
|
});
|
|
mockGetNode.mockReturnValue({ id: 7, name: 'NodeSeven', type: 'remote', api_url: 'https://n7.example', api_token: 'tok' });
|
|
await FleetSyncRetryService.getInstance().evaluate();
|
|
expect(mockPushResourceToNode).toHaveBeenCalledTimes(1);
|
|
const call = mockPushResourceToNode.mock.calls[0];
|
|
expect(call[0].id).toBe(7);
|
|
expect(call[1]).toBe('scan_policies');
|
|
});
|
|
|
|
it('does not retry when the node has been deleted from the registry', async () => {
|
|
mockGetFailedSyncTargets.mockImplementation((resource: string) => {
|
|
if (resource === 'cve_suppressions') {
|
|
return [{ node_id: 99, resource, last_success_at: null, last_failure_at: Date.now() - 1000, last_error: 'gone' }];
|
|
}
|
|
return [];
|
|
});
|
|
mockGetNode.mockReturnValue(undefined);
|
|
await FleetSyncRetryService.getInstance().evaluate();
|
|
expect(mockPushResourceToNode).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('emits a stale-target notification once when previously-working node has been failing for over an hour', async () => {
|
|
const twoHoursAgo = Date.now() - 2 * 60 * 60_000;
|
|
const fourHoursAgo = Date.now() - 4 * 60 * 60_000;
|
|
mockGetFailedSyncTargets.mockImplementation((resource: string) => {
|
|
if (resource === 'scan_policies') {
|
|
return [{ node_id: 8, resource, last_success_at: fourHoursAgo, last_failure_at: twoHoursAgo, last_error: 'unreachable' }];
|
|
}
|
|
return [];
|
|
});
|
|
mockGetNode.mockReturnValue({ id: 8, name: 'NodeEight', type: 'remote', api_url: 'https://n8.example', api_token: 'tok' });
|
|
mockGetFleetSyncStatuses.mockReturnValue([
|
|
{ node_id: 8, resource: 'scan_policies', last_success_at: fourHoursAgo, last_failure_at: twoHoursAgo, last_error: 'unreachable' },
|
|
]);
|
|
|
|
await FleetSyncRetryService.getInstance().evaluate();
|
|
expect(mockDispatchAlert).toHaveBeenCalledTimes(1);
|
|
expect(mockDispatchAlert).toHaveBeenCalledWith(
|
|
'warning',
|
|
'system',
|
|
expect.stringContaining('NodeEight'),
|
|
);
|
|
|
|
// Second tick within the cooldown should not re-alert.
|
|
mockDispatchAlert.mockClear();
|
|
await FleetSyncRetryService.getInstance().evaluate();
|
|
expect(mockDispatchAlert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not alert when failure window is still within the threshold', async () => {
|
|
const tenMinutesAgo = Date.now() - 10 * 60_000;
|
|
const twentyMinutesAgo = Date.now() - 20 * 60_000;
|
|
mockGetFailedSyncTargets.mockReturnValue([
|
|
{ node_id: 9, resource: 'scan_policies', last_success_at: twentyMinutesAgo, last_failure_at: tenMinutesAgo, last_error: 'flap' },
|
|
]);
|
|
mockGetNode.mockReturnValue({ id: 9, name: 'NodeNine', type: 'remote', api_url: 'https://n9.example', api_token: 'tok' });
|
|
await FleetSyncRetryService.getInstance().evaluate();
|
|
expect(mockDispatchAlert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not alert when the retry itself succeeds (last_failure_at cleared to NULL)', async () => {
|
|
const fourHoursAgo = Date.now() - 4 * 60 * 60_000;
|
|
const twoHoursAgo = Date.now() - 2 * 60 * 60_000;
|
|
// Pre-retry: stale failure exceeds threshold.
|
|
mockGetFailedSyncTargets.mockImplementation((resource: string) => {
|
|
if (resource === 'scan_policies') {
|
|
return [{ node_id: 11, resource, last_success_at: fourHoursAgo, last_failure_at: twoHoursAgo, last_error: 'flap' }];
|
|
}
|
|
return [];
|
|
});
|
|
mockGetNode.mockReturnValue({ id: 11, name: 'NodeEleven', type: 'remote', api_url: 'https://n11.example', api_token: 'tok' });
|
|
// Post-retry: the push succeeded, so the row now has last_success_at set
|
|
// and last_failure_at cleared to NULL. The alert path must NOT fire.
|
|
const justNow = Date.now();
|
|
mockGetFleetSyncStatuses.mockReturnValue([
|
|
{ node_id: 11, resource: 'scan_policies', last_success_at: justNow, last_failure_at: null, last_error: null },
|
|
]);
|
|
await FleetSyncRetryService.getInstance().evaluate();
|
|
expect(mockDispatchAlert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not alert for a brand-new node that has never succeeded', async () => {
|
|
const fiveMinutesAgo = Date.now() - 5 * 60_000;
|
|
mockGetFailedSyncTargets.mockReturnValue([
|
|
{ node_id: 10, resource: 'scan_policies', last_success_at: null, last_failure_at: fiveMinutesAgo, last_error: 'cold start' },
|
|
]);
|
|
mockGetNode.mockReturnValue({ id: 10, name: 'NodeTen', type: 'remote', api_url: 'https://n10.example', api_token: 'tok' });
|
|
await FleetSyncRetryService.getInstance().evaluate();
|
|
expect(mockDispatchAlert).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('FleetSyncRetryService start/stop', () => {
|
|
it('start() and stop() are idempotent and clear timers', () => {
|
|
const svc = FleetSyncRetryService.getInstance();
|
|
svc.start();
|
|
svc.stop();
|
|
svc.start();
|
|
svc.stop();
|
|
// No assertions on internal state; the absence of stray timers is enough
|
|
// for vitest to exit cleanly. This also exercises the early-clear path.
|
|
expect(true).toBe(true);
|
|
});
|
|
});
|