mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-21 23:56:39 +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:
@@ -1693,6 +1693,19 @@ export class DatabaseService {
|
||||
this.db.prepare('INSERT OR REPLACE INTO system_state (key, value) VALUES (?, ?)').run(key, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `fn` inside a single SQLite transaction. better-sqlite3 promotes a
|
||||
* nested call to a SAVEPOINT, so callers can compose this with methods
|
||||
* that already wrap their own writes in `this.db.transaction(...)`.
|
||||
*
|
||||
* Used by FleetSync receive to keep the row replacement and the
|
||||
* received_pushed_at watermark write atomic. If either step fails, both
|
||||
* roll back.
|
||||
*/
|
||||
public transaction<T>(fn: () => T): T {
|
||||
return this.db.transaction(fn)();
|
||||
}
|
||||
|
||||
// --- Stack Alerts ---
|
||||
|
||||
public getStackAlerts(stackName?: string): StackAlert[] {
|
||||
@@ -3501,6 +3514,17 @@ export class DatabaseService {
|
||||
.all() as ScanPolicy[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Local-only scan policies (created on this instance, not replicated from a
|
||||
* control). Used by the fleet sync sender so it never re-replicates rows
|
||||
* that came from a control in the first place.
|
||||
*/
|
||||
public getLocalScanPolicies(): ScanPolicy[] {
|
||||
return this.db
|
||||
.prepare('SELECT * FROM scan_policies WHERE replicated_from_control = 0 ORDER BY created_at DESC')
|
||||
.all() as ScanPolicy[];
|
||||
}
|
||||
|
||||
public getScanPolicy(id: number): ScanPolicy | null {
|
||||
return (
|
||||
(this.db
|
||||
@@ -3620,6 +3644,11 @@ export class DatabaseService {
|
||||
stackName: string | null,
|
||||
selfIdentity: string,
|
||||
): ScanPolicy | null {
|
||||
// Filter on node_id at SQL: rows are eligible when fleet-wide
|
||||
// (node_id IS NULL) or when locally scoped to this node (node_id = ?).
|
||||
// Replicated rows always insert node_id = NULL (see
|
||||
// replaceReplicatedScanPolicies), so identity scoping for replicated
|
||||
// rows is enforced in `matchesIdentity` below, not in SQL.
|
||||
const policies = this.db
|
||||
.prepare(
|
||||
'SELECT * FROM scan_policies WHERE enabled = 1 AND (node_id IS NULL OR node_id = ?)',
|
||||
@@ -3651,7 +3680,11 @@ export class DatabaseService {
|
||||
if (!aNode && bNode) return 1;
|
||||
if (a.stack_pattern && !b.stack_pattern) return -1;
|
||||
if (!a.stack_pattern && b.stack_pattern) return 1;
|
||||
return 0;
|
||||
// Deterministic tiebreaker: lowest id wins. Two rows in the same
|
||||
// scope class (e.g. both fleet-wide stack-wildcard) must resolve
|
||||
// to the same policy on every replica, regardless of SQLite row
|
||||
// iteration order.
|
||||
return a.id - b.id;
|
||||
});
|
||||
return scoped[0];
|
||||
}
|
||||
@@ -3749,6 +3782,13 @@ export class DatabaseService {
|
||||
.all() as CveSuppression[];
|
||||
}
|
||||
|
||||
/** Local-only CVE suppressions; mirrors `getLocalScanPolicies`. */
|
||||
public getLocalCveSuppressions(): CveSuppression[] {
|
||||
return this.db
|
||||
.prepare('SELECT * FROM cve_suppressions WHERE replicated_from_control = 0 ORDER BY cve_id, pkg_name')
|
||||
.all() as CveSuppression[];
|
||||
}
|
||||
|
||||
public getCveSuppression(id: number): CveSuppression | null {
|
||||
return (
|
||||
(this.db.prepare('SELECT * FROM cve_suppressions WHERE id = ?')
|
||||
|
||||
Reference in New Issue
Block a user