Skip to main content

tsoracle_standalone/admin/
mod.rs

1//
2//  ░▀█▀░█▀▀░█▀█░█▀▄░█▀█░█▀▀░█░░░█▀▀
3//  ░░█░░▀▀█░█░█░█▀▄░█▀█░█░░░█░░░█▀▀
4//  ░░▀░░▀▀▀░▀▀▀░▀░▀░▀░▀░▀▀▀░▀▀▀░▀▀▀
5//
6//  tsoracle — Distributed Timestamp Oracle
7//  https://www.tsoracle.rs
8//
9//  Copyright (c) 2026 Prisma Risk
10//
11//  Licensed under the Apache License, Version 2.0 (the "License");
12//  you may not use this file except in compliance with the License.
13//  You may obtain a copy of the License at
14//
15//      https://www.apache.org/licenses/LICENSE-2.0
16//
17//  Unless required by applicable law or agreed to in writing, software
18//  distributed under the License is distributed on an "AS IS" BASIS,
19//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
20//  See the License for the specific language governing permissions and
21//  limitations under the License.
22//
23
24//! Driver-agnostic membership-admin surface. The openraft impl lives in
25//! `admin::openraft`; paxos and file use [`UnsupportedAdmin`].
26
27#[cfg(feature = "openraft")]
28pub(crate) mod openraft;
29
30#[cfg(feature = "openraft")]
31pub(crate) mod service;
32
33/// Test-only entry points into otherwise-`pub(crate)` admin internals.
34///
35/// Gated on both `cfg(test)`/`feature = "test-support"` AND
36/// `feature = "openraft"` because the only seam currently exposed is the
37/// openraft-shaped `AdminServiceImpl` constructor. Production builds (which
38/// enable `openraft` but not `test-support`) never compile this module.
39#[cfg(all(any(test, feature = "test-support"), feature = "openraft"))]
40pub mod test_support {
41    use std::sync::Arc;
42
43    use super::MembershipAdmin;
44    use crate::admin::service::AdminServiceImpl;
45    use crate::admin_proto::membership_admin_server::MembershipAdmin as GrpcAdmin;
46
47    /// Build the gRPC handler around an arbitrary `MembershipAdmin` so a
48    /// test can call the generated trait methods directly (no socket).
49    pub fn admin_service(admin: Arc<dyn MembershipAdmin>) -> impl GrpcAdmin {
50        AdminServiceImpl::new(admin)
51    }
52}
53
54use async_trait::async_trait;
55
56/// A node's role in the current membership.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum MemberRole {
59    Voter,
60    Learner,
61}
62
63/// One member as the queried node understands it.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct MemberEntry {
66    pub id: u64,
67    pub role: MemberRole,
68    pub raft_addr: String,
69    pub service_endpoint: String,
70    pub admin_endpoint: String,
71}
72
73/// A snapshot of the cluster membership.
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct MembershipView {
76    pub members: Vec<MemberEntry>,
77    pub leader: Option<u64>,
78}
79
80/// A node to add to the cluster.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct NewMember {
83    pub id: u64,
84    pub raft_addr: String,
85    pub service_endpoint: String,
86    pub admin_endpoint: String,
87}
88
89/// A member's format-version capabilities, or the reason it could not be
90/// reached. The read-only capabilities report carries one of these per member.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub enum CapabilityState {
93    /// The member answered: the oldest/newest formats its binary can read and
94    /// the version it is actively writing right now.
95    Reported {
96        min_readable: u8,
97        max_readable: u8,
98        active_write: u8,
99    },
100    /// The member could not be queried; `detail` is the human-readable reason.
101    Unreachable { detail: String },
102}
103
104/// One member's identity (reusing [`MemberEntry`]) plus its capability state.
105/// Reusing `MemberEntry` lets the `capabilities` table and the `members
106/// --capabilities` listing render from the same report.
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct MemberCapability {
109    pub member: MemberEntry,
110    pub caps: CapabilityState,
111}
112
113/// A cluster-wide snapshot of every member's format capabilities, built from a
114/// single membership read on the queried node so membership, roles, leader,
115/// and the gather target stay consistent.
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub struct CapabilityReport {
118    pub members: Vec<MemberCapability>,
119    pub leader: Option<u64>,
120}
121
122/// Membership-admin failure modes.
123#[derive(Debug, thiserror::Error)]
124pub enum AdminError {
125    /// Mutating op reached a follower; `leader_admin_endpoint` is the leader's
126    /// admin port when one is known.
127    #[error("not the leader")]
128    NotLeader {
129        leader_admin_endpoint: Option<String>,
130    },
131    /// This driver does not support runtime membership changes.
132    #[error("membership changes are not supported by this driver")]
133    Unsupported,
134    /// The referenced node is not a current member.
135    #[error("node {0} is not a member")]
136    NotMember(u64),
137    /// A learner was asked to be promoted but had not caught up.
138    #[error("node {0} has not caught up")]
139    NotCaughtUp(u64),
140    /// The change would drop the cluster below a viable quorum.
141    #[error("change would lose quorum")]
142    WouldLoseQuorum,
143    /// The change did not commit within the deadline.
144    #[error("membership change timed out")]
145    Timeout,
146    /// Wrapped driver error.
147    #[error("driver error: {0}")]
148    Driver(String),
149    /// Format-activation gate rejection: at least one member can't read the
150    /// target. `incapable` lists `(node_id, max_readable_version)`.
151    #[error("format activation to target {target} blocked: members below target: {incapable:?}")]
152    MembersBelowTarget {
153        target: u8,
154        incapable: Vec<(u64, u8)>,
155    },
156    /// Format-activation target outside local binary's readable range.
157    #[error("format activation: target {target} outside readable range [{min}, {max}]")]
158    TargetOutOfRange { target: u8, min: u8, max: u8 },
159    /// Format-activation apply-keyed no-op: membership changed since gate.
160    #[error("format activation to target {target} no-op: membership changed since gate")]
161    MembershipChangedSinceGate { target: u8 },
162}
163
164/// Runtime membership administration. One impl per driver.
165#[async_trait]
166pub trait MembershipAdmin: Send + Sync {
167    async fn list_members(&self) -> Result<MembershipView, AdminError>;
168    async fn add_learner(&self, member: NewMember) -> Result<(), AdminError>;
169    async fn promote(&self, id: u64) -> Result<(), AdminError>;
170    async fn remove(&self, id: u64) -> Result<(), AdminError>;
171    /// Initiate an all-members format-version activation to `target`. The
172    /// implementation runs the all-members capability gate, proposes the
173    /// bump via raft, and reports the apply-keyed outcome. Returns
174    /// `Unsupported` on drivers without zero-downtime format migration
175    /// (file, paxos).
176    async fn activate_format(&self, target: u8) -> Result<(), AdminError>;
177
178    /// Report every current member's format-version capabilities (read-only,
179    /// answerable on any node). The openraft impl gathers tolerantly — an
180    /// unreachable member is reported, not fatal. Drivers without the
181    /// format-migration surface (file, paxos) return `Unsupported`.
182    async fn report_capabilities(&self) -> Result<CapabilityReport, AdminError>;
183}
184
185/// Admin handle for drivers without runtime membership (file, and — in this
186/// sub-project — paxos). `list_members` reports a fixed view supplied at
187/// construction; every mutating op is `Unsupported`.
188pub struct UnsupportedAdmin {
189    view: MembershipView,
190}
191
192impl UnsupportedAdmin {
193    pub fn new(view: MembershipView) -> Self {
194        Self { view }
195    }
196}
197
198#[async_trait]
199impl MembershipAdmin for UnsupportedAdmin {
200    async fn list_members(&self) -> Result<MembershipView, AdminError> {
201        Ok(self.view.clone())
202    }
203    async fn add_learner(&self, _member: NewMember) -> Result<(), AdminError> {
204        Err(AdminError::Unsupported)
205    }
206    async fn promote(&self, _id: u64) -> Result<(), AdminError> {
207        Err(AdminError::Unsupported)
208    }
209    async fn remove(&self, _id: u64) -> Result<(), AdminError> {
210        Err(AdminError::Unsupported)
211    }
212    async fn activate_format(&self, _target: u8) -> Result<(), AdminError> {
213        Err(AdminError::Unsupported)
214    }
215
216    async fn report_capabilities(&self) -> Result<CapabilityReport, AdminError> {
217        Err(AdminError::Unsupported)
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    fn empty_view() -> MembershipView {
226        MembershipView {
227            members: Vec::new(),
228            leader: None,
229        }
230    }
231
232    fn new_member() -> NewMember {
233        NewMember {
234            id: 2,
235            raft_addr: "127.0.0.1:9".into(),
236            service_endpoint: "127.0.0.1:8".into(),
237            admin_endpoint: "127.0.0.1:7".into(),
238        }
239    }
240
241    #[tokio::test]
242    async fn unsupported_admin_rejects_every_mutation() {
243        let admin = UnsupportedAdmin::new(empty_view());
244        assert!(matches!(
245            admin.add_learner(new_member()).await,
246            Err(AdminError::Unsupported)
247        ));
248        assert!(matches!(
249            admin.promote(2).await,
250            Err(AdminError::Unsupported)
251        ));
252        assert!(matches!(
253            admin.remove(2).await,
254            Err(AdminError::Unsupported)
255        ));
256    }
257
258    #[tokio::test]
259    async fn unsupported_admin_rejects_activate_format() {
260        let admin = UnsupportedAdmin::new(empty_view());
261        assert!(matches!(
262            admin.activate_format(5).await,
263            Err(AdminError::Unsupported)
264        ));
265    }
266
267    #[tokio::test]
268    async fn unsupported_admin_rejects_report_capabilities() {
269        let admin = UnsupportedAdmin::new(empty_view());
270        assert!(matches!(
271            admin.report_capabilities().await,
272            Err(AdminError::Unsupported)
273        ));
274    }
275
276    #[tokio::test]
277    async fn unsupported_admin_returns_its_fixed_view() {
278        let view = MembershipView {
279            members: vec![MemberEntry {
280                id: 1,
281                role: MemberRole::Voter,
282                raft_addr: "a:1".into(),
283                service_endpoint: "a:2".into(),
284                admin_endpoint: "a:3".into(),
285            }],
286            leader: Some(1),
287        };
288        let admin = UnsupportedAdmin::new(view.clone());
289        assert_eq!(admin.list_members().await.unwrap(), view);
290    }
291}