mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 10:21:03 +00:00
e05099f2a1
Treat 409 CONTROL_IDENTITY_MISMATCH from a replica as a non-retriable
failure instead of looping the same 409 through the 5-minute retry
service forever and silently writing identical failure rows.
Backend
- DatabaseService: add `sticky_error_code`, `sticky_error_expected`,
`sticky_error_got` columns to `fleet_sync_status` via an idempotent
migration. New methods setFleetSyncSticky, getFleetSyncStickyCode,
clearFleetSyncStickyForNode. recordFleetSyncSuccess clears the sticky
flag on a clean push. getFailedSyncTargets SQL adds
`AND sticky_error_code IS NULL` so the retry loop skips sticky rows.
- FleetSyncService.executePushToNode: short-circuits at the top when
sticky is set (covers event-driven pushResourceAsync calls). On a 409
with code CONTROL_IDENTITY_MISMATCH, records the failure once and
pins sticky with the expected/got fingerprints carried in the 409 body.
- routes/nodes.ts: new POST /api/nodes/:id/fleet-sync/reset-anchor.
Admin + paid + node:manage. Proxies POST /api/fleet/role/reanchor to
the peer with `{override:true}` using the stored Bearer node_proxy
token. On peer 200, clears every sticky row for the node so the next
push re-anchors and resumes replication. Distinct 502 / 504 responses
for peer-rejected / peer-unreachable so the UI can show a useful toast.
Frontend
- New lib/fleetSyncApi.ts + hooks/useFleetSyncStatus.ts. Polling hook
(30s visibilityInterval) skips fetch when !isPaid.
- NodeManager.tsx: destructive banner per affected node listing both
fingerprints, with `Reset anchor on peer` and `Remove node` buttons.
Hidden for community-tier users via empty hook data.
- FleetConfiguration.tsx (Fleet -> Status): read-only `Policy sync`
SummaryRow per remote node card. In sync / degraded / paused with
a tooltip; no action buttons (the action lives in NodeManager).
Tests
- fleet-sync-service.test.ts: 4 new cases for sticky-set on first
mismatch, short-circuit on subsequent pushes, null fingerprints,
and non-mismatch failures not setting sticky.
- database-fleet-sync-sticky.test.ts (new): 6 cases pinning the DB
contract incl. retry-loop SQL filter and migration idempotency.
- nodes-fleet-sync-reset-anchor.test.ts (new): 6 cases covering
happy path, peer 401 -> 502, peer unreachable -> 504, local-node
rejection, unknown node id, and community-tier 403.
Gate parity (Directive 30): the new POST .../reset-anchor enforces
requireAdmin + requirePaid + node:manage (matches the existing read at
GET /api/fleet/sync-status). UI banner + SummaryRow only render when the
hook returns data, which it only does for paid-tier authed users. No
existing tier-gate file moved; this is greenfield parity.
Auth audit: the peer's POST /api/fleet/role/reanchor route already uses
requireAdmin, which accepts the central's stored node_proxy Bearer
token because authMiddleware maps `scope === 'node_proxy'` to
`req.user = { username: 'node-proxy', role: 'admin', userId: 0 }`.
No widening required.
Backend tsc clean. Frontend tsc -b clean. 59 fleet-sync tests pass; full
backend suite green minus the pre-existing Windows-only file-lock flake
on filesystem-backup.test.ts that reproduces unchanged on main.
44 lines
1.6 KiB
TypeScript
44 lines
1.6 KiB
TypeScript
import { apiFetch } from '@/lib/api';
|
|
|
|
/** Wire shape of `GET /api/fleet/sync-status`. Mirrors backend `FleetSyncStatus`. */
|
|
export interface FleetSyncStatus {
|
|
node_id: number;
|
|
resource: string;
|
|
last_success_at: number | null;
|
|
last_failure_at: number | null;
|
|
last_error: string | null;
|
|
/** Non-null when retries are paused (today: 'CONTROL_IDENTITY_MISMATCH'). */
|
|
sticky_error_code: string | null;
|
|
/** Fingerprint the peer is anchored to (from 409 body); null when not applicable. */
|
|
sticky_error_expected: string | null;
|
|
/** Fingerprint this central pushed (from 409 body); null when not applicable. */
|
|
sticky_error_got: string | null;
|
|
}
|
|
|
|
export const STICKY_CONTROL_IDENTITY_MISMATCH = 'CONTROL_IDENTITY_MISMATCH';
|
|
|
|
export async function fetchFleetSyncStatuses(): Promise<FleetSyncStatus[]> {
|
|
const res = await apiFetch('/fleet/sync-status', { localOnly: true });
|
|
if (!res.ok) {
|
|
throw new Error(`Failed to fetch fleet sync status (HTTP ${res.status})`);
|
|
}
|
|
return (await res.json()) as FleetSyncStatus[];
|
|
}
|
|
|
|
/**
|
|
* Proxy the peer's reanchor endpoint so the peer drops its cached control
|
|
* fingerprint. Central clears every sticky-error row for the node on a 200,
|
|
* so the next push (event-driven or via the 5-minute retry tick) re-tries
|
|
* cleanly.
|
|
*/
|
|
export async function resetFleetSyncAnchor(nodeId: number): Promise<void> {
|
|
const res = await apiFetch(`/nodes/${nodeId}/fleet-sync/reset-anchor`, {
|
|
method: 'POST',
|
|
localOnly: true,
|
|
});
|
|
if (!res.ok) {
|
|
const body = (await res.json().catch(() => ({}))) as { error?: string };
|
|
throw new Error(body.error ?? `Reset anchor failed (HTTP ${res.status})`);
|
|
}
|
|
}
|