mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-25 17:57:06 +00:00
feat(fleet-sync): replica self-demote endpoint and role UX (#969)
A replica admin can now demote the instance back to a standalone
control without raw SQLite access. The Settings → Security UI surfaces
a confirm-gated button when the role is replica; the role probe also
surfaces a soft banner when it cannot determine fleet role rather
than silently defaulting to control.
Backend:
- POST /api/fleet/role/demote (admin, requires `{confirm: true}`):
flips fleet_role to 'control', clears fleet_self_identity,
fleet_control_identity, and both received_pushed_at:* watermarks,
drops every replicated_from_control row from scan_policies and
cve_suppressions, nulls out any orphaned policy_evaluation cache.
Returns 409 ALREADY_CONTROL when invoked on a control.
- DatabaseService gains `clearOrphanPolicyEvaluations()` and
`clearReplicatedRows()` helpers. Reanchor consolidates onto
clearReplicatedRows so it shares the same code path.
- `FleetSyncService.demote()` returns boolean for the route to
translate into 200 or 409.
Frontend:
- SecuritySection probes /fleet/role and now records explicit success
vs failure rather than silently treating an error as control. A
soft banner appears when probe fails.
- Replica banner gains a "Demote to control" button and a destructive
ConfirmModal explaining the wipe.
Tests:
- 4 new route-level vitest cases (401, 400 without confirm,
end-to-end demote with replica setup, 409 ALREADY_CONTROL with
explicit precondition).
- Service unit test asserts the consolidated clearReplicatedRows path.
- Full backend suite: 1773 pass / 5 skipped. Frontend: 185 pass.
This commit is contained in:
@@ -303,12 +303,44 @@ export class FleetSyncService {
|
||||
db.setSystemState(SYNC_STATE_KEYS.fleetControlIdentity, '');
|
||||
db.setSystemState(SYNC_STATE_KEYS.receivedPushedAt('scan_policies'), '');
|
||||
db.setSystemState(SYNC_STATE_KEYS.receivedPushedAt('cve_suppressions'), '');
|
||||
db.replaceReplicatedScanPolicies([]);
|
||||
db.replaceReplicatedCveSuppressions([]);
|
||||
db.clearReplicatedRows();
|
||||
});
|
||||
FleetSyncService.cachedControlIdentity = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Demote this replica back to a standalone control. Atomic transaction:
|
||||
* - flips `fleet_role` to 'control'
|
||||
* - clears `fleet_self_identity`, `fleet_control_identity`, and both
|
||||
* `received_pushed_at:*` watermarks
|
||||
* - drops every replicated_from_control row from scan_policies and
|
||||
* cve_suppressions
|
||||
* - nulls out any orphaned policy_evaluation cache
|
||||
*
|
||||
* After this returns, local writes to scan_policies and cve_suppressions
|
||||
* succeed again (`blockIfReplica` no longer trips).
|
||||
*
|
||||
* Returns `false` if the instance is already a control (no work done);
|
||||
* the route translates that to 409 so the UI doesn't show a misleading
|
||||
* success toast.
|
||||
*/
|
||||
public demote(): boolean {
|
||||
const db = DatabaseService.getInstance();
|
||||
if (FleetSyncService.getRole() === 'control') {
|
||||
return false;
|
||||
}
|
||||
db.transaction(() => {
|
||||
db.setSystemState(SYNC_STATE_KEYS.fleetRole, 'control');
|
||||
db.setSystemState(SYNC_STATE_KEYS.fleetSelfIdentity, '');
|
||||
db.setSystemState(SYNC_STATE_KEYS.fleetControlIdentity, '');
|
||||
db.setSystemState(SYNC_STATE_KEYS.receivedPushedAt('scan_policies'), '');
|
||||
db.setSystemState(SYNC_STATE_KEYS.receivedPushedAt('cve_suppressions'), '');
|
||||
db.clearReplicatedRows();
|
||||
});
|
||||
FleetSyncService.cachedControlIdentity = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chain a push behind any in-flight push for the same node. Different
|
||||
* nodes still run in parallel via the outer Promise.all.
|
||||
|
||||
Reference in New Issue
Block a user