mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-19 06:46:23 +00:00
fix(fleet-sync): version the wire protocol and serialize per-node pushes (#967)
Hardens the scan-policy and CVE-suppression replication channel as the foundation of a multi-PR fleet sync hardening track. No new endpoints, no new tables, no schema changes; receivers still tolerate legacy payloads (absent pushedAt and controlIdentity) for rollout safety. Wire protocol: - Sender stamps every push with a strictly-increasing pushedAt and a placeholder controlIdentity. Receiver rejects strictly-older pushedAt with 409 STALE_SYNC_PUSH so the next write retries. - pushedAt comparison plus row replacement plus watermark write run in a single SQLite transaction; a partial-write window cannot leave the watermark behind the row state. Concurrency and limits: - Per-node mutex on the sender so concurrent control writes serialize per remote and never apply older state on top of newer. - Sender-side row cap at MAX_SYNC_ROWS=5000 with a 6-hour throttled truncation alert so flapping configs cannot flood the operator. - Route-level body limit raised to 5MB on POST /api/fleet/sync/:resource only; the global 100KB cap is unchanged. Oversize bodies return a structured 413 SYNC_PAYLOAD_TOO_LARGE. Determinism and hygiene: - getMatchingPolicy gains an id-ASC tiebreaker so two replicas resolve the same winner when policies tie on scope class. - Inline comment documents why getMatchingPolicy filters node_id at SQL yet still relies on JS identity matching for replicated rows. - Comment on the receive endpoint documents why no requirePaid is enforced (control's tier authorizes; replica trusts the bearer). - STALE_SYNC_PUSH 409s no longer record a node failure; they are expected protocol outcomes, not health issues. Public surface additions: - DatabaseService.transaction(fn): generic SAVEPOINT-friendly wrapper. - DatabaseService.getLocalScanPolicies / getLocalCveSuppressions: SQL filter on replicated_from_control = 0. - StaleSyncPushError: typed sentinel the route translates to 409. - fleetSyncConstants: shared MAX_SYNC_ROWS, body limit, state-key and error-code maps so the wire protocol has one source of truth. Tests: - 24 new vitest cases across fleet-sync-service, fleet-sync-routes, and database-matching-policy covering monotonic pushedAt, per-node serialization, row truncation and throttle, stale-push suppression, receiver back-compat, oversize-body 413, deterministic matching. - Full backend suite: 1757 pass / 5 skipped.
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Pins the matching and ordering behavior of `DatabaseService.getMatchingPolicy`.
|
||||
*
|
||||
* The matcher must be deterministic across replicas: two policies in the same
|
||||
* scope class (e.g. both fleet-wide stack-wildcard) need to resolve to the same
|
||||
* winner regardless of SQLite row-iteration order. The chosen tiebreaker is
|
||||
* lowest id wins, so the oldest-defined policy stays authoritative.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
||||
|
||||
let tmpDir: string;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
describe('getMatchingPolicy tiebreaker', () => {
|
||||
it('returns the lowest-id row when two policies tie on scope class', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const first = db.createScanPolicy({
|
||||
name: 'first-fleet-wide',
|
||||
node_id: null,
|
||||
node_identity: '',
|
||||
stack_pattern: null,
|
||||
max_severity: 'HIGH',
|
||||
block_on_deploy: 0,
|
||||
enabled: 1,
|
||||
replicated_from_control: 0,
|
||||
});
|
||||
const second = db.createScanPolicy({
|
||||
name: 'second-fleet-wide',
|
||||
node_id: null,
|
||||
node_identity: '',
|
||||
stack_pattern: null,
|
||||
max_severity: 'CRITICAL',
|
||||
block_on_deploy: 0,
|
||||
enabled: 1,
|
||||
replicated_from_control: 0,
|
||||
});
|
||||
const winner = db.getMatchingPolicy(1, 'web', 'local');
|
||||
expect(winner?.id).toBe(first.id);
|
||||
// Sanity: with first deleted, the next-lowest takes over.
|
||||
db.deleteScanPolicy(first.id);
|
||||
const next = db.getMatchingPolicy(1, 'web', 'local');
|
||||
expect(next?.id).toBe(second.id);
|
||||
});
|
||||
|
||||
it('prefers node-scoped over fleet-wide regardless of id order', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const fleetWide = db.createScanPolicy({
|
||||
name: 'tie-fleet',
|
||||
node_id: null,
|
||||
node_identity: '',
|
||||
stack_pattern: null,
|
||||
max_severity: 'LOW',
|
||||
block_on_deploy: 0,
|
||||
enabled: 1,
|
||||
replicated_from_control: 0,
|
||||
});
|
||||
const nodeScoped = db.createScanPolicy({
|
||||
name: 'tie-node',
|
||||
node_id: 1,
|
||||
node_identity: 'local',
|
||||
stack_pattern: null,
|
||||
max_severity: 'CRITICAL',
|
||||
block_on_deploy: 0,
|
||||
enabled: 1,
|
||||
replicated_from_control: 0,
|
||||
});
|
||||
const winner = db.getMatchingPolicy(1, 'web', 'local');
|
||||
// Node-scoped wins by class, even though its id is higher than the fleet-wide row.
|
||||
expect(winner?.id).toBe(nodeScoped.id);
|
||||
db.deleteScanPolicy(fleetWide.id);
|
||||
db.deleteScanPolicy(nodeScoped.id);
|
||||
});
|
||||
|
||||
it('respects identity matching for replicated rows', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const otherIdentity = db.createScanPolicy({
|
||||
name: 'replicated-other',
|
||||
node_id: null,
|
||||
node_identity: 'https://other.example',
|
||||
stack_pattern: null,
|
||||
max_severity: 'CRITICAL',
|
||||
block_on_deploy: 0,
|
||||
enabled: 1,
|
||||
replicated_from_control: 1,
|
||||
});
|
||||
const ourIdentity = db.createScanPolicy({
|
||||
name: 'replicated-self',
|
||||
node_id: null,
|
||||
node_identity: 'https://me.example',
|
||||
stack_pattern: null,
|
||||
max_severity: 'HIGH',
|
||||
block_on_deploy: 0,
|
||||
enabled: 1,
|
||||
replicated_from_control: 1,
|
||||
});
|
||||
const winner = db.getMatchingPolicy(1, 'web', 'https://me.example');
|
||||
expect(winner?.id).toBe(ourIdentity.id);
|
||||
db.deleteScanPolicy(otherIdentity.id);
|
||||
db.deleteScanPolicy(ourIdentity.id);
|
||||
});
|
||||
});
|
||||
@@ -131,3 +131,59 @@ describe('GET /api/fleet/sync-status', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/fleet/sync/:resource pushedAt protocol', () => {
|
||||
const validRow = {
|
||||
name: 'from-control',
|
||||
node_identity: '',
|
||||
stack_pattern: null,
|
||||
max_severity: 'CRITICAL' as const,
|
||||
block_on_deploy: 0,
|
||||
enabled: 1,
|
||||
};
|
||||
|
||||
it('accepts payloads without pushedAt for back-compat with legacy controls', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/sync/scan_policies')
|
||||
.set('Authorization', nodeProxyAuthHeader)
|
||||
.send({ rows: [validRow], targetIdentity: 'https://me.example' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a fresh pushedAt and persists it for stale-rejection compare', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/sync/scan_policies')
|
||||
.set('Authorization', nodeProxyAuthHeader)
|
||||
.send({ rows: [validRow], targetIdentity: 'https://me.example', pushedAt: 1_700_000_000_000 });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('rejects a stale pushedAt with 409 STALE_SYNC_PUSH', async () => {
|
||||
// Send fresh, then send strictly-older.
|
||||
await request(app)
|
||||
.post('/api/fleet/sync/scan_policies')
|
||||
.set('Authorization', nodeProxyAuthHeader)
|
||||
.send({ rows: [validRow], targetIdentity: 'https://me.example', pushedAt: 1_800_000_000_000 });
|
||||
const stale = await request(app)
|
||||
.post('/api/fleet/sync/scan_policies')
|
||||
.set('Authorization', nodeProxyAuthHeader)
|
||||
.send({ rows: [validRow], targetIdentity: 'https://me.example', pushedAt: 1_700_000_000_000 });
|
||||
expect(stale.status).toBe(409);
|
||||
expect(stale.body.code).toBe('STALE_SYNC_PUSH');
|
||||
});
|
||||
|
||||
it('returns a friendly 413 SYNC_PAYLOAD_TOO_LARGE when the body exceeds the parser limit', async () => {
|
||||
// ~6 MB of padding pushes past the 5mb route-level limit. Keeps a single
|
||||
// valid row so any path that did parse would succeed; we want the parser
|
||||
// to reject before the handler runs.
|
||||
const padding = 'x'.repeat(6 * 1024 * 1024);
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/sync/scan_policies')
|
||||
.set('Authorization', nodeProxyAuthHeader)
|
||||
.send({ rows: [validRow], targetIdentity: 'https://me.example', pad: padding });
|
||||
expect(res.status).toBe(413);
|
||||
expect(res.body.code).toBe('SYNC_PAYLOAD_TOO_LARGE');
|
||||
expect(res.body.error).toMatch(/Sync payload too large/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,22 +7,28 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
const {
|
||||
mockGetNodes,
|
||||
mockGetNode,
|
||||
mockGetScanPolicies,
|
||||
mockGetLocalScanPolicies,
|
||||
mockGetLocalCveSuppressions,
|
||||
mockReplaceReplicatedScanPolicies,
|
||||
mockRecordFleetSyncSuccess,
|
||||
mockRecordFleetSyncFailure,
|
||||
mockGetSystemState,
|
||||
mockSetSystemState,
|
||||
mockTransaction,
|
||||
mockDispatchAlert,
|
||||
mockAxiosPost,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetNodes: vi.fn().mockReturnValue([]),
|
||||
mockGetNode: vi.fn(),
|
||||
mockGetScanPolicies: vi.fn().mockReturnValue([]),
|
||||
mockGetLocalScanPolicies: vi.fn().mockReturnValue([]),
|
||||
mockGetLocalCveSuppressions: vi.fn().mockReturnValue([]),
|
||||
mockReplaceReplicatedScanPolicies: vi.fn(),
|
||||
mockRecordFleetSyncSuccess: vi.fn(),
|
||||
mockRecordFleetSyncFailure: vi.fn(),
|
||||
mockGetSystemState: vi.fn().mockReturnValue(null),
|
||||
mockSetSystemState: vi.fn(),
|
||||
mockTransaction: vi.fn().mockImplementation((fn: () => unknown) => fn()),
|
||||
mockDispatchAlert: vi.fn().mockResolvedValue(undefined),
|
||||
mockAxiosPost: vi.fn().mockResolvedValue({ data: { success: true } }),
|
||||
}));
|
||||
|
||||
@@ -30,12 +36,14 @@ vi.mock('../services/DatabaseService', () => ({
|
||||
DatabaseService: {
|
||||
getInstance: () => ({
|
||||
getNodes: mockGetNodes,
|
||||
getScanPolicies: mockGetScanPolicies,
|
||||
getLocalScanPolicies: mockGetLocalScanPolicies,
|
||||
getLocalCveSuppressions: mockGetLocalCveSuppressions,
|
||||
replaceReplicatedScanPolicies: mockReplaceReplicatedScanPolicies,
|
||||
recordFleetSyncSuccess: mockRecordFleetSyncSuccess,
|
||||
recordFleetSyncFailure: mockRecordFleetSyncFailure,
|
||||
getSystemState: mockGetSystemState,
|
||||
setSystemState: mockSetSystemState,
|
||||
transaction: mockTransaction,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
@@ -48,6 +56,16 @@ vi.mock('../services/NodeRegistry', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../services/NotificationService', () => ({
|
||||
NotificationService: {
|
||||
getInstance: () => ({ dispatchAlert: mockDispatchAlert }),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../utils/debug', () => ({
|
||||
isDebugEnabled: () => false,
|
||||
}));
|
||||
|
||||
vi.mock('axios', () => ({
|
||||
default: { post: mockAxiosPost },
|
||||
AxiosError: class AxiosError extends Error {
|
||||
@@ -128,9 +146,9 @@ describe('FleetSyncService.pushResource', () => {
|
||||
{ id: 2, type: 'remote', api_url: 'https://a.example', api_token: 'tokA', name: 'A' },
|
||||
{ id: 3, type: 'remote', api_url: 'https://b.example', api_token: 'tokB', name: 'B' },
|
||||
]);
|
||||
mockGetScanPolicies.mockReturnValue([
|
||||
// getLocalScanPolicies() filters out replicated rows at SQL time.
|
||||
mockGetLocalScanPolicies.mockReturnValue([
|
||||
{ id: 1, name: 'local-1', node_identity: '', replicated_from_control: 0, created_at: 1, updated_at: 1 },
|
||||
{ id: 2, name: 'mirrored', node_identity: 'https://somewhere', replicated_from_control: 1, created_at: 1, updated_at: 1 },
|
||||
]);
|
||||
await FleetSyncService.getInstance().pushResource('scan_policies');
|
||||
|
||||
@@ -180,3 +198,177 @@ describe('FleetSyncService.applyIncomingSync', () => {
|
||||
expect(mockSetSystemState).not.toHaveBeenCalledWith('fleet_self_identity', expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
describe('FleetSyncService payload schema', () => {
|
||||
it('includes pushedAt and controlIdentity in every push body', async () => {
|
||||
mockGetNodes.mockReturnValue([
|
||||
{ id: 2, type: 'remote', api_url: 'https://a.example', api_token: 'tokA', name: 'A' },
|
||||
]);
|
||||
mockGetLocalScanPolicies.mockReturnValue([
|
||||
{ id: 1, name: 'one', node_identity: '', replicated_from_control: 0, created_at: 1, updated_at: 1 },
|
||||
]);
|
||||
await FleetSyncService.getInstance().pushResource('scan_policies');
|
||||
const body = mockAxiosPost.mock.calls[0][1];
|
||||
expect(typeof body.pushedAt).toBe('number');
|
||||
expect(body.pushedAt).toBeGreaterThan(0);
|
||||
expect(body).toHaveProperty('controlIdentity');
|
||||
expect(typeof body.controlIdentity).toBe('string');
|
||||
});
|
||||
|
||||
it('emits strictly increasing pushedAt across consecutive pushes', async () => {
|
||||
mockGetNodes.mockReturnValue([
|
||||
{ id: 2, type: 'remote', api_url: 'https://a.example', api_token: 'tokA', name: 'A' },
|
||||
]);
|
||||
mockGetLocalScanPolicies.mockReturnValue([]);
|
||||
await FleetSyncService.getInstance().pushResource('scan_policies');
|
||||
await FleetSyncService.getInstance().pushResource('scan_policies');
|
||||
await FleetSyncService.getInstance().pushResource('scan_policies');
|
||||
const stamps = mockAxiosPost.mock.calls.map((c) => c[1].pushedAt);
|
||||
expect(stamps).toHaveLength(3);
|
||||
expect(stamps[1]).toBeGreaterThan(stamps[0]);
|
||||
expect(stamps[2]).toBeGreaterThan(stamps[1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FleetSyncService per-node push serialization', () => {
|
||||
it('serializes pushes to the same node so a second push waits for the first', async () => {
|
||||
mockGetNodes.mockReturnValue([
|
||||
{ id: 2, type: 'remote', api_url: 'https://a.example', api_token: 'tokA', name: 'A' },
|
||||
]);
|
||||
mockGetLocalScanPolicies.mockReturnValue([]);
|
||||
|
||||
let resolveFirst: (() => void) | null = null;
|
||||
const firstStarted = new Promise<void>((resolveStart) => {
|
||||
mockAxiosPost.mockImplementationOnce(() => {
|
||||
resolveStart();
|
||||
return new Promise((resolve) => {
|
||||
resolveFirst = () => resolve({ data: { success: true } });
|
||||
});
|
||||
});
|
||||
});
|
||||
mockAxiosPost.mockImplementationOnce(() => Promise.resolve({ data: { success: true } }));
|
||||
|
||||
const first = FleetSyncService.getInstance().pushResource('scan_policies');
|
||||
await firstStarted;
|
||||
|
||||
const second = FleetSyncService.getInstance().pushResource('scan_policies');
|
||||
// After awaiting a tick, only the first push should have hit axios.
|
||||
await Promise.resolve();
|
||||
expect(mockAxiosPost).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolveFirst!();
|
||||
await Promise.all([first, second]);
|
||||
expect(mockAxiosPost).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FleetSyncService row cap', () => {
|
||||
it('truncates to MAX_SYNC_ROWS and emits a warning notification', async () => {
|
||||
mockGetNodes.mockReturnValue([
|
||||
{ id: 2, type: 'remote', api_url: 'https://a.example', api_token: 'tokA', name: 'A' },
|
||||
]);
|
||||
const huge = Array.from({ length: 5005 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
name: `p${i}`,
|
||||
node_identity: '',
|
||||
replicated_from_control: 0,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
}));
|
||||
mockGetLocalScanPolicies.mockReturnValue(huge);
|
||||
await FleetSyncService.getInstance().pushResource('scan_policies');
|
||||
const body = mockAxiosPost.mock.calls[0][1];
|
||||
expect(body.rows).toHaveLength(5000);
|
||||
expect(mockDispatchAlert).toHaveBeenCalledWith(
|
||||
'warning',
|
||||
'system',
|
||||
expect.stringContaining('truncated'),
|
||||
);
|
||||
});
|
||||
|
||||
it('throttles repeat truncation alerts via fleet_sync_truncation_alert_at watermark', async () => {
|
||||
mockGetNodes.mockReturnValue([
|
||||
{ id: 2, type: 'remote', api_url: 'https://a.example', api_token: 'tokA', name: 'A' },
|
||||
]);
|
||||
const huge = Array.from({ length: 5005 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
name: `p${i}`,
|
||||
node_identity: '',
|
||||
replicated_from_control: 0,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
}));
|
||||
mockGetLocalScanPolicies.mockReturnValue(huge);
|
||||
// Simulate that an alert was emitted 1 minute ago (well within the cooldown).
|
||||
mockGetSystemState.mockImplementation((key: string) => {
|
||||
if (key === 'fleet_sync_truncation_alert_at:scan_policies') return String(Date.now() - 60_000);
|
||||
return null;
|
||||
});
|
||||
await FleetSyncService.getInstance().pushResource('scan_policies');
|
||||
expect(mockDispatchAlert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FleetSyncService stale-push handling', () => {
|
||||
it('does not record a fleet sync failure for STALE_SYNC_PUSH 409 responses', async () => {
|
||||
mockGetNodes.mockReturnValue([
|
||||
{ id: 2, type: 'remote', api_url: 'https://stale.example', api_token: 'tok', name: 'stale' },
|
||||
]);
|
||||
mockGetLocalScanPolicies.mockReturnValue([]);
|
||||
mockAxiosPost.mockImplementation(async () => {
|
||||
const { AxiosError } = await import('axios');
|
||||
const err = new AxiosError('Request failed with status code 409');
|
||||
(err as unknown as { response: unknown }).response = {
|
||||
status: 409,
|
||||
statusText: 'Conflict',
|
||||
data: { error: 'stale', code: 'STALE_SYNC_PUSH' },
|
||||
};
|
||||
throw err;
|
||||
});
|
||||
await FleetSyncService.getInstance().pushResource('scan_policies');
|
||||
expect(mockRecordFleetSyncFailure).not.toHaveBeenCalled();
|
||||
expect(mockRecordFleetSyncSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still records fleet sync failure for non-STALE 409 responses', async () => {
|
||||
mockGetNodes.mockReturnValue([
|
||||
{ id: 2, type: 'remote', api_url: 'https://other.example', api_token: 'tok', name: 'other' },
|
||||
]);
|
||||
mockGetLocalScanPolicies.mockReturnValue([]);
|
||||
mockAxiosPost.mockImplementation(async () => {
|
||||
const { AxiosError } = await import('axios');
|
||||
const err = new AxiosError('Request failed with status code 409');
|
||||
(err as unknown as { response: unknown }).response = {
|
||||
status: 409,
|
||||
statusText: 'Conflict',
|
||||
data: { error: 'something else', code: 'CONTROL_IDENTITY_MISMATCH' },
|
||||
};
|
||||
throw err;
|
||||
});
|
||||
await FleetSyncService.getInstance().pushResource('scan_policies');
|
||||
expect(mockRecordFleetSyncFailure).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FleetSyncService.applyIncomingSync transactional', () => {
|
||||
it('runs inside DatabaseService.transaction so apply and watermark commit atomically', () => {
|
||||
FleetSyncService.getInstance().applyIncomingSync(
|
||||
'scan_policies',
|
||||
[],
|
||||
'https://me.example',
|
||||
1_700_000_000_000,
|
||||
);
|
||||
expect(mockTransaction).toHaveBeenCalledTimes(1);
|
||||
expect(mockSetSystemState).toHaveBeenCalledWith('fleet_role', 'replica');
|
||||
expect(mockSetSystemState).toHaveBeenCalledWith('fleet_self_identity', 'https://me.example');
|
||||
expect(mockSetSystemState).toHaveBeenCalledWith('received_pushed_at:scan_policies', '1700000000000');
|
||||
});
|
||||
|
||||
it('omits the watermark write when pushedAt is undefined (legacy back-compat)', () => {
|
||||
FleetSyncService.getInstance().applyIncomingSync('scan_policies', [], 'https://me.example');
|
||||
const watermarkWrites = mockSetSystemState.mock.calls.filter(
|
||||
(c) => typeof c[0] === 'string' && c[0].startsWith('received_pushed_at:'),
|
||||
);
|
||||
expect(watermarkWrites).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user