mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 02:12:59 +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:
@@ -3872,6 +3872,38 @@ export class DatabaseService {
|
||||
txn(rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Null out `vulnerability_scans.policy_evaluation` rows whose `$.policyId`
|
||||
* no longer exists in `scan_policies`. Used after replicated rows are
|
||||
* wiped (sync replace, demote) so cached scan banners do not reference
|
||||
* deleted policies.
|
||||
*/
|
||||
public clearOrphanPolicyEvaluations(): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE vulnerability_scans
|
||||
SET policy_evaluation = NULL
|
||||
WHERE policy_evaluation IS NOT NULL
|
||||
AND CAST(json_extract(policy_evaluation, '$.policyId') AS INTEGER)
|
||||
NOT IN (SELECT id FROM scan_policies)`,
|
||||
)
|
||||
.run();
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically delete every replicated_from_control row from both
|
||||
* scan_policies and cve_suppressions, then null out any orphaned
|
||||
* policy_evaluation cache. Used by the demote endpoint and any future
|
||||
* "drop replicated state" operation.
|
||||
*/
|
||||
public clearReplicatedRows(): void {
|
||||
this.transaction(() => {
|
||||
this.db.prepare('DELETE FROM scan_policies WHERE replicated_from_control = 1').run();
|
||||
this.db.prepare('DELETE FROM cve_suppressions WHERE replicated_from_control = 1').run();
|
||||
this.clearOrphanPolicyEvaluations();
|
||||
});
|
||||
}
|
||||
|
||||
// --- Stack Labels ---
|
||||
|
||||
public getLabels(nodeId: number): Label[] {
|
||||
|
||||
@@ -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